feat(FN-4135): add always-on web search with locked provider setting

Adds always-on web search capability to the research system, locking the web search provider in settings UI and updating the ResearchView to surface web search as a persistent toggle with agent tool integration, plus corresponding docs and test coverage.

Fusion-Task-Id: FN-4135
This commit is contained in:
Fusion
2026-05-12 12:20:10 -07:00
committed by gsxdsm
parent 917ffa5b74
commit 2d250e6067
16 changed files with 92 additions and 88 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Keep web search always enabled in the Research view and remove the `none` web-search provider option plus the per-project Web Search source toggle from settings.

View File

@@ -53,7 +53,7 @@ This also reveals the **Research Defaults** and **Research** settings sections i
### 2. Built-in web search is the default
By default, `researchGlobalWebSearchProvider` resolves to `"builtin"`. Search and fetch run through the agent runtime's native `WebSearch` and `WebFetch` tools, so no API key is required for baseline usage.
By default, `researchGlobalWebSearchProvider` resolves to `"builtin"`. Search and fetch run through the agent runtime's native `WebSearch` and `WebFetch` tools, so no API key is required for baseline usage, and web search stays enabled even if older persisted settings still contain `enabledSources.webSearch: false`.
### 3. Optional: external search backends
@@ -66,7 +66,6 @@ You can opt into external providers in global settings:
| `"brave"` | `researchGlobalBraveApiKey` — Brave Search API key |
| `"google"` | `researchGlobalGoogleSearchApiKey` + `researchGlobalGoogleSearchCx` — Google Custom Search credentials |
| `"tavily"` | `researchGlobalTavilyApiKey` — Tavily API key |
| `"none"` | Disables web search (Page Fetch, Local Docs, GitHub, and LLM synthesis can still run) |
API keys are stored through Fusion's auth credential pipeline (`/api/auth/api-key`), not in settings JSON directly.
@@ -304,7 +303,7 @@ Before creating runs, `fn_research_run` checks:
2. Web search is available (`"builtin"` by default, or an explicitly configured external backend)
3. Required API keys are present for external providers
If a check fails, the tool returns an actionable error with setup guidance instead of crashing. In practice, with the default `"builtin"` backend, provider-setup errors are mostly limited to explicit external-provider selections or explicit `"none"` opt-out.
If a check fails, the tool returns an actionable error with setup guidance instead of crashing. In practice, with the default `"builtin"` backend, provider-setup errors are mostly limited to explicit external-provider selections.
### Best practices for agents
@@ -437,7 +436,7 @@ When all retries are exhausted, the run transitions to `retry_exhausted`.
| Symptom | Cause | Resolution |
|---|---|---|
| "Research is disabled in settings" | `researchGlobalEnabled` or `researchSettings.enabled` is `false` | Enable in Settings → Research |
| "Research provider is not configured" | Web search was explicitly disabled (`researchGlobalWebSearchProvider: "none"`) or external provider setup is incomplete | Re-enable builtin search, or finish configuring your selected external provider in Settings |
| "Research provider is not configured" | External provider setup is incomplete | Switch back to builtin search or finish configuring your selected external provider in Settings |
| "Missing API key for {provider}" | Auth credential not found | Configure provider credentials in Settings → Authentication |
| Run stuck in `queued` | Engine not running or no available concurrency slots | Start the project engine; check `maxConcurrentRuns` |
| Run times out | Provider slow or `maxDurationMs` too low | Increase timeout in project research settings |

View File

@@ -92,7 +92,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
| `researchGlobalDefaultTimeout` | `number` | `300000` | Default timeout for end-to-end research runs in milliseconds (5 minutes). |
| `researchGlobalMaxSourcesPerRun` | `number` | `20` | Maximum number of sources per research run. |
| `researchGlobalMaxSynthesisRounds` | `number` | `2` | Maximum synthesis rounds per research run. |
| `researchGlobalWebSearchProvider` | `"builtin" \| "searxng" \| "brave" \| "google" \| "tavily" \| "none"` | `"builtin"` | Web search backend for research. Default: `"builtin"` (uses agent-native WebSearch/WebFetch tools with no API key requirement). |
| `researchGlobalWebSearchProvider` | `"builtin" \| "searxng" \| "brave" \| "google" \| "tavily"` | `"builtin"` | Web search backend for research. Default: `"builtin"` (uses agent-native WebSearch/WebFetch tools with no API key requirement). Web search itself is always enabled. |
| `researchGlobalSearxngUrl` | `string` | `undefined` | SearXNG instance URL (required when provider is `"searxng"`). |
| `researchGlobalBraveApiKey` | `string` | `undefined` | Brave Search API key (required when provider is `"brave"`). |
| `researchGlobalGoogleSearchApiKey` | `string` | `undefined` | Google Custom Search API key (required when provider is `"google"`). |

