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 ### 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 ### 3. Optional: external search backends
@@ -66,7 +66,6 @@ You can opt into external providers in global settings:
| `"brave"` | `researchGlobalBraveApiKey` — Brave Search API key | | `"brave"` | `researchGlobalBraveApiKey` — Brave Search API key |
| `"google"` | `researchGlobalGoogleSearchApiKey` + `researchGlobalGoogleSearchCx` — Google Custom Search credentials | | `"google"` | `researchGlobalGoogleSearchApiKey` + `researchGlobalGoogleSearchCx` — Google Custom Search credentials |
| `"tavily"` | `researchGlobalTavilyApiKey` — Tavily API key | | `"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. 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) 2. Web search is available (`"builtin"` by default, or an explicitly configured external backend)
3. Required API keys are present for external providers 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 ### Best practices for agents
@@ -437,7 +436,7 @@ When all retries are exhausted, the run transitions to `retry_exhausted`.
| Symptom | Cause | Resolution | | Symptom | Cause | Resolution |
|---|---|---| |---|---|---|
| "Research is disabled in settings" | `researchGlobalEnabled` or `researchSettings.enabled` is `false` | Enable in Settings → Research | | "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 | | "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 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 | | 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). | | `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. | | `researchGlobalMaxSourcesPerRun` | `number` | `20` | Maximum number of sources per research run. |
| `researchGlobalMaxSynthesisRounds` | `number` | `2` | Maximum synthesis rounds 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"`). | | `researchGlobalSearxngUrl` | `string` | `undefined` | SearXNG instance URL (required when provider is `"searxng"`). |
| `researchGlobalBraveApiKey` | `string` | `undefined` | Brave Search API key (required when provider is `"brave"`). | | `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"`). | | `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 { function hasProviderCredentials(settings: Awaited<ReturnType<TaskStore["getSettings"]>>, providerId: string | undefined): boolean {
if (!providerId || providerId === "builtin") return true; if (!providerId || providerId === "builtin") return true;
if (providerId === "none") return false;
if (providerId === "searxng") return Boolean(settings.researchGlobalSearxngUrl); if (providerId === "searxng") return Boolean(settings.researchGlobalSearxngUrl);
if (providerId === "brave") return Boolean(settings.researchGlobalBraveApiKey); if (providerId === "brave") return Boolean(settings.researchGlobalBraveApiKey);
if (providerId === "google") return Boolean(settings.researchGlobalGoogleSearchApiKey && settings.researchGlobalGoogleSearchCx); 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"; 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.`); 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 backend = (resolved.searchProvider as string | undefined) ?? settings.researchGlobalWebSearchProvider ?? "builtin";
const configured = backend === "builtin" const configured = backend === "builtin"
? true ? true
: backend === "none" : backend === "searxng"
? false
: backend === "searxng"
? Boolean(settings.researchGlobalSearxngUrl) ? Boolean(settings.researchGlobalSearxngUrl)
: backend === "brave" : backend === "brave"
? Boolean(settings.researchGlobalBraveApiKey) ? Boolean(settings.researchGlobalBraveApiKey)

View File

