FN-6228: prioritize project model overrides

Ensure saved project model settings outrank stale durable-agent runtime models across AI lanes.

- Document the updated task, heartbeat, validator, merger, and summarizer model precedence.
- Add model-resolution regression coverage for project lanes, globals, defaults, runtime fallback, and test-mode mock forcing.
- Route AI merge body summarization through the shared title-summarizer settings resolver.
- Preserve upstream triage prompt coverage for TRIAGE_POLICY_PROMPT while resolving conflicts.

Files changed:
 docs/agents.md                                     |  18 +--
 docs/settings-reference.md                         |  32 ++--
 .../core/src/__tests__/model-resolution.test.ts    |  98 ++++++++++++
 packages/core/vitest.config.ts                     |   1 +
 .../src/__tests__/agent-session-helpers.test.ts    | 173 +++++++++++++++++++++
 .../src/__tests__/merger-ai-merge-body.test.ts     |  85 +++++++++-
 packages/engine/src/agent-session-helpers.ts       |   4 +
 packages/engine/src/merger.ts                      |  26 +---
 8 files changed, 392 insertions(+), 45 deletions(-)

Fusion-Task-Id: FN-6228

Fusion-Task-Lineage: a6c03f2a-dde7-4037-bdcd-a1c92a8b7fae
This commit is contained in:
gsxdsm
2026-06-11 20:44:52 -07:00
parent 7763ba5fab
commit 905d5bf849
8 changed files with 391 additions and 44 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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(

View File

@@ -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"),
},

View File

@@ -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<Record<string, unknown>> = [
{ 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<string, unknown>) =>
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<string, unknown>) =>
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<string, unknown>) =>
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<string, unknown>) => {
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<string, unknown>) =>
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();

View File

@@ -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<typeof DEFAULT_SETTINGS>) {
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");

View File

@@ -144,6 +144,10 @@ function pickSettingsThenRuntimeModel(
settingsModel: ResolvedModelSelection,
assignedAgentRuntimeConfig?: Record<string, unknown>,
): { 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,

View File

@@ -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,