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

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