feat(FN-1974): merge fusion/fn-1974

This commit is contained in:
gsxdsm
2026-04-16 15:55:14 -07:00
parent 84bd5e7c9d
commit 47f19676ae
6 changed files with 469 additions and 51 deletions

View File

@@ -12,7 +12,6 @@ vi.mock("../../api", () => ({
dismissInsight: vi.fn(),
triggerInsightRun: vi.fn(),
fetchInsightRuns: vi.fn(),
fetchInsightRun: vi.fn(),
getInsightCreateTaskData: vi.fn(),
}));
@@ -42,7 +41,6 @@ import {
dismissInsight,
triggerInsightRun,
fetchInsightRuns,
fetchInsightRun,
getInsightCreateTaskData,
} from "../../api";
@@ -50,7 +48,6 @@ const mockFetchInsights = vi.mocked(fetchInsights);
const mockDismissInsight = vi.mocked(dismissInsight);
const mockTriggerInsightRun = vi.mocked(triggerInsightRun);
const mockFetchInsightRuns = vi.mocked(fetchInsightRuns);
const mockFetchInsightRun = vi.mocked(fetchInsightRun);
const mockGetInsightCreateTaskData = vi.mocked(getInsightCreateTaskData);
describe("useInsights", () => {
@@ -198,27 +195,26 @@ describe("useInsights", () => {
});
describe("runInsights", () => {
it("should trigger manual insight run", async () => {
const mockRun = {
it("should trigger manual insight run and refresh when run completes", async () => {
const completedRun = {
id: "RUN-1",
projectId: "project-1",
trigger: "manual" as const,
status: "pending" as const,
summary: null,
status: "completed" as const,
summary: "Generated insights",
error: null,
insightsCreated: 0,
insightsUpdated: 0,
insightsCreated: 3,
insightsUpdated: 1,
inputMetadata: {},
outputMetadata: {},
createdAt: "2024-01-01T00:00:00Z",
startedAt: null,
completedAt: null,
startedAt: "2024-01-01T00:00:10Z",
completedAt: "2024-01-01T00:01:00Z",
};
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockTriggerInsightRun.mockResolvedValue(mockRun);
mockFetchInsightRun.mockResolvedValue({ ...mockRun, status: "completed" });
mockTriggerInsightRun.mockResolvedValue(completedRun);
const { result } = renderHook(() => useInsights("project-1"));
@@ -231,10 +227,50 @@ describe("useInsights", () => {
});
expect(mockTriggerInsightRun).toHaveBeenCalledWith("manual", undefined, "project-1");
expect(result.current.latestRun?.status).toBe("completed");
expect(result.current.isRunInFlight).toBe(false);
expect(mockFetchInsights.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(mockFetchInsightRuns.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("should handle run errors", async () => {
it("should surface failed run errors from triggerInsightRun response", async () => {
const failedRun = {
id: "RUN-1",
projectId: "project-1",
trigger: "manual" as const,
status: "failed" as const,
summary: null,
error: "No working memory to analyze",
insightsCreated: 0,
insightsUpdated: 0,
inputMetadata: {},
outputMetadata: {},
createdAt: "2024-01-01T00:00:00Z",
startedAt: "2024-01-01T00:00:10Z",
completedAt: "2024-01-01T00:01:00Z",
};
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockTriggerInsightRun.mockResolvedValue(failedRun);
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.runInsights();
});
expect(result.current.runError).toBe("No working memory to analyze");
expect(result.current.isRunInFlight).toBe(false);
// Initial load only; failed run should not trigger refresh.
expect(mockFetchInsights).toHaveBeenCalledTimes(1);
});
it("should propagate trigger errors and always clear in-flight state", async () => {
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockTriggerInsightRun.mockRejectedValue(new Error("Run failed"));
@@ -245,8 +281,19 @@ describe("useInsights", () => {
expect(result.current.loading).toBe(false);
});
// Test that runInsights throws
await expect(result.current.runInsights()).rejects.toThrow("Run failed");
let thrown: unknown = null;
await act(async () => {
try {
await result.current.runInsights();
} catch (error) {
thrown = error;
}
});
expect(thrown).toBeInstanceOf(Error);
expect((thrown as Error).message).toBe("Run failed");
expect(result.current.runError).toBe("Run failed");
expect(result.current.isRunInFlight).toBe(false);
});
});

View File

@@ -15,10 +15,7 @@ import {
dismissInsight,
triggerInsightRun,
fetchInsightRuns,
fetchInsightRun,
getInsightCreateTaskData,
type InsightsListResponse,
type RunsListResponse,
} from "../api";
// Canonical insight categories (in display order)
@@ -196,30 +193,13 @@ export function useInsights(projectId?: string): UseInsightsResult {
const run = await triggerInsightRun("manual", undefined, projectId);
setLatestRun(run);
// Poll for completion (simple approach)
const pollInterval = setInterval(async () => {
try {
const updatedRun = await fetchInsightRun(run.id, projectId);
if (updatedRun.status === "completed" || updatedRun.status === "failed") {
clearInterval(pollInterval);
setLatestRun(updatedRun);
if (updatedRun.status === "failed" && updatedRun.error) {
setRunError(updatedRun.error);
}
// Refresh insights list
await refresh();
}
} catch {
// Ignore polling errors
}
}, 2000);
// Fallback: stop polling after 60 seconds
setTimeout(() => {
clearInterval(pollInterval);
}, 60000);
if (run.status === "completed") {
await refresh();
} else if (run.status === "failed" && run.error) {
setRunError(run.error);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to start insight generation";
const message = err instanceof Error ? err.message : "Failed to generate insights";
setRunError(message);
throw err;
} finally {