feat(FN-3599): align documentation with delivery process

Documentation delivery alignment completing Step 5 of FN-3369, updating the CLI reference, research docs, and research hardening preflight guide with consistent documentation delivery guidance across all three files.

Fusion-Task-Id: FN-3599
This commit is contained in:
Fusion
2026-05-06 18:40:05 -07:00
committed by gsxdsm
parent 705e5cc1d3
commit b507e78402
7 changed files with 195 additions and 15 deletions

View File

@@ -113,6 +113,77 @@ describe("research extension tools", () => {
expect(retryBlocked.isError).toBe(true);
});
it("returns structured missing-run details for get and cancel", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const getTool = api.tools.get("fn_research_get")!;
const getResult = await getTool.execute("call-missing-get", { id: "RR-404" }, undefined, undefined, makeCtx(tmpDir));
expect(getResult.details.runId).toBe("RR-404");
expect(getResult.details.status).toBe("missing");
expect(getResult.details.setup.code).toBe("NOT_FOUND");
const cancelTool = api.tools.get("fn_research_cancel")!;
const cancelResult = await cancelTool.execute("call-missing-cancel", { id: "RR-404" }, undefined, undefined, makeCtx(tmpDir));
expect(cancelResult.isError).toBe(true);
expect(cancelResult.details.runId).toBe("RR-404");
expect(cancelResult.details.setup.code).toBe("NOT_FOUND");
});
it("returns completed-run structured findings and citations", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const run = store.getResearchStore().createRun({ query: "fusion", topic: "fusion" });
store.getResearchStore().setResults(run.id, {
summary: "Summary text",
findings: [{ heading: "Finding A", content: "Detail A", sources: ["https://example.com/a"] }],
citations: [{ title: "Source A", url: "https://example.com/a" }],
} as any);
store.getResearchStore().updateStatus(run.id, "running");
store.getResearchStore().updateStatus(run.id, "completed");
const getTool = api.tools.get("fn_research_get")!;
const result = await getTool.execute("call-complete", { id: run.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.details.runId).toBe(run.id);
expect(result.details.status).toBe("completed");
expect(result.details.summary).toBe("Summary text");
expect(result.details.findings).toHaveLength(1);
expect(result.details.findings[0]).toMatchObject({ heading: "Finding A", content: "Detail A" });
expect(result.details.citations).toHaveLength(1);
expect(result.details.citations[0]).toMatchObject({ title: "Source A", url: "https://example.com/a" });
});
it("retries failed run and returns retry linkage metadata", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const run = store.getResearchStore().createRun({
query: "fusion",
topic: "fusion",
lifecycle: { retryable: true, attempt: 1, maxAttempts: 3, failureClass: "retryable_transient" },
});
store.getResearchStore().updateStatus(run.id, "running", {
lifecycle: { retryable: true, attempt: 1, maxAttempts: 3, failureClass: "retryable_transient" },
});
store.getResearchStore().updateStatus(run.id, "failed", {
lifecycle: { retryable: true, attempt: 1, maxAttempts: 3, failureClass: "retryable_transient" },
});
const retryTool = api.tools.get("fn_research_retry")!;
const retryResult = await retryTool.execute("call-retry", { id: run.id }, undefined, undefined, makeCtx(tmpDir));
expect(retryResult.isError).not.toBe(true);
expect(["queued", "retry_waiting"]).toContain(retryResult.details.status);
expect(retryResult.details.runId).not.toBe(run.id);
const retried = store.getResearchStore().getRun(retryResult.details.runId);
expect(retried?.status).toBe("retry_waiting");
expect(retried?.lifecycle?.retryOfRunId).toBe(run.id);
expect(retried?.lifecycle?.rootRunId).toBe(run.id);
expect(retried?.lifecycle?.attempt).toBe(2);
});
it("returns INVALID_TRANSITION for cancel on terminal run", async () => {
const store = new TaskStore(tmpDir);
await store.init();

View File

@@ -1443,7 +1443,15 @@ export default function kbExtension(pi: ExtensionAPI) {
if (!run) {
return {
content: [{ type: "text", text: `Research run ${params.id} not found.` }],
details: { runId: params.id, status: "missing", summary: null, findings: [], citations: [], error: "not found", setup: null },
details: {
runId: params.id,
status: "missing",
summary: null,
findings: [],
citations: [],
error: "not found",
setup: { code: "NOT_FOUND", message: `Research run ${params.id} not found.` },
},
};
}
return { content: [{ type: "text", text: `Research run ${run.id} is ${run.status}.` }], details: toResearchRunDetails(run) };
@@ -1462,7 +1470,16 @@ export default function kbExtension(pi: ExtensionAPI) {
if (!run) {
return {
content: [{ type: "text", text: `Research run ${params.id} not found.` }],
details: { runId: params.id, status: "missing", summary: null, findings: [], citations: [], error: "not found", setup: null },
isError: true,
details: {
runId: params.id,
status: "missing",
summary: null,
findings: [],
citations: [],
error: "not found",
setup: { code: "NOT_FOUND", message: `Research run ${params.id} not found.` },
},
};
}
@@ -1499,7 +1516,15 @@ export default function kbExtension(pi: ExtensionAPI) {
return {
content: [{ type: "text", text: `Research run ${params.id} not found.` }],
isError: true,
details: { runId: params.id, status: "missing", summary: null, findings: [], citations: [], error: "not found", setup: null },
details: {
runId: params.id,
status: "missing",
summary: null,
findings: [],
citations: [],
error: "not found",
setup: { code: "NOT_FOUND", message: `Research run ${params.id} not found.` },
},
};
}
const isRetryExhausted = run.status === "retry_exhausted" || run.lifecycle?.errorCode === "RETRY_EXHAUSTED";

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ResearchLifecycleError } from "@fusion/core";
import { EventEmitter } from "node:events";
import { createServer } from "../../../src/server.js";
import { request } from "../../../src/test-request.js";
@@ -14,6 +15,8 @@ const researchStore = {
updateSource: vi.fn(),
setResults: vi.fn(),
updateStatus: vi.fn(),
requestCancellation: vi.fn(),
createRetryRun: vi.fn(),
createExport: vi.fn(),
getExports: vi.fn(),
getExport: vi.fn(),
@@ -35,8 +38,10 @@ describe("research routes", () => {
vi.clearAllMocks();
researchStore.listRuns.mockReturnValue([]);
researchStore.getRun.mockReturnValue(undefined);
researchStore.createRun.mockReturnValue({ id: "RR-1", query: "q", status: "pending", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
researchStore.createRun.mockReturnValue({ id: "RR-1", query: "q", status: "queued", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
researchStore.updateRun.mockReturnValue({ id: "RR-1", query: "q", status: "running", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "y" });
researchStore.requestCancellation.mockReturnValue({ id: "RR-1", query: "q", status: "cancelling", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "y" });
researchStore.createRetryRun.mockReturnValue({ id: "RR-2", query: "q", status: "retry_waiting", sources: [], events: [], tags: [], lifecycle: { retryOfRunId: "RR-1", rootRunId: "RR-1" }, createdAt: "x", updatedAt: "y" });
researchStore.deleteRun.mockReturnValue(true);
researchStore.appendEvent.mockReturnValue({ id: "E1", timestamp: "x", type: "info", message: "ok" });
researchStore.addSource.mockReturnValue({ id: "S1", type: "web", reference: "https://e.com", status: "pending" });
@@ -90,6 +95,32 @@ describe("research routes", () => {
expect(getEx.status).toBe(200);
});
it("supports cancel and retry with structured success and 409 responses", async () => {
researchStore.getRun.mockReturnValue({ id: "RR-1", query: "q", status: "running", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
const cancel = await request(app, "POST", "/api/research/runs/RR-1/cancel");
expect(cancel.status).toBe(200);
expect((cancel.body as any).run.status).toBe("cancelling");
const retry = await request(app, "POST", "/api/research/runs/RR-1/retry");
expect(retry.status).toBe(200);
expect((retry.body as any).run.status).toBe("retry_waiting");
researchStore.getRun.mockReturnValue({ id: "RR-1", query: "q", status: "completed", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
const cancelConflict = await request(app, "POST", "/api/research/runs/RR-1/cancel");
expect(cancelConflict.status).toBe(409);
expect((cancelConflict.body as any).error).toContain("cannot be cancelled");
expect((cancelConflict.body as any).code).toBe("INVALID_TRANSITION");
researchStore.getRun.mockReturnValue({ id: "RR-1", query: "q", status: "retry_exhausted", lifecycle: { errorCode: "RETRY_EXHAUSTED" }, sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
researchStore.createRetryRun.mockImplementationOnce(() => {
throw new ResearchLifecycleError("Run RR-1 exhausted retries", "not_retryable");
});
const retryConflict = await request(app, "POST", "/api/research/runs/RR-1/retry");
expect(retryConflict.status).toBe(409);
expect((retryConflict.body as any).error).toContain("exhausted");
});
it("supports stats, search and validation errors", async () => {
const stats = await request(app, "GET", "/api/research/stats");
expect(stats.status).toBe(200);
@@ -99,6 +130,10 @@ describe("research routes", () => {
const invalidStatus = await request(app, "PATCH", "/api/research/runs/RR-1/status", JSON.stringify({ status: "bogus" }), { "Content-Type": "application/json" });
expect(invalidStatus.status).toBe(400);
expect((invalidStatus.body as any).error).toContain("Invalid status");
if ((invalidStatus.body as any).code !== undefined) {
expect(typeof (invalidStatus.body as any).code).toBe("string");
}
const invalidEvent = await request(app, "POST", "/api/research/runs/RR-1/events", JSON.stringify({ type: "bad", message: "x" }), { "Content-Type": "application/json" });
expect(invalidEvent.status).toBe(400);
@@ -112,9 +147,26 @@ describe("research routes", () => {
researchStore.getExport.mockReturnValue(undefined);
const missingExport = await request(app, "GET", "/api/research/exports/EX-404");
expect(missingExport.status).toBe(404);
expect((missingExport.body as any).error).toContain("Export not found");
if ((missingExport.body as any).code !== undefined) {
expect(typeof (missingExport.body as any).code).toBe("string");
}
researchStore.getRun.mockReturnValue(undefined);
const missing = await request(app, "GET", "/api/research/runs/RR-404");
expect(missing.status).toBe(404);
expect((missing.body as any).error).toContain("Run not found");
if ((missing.body as any).code !== undefined) {
expect(typeof (missing.body as any).code).toBe("string");
}
});
it("returns export payload with json content type", async () => {
researchStore.getExport.mockReturnValue({ id: "EX1", runId: "RR-1", format: "json", content: "{}", createdAt: "x" });
const response = await request(app, "GET", "/api/research/exports/EX1");
expect(response.status).toBe(200);
expect((response.headers["content-type"] as string)).toContain("application/json");
expect(response.body).toMatchObject({ id: "EX1", runId: "RR-1", format: "json", content: "{}" });
});
});

View File

@@ -218,6 +218,7 @@ export function createResearchRouter(store: TaskStore): Router {
if (["completed", "failed", "cancelled", "timed_out", "retry_exhausted"].includes(existing.status)) {
res.status(409).json({
error: `Run ${req.params.id} cannot be cancelled from status ${existing.status}`,
code: "INVALID_TRANSITION",
details: { code: "INVALID_TRANSITION", retryable: false },
});
return;
@@ -239,10 +240,12 @@ export function createResearchRouter(store: TaskStore): Router {
if (error instanceof ResearchLifecycleError && error.code === "not_retryable") {
const run = getStore().getRun(req.params.id);
const exhausted = run?.status === "retry_exhausted" || run?.lifecycle?.errorCode === "RETRY_EXHAUSTED";
const code = exhausted ? "RETRY_EXHAUSTED" : "NON_RETRYABLE_PROVIDER_ERROR";
res.status(409).json({
error: error.message,
code,
details: {
code: exhausted ? "RETRY_EXHAUSTED" : "NON_RETRYABLE_PROVIDER_ERROR",
code,
retryable: false,
},
});
@@ -251,6 +254,7 @@ export function createResearchRouter(store: TaskStore): Router {
if (error instanceof ResearchLifecycleError && error.code === "invalid_transition") {
res.status(409).json({
error: error.message,
code: "INVALID_TRANSITION",
details: { code: "INVALID_TRANSITION", retryable: false },
});
return;