feat(FN-3014): document research recovery semantics

Documents research recovery semantics in the CLI reference and settings reference, adding 9 lines of documentation across both files.

Fusion-Task-Id: FN-3014
This commit is contained in:
Fusion
2026-05-04 08:03:34 -07:00
committed by gsxdsm
parent 815031baf4
commit f1ee69e59a
20 changed files with 631 additions and 61 deletions

View File

@@ -53,6 +53,7 @@ describe("research extension tools", () => {
expect(api.tools.has("fn_research_list")).toBe(true);
expect(api.tools.has("fn_research_get")).toBe(true);
expect(api.tools.has("fn_research_cancel")).toBe(true);
expect(api.tools.has("fn_research_retry")).toBe(true);
});
it("returns actionable disabled response when research is off", async () => {
@@ -67,29 +68,68 @@ describe("research extension tools", () => {
expect(result.content[0].text).toContain("disabled");
});
it("returns actionable missing-credentials response", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateSettings({
researchWebSearchProvider: "tavily",
researchSettings: { enabled: true },
researchGlobalDefaults: { searchProvider: "tavily" },
});
const runTool = api.tools.get("fn_research_run")!;
const result = await runTool.execute("call-0", { query: "fusion" }, undefined, undefined, makeCtx(tmpDir));
expect(result.details.setup.code).toBe("missing-credentials");
expect(result.content[0].text).toContain("Missing credentials");
});
it("creates, reads, lists, and cancels runs", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateSettings({
researchWebSearchProvider: "tavily",
researchTavilyApiKey: "test-key",
researchSettings: { enabled: true, searchProvider: "tavily" },
researchSettings: { enabled: true },
researchGlobalDefaults: { searchProvider: "tavily" },
});
const runTool = api.tools.get("fn_research_run")!;
const runResult = await runTool.execute("call-1", { query: "fusion architecture" }, undefined, undefined, makeCtx(tmpDir));
expect(runResult.details.runId).toBeTruthy();
const created = store.getResearchStore().createRun({ query: "fusion architecture", topic: "fusion architecture" });
const listTool = api.tools.get("fn_research_list")!;
const listResult = await listTool.execute("call-2", {}, undefined, undefined, makeCtx(tmpDir));
expect(listResult.details.runs.length).toBeGreaterThan(0);
const getTool = api.tools.get("fn_research_get")!;
const getResult = await getTool.execute("call-3", { id: runResult.details.runId }, undefined, undefined, makeCtx(tmpDir));
expect(getResult.details.runId).toBe(runResult.details.runId);
const getResult = await getTool.execute("call-3", { id: created.id }, undefined, undefined, makeCtx(tmpDir));
expect(getResult.details.runId).toBe(created.id);
const cancelTool = api.tools.get("fn_research_cancel")!;
const cancelResult = await cancelTool.execute("call-4", { id: runResult.details.runId }, undefined, undefined, makeCtx(tmpDir));
const cancelResult = await cancelTool.execute("call-4", { id: created.id }, undefined, undefined, makeCtx(tmpDir));
expect(["cancelling", "cancelled"]).toContain(cancelResult.details.status);
const retryTool = api.tools.get("fn_research_retry")!;
const retryBlocked = await retryTool.execute("call-5", { id: created.id }, undefined, undefined, makeCtx(tmpDir));
expect(retryBlocked.isError).toBe(true);
});
it("returns INVALID_TRANSITION for cancel on terminal run", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateSettings({
researchWebSearchProvider: "tavily",
researchTavilyApiKey: "test-key",
researchSettings: { enabled: true },
researchGlobalDefaults: { searchProvider: "tavily" },
});
const run = store.getResearchStore().createRun({ query: "fusion", topic: "fusion" });
store.getResearchStore().updateStatus(run.id, "running");
store.getResearchStore().updateStatus(run.id, "completed");
const cancelTool = api.tools.get("fn_research_cancel")!;
const result = await cancelTool.execute("call-6", { id: run.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBe(true);
expect(result.details.setup.code).toBe("INVALID_TRANSITION");
});
});

View File

