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 {

View File

@@ -1,26 +1,53 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
import * as coreModule from "@fusion/core";
import { request } from "../test-request.js";
import { createServer } from "../server.js";
const piMocks = vi.hoisted(() => ({
createKbAgent: vi.fn(),
promptWithFallback: vi.fn(),
}));
vi.mock("@fusion/engine", async () => {
const actual = await vi.importActual<typeof import("@fusion/engine")>("@fusion/engine");
return {
...actual,
createKbAgent: piMocks.createKbAgent,
promptWithFallback: piMocks.promptWithFallback,
};
});
const mockListRuns = vi.fn().mockReturnValue([]);
const mockGetRun = vi.fn();
const mockCreateRun = vi.fn();
const mockUpdateRun = vi.fn();
const mockListInsights = vi.fn().mockReturnValue([]);
const mockCountInsights = vi.fn().mockReturnValue(0);
const mockGetInsight = vi.fn();
const mockUpdateInsight = vi.fn();
const mockDeleteInsight = vi.fn();
const mockUpsertInsight = vi.fn();
const readWorkingMemorySpy = vi.spyOn(coreModule, "readWorkingMemory");
const readInsightsMemorySpy = vi.spyOn(coreModule, "readInsightsMemory");
const writeInsightsMemorySpy = vi.spyOn(coreModule, "writeInsightsMemory");
const buildPromptSpy = vi.spyOn(coreModule, "buildInsightExtractionPrompt");
const parseResponseSpy = vi.spyOn(coreModule, "parseInsightExtractionResponse");
const mergeInsightsSpy = vi.spyOn(coreModule, "mergeInsights");
const computeFingerprintSpy = vi.spyOn(coreModule, "computeInsightFingerprint");
const mockInsightStore = {
listRuns: mockListRuns,
getRun: mockGetRun,
createRun: mockCreateRun,
updateRun: mockUpdateRun,
listInsights: mockListInsights,
countInsights: mockCountInsights,
getInsight: mockGetInsight,
updateInsight: mockUpdateInsight,
deleteInsight: mockDeleteInsight,
upsertInsight: mockUpsertInsight,
};
class MockStore extends EventEmitter {
@@ -49,14 +76,80 @@ describe("Insights routes", () => {
beforeEach(() => {
vi.clearAllMocks();
let runRecord = {
id: "IR-run-new",
projectId: "",
trigger: "manual" as const,
status: "pending" as const,
summary: null,
error: null,
insightsCreated: 0,
insightsUpdated: 0,
inputMetadata: {},
outputMetadata: {},
createdAt: "2026-04-16T00:00:00.000Z",
startedAt: null,
completedAt: null,
};
mockListRuns.mockReturnValue([]);
mockGetRun.mockReturnValue(null);
mockCreateRun.mockReturnValue({ id: "IR-test-1", trigger: "manual", status: "running", projectId: "proj", createdAt: "2026-04-16T00:00:00.000Z" });
mockCreateRun.mockImplementation((projectId: string, input: { trigger: "manual" }) => {
runRecord = {
...runRecord,
projectId,
trigger: input.trigger,
};
return { ...runRecord };
});
mockUpdateRun.mockImplementation((_id: string, input: Record<string, unknown>) => {
runRecord = {
...runRecord,
...input,
} as typeof runRecord;
return { ...runRecord };
});
mockListInsights.mockReturnValue([]);
mockCountInsights.mockReturnValue(0);
mockGetInsight.mockReturnValue(null);
mockUpdateInsight.mockReturnValue(null);
mockDeleteInsight.mockReturnValue(false);
mockUpsertInsight.mockImplementation((_projectId: string, input: { title: string; content: string; category: string; fingerprint: string; provenance: Record<string, unknown> }) => ({
id: "INS-created-1",
projectId: _projectId,
title: input.title,
content: input.content,
category: input.category,
status: "confirmed",
fingerprint: input.fingerprint,
provenance: input.provenance,
lastRunId: "IR-run-new",
createdAt: "2026-04-16T00:10:00.000Z",
updatedAt: "2026-04-16T00:10:00.000Z",
}));
readWorkingMemorySpy.mockResolvedValue("Test working memory content");
readInsightsMemorySpy.mockResolvedValue(null);
writeInsightsMemorySpy.mockResolvedValue(undefined);
buildPromptSpy.mockReturnValue("Test prompt");
parseResponseSpy.mockReturnValue({
summary: "Test extraction",
insights: [],
extractedAt: "2026-04-16T00:00:00.000Z",
});
mergeInsightsSpy.mockReturnValue("# merged insights");
computeFingerprintSpy.mockImplementation((title: string, category: string) => `fp-${category}-${title.length}`);
piMocks.createKbAgent.mockImplementation((options: { onText?: (delta: string) => void }) => {
options.onText?.('{"summary":"Test extraction","insights":[]}');
return {
session: {
dispose: vi.fn(),
},
};
});
piMocks.promptWithFallback.mockResolvedValue(undefined);
});
// ── Route ordering regression: static routes must not be shadowed by /:id ──
@@ -78,8 +171,48 @@ describe("Insights routes", () => {
});
});
it("POST /api/insights/run creates a run successfully (not shadowed by /:id)", async () => {
mockCreateRun.mockReturnValue({ id: "IR-run-new", trigger: "manual", status: "running", projectId: "proj", createdAt: "2026-04-16T00:00:00.000Z" });
it("POST /api/insights/run executes AI extraction and returns completed run", async () => {
const res = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({ trigger: "manual" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(mockCreateRun).toHaveBeenCalledWith("", { trigger: "manual", inputMetadata: undefined });
expect(mockUpdateRun).toHaveBeenNthCalledWith(
1,
"IR-run-new",
expect.objectContaining({ status: "running", startedAt: expect.any(String) }),
);
expect(readWorkingMemorySpy).toHaveBeenCalledWith("/tmp/fn-1909");
expect(buildPromptSpy).toHaveBeenCalledWith("Test working memory content", null);
expect(piMocks.createKbAgent).toHaveBeenCalledTimes(1);
expect(piMocks.promptWithFallback).toHaveBeenCalledTimes(1);
expect(parseResponseSpy).toHaveBeenCalledTimes(1);
expect(mockUpdateRun).toHaveBeenLastCalledWith(
"IR-run-new",
expect.objectContaining({
status: "completed",
insightsCreated: 0,
insightsUpdated: 0,
summary: "Test extraction",
completedAt: expect.any(String),
}),
);
expect(res.body).toEqual(
expect.objectContaining({
id: "IR-run-new",
status: "completed",
summary: "Test extraction",
}),
);
});
it("POST /api/insights/run marks run failed when working memory is empty", async () => {
readWorkingMemorySpy.mockResolvedValue(" \n ");
const res = await request(
app,
@@ -90,7 +223,119 @@ describe("Insights routes", () => {
);
expect(res.status).toBe(201);
expect(res.body).toEqual({ id: "IR-run-new", trigger: "manual", status: "running", projectId: "proj", createdAt: "2026-04-16T00:00:00.000Z" });
expect(mockUpdateRun).toHaveBeenNthCalledWith(
2,
"IR-run-new",
expect.objectContaining({
status: "failed",
error: "No working memory to analyze",
completedAt: expect.any(String),
}),
);
expect(piMocks.createKbAgent).not.toHaveBeenCalled();
expect(piMocks.promptWithFallback).not.toHaveBeenCalled();
expect(res.body).toEqual(
expect.objectContaining({
id: "IR-run-new",
status: "failed",
error: "No working memory to analyze",
}),
);
});
it("POST /api/insights/run marks run failed and returns 500 when AI execution errors", async () => {
piMocks.promptWithFallback.mockRejectedValue(new Error("AI execution failed"));
const res = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({ trigger: "manual" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect((res.body as { error?: string }).error).toContain("AI execution failed");
expect(mockUpdateRun).toHaveBeenLastCalledWith(
"IR-run-new",
expect.objectContaining({
status: "failed",
error: "AI execution failed",
completedAt: expect.any(String),
}),
);
});
it("POST /api/insights/run persists generated insights and tracks created vs updated counts", async () => {
const longContent = "x".repeat(110);
parseResponseSpy.mockReturnValue({
summary: "Generated two insights",
insights: [
{
category: "pattern",
content: "Prefer shared hooks for common dashboard logic",
extractedAt: "2026-04-16T00:20:00.000Z",
},
{
category: "pitfall",
content: longContent,
extractedAt: "2026-04-16T00:20:00.000Z",
},
],
extractedAt: "2026-04-16T00:20:00.000Z",
});
mergeInsightsSpy.mockReturnValue("# merged result");
mockUpsertInsight
.mockReturnValueOnce({
id: "INS-created",
projectId: "",
title: "Prefer shared hooks for common dashboard logic",
content: "Prefer shared hooks for common dashboard logic",
category: "workflow",
status: "confirmed",
fingerprint: "fp-workflow-46",
provenance: { trigger: "manual" },
lastRunId: "IR-run-new",
createdAt: "2026-04-16T00:20:01.000Z",
updatedAt: "2026-04-16T00:20:01.000Z",
})
.mockReturnValueOnce({
id: "INS-updated",
projectId: "",
title: `${"x".repeat(100)}...`,
content: longContent,
category: "quality",
status: "confirmed",
fingerprint: "fp-quality-103",
provenance: { trigger: "manual" },
lastRunId: "IR-run-new",
createdAt: "2026-04-16T00:00:00.000Z",
updatedAt: "2026-04-16T00:20:01.000Z",
});
const res = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({ trigger: "manual" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(writeInsightsMemorySpy).toHaveBeenCalledWith("/tmp/fn-1909", "# merged result");
expect(mockUpsertInsight).toHaveBeenCalledTimes(2);
expect(computeFingerprintSpy).toHaveBeenNthCalledWith(1, "Prefer shared hooks for common dashboard logic", "workflow");
expect(computeFingerprintSpy).toHaveBeenNthCalledWith(2, `${"x".repeat(100)}...`, "quality");
expect(mockUpdateRun).toHaveBeenLastCalledWith(
"IR-run-new",
expect.objectContaining({
status: "completed",
insightsCreated: 1,
insightsUpdated: 1,
summary: "Generated two insights",
}),
);
});
it("GET /api/insights/runs/:id returns run by id", async () => {

View File

@@ -17,6 +17,7 @@ import type { TaskStore } from "@fusion/core";
import {
InsightStore,
type InsightCategory,
type MemoryInsightCategory,
type InsightStatus,
type InsightListOptions,
type InsightRunTrigger,
@@ -71,6 +72,27 @@ const VALID_STATUSES: InsightStatus[] = ["generated", "confirmed", "stale", "dis
// Valid run triggers
const VALID_TRIGGERS: InsightRunTrigger[] = ["schedule", "manual", "task_completion", "merge_event", "api"];
const INSIGHT_CATEGORY_BY_MEMORY_CATEGORY: Record<MemoryInsightCategory, InsightCategory> = {
pattern: "workflow",
principle: "architecture",
convention: "workflow",
pitfall: "quality",
context: "other",
};
function toInsightTitle(content: string): string {
const trimmed = content.trim();
if (!trimmed) {
return "Untitled insight";
}
if (trimmed.length <= 100) {
return trimmed;
}
return `${trimmed.slice(0, 100).trimEnd()}...`;
}
/**
* Create the insights router.
*/
@@ -167,10 +189,21 @@ export function createInsightsRouter(store: TaskStore): Router {
// ── Trigger Insight Run ───────────────────────────────────────────────
router.post("/run", (req: Request, res: Response) => {
/**
* Execute a full manual insight-generation lifecycle for the current project.
*
* Lifecycle:
* 1. Create run (pending)
* 2. Mark run running
* 3. Read working-memory context
* 4. Execute AI extraction prompt
* 5. Parse + persist extracted insights
* 6. Mark run completed (or failed on error)
*/
router.post("/run", async (req: Request, res: Response) => {
try {
const projectId = getProjectId(req) ?? "";
const store = getInsightStore();
const insightStore = getInsightStore();
const trigger: InsightRunTrigger = (req.body.trigger as InsightRunTrigger) ?? "manual";
if (!VALID_TRIGGERS.includes(trigger)) {
@@ -182,8 +215,120 @@ export function createInsightsRouter(store: TaskStore): Router {
inputMetadata: req.body.inputMetadata,
};
const run = store.createRun(projectId, input);
res.status(201).json(run);
const run = insightStore.createRun(projectId, input);
insightStore.updateRun(run.id, {
status: "running",
startedAt: new Date().toISOString(),
});
const taskStore = requestContext.getStore();
if (!taskStore) {
throw new ApiError(500, "Store context not available");
}
const rootDir = taskStore.getRootDir();
const {
readWorkingMemory,
readInsightsMemory,
writeInsightsMemory,
buildInsightExtractionPrompt,
parseInsightExtractionResponse,
mergeInsights,
computeInsightFingerprint,
} = await import("@fusion/core");
const workingMemory = await readWorkingMemory(rootDir);
if (!workingMemory.trim()) {
const failedRun = insightStore.updateRun(run.id, {
status: "failed",
error: "No working memory to analyze",
completedAt: new Date().toISOString(),
});
res.status(201).json(failedRun ?? run);
return;
}
const existingInsights = await readInsightsMemory(rootDir);
try {
const { createKbAgent, promptWithFallback } = await import("@fusion/engine");
let responseText = "";
const { session } = await createKbAgent({
cwd: rootDir,
systemPrompt: [
"You extract durable project insights from working memory notes.",
"Return only valid JSON that matches the requested schema.",
"Do not execute tools or make code changes.",
].join("\n"),
tools: "readonly",
onText: (delta: string) => {
responseText += delta;
},
});
try {
const prompt = buildInsightExtractionPrompt(workingMemory, existingInsights);
await promptWithFallback(session, prompt);
} finally {
try {
session.dispose();
} catch {
// Best-effort disposal
}
}
const parsedResult = parseInsightExtractionResponse(responseText);
const mergedInsightsContent = mergeInsights(existingInsights ?? "", parsedResult.insights);
await writeInsightsMemory(rootDir, mergedInsightsContent);
let insightsCreated = 0;
let insightsUpdated = 0;
for (const insight of parsedResult.insights) {
const category = INSIGHT_CATEGORY_BY_MEMORY_CATEGORY[insight.category] ?? "other";
const title = toInsightTitle(insight.content);
const fingerprint = computeInsightFingerprint(title, category);
const upsertedInsight = insightStore.upsertInsight(projectId, {
title,
content: insight.content,
category,
fingerprint,
provenance: {
trigger: "manual",
description: "Manual insight generation",
metadata: {
runId: run.id,
extractedAt: insight.extractedAt,
},
},
});
if (upsertedInsight.createdAt === upsertedInsight.updatedAt) {
insightsCreated += 1;
} else {
insightsUpdated += 1;
}
}
const completedRun = insightStore.updateRun(run.id, {
status: "completed",
insightsCreated,
insightsUpdated,
completedAt: new Date().toISOString(),
summary: parsedResult.summary,
});
res.status(201).json(completedRun ?? run);
} catch (err) {
insightStore.updateRun(run.id, {
status: "failed",
error: err instanceof Error ? err.message : String(err),
completedAt: new Date().toISOString(),
});
throw err;
}
} catch (error) {
rethrowAsApiError(error, "Failed to create insight run");
}