feat(FN-2995): export research settings resolver for dashboard alias builds

Exports the research settings resolver from `@fusion/core` types to fix dashboard alias builds, ensuring the resolver is available when imported through the dashboard's module alias configuration.

Fusion-Task-Id: FN-2995
This commit is contained in:
Fusion
2026-04-30 18:46:37 -07:00
committed by gsxdsm
parent 718fd29251
commit 1b6ee247f7
18 changed files with 1016 additions and 21 deletions

View File

@@ -101,6 +101,17 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(providerIds).not.toContain("pi-claude-cli");
});
it("includes research-only API-key providers", () => {
const fusionAuth = makeAuthStorage();
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
const providerIds = wrapped.getApiKeyProviders().map((provider) => provider.id);
expect(providerIds).toContain("brave");
expect(providerIds).toContain("tavily");
});
it("reads legacy auth JSON without creating missing files", async () => {
const tempDir = tempWorkspace("fusion-provider-auth-");
const legacyAgentDir = join(tempDir, ".pi", "agent");

View File

@@ -41,9 +41,11 @@ type StoredCredential = {
};
const BUILT_IN_API_KEY_PROVIDERS: Array<{ id: string; name: string }> = [
{ id: "brave", name: "Brave Search" },
{ id: "kimi-coding", name: "Kimi" },
{ id: "minimax", name: "Minimax" },
{ id: "openrouter", name: "OpenRouter" },
{ id: "tavily", name: "Tavily" },
{ id: "zai", name: "Zai" },
];

View File

@@ -333,6 +333,45 @@ describe("GlobalSettingsStore", () => {
const settings = await store.getSettings();
expect(settings.ntfyTopic).toMatch(/^topic-\d$/);
});
it("persists and clears nested researchGlobalDefaults with null-as-delete semantics", async () => {
await store.init();
await store.updateSettings({
researchGlobalDefaults: {
searchProvider: "tavily",
enabledSources: {
webSearch: true,
pageFetch: true,
github: false,
localDocs: true,
llmSynthesis: true,
},
maxSourcesPerRun: 15,
defaultExportFormat: "json",
},
});
let settings = await store.getSettings();
expect(settings.researchGlobalDefaults?.searchProvider).toBe("tavily");
// @ts-expect-error null is intentionally used to clear field
await store.updateSettings({ researchGlobalDefaults: null });
settings = await store.getSettings();
expect(settings.researchGlobalDefaults).toMatchObject({
searchProvider: undefined,
synthesisProvider: undefined,
synthesisModelId: undefined,
maxSourcesPerRun: 20,
defaultExportFormat: "markdown",
});
expect(settings.researchGlobalDefaults?.enabledSources).toEqual({
webSearch: true,
pageFetch: true,
github: false,
localDocs: true,
llmSynthesis: true,
});
});
});
describe("schema protection", () => {

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { resolveResearchSettings } from "../research-settings.js";
import type { Settings } from "../types.js";
describe("resolveResearchSettings", () => {
it("resolves global-only defaults", () => {
const resolved = resolveResearchSettings({
researchGlobalDefaults: {
searchProvider: "tavily",
synthesisProvider: "anthropic",
synthesisModelId: "claude-sonnet-4-5",
enabledSources: {
webSearch: true,
pageFetch: true,
github: false,
localDocs: true,
llmSynthesis: true,
},
maxSourcesPerRun: 12,
defaultExportFormat: "json",
},
researchGlobalEnabled: true,
});
expect(resolved.searchProvider).toBe("tavily");
expect(resolved.synthesisProvider).toBe("anthropic");
expect(resolved.synthesisModelId).toBe("claude-sonnet-4-5");
expect(resolved.enabled).toBe(true);
expect(resolved.enabledSources.github).toBe(false);
expect(resolved.limits.maxSourcesPerRun).toBe(12);
expect(resolved.defaultExportFormat).toBe("json");
});
it("project values override global values", () => {
const resolved = resolveResearchSettings({
researchGlobalDefaults: { searchProvider: "brave", maxSourcesPerRun: 10 },
researchSettings: {
enabled: false,
searchProvider: "tavily",
limits: { maxSourcesPerRun: 3, maxConcurrentRuns: 1, maxDurationMs: 1000, requestTimeoutMs: 500 },
},
});
expect(resolved.enabled).toBe(false);
expect(resolved.searchProvider).toBe("tavily");
expect(resolved.limits.maxSourcesPerRun).toBe(3);
expect(resolved.limits.maxConcurrentRuns).toBe(1);
});
it("partial nested project overrides preserve remaining global defaults", () => {
const resolved = resolveResearchSettings({
researchGlobalDefaults: {
enabledSources: {
webSearch: true,
pageFetch: true,
github: true,
localDocs: true,
llmSynthesis: true,
},
},
researchSettings: {
enabledSources: { github: false },
},
});
expect(resolved.enabledSources.github).toBe(false);
expect(resolved.enabledSources.webSearch).toBe(true);
expect(resolved.enabledSources.pageFetch).toBe(true);
});
it("clearing project override falls back to global/default", () => {
const resolved = resolveResearchSettings({
researchGlobalDefaults: {
searchProvider: "brave",
},
researchSettings: {
searchProvider: undefined,
},
});
expect(resolved.searchProvider).toBe("brave");
});
it("supports null-as-delete semantics for researchSettings object", () => {
const resolved = resolveResearchSettings({
researchGlobalDefaults: {
searchProvider: "tavily",
},
researchSettings: null as unknown as Settings["researchSettings"],
});
expect(resolved.searchProvider).toBe("tavily");
expect(resolved.enabled).toBe(true);
});
it("falls back through legacy and hardcoded limit chains", () => {
const fromLegacyProjectTimeout = resolveResearchSettings({
researchDefaultTimeout: 111_000,
researchFetchTimeoutMs: 8_000,
});
expect(fromLegacyProjectTimeout.limits.maxDurationMs).toBe(111_000);
expect(fromLegacyProjectTimeout.limits.requestTimeoutMs).toBe(8_000);
const fromLegacyGlobalTimeout = resolveResearchSettings({
researchGlobalDefaultTimeout: 222_000,
researchGlobalMaxConcurrentRuns: 9,
});
expect(fromLegacyGlobalTimeout.limits.maxDurationMs).toBe(222_000);
expect(fromLegacyGlobalTimeout.limits.maxConcurrentRuns).toBe(9);
const hardcodedFallback = resolveResearchSettings({});
expect(hardcodedFallback.limits.maxDurationMs).toBe(300000);
expect(hardcodedFallback.limits.requestTimeoutMs).toBe(30000);
expect(hardcodedFallback.defaultExportFormat).toBe("markdown");
});
});

View File

@@ -51,8 +51,11 @@ describe("settings key parity", () => {
expect(isProjectSettingsKey("maxConcurrent")).toBe(true);
expect(isProjectSettingsKey("heartbeatMultiplier")).toBe(true);
expect(isProjectSettingsKey("remoteAccess")).toBe(true);
expect(isProjectSettingsKey("researchSettings")).toBe(true);
expect(isGlobalSettingsKey("researchGlobalDefaults")).toBe(true);
expect(isProjectSettingsKey("themeMode")).toBe(false);
expect(isGlobalSettingsKey("remoteAccess")).toBe(false);
expect(isGlobalSettingsKey("researchSettings")).toBe(false);
});
it("includes heartbeatMultiplier in project defaults", () => {

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,
@@ -620,6 +620,9 @@ export type {
ResearchCancellationState,
} from "./research-types.js";
export { resolveResearchSettings } from "./research-settings.js";
export type { ResolvedResearchSettings } from "./research-settings.js";
export { TodoStore } from "./todo-store.js";
export type { TodoStoreEvents } from "./todo-store.js";

View File

@@ -0,0 +1,65 @@
import type { ResearchEnabledSources, Settings } from "./types.js";
export interface ResolvedResearchSettings {
enabled: boolean;
searchProvider?: string;
synthesisProvider?: string;
synthesisModelId?: string;
enabledSources: ResearchEnabledSources;
limits: {
maxConcurrentRuns: number;
maxSourcesPerRun: number;
maxDurationMs: number;
requestTimeoutMs: number;
};
defaultExportFormat: "markdown" | "json";
}
const FALLBACK_SOURCES: ResearchEnabledSources = {
webSearch: true,
pageFetch: true,
github: false,
localDocs: true,
llmSynthesis: true,
};
export function resolveResearchSettings(settings: Partial<Settings> | undefined): ResolvedResearchSettings {
const globalDefaults = settings?.researchGlobalDefaults;
const projectSettings = settings?.researchSettings;
return {
enabled: projectSettings?.enabled ?? settings?.researchEnabled ?? settings?.researchGlobalEnabled ?? true,
searchProvider: projectSettings?.searchProvider ?? globalDefaults?.searchProvider,
synthesisProvider: projectSettings?.synthesisProvider ?? globalDefaults?.synthesisProvider,
synthesisModelId: projectSettings?.synthesisModelId ?? globalDefaults?.synthesisModelId,
enabledSources: {
webSearch:
projectSettings?.enabledSources?.webSearch ??
globalDefaults?.enabledSources?.webSearch ??
FALLBACK_SOURCES.webSearch,
pageFetch:
projectSettings?.enabledSources?.pageFetch ??
globalDefaults?.enabledSources?.pageFetch ??
FALLBACK_SOURCES.pageFetch,
github:
projectSettings?.enabledSources?.github ??
globalDefaults?.enabledSources?.github ??
FALLBACK_SOURCES.github,
localDocs:
projectSettings?.enabledSources?.localDocs ??
globalDefaults?.enabledSources?.localDocs ??
FALLBACK_SOURCES.localDocs,
llmSynthesis:
projectSettings?.enabledSources?.llmSynthesis ??
globalDefaults?.enabledSources?.llmSynthesis ??
FALLBACK_SOURCES.llmSynthesis,
},
limits: {
maxConcurrentRuns: projectSettings?.limits?.maxConcurrentRuns ?? settings?.researchMaxConcurrentRuns ?? settings?.researchGlobalMaxConcurrentRuns ?? 3,
maxSourcesPerRun: projectSettings?.limits?.maxSourcesPerRun ?? globalDefaults?.maxSourcesPerRun ?? settings?.researchMaxSourcesPerRun ?? settings?.researchGlobalMaxSourcesPerRun ?? 20,
maxDurationMs: projectSettings?.limits?.maxDurationMs ?? settings?.researchDefaultTimeout ?? settings?.researchGlobalDefaultTimeout ?? 300000,
requestTimeoutMs: projectSettings?.limits?.requestTimeoutMs ?? settings?.researchFetchTimeoutMs ?? 30000,
},
defaultExportFormat: globalDefaults?.defaultExportFormat ?? "markdown",
};
}

View File

@@ -64,6 +64,20 @@ export const DEFAULT_GLOBAL_SETTINGS = {
// Dashboard TUI memory guard
vitestAutoKillEnabled: true,
vitestKillThresholdPct: 90,
researchGlobalDefaults: {
searchProvider: undefined,
synthesisProvider: undefined,
synthesisModelId: undefined,
enabledSources: {
webSearch: true,
pageFetch: true,
github: false,
localDocs: true,
llmSynthesis: true,
},
maxSourcesPerRun: 20,
defaultExportFormat: "markdown",
},
researchGlobalEnabled: true,
researchGlobalMaxConcurrentRuns: 3,
researchGlobalDefaultTimeout: 300000,
@@ -228,6 +242,25 @@ export const DEFAULT_PROJECT_SETTINGS = {
reflectionAfterTask: true,
reviewHandoffPolicy: "disabled",
showQuickChatFAB: false,
researchSettings: {
enabled: true,
searchProvider: undefined,
synthesisProvider: undefined,
synthesisModelId: undefined,
enabledSources: {
webSearch: true,
pageFetch: true,
github: false,
localDocs: true,
llmSynthesis: true,
},
limits: {
maxConcurrentRuns: 3,
maxSourcesPerRun: 20,
maxDurationMs: 300000,
requestTimeoutMs: 30000,
},
},
researchEnabled: true,
researchMaxConcurrentRuns: 3,
researchDefaultTimeout: 300000,

View File

@@ -1190,6 +1190,39 @@ export interface DaemonTokenSettings {
/** Web search backend for auto-research provider. */
export type WebSearchBackend = "searxng" | "brave" | "google" | "tavily" | "none";
export interface ResearchEnabledSources {
webSearch: boolean;
pageFetch: boolean;
github: boolean;
localDocs: boolean;
llmSynthesis: boolean;
}
export interface ResearchGlobalDefaults {
searchProvider?: string;
synthesisProvider?: string;
synthesisModelId?: string;
enabledSources?: ResearchEnabledSources;
maxSourcesPerRun?: number;
defaultExportFormat?: "markdown" | "json";
}
export interface ResearchProjectLimits {
maxConcurrentRuns?: number;
maxSourcesPerRun?: number;
maxDurationMs?: number;
requestTimeoutMs?: number;
}
export interface ResearchProjectSettings {
enabled?: boolean;
searchProvider?: string;
synthesisProvider?: string;
synthesisModelId?: string;
enabledSources?: Partial<ResearchEnabledSources>;
limits?: ResearchProjectLimits;
}
export interface GlobalSettings {
/** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */
themeMode?: ThemeMode;
@@ -1392,6 +1425,9 @@ export interface GlobalSettings {
* triggers a vitest auto-kill. Clamped to [50, 99] in the UI.
* Default: 90. */
vitestKillThresholdPct?: number;
/** Research defaults shared across all projects.
* Project settings may override these via `researchSettings`. */
researchGlobalDefaults?: ResearchGlobalDefaults;
/** Enable or disable the research subsystem globally.
* When false, dashboard/API entrypoints should reject new research runs.
* Default: true when research store exists. */
@@ -1555,8 +1591,11 @@ export interface ProjectSettings {
* - "block": prevent execution until the selected node is healthy/available (default)
* - "fallback-local": run on the local node when the selected node is unavailable */
unavailableNodePolicy?: UnavailableNodePolicy;
/** Project-level research configuration overrides. */
researchSettings?: ResearchProjectSettings;
/** Enable or disable the research subsystem for this project.
* When undefined, falls back to global settings. */
* When undefined, falls back to global settings.
* @deprecated Prefer researchSettings.enabled */
researchEnabled?: boolean;
/** Project-level maximum concurrent research runs.
* When undefined, falls back to global settings (default 3). */
@@ -3560,3 +3599,5 @@ export {
resolveValidatorSettingsModel,
} from "./model-resolution.js";
export type { ResolvedModelSelection } from "./model-resolution.js";
export { resolveResearchSettings } from "./research-settings.js";
export type { ResolvedResearchSettings } from "./research-settings.js";

View File

@@ -220,6 +220,7 @@ function AppInner() {
);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [researchReadinessVersion, setResearchReadinessVersion] = useState(0);
const mountTimeRef = useRef(performance.now());
const projectsReadyLoggedRef = useRef(false);
const projectReadyLoggedRef = useRef(false);
@@ -731,7 +732,12 @@ function AppInner() {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<ResearchView projectId={currentProject?.id} addToast={addToast} />
<ResearchView
projectId={currentProject?.id}
addToast={addToast}
onOpenSettings={(section) => modalManager.openSettings(section as SectionId)}
readinessVersion={researchReadinessVersion}
/>
</Suspense>
</PageErrorBoundary>
);
@@ -1043,6 +1049,7 @@ function AppInner() {
settings={{ prAuthAvailable, themeMode, colorTheme, setThemeMode, setColorTheme }}
onSettingsClose={() => {
modalManager.closeSettings();
setResearchReadinessVersion((current) => current + 1);
void refreshAppSettings();
}}
onReopenOnboarding={() => {

View File

@@ -1,16 +1,29 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { resolveResearchSettings, type Settings } from "@fusion/core";
import { Loader2, Search } from "lucide-react";
import { fetchAuthStatus, fetchSettings } from "../api";
import { useResearch } from "../hooks/useResearch";
import type { ResearchProviderOption } from "../research-types";
import type { SectionId } from "./SettingsModal";
import "./ResearchView.css";
interface ResearchViewProps {
projectId?: string;
addToast?: (message: string, type?: "success" | "error" | "info") => void;
onOpenSettings?: (section?: SectionId) => void;
readinessVersion?: number;
}
const DEFAULT_PROVIDERS: ResearchProviderOption[] = ["web-search", "page-fetch", "github", "local-docs", "llm-synthesis"];
const PROVIDER_TO_SOURCE_KEY: Record<ResearchProviderOption, keyof ReturnType<typeof resolveResearchSettings>["enabledSources"]> = {
"web-search": "webSearch",
"page-fetch": "pageFetch",
github: "github",
"local-docs": "localDocs",
"llm-synthesis": "llmSynthesis",
};
const PROVIDER_LABELS: Record<ResearchProviderOption, string> = {
"web-search": "Web Search",
"page-fetch": "Page Fetch",
@@ -19,7 +32,7 @@ const PROVIDER_LABELS: Record<ResearchProviderOption, string> = {
"llm-synthesis": "LLM Synthesis",
};
export function ResearchView({ projectId, addToast }: ResearchViewProps) {
export function ResearchView({ projectId, addToast, onOpenSettings, readinessVersion = 0 }: ResearchViewProps) {
const {
runs,
selectedRun,
@@ -40,12 +53,52 @@ export function ResearchView({ projectId, addToast }: ResearchViewProps) {
refresh,
} = useResearch({ projectId });
const [query, setQuery] = useState("");
const [effectiveSettings, setEffectiveSettings] = useState(() => resolveResearchSettings(undefined));
const [authProviders, setAuthProviders] = useState<Array<{ id: string; authenticated: boolean }>>([]);
const [submitting, setSubmitting] = useState(false);
const [selectedProviders, setSelectedProviders] = useState<ResearchProviderOption[]>(["web-search", "llm-synthesis"]);
const [selectedProviders, setSelectedProviders] = useState<ResearchProviderOption[]>([]);
const [taskIdToAttach, setTaskIdToAttach] = useState("");
const [actionLoading, setActionLoading] = useState<string | null>(null);
const providerOptions = availability.supportedProviders ?? DEFAULT_PROVIDERS;
const isProviderEnabled = (provider: ResearchProviderOption) => effectiveSettings.enabledSources[PROVIDER_TO_SOURCE_KEY[provider]];
useEffect(() => {
const enabledProviders = providerOptions.filter((provider) => isProviderEnabled(provider));
setSelectedProviders((current) => {
const currentEnabled = current.filter((provider) => enabledProviders.includes(provider));
if (currentEnabled.length > 0) {
return currentEnabled;
}
return enabledProviders;
});
}, [effectiveSettings.enabledSources, providerOptions]);
useEffect(() => {
let cancelled = false;
Promise.all([
fetchSettings(projectId) as Promise<Partial<Settings>>,
fetchAuthStatus().catch(() => ({ providers: [] })),
])
.then(([settings, authStatus]) => {
if (cancelled) return;
setEffectiveSettings(resolveResearchSettings(settings));
setAuthProviders(
authStatus.providers
.filter((provider) => provider.type === "api_key")
.map((provider) => ({ id: provider.id, authenticated: provider.authenticated })),
);
})
.catch(() => {
if (!cancelled) {
setEffectiveSettings(resolveResearchSettings(undefined));
}
});
return () => {
cancelled = true;
};
}, [projectId, readinessVersion]);
const statusLabel = useMemo(() => {
if (!selectedRun) return "No run selected";
@@ -63,6 +116,56 @@ export function ResearchView({ projectId, addToast }: ResearchViewProps) {
const supportedExportFormats = availability.supportedExportFormats ?? ["markdown", "json", "html"];
const selectedSearchProvider = effectiveSettings.searchProvider;
const needsSearchProvider = effectiveSettings.enabledSources.webSearch && !selectedSearchProvider;
const needsSynthesisModel =
effectiveSettings.enabledSources.llmSynthesis &&
(!effectiveSettings.synthesisProvider || !effectiveSettings.synthesisModelId);
const apiKeyProviderAuth = useMemo(() => new Map(authProviders.map((provider) => [provider.id, provider.authenticated])), [authProviders]);
const requiredCredentialProviders = useMemo(() => {
const required = new Set<string>();
if (effectiveSettings.enabledSources.webSearch && selectedSearchProvider) {
required.add(selectedSearchProvider);
}
if (effectiveSettings.enabledSources.llmSynthesis && effectiveSettings.synthesisProvider) {
required.add(effectiveSettings.synthesisProvider);
}
return [...required].filter((providerId) => apiKeyProviderAuth.has(providerId));
}, [effectiveSettings.enabledSources.llmSynthesis, effectiveSettings.enabledSources.webSearch, effectiveSettings.synthesisProvider, selectedSearchProvider, apiKeyProviderAuth]);
const missingCredentialProvider = requiredCredentialProviders.find((providerId) => apiKeyProviderAuth.get(providerId) !== true);
const setupState = useMemo(() => {
if (!availability.available) {
return {
reason: availability.reason ?? "Research is unavailable for this project.",
details: availability.setupInstructions,
settingsSection: "research-project" as SectionId,
};
}
if (!effectiveSettings.enabled) {
return {
reason: "Research is disabled for this project.",
details: "Enable project research settings to create runs.",
settingsSection: "research-project" as SectionId,
};
}
if (needsSearchProvider || needsSynthesisModel) {
return {
reason: "Research defaults are incomplete.",
details: "Select the required provider/model defaults in Research settings.",
settingsSection: "research-global" as SectionId,
};
}
if (missingCredentialProvider) {
return {
reason: `Missing API key for ${missingCredentialProvider}.`,
details: "Add provider credentials in Authentication settings.",
settingsSection: "authentication" as SectionId,
};
}
return null;
}, [availability.available, availability.reason, availability.setupInstructions, effectiveSettings.enabled, missingCredentialProvider, needsSearchProvider, needsSynthesisModel]);
const runAction = async (key: string, action: () => Promise<unknown>, successMessage: string) => {
setActionLoading(key);
try {
@@ -102,7 +205,12 @@ export function ResearchView({ projectId, addToast }: ResearchViewProps) {
if (!query.trim()) return;
setSubmitting(true);
try {
const response = await createRun({ query: query.trim(), providers: selectedProviders });
const providers = selectedProviders.filter((provider) => isProviderEnabled(provider));
if (providers.length === 0) {
addToast?.("No enabled research sources are available for this project.", "error");
return;
}
const response = await createRun({ query: query.trim(), providers });
setSelectedRunId(response.run.id);
setQuery("");
addToast?.("Research run created", "success");
@@ -126,10 +234,21 @@ export function ResearchView({ projectId, addToast }: ResearchViewProps) {
</button>
</header>
{!availability.available ? (
{setupState ? (
<div className="research-view__state research-view__state--error card" data-testid="research-state-unavailable">
<p>{availability.reason ?? "Research is unavailable for this project."}</p>
{availability.setupInstructions && <p>{availability.setupInstructions}</p>}
<p>{setupState.reason}</p>
{setupState.details && <p>{setupState.details}</p>}
<p>
Current defaults: provider {effectiveSettings.searchProvider ?? "(not set)"}, max sources {effectiveSettings.limits.maxSourcesPerRun}
</p>
<div className="research-view__actions">
<button className="btn" type="button" onClick={() => void refresh()}>
Refresh
</button>
<button className="btn btn-primary" type="button" onClick={() => onOpenSettings?.(setupState.settingsSection)}>
Open Settings
</button>
</div>
</div>
) : (
<div className="research-view__layout">
@@ -147,7 +266,11 @@ export function ResearchView({ projectId, addToast }: ResearchViewProps) {
<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],
);

View File

@@ -256,6 +256,18 @@
background: var(--text-muted);
}
.settings-research-source-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-sm);
}
@media (max-width: 768px) {
.settings-research-source-grid {
grid-template-columns: 1fr;
}
}
.settings-nav-item {
display: block;
width: 100%;

View File

@@ -180,6 +180,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
{ id: "notifications", label: "Notifications", scope: "global" },
{ id: "node-sync", label: "Node Sync", scope: "global" },
{ id: "global-models", label: "Models", scope: "global" },
{ id: "research-global", label: "Research Defaults", scope: "global" },
{ id: "updates", label: "Updates", scope: "global" },
// Runtimes group (plugin runtimes with their own settings)
@@ -198,6 +199,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
{ id: "commands", label: "Commands", scope: "project" },
{ id: "merge", label: "Merge", scope: "project" },
{ id: "memory", label: "Memory", scope: "project" },
{ id: "research-project", label: "Research", scope: "project" },
{ id: "experimental", label: "Experimental Features", scope: "project" },
{ id: "prompts", label: "Prompts", scope: "project" },
{ id: "backups", label: "Backups", scope: "project" },
@@ -241,6 +243,7 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
nodesView: "Nodes View",
devServerView: "Dev Server",
todoView: "Todo List",
researchView: "Research View",
};
const EXPERIMENTAL_FEATURE_LEGACY_ALIASES: Record<string, string> = {
@@ -374,6 +377,7 @@ export function SettingsModal({
const gitHubStarCount = useGitHubStarCount();
const [starClicked, markStarClicked] = useStarClickedFlag();
const [prefixError, setPrefixError] = useState<string | null>(null);
const [researchLimitError, setResearchLimitError] = useState<string | null>(null);
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null);
const {
@@ -396,8 +400,13 @@ export function SettingsModal({
useEffect(() => {
if (activeSection === "remote" && !remoteAccessEnabled) {
setActiveSection(firstVisibleSectionId);
return;
}
}, [activeSection, remoteAccessEnabled, firstVisibleSectionId]);
if (!visibleSections.some((section) => section.id === activeSection)) {
setActiveSection(firstVisibleSectionId);
}
}, [activeSection, remoteAccessEnabled, firstVisibleSectionId, visibleSections]);
// Auth state (independent of the settings save flow)
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
@@ -817,7 +826,7 @@ export function SettingsModal({
}, [activeSection, memoryDirty, selectedMemoryPath, projectId, addToast]);
useEffect(() => {
if (activeSection === "authentication") {
if (activeSection === "authentication" || activeSection === "research-global") {
setAuthLoading(true);
loadAuthStatus().finally(() => setAuthLoading(false));
}
@@ -1364,6 +1373,26 @@ export function SettingsModal({
const handleSave = useCallback(async () => {
if (prefixError || presetDraft) return;
const limits = form.researchSettings?.limits;
if (limits?.maxConcurrentRuns !== undefined && (!Number.isFinite(limits.maxConcurrentRuns) || limits.maxConcurrentRuns < 1)) {
setResearchLimitError("Research max concurrent runs must be at least 1.");
return;
}
if (limits?.maxSourcesPerRun !== undefined && (!Number.isFinite(limits.maxSourcesPerRun) || limits.maxSourcesPerRun < 1)) {
setResearchLimitError("Research max sources per run must be at least 1.");
return;
}
if (limits?.maxDurationMs !== undefined && (!Number.isFinite(limits.maxDurationMs) || limits.maxDurationMs < 1000)) {
setResearchLimitError("Research max duration must be at least 1000 ms.");
return;
}
if (limits?.requestTimeoutMs !== undefined && (!Number.isFinite(limits.requestTimeoutMs) || limits.requestTimeoutMs < 1000)) {
setResearchLimitError("Research request timeout must be at least 1000 ms.");
return;
}
setResearchLimitError(null);
try {
const payload = {
...form,
@@ -3589,6 +3618,213 @@ export function SettingsModal({
</>
);
}
case "research-global": {
const providerStatuses = authProviders.filter((provider) => provider.id === "brave" || provider.id === "tavily");
const hasMissingResearchCredential = providerStatuses.some((provider) => !provider.authenticated);
return (
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">Research Defaults</h4>
<div className="form-group">
<label htmlFor="research-global-search-provider">Default Search Provider</label>
<input
id="research-global-search-provider"
className="input"
value={form.researchGlobalDefaults?.searchProvider ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalDefaults: {
...(current.researchGlobalDefaults ?? {}),
searchProvider: event.target.value || undefined,
},
}))
}
placeholder="tavily"
/>
</div>
<div className="form-group">
<label htmlFor="research-global-max-sources">Default Max Sources Per Run</label>
<input
id="research-global-max-sources"
className="input"
type="number"
min={1}
value={form.researchGlobalDefaults?.maxSourcesPerRun ?? 20}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalDefaults: {
...(current.researchGlobalDefaults ?? {}),
maxSourcesPerRun: Number(event.target.value) || 1,
},
}))
}
/>
</div>
{hasMissingResearchCredential && (
<div className="settings-empty-state" role="alert">
Missing credentials for one or more research providers.
<button type="button" className="btn btn-sm" onClick={() => setActiveSection("authentication")}>
Open Authentication
</button>
</div>
)}
</>
);
}
case "research-project": {
const limits = form.researchSettings?.limits;
const sources = form.researchSettings?.enabledSources;
return (
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">Project Research Settings</h4>
<div className="form-group">
<label htmlFor="research-project-enabled" className="checkbox-label">
<input
id="research-project-enabled"
type="checkbox"
checked={form.researchSettings?.enabled ?? true}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
enabled: event.target.checked,
},
}))
}
/>
Enable research in this project
</label>
</div>
<div className="form-group">
<label>Enabled Sources</label>
<div className="settings-research-source-grid">
{[
["webSearch", "Web Search"],
["pageFetch", "Page Fetch"],
["github", "GitHub"],
["localDocs", "Local Docs"],
["llmSynthesis", "LLM Synthesis"],
].map(([key, label]) => (
<label key={key} htmlFor={`research-project-source-${key}`} className="checkbox-label">
<input
id={`research-project-source-${key}`}
type="checkbox"
checked={sources?.[key as keyof NonNullable<typeof sources>] ?? false}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
enabledSources: {
...(current.researchSettings?.enabledSources ?? {}),
[key]: event.target.checked,
},
},
}))
}
/>
{label}
</label>
))}
</div>
</div>
<div className="form-group">
<label htmlFor="research-project-max-concurrent">Max Concurrent Runs</label>
<input
id="research-project-max-concurrent"
className="input"
type="number"
min={1}
value={limits?.maxConcurrentRuns ?? 3}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
</div>
<div className="form-group">
<label htmlFor="research-project-max-sources">Max Sources Per Run</label>
<input
id="research-project-max-sources"
className="input"
type="number"
min={1}
value={limits?.maxSourcesPerRun ?? 20}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
</div>
<div className="form-group">
<label htmlFor="research-project-max-duration">Max Duration (ms)</label>
<input
id="research-project-max-duration"
className="input"
type="number"
min={1000}
value={limits?.maxDurationMs ?? 300000}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxDurationMs: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
</div>
<div className="form-group">
<label htmlFor="research-project-request-timeout">Request Timeout (ms)</label>
<input
id="research-project-request-timeout"
className="input"
type="number"
min={1000}
value={limits?.requestTimeoutMs ?? 30000}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
requestTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
{researchLimitError && <small className="field-error">{researchLimitError}</small>}
</div>
</>
);
}
case "experimental": {
const experimentalFeatures = form.experimentalFeatures ?? {};
// Merge known features (always shown) with custom features from settings,

View File

@@ -9,8 +9,23 @@ vi.mock("../../hooks/useResearch", () => ({
useResearch: (...args: unknown[]) => mockUseResearch(...args),
}));
const configuredResearchSettings = {
researchSettings: { enabled: true },
researchGlobalDefaults: {
searchProvider: "openrouter",
synthesisProvider: "openrouter",
synthesisModelId: "gpt-5",
maxSourcesPerRun: 20,
},
};
const mockFetchSettings = vi.fn().mockResolvedValue(configuredResearchSettings);
const mockFetchAuthStatus = vi.fn().mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: false }] });
vi.mock("../../api", () => ({
fetchScripts: vi.fn().mockResolvedValue({}),
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
}));
vi.mock("lucide-react", async (importOriginal) => {
@@ -85,15 +100,25 @@ describe("ResearchView", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchSettings.mockResolvedValue(configuredResearchSettings);
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: false }] });
mockUseResearch.mockReturnValue(baseHookValue);
});
it("renders empty state", () => {
it("shows authentication setup state when required credentials are missing", async () => {
mockFetchSettings.mockResolvedValue(configuredResearchSettings);
render(<ResearchView projectId="p1" />);
expect(screen.getByTestId("research-state-empty")).toBeInTheDocument();
expect(await screen.findByText(/Missing API key for openrouter/i)).toBeInTheDocument();
});
it("renders selected run details", () => {
it("renders empty state when required credentials are configured", async () => {
mockFetchSettings.mockResolvedValue(configuredResearchSettings);
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
render(<ResearchView projectId="p1" />);
expect(await screen.findByTestId("research-state-empty")).toBeInTheDocument();
});
it("renders selected run details", async () => {
mockUseResearch.mockReturnValue({
...baseHookValue,
runs: [{ id: "RR-1", title: "t", query: "q", status: "running" }],
@@ -101,9 +126,10 @@ describe("ResearchView", () => {
selectedRunId: "RR-1",
statusCounts: { pending: 0, running: 1, completed: 0, failed: 0, cancelled: 0 },
});
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
render(<ResearchView projectId="p1" />);
expect(screen.getByTestId("research-state-results")).toHaveTextContent("Summary");
expect(await screen.findByTestId("research-state-results")).toHaveTextContent("Summary");
});
it("triggers lifecycle/task/export actions", async () => {
@@ -124,8 +150,10 @@ describe("ResearchView", () => {
attachRunToTask,
exportRun,
});
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
render(<ResearchView projectId="p1" />);
await screen.findByText("Cancel");
fireEvent.click(screen.getByText("Cancel"));
fireEvent.click(screen.getByText("Retry"));
@@ -143,25 +171,155 @@ describe("ResearchView", () => {
});
});
it("renders unavailable state without interactive workflow controls", () => {
it("renders unavailable state without interactive workflow controls", async () => {
mockUseResearch.mockReturnValue({ ...baseHookValue, availability: { available: false, reason: "disabled" } });
render(<ResearchView projectId="p1" />);
expect(screen.getByTestId("research-state-unavailable")).toBeInTheDocument();
expect(await screen.findByTestId("research-state-unavailable")).toBeInTheDocument();
expect(screen.queryByLabelText("Query")).not.toBeInTheDocument();
expect(screen.queryByText("Create Run")).not.toBeInTheDocument();
});
it("renders human-readable provider labels", () => {
it("shows setup card when project research is disabled", async () => {
mockFetchSettings.mockResolvedValue({
...configuredResearchSettings,
researchSettings: { enabled: false },
});
const onOpenSettings = vi.fn();
render(<ResearchView projectId="p1" onOpenSettings={onOpenSettings} />);
expect(await screen.findByText("Research is disabled for this project.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open Settings" }));
expect(onOpenSettings).toHaveBeenCalledWith("research-project");
});
it("shows incomplete defaults setup CTA routed to global research settings", async () => {
mockFetchSettings.mockResolvedValue({
researchSettings: { enabled: true },
researchGlobalDefaults: {
searchProvider: "tavily",
synthesisProvider: undefined,
synthesisModelId: undefined,
maxSourcesPerRun: 20,
},
});
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "tavily", type: "api_key", authenticated: true }] });
const onOpenSettings = vi.fn();
render(<ResearchView projectId="p1" onOpenSettings={onOpenSettings} />);
expect(await screen.findByText(/Research defaults are incomplete/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open Settings" }));
expect(onOpenSettings).toHaveBeenCalledWith("research-global");
});
it("shows authentication CTA when provider credentials are missing", async () => {
mockFetchSettings.mockResolvedValue({
researchSettings: { enabled: true },
researchGlobalDefaults: {
searchProvider: "tavily",
synthesisProvider: "openrouter",
synthesisModelId: "gpt-5",
maxSourcesPerRun: 20,
},
});
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "tavily", type: "api_key", authenticated: false },
{ id: "openrouter", type: "api_key", authenticated: true },
],
});
const onOpenSettings = vi.fn();
render(<ResearchView projectId="p1" onOpenSettings={onOpenSettings} />);
expect(await screen.findByText(/Missing API key for tavily/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open Settings" }));
expect(onOpenSettings).toHaveBeenCalledWith("authentication");
});
it("renders human-readable provider labels", async () => {
mockUseResearch.mockReturnValue({
...baseHookValue,
availability: { available: true, supportedProviders: ["web-search", "page-fetch", "llm-synthesis"] },
});
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
render(<ResearchView projectId="p1" />);
expect(screen.getByText("Web Search")).toBeInTheDocument();
expect(await screen.findByText("Web Search")).toBeInTheDocument();
expect(screen.getByText("Page Fetch")).toBeInTheDocument();
expect(screen.getByText("LLM Synthesis")).toBeInTheDocument();
});
it("disables provider checkboxes when sources are disabled in settings", async () => {
mockFetchSettings.mockResolvedValue({
...configuredResearchSettings,
researchGlobalDefaults: {
...configuredResearchSettings.researchGlobalDefaults,
enabledSources: {
webSearch: false,
pageFetch: true,
github: false,
localDocs: true,
llmSynthesis: true,
},
},
});
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
mockUseResearch.mockReturnValue({
...baseHookValue,
availability: { available: true, supportedProviders: ["web-search", "page-fetch", "llm-synthesis"] },
});
render(<ResearchView projectId="p1" />);
const webSearch = (await screen.findByLabelText("Web Search")) as HTMLInputElement;
const pageFetch = screen.getByLabelText("Page Fetch") as HTMLInputElement;
expect(webSearch.disabled).toBe(true);
expect(pageFetch.disabled).toBe(false);
});
it("submits only enabled providers", async () => {
const createRun = vi.fn().mockResolvedValue({ run: { id: "RR-2" } });
mockFetchSettings.mockResolvedValue({
...configuredResearchSettings,
researchGlobalDefaults: {
...configuredResearchSettings.researchGlobalDefaults,
enabledSources: {
webSearch: false,
pageFetch: true,
github: false,
localDocs: false,
llmSynthesis: true,
},
},
});
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
mockUseResearch.mockReturnValue({
...baseHookValue,
createRun,
availability: { available: true, supportedProviders: ["web-search", "page-fetch", "llm-synthesis"] },
});
render(<ResearchView projectId="p1" />);
fireEvent.change(await screen.findByLabelText("Query"), { target: { value: "hello" } });
fireEvent.click(screen.getByText("Create Run"));
await waitFor(() => {
expect(createRun).toHaveBeenCalledWith(expect.objectContaining({ providers: ["page-fetch", "llm-synthesis"] }));
});
});
it("refreshes readiness state when readinessVersion changes", async () => {
const onOpenSettings = vi.fn();
mockFetchSettings.mockResolvedValueOnce(configuredResearchSettings);
mockFetchAuthStatus.mockResolvedValueOnce({ providers: [{ id: "openrouter", type: "api_key", authenticated: false }] });
const { rerender } = render(<ResearchView projectId="p1" onOpenSettings={onOpenSettings} readinessVersion={0} />);
expect(await screen.findByText(/Missing API key for openrouter/i)).toBeInTheDocument();
mockFetchSettings.mockResolvedValueOnce(configuredResearchSettings);
mockFetchAuthStatus.mockResolvedValueOnce({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
rerender(<ResearchView projectId="p1" onOpenSettings={onOpenSettings} readinessVersion={1} />);
expect(await screen.findByTestId("research-state-empty")).toBeInTheDocument();
});
it("includes mobile layout media rule", async () => {
const css = await import("../ResearchView.css?inline");
expect(css.default).toContain("@media (max-width: 768px)");

View File

@@ -1468,6 +1468,14 @@ describe("SettingsModal", () => {
expect(screen.getByText("Roadmaps")).toBeInTheDocument();
});
it("shows researchView in the Experimental Features list", async () => {
renderModal();
await openExperimentalFeaturesSection();
expect(screen.getByLabelText("Research View")).toBeInTheDocument();
});
it("shows a single canonical Dev Server toggle", async () => {
renderModal();
@@ -2249,6 +2257,88 @@ describe("SettingsModal", () => {
});
});
describe("research settings sections", () => {
it("saves global research defaults through updateGlobalSettings only", async () => {
renderModal({ initialSection: "research-global" });
await waitForSettingsModalReady();
const searchInput = await screen.findByLabelText("Default Search Provider");
await userEvent.clear(searchInput);
await userEvent.type(searchInput, "tavily");
await userEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
researchGlobalDefaults: expect.objectContaining({ searchProvider: "tavily" }),
}),
);
});
expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.not.objectContaining({ researchSettings: expect.anything() }),
undefined,
);
});
it("saves project research settings through updateSettings only", async () => {
renderModal({ initialSection: "research-project" });
await waitForSettingsModalReady();
await userEvent.click(screen.getByLabelText("Enable research in this project"));
const maxConcurrent = await screen.findByLabelText("Max Concurrent Runs");
fireEvent.change(maxConcurrent, { target: { value: "4" } });
await userEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.objectContaining({
researchSettings: expect.objectContaining({
enabled: false,
limits: expect.objectContaining({ maxConcurrentRuns: 4 }),
}),
}),
undefined,
);
});
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
expect.not.objectContaining({ researchGlobalDefaults: expect.anything() }),
);
});
it("blocks save and shows inline error for invalid research limits", async () => {
renderModal({ initialSection: "research-project" });
await waitForSettingsModalReady();
const maxConcurrent = await screen.findByLabelText("Max Concurrent Runs");
fireEvent.change(maxConcurrent, { target: { value: "0" } });
await userEvent.click(screen.getByText("Save"));
expect(await screen.findByText("Research max concurrent runs must be at least 1.")).toBeInTheDocument();
});
it("shows missing credentials warning and routes CTA to Authentication", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "brave", name: "Brave Search", type: "api_key", authenticated: false },
{ id: "tavily", name: "Tavily", type: "api_key", authenticated: true },
],
});
renderModal({ initialSection: "research-global" });
await waitForSettingsModalReady();
expect(await screen.findByText(/Missing credentials for one or more research providers/i)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Open Authentication" }));
expect(await screen.findByRole("heading", { name: "Authentication" })).toBeInTheDocument();
});
it("falls back to first visible section when initial section is unavailable", async () => {
renderModal({ initialSection: "unknown-section" as any });
await waitForSettingsModalReady();
expect(await screen.findByRole("heading", { name: "Authentication" })).toBeInTheDocument();
});
});
describe("memory dream trigger", () => {
const openMemorySection = async () => {
const [memorySectionButton] = await screen.findAllByRole("button", { name: /^Memory$/i });

View File

@@ -4772,6 +4772,21 @@ describe("GET /auth/status", () => {
expect(openrouter.type).toBe("api_key");
});
it("reports research API-key providers with type api_key", async () => {
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "tavily", name: "Tavily" },
]);
const res = await GET(buildApp(), "/api/auth/status");
expect(res.status).toBe(200);
expect(res.body.providers).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "tavily", type: "api_key" }),
]),
);
});
it("returns 500 on error", async () => {
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("storage error");
@@ -5189,6 +5204,20 @@ describe("POST /auth/api-key", () => {
expect(authStorage.setApiKey).toHaveBeenCalledWith("openrouter", "sk-or-v1-test-key");
});
it("saves a trimmed key for research API-key providers", async () => {
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "tavily", name: "Tavily" },
]);
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
provider: "tavily",
apiKey: " tavily-secret ",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(authStorage.setApiKey).toHaveBeenCalledWith("tavily", "tavily-secret");
});
it("returns 400 when provider is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
apiKey: "sk-test",