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 273ca03052
commit 66fa56b45e
13 changed files with 243 additions and 32 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Gate `fn_research_*` tool availability behind `experimentalFeatures.researchView` so CLI and agent sessions consistently return feature-disabled responses when Research is not experimentally enabled.

View File

@@ -35,7 +35,7 @@ Research is **not** a replacement for reading source code or local docs — use
## Prerequisites
Research requires provider configuration before runs can execute. If setup is incomplete, the dashboard shows a setup prompt and CLI/agent tools return actionable error codes.
Research requires both the experimental flag and provider configuration before runs can execute. If setup is incomplete, the dashboard shows a setup prompt and CLI/agent tools return actionable error codes.
### 1. Enable the feature flag
@@ -49,7 +49,7 @@ The Research view is gated behind an experimental feature flag. Set in global se
}
```
This also reveals the **Research Defaults** and **Research** settings sections in the dashboard Settings modal.
This also reveals the **Research Defaults** and **Research** settings sections in the dashboard Settings modal, and enables agent/CLI research tools (`fn_research_*`).
### 2. Configure a web search provider

View File

@@ -96,7 +96,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `researchGlobalMaxSearchResults` | `number` | `undefined` | Maximum search results per provider query. |
| `researchGlobalFetchTimeoutMs` | `number` | `30000` | Timeout for individual HTTP fetches in milliseconds. |
| `researchGlobalUserAgent` | `string` | `"FusionResearchBot/1.0"` | User-Agent header for HTTP requests made by research providers. |
| `experimentalFeatures` | `Record<string, boolean>` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView` for standalone Research route visibility. |
| `experimentalFeatures` | `Record<string, boolean>` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView`, which gates all Research surfaces and tools (dashboard view, engine task-session tools, and CLI `fn_research_*` tools). |
| `remoteAccess` | `RemoteAccessSettings` | `{ activeProvider: null, providers: {...}, tokenStrategy: {...}, lifecycle: {...} }` | Global-scoped remote access provider + token strategy configuration used by Remote Access routes and tunnel lifecycle controls. |
### Notification providers (pluggable)
@@ -293,7 +293,7 @@ This applies to:
- run limits (`maxConcurrentRuns`, `maxSourcesPerRun`, `maxDurationMs`, `requestTimeoutMs`)
- export default (`defaultExportFormat`)
The standalone Research route is feature-gated separately via `experimentalFeatures.researchView`.
Research is globally feature-gated via `experimentalFeatures.researchView`.
When that flag is disabled, the Settings modal also hides both Research sections (`Research Defaults` and project `Research`) and falls back to the first visible section if a hidden research section is requested directly.
Research failures are normalized to a shared error-code contract (`FEATURE_DISABLED`, `MISSING_CREDENTIALS`, `PROVIDER_UNAVAILABLE`, `RATE_LIMITED`, `PROVIDER_TIMEOUT`, `RUN_CANCELLED`, `RETRY_EXHAUSTED`, `INVALID_TRANSITION`, `NON_RETRYABLE_PROVIDER_ERROR`, `INTERNAL_ERROR`) with retryability metadata so dashboard, API, CLI, and agent tooling show consistent recovery guidance.
@@ -302,7 +302,7 @@ Recovery entrypoints in the dashboard:
- **Settings → Research Defaults**: fix missing default provider configuration and provider-level readiness.
- **Settings → Authentication**: repair missing provider credentials (`MISSING_CREDENTIALS`).
- **Settings → Research (project)**: re-enable project research or source toggles when runs are blocked by project settings.
- **Settings → Experimental Features**: enable `researchView` when the standalone Research route/surfaces are hidden.
- **Settings → Experimental Features**: enable `researchView` when Research surfaces or `fn_research_*` tools report feature-disabled.
**Credential storage rule:** API keys for Research providers are not stored in settings JSON. They are managed through the existing auth storage pipeline (`/api/auth/status`, `POST /api/auth/api-key`, `DELETE /api/auth/api-key`) and persisted in auth credential storage with masked hints in API responses.

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) {

View File

@@ -1,7 +1,21 @@
import { describe, expect, it } from "vitest";
import { resolveResearchSettings } from "../research-settings.js";
import { isResearchExperimentalEnabled, resolveResearchSettings } from "../research-settings.js";
import type { Settings } from "../types.js";
describe("isResearchExperimentalEnabled", () => {
it("returns false when settings are missing", () => {
expect(isResearchExperimentalEnabled(undefined)).toBe(false);
});
it("returns false when researchView is false", () => {
expect(isResearchExperimentalEnabled({ experimentalFeatures: { researchView: false } as Record<string, boolean> } as Settings)).toBe(false);
});
it("returns true when researchView is true", () => {
expect(isResearchExperimentalEnabled({ experimentalFeatures: { researchView: true } as Record<string, boolean> } as Settings)).toBe(true);
});
});
describe("resolveResearchSettings", () => {
it("resolves global-only defaults", () => {
const resolved = resolveResearchSettings({

View File

@@ -721,7 +721,7 @@ export type {
ResearchCancellationState,
} from "./research-types.js";
export { resolveResearchSettings } from "./research-settings.js";
export { isResearchExperimentalEnabled, resolveResearchSettings } from "./research-settings.js";
export type { ResolvedResearchSettings } from "./research-settings.js";
export { resolveEvalSettings } from "./eval-settings.js";

View File

@@ -1,5 +1,9 @@
import type { ResearchEnabledSources, Settings } from "./types.js";
export function isResearchExperimentalEnabled(settings: Partial<Settings> | undefined): boolean {
return settings?.experimentalFeatures?.researchView === true;
}
export interface ResolvedResearchSettings {
enabled: boolean;
searchProvider?: string;

View File

@@ -4684,8 +4684,11 @@ const mockedReviewStep = vi.mocked(mockedReviewStepFn);
* Helper: executes a task and captures the custom tools passed to createFnAgent.
* Returns a map of tool name → tool execute function for direct testing.
*/
async function captureTools(): Promise<Record<string, (id: string, params: any) => Promise<any>>> {
async function captureTools(settingsOverride?: Record<string, unknown>): Promise<Record<string, (id: string, params: any) => Promise<any>>> {
const store = createMockStore();
if (settingsOverride) {
store.getSettings.mockResolvedValue({ ...(await store.getSettings()), ...settingsOverride });
}
// Simulate the real TaskStore: forward transitions persist, but in-progress
// regressions on done/skipped steps are rejected so executor.ts can surface
// the "already <status>" diagnostic.
@@ -4908,14 +4911,22 @@ describe("Code review verdict enforcement - fn_task_update blocking", () => {
expect(allowed.content[0].text).toContain("→ done");
});
it("registers research runtime tools in customTools", async () => {
const tools = await captureTools();
it("registers research runtime tools in customTools when researchView experimental flag is enabled", async () => {
const tools = await captureTools({ experimentalFeatures: { researchView: true } });
expect(tools.fn_research_run).toBeTypeOf("function");
expect(tools.fn_research_list).toBeTypeOf("function");
expect(tools.fn_research_get).toBeTypeOf("function");
expect(tools.fn_research_cancel).toBeTypeOf("function");
});
it("does not register research runtime tools when researchView experimental flag is disabled", async () => {
const tools = await captureTools({ experimentalFeatures: { researchView: false } });
expect(tools.fn_research_run).toBeUndefined();
expect(tools.fn_research_list).toBeUndefined();
expect(tools.fn_research_get).toBeUndefined();
expect(tools.fn_research_cancel).toBeUndefined();
});
it("REVISE tool response text includes re-review instructions", async () => {
mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Bug found", summary: "Issues" });

View File

@@ -838,6 +838,14 @@ describe("fast-mode triage", () => {
await mkdir(join(rootDir, ".fusion", "tasks", task.id), { recursive: true });
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
experimentalFeatures: { researchView: true },
} as Settings),
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
@@ -880,6 +888,43 @@ describe("fast-mode triage", () => {
await cleanupTriageFixtureRoot(rootDir);
}
});
it("omits research tools when researchView experimental flag is disabled", async () => {
const task = createTriageTask({ id: "FN-FAST-005", executionMode: "fast" });
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
experimentalFeatures: { researchView: false },
} as Settings),
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }),
});
let capturedTools: any[] = [];
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
capturedTools = opts.customTools;
return {
session: {
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
const processor = new TriageProcessor(store, "/tmp/root");
await processor.specifyTask(task);
expect(capturedTools.some((tool: any) => tool.name === "fn_research_run")).toBe(false);
expect(capturedTools.some((tool: any) => tool.name === "fn_research_list")).toBe(false);
expect(capturedTools.some((tool: any) => tool.name === "fn_research_get")).toBe(false);
expect(capturedTools.some((tool: any) => tool.name === "fn_research_cancel")).toBe(false);
});
});
describe("readAttachmentContents", () => {

View File

@@ -10,6 +10,7 @@ import {
buildExecutionMemoryInstructions,
getTaskMergeBlocker,
isEphemeralAgent,
isResearchExperimentalEnabled,
resolveAgentPrompt,
resolveProjectDefaultModel,
type RunCommandResult,
@@ -2826,11 +2827,13 @@ export class TaskExecutor {
this.createSpawnAgentTool(task.id, worktreePath, settings),
this.createTaskDocumentWriteTool(task.id),
this.createTaskDocumentReadTool(task.id),
...createResearchTools({
store: this.store,
rootDir: this.rootDir,
getSettings: async () => this.store.getSettings(),
}),
...(isResearchExperimentalEnabled(settings)
? createResearchTools({
store: this.store,
rootDir: this.rootDir,
getSettings: async () => this.store.getSettings(),
})
: []),
...createMemoryTools(this.rootDir, settings, assignedAgent ? {
agentMemory: {
agentId: assignedAgent.id,

View File

@@ -8,6 +8,7 @@ import type {
} from "@fusion/core";
import {
buildTriageMemoryInstructions,
isResearchExperimentalEnabled,
resolveAgentPrompt,
sortTasksByPriorityThenAgeAndId,
} from "@fusion/core";
@@ -941,11 +942,13 @@ export class TriageProcessor {
}),
createTaskDocumentWriteTool(this.store, task.id),
createTaskDocumentReadTool(this.store, task.id),
...createResearchTools({
store: this.store,
rootDir: this.rootDir,
getSettings: async () => this.store.getSettings(),
}),
...(isResearchExperimentalEnabled(settings)
? createResearchTools({
store: this.store,
rootDir: this.rootDir,
getSettings: async () => this.store.getSettings(),
})
: []),
...createMemoryTools(this.rootDir, settings),
// Agent delegation tools — discover and delegate work to other agents.
...(this.options.agentStore ? [