diff --git a/docs/agents.md b/docs/agents.md index bdab5b387e..6eefc41e3b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -260,23 +260,23 @@ Programmatic equivalent: ### Assigned-agent runtime model precedence for task execution -When a task is executed by an assigned durable agent, executor session model selection now prefers that agent's explicit runtime model when it is fully specified. +When a task is executed by an assigned durable agent, executor session model selection prefers fresh task and settings values before the agent's stored runtime model. Executor precedence for task runs: -1. Assigned agent `runtimeConfig` model pair (combined `runtimeConfig.model = "provider/modelId"` or separate `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are present -2. Task `modelProvider` + `modelId` -3. Project/global execution lane fallbacks (same resolution as unassigned runs) +1. Task `modelProvider` + `modelId` +2. Project/global execution lane fallbacks (same resolution as unassigned runs) +3. Assigned agent `runtimeConfig` model pair (combined `runtimeConfig.model = "provider/modelId"` or separate `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) only when both provider and model ID are present and no task/settings pair is configured -If the assigned agent runtime model is missing or incomplete, Fusion falls back to the normal task/settings execution hierarchy. +If the assigned agent runtime model is missing or incomplete, Fusion continues to automatic provider/model resolution without mixing partial runtime fields into the selected pair. ### Durable-agent heartbeat model precedence and unavailable-provider behavior -Heartbeat sessions for durable agents resolve models with heartbeat-specific fallback semantics: +Heartbeat sessions for durable agents resolve models with the same fresh-settings-first rule: -1. Agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when present -2. Execution-lane settings fallback (`executionProvider`/`executionModelId` → `executionGlobalProvider`/`executionGlobalModelId` → project/global defaults) +1. Execution-lane settings fallback (`executionProvider`/`executionModelId` → `executionGlobalProvider`/`executionGlobalModelId` → project/global defaults) +2. Agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) only when both provider and model ID are present and no execution/default pair is configured -When the runtime model is present and differs from execution-lane settings, heartbeat passes the execution-lane model as a fallback pair for session creation. +Heartbeat no longer passes a stale runtime model ahead of a saved execution lane or project default override. Task-scoped heartbeat runs for durable agents execute inside the task's git worktree (same as ephemeral task execution), while no-task heartbeat runs continue to execute from the project root. Heartbeat and executor system prompts share the same active-goal context injector (`buildGoalContextSection`), so both lanes receive identical goal preambles when active goals exist. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 6c2c4d8978..1e8d0f188d 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -788,26 +788,26 @@ Fusion resolves task models through workflow-backed lane values first, then glob ### Executor model -1. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set -2. Per-task `modelProvider` + `modelId` -3. Default workflow lane value `executionProvider` + `executionModelId` -4. Global `executionGlobalProvider` + `executionGlobalModelId` -5. Project `defaultProviderOverride` + `defaultModelIdOverride` -6. Global `defaultProvider` + `defaultModelId` +1. Per-task `modelProvider` + `modelId` +2. Default workflow lane value `executionProvider` + `executionModelId` +3. Global `executionGlobalProvider` + `executionGlobalModelId` +4. Project `defaultProviderOverride` + `defaultModelIdOverride` +5. Global `defaultProvider` + `defaultModelId` +6. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set and no task/lane/default pair is configured 7. Automatic provider/model resolution ### Heartbeat model (durable agents) Heartbeat sessions for durable agents use this order: -1. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when present -2. Default workflow lane value `executionProvider` + `executionModelId` -3. Global `executionGlobalProvider` + `executionGlobalModelId` -4. Project `defaultProviderOverride` + `defaultModelIdOverride` -5. Global `defaultProvider` + `defaultModelId` +1. Default workflow lane value `executionProvider` + `executionModelId` +2. Global `executionGlobalProvider` + `executionGlobalModelId` +3. Project `defaultProviderOverride` + `defaultModelIdOverride` +4. Global `defaultProvider` + `defaultModelId` +5. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set and no execution/default pair is configured 6. Automatic provider/model resolution -When heartbeat has both (1) and (2-5), the runtime model is used as primary and the execution-lane model is passed as fallback. On timer-triggered runs, unrecoverable missing-provider credential/registry failures complete as `heartbeat_model_unavailable` instead of permanently setting the durable agent to `state=error`. +On timer-triggered runs, unrecoverable missing-provider credential/registry failures complete as `heartbeat_model_unavailable` instead of permanently setting the durable agent to `state=error`. ### Reviewer model @@ -818,13 +818,13 @@ When heartbeat has both (1) and (2-5), the runtime model is used as primary and 5. Global `defaultProvider` + `defaultModelId` 6. Automatic provider/model resolution -Mission validation sessions use this same validator lane, with an assigned durable agent runtime model taking precedence when the linked task has one. +Mission validation sessions use this same validator lane; assigned durable agent runtime models are only used as a fallback when no complete validator/default pair is configured. ### Merger model -1. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set -2. Project `defaultProviderOverride` + `defaultModelIdOverride` -3. Global `defaultProvider` + `defaultModelId` +1. Project `defaultProviderOverride` + `defaultModelIdOverride` +2. Global `defaultProvider` + `defaultModelId` +3. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set and no default pair is configured 4. Automatic provider/model resolution For post-merge prompt workflow steps, explicit step-level `modelProvider` + `modelId` overrides take precedence over the merger lane above. diff --git a/packages/core/src/__tests__/model-resolution.test.ts b/packages/core/src/__tests__/model-resolution.test.ts index d79ba0352c..c48ef1e1e5 100644 --- a/packages/core/src/__tests__/model-resolution.test.ts +++ b/packages/core/src/__tests__/model-resolution.test.ts @@ -82,6 +82,104 @@ describe("model-resolution", () => { ).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" }); }); + it("uses project lane overrides for every pure settings lane before global and default fallbacks", () => { + expect(resolveExecutionSettingsModel({ + executionProvider: "project-exec-provider", + executionModelId: "project-exec-model", + executionGlobalProvider: "global-exec-provider", + executionGlobalModelId: "global-exec-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + })).toEqual({ provider: "project-exec-provider", modelId: "project-exec-model" }); + + expect(resolvePlanningSettingsModel({ + planningProvider: "project-plan-provider", + planningModelId: "project-plan-model", + planningGlobalProvider: "global-plan-provider", + planningGlobalModelId: "global-plan-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + })).toEqual({ provider: "project-plan-provider", modelId: "project-plan-model" }); + + expect(resolveValidatorSettingsModel({ + validatorProvider: "project-validator-provider", + validatorModelId: "project-validator-model", + validatorGlobalProvider: "global-validator-provider", + validatorGlobalModelId: "global-validator-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + })).toEqual({ provider: "project-validator-provider", modelId: "project-validator-model" }); + + expect(resolveTitleSummarizerSettingsModel({ + titleSummarizerProvider: "project-title-provider", + titleSummarizerModelId: "project-title-model", + titleSummarizerGlobalProvider: "global-title-provider", + titleSummarizerGlobalModelId: "global-title-model", + planningProvider: "project-plan-provider", + planningModelId: "project-plan-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + })).toEqual({ provider: "project-title-provider", modelId: "project-title-model" }); + }); + + it("does not mix partial project lane pairs with lower precedence model fields", () => { + expect(resolveExecutionSettingsModel({ + executionProvider: "project-exec-provider", + executionGlobalProvider: "global-exec-provider", + executionGlobalModelId: "global-exec-model", + })).toEqual({ provider: "global-exec-provider", modelId: "global-exec-model" }); + + expect(resolvePlanningSettingsModel({ + planningModelId: "project-plan-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + })).toEqual({ provider: "project-default-provider", modelId: "project-default-model" }); + + expect(resolveValidatorSettingsModel({ + validatorProvider: "project-validator-provider", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + })).toEqual({ provider: "global-default-provider", modelId: "global-default-model" }); + + expect(resolveTitleSummarizerSettingsModel({ + titleSummarizerModelId: "project-title-model", + titleSummarizerGlobalProvider: "global-title-provider", + titleSummarizerGlobalModelId: "global-title-model", + planningProvider: "project-plan-provider", + planningModelId: "project-plan-model", + })).toEqual({ provider: "global-title-provider", modelId: "global-title-model" }); + }); + + it("keeps global lane and default fallback order intact when project lanes are unset", () => { + expect(resolveExecutionSettingsModel({ + executionGlobalProvider: "global-exec-provider", + executionGlobalModelId: "global-exec-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + })).toEqual({ provider: "global-exec-provider", modelId: "global-exec-model" }); + + expect(resolvePlanningSettingsModel({ + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + })).toEqual({ provider: "project-default-provider", modelId: "project-default-model" }); + + expect(resolveValidatorSettingsModel({ + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + })).toEqual({ provider: "global-default-provider", modelId: "global-default-model" }); + + expect(resolveTitleSummarizerSettingsModel({ + titleSummarizerGlobalProvider: "global-title-provider", + titleSummarizerGlobalModelId: "global-title-model", + planningProvider: "project-plan-provider", + planningModelId: "project-plan-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + })).toEqual({ provider: "global-title-provider", modelId: "global-title-model" }); + }); + it("uses task overrides before settings fallbacks", () => { expect( resolveTaskExecutionModel( diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 468dba67ac..ac879d63b4 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -7,6 +7,7 @@ const maxWorkers = computeMaxWorkers(); export default defineConfig({ resolve: { alias: { + "@fusion/core": resolve(__dirname, "./src/index.ts"), "@fusion/test-utils": resolve(__dirname, "./src/__test-utils__/workspace.ts"), "@fusion/plugin-sdk": resolve(__dirname, "../plugin-sdk/src/index.ts"), }, diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index 0ef1a3b8b8..fe4958be2d 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -145,6 +145,14 @@ describe("resolve session model parity", () => { provider: "validator-task-provider", modelId: "validator-task-model", }); + expect(resolveValidatorSessionModel(undefined, undefined, { + ...settings, + validatorProvider: "google", + validatorModelId: "gemini-2.5-pro", + }, staleRuntimeConfig)).toEqual({ + provider: "google", + modelId: "gemini-2.5-pro", + }); expect(resolveMergerSessionModel(settings, staleRuntimeConfig)).toEqual({ provider: "google", modelId: "gemini-2.5-pro", @@ -240,6 +248,171 @@ describe("resolve session model parity", () => { }); }); +describe("project model override precedence invariant", () => { + const staleRuntimeConfig = { model: "stale-provider/stale-model" }; + const partialRuntimeConfigs: Array> = [ + { modelProvider: "stale-provider" }, + { modelId: "stale-model" }, + { model: "stale-provider" }, + ]; + + const sessionCases = [ + { + label: "executor", + settings: { executionProvider: "project-exec-provider", executionModelId: "project-exec-model" }, + resolve: (runtimeConfig?: Record) => + resolveExecutorSessionModel(undefined, undefined, { + executionProvider: "project-exec-provider", + executionModelId: "project-exec-model", + }, runtimeConfig), + expected: { provider: "project-exec-provider", modelId: "project-exec-model" }, + }, + { + label: "planning", + settings: { planningProvider: "project-plan-provider", planningModelId: "project-plan-model" }, + resolve: (runtimeConfig?: Record) => + resolvePlanningSessionModel(undefined, undefined, { + planningProvider: "project-plan-provider", + planningModelId: "project-plan-model", + }, runtimeConfig), + expected: { provider: "project-plan-provider", modelId: "project-plan-model" }, + }, + { + label: "validator", + settings: { validatorProvider: "project-validator-provider", validatorModelId: "project-validator-model" }, + resolve: (runtimeConfig?: Record) => + resolveValidatorSessionModel(undefined, undefined, { + validatorProvider: "project-validator-provider", + validatorModelId: "project-validator-model", + }, runtimeConfig), + expected: { provider: "project-validator-provider", modelId: "project-validator-model" }, + }, + { + label: "heartbeat execution lane", + settings: { executionProvider: "project-heartbeat-provider", executionModelId: "project-heartbeat-model" }, + resolve: (runtimeConfig?: Record) => { + const resolved = resolveHeartbeatSessionModels({ + executionProvider: "project-heartbeat-provider", + executionModelId: "project-heartbeat-model", + }, runtimeConfig); + return { provider: resolved.defaultProvider, modelId: resolved.defaultModelId }; + }, + expected: { provider: "project-heartbeat-provider", modelId: "project-heartbeat-model" }, + }, + { + label: "merger default lane", + settings: { defaultProviderOverride: "project-default-provider", defaultModelIdOverride: "project-default-model" }, + resolve: (runtimeConfig?: Record) => + resolveMergerSessionModel({ + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + }, runtimeConfig), + expected: { provider: "project-default-provider", modelId: "project-default-model" }, + }, + ]; + + it.each(sessionCases)("$label project override wins when runtimeConfig is absent, complete, or partial", ({ resolve, expected }) => { + expect(resolve()).toEqual(expected); + expect(resolve(staleRuntimeConfig)).toEqual(expected); + for (const partialRuntimeConfig of partialRuntimeConfigs) { + expect(resolve(partialRuntimeConfig)).toEqual(expected); + } + }); + + it("per-task overrides still outrank saved project lane overrides", () => { + const runtimeConfig = { model: "stale-provider/stale-model" }; + + expect(resolveExecutorSessionModel("task-provider", "task-model", { + executionProvider: "project-provider", + executionModelId: "project-model", + }, runtimeConfig)).toEqual({ provider: "task-provider", modelId: "task-model" }); + expect(resolvePlanningSessionModel("task-planning-provider", "task-planning-model", { + planningProvider: "project-planning-provider", + planningModelId: "project-planning-model", + }, runtimeConfig)).toEqual({ provider: "task-planning-provider", modelId: "task-planning-model" }); + expect(resolveValidatorSessionModel("task-validator-provider", "task-validator-model", { + validatorProvider: "project-validator-provider", + validatorModelId: "project-validator-model", + }, runtimeConfig)).toEqual({ provider: "task-validator-provider", modelId: "task-validator-model" }); + }); + + it("falls back to global lanes and global defaults only when project lanes are unset", () => { + const runtimeConfig = { model: "stale-provider/stale-model" }; + + expect(resolveExecutorSessionModel(undefined, undefined, { + executionGlobalProvider: "global-exec-provider", + executionGlobalModelId: "global-exec-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + }, runtimeConfig)).toEqual({ provider: "global-exec-provider", modelId: "global-exec-model" }); + expect(resolvePlanningSessionModel(undefined, undefined, { + planningGlobalProvider: "global-plan-provider", + planningGlobalModelId: "global-plan-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + }, runtimeConfig)).toEqual({ provider: "global-plan-provider", modelId: "global-plan-model" }); + expect(resolveValidatorSessionModel(undefined, undefined, { + validatorGlobalProvider: "global-validator-provider", + validatorGlobalModelId: "global-validator-model", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + }, runtimeConfig)).toEqual({ provider: "global-validator-provider", modelId: "global-validator-model" }); + expect(resolveMergerSessionModel({ + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + }, runtimeConfig)).toEqual({ provider: "global-default-provider", modelId: "global-default-model" }); + }); + + it("uses complete runtimeConfig only after project defaults and globals are absent", () => { + const runtimeConfig = { model: "runtime-provider/runtime-model" }; + + expect(resolveExecutorSessionModel(undefined, undefined, { + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + }, runtimeConfig)).toEqual({ provider: "project-default-provider", modelId: "project-default-model" }); + expect(resolvePlanningSessionModel(undefined, undefined, { + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + }, runtimeConfig)).toEqual({ provider: "global-default-provider", modelId: "global-default-model" }); + expect(resolveValidatorSessionModel(undefined, undefined, undefined, runtimeConfig)).toEqual({ + provider: "runtime-provider", + modelId: "runtime-model", + }); + expect(resolveHeartbeatSessionModels(undefined, runtimeConfig)).toEqual({ + defaultProvider: "runtime-provider", + defaultModelId: "runtime-model", + fallbackProvider: undefined, + fallbackModelId: undefined, + }); + }); + + it("forces mock/scripted across session surfaces when testMode or mock default is active", () => { + const runtimeConfig = { model: "runtime-provider/runtime-model" }; + const testModeSettings = { + testMode: true, + executionProvider: "project-exec-provider", + executionModelId: "project-exec-model", + planningProvider: "project-plan-provider", + planningModelId: "project-plan-model", + validatorProvider: "project-validator-provider", + validatorModelId: "project-validator-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + }; + + expect(resolveExecutorSessionModel("task-provider", "task-model", testModeSettings, runtimeConfig)).toEqual({ provider: "mock", modelId: "scripted" }); + expect(resolvePlanningSessionModel("task-plan-provider", "task-plan-model", testModeSettings, runtimeConfig)).toEqual({ provider: "mock", modelId: "scripted" }); + expect(resolveValidatorSessionModel("task-validator-provider", "task-validator-model", testModeSettings, runtimeConfig)).toEqual({ provider: "mock", modelId: "scripted" }); + expect(resolveMergerSessionModel(testModeSettings, runtimeConfig)).toEqual({ provider: "mock", modelId: "scripted" }); + expect(resolveHeartbeatSessionModels({ defaultProvider: "mock", defaultModelId: "global-default-model" }, runtimeConfig)).toEqual({ + defaultProvider: "mock", + defaultModelId: "scripted", + fallbackProvider: undefined, + fallbackModelId: undefined, + }); + }); +}); + describe("createResolvedAgentSession", () => { beforeEach(() => { resolveRuntimeMock.mockReset(); diff --git a/packages/engine/src/__tests__/merger-ai-merge-body.test.ts b/packages/engine/src/__tests__/merger-ai-merge-body.test.ts index e31bdfd86d..4a39a22130 100644 --- a/packages/engine/src/__tests__/merger-ai-merge-body.test.ts +++ b/packages/engine/src/__tests__/merger-ai-merge-body.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("../pi.js", () => ({ createFnAgent: vi.fn(), @@ -16,7 +16,14 @@ vi.mock("node:child_process", () => ({ import { composeMergeCommitBody, __testOnlyBuildDeterministicMergeMessage as buildDeterministicMergeMessage, + __testOnlyResolveSafeCommitBody as resolveSafeCommitBody, } from "../merger.js"; +import * as core from "@fusion/core"; +import { DEFAULT_SETTINGS } from "@fusion/core"; + +afterEach(() => { + vi.restoreAllMocks(); +}); describe("composeMergeCommitBody", () => { const commitLog = "- feat: one"; @@ -58,6 +65,82 @@ describe("composeMergeCommitBody", () => { }); }); +describe("resolveSafeCommitBody", () => { + async function resolveWithSettings(settings: Partial) { + const summarySpy = vi.spyOn(core, "summarizeCommitBody").mockResolvedValue("- ai body"); + + await expect(resolveSafeCommitBody({ + rootDir: "/tmp/project", + taskId: "FN-6228", + branch: "fusion/FN-6228", + commitLog: "", + diffStat: "1 file changed", + settings: { + ...DEFAULT_SETTINGS, + useAiMergeCommitSummary: true, + ...settings, + }, + })).resolves.toBe("- ai body"); + + return summarySpy.mock.calls[0]; + } + + it("uses the project title summarizer lane before all fallbacks", async () => { + const call = await resolveWithSettings({ + titleSummarizerProvider: "project-title-provider", + titleSummarizerModelId: "project-title-model", + titleSummarizerGlobalProvider: "global-title-provider", + titleSummarizerGlobalModelId: "global-title-model", + planningProvider: "planning-provider", + planningModelId: "planning-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + }); + + expect(call?.[2]).toBe("project-title-provider"); + expect(call?.[3]).toBe("project-title-model"); + }); + + it("uses title-summarizer global, planning, project default, then global default fallbacks", async () => { + await expect(resolveWithSettings({ + titleSummarizerGlobalProvider: "global-title-provider", + titleSummarizerGlobalModelId: "global-title-model", + planningProvider: "planning-provider", + planningModelId: "planning-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + })).resolves.toMatchObject({ 2: "global-title-provider", 3: "global-title-model" }); + + vi.restoreAllMocks(); + await expect(resolveWithSettings({ + planningProvider: "planning-provider", + planningModelId: "planning-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + })).resolves.toMatchObject({ 2: "planning-provider", 3: "planning-model" }); + + vi.restoreAllMocks(); + await expect(resolveWithSettings({ + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + })).resolves.toMatchObject({ 2: "project-default-provider", 3: "project-default-model" }); + + vi.restoreAllMocks(); + await expect(resolveWithSettings({ + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + })).resolves.toMatchObject({ 2: "global-default-provider", 3: "global-default-model" }); + }); +}); + describe("buildDeterministicMergeMessage", () => { const decodeArg = (arg: string) => arg.replace(/^-m\s+"/, "").replace(/"$/, "").replace(/\\(["\\$`])/g, "$1"); diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index 31c1144d17..7d252371d5 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -144,6 +144,10 @@ function pickSettingsThenRuntimeModel( settingsModel: ResolvedModelSelection, assignedAgentRuntimeConfig?: Record, ): { provider: string | undefined; modelId: string | undefined } { + // Project/task/global settings are the authoritative model hierarchy. The + // assigned durable agent runtime model is only a final compatibility fallback + // when the hierarchy produced no complete pair; partial runtime pairs must + // never be mixed with settings fields or mask saved project overrides. if (settingsModel.provider && settingsModel.modelId) { return { provider: settingsModel.provider, diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 9cd0dd777f..966652f0a4 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -4056,6 +4056,7 @@ async function buildDeterministicMergeMessage(params: { } export { buildDeterministicMergeMessage as __testOnlyBuildDeterministicMergeMessage }; +export { resolveSafeCommitBody as __testOnlyResolveSafeCommitBody }; export { resolveComplexRebaseConflictsWithAi as __testOnlyResolveComplexRebaseConflictsWithAi }; /** @@ -6672,25 +6673,12 @@ async function resolveSafeCommitBody(opts: { const cleanStat = opts.diffStat.trim(); if (cleanStat.length > 0) { if (opts.settings.useAiMergeCommitSummary) { - // Prefer the dedicated title-summarization model — a small, fast tier - // intended for short summarization. Falls back to the project / global - // default model when the summarizer lane isn't configured. The core - // `summarizeCommitBody` helper handles missing-runtime / timeout / empty - // response gracefully and returns null. - const useTitleSummarizer = - !!opts.settings.titleSummarizerProvider && !!opts.settings.titleSummarizerModelId; - const provider = useTitleSummarizer - ? opts.settings.titleSummarizerProvider! - : (opts.settings.defaultProviderOverride && opts.settings.defaultModelIdOverride - ? opts.settings.defaultProviderOverride - : opts.settings.defaultProvider); - const modelId = useTitleSummarizer - ? opts.settings.titleSummarizerModelId! - : (opts.settings.defaultProviderOverride && opts.settings.defaultModelIdOverride - ? opts.settings.defaultModelIdOverride - : opts.settings.defaultModelId); + // Prefer the dedicated title-summarization lane and its documented + // fallbacks. The core `summarizeCommitBody` helper handles missing-runtime + // / timeout / empty response gracefully and returns null. + const resolved = resolveTitleSummarizerSettingsModel(opts.settings); - const ai = await summarizeCommitBody(cleanStat, opts.rootDir, provider, modelId, { + const ai = await summarizeCommitBody(cleanStat, opts.rootDir, resolved.provider, resolved.modelId, { branch: opts.branch, taskId: opts.taskId, signal: opts.signal,