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:
5
.changeset/fn-4135-always-on-web-search.md
Normal file
5
.changeset/fn-4135-always-on-web-search.md
Normal 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.
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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"`). |
|
||||||
|
|||||||
@@ -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.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 ??
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()}>
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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"] }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user