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:
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
116
packages/core/src/__tests__/research-settings.test.ts
Normal file
116
packages/core/src/__tests__/research-settings.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
65
packages/core/src/research-settings.ts
Normal file
65
packages/core/src/research-settings.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user