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:
@@ -33,7 +33,7 @@ Mission → Milestone → Slice → Feature → Task
|
||||
- **Agent tools** — `fn_agent_stop`, `fn_agent_start`, `fn_list_agents`, `fn_delegate_task`, `fn_agent_show`, `fn_agent_org_chart`
|
||||
- **Skills tools** — `fn_skills_search`, `fn_skills_install`
|
||||
- **Insight tools** — `fn_insight_list`, `fn_insight_show`, `fn_insight_run_list`, `fn_insight_run_show`
|
||||
- **Other tools** — `fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`
|
||||
- **Other tools** — `fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`, `fn_research_retry`
|
||||
<!-- END: tool-categories -->
|
||||
- **Dashboard** — Use `/fn` command to start/stop the dashboard
|
||||
|
||||
|
||||
@@ -390,7 +390,15 @@ Get one research run and structured findings.
|
||||
|
||||
### fn_research_cancel
|
||||
|
||||
Cancel a research run.
|
||||
Cancel an in-flight research run. Terminal runs return INVALID_TRANSITION.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `id` | string | ✓ | Research run ID |
|
||||
|
||||
### fn_research_retry
|
||||
|
||||
Retry a failed research run when lifecycle marks it retryable.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
|
||||
@@ -32,7 +32,8 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
|
||||
| `fn_research_run` | Start a bounded research run and optionally wait for findings. |
|
||||
| `fn_research_list` | List recent research runs. |
|
||||
| `fn_research_get` | Get one research run and structured findings. |
|
||||
| `fn_research_cancel` | Cancel a research run. |
|
||||
| `fn_research_cancel` | Cancel an in-flight research run. Terminal runs return INVALID_TRANSITION. |
|
||||
| `fn_research_retry` | Retry a failed research run when lifecycle marks it retryable. |
|
||||
| `fn_insight_list` | List persisted project insights with optional category/status filters. |
|
||||
| `fn_insight_show` | Show a single persisted insight by ID. |
|
||||
| `fn_insight_run_list` | List recent insight-generation runs with optional status/trigger filters. |
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user