fix(FN-2662): honor project model overrides and stabilize tests
This commit is contained in:
5
.changeset/fix-project-model-overrides.md
Normal file
5
.changeset/fix-project-model-overrides.md
Normal 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.
|
||||
119
packages/core/src/__tests__/model-resolution.test.ts
Normal file
119
packages/core/src/__tests__/model-resolution.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
148
packages/core/src/model-resolution.ts
Normal file
148
packages/core/src/model-resolution.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 ~10–80 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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
4
packages/dashboard/src/exec-file.ts
Normal file
4
packages/dashboard/src/exec-file.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
export const execFileAsync = promisify(execFile);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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 };
|
||||
|
||||
7
packages/pi-claude-cli/src/types/cross-spawn.d.ts
vendored
Normal file
7
packages/pi-claude-cli/src/types/cross-spawn.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -14,5 +14,5 @@
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "index.ts"]
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "index.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user