feat(FN-3705): gate research tools behind experimental flag

This merge introduces two major themes. First, research tools in both the engine and CLI are now gated behind an experimental flag, using a shared helper from core — the research tools are documented as experimental and the dashboard settings reference is updated. Second, the testing suite receives

Fusion-Task-Id: FN-3705
This commit is contained in:
Fusion
2026-05-07 10:29:03 -07:00
committed by gsxdsm
parent 1c0aef28cd
commit af82119a75
13 changed files with 243 additions and 32 deletions

View File

@@ -123,6 +123,7 @@ async function enableResearch(cwd: string): Promise<TaskStore> {
researchGlobalEnabled: true,
researchGlobalDefaults: { searchProvider: "searxng" },
researchGlobalSearxngUrl: "http://localhost:8888",
experimentalFeatures: { researchView: true } as Record<string, boolean>,
});
await store.updateSettings({
researchEnabled: true,

View File

@@ -56,10 +56,10 @@ describe("research extension tools", () => {
expect(api.tools.has("fn_research_retry")).toBe(true);
});
it("returns actionable disabled response when research is off", async () => {
it("returns feature-disabled response when experimental research flag is off", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateSettings({ researchSettings: { enabled: false } });
await store.updateSettings({ researchSettings: { enabled: true }, experimentalFeatures: { researchView: false } as Record<string, boolean> });
const runTool = api.tools.get("fn_research_run")!;
const result = await runTool.execute("call-1", { query: "fusion" }, undefined, undefined, makeCtx(tmpDir));
@@ -68,14 +68,38 @@ describe("research extension tools", () => {
expect(result.content[0].text).toContain("disabled");
});
it("returns feature-disabled contract for list/get/cancel/retry when flag is off", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateSettings({ researchSettings: { enabled: true }, experimentalFeatures: { researchView: false } as Record<string, boolean> });
const listResult = await api.tools.get("fn_research_list")!.execute("call-list", {}, undefined, undefined, makeCtx(tmpDir));
expect(listResult.details.setup.code).toBe("feature-disabled");
const getResult = await api.tools.get("fn_research_get")!.execute("call-get", { id: "RR-1" }, undefined, undefined, makeCtx(tmpDir));
expect(getResult.details.setup.code).toBe("feature-disabled");
const cancelResult = await api.tools.get("fn_research_cancel")!.execute("call-cancel", { id: "RR-1" }, undefined, undefined, makeCtx(tmpDir));
expect(cancelResult.isError).toBe(true);
expect(cancelResult.details.setup.code).toBe("feature-disabled");
const retryResult = await api.tools.get("fn_research_retry")!.execute("call-retry", { id: "RR-1" }, undefined, undefined, makeCtx(tmpDir));
expect(retryResult.isError).toBe(true);
expect(retryResult.details.setup.code).toBe("feature-disabled");
});
it("returns actionable missing-credentials response", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateSettings({
await store.updateGlobalSettings({
experimentalFeatures: { researchView: true } as Record<string, boolean>,
researchGlobalEnabled: true,
researchGlobalWebSearchProvider: "tavily",
researchSettings: { enabled: true },
researchGlobalDefaults: { searchProvider: "tavily" },
});
await store.updateSettings({
researchSettings: { enabled: true },
});
const runTool = api.tools.get("fn_research_run")!;
const result = await runTool.execute("call-0", { query: "fusion" }, undefined, undefined, makeCtx(tmpDir));
@@ -87,11 +111,15 @@ describe("research extension tools", () => {
it("creates, reads, lists, and cancels runs", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateGlobalSettings({
experimentalFeatures: { researchView: true } as Record<string, boolean>,
researchGlobalEnabled: true,
researchGlobalWebSearchProvider: "searxng",
researchGlobalSearxngUrl: "http://localhost:8888",
researchGlobalDefaults: { searchProvider: "searxng" },
});
await store.updateSettings({
researchGlobalWebSearchProvider: "tavily",
researchGlobalTavilyApiKey: "test-key",
researchSettings: { enabled: true },
researchGlobalDefaults: { searchProvider: "tavily" },
researchSettings: { enabled: true, searchProvider: "searxng" },
});
const created = store.getResearchStore().createRun({ query: "fusion architecture", topic: "fusion architecture" });
@@ -116,6 +144,16 @@ describe("research extension tools", () => {
it("returns structured missing-run details for get and cancel", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateGlobalSettings({
experimentalFeatures: { researchView: true } as Record<string, boolean>,
researchGlobalEnabled: true,
researchGlobalWebSearchProvider: "searxng",
researchGlobalSearxngUrl: "http://localhost:8888",
researchGlobalDefaults: { searchProvider: "searxng" },
});
await store.updateSettings({
researchSettings: { enabled: true, searchProvider: "searxng" },
});
const getTool = api.tools.get("fn_research_get")!;
const getResult = await getTool.execute("call-missing-get", { id: "RR-404" }, undefined, undefined, makeCtx(tmpDir));
@@ -133,6 +171,16 @@ describe("research extension tools", () => {
it("returns completed-run structured findings and citations", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateGlobalSettings({
experimentalFeatures: { researchView: true } as Record<string, boolean>,
researchGlobalEnabled: true,
researchGlobalWebSearchProvider: "searxng",
researchGlobalSearxngUrl: "http://localhost:8888",
researchGlobalDefaults: { searchProvider: "searxng" },
});
await store.updateSettings({
researchSettings: { enabled: true, searchProvider: "searxng" },
});
const run = store.getResearchStore().createRun({ query: "fusion", topic: "fusion" });
store.getResearchStore().setResults(run.id, {
@@ -157,6 +205,16 @@ describe("research extension tools", () => {
it("retries failed run and returns retry linkage metadata", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateGlobalSettings({
experimentalFeatures: { researchView: true } as Record<string, boolean>,
researchGlobalEnabled: true,
researchGlobalWebSearchProvider: "searxng",
researchGlobalSearxngUrl: "http://localhost:8888",
researchGlobalDefaults: { searchProvider: "searxng" },
});
await store.updateSettings({
researchSettings: { enabled: true, searchProvider: "searxng" },
});
const run = store.getResearchStore().createRun({
query: "fusion",
@@ -187,11 +245,15 @@ describe("research extension tools", () => {
it("returns INVALID_TRANSITION for cancel on terminal run", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateGlobalSettings({
experimentalFeatures: { researchView: true } as Record<string, boolean>,
researchGlobalEnabled: true,
researchGlobalWebSearchProvider: "searxng",
researchGlobalSearxngUrl: "http://localhost:8888",
researchGlobalDefaults: { searchProvider: "searxng" },
});
await store.updateSettings({
researchGlobalWebSearchProvider: "tavily",
researchGlobalTavilyApiKey: "test-key",
researchSettings: { enabled: true },
researchGlobalDefaults: { searchProvider: "tavily" },
researchSettings: { enabled: true, searchProvider: "searxng" },
});
const run = store.getResearchStore().createRun({ query: "fusion", topic: "fusion" });

View File

@@ -14,6 +14,7 @@ import {
type ResearchRun,
type ResearchRunStatus,
RESEARCH_RUN_STATUSES,
isResearchExperimentalEnabled,
resolveResearchSettings,
} from "@fusion/core";
import {
@@ -200,6 +201,10 @@ function formatTaskLine(t: Task): string {
async function getResearchAvailability(store: TaskStore): Promise<{ ok: boolean; code?: string; message?: string }> {
const settings = await store.getSettings();
if (!isResearchExperimentalEnabled(settings)) {
return { ok: false, code: "feature-disabled", message: "Research tools are disabled. Enable experimentalFeatures.researchView first." };
}
const resolved = resolveResearchSettings(settings);
if (!resolved.enabled) {
return { ok: false, code: "feature-disabled", message: "Research is disabled in settings." };
@@ -1426,6 +1431,14 @@ export default function kbExtension(pi: ExtensionAPI) {
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const availability = await getResearchAvailability(store);
if (!availability.ok) {
return {
content: [{ type: "text", text: availability.message! }],
details: { runs: [], setup: { code: availability.code, message: availability.message } },
};
}
const runs = store.getResearchStore().listRuns({ status: params.status as ResearchRunStatus | undefined, limit: params.limit ?? 10 });
const text = runs.length ? runs.map((run) => `- ${run.id} [${run.status}] ${run.query}`).join("\n") : "No research runs found.";
return { content: [{ type: "text", text }], details: { runs: runs.map(toResearchRunDetails) } };
@@ -1439,6 +1452,22 @@ export default function kbExtension(pi: ExtensionAPI) {
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const availability = await getResearchAvailability(store);
if (!availability.ok) {
return {
content: [{ type: "text", text: availability.message! }],
details: {
runId: params.id,
status: "unavailable",
summary: null,
findings: [],
citations: [],
error: availability.message,
setup: { code: availability.code, message: availability.message },
},
};
}
const run = store.getResearchStore().getRun(params.id);
if (!run) {
return {
@@ -1465,6 +1494,23 @@ export default function kbExtension(pi: ExtensionAPI) {
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const availability = await getResearchAvailability(store);
if (!availability.ok) {
return {
content: [{ type: "text", text: availability.message! }],
isError: true,
details: {
runId: params.id,
status: "unavailable",
summary: null,
findings: [],
citations: [],
error: availability.message,
setup: { code: availability.code, message: availability.message },
},
};
}
const researchStore = store.getResearchStore();
const run = researchStore.getRun(params.id);
if (!run) {
@@ -1510,6 +1556,23 @@ export default function kbExtension(pi: ExtensionAPI) {
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const availability = await getResearchAvailability(store);
if (!availability.ok) {
return {
content: [{ type: "text", text: availability.message! }],
isError: true,
details: {
runId: params.id,
status: "unavailable",
summary: null,
findings: [],
citations: [],
error: availability.message,
setup: { code: availability.code, message: availability.message },
},
};
}
const researchStore = store.getResearchStore();
const run = researchStore.getRun(params.id);
if (!run) {