fix(FN-2662): honor project model overrides and stabilize tests

This commit is contained in:
gsxdsm
2026-04-28 18:21:17 -07:00
parent 964c55da77
commit 2029968d23
31 changed files with 980 additions and 361 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix project-level model overrides so they take precedence over the default model fallback consistently across dashboard and engine AI flows.

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
resolveExecutionSettingsModel,
resolvePlanningSettingsModel,
resolveProjectDefaultModel,
resolveTaskExecutionModel,
resolveTaskPlanningModel,
resolveTaskValidatorModel,
resolveTitleSummarizerSettingsModel,
resolveValidatorSettingsModel,
} from "../model-resolution.js";
describe("model-resolution", () => {
it("prefers the project default override over the global default", () => {
expect(
resolveProjectDefaultModel({
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
}),
).toEqual({ provider: "openai", modelId: "gpt-4o" });
});
it("uses the execution lane before the project default override", () => {
expect(
resolveExecutionSettingsModel({
executionProvider: "google",
executionModelId: "gemini-2.5-pro",
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
}),
).toEqual({ provider: "google", modelId: "gemini-2.5-pro" });
});
it("falls back from planning global to the project default override", () => {
expect(
resolvePlanningSettingsModel({
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o-mini",
}),
).toEqual({ provider: "openai", modelId: "gpt-4o-mini" });
});
it("falls back from validator global to the project default override", () => {
expect(
resolveValidatorSettingsModel({
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-opus-4",
}),
).toEqual({ provider: "anthropic", modelId: "claude-opus-4" });
});
it("uses title summarizer global, then project planning, then project default override", () => {
expect(
resolveTitleSummarizerSettingsModel({
titleSummarizerGlobalProvider: "openai",
titleSummarizerGlobalModelId: "gpt-4.1",
planningProvider: "google",
planningModelId: "gemini-2.5-pro",
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-sonnet-4-5",
}),
).toEqual({ provider: "openai", modelId: "gpt-4.1" });
expect(
resolveTitleSummarizerSettingsModel({
planningProvider: "google",
planningModelId: "gemini-2.5-pro",
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-sonnet-4-5",
}),
).toEqual({ provider: "google", modelId: "gemini-2.5-pro" });
expect(
resolveTitleSummarizerSettingsModel({
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-sonnet-4-5",
}),
).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
});
it("uses task overrides before settings fallbacks", () => {
expect(
resolveTaskExecutionModel(
{
modelProvider: "openai",
modelId: "gpt-4o",
},
{
executionProvider: "anthropic",
executionModelId: "claude-sonnet-4-5",
},
),
).toEqual({ provider: "openai", modelId: "gpt-4o" });
expect(
resolveTaskValidatorModel(
{},
{
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-sonnet-4-5",
},
),
).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
expect(
resolveTaskPlanningModel(
{},
{
planningGlobalProvider: "openai",
planningGlobalModelId: "gpt-4.1",
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-sonnet-4-5",
},
),
).toEqual({ provider: "openai", modelId: "gpt-4.1" });
});
});

View File

@@ -196,6 +196,17 @@ export {
AiServiceError,
__resetSummarizeState,
} from "./ai-summarize.js";
export {
resolveExecutionSettingsModel,
resolvePlanningSettingsModel,
resolveProjectDefaultModel,
resolveTaskExecutionModel,
resolveTaskPlanningModel,
resolveTaskValidatorModel,
resolveTitleSummarizerSettingsModel,
resolveValidatorSettingsModel,
} from "./model-resolution.js";
export type { ResolvedModelSelection } from "./model-resolution.js";
// ── Memory Compaction ─────────────────────────────────────────────────

View File

@@ -0,0 +1,148 @@
import type { Settings } from "./types.js";
export interface ResolvedModelSelection {
provider?: string;
modelId?: string;
}
type ModelPair =
| ResolvedModelSelection
| {
provider?: string | null;
modelId?: string | null;
}
| undefined;
type TaskModelLike = {
modelProvider?: string | null;
modelId?: string | null;
validatorModelProvider?: string | null;
validatorModelId?: string | null;
planningModelProvider?: string | null;
planningModelId?: string | null;
};
function hasCompleteModelPair(pair: ModelPair): pair is { provider: string; modelId: string } {
return Boolean(pair?.provider && pair?.modelId);
}
function pickFirstModelPair(...pairs: ModelPair[]): ResolvedModelSelection {
for (const pair of pairs) {
if (hasCompleteModelPair(pair)) {
return { provider: pair.provider, modelId: pair.modelId };
}
}
return {};
}
export function resolveProjectDefaultModel(settings?: Partial<Settings>): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: settings?.defaultProviderOverride,
modelId: settings?.defaultModelIdOverride,
},
{
provider: settings?.defaultProvider,
modelId: settings?.defaultModelId,
},
);
}
export function resolveExecutionSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: settings?.executionProvider,
modelId: settings?.executionModelId,
},
{
provider: settings?.executionGlobalProvider,
modelId: settings?.executionGlobalModelId,
},
resolveProjectDefaultModel(settings),
);
}
export function resolvePlanningSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: settings?.planningProvider,
modelId: settings?.planningModelId,
},
{
provider: settings?.planningGlobalProvider,
modelId: settings?.planningGlobalModelId,
},
resolveProjectDefaultModel(settings),
);
}
export function resolveValidatorSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: settings?.validatorProvider,
modelId: settings?.validatorModelId,
},
{
provider: settings?.validatorGlobalProvider,
modelId: settings?.validatorGlobalModelId,
},
resolveProjectDefaultModel(settings),
);
}
export function resolveTitleSummarizerSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: settings?.titleSummarizerProvider,
modelId: settings?.titleSummarizerModelId,
},
{
provider: settings?.titleSummarizerGlobalProvider,
modelId: settings?.titleSummarizerGlobalModelId,
},
{
provider: settings?.planningProvider,
modelId: settings?.planningModelId,
},
resolveProjectDefaultModel(settings),
);
}
export function resolveTaskExecutionModel(
task: TaskModelLike,
settings?: Partial<Settings>,
): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: task.modelProvider,
modelId: task.modelId,
},
resolveExecutionSettingsModel(settings),
);
}
export function resolveTaskValidatorModel(
task: TaskModelLike,
settings?: Partial<Settings>,
): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: task.validatorModelProvider,
modelId: task.validatorModelId,
},
resolveValidatorSettingsModel(settings),
);
}
export function resolveTaskPlanningModel(
task: TaskModelLike,
settings?: Partial<Settings>,
): ResolvedModelSelection {
return pickFirstModelPair(
{
provider: task.planningModelProvider,
modelId: task.planningModelId,
},
resolvePlanningSettingsModel(settings),
);
}

View File

@@ -3376,3 +3376,14 @@ export { PROMPT_KEY_CATALOG } from "./prompt-overrides.js";
// Re-exported here so the dashboard's `@fusion/core` → types.ts alias resolves
// client-side consumers (see packages/dashboard/vite.config.ts).
export { getErrorMessage } from "./error-message.js";
export {
resolveExecutionSettingsModel,
resolvePlanningSettingsModel,
resolveProjectDefaultModel,
resolveTaskExecutionModel,
resolveTaskPlanningModel,
resolveTaskValidatorModel,
resolveTitleSummarizerSettingsModel,
resolveValidatorSettingsModel,
} from "./model-resolution.js";
export type { ResolvedModelSelection } from "./model-resolution.js";

View File

