From 5b4ec4c08f79a52c6068a37b9f35ff4f07c49f57 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 04:29:17 -0700 Subject: [PATCH] FN-8042: add merger fallback model lane Add configurable project fallback models for AI merger retries. - Resolve complete project merger fallback pairs before the shared global fallback. - Expose merger fallback model and thinking controls in Project Models with reset-aware persistence. - Apply the fallback lane across merger, review, PR-response, and recovery sessions with tests and documentation. Files changed: .changeset/fn-8042-merger-fallback-model.md | 7 +++ docs/settings-reference.md | 4 +- .../core/src/__tests__/model-resolution.test.ts | 24 ++++++++++ .../core/src/__tests__/settings-parity.test.ts | 3 ++ packages/core/src/index.gate.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/model-resolution.ts | 22 +++++++++ packages/core/src/settings-schema.ts | 4 ++ packages/core/src/types.ts | 13 ++++++ .../app/__tests__/settings-save-split.test.ts | 41 +++++++++++++++++ .../app/__tests__/settings-sections.test.tsx | 36 +++++++++++++++ .../app/components/settings/save-split.ts | 7 +-- .../settings/sections/ProjectModelsSection.tsx | 53 +++++++++++++++++++++- .../settings-default-descriptions.test.tsx | 3 ++ .../src/__tests__/agent-session-helpers.test.ts | 1 + .../__tests__/mcp-pr-response-forwarding.test.ts | 1 + packages/engine/src/agent-session-helpers.ts | 10 +++- packages/engine/src/merger-ai.ts | 17 +++++-- packages/engine/src/merger.ts | 43 +++++++++++++----- packages/engine/src/pr-response-run-ops.ts | 11 +++-- 20 files changed, 276 insertions(+), 26 deletions(-) Fusion-Task-Id: FN-8042 Fusion-Task-Lineage: 31762a72-461e-438c-a12d-2816580283fd Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8042-merger-fallback-model.md | 7 +++ docs/settings-reference.md | 4 +- .../src/__tests__/model-resolution.test.ts | 24 +++++++++ .../src/__tests__/settings-parity.test.ts | 3 ++ packages/core/src/index.gate.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/model-resolution.ts | 22 ++++++++ packages/core/src/settings-schema.ts | 4 ++ packages/core/src/types.ts | 13 +++++ .../app/__tests__/settings-save-split.test.ts | 41 ++++++++++++++ .../app/__tests__/settings-sections.test.tsx | 36 +++++++++++++ .../app/components/settings/save-split.ts | 7 +-- .../sections/ProjectModelsSection.tsx | 53 ++++++++++++++++++- .../settings-default-descriptions.test.tsx | 3 ++ .../__tests__/agent-session-helpers.test.ts | 1 + .../mcp-pr-response-forwarding.test.ts | 1 + packages/engine/src/agent-session-helpers.ts | 10 +++- packages/engine/src/merger-ai.ts | 17 ++++-- packages/engine/src/merger.ts | 43 +++++++++++---- packages/engine/src/pr-response-run-ops.ts | 11 ++-- 20 files changed, 276 insertions(+), 26 deletions(-) create mode 100644 .changeset/fn-8042-merger-fallback-model.md diff --git a/.changeset/fn-8042-merger-fallback-model.md b/.changeset/fn-8042-merger-fallback-model.md new file mode 100644 index 0000000000..47c6d43329 --- /dev/null +++ b/.changeset/fn-8042-merger-fallback-model.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a dedicated fallback model lane for the AI merger, configurable under Project Models. +category: feature +dev: New project settings mergerFallbackProvider/mergerFallbackModelId/mergerFallbackThinkingLevel; resolveMergerFallbackModel resolves project merger-fallback → global fallbackProvider/fallbackModelId. Every merger session builder consumes the resolved merger fallback pair and lane-specific fallback thinking; unset keys preserve existing behavior. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 6e7eb26241..758068a910 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1090,7 +1090,9 @@ Dedicated model lane for merger agent sessions (conflict resolution, clean-room Thinking level for merger sessions: project `mergerThinkingLevel` → global `mergerGlobalThinkingLevel` → project `defaultThinkingLevelOverride` → global `defaultThinkingLevel`. -Session-level model fallback on retryable failures still uses the shared global `fallbackProvider` + `fallbackModelId` pair (not a merger-specific fallback lane). +Session-level fallback on retryable failures resolves project `mergerFallbackProvider` + `mergerFallbackModelId` first, then the shared global `fallbackProvider` + `fallbackModelId` pair. Partial project fallback pairs are ignored. This lane applies to every merger session, including merger-ai mutating and review agents and the PR-response runner. + +Fallback thinking level resolves project `mergerFallbackThinkingLevel` → global `fallbackThinkingLevel` → the merger thinking chain above. 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 7cdb3ddf01..3abd0d3eb2 100644 --- a/packages/core/src/__tests__/model-resolution.test.ts +++ b/packages/core/src/__tests__/model-resolution.test.ts @@ -7,6 +7,7 @@ import { resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel, + resolveMergerFallbackModel, resolveMergerSettingsModel, resolveTitleSummarizerSettingsModel, resolveValidatorSettingsModel, @@ -144,6 +145,29 @@ describe("model-resolution", () => { })).toEqual({ provider: "project-merger-provider", modelId: "project-merger-model" }); }); + it("resolves merger fallback project pair, global fallback, partial pairs, and test mode", () => { + expect(resolveMergerFallbackModel({ + mergerFallbackProvider: "project-merger-fallback-provider", + mergerFallbackModelId: "project-merger-fallback-model", + fallbackProvider: "global-fallback-provider", + fallbackModelId: "global-fallback-model", + })).toEqual({ provider: "project-merger-fallback-provider", modelId: "project-merger-fallback-model" }); + expect(resolveMergerFallbackModel({ + mergerFallbackProvider: "partial-provider", + fallbackProvider: "global-fallback-provider", + fallbackModelId: "global-fallback-model", + })).toEqual({ provider: "global-fallback-provider", modelId: "global-fallback-model" }); + expect(resolveMergerFallbackModel({ + fallbackProvider: "global-fallback-provider", + fallbackModelId: "global-fallback-model", + })).toEqual({ provider: "global-fallback-provider", modelId: "global-fallback-model" }); + expect(resolveMergerFallbackModel({ + testMode: true, + mergerFallbackProvider: "project-merger-fallback-provider", + mergerFallbackModelId: "project-merger-fallback-model", + })).toEqual(TEST_MODE_RESOLVED); + }); + it("does not mix partial project lane pairs with lower precedence model fields", () => { expect(resolveExecutionSettingsModel({ executionProvider: "project-exec-provider", diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index e40860adc1..ee33a9e2d8 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -106,6 +106,7 @@ describe("settings key parity", () => { expect(DEFAULT_PROJECT_SETTINGS.titleSummarizerThinkingLevel).toBeUndefined(); expect(DEFAULT_PROJECT_SETTINGS.titleSummarizerFallbackThinkingLevel).toBeUndefined(); expect(DEFAULT_PROJECT_SETTINGS.mergerThinkingLevel).toBeUndefined(); + expect(DEFAULT_PROJECT_SETTINGS.mergerFallbackThinkingLevel).toBeUndefined(); expect(DEFAULT_GLOBAL_SETTINGS.fallbackThinkingLevel).toBeUndefined(); expect(DEFAULT_GLOBAL_SETTINGS.executionGlobalThinkingLevel).toBeUndefined(); expect(DEFAULT_GLOBAL_SETTINGS.planningGlobalThinkingLevel).toBeUndefined(); @@ -117,6 +118,7 @@ describe("settings key parity", () => { expect(isProjectSettingsKey("titleSummarizerThinkingLevel")).toBe(true); expect(isProjectSettingsKey("titleSummarizerFallbackThinkingLevel")).toBe(true); expect(isProjectSettingsKey("mergerThinkingLevel")).toBe(true); + expect(isProjectSettingsKey("mergerFallbackThinkingLevel")).toBe(true); expect(isGlobalSettingsKey("fallbackThinkingLevel")).toBe(true); expect(isProjectSettingsKey("planningFallbackThinkingLevel")).toBe(false); expect(isGlobalSettingsKey("planningFallbackThinkingLevel")).toBe(false); @@ -582,6 +584,7 @@ describe("model lane key parity regression (FN-1729)", () => { { provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId", expectedScope: "project" }, // Merger lane (project-scoped like summarizer; not workflow-moved) { provider: "mergerProvider", modelId: "mergerModelId", expectedScope: "project" }, + { provider: "mergerFallbackProvider", modelId: "mergerFallbackModelId", expectedScope: "project" }, { provider: "mergerGlobalProvider", modelId: "mergerGlobalModelId", expectedScope: "global" }, ] as const; diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 624e2abed6..7d2e9777e1 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -1371,6 +1371,7 @@ export { applyTestModeOverrides, isTestModeActive, resolveExecutionSettingsModel, + resolveMergerFallbackModel, resolveMergerSettingsModel, resolvePlanningSettingsModel, resolveProjectDefaultModel, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5bd5f348dc..d58c2d7e15 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1423,6 +1423,7 @@ export { applyTestModeOverrides, isTestModeActive, resolveExecutionSettingsModel, + resolveMergerFallbackModel, resolveMergerSettingsModel, resolvePhaseThinkingLevel, resolvePlanningSettingsModel, diff --git a/packages/core/src/model-resolution.ts b/packages/core/src/model-resolution.ts index a0cd0bbc8b..8c8b8d4b7c 100644 --- a/packages/core/src/model-resolution.ts +++ b/packages/core/src/model-resolution.ts @@ -240,6 +240,28 @@ export function resolveMergerSettingsModel(settings?: Partial): Resolv ); } +/** + * FNXC:Settings-MergerModel 2026-07-16-00:00: + * Retryable merger sessions resolve a project merger-fallback pair before the shared + * global fallback pair. Complete-pair selection and test-mode override behavior match + * all other model lanes, preserving existing behavior while this lane is unset. + */ +export function resolveMergerFallbackModel(settings?: Partial): ResolvedModelSelection { + return applyTestModeOverrides( + pickFirstModelPair( + { + provider: settings?.mergerFallbackProvider, + modelId: settings?.mergerFallbackModelId, + }, + { + provider: settings?.fallbackProvider, + modelId: settings?.fallbackModelId, + }, + ), + settings, + ); +} + export function resolveTaskExecutionModel( task: TaskModelLike, settings?: Partial, diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 05f1d96c3a..0d191745de 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -641,6 +641,10 @@ export const DEFAULT_PROJECT_SETTINGS = { mergerProvider: undefined, mergerModelId: undefined, mergerThinkingLevel: undefined, + // FNXC:Settings-MergerModel 2026-07-16-00:00: project merger fallback overrides shared global fallback only when its provider/model pair is complete. + mergerFallbackProvider: undefined, + mergerFallbackModelId: undefined, + mergerFallbackThinkingLevel: undefined, prTitlePromptInstructions: undefined, prDescriptionPromptInstructions: undefined, scripts: undefined, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index cbe3fdaf29..d2af7981b2 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3921,6 +3921,19 @@ export interface ProjectSettings { /** Optional project merger-lane thinking override. Inherits through global merger thinking then default thinking when unset. */ mergerThinkingLevel?: ThinkingLevel; /* + FNXC:Settings-MergerModel 2026-07-16-00:00: + Merger session retries need a project-scoped fallback lane so operators can pin a merge-capable recovery model without changing the shared global fallback. Both provider and model id must be set; partial pairs fall through to the shared global fallback pair. + */ + /** Project fallback AI model provider for merger agent sessions. + * Must be set together with `mergerFallbackModelId`. Resolves before the global + * `fallbackProvider`/`fallbackModelId` pair. */ + mergerFallbackProvider?: string; + /** Project fallback AI model ID for merger agent sessions. + * Must be set together with `mergerFallbackProvider`. */ + mergerFallbackModelId?: string; + /** Optional project merger-fallback thinking override. Falls through to global fallback thinking, then merger thinking. */ + mergerFallbackThinkingLevel?: ThinkingLevel; + /* FNXC:GitHubImportTranslate 2026-07-15-09:30: Import Tasks auto-translation is a dedicated one-off AI helper lane, kept separate from the summarization lane so operators can pin a cheap/fast translation model without dragging title summarization onto it. Both provider and model id must be set together; partial pairs are ignored and fall through to global translate lane, then summarization, then project/global default. diff --git a/packages/dashboard/app/__tests__/settings-save-split.test.ts b/packages/dashboard/app/__tests__/settings-save-split.test.ts index e0425c1493..2c09670219 100644 --- a/packages/dashboard/app/__tests__/settings-save-split.test.ts +++ b/packages/dashboard/app/__tests__/settings-save-split.test.ts @@ -648,6 +648,47 @@ describe("splitSettingsSave", () => { expect(projectClearResult.projectPatch).toEqual({ titleSummarizerFallbackThinkingLevel: null }); }); + it("emits set and null-cleared merger fallback lane values in the project patch", () => { + const setResult = splitSettingsSave({ + payload: { + mergerFallbackProvider: "anthropic", + mergerFallbackModelId: "claude-sonnet-4-5", + mergerFallbackThinkingLevel: "high", + }, + initialValues: {} as never, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "project-models", + }); + expect(setResult.projectPatch).toEqual({ + mergerFallbackProvider: "anthropic", + mergerFallbackModelId: "claude-sonnet-4-5", + mergerFallbackThinkingLevel: "high", + }); + + const clearResult = splitSettingsSave({ + payload: { + mergerFallbackProvider: undefined, + mergerFallbackModelId: undefined, + mergerFallbackThinkingLevel: undefined, + }, + initialValues: {} as never, + initialScopedValues: { + global: {}, + project: { + mergerFallbackProvider: "anthropic", + mergerFallbackModelId: "claude-sonnet-4-5", + mergerFallbackThinkingLevel: "high", + }, + } as never, + activeSection: "project-models", + }); + expect(clearResult.projectPatch).toEqual({ + mergerFallbackProvider: null, + mergerFallbackModelId: null, + mergerFallbackThinkingLevel: null, + }); + }); + it("drops plain-undefined global keys that were never set", () => { const payload: Record = { ntfyTopic: undefined, // never had a value → passed through as undefined diff --git a/packages/dashboard/app/__tests__/settings-sections.test.tsx b/packages/dashboard/app/__tests__/settings-sections.test.tsx index 9a2cb26a59..c397224a70 100644 --- a/packages/dashboard/app/__tests__/settings-sections.test.tsx +++ b/packages/dashboard/app/__tests__/settings-sections.test.tsx @@ -596,6 +596,42 @@ describe("ProjectModelsSection", () => { expect(screen.getByTestId("mock-model-dropdown-titleSummarizerFallbackModel")).toHaveAttribute("data-thinking-value", ""); }); + it("renders merger fallback directly after merger and resets its complete lane", () => { + function ProjectMergerFallbackHost() { + const [form, setForm] = useState({ + mergerFallbackProvider: "anthropic", + mergerFallbackModelId: "claude-sonnet-4-5", + mergerFallbackThinkingLevel: "high", + } as SettingsFormState); + return ; + } + render(); + + const dropdown = screen.getByTestId("mock-model-dropdown-mergerFallbackModel"); + expect(dropdown).toHaveAttribute("data-value", "anthropic/claude-sonnet-4-5"); + expect(dropdown).toHaveAttribute("data-thinking-value", "high"); + const dropdowns = Array.from(document.querySelectorAll("[data-testid^='mock-model-dropdown-']")); + const mergerIndex = dropdowns.indexOf(screen.getByTestId("mock-model-dropdown-mergerModel")); + expect(dropdowns[mergerIndex + 1]).toBe(dropdown); + + fireEvent.click(screen.getByRole("button", { name: "Reset" })); + expect(dropdown).toHaveAttribute("data-value", ""); + expect(dropdown).toHaveAttribute("data-thinking-value", ""); + }); + it("wires workflow fallback lane thinking render, persist, and reset", async () => { let saver: (() => Promise) | null = null; vi.mocked(fetchWorkflow).mockResolvedValue({ diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index b41047e3be..f6d333944a 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -43,9 +43,9 @@ import type { GlobalSettings, McpServersSettings, Settings } from "@fusion/core" * null-as-delete instead of being dropped as an unchanged inherited value. * * FNXC:Settings-MergerModel 2026-07-13-07:52: - * Merger project lane (provider/model/thinking) is project-scoped like - * title summarizer — not workflow-moved — so it participates in the same - * changed-only/null-as-delete project-branch write path. + * Merger primary and merger-fallback lanes (provider/model/thinking) are project-scoped + * like title summarizer — not workflow-moved — so set edits and null-as-delete resets + * participate in the same changed-only project-branch write path instead of being dropped. * * FNXC:GitHubImportTranslate 2026-07-15-09:30: * The import auto-translation settings are PROJECT-scoped: which language a repo's @@ -63,6 +63,7 @@ export const MODEL_LANE_KEYS = [ "titleSummarizerProvider", "titleSummarizerModelId", "titleSummarizerFallbackProvider", "titleSummarizerFallbackModelId", "titleSummarizerFallbackThinkingLevel", "mergerProvider", "mergerModelId", "mergerThinkingLevel", + "mergerFallbackProvider", "mergerFallbackModelId", "mergerFallbackThinkingLevel", "githubImportAutoTranslate", "importTranslateTargetLocale", "importTranslateProvider", "importTranslateModelId", "importTranslateThinkingLevel", ] as const; diff --git a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx index c9e8d74434..69fb5dfb72 100644 --- a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx @@ -312,6 +312,55 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW } return lane.helperText; }; + /* + * FNXC:SettingsModels 2026-07-16-00:00: + * The merger fallback is project-scoped and must sit directly after Project Merger + * in Model Lanes so operators configure the primary and retry models together. + */ + const mergerFallbackValue = form.mergerFallbackProvider && form.mergerFallbackModelId + ? `${form.mergerFallbackProvider}/${form.mergerFallbackModelId}` + : ""; + const mergerFallbackThinkingValue = typeof form.mergerFallbackThinkingLevel === "string" + ? form.mergerFallbackThinkingLevel + : ""; + const mergerFallbackCustomized = Boolean(mergerFallbackValue || mergerFallbackThinkingValue); + const setMergerFallbackValue = (value: string) => { + if (!value) { + setForm((f) => ({ ...f, mergerFallbackProvider: undefined, mergerFallbackModelId: undefined, mergerFallbackThinkingLevel: undefined } as SettingsFormState)); + return; + } + const slashIdx = value.indexOf("/"); + setForm((f) => ({ + ...f, + mergerFallbackProvider: value.slice(0, slashIdx), + mergerFallbackModelId: value.slice(slashIdx + 1), + } as SettingsFormState)); + }; + const setMergerFallbackThinkingValue = (value: string) => { + setForm((f) => ({ ...f, mergerFallbackThinkingLevel: value || undefined } as SettingsFormState)); + }; + const resetMergerFallbackValue = () => { + setForm((f) => ({ ...f, mergerFallbackProvider: undefined, mergerFallbackModelId: undefined, mergerFallbackThinkingLevel: undefined } as SettingsFormState)); + }; + const renderMergerFallbackLane = () => ( +
+
+ + + {mergerFallbackCustomized ? "Override (Project)" : "Inherited (Global)"} + + + {t("settings.projectModels.mergerFallbackHelp", "Fallback provider and model used when a merger session retries. Leave unset to use the shared global fallback model pair.")} + +
+
+
+ +
+ {mergerFallbackCustomized && ()} +
+
+ ); const titleSummarizerFallbackValue = form.titleSummarizerFallbackProvider && form.titleSummarizerFallbackModelId ? `${form.titleSummarizerFallbackProvider}/${form.titleSummarizerFallbackModelId}` : ""; @@ -431,7 +480,9 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW {t("settings.projectModels.overrideGlobalModelSettingsAtTheProjectLevel", " Override global model settings at the project level. Each lane controls a specific AI usage context. Unset lanes inherit from the corresponding global lane. The Project Default Model is the fallback for this project when a more specific lane is unset. ")} {modelsLoading ? (
) : availableModels.length === 0 ? (
{t("settings.projectModels.noModelsAvailableConfigureAuthenticationFirst", " No models available. Configure authentication first. ")}
) : (<> - {projectModelLanes.map(renderProjectLane)} + {projectModelLanes.filter((lane) => lane.laneId === "default" || lane.laneId === "merger").map(renderProjectLane)} + {renderMergerFallbackLane()} + {projectModelLanes.filter((lane) => lane.laneId === "import-translate").map(renderProjectLane)} )} {/* FNXC:ChatModels 2026-07-12-20:45: Project Models owns the Direct-chat default because New Chat needs a project-scoped model-or-agent target plus prompt-vs-direct creation mode without changing workflow or in-chat switcher settings. */} diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index 598ecf6b6a..20cfb8de94 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -351,6 +351,8 @@ const NOT_SURFACED_ALLOWLIST: Record = { titleSummarizerGlobalModelId: "configured via the model-lane picker, not a plain description field", mergerProvider: "configured via the model-lane picker, not a plain description field", mergerModelId: "configured via the model-lane picker, not a plain description field", + mergerFallbackProvider: "configured via the inline merger-fallback model picker, not a plain description field", + mergerFallbackModelId: "configured via the inline merger-fallback model picker, not a plain description field", mergerGlobalProvider: "configured via the model-lane picker, not a plain description field", mergerGlobalModelId: "configured via the model-lane picker, not a plain description field", executionGlobalProvider: "configured via the model-lane picker, not a plain description field", @@ -376,6 +378,7 @@ const NOT_SURFACED_ALLOWLIST: Record = { titleSummarizerThinkingLevel: "project title-summarizer inline thinking companion, configured via the model-lane picker, not a plain description field", titleSummarizerFallbackThinkingLevel: "project title-summarizer fallback inline thinking companion, configured via the model-lane picker, not a plain description field", mergerThinkingLevel: "project merger inline thinking companion, configured via the model-lane picker, not a plain description field", + mergerFallbackThinkingLevel: "project merger-fallback inline thinking companion, configured via the model-lane picker, not a plain description field", fallbackThinkingLevel: "global fallback model inline thinking companion, configured via the model-lane picker, not a plain description field", agentPrompts2: "not a real key (placeholder guard)", promptOverrides2: "not a real key (placeholder guard)", diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index b269728e96..83de333d1e 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -124,6 +124,7 @@ describe("resolve model-lane thinking levels", () => { expect(resolveTitleSummarizerFallbackThinkingLevel({ defaultThinkingLevelOverride: "medium", defaultThinkingLevel: "minimal" })).toBe("medium"); expect(resolveTitleSummarizerFallbackThinkingLevel({ defaultThinkingLevel: "minimal" })).toBe("minimal"); + expect(resolveMergerFallbackThinkingLevel({ mergerFallbackThinkingLevel: "xhigh", fallbackThinkingLevel: "high", mergerThinkingLevel: "medium" })).toBe("xhigh"); expect(resolveMergerFallbackThinkingLevel({ fallbackThinkingLevel: "high", defaultThinkingLevel: "low" })).toBe("high"); expect(resolveMergerFallbackThinkingLevel({ defaultThinkingLevelOverride: "medium", defaultThinkingLevel: "low" })).toBe("medium"); expect(resolveMergerFallbackThinkingLevel({ defaultThinkingLevel: "low" })).toBe("low"); diff --git a/packages/engine/src/__tests__/mcp-pr-response-forwarding.test.ts b/packages/engine/src/__tests__/mcp-pr-response-forwarding.test.ts index 92134e7bec..5f5ce4636f 100644 --- a/packages/engine/src/__tests__/mcp-pr-response-forwarding.test.ts +++ b/packages/engine/src/__tests__/mcp-pr-response-forwarding.test.ts @@ -22,6 +22,7 @@ vi.mock("../agent-session-helpers.js", () => ({ // The mock must surface it or every PR-response run throws on the missing // export. Neutral undefined return — no test asserts on thinking level. resolveMergerThinkingLevel: vi.fn(() => undefined), + resolveMergerFallbackThinkingLevel: vi.fn((settings: Settings) => settings.mergerFallbackThinkingLevel ?? settings.fallbackThinkingLevel), })); vi.mock("../pi.js", () => ({ diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index eb246d624b..efc07f7fee 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -320,8 +320,16 @@ export function resolveTitleSummarizerFallbackThinkingLevel(settings: Partial | undefined): string | undefined { - return firstThinkingLevel(settings?.fallbackThinkingLevel, resolveMergerThinkingLevel(settings)); + return firstThinkingLevel( + settings?.mergerFallbackThinkingLevel, + settings?.fallbackThinkingLevel, + resolveMergerThinkingLevel(settings), + ); } function hasCompleteRuntimeModel( diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index ad9a9ec5c6..2639eb2bd2 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -50,6 +50,7 @@ import { resolvePersistAgentThinkingLog, resolveTaskMergeTarget, resolveValidatorSettingsModel, + resolveMergerFallbackModel, type MergeDetails, type MergeResult, type MergeTargetResolution, @@ -61,7 +62,7 @@ import { selectUserCommentsForAgentContext } from "./agent-user-comments.js"; import { resolveTaskWorkingBranch } from "./worktree-names.js"; import { resolveIntegrationBranch } from "./integration-branch.js"; import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js"; -import { createResolvedAgentSession, resolveMergerSessionModel, resolveMergerThinkingLevel, resolveValidatorThinkingLevel } from "./agent-session-helpers.js"; +import { createResolvedAgentSession, resolveMergerSessionModel, resolveMergerThinkingLevel, resolveMergerFallbackThinkingLevel, resolveValidatorThinkingLevel } from "./agent-session-helpers.js"; import { promptWithFallback } from "./pi.js"; import { AgentLogger } from "./agent-logger.js"; import { withRateLimitRetry } from "./rate-limit-retry.js"; @@ -381,6 +382,8 @@ interface AgentDeps { function makeMutatingAgent(store: TaskStore, settings: Settings, taskId: string, options: MergerOptions, audit: RunAuditor, systemPrompt: string) { return async (cwd: string, prompt: string): Promise => { const model = resolveMergerSessionModel(settings); + // FNXC:Settings-MergerModel 2026-07-16-00:00: mutating merger retries resolve the project merger fallback lane before the shared global fallback. + const mergerFallbackModel = resolveMergerFallbackModel(settings); const logger = new AgentLogger({ store, taskId, @@ -406,8 +409,9 @@ function makeMutatingAgent(store: TaskStore, settings: Settings, taskId: string, onToolEnd: logger.onToolEnd, defaultProvider: model.provider, defaultModelId: model.modelId, - fallbackProvider: settings.fallbackProvider, - fallbackModelId: settings.fallbackModelId, + fallbackProvider: mergerFallbackModel.provider, + fallbackModelId: mergerFallbackModel.modelId, + fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings), defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: audit, settings, @@ -436,6 +440,8 @@ function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, o // that lane resolves to nothing. const validator = resolveValidatorSettingsModel(settings); const model = validator.provider && validator.modelId ? validator : resolveMergerSessionModel(settings); + // FNXC:Settings-MergerModel 2026-07-16-00:00: review merger retries share the dedicated merger fallback provider/model and thinking lane. + const mergerFallbackModel = resolveMergerFallbackModel(settings); // FNXC:Settings-ThinkingLevel 2026-07-10-00:00: The review agent's model falls back // between the validator lane and the merger default lane, so its thinking level // must follow the same lane it actually resolved a model from. @@ -471,8 +477,9 @@ function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, o onToolEnd: logger.onToolEnd, defaultProvider: model.provider, defaultModelId: model.modelId, - fallbackProvider: settings.fallbackProvider, - fallbackModelId: settings.fallbackModelId, + fallbackProvider: mergerFallbackModel.provider, + fallbackModelId: mergerFallbackModel.modelId, + fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings), defaultThinkingLevel: reviewThinkingLevel, runAuditor: audit, settings, diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index dba3594912..ca32f6e6f7 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -104,6 +104,7 @@ import { type AutostashOrphanRecord, normalizeMergeAdvanceAutoSyncMode, isMergeRequestContractShadowEnabled, + resolveMergerFallbackModel, } from "@fusion/core"; import { evaluateAutoMergeFactProviders } from "./auto-merge-fact-providers.js"; import { resolveMergePolicy } from "./merge-trait.js"; @@ -1112,7 +1113,11 @@ async function attemptInMergeVerificationFix( : null; const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig); const mergerSessionModel = resolveMergerSessionModel(settings, assignedAgent?.runtimeConfig); - // FN-5279: verification-fix sessions run in the resolved integration root, + + // FNXC:Settings-MergerModel 2026-07-16-00:00: merger retries use the dedicated project fallback lane before the shared global fallback pair. + + const mergerFallbackModel = resolveMergerFallbackModel(settings); + // FN-5279: verification-fix sessions run in the resolved integration root, // which is the reused task worktree in handoff mode. const { session } = await createResolvedAgentSession({ sessionPurpose: "merger", @@ -1142,8 +1147,8 @@ Do not refactor, rename broadly, or make opportunistic improvements. onToolEnd: logger.onToolEnd, defaultProvider: mergerSessionModel.provider, defaultModelId: mergerSessionModel.modelId, - fallbackProvider: settings.fallbackProvider, - fallbackModelId: settings.fallbackModelId, + fallbackProvider: mergerFallbackModel.provider, + fallbackModelId: mergerFallbackModel.modelId, fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings), defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: createRunAuditor(store, { @@ -2391,6 +2396,10 @@ async function runAiAgentForAutostashConflict(params: { const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig); const mergerSessionModel = resolveMergerSessionModel(settings, assignedAgent?.runtimeConfig); + // FNXC:Settings-MergerModel 2026-07-16-00:00: merger retries use the dedicated project fallback lane before the shared global fallback pair. + + const mergerFallbackModel = resolveMergerFallbackModel(settings); + const systemPrompt = `You are an autostash-conflict resolution agent running after a Fusion merge has already committed on the main branch. Before the merge ran, the developer had uncommitted local changes in their working tree. The merger snapshotted those changes into a git stash, ran the merge cleanly, and is now reapplying the stash on top of the merged HEAD. The reapply hit conflicts because the merge committed changes that overlap the developer's stashed edits. @@ -2435,8 +2444,8 @@ ${fileList} onToolEnd: agentLogger.onToolEnd, defaultProvider: mergerSessionModel.provider, defaultModelId: mergerSessionModel.modelId, - fallbackProvider: settings.fallbackProvider, - fallbackModelId: settings.fallbackModelId, + fallbackProvider: mergerFallbackModel.provider, + fallbackModelId: mergerFallbackModel.modelId, fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings), defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: createRunAuditor(store, { @@ -2800,6 +2809,10 @@ async function runAiAgentForAutostashHardFail(params: { const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig); const mergerSessionModel = resolveMergerSessionModel(settings, assignedAgent?.runtimeConfig); + // FNXC:Settings-MergerModel 2026-07-16-00:00: merger retries use the dedicated project fallback lane before the shared global fallback pair. + + const mergerFallbackModel = resolveMergerFallbackModel(settings); + const systemPrompt = `You are an autostash hard-failure recovery agent for the Fusion merger. Before the merge ran, the developer had uncommitted local changes. We snapshotted them into a git stash, ran the merge cleanly on top, and tried to re-apply the stash. Both \`git stash apply\` and \`git apply --3way\` failed without producing conflict markers — meaning git refused to attempt the apply at all (typical causes: untracked-file overwrite, a path in the stash no longer exists at HEAD, or an index conflict that produced no in-tree markers). @@ -2854,8 +2867,8 @@ ${fileList} onToolEnd: agentLogger.onToolEnd, defaultProvider: mergerSessionModel.provider, defaultModelId: mergerSessionModel.modelId, - fallbackProvider: settings.fallbackProvider, - fallbackModelId: settings.fallbackModelId, + fallbackProvider: mergerFallbackModel.provider, + fallbackModelId: mergerFallbackModel.modelId, fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings), defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: createRunAuditor(store, { @@ -5860,6 +5873,10 @@ You are assisting with a paused \`git pull --rebase\`. throwIfAborted(options?.signal, taskId); const mergerSessionModel = resolveMergerSessionModel(settings, options?.assignedAgentRuntimeConfig); + + // FNXC:Settings-MergerModel 2026-07-16-00:00: merger retries use the dedicated project fallback lane before the shared global fallback pair. + + const mergerFallbackModel = resolveMergerFallbackModel(settings); const { session } = await createResolvedAgentSession({ sessionPurpose: "merger", runtimeHint: options?.runtimeHint, @@ -5873,8 +5890,8 @@ You are assisting with a paused \`git pull --rebase\`. onToolEnd: agentLogger.onToolEnd, defaultProvider: mergerSessionModel.provider, defaultModelId: mergerSessionModel.modelId, - fallbackProvider: settings.fallbackProvider, - fallbackModelId: settings.fallbackModelId, + fallbackProvider: mergerFallbackModel.provider, + fallbackModelId: mergerFallbackModel.modelId, fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings), defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: createRunAuditor(store, { @@ -10822,6 +10839,10 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig); const mergerSessionModel = resolveMergerSessionModel(settings, assignedAgent?.runtimeConfig); + // FNXC:Settings-MergerModel 2026-07-16-00:00: merger retries use the dedicated project fallback lane before the shared global fallback pair. + + const mergerFallbackModel = resolveMergerFallbackModel(settings); + // FN-5279: Layer 3 / merge-authoring AI runs in the resolved integration // root so arbiter edits land in the reused task worktree when handoff mode // is active. @@ -10839,8 +10860,8 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo onToolEnd: agentLogger.onToolEnd, defaultProvider: mergerSessionModel.provider, defaultModelId: mergerSessionModel.modelId, - fallbackProvider: settings.fallbackProvider, - fallbackModelId: settings.fallbackModelId, + fallbackProvider: mergerFallbackModel.provider, + fallbackModelId: mergerFallbackModel.modelId, fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings), defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: createRunAuditor(store, { diff --git a/packages/engine/src/pr-response-run-ops.ts b/packages/engine/src/pr-response-run-ops.ts index 62a02801ce..9c77921fa5 100644 --- a/packages/engine/src/pr-response-run-ops.ts +++ b/packages/engine/src/pr-response-run-ops.ts @@ -10,8 +10,8 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { PrEntity, Settings, TaskStore } from "@fusion/core"; -import { resolveAgentPrompt } from "@fusion/core"; -import { createResolvedAgentSession, resolveMergerSessionModel, resolveMergerThinkingLevel } from "./agent-session-helpers.js"; +import { resolveAgentPrompt, resolveMergerFallbackModel } from "@fusion/core"; +import { createResolvedAgentSession, resolveMergerSessionModel, resolveMergerThinkingLevel, resolveMergerFallbackThinkingLevel } from "./agent-session-helpers.js"; import { resolveMcpServersForStore } from "./mcp-resolution.js"; import { promptWithFallback } from "./pi.js"; import { withRateLimitRetry } from "./rate-limit-retry.js"; @@ -101,6 +101,8 @@ export function makePrResponseAgentRunner( }) => Promise { return async ({ prompt, systemPrompt, signal, threads }) => { const model = resolveMergerSessionModel(settings); + // FNXC:Settings-MergerModel 2026-07-16-00:00: PR-response retries use the merger-only fallback lane, retaining shared fallback inheritance when unset. + const mergerFallbackModel = resolveMergerFallbackModel(settings); let captured = ""; /* * FNXC:McpConfig 2026-06-26-00:00: @@ -129,8 +131,9 @@ export function makePrResponseAgentRunner( }, defaultProvider: model.provider, defaultModelId: model.modelId, - fallbackProvider: settings.fallbackProvider, - fallbackModelId: settings.fallbackModelId, + fallbackProvider: mergerFallbackModel.provider, + fallbackModelId: mergerFallbackModel.modelId, + fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings), defaultThinkingLevel: resolveMergerThinkingLevel(settings), settings, taskId,