@@ -95,12 +95,25 @@ describe("resolveResearchSettings", () => {
expect(resolved.searchProvider).toBe("brave"); 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({ 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", () => { it("supports null-as-delete semantics for researchSettings object", () => {

View File

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

View File

@@ -1531,7 +1531,7 @@ export interface DaemonTokenSettings {
* The dashboard UI shows these under a "Global" section. * The dashboard UI shows these under a "Global" section.
*/ */
/** Web search backend for auto-research provider. */ /** 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 { export interface ResearchEnabledSources {
webSearch: boolean; webSearch: boolean;
@@ -1858,7 +1858,7 @@ export interface GlobalSettings {
/** Default maximum number of synthesis rounds per run. /** Default maximum number of synthesis rounds per run.
* Default: 2. */ * Default: 2. */
researchGlobalMaxSynthesisRounds?: number; 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; researchGlobalWebSearchProvider?: WebSearchBackend;
/** SearXNG instance URL (required when researchGlobalWebSearchProvider is "searxng"). */ /** SearXNG instance URL (required when researchGlobalWebSearchProvider is "searxng"). */
researchGlobalSearxngUrl?: string; researchGlobalSearxngUrl?: string;

View File

@@ -57,7 +57,6 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
} = useResearch({ projectId }); } = useResearch({ projectId });
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [effectiveSettings, setEffectiveSettings] = useState(() => resolveResearchSettings(undefined)); const [effectiveSettings, setEffectiveSettings] = useState(() => resolveResearchSettings(undefined));
const [rawSettings, setRawSettings] = useState<Partial<Settings>>({});
const [authProviders, setAuthProviders] = useState<Array<{ id: string; authenticated: boolean }>>([]); const [authProviders, setAuthProviders] = useState<Array<{ id: string; authenticated: boolean }>>([]);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [selectedProviders, setSelectedProviders] = useState<ResearchProviderOption[]>([]); const [selectedProviders, setSelectedProviders] = useState<ResearchProviderOption[]>([]);
@@ -67,8 +66,8 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
const providerOptions = availability.supportedProviders ?? DEFAULT_PROVIDERS; const providerOptions = availability.supportedProviders ?? DEFAULT_PROVIDERS;
const selectedSearchProvider = effectiveSettings.searchProvider; const selectedSearchProvider = effectiveSettings.searchProvider;
const isProviderEnabled = (provider: ResearchProviderOption) => { const isProviderEnabled = (provider: ResearchProviderOption) => {
if (provider === "web-search" && selectedSearchProvider === "none") { if (provider === "web-search") {
return false; return true;
} }
return effectiveSettings.enabledSources[PROVIDER_TO_SOURCE_KEY[provider]]; return effectiveSettings.enabledSources[PROVIDER_TO_SOURCE_KEY[provider]];
}; };
@@ -92,7 +91,6 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
]) ])
.then(([settings, authStatus]) => { .then(([settings, authStatus]) => {
if (cancelled) return; if (cancelled) return;
setRawSettings(settings);
setEffectiveSettings(resolveResearchSettings(settings)); setEffectiveSettings(resolveResearchSettings(settings));
setAuthProviders( setAuthProviders(
authStatus.providers authStatus.providers
@@ -127,7 +125,6 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
const supportedExportFormats = availability.supportedExportFormats ?? ["markdown", "json", "html"]; 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 apiKeyProviderAuth = useMemo(() => new Map(authProviders.map((provider) => [provider.id, provider.authenticated])), [authProviders]);
const requiredCredentialProviders = useMemo(() => { const requiredCredentialProviders = useMemo(() => {
const required = new Set<string>(); const required = new Set<string>();
@@ -156,9 +153,6 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
settingsSection: "research-project" as SectionId, settingsSection: "research-project" as SectionId,
}; };
} }
if (webSearchExplicitlyDisabled) {
return null;
}
if (missingCredentialProvider) { if (missingCredentialProvider) {
return { return {
reason: `Missing API key for ${missingCredentialProvider}.`, reason: `Missing API key for ${missingCredentialProvider}.`,
@@ -167,7 +161,7 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
}; };
} }
return null; 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) => { const runAction = async (key: string, action: () => Promise<unknown>, successMessage: string) => {
setActionLoading(key); setActionLoading(key);
@@ -256,16 +250,6 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
</div> </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"> <div className="research-view__layout">
<aside className="research-view__sidebar card"> <aside className="research-view__sidebar card">
<div className="research-view__form"> <div className="research-view__form">
@@ -276,24 +260,28 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
<div className="form-group"> <div className="form-group">
<label>Providers</label> <label>Providers</label>
<div className="research-view__providers"> <div className="research-view__providers">
{providerOptions.map((provider) => ( {providerOptions.map((provider) => {
<label key={provider} className="checkbox-label"> const providerEnabled = isProviderEnabled(provider);
<input const providerLocked = provider === "web-search";
type="checkbox" return (
checked={selectedProviders.includes(provider)} <label key={provider} className="checkbox-label">
disabled={!isProviderEnabled(provider)} <input
onChange={() => { type="checkbox"
if (!isProviderEnabled(provider)) { checked={providerLocked || selectedProviders.includes(provider)}
return; disabled={providerLocked || !providerEnabled}
} onChange={() => {
setSelectedProviders((current) => if (providerLocked || !providerEnabled) {
current.includes(provider) ? current.filter((entry) => entry !== provider) : [...current, provider], return;
); }
}} setSelectedProviders((current) =>
/> current.includes(provider) ? current.filter((entry) => entry !== provider) : [...current, provider],
<span>{PROVIDER_LABELS[provider] ?? provider}</span> );
</label> }}
))} />
<span>{PROVIDER_LABELS[provider] ?? provider}{providerLocked ? " (always on)" : ""}</span>
</label>
);
})}
</div> </div>
</div> </div>
<button className="btn btn-primary" type="button" disabled={!query.trim() || submitting} onClick={() => void handleCreateRun()}> <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 === "searxng" ||
resolvedProvider === "brave" || resolvedProvider === "brave" ||
resolvedProvider === "google" || resolvedProvider === "google" ||
resolvedProvider === "tavily" || resolvedProvider === "tavily";
resolvedProvider === "none";
const selectedCredentialProvider = const selectedCredentialProvider =
resolvedProvider === "brave" || resolvedProvider === "tavily" ? resolvedProvider : null; resolvedProvider === "brave" || resolvedProvider === "tavily" ? resolvedProvider : null;
const hasMissingResearchCredential = selectedCredentialProvider const hasMissingResearchCredential = selectedCredentialProvider
@@ -4352,7 +4351,6 @@ export function SettingsModal({
<option value="brave">Brave</option> <option value="brave">Brave</option>
<option value="google">Google Custom Search</option> <option value="google">Google Custom Search</option>
<option value="tavily">Tavily</option> <option value="tavily">Tavily</option>
<option value="none">None (disable web search)</option>
</select> </select>
</div> </div>
<div className="form-group"> <div className="form-group">
@@ -4451,8 +4449,16 @@ export function SettingsModal({
<div className="form-group"> <div className="form-group">
<label>Enabled Sources</label> <label>Enabled Sources</label>
<div className="settings-research-source-grid"> <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"], ["pageFetch", "Page Fetch"],
["github", "GitHub"], ["github", "GitHub"],
["localDocs", "Local Docs"], ["localDocs", "Local Docs"],

View File

@@ -134,19 +134,14 @@ describe("ResearchView", () => {
expect(await screen.findByTestId("research-state-empty")).toBeInTheDocument(); expect(await screen.findByTestId("research-state-empty")).toBeInTheDocument();
}); });
it("shows friendly web-search disabled state when provider is none", async () => { it("does not render a web-search disabled state when the provider setting is absent", async () => {
const onOpenSettings = vi.fn(); mockFetchSettings.mockResolvedValue({ researchSettings: { enabled: true } });
mockFetchSettings.mockResolvedValue({ researchGlobalWebSearchProvider: "none" });
mockFetchAuthStatus.mockResolvedValue({ providers: [] }); mockFetchAuthStatus.mockResolvedValue({ providers: [] });
render(<ResearchView projectId="p1" onOpenSettings={onOpenSettings} />); render(<ResearchView projectId="p1" />);
expect(await screen.findByTestId("research-state-web-search-disabled")).toHaveTextContent( await screen.findByLabelText("Query");
"Web search is disabled. Page Fetch, Local Docs, GitHub and LLM Synthesis still work.", expect(screen.queryByTestId("research-state-web-search-disabled")).not.toBeInTheDocument();
);
expect(screen.getByRole("checkbox", { name: "Web Search" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Re-enable Web Search" }));
expect(onOpenSettings).toHaveBeenCalledWith("research-global");
}); });
it("renders selected run details, citations, and history", async () => { 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 }] }); mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
render(<ResearchView projectId="p1" />); 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("Page Fetch")).toBeInTheDocument();
expect(screen.getByText("LLM Synthesis")).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({ mockFetchSettings.mockResolvedValue({
...configuredResearchSettings, ...configuredResearchSettings,
researchGlobalDefaults: { researchGlobalDefaults: {
...configuredResearchSettings.researchGlobalDefaults, ...configuredResearchSettings.researchGlobalDefaults,
enabledSources: { enabledSources: {
webSearch: false, webSearch: false,
pageFetch: true, pageFetch: false,
github: false, github: false,
localDocs: true, localDocs: true,
llmSynthesis: true, llmSynthesis: true,
@@ -417,13 +412,14 @@ describe("ResearchView", () => {
render(<ResearchView projectId="p1" />); 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; const pageFetch = screen.getByLabelText("Page Fetch") as HTMLInputElement;
expect(webSearch.disabled).toBe(true); 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" } }); const createRun = vi.fn().mockResolvedValue({ run: { id: "RR-2" } });
mockFetchSettings.mockResolvedValue({ mockFetchSettings.mockResolvedValue({
...configuredResearchSettings, ...configuredResearchSettings,
@@ -450,7 +446,7 @@ describe("ResearchView", () => {
fireEvent.click(screen.getByText("Create Run")); fireEvent.click(screen.getByText("Create Run"));
await waitFor(() => { 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 () => { it("saves project research settings through updateSettings only", async () => {
renderModal(); renderModal();
await waitForSettingsModalReady(); await waitForSettingsModalReady();

View File

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

View File

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

View File

@@ -28,7 +28,6 @@ describe("WebSearchProvider", () => {
it("validates configuration for each backend", () => { it("validates configuration for each backend", () => {
expect(new WebSearchProvider({ backend: "builtin", projectRoot: process.cwd() }).isConfigured()).toBe(true); 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: "searxng", searxngUrl: "https://sx" }).isConfigured()).toBe(true);
expect(new WebSearchProvider({ backend: "brave", braveApiKey: "k" }).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); 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 { isConfigured(): boolean {
const backend = this.options.backend ?? "builtin"; const backend = this.options.backend ?? "builtin";
if (backend === "builtin") return true; if (backend === "builtin") return true;
if (backend === "none") return false;
if (backend === "searxng") return Boolean(this.options.searxngUrl); if (backend === "searxng") return Boolean(this.options.searxngUrl);
if (backend === "brave") return Boolean(this.options.braveApiKey); if (backend === "brave") return Boolean(this.options.braveApiKey);
if (backend === "google") return Boolean(this.options.googleApiKey && this.options.googleCx); if (backend === "google") return Boolean(this.options.googleApiKey && this.options.googleCx);