View File

@@ -43,7 +43,6 @@ async function getStore(projectName?: string): Promise<TaskStore> {
function hasProviderCredentials(settings: Awaited<ReturnType<TaskStore["getSettings"]>>, providerId: string | undefined): boolean {
if (!providerId || providerId === "builtin") return true;
if (providerId === "none") return false;
if (providerId === "searxng") return Boolean(settings.researchGlobalSearxngUrl);
if (providerId === "brave") return Boolean(settings.researchGlobalBraveApiKey);
if (providerId === "google") return Boolean(settings.researchGlobalGoogleSearchApiKey && settings.researchGlobalGoogleSearchCx);
@@ -59,7 +58,7 @@ async function getResearchRuntime(store: TaskStore) {
}
const configuredProvider = (resolved.searchProvider as string | undefined) ?? settings.researchGlobalWebSearchProvider ?? "builtin";
if (configuredProvider !== "builtin" && configuredProvider !== "none" && !hasProviderCredentials(settings, configuredProvider)) {
if (configuredProvider !== "builtin" && !hasProviderCredentials(settings, configuredProvider)) {
throw new Error(`missing-credentials: ${configuredProvider} credentials are missing. Configure Authentication and Research defaults in settings.`);
}

View File

@@ -240,9 +240,7 @@ async function getResearchAvailability(store: TaskStore): Promise<{ ok: boolean;
const backend = (resolved.searchProvider as string | undefined) ?? settings.researchGlobalWebSearchProvider ?? "builtin";
const configured = backend === "builtin"
? true
: backend === "none"
? false
: backend === "searxng"
: backend === "searxng"
? Boolean(settings.researchGlobalSearxngUrl)
: backend === "brave"
? Boolean(settings.researchGlobalBraveApiKey)

View File

@@ -95,12 +95,25 @@ describe("resolveResearchSettings", () => {
expect(resolved.searchProvider).toBe("brave");
});
it("honors explicit legacy global search provider opt-out", () => {
it("forces web search on even when persisted settings disable it", () => {
const resolved = resolveResearchSettings({
researchGlobalWebSearchProvider: "none",
researchGlobalDefaults: {
enabledSources: {
webSearch: false,
pageFetch: true,
github: false,
localDocs: true,
llmSynthesis: true,
},
},
researchSettings: {
enabledSources: {
webSearch: false,
},
},
});
expect(resolved.searchProvider).toBe("none");
expect(resolved.enabledSources.webSearch).toBe(true);
});
it("supports null-as-delete semantics for researchSettings object", () => {

View File

@@ -22,8 +22,7 @@ export interface ResolvedResearchSettings {
const DEFAULT_SEARCH_PROVIDER = "builtin";
const FALLBACK_SOURCES: ResearchEnabledSources = {
webSearch: true,
const FALLBACK_SOURCES: Omit<ResearchEnabledSources, "webSearch"> = {
pageFetch: true,
github: false,
localDocs: true,
@@ -44,10 +43,7 @@ export function resolveResearchSettings(settings: Partial<Settings> | undefined)
synthesisProvider: projectSettings?.synthesisProvider ?? globalDefaults?.synthesisProvider,
synthesisModelId: projectSettings?.synthesisModelId ?? globalDefaults?.synthesisModelId,
enabledSources: {
webSearch:
projectSettings?.enabledSources?.webSearch ??
globalDefaults?.enabledSources?.webSearch ??
FALLBACK_SOURCES.webSearch,
webSearch: true,
pageFetch:
projectSettings?.enabledSources?.pageFetch ??
globalDefaults?.enabledSources?.pageFetch ??

View File

@@ -1531,7 +1531,7 @@ export interface DaemonTokenSettings {
* The dashboard UI shows these under a "Global" section.
*/
/** Web search backend for auto-research provider. */
export type WebSearchBackend = "builtin" | "searxng" | "brave" | "google" | "tavily" | "none";
export type WebSearchBackend = "builtin" | "searxng" | "brave" | "google" | "tavily";
export interface ResearchEnabledSources {
webSearch: boolean;
@@ -1858,7 +1858,7 @@ export interface GlobalSettings {
/** Default maximum number of synthesis rounds per run.
* Default: 2. */
researchGlobalMaxSynthesisRounds?: number;
/** Web search backend for auto-research. Default: "builtin". */
/** Web search backend for auto-research. Default: "builtin"; web search itself cannot be disabled. */
researchGlobalWebSearchProvider?: WebSearchBackend;
/** SearXNG instance URL (required when researchGlobalWebSearchProvider is "searxng"). */
researchGlobalSearxngUrl?: string;

View File

@@ -57,7 +57,6 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
} = useResearch({ projectId });
const [query, setQuery] = useState("");
const [effectiveSettings, setEffectiveSettings] = useState(() => resolveResearchSettings(undefined));
const [rawSettings, setRawSettings] = useState<Partial<Settings>>({});
const [authProviders, setAuthProviders] = useState<Array<{ id: string; authenticated: boolean }>>([]);
const [submitting, setSubmitting] = useState(false);
const [selectedProviders, setSelectedProviders] = useState<ResearchProviderOption[]>([]);
@@ -67,8 +66,8 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
const providerOptions = availability.supportedProviders ?? DEFAULT_PROVIDERS;
const selectedSearchProvider = effectiveSettings.searchProvider;
const isProviderEnabled = (provider: ResearchProviderOption) => {
if (provider === "web-search" && selectedSearchProvider === "none") {
return false;
if (provider === "web-search") {
return true;
}
return effectiveSettings.enabledSources[PROVIDER_TO_SOURCE_KEY[provider]];
};
@@ -92,7 +91,6 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
])
.then(([settings, authStatus]) => {
if (cancelled) return;
setRawSettings(settings);
setEffectiveSettings(resolveResearchSettings(settings));
setAuthProviders(
authStatus.providers
@@ -127,7 +125,6 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
const supportedExportFormats = availability.supportedExportFormats ?? ["markdown", "json", "html"];
const webSearchExplicitlyDisabled = rawSettings.researchGlobalWebSearchProvider === "none" || selectedSearchProvider === "none";
const apiKeyProviderAuth = useMemo(() => new Map(authProviders.map((provider) => [provider.id, provider.authenticated])), [authProviders]);
const requiredCredentialProviders = useMemo(() => {
const required = new Set<string>();
@@ -156,9 +153,6 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
settingsSection: "research-project" as SectionId,
};
}
if (webSearchExplicitlyDisabled) {
return null;
}
if (missingCredentialProvider) {
return {
reason: `Missing API key for ${missingCredentialProvider}.`,
@@ -167,7 +161,7 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
};
}
return null;
}, [availability.available, availability.reason, availability.setupInstructions, effectiveSettings.enabled, missingCredentialProvider, webSearchExplicitlyDisabled]);
}, [availability.available, availability.reason, availability.setupInstructions, effectiveSettings.enabled, missingCredentialProvider]);
const runAction = async (key: string, action: () => Promise<unknown>, successMessage: string) => {
setActionLoading(key);
@@ -256,16 +250,6 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
</div>
) : (
<>
{webSearchExplicitlyDisabled ? (
<div className="research-view__state card" data-testid="research-state-web-search-disabled">
<p>Web search is disabled. Page Fetch, Local Docs, GitHub and LLM Synthesis still work.</p>
<div className="research-view__actions">
<button className="btn btn-primary" type="button" onClick={() => onOpenSettings?.("research-global")}>
Re-enable Web Search
</button>
</div>
</div>
) : null}
<div className="research-view__layout">
<aside className="research-view__sidebar card">
<div className="research-view__form">
@@ -276,24 +260,28 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
<div className="form-group">
<label>Providers</label>
<div className="research-view__providers">
{providerOptions.map((provider) => (
<label key={provider} className="checkbox-label">
<input
type="checkbox"
checked={selectedProviders.includes(provider)}
disabled={!isProviderEnabled(provider)}
onChange={() => {
if (!isProviderEnabled(provider)) {
return;
}
setSelectedProviders((current) =>
current.includes(provider) ? current.filter((entry) => entry !== provider) : [...current, provider],
);
}}
/>
<span>{PROVIDER_LABELS[provider] ?? provider}</span>
</label>
))}
{providerOptions.map((provider) => {
const providerEnabled = isProviderEnabled(provider);
const providerLocked = provider === "web-search";
return (
<label key={provider} className="checkbox-label">
<input
type="checkbox"
checked={providerLocked || selectedProviders.includes(provider)}
disabled={providerLocked || !providerEnabled}
onChange={() => {
if (providerLocked || !providerEnabled) {
return;
}
setSelectedProviders((current) =>
current.includes(provider) ? current.filter((entry) => entry !== provider) : [...current, provider],
);
}}
/>
<span>{PROVIDER_LABELS[provider] ?? provider}{providerLocked ? " (always on)" : ""}</span>
</label>
);
})}
</div>
</div>
<button className="btn btn-primary" type="button" disabled={!query.trim() || submitting} onClick={() => void handleCreateRun()}>

View File

@@ -4298,8 +4298,7 @@ export function SettingsModal({
resolvedProvider === "searxng" ||
resolvedProvider === "brave" ||
resolvedProvider === "google" ||
resolvedProvider === "tavily" ||
resolvedProvider === "none";
resolvedProvider === "tavily";
const selectedCredentialProvider =
resolvedProvider === "brave" || resolvedProvider === "tavily" ? resolvedProvider : null;
const hasMissingResearchCredential = selectedCredentialProvider
@@ -4352,7 +4351,6 @@ export function SettingsModal({
<option value="brave">Brave</option>
<option value="google">Google Custom Search</option>
<option value="tavily">Tavily</option>
<option value="none">None (disable web search)</option>
</select>
</div>
<div className="form-group">
@@ -4451,8 +4449,16 @@ export function SettingsModal({
<div className="form-group">
<label>Enabled Sources</label>
<div className="settings-research-source-grid">
<div>
<label htmlFor="research-project-source-webSearch" className="checkbox-label">
<input id="research-project-source-webSearch" type="checkbox" checked disabled readOnly />
Web Search
</label>
<div className="settings-field-help">
Always on. The resolver ignores any older persisted <code>enabledSources.webSearch=false</code> value.
</div>
</div>
{[
["webSearch", "Web Search"],
["pageFetch", "Page Fetch"],
["github", "GitHub"],
["localDocs", "Local Docs"],

View File

@@ -134,19 +134,14 @@ describe("ResearchView", () => {
expect(await screen.findByTestId("research-state-empty")).toBeInTheDocument();
});
it("shows friendly web-search disabled state when provider is none", async () => {
const onOpenSettings = vi.fn();
mockFetchSettings.mockResolvedValue({ researchGlobalWebSearchProvider: "none" });
it("does not render a web-search disabled state when the provider setting is absent", async () => {
mockFetchSettings.mockResolvedValue({ researchSettings: { enabled: true } });
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
render(<ResearchView projectId="p1" onOpenSettings={onOpenSettings} />);
render(<ResearchView projectId="p1" />);
expect(await screen.findByTestId("research-state-web-search-disabled")).toHaveTextContent(
"Web search is disabled. Page Fetch, Local Docs, GitHub and LLM Synthesis still work.",
);
expect(screen.getByRole("checkbox", { name: "Web Search" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Re-enable Web Search" }));
expect(onOpenSettings).toHaveBeenCalledWith("research-global");
await screen.findByLabelText("Query");
expect(screen.queryByTestId("research-state-web-search-disabled")).not.toBeInTheDocument();
});
it("renders selected run details, citations, and history", async () => {
@@ -389,19 +384,19 @@ describe("ResearchView", () => {
});
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
render(<ResearchView projectId="p1" />);
expect(await screen.findByText("Web Search")).toBeInTheDocument();
expect(await screen.findByText("Web Search (always on)")).toBeInTheDocument();
expect(screen.getByText("Page Fetch")).toBeInTheDocument();
expect(screen.getByText("LLM Synthesis")).toBeInTheDocument();
});
it("disables provider checkboxes when sources are disabled in settings", async () => {
it("keeps web search visibly locked on when other sources are disabled in settings", async () => {
mockFetchSettings.mockResolvedValue({
...configuredResearchSettings,
researchGlobalDefaults: {
...configuredResearchSettings.researchGlobalDefaults,
enabledSources: {
webSearch: false,
pageFetch: true,
pageFetch: false,
github: false,
localDocs: true,
llmSynthesis: true,
@@ -417,13 +412,14 @@ describe("ResearchView", () => {
render(<ResearchView projectId="p1" />);
const webSearch = (await screen.findByLabelText("Web Search")) as HTMLInputElement;
const webSearch = (await screen.findByLabelText("Web Search (always on)")) as HTMLInputElement;
const pageFetch = screen.getByLabelText("Page Fetch") as HTMLInputElement;
expect(webSearch.disabled).toBe(true);
expect(pageFetch.disabled).toBe(false);
expect(webSearch.checked).toBe(true);
expect(pageFetch.disabled).toBe(true);
});
it("submits only enabled providers", async () => {
it("submits only enabled providers while always including web search", async () => {
const createRun = vi.fn().mockResolvedValue({ run: { id: "RR-2" } });
mockFetchSettings.mockResolvedValue({
...configuredResearchSettings,
@@ -450,7 +446,7 @@ describe("ResearchView", () => {
fireEvent.click(screen.getByText("Create Run"));
await waitFor(() => {
expect(createRun).toHaveBeenCalledWith(expect.objectContaining({ providers: ["page-fetch", "llm-synthesis"] }));
expect(createRun).toHaveBeenCalledWith(expect.objectContaining({ providers: ["web-search", "page-fetch", "llm-synthesis"] }));
});
});

View File

@@ -3411,6 +3411,17 @@ describe("SettingsModal", () => {
);
});
it("shows web search as always on in project research settings", async () => {
renderModal();
await waitForSettingsModalReady();
await openResearchProjectSection();
const webSearch = await screen.findByLabelText("Web Search");
expect(webSearch).toBeChecked();
expect(webSearch).toBeDisabled();
expect(screen.getByText(/Always on\./i)).toBeInTheDocument();
});
it("saves project research settings through updateSettings only", async () => {
renderModal();
await waitForSettingsModalReady();

View File

@@ -962,7 +962,7 @@ describe("createResearchTools", () => {
researchGlobalMaxConcurrentRuns: 2,
researchGlobalDefaultTimeout: 30_000,
researchGlobalMaxSynthesisRounds: 2,
researchGlobalWebSearchProvider: "none",
researchGlobalWebSearchProvider: "builtin",
researchSettings: { enabled: true },
};

View File

@@ -11,11 +11,6 @@ describe("ResearchProviderRegistry", () => {
expect(registry.isProviderAvailable("github")).toBe(false);
});
it("honors explicit none by disabling web-search", () => {
const registry = new ResearchProviderRegistry({ researchGlobalWebSearchProvider: "none" }, process.cwd());
expect(registry.isProviderAvailable("web-search")).toBe(false);
});
it("requires credentials for explicit external providers", () => {
const registry = new ResearchProviderRegistry({ researchGlobalWebSearchProvider: "tavily" }, process.cwd());
expect(registry.isProviderAvailable("web-search")).toBe(false);
@@ -37,7 +32,7 @@ describe("ResearchProviderRegistry", () => {
});
it("refreshes providers after settings changes", () => {
const registry = new ResearchProviderRegistry({ researchGlobalWebSearchProvider: "none" }, process.cwd());
const registry = new ResearchProviderRegistry({ researchGlobalWebSearchProvider: "tavily" }, process.cwd());
expect(registry.isProviderAvailable("web-search")).toBe(false);
registry.refreshSettings({ researchGlobalWebSearchProvider: "tavily", researchGlobalTavilyApiKey: "key" });

View File

@@ -28,7 +28,6 @@ describe("WebSearchProvider", () => {
it("validates configuration for each backend", () => {
expect(new WebSearchProvider({ backend: "builtin", projectRoot: process.cwd() }).isConfigured()).toBe(true);
expect(new WebSearchProvider({ backend: "none" }).isConfigured()).toBe(false);
expect(new WebSearchProvider({ backend: "searxng", searxngUrl: "https://sx" }).isConfigured()).toBe(true);
expect(new WebSearchProvider({ backend: "brave", braveApiKey: "k" }).isConfigured()).toBe(true);
expect(new WebSearchProvider({ backend: "google", googleApiKey: "k", googleCx: "cx" }).isConfigured()).toBe(true);

View File

@@ -35,7 +35,6 @@ export class WebSearchProvider implements ResearchProvider {
isConfigured(): boolean {
const backend = this.options.backend ?? "builtin";
if (backend === "builtin") return true;
if (backend === "none") return false;
if (backend === "searxng") return Boolean(this.options.searxngUrl);
if (backend === "brave") return Boolean(this.options.braveApiKey);
if (backend === "google") return Boolean(this.options.googleApiKey && this.options.googleCx);