@@ -59,6 +59,9 @@ import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog";
// first render.
import "./components/ChatView.css";
const IS_TEST_ENV = import.meta.env.MODE === "test";
const DASHBOARD_READY_SETTLE_DELAY_MS = IS_TEST_ENV ? 0 : 200;
const AgentsView = lazy(() => import("./components/AgentsView").then((m) => ({ default: m.AgentsView })));
const DocumentsView = lazy(() => import("./components/DocumentsView").then((m) => ({ default: m.DocumentsView })));
const InsightsView = lazy(() => import("./components/InsightsView").then((m) => ({ default: m.InsightsView })));
@@ -74,6 +77,10 @@ const TodoView = lazy(() => import("./components/TodoView").then((m) => ({ defau
// instant. Each chunk is ~1080 kB; total prefetch finishes well under a
// second on broadband. Uses requestIdleCallback so it never blocks render.
function prefetchLazyViews() {
if (IS_TEST_ENV) {
return;
}
const idle =
(typeof window !== "undefined" && (window as Window & { requestIdleCallback?: (cb: () => void) => number }).requestIdleCallback) ||
((cb: () => void) => setTimeout(cb, 200));
@@ -224,14 +231,18 @@ function AppInner() {
if (!projectsLoading && !projectsReadyLoggedRef.current) {
projectsReadyLoggedRef.current = true;
const msg = `projects loaded at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount`;
console.log(`[App] ${msg}`);
reportDashboardPerf("[App]", msg);
if (!IS_TEST_ENV) {
console.log(`[App] ${msg}`);
reportDashboardPerf("[App]", msg);
}
}
if (!currentProjectLoading && !projectReadyLoggedRef.current) {
projectReadyLoggedRef.current = true;
const msg = `current-project resolved at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount`;
console.log(`[App] ${msg}`);
reportDashboardPerf("[App]", msg);
if (!IS_TEST_ENV) {
console.log(`[App] ${msg}`);
reportDashboardPerf("[App]", msg);
}
}
}, [projectsLoading, currentProjectLoading]);
@@ -247,10 +258,12 @@ function AppInner() {
const settleStart = performance.now();
const settleTimer = window.setTimeout(() => {
const msg = `dashboard ready at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount (settle delay=${Math.round(performance.now() - settleStart)}ms)`;
console.log(`[App] ${msg}`);
reportDashboardPerf("[App]", msg);
if (!IS_TEST_ENV) {
console.log(`[App] ${msg}`);
reportDashboardPerf("[App]", msg);
}
setInitialLoadComplete(true);
}, 200);
}, DASHBOARD_READY_SETTLE_DELAY_MS);
return () => {
window.clearTimeout(settleTimer);

View File

@@ -3,7 +3,12 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { fetchModels, updateTask, updateGlobalSettings } from "../api";
import type { ModelInfo } from "../api";
import type { Settings, Task, TaskDetail } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import {
getErrorMessage,
resolveTaskExecutionModel,
resolveTaskPlanningModel,
resolveTaskValidatorModel,
} from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
@@ -49,45 +54,21 @@ function resolveEffectiveExecutor(
task: Task | TaskDetail,
settings?: Settings,
): ModelSelection {
if (task.modelProvider && task.modelId) {
return { provider: task.modelProvider, modelId: task.modelId };
}
if (settings?.defaultProvider && settings.defaultModelId) {
return { provider: settings.defaultProvider, modelId: settings.defaultModelId };
}
return {};
return resolveTaskExecutionModel(task, settings);
}
function resolveEffectiveValidator(
task: Task | TaskDetail,
settings?: Settings,
): ModelSelection {
if (task.validatorModelProvider && task.validatorModelId) {
return { provider: task.validatorModelProvider, modelId: task.validatorModelId };
}
if (settings?.validatorProvider && settings.validatorModelId) {
return { provider: settings.validatorProvider, modelId: settings.validatorModelId };
}
if (settings?.defaultProvider && settings.defaultModelId) {
return { provider: settings.defaultProvider, modelId: settings.defaultModelId };
}
return {};
return resolveTaskValidatorModel(task, settings);
}
function resolveEffectivePlanning(
task: Task | TaskDetail,
settings?: Settings,
): ModelSelection {
if (task.planningModelProvider && task.planningModelId) {
return { provider: task.planningModelProvider, modelId: task.planningModelId };
}
if (settings?.planningProvider && settings.planningModelId) {
return { provider: settings.planningProvider, modelId: settings.planningModelId };
}
if (settings?.defaultProvider && settings.defaultModelId) {
return { provider: settings.defaultProvider, modelId: settings.defaultModelId };
}
return {};
return resolveTaskPlanningModel(task, settings);
}
function parseModelValue(value: string): ModelSelection {

View File

@@ -1,6 +1,14 @@
import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEvent } from "react";
import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2 } from "lucide-react";
import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core";
import {
THINKING_LEVELS,
getErrorMessage,
isGlobalSettingsKey,
isProjectSettingsKey,
resolvePlanningSettingsModel,
resolveProjectDefaultModel,
resolveTitleSummarizerSettingsModel,
} from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
@@ -1122,7 +1130,7 @@ export function SettingsModal({
projectProviderKey: "titleSummarizerProvider",
projectModelKey: "titleSummarizerModelId",
helperText: "AI model used for auto-generating task titles from descriptions.",
fallbackOrder: "Project override → Global summarization lane → Global planning lane → Global default lane → Automatic resolution",
fallbackOrder: "Project override → Global summarization lane → Project planning lane → Project default lane → Global default lane → Automatic resolution",
},
];
@@ -1862,11 +1870,18 @@ export function SettingsModal({
const presetOptions = presets.map((preset) => ({ id: preset.id, name: preset.name }));
const inUsePresetIds = new Set(Object.values(form.defaultPresetBySize || {}).filter(Boolean));
// Filter model lanes to show in project scope (execution, planning, validator, summarization)
// Default lane is global-only
// Filter model lanes to show in project scope.
const projectModelLanes = MODEL_LANES.filter(
(lane) => lane.laneId === "execution" || lane.laneId === "planning" || lane.laneId === "validator" || lane.laneId === "summarization",
(lane) =>
lane.laneId === "default"
|| lane.laneId === "execution"
|| lane.laneId === "planning"
|| lane.laneId === "validator"
|| lane.laneId === "summarization",
);
const resolvedPlanningModel = resolvePlanningSettingsModel(form);
const resolvedDefaultModel = resolveProjectDefaultModel(form);
const resolvedTitleSummarizerModel = resolveTitleSummarizerSettingsModel(form);
return (
<>
@@ -2301,11 +2316,17 @@ export function SettingsModal({
<small>
{form.titleSummarizerProvider && form.titleSummarizerModelId
? "Using explicitly configured model"
: form.planningProvider && form.planningModelId
? "(using planning model)"
: form.defaultProvider && form.defaultModelId
? "(using default model)"
: "(using automatic model selection)"}
: resolvedTitleSummarizerModel.provider && resolvedTitleSummarizerModel.modelId
? resolvedTitleSummarizerModel.provider === resolvedPlanningModel.provider
&& resolvedTitleSummarizerModel.modelId === resolvedPlanningModel.modelId
? "(using planning model)"
: resolvedTitleSummarizerModel.provider === resolvedDefaultModel.provider
&& resolvedTitleSummarizerModel.modelId === resolvedDefaultModel.modelId
? form.defaultProviderOverride && form.defaultModelIdOverride
? "(using project default model)"
: "(using global default model)"
: "(using global summarization model)"
: "(using automatic model selection)"}
</small>
</div>
@@ -2317,11 +2338,11 @@ export function SettingsModal({
onClick={() =>
setForm((f) => ({
...f,
titleSummarizerProvider: f.planningProvider,
titleSummarizerModelId: f.planningModelId,
titleSummarizerProvider: resolvedPlanningModel.provider,
titleSummarizerModelId: resolvedPlanningModel.modelId,
}))
}
disabled={!form.planningProvider || !form.planningModelId}
disabled={!resolvedPlanningModel.provider || !resolvedPlanningModel.modelId}
>
Use planning model
</button>
@@ -2331,11 +2352,11 @@ export function SettingsModal({
onClick={() =>
setForm((f) => ({
...f,
titleSummarizerProvider: f.defaultProvider,
titleSummarizerModelId: f.defaultModelId,
titleSummarizerProvider: resolvedDefaultModel.provider,
titleSummarizerModelId: resolvedDefaultModel.modelId,
}))
}
disabled={!form.defaultProvider || !form.defaultModelId}
disabled={!resolvedDefaultModel.provider || !resolvedDefaultModel.modelId}
>
Use default model
</button>

View File

@@ -5,10 +5,18 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue } from "@fusion/core";
import { COLUMN_LABELS, DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, VALID_TRANSITIONS, getErrorMessage } from "@fusion/core";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult } from "@fusion/core";
import {
COLUMN_LABELS,
DEFAULT_TASK_PRIORITY,
TASK_PRIORITIES,
VALID_TRANSITIONS,
getErrorMessage,
resolveTaskExecutionModel,
resolveTaskPlanningModel,
resolveTaskValidatorModel,
} from "@fusion/core";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent } from "../api";
import type { WorkflowStepResult } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { useAgentLogs } from "../hooks/useAgentLogs";
import { useConfirm } from "../hooks/useConfirm";
@@ -42,41 +50,25 @@ const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finali
/**
* Resolve the effective executor model following the engine's resolution order:
* 1. Per-task modelProvider/modelId (both must be set)
* 2. Global settings defaultProvider/defaultModelId
* 2. Project/global execution lane fallback
*/
function resolveEffectiveExecutor(
task: Task | TaskDetail,
settings?: Settings,
): ModelSelection {
if (task.modelProvider && task.modelId) {
return { provider: task.modelProvider, modelId: task.modelId };
}
if (settings?.defaultProvider && settings.defaultModelId) {
return { provider: settings.defaultProvider, modelId: settings.defaultModelId };
}
return {};
return resolveTaskExecutionModel(task, settings);
}
/**
* Resolve the effective validator model following the engine's resolution order:
* 1. Per-task validatorModelProvider/validatorModelId (both must be set)
* 2. Project settings validatorProvider/validatorModelId
* 3. Global settings defaultProvider/defaultModelId
* 2. Project/global validator lane fallback
*/
function resolveEffectiveValidator(
task: Task | TaskDetail,
settings?: Settings,
): ModelSelection {
if (task.validatorModelProvider && task.validatorModelId) {
return { provider: task.validatorModelProvider, modelId: task.validatorModelId };
}
if (settings?.validatorProvider && settings.validatorModelId) {
return { provider: settings.validatorProvider, modelId: settings.validatorModelId };
}
if (settings?.defaultProvider && settings.defaultModelId) {
return { provider: settings.defaultProvider, modelId: settings.defaultModelId };
}
return {};
return resolveTaskValidatorModel(task, settings);
}
/**
@@ -102,8 +94,7 @@ function extractPlanningModelFromLog(entries: AgentLogEntry[]): { provider: stri
* Resolve the effective planning model following the resolution order:
* 1. Per-task planningModelProvider/planningModelId override
* 2. Runtime triage model from agent log marker (if present)
* 3. Project settings planningProvider/planningModelId
* 4. Global settings defaultProvider/defaultModelId
* 3. Project/global planning lane fallback
*/
function resolveEffectivePlanning(
task: Task | TaskDetail,
@@ -119,15 +110,7 @@ function resolveEffectivePlanning(
if (fromLog) {
return fromLog;
}
// 3. Project settings planningProvider/planningModelId
if (settings?.planningProvider && settings.planningModelId) {
return { provider: settings.planningProvider, modelId: settings.planningModelId };
}
// 4. Global settings defaultProvider/defaultModelId
if (settings?.defaultProvider && settings.defaultModelId) {
return { provider: settings.defaultProvider, modelId: settings.defaultModelId };
}
return {};
return resolveTaskPlanningModel(task, settings);
}
function getStepStatusColor(status: string): string {

View File

@@ -1,4 +1,3 @@
import { readFileSync } from "node:fs";
import { loadAllAppCss } from "../../test/cssFixture";
import { resolve } from "node:path";
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -94,18 +93,21 @@ function makePromptOverrides(overrides: Partial<Record<PromptKey, string | null>
const onChange = vi.fn();
const onPromptOverridesChange = vi.fn();
const stylesContent = loadAllAppCss();
const testStylesId = "agent-prompts-manager-test-styles";
function ensureTestStyles(): void {
if (document.getElementById(testStylesId)) {
return;
}
const styleElement = document.createElement("style");
styleElement.id = testStylesId;
styleElement.textContent = loadAllAppCss();
document.head.appendChild(styleElement);
}
beforeEach(() => {
vi.clearAllMocks();
if (!document.getElementById(testStylesId)) {
const styleElement = document.createElement("style");
styleElement.id = testStylesId;
styleElement.textContent = stylesContent;
document.head.appendChild(styleElement);
}
});
describe("AgentPromptsManager", () => {
@@ -223,6 +225,8 @@ describe("AgentPromptsManager", () => {
});
it("template preview area has scrollable overflow", () => {
ensureTestStyles();
render(
<AgentPromptsManager
value={defaultConfig}

View File

@@ -102,6 +102,16 @@ vi.mock("../../hooks/useRemoteNodeEvents", () => ({
})),
}));
vi.mock("../../hooks/useBackgroundSessions", () => ({
useBackgroundSessions: vi.fn(() => ({
sessions: [],
generating: false,
needsInput: false,
planningSessions: [],
dismissSession: vi.fn(),
})),
}));
// Mock NodeContext - default to local mode
const mockNodeContextValue: {
currentNode: NodeConfig | null;
@@ -165,6 +175,191 @@ vi.mock("../../components/CustomModelDropdown", () => ({
),
}));
vi.mock("../../components/TaskDetailModal", () => ({
TaskDetailModal: ({ task, onClose }: { task: { id: string; title?: string }; onClose: () => void }) => (
<div className="modal-overlay open">
<div role="dialog" aria-label={task.title ?? task.id}>
<button type="button" className="modal-close" onClick={onClose}>
Close
</button>
<h2>{task.title ?? task.id}</h2>
</div>
</div>
),
}));
vi.mock("../../components/GitHubImportModal", () => ({
GitHubImportModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) =>
isOpen ? (
<div className="modal-overlay open">
<h2>Import from GitHub</h2>
<button type="button" onClick={onClose}>
Cancel
</button>
</div>
) : null,
}));
vi.mock("../../components/PlanningModeModal", () => ({
PlanningModeModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) =>
isOpen ? (
<div className="modal-overlay open">
<button type="button" aria-label="Close" onClick={onClose}>
Close
</button>
<h2>Planning Mode</h2>
<p>Transform your idea into a detailed task</p>
<input placeholder="e.g., Build a user authentication system with login" />
<button type="button">Start Planning</button>
</div>
) : null,
}));
vi.mock("../../components/ScriptsModal", () => ({
ScriptsModal: ({
isOpen,
onClose,
onRunScript,
}: {
isOpen: boolean;
onClose: () => void;
onRunScript: (scriptName: string) => void;
}) =>
isOpen ? (
<div className="modal-overlay open" data-testid="scripts-modal">
<button type="button" onClick={onClose}>
Close
</button>
<button type="button" data-testid="run-script-build" onClick={() => onRunScript("build")}>
Run build
</button>
</div>
) : null,
}));
vi.mock("../../components/TerminalModal", () => ({
TerminalModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) =>
isOpen ? (
<div className="modal-overlay open" data-testid="terminal-modal">
<button type="button" data-testid="terminal-close-btn" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
vi.mock("../../components/AgentsView", () => ({
AgentsView: () => <div className="agents-view">Agents view</div>,
}));
vi.mock("../../components/QuickChatFAB", () => ({
QuickChatFAB: () => null,
}));
vi.mock("../../components/SetupWizardModal", () => ({
SetupWizardModal: () => <div className="modal-overlay open">Welcome to Fusion</div>,
}));
vi.mock("../../components/SettingsModal", async () => {
const React = await import("react");
const api = await import("../../api");
function MockSettingsModal({
onClose,
onReopenOnboarding,
initialSection,
}: {
onClose: () => void;
onReopenOnboarding?: () => void;
initialSection?: string;
}) {
const [section, setSection] = React.useState(
initialSection === "general" ? "general" : "authentication",
);
const [providers, setProviders] = React.useState<Array<{ id: string; name: string }>>([]);
React.useEffect(() => {
void api.fetchSettings();
void api.fetchAuthStatus().then((result) => {
setProviders(result.providers ?? []);
});
}, []);
return (
<div className="modal-overlay open">
<h2>Settings</h2>
<button type="button" onClick={onClose}>
Close
</button>
<button type="button" onClick={() => setSection("authentication")}>
Authentication
</button>
<button type="button" onClick={() => setSection("general")}>
General
</button>
{section === "authentication" ? (
<div>
{providers.map((provider) => (
<div key={provider.id}>{provider.name}</div>
))}
<button type="button" onClick={onReopenOnboarding}>
Reopen onboarding guide
</button>
</div>
) : (
<label>
Task Prefix
<input aria-label="Task Prefix" />
</label>
)}
</div>
);
}
return { SettingsModal: MockSettingsModal };
});
vi.mock("../../components/ModelOnboardingModal", async () => {
const React = await import("react");
const api = await import("../../api");
function MockModelOnboardingModal({
onComplete,
}: {
onComplete: () => void;
}) {
const [value, setValue] = React.useState("");
React.useEffect(() => {
void Promise.all([api.fetchGlobalSettings(), api.fetchModels()]).then(([settings]) => {
if (settings.defaultProvider && settings.defaultModelId) {
setValue(`${settings.defaultProvider}/${settings.defaultModelId}`);
}
});
}, []);
return (
<div className="modal-overlay open">
<h2>Set Up AI</h2>
<button type="button" onClick={onComplete}>
Skip for now
</button>
<select
data-testid="mock-model-dropdown"
value={value}
onChange={(e) => setValue(e.target.value)}
>
<option value="">Select</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="openai/gpt-4o">GPT-4o</option>
</select>
</div>
);
}
return { ModelOnboardingModal: MockModelOnboardingModal };
});
// Mock state holders for dynamic mocking
const mockRefreshProjects = vi.fn(async () => {});
@@ -369,7 +564,7 @@ describe("App mailbox unread count", () => {
);
});
expect(screen.getByRole("status", { name: "Loading Fusion dashboard" })).toBeInTheDocument();
await waitForAppShell();
warnSpy.mockRestore();
});

View File

@@ -192,6 +192,27 @@ describe("ModelSelectorTab", () => {
expect(within(executorSection!).getByText("Using default (anthropic/claude-sonnet-4-5)")).toBeInTheDocument();
});
it("prefers the project default override over the global default in resolved badges", async () => {
render(
<ModelSelectorTab
task={FAKE_TASK}
addToast={mockAddToast}
settings={makeSettings({
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
})}
/>,
);
await waitForSelectors();
expect(within(getSection("Executor Model")!).getByText("Using default (openai/gpt-4o)")).toBeInTheDocument();
expect(within(getSection("Reviewer Model")!).getByText("Using default (openai/gpt-4o)")).toBeInTheDocument();
expect(within(getSection("Planning Model")!).getByText("Using default (openai/gpt-4o)")).toBeInTheDocument();
});
it("shows 'Using default' without resolution when settings prop is undefined", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);

View File

@@ -166,9 +166,27 @@ async function waitForSettingsModalReady() {
});
}
const MODEL_FIXTURE = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
];
describe("SettingsModal", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
mockFetchSettings.mockResolvedValue(defaultSettings);
mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: {} });
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
@@ -371,6 +389,85 @@ describe("SettingsModal", () => {
});
});
describe("Project Models", () => {
it("renders a project-scoped default model lane", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
mockFetchSettingsByScope.mockResolvedValue({
global: {
...defaultSettings,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
},
project: {},
});
mockFetchModels.mockResolvedValue({
models: MODEL_FIXTURE,
favoriteProviders: [],
favoriteModels: [],
});
renderModal();
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: "Project Models" }));
const defaultSection = screen.getByLabelText("Default Model").closest(".form-group");
expect(defaultSection).toBeTruthy();
expect(within(defaultSection as HTMLElement).getByText("Inherited (Global)")).toBeInTheDocument();
});
it("saves the project default model override under project scope keys", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
mockFetchSettingsByScope.mockResolvedValue({
global: {
...defaultSettings,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
},
project: {},
});
mockFetchModels.mockResolvedValue({
models: MODEL_FIXTURE,
favoriteProviders: [],
favoriteModels: [],
});
renderModal();
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: "Project Models" }));
await userEvent.click(screen.getByLabelText("Default Model"));
await userEvent.click(screen.getByText("GPT-4o"));
await userEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.objectContaining({
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
}),
undefined,
);
if (mockUpdateGlobalSettings.mock.calls.length > 0) {
const [globalPayload] = mockUpdateGlobalSettings.mock.calls[0] as [Record<string, unknown>];
expect(globalPayload).not.toHaveProperty("defaultProviderOverride");
expect(globalPayload).not.toHaveProperty("defaultModelIdOverride");
}
});
});
describe("settings version display", () => {
it("renders the app version from the health endpoint", async () => {
renderModal();

View File

@@ -1875,6 +1875,20 @@ describe("TaskDetailModal", () => {
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
});
it("shows the project default override before the global default", async () => {
const { container } = await setupModelTest({
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
const header = await openAgentLogAndExpandModelDetails(container);
const matches = header.textContent!.match(/openai\/gpt-4o/g);
expect(matches).toHaveLength(3);
expect(header.textContent).not.toContain("anthropic/claude-sonnet-4-5");
});
it("shows resolved validator from project validator settings when task has no validator override", async () => {
const { container } = await setupModelTest({
defaultProvider: "anthropic",

View File

@@ -6,7 +6,6 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Task } from "@fusion/core";
import { request } from "../test-request.js";
import { createServer } from "../server.js";
// Use vi.hoisted() for mock functions that need to be accessible in hoisted vi.mock calls
const {
@@ -15,7 +14,7 @@ const {
mockFsReaddir,
mockFsMkdir,
mockFsRm,
mockExecFile,
mockExecFileAsync,
mockListProjects,
mockGetProject,
mockRegisterProject,
@@ -38,10 +37,7 @@ const {
mockFsReaddir: vi.fn().mockResolvedValue([]),
mockFsMkdir: vi.fn().mockResolvedValue(undefined),
mockFsRm: vi.fn().mockResolvedValue(undefined),
mockExecFile: vi.fn((_file, _args, optsOrCallback, maybeCallback) => {
const callback = typeof optsOrCallback === "function" ? optsOrCallback : maybeCallback;
callback?.(null, "", "");
}),
mockExecFileAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
mockListProjects: vi.fn().mockResolvedValue([]),
mockGetProject: vi.fn().mockResolvedValue(null),
mockRegisterProject: vi.fn().mockResolvedValue({
@@ -117,13 +113,9 @@ vi.mock("node:fs/promises", async () => {
};
});
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
execFile: mockExecFile,
};
});
vi.mock("../exec-file.js", () => ({
execFileAsync: (...args: unknown[]) => mockExecFileAsync(...args),
}));
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
@@ -194,6 +186,11 @@ function mockFetchResponse(
} as unknown as Response);
}
async function createApp(store: unknown) {
const { createServer } = await import("../server.js");
return createServer(store as any);
}
describe("Project Routes API Functions", () => {
const originalFetch = globalThis.fetch;
@@ -602,10 +599,7 @@ describe("POST /api/projects route handler", () => {
mockFsReaddir.mockResolvedValue([]);
mockFsMkdir.mockResolvedValue(undefined);
mockFsRm.mockResolvedValue(undefined);
mockExecFile.mockImplementation((_file, _args, optsOrCallback, maybeCallback) => {
const callback = typeof optsOrCallback === "function" ? optsOrCallback : maybeCallback;
callback?.(null, "", "");
});
mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" });
// Reset mocks to default values for route handler tests
mockRegisterProject.mockResolvedValue({
@@ -631,7 +625,7 @@ describe("POST /api/projects route handler", () => {
it("calls updateProject with status 'active' after registration", async () => {
const store = new MockStoreForRoutes();
const app = createServer(store as any);
const app = await createApp(store);
const res = await request(
app,
@@ -653,7 +647,7 @@ describe("POST /api/projects route handler", () => {
it("passes nodeId to registerProject when provided", async () => {
const store = new MockStoreForRoutes();
const app = createServer(store as any);
const app = await createApp(store);
const res = await request(
app,
@@ -678,7 +672,7 @@ describe("POST /api/projects route handler", () => {
it("calls ensureMemoryFileWithBackend after project activation", async () => {
const store = new MockStoreForRoutes();
const app = createServer(store as any);
const app = await createApp(store);
mockEnsureMemoryFileWithBackend.mockResolvedValue(true);
const res = await request(
@@ -697,7 +691,7 @@ describe("POST /api/projects route handler", () => {
it("returns 201 even when memory bootstrap fails", async () => {
const store = new MockStoreForRoutes();
const app = createServer(store as any);
const app = await createApp(store);
mockEnsureMemoryFileWithBackend.mockRejectedValue(new Error("disk full"));
const res = await request(
@@ -715,7 +709,7 @@ describe("POST /api/projects route handler", () => {
it("clones and registers when cloneUrl is provided", async () => {
const store = new MockStoreForRoutes();
const app = createServer(store as any);
const app = await createApp(store);
const tempRoot = mkdtempSync(join(tmpdir(), "fn-2310-clone-"));
const bareRepo = join(tempRoot, "remote.git");
@@ -750,13 +744,12 @@ describe("POST /api/projects route handler", () => {
it("returns clone failure and skips registration when git clone fails", async () => {
const store = new MockStoreForRoutes();
const app = createServer(store as any);
mockExecFile.mockImplementation((_file, _args, optsOrCallback, maybeCallback) => {
const callback = typeof optsOrCallback === "function" ? optsOrCallback : maybeCallback;
const cloneError = Object.assign(new Error("git exited with code 128"), { stderr: "fatal: repository not found" });
callback?.(cloneError, "", "fatal: repository not found");
});
const app = await createApp(store);
mockExecFileAsync.mockRejectedValueOnce(
Object.assign(new Error("git exited with code 128"), {
stderr: "fatal: repository not found",
}),
);
const res = await request(
app,
@@ -778,7 +771,7 @@ describe("POST /api/projects route handler", () => {
it("rejects clone mode when destination directory is non-empty", async () => {
const store = new MockStoreForRoutes();
const app = createServer(store as any);
const app = await createApp(store);
mockFsStat.mockResolvedValue({ isDirectory: () => true } as import("node:fs").Stats);
mockFsReaddir.mockResolvedValue(["README.md"]);
@@ -797,13 +790,13 @@ describe("POST /api/projects route handler", () => {
expect(res.status).toBe(400);
expect((res.body as { error?: string }).error).toContain("Clone destination must be empty");
expect(mockExecFile).not.toHaveBeenCalled();
expect(mockExecFileAsync).not.toHaveBeenCalled();
expect(mockRegisterProject).not.toHaveBeenCalled();
});
it("rejects clone mode when cloneUrl is blank", async () => {
const store = new MockStoreForRoutes();
const app = createServer(store as any);
const app = await createApp(store);
const res = await request(
app,
@@ -819,12 +812,12 @@ describe("POST /api/projects route handler", () => {
expect(res.status).toBe(400);
expect((res.body as { error?: string }).error).toContain("cloneUrl must be a non-empty string");
expect(mockExecFile).not.toHaveBeenCalled();
expect(mockExecFileAsync).not.toHaveBeenCalled();
});
it("rejects clone mode destination path with null-byte input", async () => {
const store = new MockStoreForRoutes();
const app = createServer(store as any);
const app = await createApp(store);
const res = await request(
app,
@@ -840,7 +833,7 @@ describe("POST /api/projects route handler", () => {
expect(res.status).toBe(400);
expect((res.body as { error?: string }).error).toContain("path cannot contain null bytes");
expect(mockExecFile).not.toHaveBeenCalled();
expect(mockExecFileAsync).not.toHaveBeenCalled();
});
});
@@ -851,7 +844,7 @@ describe("GET /api/projects route handler", () => {
it("calls reconcileProjectStatuses before listing projects", async () => {
const store = new MockStoreForRoutes();
const app = createServer(store as any);
const app = await createApp(store);
mockReconcileProjectStatuses.mockResolvedValue([]);
mockListProjects.mockResolvedValue([
@@ -877,7 +870,7 @@ describe("GET /api/projects route handler", () => {
it("returns healed status after reconciliation promotes stale projects", async () => {
const store = new MockStoreForRoutes();
const app = createServer(store as any);
const app = await createApp(store);
// Simulate reconciliation promoting one stale project
mockReconcileProjectStatuses.mockResolvedValue([
@@ -910,7 +903,7 @@ describe("PUT /api/global-concurrency route handler", () => {
it("updates the central global concurrency limit", async () => {
const store = new MockStoreForRoutes();
const app = createServer(store as any);
const app = await createApp(store);
const res = await request(
app,
@@ -927,7 +920,7 @@ describe("PUT /api/global-concurrency route handler", () => {
it("rejects globalMaxConcurrent above 10000", async () => {
const store = new MockStoreForRoutes();
const app = createServer(store as any);
const app = await createApp(store);
const res = await request(
app,
@@ -997,7 +990,7 @@ describe("GET /api/projects/:id/health route handler", () => {
});
const defaultStore = new MockStoreForRoutes();
const app = createServer(defaultStore as any);
const app = await createApp(defaultStore);
const res = await request(app, "GET", "/api/projects/proj_a/health");
@@ -1039,7 +1032,7 @@ describe("GET /api/projects/:id/health route handler", () => {
});
const defaultStore = new MockStoreForRoutes();
const app = createServer(defaultStore as any);
const app = await createApp(defaultStore);
// Request health for project A
mockGetProject.mockResolvedValue({
@@ -1132,7 +1125,7 @@ describe("GET /api/projects/:id/health route handler", () => {
});
const defaultStore = new MockStoreForRoutes();
const app = createServer(defaultStore as any);
const app = await createApp(defaultStore);
// Should NOT return 404 - should synthesize valid health from project store
const res = await request(app, "GET", "/api/projects/proj_new/health");
@@ -1176,7 +1169,7 @@ describe("GET /api/projects/:id/health route handler", () => {
});
const defaultStore = new MockStoreForRoutes();
const app = createServer(defaultStore as any);
const app = await createApp(defaultStore);
const res = await request(app, "GET", "/api/projects/proj_test/health");

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import type { Task } from "@fusion/core";
import * as fs from "node:fs";
import { createServer } from "../server.js";
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
@@ -98,7 +99,6 @@ describe("GET /api/tasks/:id/diff", () => {
it("returns 404 when task not found", async () => {
const store = new MockStore();
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestDiff(app, "NONEXISTENT");
@@ -109,7 +109,6 @@ describe("GET /api/tasks/:id/diff", () => {
const store = new MockStore();
store.addTask(createTask({ baseBranch: "develop" }));
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestDiff(app);
@@ -135,7 +134,6 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
mergeDetails: { commitSha: "broken_sha" },
}));
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestDiff(app);
@@ -150,7 +148,6 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
mergeDetails: undefined,
}));
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestDiff(app);
@@ -176,7 +173,6 @@ describe("GET /api/tasks/:id/diff — in-progress tasks without valid worktree",
worktree: null as any,
}));
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestDiff(app);
@@ -194,7 +190,6 @@ describe("GET /api/tasks/:id/diff — in-progress tasks without valid worktree",
worktree: undefined,
}));
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestDiff(app);
@@ -220,7 +215,6 @@ describe("GET /api/tasks/:id/diff — in-progress tasks without valid worktree",
return true;
});
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestDiff(app);
@@ -240,7 +234,6 @@ describe("GET /api/tasks/:id/diff — in-progress tasks without valid worktree",
mockExistsSync.mockReturnValue(true);
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestDiff(app);
@@ -263,7 +256,6 @@ describe("GET /api/tasks/:id/diff — in-progress tasks without valid worktree",
return true;
});
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestDiff(app, "FN-679", "/tmp/query-worktree-does-not-exist");

View File

@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import type { Task } from "@fusion/core";
import { createServer } from "../server.js";
import { resolveDiffBase } from "../routes.js";
import { resolveTaskDiffBaseRef } from "../../../engine/src/merger.js";
@@ -107,7 +108,6 @@ describe("GET /api/tasks/:id/file-diffs", () => {
it("returns error when task not found", async () => {
const store = new MockStore();
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestFileDiffs(app, "NONEXISTENT");
@@ -119,7 +119,6 @@ describe("GET /api/tasks/:id/file-diffs", () => {
const store = new MockStore();
store.addTask(createTask({ worktree: undefined }));
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestFileDiffs(app);
@@ -134,7 +133,6 @@ describe("GET /api/tasks/:id/file-diffs", () => {
store.addTask(taskWithMissingWorktree);
mockExistsSync.mockReturnValue(false);
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestFileDiffs(app);
@@ -146,7 +144,6 @@ describe("GET /api/tasks/:id/file-diffs", () => {
const store = new MockStore();
store.addTask(createTask({ baseBranch: "main", baseCommitSha: "taskbase456" }));
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestFileDiffs(app);
@@ -162,7 +159,6 @@ describe("GET /api/tasks/:id/file-diffs", () => {
worktree: undefined,
}));
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestFileDiffs(app);

View File

@@ -1,10 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { createServer } from "../server.js";
import { get, request } from "../test-request.js";
// ── Mock file-service for searchWorkspaceFiles ─────────────────────────
const mockSearchWorkspaceFiles = vi.fn();
const { mockSearchWorkspaceFiles } = vi.hoisted(() => ({
mockSearchWorkspaceFiles: vi.fn(),
}));
vi.mock("../file-service.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../file-service.js")>();
@@ -43,12 +46,10 @@ describe("GET /api/files/search", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
beforeEach(async () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearchWorkspaceFiles.mockReset();
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store);
});
@@ -132,4 +133,4 @@ describe("GET /api/files/search", () => {
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("files");
});
});
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import type { Task } from "@fusion/core";
import { createServer } from "../server.js";
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
@@ -101,7 +102,6 @@ describe("GET /api/tasks/:id/session-files", () => {
it("returns error when task not found", async () => {
const store = new MockStore();
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestSessionFiles(app, "NONEXISTENT");
@@ -113,7 +113,6 @@ describe("GET /api/tasks/:id/session-files", () => {
const store = new MockStore();
store.addTask(createTask({ id: "FN-675-missing", worktree: undefined }));
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestSessionFiles(app, "FN-675-missing");
@@ -128,7 +127,6 @@ describe("GET /api/tasks/:id/session-files", () => {
store.addTask(taskWithMissingWorktree);
mockExistsSync.mockReturnValue(false);
const { createServer } = await import("../server.js");
const app = createServer(store as any);
const response = await requestSessionFiles(app, "FN-675-noexist");

View File

@@ -1662,6 +1662,41 @@ describe("POST /tasks", () => {
}),
);
});
it("passes correct settings to onSummarize callback when project default override is configured", async () => {
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
autoSummarizeTitles: true,
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
defaultProvider: "mistral",
defaultModelId: "mistral-large",
});
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "x".repeat(300),
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
settings: { autoSummarizeTitles: true },
onSummarize: expect.any(Function),
}),
);
});
});
describe("POST /subtasks/*", () => {
@@ -11231,6 +11266,37 @@ describe("POST /api/ai/summarize-title", () => {
}
}
});
it("uses the project default override for summarize-title when no higher lane is configured", async () => {
const fusionCore = await import("@fusion/core");
const summarizeTitleSpy = vi
.spyOn(fusionCore, "summarizeTitle")
.mockResolvedValueOnce("Generated title");
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
const description = "x".repeat(300);
const res = await REQUEST(
buildApp(),
"POST",
"/api/ai/summarize-title",
JSON.stringify({ description }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(summarizeTitleSpy).toHaveBeenCalledWith(
description,
"/test/project",
"openai",
"gpt-4o",
);
});
});
describe("POST /planning/start-streaming with projectId scoping", () => {
@@ -16549,6 +16615,41 @@ describe("POST /workflow-steps/:id/refine", () => {
expect(capturedModel.defaultModelId).toBe("mistral-large");
});
it("falls back to the project default override before the global default lane", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
defaultProvider: "mistral",
defaultModelId: "mistral-large",
});
const updatedWs = { ...ws, prompt: "Refined prompt from AI" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
let capturedModel: { defaultProvider?: string; defaultModelId?: string } = {};
const session = {
on: vi.fn(),
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
};
const createFnAgentMock = vi.fn(async (options: { defaultProvider?: string; defaultModelId?: string }) => {
capturedModel = options;
return { session };
});
__setCreateFnAgentForRefine(createFnAgentMock);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(capturedModel.defaultProvider).toBe("openai");
expect(capturedModel.defaultModelId).toBe("gpt-4o");
});
it("ignores partial project lane (provider only, no modelId)", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);

View File

@@ -1,105 +0,0 @@
import { execSync } from "node:child_process";
import { resolve } from "node:path";
import { rename, access } from "node:fs/promises";
import { describe, it, expect, beforeAll, afterAll } from "vitest";
/**
* Clean-checkout typecheck regression test.
*
* This test verifies that `pnpm typecheck` succeeds from a clean checkout
* state without relying on pre-built dist/ artifacts. It temporarily moves
* any existing dist directories to ensure the typecheck runs against
* source files and project references.
*/
describe("clean-checkout typecheck", () => {
const cwd = resolve(__dirname, "../..");
const distPaths = [
"packages/core/dist",
"packages/engine/dist",
"packages/dashboard/dist",
"packages/cli/dist",
];
const movedSuffix = ".moved-for-test";
const movedPaths = distPaths.map((p) => `${p}${movedSuffix}`);
// Helper to check if a path exists
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
beforeAll(async () => {
// Move any existing dist directories to simulate clean checkout
for (let i = 0; i < distPaths.length; i++) {
const distPath = resolve(cwd, distPaths[i]);
const movedPath = resolve(cwd, movedPaths[i]);
if (await pathExists(distPath)) {
await rename(distPath, movedPath);
}
}
});
afterAll(async () => {
// Restore moved directories even if test failed
for (let i = 0; i < distPaths.length; i++) {
const distPath = resolve(cwd, distPaths[i]);
const movedPath = resolve(cwd, movedPaths[i]);
if (await pathExists(movedPath)) {
try {
await rename(movedPath, distPath);
} catch {
// Best effort - if restore fails, we'll rebuild in subsequent steps
}
}
}
});
it("passes pnpm typecheck without relying on dist/ artifacts", () => {
let error: Error | null = null;
let stdout = "";
let stderr = "";
try {
stdout = execSync("pnpm typecheck", {
cwd,
encoding: "utf-8",
timeout: 120_000,
stdio: ["pipe", "pipe", "pipe"],
});
} catch (e) {
error = e as Error;
// Capture stdout/stderr from the error object if available
const execError = e as { stdout?: string; stderr?: string };
stdout = execError.stdout ?? "";
stderr = execError.stderr ?? "";
}
// Assertion-based verification - fail on non-zero exit
if (error) {
const failureContext = [
"pnpm typecheck failed with non-zero exit code",
"",
"--- STDOUT ---",
stdout,
"",
"--- STDERR ---",
stderr,
"",
"--- ERROR ---",
error.message,
].join("\n");
expect.fail(failureContext);
}
// Verify that typecheck ran and succeeded - just check no error was thrown
// The fact that we got here without error means it passed
expect(error).toBeNull();
}, 180_000);
});

View File

@@ -0,0 +1,4 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
export const execFileAsync = promisify(execFile);

View File

@@ -13,7 +13,24 @@ import os from "node:os";
import v8 from "node:v8";
import type { TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType } from "@fusion/core";
import { type Task, type PiExtensionEntry, type PiExtensionSettings, AutomationStore, RoutineStore, isWebhookTrigger, MemoryBackendError, listAgentMemoryFiles, readAgentMemoryFile, writeAgentMemoryFile, discoverPiExtensions, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
import {
type Task,
type PiExtensionEntry,
type PiExtensionSettings,
AutomationStore,
MemoryBackendError,
RoutineStore,
discoverPiExtensions,
getFusionAgentDir,
getLegacyPiAgentDir,
isWebhookTrigger,
listAgentMemoryFiles,
readAgentMemoryFile,
resolvePlanningSettingsModel,
resolveProjectDefaultModel,
resolveTitleSummarizerSettingsModel,
writeAgentMemoryFile,
} from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { verifyWebhookSignature } from "./github-webhooks.js";
import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
@@ -1576,23 +1593,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Resolve model selection hierarchy for summarization:
// 1. Request body provider+modelId (request override)
// 2. Project titleSummarizer override (titleSummarizerProvider + titleSummarizerModelId)
// 3. Global summarization lane (titleSummarizerGlobalProvider + titleSummarizerGlobalModelId)
// 4. Default pair (defaultProvider + defaultModelId)
// 5. Automatic model resolution (no explicit model)
// 2. Project title summarizer lane
// 3. Global title summarizer lane
// 4. Project planning lane
// 5. Project default override
// 6. Global default
// 7. Automatic model resolution (no explicit model)
const settings = await scopedStore.getSettings();
const resolvedSummarySettings = resolveTitleSummarizerSettingsModel(settings);
const resolvedProvider =
(provider && modelId ? provider : undefined) ||
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerProvider : undefined) ||
(settings.titleSummarizerGlobalProvider && settings.titleSummarizerGlobalModelId ? settings.titleSummarizerGlobalProvider : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultProvider : undefined);
resolvedSummarySettings.provider;
const resolvedModelId =
(provider && modelId ? modelId : undefined) ||
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerModelId : undefined) ||
(settings.titleSummarizerGlobalProvider && settings.titleSummarizerGlobalModelId ? settings.titleSummarizerGlobalModelId : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined);
resolvedSummarySettings.modelId;
if (process.env.FUSION_DEBUG_AI) {
summarizeDiagnostics.info("Summarize title model resolved", {
@@ -2717,24 +2733,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (!createFnAgent) {
throw new Error("createFnAgent is not available");
}
const planningModel = resolvePlanningSettingsModel(settings);
const { session } = await createFnAgent({
cwd: scopedStore.getRootDir(),
systemPrompt,
tools: "readonly",
// Resolve planning model using canonical lane hierarchy:
// 1. Project planning override (planningProvider + planningModelId)
// 2. Global planning lane (planningGlobalProvider + planningGlobalModelId)
// 3. Default pair (defaultProvider + defaultModelId)
defaultProvider: (settings.planningProvider && settings.planningModelId
? settings.planningProvider
: (settings.planningGlobalProvider && settings.planningGlobalModelId
? settings.planningGlobalProvider
: settings.defaultProvider)),
defaultModelId: (settings.planningProvider && settings.planningModelId
? settings.planningModelId
: (settings.planningGlobalProvider && settings.planningGlobalModelId
? settings.planningGlobalModelId
: settings.defaultModelId)),
// 1. Project planning lane
// 2. Global planning lane
// 3. Project default override
// 4. Global default
defaultProvider: planningModel.provider,
defaultModelId: planningModel.modelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
});
@@ -4071,8 +4081,9 @@ async function executeAiPromptStep(
const { createFnAgent, promptWithFallback } = await import("@fusion/engine");
const settings = await taskStore.getSettings();
const modelProvider = step.modelProvider?.trim() || settings.defaultProvider;
const modelId = step.modelId?.trim() || settings.defaultModelId;
const defaultModel = resolveProjectDefaultModel(settings);
const modelProvider = step.modelProvider?.trim() || defaultModel.provider;
const modelId = step.modelId?.trim() || defaultModel.modelId;
let responseText = "";
const { session } = await createFnAgent({
@@ -4263,4 +4274,3 @@ async function executeScheduleSteps(
stepResults,
};
}

View File

@@ -1,4 +1,4 @@
import type { TaskStore, PlanningSummary } from "@fusion/core";
import { resolvePlanningSettingsModel, type TaskStore, type PlanningSummary } from "@fusion/core";
import { ApiError, badRequest, notFound, rateLimited } from "../api-error.js";
import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js";
import type { AiSessionStore } from "../ai-session-store.js";
@@ -457,21 +457,18 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
const rootDir = scopedStore.getRootDir();
// Resolve planning model using canonical lane hierarchy:
// 1. Request body planning override (planningModelProvider + planningModelId)
// 2. Project planning override (settings.planningProvider + settings.planningModelId)
// 3. Global planning lane (settings.planningGlobalProvider + settings.planningGlobalModelId)
// 4. Default pair (settings.defaultProvider + settings.defaultModelId)
// 1. Request body planning override
// 2. Project/global planning lane
// 3. Project default override
// 4. Global default
const resolvedPlanningSettings = resolvePlanningSettingsModel(settings);
const resolvedPlanningProvider =
(planningModelProvider && planningModelId ? planningModelProvider : undefined) ||
(settings.planningProvider && settings.planningModelId ? settings.planningProvider : undefined) ||
(settings.planningGlobalProvider && settings.planningGlobalModelId ? settings.planningGlobalProvider : undefined) ||
settings.defaultProvider;
resolvedPlanningSettings.provider;
const resolvedPlanningModelId =
(planningModelProvider && planningModelId ? planningModelId : undefined) ||
(settings.planningProvider && settings.planningModelId ? settings.planningModelId : undefined) ||
(settings.planningGlobalProvider && settings.planningGlobalModelId ? settings.planningGlobalModelId : undefined) ||
settings.defaultModelId;
resolvedPlanningSettings.modelId;
const { createSessionWithAgent, RateLimitError: _RateLimitError2 } = await import("../planning.js");
const sessionId = await createSessionWithAgent(

View File

@@ -1,9 +1,8 @@
import { execFile } from "node:child_process";
import * as fsPromises from "node:fs/promises";
import { dirname, isAbsolute, join } from "node:path";
import { promisify } from "node:util";
import { ensureMemoryFileWithBackend } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
import { execFileAsync } from "../exec-file.js";
import { getOrCreateProjectStore } from "../project-store-resolver.js";
import type { ApiRouteRegistrar } from "./types.js";
@@ -15,8 +14,6 @@ const {
rm,
} = fsPromises;
const execFileAsync = promisify(execFile);
export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
const { router, options, runtimeLogger, prioritizeProjectsForCurrentDirectory, rethrowAsApiError } = ctx;

View File

@@ -23,6 +23,8 @@ import {
readProjectMemoryFileContent,
refreshQmdProjectMemoryIndex,
resolveMemoryBackend,
resolvePlanningSettingsModel,
resolveTitleSummarizerSettingsModel,
scheduleQmdProjectMemoryRefresh,
searchProjectMemory,
syncBackupRoutine,
@@ -932,19 +934,13 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
}
// Resolve model selection hierarchy for compaction:
// 1. Project titleSummarizer override (titleSummarizerProvider + titleSummarizerModelId)
// 2. Planning lane settings (planningProvider + planningModelId)
// 3. Default pair (defaultProvider + defaultModelId)
// 4. Automatic model resolution (no explicit model)
const resolvedProvider =
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerProvider : undefined) ||
(settings.planningProvider && settings.planningModelId ? settings.planningProvider : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultProvider : undefined);
const resolvedModelId =
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerModelId : undefined) ||
(settings.planningProvider && settings.planningModelId ? settings.planningModelId : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined);
// 1. Project title summarizer lane
// 2. Global title summarizer lane
// 3. Project planning lane
// 4. Project default override
// 5. Global default
const { provider: resolvedProvider, modelId: resolvedModelId } =
resolveTitleSummarizerSettingsModel(settings);
// Import and call the compaction service
const { compactMemoryWithAi } = await import("@fusion/core");
@@ -1005,15 +1001,8 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
throw new ApiError(400, "Memory dreams are disabled. Enable dream processing in memory settings first.");
}
const resolvedProvider =
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerProvider : undefined) ||
(settings.planningProvider && settings.planningModelId ? settings.planningProvider : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultProvider : undefined);
const resolvedModelId =
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerModelId : undefined) ||
(settings.planningProvider && settings.planningModelId ? settings.planningModelId : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined);
const { provider: resolvedProvider, modelId: resolvedModelId } =
resolveTitleSummarizerSettingsModel(settings);
const executePrompt: DreamPromptExecutor = async (prompt: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1183,14 +1172,13 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
// Build the extraction prompt
const extractionPrompt = buildInsightExtractionPrompt(workingMemory, existingInsights ?? "");
// Resolve model selection hierarchy for insight extraction
const resolvedProvider =
(settings.planningProvider && settings.planningModelId ? settings.planningProvider : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultProvider : undefined);
const resolvedModelId =
(settings.planningProvider && settings.planningModelId ? settings.planningModelId : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined);
// Resolve model selection hierarchy for insight extraction:
// 1. Project planning lane
// 2. Global planning lane
// 3. Project default override
// 4. Global default
const { provider: resolvedProvider, modelId: resolvedModelId } =
resolvePlanningSettingsModel(settings);
// Create AI agent session for extraction
const agentResult = await createFnAgentForInsights({

View File

@@ -1,6 +1,11 @@
import { createReadStream } from "node:fs";
import type { TaskStore, Task, TaskDetail, Column } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, validateNodeOverrideChange } from "@fusion/core";
import {
COLUMNS,
VALID_TRANSITIONS,
resolveTitleSummarizerSettingsModel,
validateNodeOverrideChange,
} from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
@@ -143,18 +148,13 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const { summarizeTitle } = await import("@fusion/core");
// Resolve model selection hierarchy for summarization:
// 1. Project titleSummarizer override (titleSummarizerProvider + titleSummarizerModelId)
// 2. Global summarization lane (titleSummarizerGlobalProvider + titleSummarizerGlobalModelId)
// 3. Default pair (defaultProvider + defaultModelId)
const resolvedProvider =
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerProvider : undefined) ||
(settings.titleSummarizerGlobalProvider && settings.titleSummarizerGlobalModelId ? settings.titleSummarizerGlobalProvider : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultProvider : undefined);
const resolvedModelId =
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerModelId : undefined) ||
(settings.titleSummarizerGlobalProvider && settings.titleSummarizerGlobalModelId ? settings.titleSummarizerGlobalModelId : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined);
// 1. Project title summarizer lane
// 2. Global title summarizer lane
// 3. Project planning lane
// 4. Project default override
// 5. Global default
const { provider: resolvedProvider, modelId: resolvedModelId } =
resolveTitleSummarizerSettingsModel(settings);
return await summarizeTitle(desc, scopedStore.getRootDir(), resolvedProvider, resolvedModelId);
} catch (err) {

View File

@@ -1,8 +1,16 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { TaskStore } from "@fusion/core";
import type { AutomationStore } from "@fusion/core";
import type { ScheduledTask, AutomationRunResult, AutomationStep, AutomationStepResult, Column, TaskCreateInput } from "@fusion/core";
import {
resolveProjectDefaultModel,
type TaskStore,
type AutomationStore,
type ScheduledTask,
type AutomationRunResult,
type AutomationStep,
type AutomationStepResult,
type Column,
type TaskCreateInput,
} from "@fusion/core";
import { createLogger } from "./logger.js";
import { defaultShell } from "./shell-utils.js";
import { createFnAgent, promptWithFallback } from "./pi.js";
@@ -494,10 +502,11 @@ export class CronRunner {
};
}
// Resolve model: step override → settings default
// Resolve model: step override → project default override → global default
const settings = await this.store.getSettings();
const modelProvider = step.modelProvider?.trim() || settings.defaultProvider;
const modelId = step.modelId?.trim() || settings.defaultModelId;
const defaultModel = resolveProjectDefaultModel(settings);
const modelProvider = step.modelProvider?.trim() || defaultModel.provider;
const modelId = step.modelId?.trim() || defaultModel.modelId;
const model = modelProvider && modelId
? `${modelProvider}/${modelId}`

View File

@@ -6,7 +6,13 @@ import { isAbsolute, join, relative, resolve as resolvePath } from "node:path";
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext } from "@fusion/core";
import { buildExecutionMemoryInstructions, getTaskMergeBlocker, resolveAgentPrompt, type RunCommandResult } from "@fusion/core";
import {
buildExecutionMemoryInstructions,
getTaskMergeBlocker,
resolveAgentPrompt,
resolveProjectDefaultModel,
type RunCommandResult,
} from "@fusion/core";
import { findWorktreeUser } from "./merger.js";
import { generateWorktreeName, slugify } from "./worktree-names.js";
import { Type, type Static } from "@mariozechner/pi-ai";
@@ -3967,11 +3973,13 @@ and show an appropriate message to the user.\`
});
// Determine primary model and an explicit fallback. The workflow step's
// own override takes precedence; otherwise we use the global default. The
// own override takes precedence; otherwise we use the project default
// override before falling through to the global default. The
// fallback is the per-step override's missing-counterpart settings, then
// the global validator/fallback pair, then the executor's `fallbackProvider`.
const primaryProvider = workflowStep.modelProvider || settings.defaultProvider;
const primaryModelId = workflowStep.modelId || settings.defaultModelId;
const defaultModel = resolveProjectDefaultModel(settings);
const primaryProvider = workflowStep.modelProvider || defaultModel.provider;
const primaryModelId = workflowStep.modelId || defaultModel.modelId;
const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
type ModelTuple = { provider?: string; modelId?: string };

View File

@@ -0,0 +1,7 @@
declare module "cross-spawn" {
const spawn: typeof import("node:child_process").spawn & {
sync: typeof import("node:child_process").spawnSync;
};
export default spawn;
}

View File

@@ -14,5 +14,5 @@
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*.ts", "index.ts"]
"include": ["src/**/*.ts", "src/**/*.d.ts", "index.ts"]
}