feat(FN-1678): merge fusion/fn-1678

This commit is contained in:
gsxdsm
2026-04-15 09:31:03 -07:00
parent 9e67333982
commit dde7c3174e
12 changed files with 559 additions and 69 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Canonical model lane hierarchy resolution: Engine and dashboard API entry points now consistently resolve AI models using the same precedence order (per-task override → project override → global lane → default). This eliminates "same task, different model" outcomes across executor, triage, reviewer, and dashboard helper routes.

View File

@@ -126,6 +126,26 @@ In **Triage**, an AI agent reads your project, understands context, and writes a
- **Multi-Project** — Manage multiple projects from a single installation with project isolation
- **Inter-Agent Messaging** — Built-in messaging for coordination between agents and users
### Model System
Fusion uses a dual-scope model hierarchy with five independent lanes. Global settings define baseline defaults, and project settings provide per-project overrides.
**Lanes:**
| Lane | Purpose | Global Baseline Keys | Project Override Keys |
|------|---------|---------------------|----------------------|
| Executor | Task execution agent | `executionGlobalProvider` + `executionGlobalModelId` | `executionProvider` + `executionModelId` |
| Planning/Triage | Task specification agent | `planningGlobalProvider` + `planningGlobalModelId` | `planningProvider` + `planningModelId` |
| Validator | Plan/code reviewer | `validatorGlobalProvider` + `validatorGlobalModelId` | `validatorProvider` + `validatorModelId` |
| Title Summarization | Auto-title generation | `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` | `titleSummarizerProvider` + `titleSummarizerModelId` |
| Workflow Step Refinement | AI prompt refinement | (uses `defaultProvider`/`defaultModelId`) | (uses `modelProvider`/`modelId` on WorkflowStep) |
**Per-Task Overrides:** Tasks can override the executor, validator, and planning lanes with per-task model fields (`modelProvider`/`modelId`, `validatorModelProvider`/`validatorModelId`, `planningModelProvider`/`planningModelId`).
**Precedence:** Per-task → Project override → Global lane → `defaultProvider`/`defaultModelId` → Automatic resolution.
For full settings documentation, see [Settings Reference](./docs/settings-reference.md).
### Quick Examples
```bash

View File

@@ -204,7 +204,7 @@ Fusion uses a dual-scope model settings system with five lanes. Global settings
5. Global `defaultProvider` + `defaultModelId`
6. Automatic provider/model resolution
> **Note:** Runtime fallback precedence logic is deferred to FN-1711. The hierarchy above reflects the schema contracts added in FN-1710.
> **Note:** Runtime fallback precedence logic is implemented in engine and dashboard routes (FN-1711). The hierarchy above reflects the full schema contracts added in FN-1710.
---

View File

@@ -77,6 +77,14 @@ API endpoints reviewed:
| `favoriteModels` | Global | `GET/PUT /api/settings/global` | Favorited models |
| `openrouterModelSync` | Global | `GET/PUT /api/settings/global` | Startup model sync behavior |
| `modelOnboardingComplete` | Global | `GET/PUT /api/settings/global` | Onboarding completion flag |
| `executionGlobalProvider` | Global | `GET/PUT /api/settings/global` | Global baseline AI provider for task execution |
| `executionGlobalModelId` | Global | `GET/PUT /api/settings/global` | Global baseline AI model ID for task execution |
| `planningGlobalProvider` | Global | `GET/PUT /api/settings/global` | Global baseline AI provider for planning/triage |
| `planningGlobalModelId` | Global | `GET/PUT /api/settings/global` | Global baseline AI model ID for planning/triage |
| `validatorGlobalProvider` | Global | `GET/PUT /api/settings/global` | Global baseline AI provider for validator/reviewer |
| `validatorGlobalModelId` | Global | `GET/PUT /api/settings/global` | Global baseline AI model ID for validator/reviewer |
| `titleSummarizerGlobalProvider` | Global | `GET/PUT /api/settings/global` | Global baseline AI provider for title summarization |
| `titleSummarizerGlobalModelId` | Global | `GET/PUT /api/settings/global` | Global baseline AI model ID for title summarization |
### 3.2 Project settings (`.fusion/config.json` / `config.settings`)
@@ -97,6 +105,10 @@ API endpoints reviewed:
| `worktreeNaming` | Project | `GET/PUT /api/settings` | Worktree naming strategy |
| `taskPrefix` | Project | `GET/PUT /api/settings` | Task ID prefix |
| `includeTaskIdInCommit` | Project | `GET/PUT /api/settings` | Commit scope formatting |
| `defaultProviderOverride` | Project | `GET/PUT /api/settings` | Project-level override for base default provider |
| `defaultModelIdOverride` | Project | `GET/PUT /api/settings` | Project-level override for base default model ID |
| `executionProvider` | Project | `GET/PUT /api/settings` | AI provider for task execution |
| `executionModelId` | Project | `GET/PUT /api/settings` | AI model ID for task execution |
| `planningProvider` | Project | `GET/PUT /api/settings` | Planning model provider |
| `planningModelId` | Project | `GET/PUT /api/settings` | Planning model id |
| `planningFallbackProvider` | Project | `GET/PUT /api/settings` | Planning fallback provider |
@@ -147,6 +159,7 @@ API endpoints reviewed:
Additional backend notes:
- `githubTokenConfigured` is returned by `GET /api/settings` but is **computed server-side**, not persisted.
- Non-settings config persisted in backend include `nextId`, `workflowSteps`, and `nextWorkflowStepId` (`config` row / config JSON compatibility path).
- **`*Global*` keys are never persisted in project settings** — these belong exclusively to global settings. Conversely, project-only keys (`defaultProviderOverride`, `executionProvider`, `planningProvider`, etc.) are never persisted in global settings. The two scopes are strictly isolated.
---

View File

@@ -2813,15 +2813,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
try {
const { summarizeTitle } = await import("@fusion/core");
// Resolve model selection hierarchy for summarization
// 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.planningProvider && settings.planningModelId ? settings.planningProvider : 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.planningProvider && settings.planningModelId ? settings.planningModelId : undefined) ||
(settings.titleSummarizerGlobalProvider && settings.titleSummarizerGlobalModelId ? settings.titleSummarizerGlobalModelId : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined);
return await summarizeTitle(desc, scopedStore.getRootDir(), resolvedProvider, resolvedModelId);
@@ -7386,13 +7389,30 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const ip = req.ip || req.socket.remoteAddress || "unknown";
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)
const resolvedPlanningProvider =
(planningModelProvider && planningModelId ? planningModelProvider : undefined) ||
(settings.planningProvider && settings.planningModelId ? settings.planningProvider : undefined) ||
(settings.planningGlobalProvider && settings.planningGlobalModelId ? settings.planningGlobalProvider : undefined) ||
settings.defaultProvider;
const resolvedPlanningModelId =
(planningModelProvider && planningModelId ? planningModelId : undefined) ||
(settings.planningProvider && settings.planningModelId ? settings.planningModelId : undefined) ||
(settings.planningGlobalProvider && settings.planningGlobalModelId ? settings.planningGlobalModelId : undefined) ||
settings.defaultModelId;
const { createSessionWithAgent, RateLimitError: _RateLimitError2 } = await import("./planning.js");
const sessionId = await createSessionWithAgent(
ip,
initialPlan,
rootDir,
planningModelProvider,
planningModelId,
resolvedPlanningProvider,
resolvedPlanningModelId,
settings.promptOverrides,
);
res.status(201).json({ sessionId });
@@ -8499,24 +8519,24 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw err;
}
// Resolve model selection hierarchy:
// 1. Request body provider+modelId
// 2. Settings titleSummarizerProvider + titleSummarizerModelId
// 3. Settings planningProvider + planningModelId
// 4. Settings defaultProvider + defaultModelId
// 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)
const settings = await scopedStore.getSettings();
const resolvedProvider =
(provider && modelId ? provider : undefined) ||
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerProvider : undefined) ||
(settings.planningProvider && settings.planningModelId ? settings.planningProvider : undefined) ||
(settings.titleSummarizerGlobalProvider && settings.titleSummarizerGlobalModelId ? settings.titleSummarizerGlobalProvider : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultProvider : undefined);
const resolvedModelId =
(provider && modelId ? modelId : undefined) ||
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerModelId : undefined) ||
(settings.planningProvider && settings.planningModelId ? settings.planningModelId : undefined) ||
(settings.titleSummarizerGlobalProvider && settings.titleSummarizerGlobalModelId ? settings.titleSummarizerGlobalModelId : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined);
if (process.env.FUSION_DEBUG_AI) {
@@ -9480,8 +9500,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
cwd: scopedStore.getRootDir(),
systemPrompt,
tools: "none",
defaultProvider: settings.planningProvider || settings.defaultProvider,
defaultModelId: settings.planningModelId || settings.defaultModelId,
// 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)),
defaultThinkingLevel: settings.defaultThinkingLevel,
});

View File

@@ -5655,6 +5655,269 @@ describe("Per-task model overrides", () => {
});
});
// ── Lane hierarchy model resolution tests ─────────────────────────────────────
describe("Executor lane hierarchy model resolution", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
});
it("resolves task override when both provider and modelId are set", async () => {
const store = createMockStore();
const capturedOptions: any[] = [];
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
capturedOptions.push(opts);
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {},
},
} as any;
});
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
defaultProvider: "openai",
defaultModelId: "gpt-4o",
executionGlobalProvider: "google",
executionGlobalModelId: "gemini-2.5",
executionProvider: undefined,
executionModelId: undefined,
});
const executor = new TaskExecutor(store, "/tmp/test");
store.getTask.mockResolvedValue({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "# test",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
// Task override takes precedence
expect(capturedOptions[0].defaultProvider).toBe("anthropic");
expect(capturedOptions[0].defaultModelId).toBe("claude-sonnet-4-5");
});
it("resolves project execution override when task override is not set", async () => {
const store = createMockStore();
const capturedOptions: any[] = [];
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
capturedOptions.push(opts);
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {},
},
} as any;
});
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
defaultProvider: "openai",
defaultModelId: "gpt-4o",
executionGlobalProvider: "google",
executionGlobalModelId: "gemini-2.5",
executionProvider: "anthropic",
executionModelId: "claude-opus-4",
});
const executor = new TaskExecutor(store, "/tmp/test");
store.getTask.mockResolvedValue({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "# test",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
// No task-level model override
});
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
// No task-level model override
});
// Project execution override takes precedence over global lane
expect(capturedOptions[0].defaultProvider).toBe("anthropic");
expect(capturedOptions[0].defaultModelId).toBe("claude-opus-4");
});
it("resolves global execution lane when project override is not set", async () => {
const store = createMockStore();
const capturedOptions: any[] = [];
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
capturedOptions.push(opts);
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {},
},
} as any;
});
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
defaultProvider: "openai",
defaultModelId: "gpt-4o",
executionGlobalProvider: "google",
executionGlobalModelId: "gemini-2.5",
executionProvider: undefined,
executionModelId: undefined,
});
const executor = new TaskExecutor(store, "/tmp/test");
store.getTask.mockResolvedValue({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "# test",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
// No task-level model override
});
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
// No task-level model override
});
// Global execution lane takes precedence over default
expect(capturedOptions[0].defaultProvider).toBe("google");
expect(capturedOptions[0].defaultModelId).toBe("gemini-2.5");
});
it("falls back to default when no lane overrides are set", async () => {
const store = createMockStore();
const capturedOptions: any[] = [];
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
capturedOptions.push(opts);
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {},
},
} as any;
});
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
defaultProvider: "openai",
defaultModelId: "gpt-4o",
executionGlobalProvider: undefined,
executionGlobalModelId: undefined,
executionProvider: undefined,
executionModelId: undefined,
});
const executor = new TaskExecutor(store, "/tmp/test");
store.getTask.mockResolvedValue({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "# test",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
// No task-level model override
});
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
// No task-level model override
});
// Default takes precedence when no lane overrides are set
expect(capturedOptions[0].defaultProvider).toBe("openai");
expect(capturedOptions[0].defaultModelId).toBe("gpt-4o");
});
});
// ── Per-task thinkingLevel override tests ───────────────────────────
describe("Per-task thinkingLevel override", () => {

View File

@@ -497,12 +497,21 @@ export class TaskExecutor {
activeEntry.lastModelId = task.modelId;
const settings = await this.store.getSettings();
// Resolve model using canonical lane hierarchy for hot-swap
const newProvider = task.modelProvider && task.modelId
? task.modelProvider
: settings?.defaultProvider;
: (settings?.executionProvider && settings?.executionModelId
? settings.executionProvider
: (settings?.executionGlobalProvider && settings?.executionGlobalModelId
? settings.executionGlobalProvider
: settings?.defaultProvider));
const newModelId = task.modelProvider && task.modelId
? task.modelId
: settings?.defaultModelId;
: (settings?.executionProvider && settings?.executionModelId
? settings.executionModelId
: (settings?.executionGlobalProvider && settings?.executionGlobalModelId
? settings.executionGlobalModelId
: settings?.defaultModelId));
if (newProvider && newModelId) {
try {
@@ -1414,14 +1423,25 @@ export class TaskExecutor {
});
const agentWork = async () => {
// Resolve model settings: use per-task overrides if both provider and modelId are set,
// otherwise fall back to global settings
// Resolve model settings using canonical lane hierarchy:
// 1. Task override pair (modelProvider + modelId)
// 2. Project execution override pair (executionProvider + executionModelId)
// 3. Global execution lane pair (executionGlobalProvider + executionGlobalModelId)
// 4. Default pair (defaultProvider + defaultModelId)
const executorProvider = detail.modelProvider && detail.modelId
? detail.modelProvider
: settings.defaultProvider;
: (settings.executionProvider && settings.executionModelId
? settings.executionProvider
: (settings.executionGlobalProvider && settings.executionGlobalModelId
? settings.executionGlobalProvider
: settings.defaultProvider));
const executorModelId = detail.modelProvider && detail.modelId
? detail.modelId
: settings.defaultModelId;
: (settings.executionProvider && settings.executionModelId
? settings.executionModelId
: (settings.executionGlobalProvider && settings.executionGlobalModelId
? settings.executionGlobalModelId
: settings.defaultModelId));
const executorFallbackProvider = settings.fallbackProvider;
const executorFallbackModelId = settings.fallbackModelId;
const executorThinkingLevel = detail.thinkingLevel ?? settings.defaultThinkingLevel;
@@ -2337,16 +2357,24 @@ export class TaskExecutor {
reviewType, promptContent, baseline,
{
onText: (delta) => options.onAgentText?.(taskId, delta),
// Execution defaults as final fallback
defaultProvider: settings.defaultProvider,
defaultModelId: settings.defaultModelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
defaultThinkingLevel: detail.thinkingLevel ?? settings.defaultThinkingLevel,
// Per-task validator overrides take precedence over global validator settings
validatorModelProvider: detail.validatorModelProvider ?? settings.validatorProvider,
validatorModelId: detail.validatorModelId ?? settings.validatorModelId,
validatorFallbackModelProvider: settings.validatorFallbackProvider,
validatorFallbackModelId: settings.validatorFallbackModelId,
// Task-level validator override (from task)
taskValidatorProvider: detail.validatorModelProvider,
taskValidatorModelId: detail.validatorModelId,
// Project-level validator override
projectValidatorProvider: settings.validatorProvider,
projectValidatorModelId: settings.validatorModelId,
// Project-level validator fallback
projectValidatorFallbackProvider: settings.validatorFallbackProvider,
projectValidatorFallbackModelId: settings.validatorFallbackModelId,
// Global validator lane
globalValidatorProvider: settings.validatorGlobalProvider,
globalValidatorModelId: settings.validatorGlobalModelId,
store,
taskId,
task: detail,

View File

@@ -259,7 +259,7 @@ describe("reviewStep — validator model overrides", () => {
vi.clearAllMocks();
});
it("uses validatorModelProvider and validatorModelId when both are set", async () => {
it("uses taskValidatorProvider and taskValidatorModelId when both are set", async () => {
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
);
@@ -270,8 +270,8 @@ describe("reviewStep — validator model overrides", () => {
{
defaultProvider: "openai",
defaultModelId: "gpt-4o",
validatorModelProvider: "anthropic",
validatorModelId: "claude-sonnet-4-5",
taskValidatorProvider: "anthropic",
taskValidatorModelId: "claude-sonnet-4-5",
},
);
@@ -281,7 +281,7 @@ describe("reviewStep — validator model overrides", () => {
expect(opts.defaultModelId).toBe("claude-sonnet-4-5");
});
it("falls back to defaultProvider/defaultModelId when validatorModelProvider is missing", async () => {
it("falls back to defaultProvider/defaultModelId when taskValidatorProvider is missing", async () => {
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
);
@@ -292,8 +292,8 @@ describe("reviewStep — validator model overrides", () => {
{
defaultProvider: "openai",
defaultModelId: "gpt-4o",
// validatorModelProvider is missing
validatorModelId: "claude-sonnet-4-5",
// taskValidatorProvider is missing
taskValidatorModelId: "claude-sonnet-4-5",
},
);
@@ -303,7 +303,7 @@ describe("reviewStep — validator model overrides", () => {
expect(opts.defaultModelId).toBe("gpt-4o");
});
it("falls back to defaultProvider/defaultModelId when validatorModelId is missing", async () => {
it("falls back to defaultProvider/defaultModelId when taskValidatorModelId is missing", async () => {
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
);
@@ -314,8 +314,8 @@ describe("reviewStep — validator model overrides", () => {
{
defaultProvider: "openai",
defaultModelId: "gpt-4o",
validatorModelProvider: "anthropic",
// validatorModelId is missing
taskValidatorProvider: "anthropic",
// taskValidatorModelId is missing
},
);
@@ -344,6 +344,74 @@ describe("reviewStep — validator model overrides", () => {
expect(opts.defaultProvider).toBe("openai");
expect(opts.defaultModelId).toBe("gpt-4o");
});
it("resolves project validator override when task override is not set", async () => {
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
);
await reviewStep(
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
defaultProvider: "openai",
defaultModelId: "gpt-4o",
projectValidatorProvider: "anthropic",
projectValidatorModelId: "claude-opus-4",
// taskValidatorProvider is not set
},
);
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect(opts.defaultProvider).toBe("anthropic");
expect(opts.defaultModelId).toBe("claude-opus-4");
});
it("resolves global validator lane when project override is not set", async () => {
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
);
await reviewStep(
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
defaultProvider: "openai",
defaultModelId: "gpt-4o",
globalValidatorProvider: "google",
globalValidatorModelId: "gemini-2.5",
// projectValidatorProvider is not set
// taskValidatorProvider is not set
},
);
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect(opts.defaultProvider).toBe("google");
expect(opts.defaultModelId).toBe("gemini-2.5");
});
it("falls back to execution default when no validator lanes are set", async () => {
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
);
await reviewStep(
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
defaultProvider: "openai",
defaultModelId: "gpt-4o",
// No validator lanes set
},
);
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect(opts.defaultProvider).toBe("openai");
expect(opts.defaultModelId).toBe("gpt-4o");
});
});
describe("REVIEWER_SYSTEM_PROMPT", () => {

View File

@@ -191,18 +191,26 @@ export interface ReviewOptions {
defaultProvider?: string;
/** Default model ID within the provider (e.g. "claude-sonnet-4-5"). When set with `defaultProvider`, overrides the reviewer's model selection. */
defaultModelId?: string;
/** Validator model provider override. When both `validatorModelProvider` and `validatorModelId` are set, they take precedence over `defaultProvider`/`defaultModelId`. */
validatorModelProvider?: string;
/** Validator model ID override. When both `validatorModelProvider` and `validatorModelId` are set, they take precedence over `defaultProvider`/`defaultModelId`. */
validatorModelId?: string;
/** Task-level validator model provider override. When both provider and modelId are set, takes precedence over project/global lanes. */
taskValidatorProvider?: string;
/** Task-level validator model ID override. When both provider and modelId are set, takes precedence over project/global lanes. */
taskValidatorModelId?: string;
/** Project-level validator model provider override. Takes precedence over global validator lane. */
projectValidatorProvider?: string;
/** Project-level validator model ID override. Takes precedence over global validator lane. */
projectValidatorModelId?: string;
/** Global validator lane provider. Takes precedence over execution defaults. */
globalValidatorProvider?: string;
/** Global validator lane model ID. Takes precedence over execution defaults. */
globalValidatorModelId?: string;
/** Fallback model provider used when the primary reviewer model hits a retryable provider-side error. */
fallbackProvider?: string;
/** Fallback model ID used with `fallbackProvider`. */
fallbackModelId?: string;
/** Validator fallback model provider override. When both validator fallback fields are set, they take precedence over fallbackProvider/fallbackModelId. */
validatorFallbackModelProvider?: string;
/** Validator fallback model ID override. When both validator fallback fields are set, they take precedence over fallbackProvider/fallbackModelId. */
validatorFallbackModelId?: string;
/** Project-level validator fallback provider override. Takes precedence over global fallback. */
projectValidatorFallbackProvider?: string;
/** Project-level validator fallback model ID override. Takes precedence over global fallback. */
projectValidatorFallbackModelId?: string;
/** Default thinking effort level for the reviewer agent session. */
defaultThinkingLevel?: string;
/** Task store for persisting agent log entries. When provided with `taskId`, enables full conversation logging. */
@@ -251,19 +259,34 @@ export async function reviewStep(
})
: null;
// Resolve validator model settings: use per-task overrides if both provider and modelId are set,
// otherwise fall back to defaultProvider/defaultModelId
const validatorProvider = options.validatorModelProvider && options.validatorModelId
? options.validatorModelProvider
: options.defaultProvider;
const validatorModelId = options.validatorModelProvider && options.validatorModelId
? options.validatorModelId
: options.defaultModelId;
const validatorFallbackProvider = options.validatorFallbackModelProvider && options.validatorFallbackModelId
? options.validatorFallbackModelProvider
// Resolve validator model settings using canonical lane hierarchy:
// 1. Task-level validator override pair (taskValidatorProvider + taskValidatorModelId)
// 2. Project-level validator override pair (projectValidatorProvider + projectValidatorModelId)
// 3. Global validator lane pair (globalValidatorProvider + globalValidatorModelId)
// 4. Execution default pair (defaultProvider + defaultModelId)
const validatorProvider = options.taskValidatorProvider && options.taskValidatorModelId
? options.taskValidatorProvider
: (options.projectValidatorProvider && options.projectValidatorModelId
? options.projectValidatorProvider
: (options.globalValidatorProvider && options.globalValidatorModelId
? options.globalValidatorProvider
: options.defaultProvider));
const validatorModelId = options.taskValidatorProvider && options.taskValidatorModelId
? options.taskValidatorModelId
: (options.projectValidatorProvider && options.projectValidatorModelId
? options.projectValidatorModelId
: (options.globalValidatorProvider && options.globalValidatorModelId
? options.globalValidatorModelId
: options.defaultModelId));
// Resolve validator fallback using lane hierarchy:
// 1. Project-level validator fallback (projectValidatorFallbackProvider + projectValidatorFallbackModelId)
// 2. Execution fallback (fallbackProvider + fallbackModelId)
const validatorFallbackProvider = options.projectValidatorFallbackProvider && options.projectValidatorFallbackModelId
? options.projectValidatorFallbackProvider
: options.fallbackProvider;
const validatorFallbackModelId = options.validatorFallbackModelProvider && options.validatorFallbackModelId
? options.validatorFallbackModelId
const validatorFallbackModelId = options.projectValidatorFallbackProvider && options.projectValidatorFallbackModelId
? options.projectValidatorFallbackModelId
: options.fallbackModelId;
// Resolve per-agent custom instructions for the reviewer role

View File

@@ -788,12 +788,34 @@ export class StepSessionExecutor {
: [];
// Create fresh agent session for this attempt
// Resolve executor model using canonical lane hierarchy:
// 1. Task override pair (taskDetail.modelProvider + taskDetail.modelId)
// 2. Project execution override pair (settings.executionProvider + settings.executionModelId)
// 3. Global execution lane pair (settings.executionGlobalProvider + settings.executionGlobalModelId)
// 4. Default pair (settings.defaultProvider + settings.defaultModelId)
const executorProvider = taskDetail.modelProvider && taskDetail.modelId
? taskDetail.modelProvider
: (settings.executionProvider && settings.executionModelId
? settings.executionProvider
: (settings.executionGlobalProvider && settings.executionGlobalModelId
? settings.executionGlobalProvider
: settings.defaultProvider));
const executorModelId = taskDetail.modelProvider && taskDetail.modelId
? taskDetail.modelId
: (settings.executionProvider && settings.executionModelId
? settings.executionModelId
: (settings.executionGlobalProvider && settings.executionGlobalModelId
? settings.executionGlobalModelId
: settings.defaultModelId));
const createResult = await createKbAgent({
cwd: worktreePath,
systemPrompt: `You are an AI agent executing step ${stepIndex} of task ${taskDetail.id}. Follow instructions precisely.`,
defaultProvider: taskDetail.modelProvider,
defaultModelId: taskDetail.modelId,
defaultThinkingLevel: taskDetail.thinkingLevel,
defaultProvider: executorProvider,
defaultModelId: executorModelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
defaultThinkingLevel: taskDetail.thinkingLevel ?? settings.defaultThinkingLevel,
customTools: [...pluginTools, ...documentTools],
onText: (delta) => {
agentLogger.onText(delta);

View File

@@ -732,8 +732,8 @@ describe("TriageProcessor", () => {
{
defaultProvider: "anthropic",
defaultModelId: "claude-opus-4-6",
validatorProvider: "anthropic",
validatorModelId: "claude-opus-4-6",
projectValidatorProvider: "anthropic",
projectValidatorModelId: "claude-opus-4-6",
},
);
@@ -751,8 +751,8 @@ describe("TriageProcessor", () => {
expect.objectContaining({
defaultProvider: "openai-codex",
defaultModelId: "gpt-5.4",
validatorModelProvider: "zai",
validatorModelId: "glm-5.1",
projectValidatorProvider: "zai",
projectValidatorModelId: "glm-5.1",
userComments: undefined,
}),
);

View File

@@ -697,17 +697,25 @@ export class TriageProcessor {
onThinking: agentLogger.onThinking,
onToolStart: agentLogger.onToolStart,
onToolEnd: agentLogger.onToolEnd,
// Per-task planning model override takes precedence, then project settings, then global defaults
// Resolve planning model using canonical lane hierarchy:
// 1. Task planning override pair (planningModelProvider + planningModelId)
// 2. Project planning override pair (planningProvider + planningModelId)
// 3. Global planning lane pair (planningGlobalProvider + planningGlobalModelId)
// 4. Default pair (defaultProvider + defaultModelId)
defaultProvider: task.planningModelProvider && task.planningModelId
? task.planningModelProvider
: (settings.planningProvider && settings.planningModelId
? settings.planningProvider
: settings.defaultProvider),
? settings.planningProvider
: (settings.planningGlobalProvider && settings.planningGlobalModelId
? settings.planningGlobalProvider
: settings.defaultProvider)),
defaultModelId: task.planningModelProvider && task.planningModelId
? task.planningModelId
: (settings.planningProvider && settings.planningModelId
? settings.planningModelId
: settings.defaultModelId),
? settings.planningModelId
: (settings.planningGlobalProvider && settings.planningGlobalModelId
? settings.planningGlobalModelId
: settings.defaultModelId)),
fallbackProvider: settings.planningFallbackProvider && settings.planningFallbackModelId
? settings.planningFallbackProvider
: settings.fallbackProvider,
@@ -1191,10 +1199,18 @@ export class TriageProcessor {
undefined,
{
onText: (delta) => options.onAgentText?.(taskId, delta),
// Execution defaults as final fallback
defaultProvider: currentSettings.defaultProvider,
defaultModelId: currentSettings.defaultModelId,
validatorModelProvider: currentSettings.validatorProvider,
validatorModelId: currentSettings.validatorModelId,
// Project-level validator override
projectValidatorProvider: currentSettings.validatorProvider,
projectValidatorModelId: currentSettings.validatorModelId,
// Project-level validator fallback
projectValidatorFallbackProvider: currentSettings.validatorFallbackProvider,
projectValidatorFallbackModelId: currentSettings.validatorFallbackModelId,
// Global validator lane
globalValidatorProvider: currentSettings.validatorGlobalProvider,
globalValidatorModelId: currentSettings.validatorGlobalModelId,
defaultThinkingLevel: currentSettings.defaultThinkingLevel,
store,
taskId,