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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user