feat: make merger AI model configurable under Global and Project Models

Add a dedicated merger model lane (project + global provider/model/thinking) so merge-agent sessions no longer share only the default model, without inheriting executor/planner/reviewer lanes.
This commit is contained in:
gsxdsm
2026-07-13 08:10:56 -07:00
parent e35620c9aa
commit d4001ab0ee
17 changed files with 258 additions and 21 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Make the merger AI model configurable under Global and Project Models.
category: feature
dev: Adds project `mergerProvider`/`mergerModelId`/`mergerThinkingLevel` and global `mergerGlobalProvider`/`mergerGlobalModelId`/`mergerGlobalThinkingLevel`. Resolution is project merger → global merger → project/global default; does not inherit executor/planner/reviewer lanes.

View File

@@ -536,13 +536,14 @@ Fusion supports OAuth-based authentication for AI providers configured via **Set
### Model system
Fusion uses a dual-scope model hierarchy with five independent lanes. Global settings define baseline defaults; project settings provide per-project overrides.
Fusion uses a dual-scope model hierarchy with independent lanes. Global settings define baseline defaults; project settings provide per-project overrides.
| Lane | Purpose | Global Baseline Keys | Project Override Keys |
|------|---------|---------------------|----------------------|
| Executor | Task execution agent | `executionGlobalProvider` + `executionGlobalModelId` | `executionProvider` + `executionModelId` |
| Planning | Task planning agent | `planningGlobalProvider` + `planningGlobalModelId` | `planningProvider` + `planningModelId` |
| Validator | Plan/code reviewer | `validatorGlobalProvider` + `validatorGlobalModelId` | `validatorProvider` + `validatorModelId` |
| Merger | Merge conflict / clean-room merge agent | `mergerGlobalProvider` + `mergerGlobalModelId` | `mergerProvider` + `mergerModelId` |
| Title Summarization | Auto-title generation | `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` | `titleSummarizerProvider` + `titleSummarizerModelId` |
| Workflow Step Refinement | AI prompt refinement | (uses `defaultProvider`/`defaultModelId`) | (uses `modelProvider`/`modelId` on WorkflowStep) |

View File

@@ -362,7 +362,7 @@ Behavior:
FNXC:WorkflowEditor 2026-06-29-21:10: Optional-group template entry/exit ownership remains visual-only. Non-editable boundary connector lines must explain how template children attach to the container without persisting fake edges into workflow IR. -->
- Optional-group, foreach, and loop containers show their template nodes inside the block. Canvas connections between the surrounding workflow and the block attach to the container boundary; connections between template nodes stay inside the block. Optional groups also draw non-editable entry/exit connector lines between the boundary and the template entry/exit nodes so single-step blocks such as Plan Review and Code Review do not look disconnected; those visual connectors are not saved into workflow IR.
- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Known workflow model values use the same model dropdown picker as **Settings → Project Models** so provider/model pairs and their inline Thinking Level companions are saved together; custom or non-model string values can still use typed inputs. Definitions remain available for custom workflow schema authoring.
- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, Reviewer, and declared Planning/Reviewer fallback model lanes from **Project Models**; the modal's primary **Save** action writes those dropdown values as workflow setting values for the active default workflow. Settings fallback model pickers (Global Fallback Model, workflow fallbacks, and project Title Summarizer Fallback) include the same inline Thinking Level selector when their companion key exists.
- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, Reviewer, and declared Planning/Reviewer fallback model lanes from **Project Models**; the modal's primary **Save** action writes those dropdown values as workflow setting values for the active default workflow. Project Models also includes project-scoped **Merger** and **Title Summarization** lanes (not workflow-moved). Settings fallback model pickers (Global Fallback Model, workflow fallbacks, and project Title Summarizer Fallback) include the same inline Thinking Level selector when their companion key exists.
- On desktop, the editor uses a multi-panel canvas layout for editing the graph and adjacent workflow metadata. The **Show simple editor** toggle switches that same workflow into the graph-outline editor with dedicated **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions** tabs.
- On viewports `<=768px`, the editor switches to a full-screen mobile sheet. Global workflow entry points open to the workflow list with no workflow preselected and prompt users to select a workflow to edit; the Board/List workflow dropdown row edit action opens directly to the selected workflow editor when that selected workflow is available.
- Simple/mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and optional-group/foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop. The structural **start** node opens an inspector for the workflow entry column when the workflow defines columns; the **Name** field remains unavailable because the start label is structural. For custom workflows, editable outline rows also expose **Move up** and **Move down** controls that reorder steps within their current column or template parent; built-in workflows remain read-only and hide those controls.

