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

@@ -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);