@@ -5,7 +5,7 @@ const mockRun = {
id: "RR-001",
query: "test query",
topic: "test query",
status: "completed",
status: "running",
sources: [],
events: [],
tags: [],
@@ -22,7 +22,7 @@ const researchStoreMock = {
const storeMock = {
init: vi.fn(),
getSettings: vi.fn(async () => ({ researchSettings: { enabled: true }, researchTavilyApiKey: "x" })),
getSettings: vi.fn(async () => ({ researchSettings: { enabled: true }, researchWebSearchProvider: "tavily", researchTavilyApiKey: "x" })),
getResearchStore: vi.fn(() => researchStoreMock),
};
@@ -42,7 +42,7 @@ const { resolveResearchSettingsMock, providerRegistryMock, writeFileMock } = vi.
vi.mock("@fusion/core", () => ({
TaskStore: vi.fn(() => storeMock),
resolveResearchSettings: resolveResearchSettingsMock,
RESEARCH_RUN_STATUSES: ["pending", "running", "completed", "failed", "cancelled"],
RESEARCH_RUN_STATUSES: ["queued", "running", "cancelling", "retry_waiting", "completed", "failed", "cancelled", "timed_out", "retry_exhausted"],
RESEARCH_EXPORT_FORMATS: ["json", "markdown", "pdf"],
}));
@@ -109,7 +109,7 @@ describe("research commands", () => {
const writeArgs = writeFileMock.mock.calls[0]!;
expect(String(writeArgs[0])).toContain("out.json");
expect(String(writeArgs[1])).toContain('"id": "RR-001"');
expect(String(writeArgs[1])).toContain('"status": "completed"');
expect(String(writeArgs[1])).toContain('"status": "running"');
expect(String(writeArgs[1])).toContain('"query": "test query"');
expect(researchStoreMock.createExport).toHaveBeenCalledWith("RR-001", "json", expect.stringContaining('"id": "RR-001"'));
});
@@ -126,7 +126,7 @@ describe("research commands", () => {
});
it("retries a run", async () => {
researchStoreMock.getRun.mockImplementation((id: string) => (id === "RR-003" ? { ...mockRun, id: "RR-003", status: "pending" } : mockRun));
researchStoreMock.getRun.mockImplementation((id: string) => (id === "RR-003" ? { ...mockRun, id: "RR-003", status: "queued" } : { ...mockRun, status: "failed" }));
await runResearchRetry("RR-001", { json: true });
expect(orchestratorMock.retryRun).toHaveBeenCalledWith("RR-001");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"retryOf"'));
@@ -144,6 +144,24 @@ describe("research commands", () => {
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("provider-unavailable"));
});
it("errors when provider credentials are missing", async () => {
storeMock.getSettings.mockResolvedValueOnce({ researchSettings: { enabled: true }, researchWebSearchProvider: "tavily" });
await expect(runResearchCreate({ query: "hello" })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("missing-credentials"));
});
it("errors on cancel for terminal runs", async () => {
researchStoreMock.getRun.mockReturnValueOnce({ ...mockRun, status: "completed" });
await expect(runResearchCancel("RR-001")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("invalid-transition"));
});
it("errors on retry exhausted runs", async () => {
researchStoreMock.getRun.mockReturnValueOnce({ ...mockRun, status: "retry_exhausted", lifecycle: { errorCode: "RETRY_EXHAUSTED" } });
await expect(runResearchRetry("RR-001")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("retry-exhausted"));
});
it("errors on invalid export format", async () => {
await expect(runResearchExport({ runId: "RR-001", format: "xml" })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Unsupported export format: xml");

View File

@@ -41,6 +41,15 @@ async function getStore(projectName?: string): Promise<TaskStore> {
return store;
}
function hasProviderCredentials(settings: Awaited<ReturnType<TaskStore["getSettings"]>>, providerId: string | undefined): boolean {
if (!providerId) return false;
if (providerId === "searxng") return Boolean(settings.researchSearxngUrl);
if (providerId === "brave") return Boolean(settings.researchBraveApiKey);
if (providerId === "google") return Boolean(settings.researchGoogleSearchApiKey && settings.researchGoogleSearchCx);
if (providerId === "tavily") return Boolean(settings.researchTavilyApiKey);
return false;
}
async function getResearchRuntime(store: TaskStore) {
const settings = await store.getSettings();
const resolved = resolveResearchSettings(settings);
@@ -48,6 +57,14 @@ async function getResearchRuntime(store: TaskStore) {
throw new Error("feature-disabled: Research is disabled in settings.");
}
const configuredProvider = (resolved.searchProvider as string | undefined) ?? settings.researchWebSearchProvider;
if (!configuredProvider) {
throw new Error("provider-unavailable: Research providers are not configured. Add provider credentials in settings.");
}
if (!hasProviderCredentials(settings, configuredProvider)) {
throw new Error(`missing-credentials: ${configuredProvider} credentials are missing. Configure Authentication and Research defaults in settings.`);
}
const registry = new ResearchProviderRegistry(settings, process.cwd());
const availableProviderTypes = registry.getAvailableProviders();
if (availableProviderTypes.length === 0) {
@@ -237,6 +254,10 @@ export async function runResearchCancel(runId: string, options: ResearchCommandO
const run = store.getResearchStore().getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
if (!["queued", "running", "cancelling", "retry_waiting"].includes(run.status)) {
throw new Error(`invalid-transition: Run ${runId} cannot be cancelled from status ${run.status}.`);
}
const { orchestrator } = await getResearchRuntime(store);
const cancelled = orchestrator.cancelRun(runId);
@@ -258,6 +279,13 @@ export async function runResearchRetry(runId: string, options: ResearchCommandOp
const existing = store.getResearchStore().getRun(runId);
if (!existing) throw new Error(`Research run not found: ${runId}`);
if (existing.status === "retry_exhausted" || existing.lifecycle?.errorCode === "RETRY_EXHAUSTED") {
throw new Error(`retry-exhausted: Run ${runId} has exhausted retry attempts.`);
}
if (existing.lifecycle?.retryable === false) {
throw new Error(`non-retryable-provider-error: Run ${runId} is marked non-retryable.`);
}
const { orchestrator } = await getResearchRuntime(store);
const newRunId = orchestrator.retryRun(runId);
const run = store.getResearchStore().getRun(newRunId);

View File

@@ -149,10 +149,14 @@ async function getResearchAvailability(store: TaskStore): Promise<{ ok: boolean;
? Boolean(settings.researchTavilyApiKey)
: false;
if (!configured && !resolved.searchProvider) {
if (!backend) {
return { ok: false, code: "provider-unavailable", message: "Research provider is not configured. Set research provider credentials in Settings." };
}
if (!configured) {
return { ok: false, code: "missing-credentials", message: `Missing credentials for ${backend}. Add provider keys in Authentication and verify Research defaults.` };
}
return { ok: true };
}
@@ -1304,7 +1308,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_research_cancel",
label: "fn: Cancel Research Run",
description: "Cancel a research run.",
description: "Cancel an in-flight research run. Terminal runs return INVALID_TRANSITION.",
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
@@ -1317,6 +1321,18 @@ export default function kbExtension(pi: ExtensionAPI) {
};
}
if (!["queued", "running", "cancelling", "retry_waiting"].includes(run.status)) {
return {
content: [{ type: "text", text: `Research run ${params.id} cannot be cancelled from status ${run.status}.` }],
isError: true,
details: {
...toResearchRunDetails(run),
error: "invalid transition",
setup: { code: "INVALID_TRANSITION", message: "Cancel is only available for queued/running/cancelling/retry_waiting runs." },
},
};
}
const updated = researchStore.requestCancellation(params.id);
return {
content: [{ type: "text", text: `Requested cancellation for research run ${params.id} (status: ${updated.status}).` }],
@@ -1325,6 +1341,43 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
pi.registerTool({
name: "fn_research_retry",
label: "fn: Retry Research Run",
description: "Retry a failed research run when lifecycle marks it retryable.",
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const researchStore = store.getResearchStore();
const run = researchStore.getRun(params.id);
if (!run) {
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 },
};
}
const isRetryExhausted = run.status === "retry_exhausted" || run.lifecycle?.errorCode === "RETRY_EXHAUSTED";
if ((run.status !== "failed" && run.status !== "timed_out") || run.lifecycle?.retryable === false || isRetryExhausted) {
return {
content: [{ type: "text", text: `Research run ${params.id} is not retryable from status ${run.status}.` }],
isError: true,
details: {
...toResearchRunDetails(run),
error: "not retryable",
setup: { code: isRetryExhausted ? "RETRY_EXHAUSTED" : "INVALID_TRANSITION", message: "Retry is only available for failed/timed_out retryable runs." },
},
};
}
const retryRun = researchStore.createRetryRun(params.id);
return {
content: [{ type: "text", text: `Created retry run ${retryRun.id} from ${params.id}.` }],
details: toResearchRunDetails(retryRun),
};
},
});
// ── Insights Tools ──────────────────────────────────────────────
pi.registerTool({