View File

@@ -1054,10 +1054,18 @@ Mission validation sessions use this same validator lane; assigned durable agent
### Merger model
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
Dedicated model lane for merger agent sessions (conflict resolution, clean-room merge, stash-conflict recovery, PR-response helpers, and related merge-agent runs). Configurable under **Settings → Global Models** and **Settings → Project Models**. Does not inherit the executor, planner, or reviewer lanes.
1. Project `mergerProvider` + `mergerModelId`
2. Global `mergerGlobalProvider` + `mergerGlobalModelId`
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 merger/default pair is configured
6. Automatic provider/model resolution
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).
For post-merge prompt workflow steps, explicit step-level `modelProvider` + `modelId` overrides take precedence over the merger lane above.

View File

@@ -7,6 +7,7 @@ import {
resolveTaskExecutionModel,
resolveTaskPlanningModel,
resolveTaskValidatorModel,
resolveMergerSettingsModel,
resolveTitleSummarizerSettingsModel,
resolveValidatorSettingsModel,
TEST_MODE_RESOLVED,
@@ -132,6 +133,15 @@ describe("model-resolution", () => {
defaultProviderOverride: "project-default-provider",
defaultModelIdOverride: "project-default-model",
})).toEqual({ provider: "project-title-provider", modelId: "project-title-model" });
expect(resolveMergerSettingsModel({
mergerProvider: "project-merger-provider",
mergerModelId: "project-merger-model",
mergerGlobalProvider: "global-merger-provider",
mergerGlobalModelId: "global-merger-model",
defaultProviderOverride: "project-default-provider",
defaultModelIdOverride: "project-default-model",
})).toEqual({ provider: "project-merger-provider", modelId: "project-merger-model" });
});
it("does not mix partial project lane pairs with lower precedence model fields", () => {
@@ -160,6 +170,12 @@ describe("model-resolution", () => {
planningProvider: "project-plan-provider",
planningModelId: "project-plan-model",
})).toEqual({ provider: "global-title-provider", modelId: "global-title-model" });
expect(resolveMergerSettingsModel({
mergerProvider: "project-merger-provider",
mergerGlobalProvider: "global-merger-provider",
mergerGlobalModelId: "global-merger-model",
})).toEqual({ provider: "global-merger-provider", modelId: "global-merger-model" });
});
it("keeps global lane and default fallback order intact when project lanes are unset", () => {
@@ -190,6 +206,33 @@ describe("model-resolution", () => {
defaultProviderOverride: "project-default-provider",
defaultModelIdOverride: "project-default-model",
})).toEqual({ provider: "global-title-provider", modelId: "global-title-model" });
expect(resolveMergerSettingsModel({
mergerGlobalProvider: "global-merger-provider",
mergerGlobalModelId: "global-merger-model",
defaultProviderOverride: "project-default-provider",
defaultModelIdOverride: "project-default-model",
})).toEqual({ provider: "global-merger-provider", modelId: "global-merger-model" });
expect(resolveMergerSettingsModel({
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" });
});
it("does not inherit execution/planning/validator lanes for the merger model", () => {
expect(resolveMergerSettingsModel({
executionProvider: "project-exec-provider",
executionModelId: "project-exec-model",
planningProvider: "project-plan-provider",
planningModelId: "project-plan-model",
validatorProvider: "project-validator-provider",
validatorModelId: "project-validator-model",
defaultProvider: "global-default-provider",
defaultModelId: "global-default-model",
})).toEqual({ provider: "global-default-provider", modelId: "global-default-model" });
});
it("uses task overrides before settings fallbacks", () => {
@@ -397,6 +440,8 @@ describe("model-resolution", () => {
validatorModelId: "claude-sonnet-4-5",
titleSummarizerProvider: "anthropic",
titleSummarizerModelId: "claude-sonnet-4-5",
mergerProvider: "anthropic",
mergerModelId: "claude-sonnet-4-5",
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-sonnet-4-5",
};
@@ -414,6 +459,7 @@ describe("model-resolution", () => {
expect(resolvePlanningSettingsModel(settings)).toEqual(TEST_MODE_RESOLVED);
expect(resolveValidatorSettingsModel(settings)).toEqual(TEST_MODE_RESOLVED);
expect(resolveTitleSummarizerSettingsModel(settings)).toEqual(TEST_MODE_RESOLVED);
expect(resolveMergerSettingsModel(settings)).toEqual(TEST_MODE_RESOLVED);
expect(resolveTaskExecutionModel(taskOverrides, settings)).toEqual(TEST_MODE_RESOLVED);
expect(resolveTaskValidatorModel(taskOverrides, settings)).toEqual(TEST_MODE_RESOLVED);
expect(resolveTaskPlanningModel(taskOverrides, settings)).toEqual(TEST_MODE_RESOLVED);

View File

@@ -105,15 +105,18 @@ describe("settings key parity", () => {
expect(DEFAULT_PROJECT_SETTINGS.defaultThinkingLevelOverride).toBeUndefined();
expect(DEFAULT_PROJECT_SETTINGS.titleSummarizerThinkingLevel).toBeUndefined();
expect(DEFAULT_PROJECT_SETTINGS.titleSummarizerFallbackThinkingLevel).toBeUndefined();
expect(DEFAULT_PROJECT_SETTINGS.mergerThinkingLevel).toBeUndefined();
expect(DEFAULT_GLOBAL_SETTINGS.fallbackThinkingLevel).toBeUndefined();
expect(DEFAULT_GLOBAL_SETTINGS.executionGlobalThinkingLevel).toBeUndefined();
expect(DEFAULT_GLOBAL_SETTINGS.planningGlobalThinkingLevel).toBeUndefined();
expect(DEFAULT_GLOBAL_SETTINGS.validatorGlobalThinkingLevel).toBeUndefined();
expect(DEFAULT_GLOBAL_SETTINGS.titleSummarizerGlobalThinkingLevel).toBeUndefined();
expect(DEFAULT_GLOBAL_SETTINGS.mergerGlobalThinkingLevel).toBeUndefined();
expect(isProjectSettingsKey("defaultThinkingLevelOverride")).toBe(true);
expect(isProjectSettingsKey("titleSummarizerThinkingLevel")).toBe(true);
expect(isProjectSettingsKey("titleSummarizerFallbackThinkingLevel")).toBe(true);
expect(isProjectSettingsKey("mergerThinkingLevel")).toBe(true);
expect(isGlobalSettingsKey("fallbackThinkingLevel")).toBe(true);
expect(isProjectSettingsKey("planningFallbackThinkingLevel")).toBe(false);
expect(isGlobalSettingsKey("planningFallbackThinkingLevel")).toBe(false);
@@ -123,6 +126,7 @@ describe("settings key parity", () => {
expect(isGlobalSettingsKey("planningGlobalThinkingLevel")).toBe(true);
expect(isGlobalSettingsKey("validatorGlobalThinkingLevel")).toBe(true);
expect(isGlobalSettingsKey("titleSummarizerGlobalThinkingLevel")).toBe(true);
expect(isGlobalSettingsKey("mergerGlobalThinkingLevel")).toBe(true);
});
it("defaults persisted thinking logs to disabled", () => {
@@ -573,6 +577,9 @@ describe("model lane key parity regression (FN-1729)", () => {
{ provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId", expectedScope: "project" },
{ provider: "titleSummarizerGlobalProvider", modelId: "titleSummarizerGlobalModelId", expectedScope: "global" },
{ provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId", expectedScope: "project" },
// Merger lane (project-scoped like summarizer; not workflow-moved)
{ provider: "mergerProvider", modelId: "mergerModelId", expectedScope: "project" },
{ provider: "mergerGlobalProvider", modelId: "mergerGlobalModelId", expectedScope: "global" },
] as const;
it.each(allModelLanePairs)(

View File

@@ -1345,6 +1345,7 @@ export {
applyTestModeOverrides,
isTestModeActive,
resolveExecutionSettingsModel,
resolveMergerSettingsModel,
resolvePlanningSettingsModel,
resolveProjectDefaultModel,
resolveTaskExecutionModel,

View File

@@ -1383,6 +1383,7 @@ export {
applyTestModeOverrides,
isTestModeActive,
resolveExecutionSettingsModel,
resolveMergerSettingsModel,
resolvePhaseThinkingLevel,
resolvePlanningSettingsModel,
resolveProjectDefaultModel,

View File

@@ -186,6 +186,29 @@ export function resolveTitleSummarizerSettingsModel(settings?: Partial<Settings>
);
}
/**
* FNXC:Settings-MergerModel 2026-07-13-07:52:
* Merger sessions resolve project merger lane → global merger lane → project/global default.
* They intentionally do not inherit execution/planning/validator lanes so a merge-specific
* model can be configured under Global/Project Models without changing other AI roles.
*/
export function resolveMergerSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return applyTestModeOverrides(
pickFirstModelPair(
{
provider: settings?.mergerProvider,
modelId: settings?.mergerModelId,
},
{
provider: settings?.mergerGlobalProvider,
modelId: settings?.mergerGlobalModelId,
},
resolveProjectDefaultModel(settings),
),
settings,
);
}
export function resolveTaskExecutionModel(
task: TaskModelLike,
settings?: Partial<Settings>,

View File

@@ -193,6 +193,12 @@ export const DEFAULT_GLOBAL_SETTINGS = {
titleSummarizerGlobalProvider: undefined,
titleSummarizerGlobalModelId: undefined,
/*
FNXC:Settings-MergerModel 2026-07-13-07:52:
Global merger baseline lane (provider/model/thinking) is independent of executor/planner/reviewer so operators can pin a merge-capable model under Settings → Global Models without changing other lanes. Undefined falls through to defaultProvider/defaultModelId at resolve time.
*/
mergerGlobalProvider: undefined,
mergerGlobalModelId: undefined,
/*
FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
Global model lanes can override the default thinking effort independently. Undefined preserves the existing inheritance to `defaultThinkingLevel`.
*/
@@ -200,6 +206,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
planningGlobalThinkingLevel: undefined,
validatorGlobalThinkingLevel: undefined,
titleSummarizerGlobalThinkingLevel: undefined,
mergerGlobalThinkingLevel: undefined,
// Daemon mode settings
daemonToken: undefined,
daemonPort: 4040,
@@ -593,6 +600,13 @@ export const DEFAULT_PROJECT_SETTINGS = {
titleSummarizerFallbackProvider: undefined,
titleSummarizerFallbackModelId: undefined,
titleSummarizerFallbackThinkingLevel: undefined,
/*
FNXC:Settings-MergerModel 2026-07-13-07:52:
Merger model lane stays project-scoped (not workflow-moved) like title summarizer: Settings → Project Models can override the global merger baseline without binding the choice to a workflow graph.
*/
mergerProvider: undefined,
mergerModelId: undefined,
mergerThinkingLevel: undefined,
prTitlePromptInstructions: undefined,
prDescriptionPromptInstructions: undefined,
scripts: undefined,

View File

@@ -3508,6 +3508,17 @@ export interface GlobalSettings {
/** Global baseline AI model ID for title summarization.
* Must be set together with `titleSummarizerGlobalProvider`. */
titleSummarizerGlobalModelId?: string;
/*
FNXC:Settings-MergerModel 2026-07-13-07:52:
Merger AI sessions (conflict resolution, clean-room merge, stash-conflict, PR-response helpers, merge commit agent) need a dedicated global baseline lane so operators can pin a merge-capable model without forcing the same choice onto executor/planner/reviewer. Project `mergerProvider`/`mergerModelId` override this pair; unset falls through to project/global default.
*/
/** Global baseline AI model provider for merger agent sessions.
* Must be set together with `mergerGlobalModelId`. Falls back to
* `defaultProvider`/`defaultModelId` when undefined. */
mergerGlobalProvider?: string;
/** Global baseline AI model ID for merger agent sessions.
* Must be set together with `mergerGlobalProvider`. */
mergerGlobalModelId?: string;
/** Optional global execution-lane thinking override. Inherits `defaultThinkingLevel` when unset. */
executionGlobalThinkingLevel?: ThinkingLevel;
/** Optional global planning-lane thinking override. Inherits `defaultThinkingLevel` when unset. */
@@ -3516,6 +3527,8 @@ export interface GlobalSettings {
validatorGlobalThinkingLevel?: ThinkingLevel;
/** Optional global summarization-lane thinking override. Inherits `defaultThinkingLevel` when unset. */
titleSummarizerGlobalThinkingLevel?: ThinkingLevel;
/** Optional global merger-lane thinking override. Inherits `defaultThinkingLevel` when unset. */
mergerGlobalThinkingLevel?: ThinkingLevel;
/** The daemon authentication token (format: fn_<32 hex chars>).
* Used for authenticating CLI clients to the daemon server. */
daemonToken?: string;
@@ -4685,6 +4698,19 @@ export interface ProjectSettings {
titleSummarizerModelId?: string;
/** Optional project summarization-lane thinking override. Inherits `defaultThinkingLevel` when unset. */
titleSummarizerThinkingLevel?: ThinkingLevel;
/*
FNXC:Settings-MergerModel 2026-07-13-07:52:
Project-scoped merger lane overrides the global merger baseline for conflict resolution and related merge-agent sessions. Both provider and model id must be set together; partial pairs are ignored and fall through. Unset inherits global merger lane then project/global default.
*/
/** Project AI model provider for merger agent sessions.
* Must be set together with `mergerModelId`. Falls back to
* `mergerGlobalProvider`/`mergerGlobalModelId`, then project/global default. */
mergerProvider?: string;
/** Project AI model ID for merger agent sessions.
* Must be set together with `mergerProvider`. */
mergerModelId?: string;
/** Optional project merger-lane thinking override. Inherits through global merger thinking then default thinking when unset. */
mergerThinkingLevel?: ThinkingLevel;
/** Fallback model provider for title summarization. When unset, falls back to
* planning fallback, then global fallback. Must be set together with
* `titleSummarizerFallbackModelId`. */

View File

@@ -2633,7 +2633,11 @@ export function SettingsModal({
fallbackOrder: string;
}
/** All five model lanes with their global and project override keys */
/*
FNXC:Settings-MergerModel 2026-07-13-07:52:
MODEL_LANES drives Global Models + Project Models pickers. Merger is a sixth dedicated lane (project-scoped like summarization, not workflow-moved) so conflict/merge agents can use a different model from executor/planner/reviewer without a separate settings surface.
*/
/** All model lanes with their global and project override keys */
const MODEL_LANES: ModelLane[] = [
{
laneId: "default",
@@ -2679,6 +2683,18 @@ export function SettingsModal({
helperText: "AI model used for code and specification review.",
fallbackOrder: "Project override → Global reviewer lane → Global default lane → Automatic resolution",
},
{
laneId: "merger",
label: "Merger Model",
globalProviderKey: "mergerGlobalProvider",
globalModelKey: "mergerGlobalModelId",
globalThinkingKey: "mergerGlobalThinkingLevel",
projectProviderKey: "mergerProvider",
projectModelKey: "mergerModelId",
projectThinkingKey: "mergerThinkingLevel",
helperText: "AI model used for merge conflict resolution, clean-room merge, stash-conflict recovery, and related merger agent sessions.",
fallbackOrder: "Project override → Global merger lane → Project default lane → Global default lane → Automatic resolution",
},
{
laneId: "summarization",
label: "Title and Git Commit Message Summarization Model",

View File

@@ -41,11 +41,17 @@ import type { GlobalSettings, Settings } from "@fusion/core";
* The project-scoped title-summarizer fallback thinking companion must travel
* with its provider/model pair so clearing the inline selector serializes as
* 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.
*/
export const MODEL_LANE_KEYS = [
"defaultProviderOverride", "defaultModelIdOverride",
"titleSummarizerProvider", "titleSummarizerModelId",
"titleSummarizerFallbackProvider", "titleSummarizerFallbackModelId", "titleSummarizerFallbackThinkingLevel",
"mergerProvider", "mergerModelId", "mergerThinkingLevel",
] as const;
const MODEL_LANE_KEY_SET = new Set<string>(MODEL_LANE_KEYS);
@@ -148,6 +154,9 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
"validatorGlobalModelId",
"titleSummarizerGlobalProvider",
"titleSummarizerGlobalModelId",
"mergerGlobalProvider",
"mergerGlobalModelId",
"mergerGlobalThinkingLevel",
]),
"project-models": new Set([
"defaultProvider",
@@ -172,6 +181,9 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
"validatorGlobalModelId",
"titleSummarizerGlobalProvider",
"titleSummarizerGlobalModelId",
"mergerGlobalProvider",
"mergerGlobalModelId",
"mergerGlobalThinkingLevel",
]),
"node-sync": new Set([
"settingsSyncEnabled",

View File

@@ -271,14 +271,18 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
registerWorkflowLaneSaver?.(saveWorkflowLanes);
return () => registerWorkflowLaneSaver?.(null);
}, [registerWorkflowLaneSaver, saveWorkflowLanes]);
// The project DEFAULT lane and restored title-summarizer lane remain editable
// The project DEFAULT, title-summarizer, and merger lanes remain editable
// here. Execution/planning/validator workflow-specific lanes still redirect to
// workflow settings below.
const projectModelLanes = modelLanes.filter((lane) => ["default", "summarization"].includes(lane.laneId));
// FNXC:Settings-MergerModel 2026-07-13-07:52: Merger is project-scoped (like summarization), not workflow-moved.
const projectModelLanes = modelLanes.filter((lane) => ["default", "merger", "summarization"].includes(lane.laneId));
const getProjectLaneLabel = (lane: ModelLane) => {
if (lane.laneId === "default") {
return "Project Default Model";
}
if (lane.laneId === "merger") {
return "Project Merger Model";
}
if (lane.laneId === "summarization") {
return "Project Summarization Model";
}
@@ -288,6 +292,9 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
if (lane.laneId === "default") {
return "Project-wide default AI model used when no more specific task or project lane override is set.";
}
if (lane.laneId === "merger") {
return "Model used for merge conflict resolution, clean-room merge, stash-conflict recovery, and related merger agent sessions.";
}
if (lane.laneId === "summarization") {
return "Model used for title auto-summarization, merge commit summaries, GitHub tracking issue titles, and PR title/body generation.";
}

View File

@@ -330,6 +330,10 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
titleSummarizerFallbackModelId: "configured via the model-lane picker, not a plain description field",
titleSummarizerGlobalProvider: "configured via the model-lane picker, not a plain description field",
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",
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",
executionGlobalModelId: "configured via the model-lane picker, not a plain description field",
planningGlobalProvider: "configured via the model-lane picker, not a plain description field",
@@ -343,13 +347,16 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
// model-lane pickers (GlobalModelsSection / ProjectModelsSection). They are NOT standalone
// description fields — they ride alongside their provider/model lane pair, exactly like
// executionGlobalProvider / titleSummarizerProvider etc. above.
// FNXC:Settings-MergerModel 2026-07-13-07:52: merger lane thinking companions follow the same picker pattern.
executionGlobalThinkingLevel: "inline thinking companion for the global execution lane, configured via the model-lane picker, not a plain description field",
planningGlobalThinkingLevel: "inline thinking companion for the global planning lane, configured via the model-lane picker, not a plain description field",
validatorGlobalThinkingLevel: "inline thinking companion for the global validator lane, configured via the model-lane picker, not a plain description field",
titleSummarizerGlobalThinkingLevel: "inline thinking companion for the global title-summarizer lane, configured via the model-lane picker, not a plain description field",
mergerGlobalThinkingLevel: "inline thinking companion for the global merger lane, configured via the model-lane picker, not a plain description field",
defaultThinkingLevelOverride: "project-scoped Default-lane inline thinking companion, configured via the model-lane picker, not a plain description field",
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",
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)",

View File

@@ -8,6 +8,7 @@ import {
resolveHeartbeatSessionModels,
resolveImplicitPlanningFallbackModel,
resolveMergerSessionModel,
resolveMergerThinkingLevel,
resolveMergerFallbackThinkingLevel,
resolvePlanningSessionModel,
resolvePlanningThinkingLevel,
@@ -112,6 +113,25 @@ describe("resolve model-lane thinking levels", () => {
expect(resolveMergerFallbackThinkingLevel({ defaultThinkingLevelOverride: "medium", defaultThinkingLevel: "low" })).toBe("medium");
expect(resolveMergerFallbackThinkingLevel({ defaultThinkingLevel: "low" })).toBe("low");
});
it("applies project merger thinking > global merger thinking > default thinking for merger sessions", () => {
expect(resolveMergerThinkingLevel({
mergerThinkingLevel: "xhigh",
mergerGlobalThinkingLevel: "high",
defaultThinkingLevelOverride: "medium",
defaultThinkingLevel: "low",
})).toBe("xhigh");
expect(resolveMergerThinkingLevel({
mergerGlobalThinkingLevel: "high",
defaultThinkingLevelOverride: "medium",
defaultThinkingLevel: "low",
})).toBe("high");
expect(resolveMergerThinkingLevel({
defaultThinkingLevelOverride: "medium",
defaultThinkingLevel: "low",
})).toBe("medium");
expect(resolveMergerThinkingLevel({ defaultThinkingLevel: "low" })).toBe("low");
});
});
describe("extractRuntimeHint", () => {
@@ -294,6 +314,36 @@ describe("resolve session model parity", () => {
}, staleRuntimeConfig)).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
});
it("prefers the dedicated merger lane over default and other AI role lanes", () => {
const staleRuntimeConfig = { model: "stale-provider/stale-model" };
expect(resolveMergerSessionModel({
mergerProvider: "merger-project-provider",
mergerModelId: "merger-project-model",
mergerGlobalProvider: "merger-global-provider",
mergerGlobalModelId: "merger-global-model",
executionProvider: "openai",
executionModelId: "gpt-4.1",
defaultProviderOverride: "google",
defaultModelIdOverride: "gemini-2.5-pro",
}, staleRuntimeConfig)).toEqual({
provider: "merger-project-provider",
modelId: "merger-project-model",
});
expect(resolveMergerSessionModel({
mergerGlobalProvider: "merger-global-provider",
mergerGlobalModelId: "merger-global-model",
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
defaultProviderOverride: "google",
defaultModelIdOverride: "gemini-2.5-pro",
}, staleRuntimeConfig)).toEqual({
provider: "merger-global-provider",
modelId: "merger-global-model",
});
});
it("uses a complete runtime model only when no lane/task/default model is configured", () => {
const runtimeConfig = { modelProvider: "anthropic", modelId: "claude-opus-4" };

View File

@@ -16,6 +16,7 @@ import {
isGrokApiKeyFusionVisible,
isTestModeActive,
resolveExecutionSettingsModel,
resolveMergerSettingsModel,
resolvePhaseThinkingLevel,
resolveProjectDefaultModel,
resolveTaskExecutionModel,
@@ -257,15 +258,22 @@ export function resolveTitleSummarizerThinkingLevel(settings: Partial<Settings>
/**
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* `resolveMergerSessionModel` intentionally resolves the merger's model from the
* project/global DEFAULT lane, not the title-summarizer lane. The thinking level
* threaded into merger sessions (mutating merge agent, stash-conflict resolver,
* commit agent, PR-response agent) must follow that same default-lane precedence
* so a `titleSummarizerThinkingLevel` override (meant only for title/commit-message
* summarization sessions) does not leak into full merge-agent runs.
* Merger thinking must not inherit title-summarizer thinking (commit-message
* summarization is a different session purpose).
*
* FNXC:Settings-MergerModel 2026-07-13-07:52:
* After the merger model lane became configurable under Global/Project Models,
* thinking follows the same precedence as other dedicated lanes: project merger
* thinking → global merger thinking → project default thinking override → global
* default thinking. Unset at every level preserves prior default-only behavior.
*/
export function resolveMergerThinkingLevel(settings: Partial<Settings> | undefined): string | undefined {
return firstThinkingLevel(settings?.defaultThinkingLevelOverride, settings?.defaultThinkingLevel);
return firstThinkingLevel(
settings?.mergerThinkingLevel,
settings?.mergerGlobalThinkingLevel,
settings?.defaultThinkingLevelOverride,
settings?.defaultThinkingLevel,
);
}
/**
@@ -559,11 +567,14 @@ export function resolveMergerSessionModel(
};
}
// Merger intentionally uses the default lane rather than execution/validator
// lanes. Validator-specific callers resolve `resolveValidatorSettingsModel`
// before falling back here; generic merger work uses project/global defaults.
const defaultModel = resolveProjectDefaultModel(settings);
return pickSettingsThenRuntimeModel(defaultModel, assignedAgentRuntimeConfig);
/*
FNXC:Settings-MergerModel 2026-07-13-07:52:
Merger sessions use the dedicated merger settings lane (project → global → default),
not execution/planning/validator. Session fallback still uses the shared global
fallbackProvider/fallbackModelId pair at createResolvedAgentSession call sites.
*/
const mergerModel = resolveMergerSettingsModel(settings);
return pickSettingsThenRuntimeModel(mergerModel, assignedAgentRuntimeConfig);
}
/**