diff --git a/.changeset/fn-7770-lane-thinking.md b/.changeset/fn-7770-lane-thinking.md new file mode 100644 index 0000000000..f7627f1b20 --- /dev/null +++ b/.changeset/fn-7770-lane-thinking.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add per-lane thinking effort overrides to Settings model lane dropdowns. +category: feature +dev: Adds optional lane thinking settings and runtime precedence task > lane > global default. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 102d19282f..35e7fdbde9 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -108,12 +108,16 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio | `grokCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Grok CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `grok` on PATH. Invalid non-empty saves are rejected with bounded diagnostics. | | `executionGlobalProvider` | `string` | `undefined` | Global baseline provider for task execution. Project `executionProvider` overrides this. | | `executionGlobalModelId` | `string` | `undefined` | Global baseline model ID for task execution. | +| `executionGlobalThinkingLevel` | `ThinkingLevel` | `undefined` | Optional global execution-lane thinking override. Inherits `defaultThinkingLevel` when unset. | | `planningGlobalProvider` | `string` | `undefined` | Global baseline provider for planning. Project `planningProvider` overrides this. | | `planningGlobalModelId` | `string` | `undefined` | Global baseline model ID for planning. | +| `planningGlobalThinkingLevel` | `ThinkingLevel` | `undefined` | Optional global planning-lane thinking override. Inherits `defaultThinkingLevel` when unset. | | `validatorGlobalProvider` | `string` | `undefined` | Global baseline provider for validator/reviewer runs. Project `validatorProvider` overrides this. | | `validatorGlobalModelId` | `string` | `undefined` | Global baseline model ID for validator/reviewer runs. | +| `validatorGlobalThinkingLevel` | `ThinkingLevel` | `undefined` | Optional global reviewer-lane thinking override. Inherits `defaultThinkingLevel` when unset. | | `titleSummarizerGlobalProvider` | `string` | `undefined` | Global baseline provider for title summarization. Project `titleSummarizerProvider` overrides this. | | `titleSummarizerGlobalModelId` | `string` | `undefined` | Global baseline model ID for title summarization. | +| `titleSummarizerGlobalThinkingLevel` | `ThinkingLevel` | `undefined` | Optional global summarization-lane thinking override. Inherits `defaultThinkingLevel` when unset. | | `daemonToken` | `string` | `undefined` | Daemon authentication token (`fn_<32 hex chars>`) used by CLI clients. | | `daemonPort` | `number` | `4040` | Port for daemon/serve mode binding. | | `daemonHost` | `string` | `"127.0.0.1"` | Host for daemon/serve mode binding. Defaults to localhost only; pass `"0.0.0.0"` to expose on all interfaces. | @@ -527,6 +531,7 @@ Default notes: | `planningFallbackModelId` | `string` | `undefined` | Fallback model ID for planning. | | `defaultProviderOverride` | `string` | `undefined` | Project-level override for global default provider baseline. | | `defaultModelIdOverride` | `string` | `undefined` | Project-level override for global default model baseline. | +| `defaultThinkingLevelOverride` | `ThinkingLevel` | `undefined` | Optional project default-lane thinking override used when a task does not set `thinkingLevel`; inherits `defaultThinkingLevel` when unset. | | `executionProvider` | `string` | `undefined` | Provider for task execution agents. | | `executionModelId` | `string` | `undefined` | Model ID for task execution agents. | | `validatorProvider` | `string` | `undefined` | Provider for plan/code reviewers. | @@ -643,6 +648,7 @@ GitLab configuration examples: leave both URL fields blank for GitLab.com (`http | `useAiMergeCommitSummary` | `boolean` | `true` | Use AI-generated merge commit summaries (subject + bullet body + diff-stat) instead of raw step-commit subject lists. | | `titleSummarizerProvider` | `string` | `undefined` | Provider for title summarization. | | `titleSummarizerModelId` | `string` | `undefined` | Model ID for title summarization. | +| `titleSummarizerThinkingLevel` | `ThinkingLevel` | `undefined` | Optional project summarization-lane thinking override. Inherits `titleSummarizerGlobalThinkingLevel` or `defaultThinkingLevel` when unset. | | `titleSummarizerFallbackProvider` | `string` | `undefined` | Fallback provider for title summarization. | | `titleSummarizerFallbackModelId` | `string` | `undefined` | Fallback model ID for title summarization. | | `prTitlePromptInstructions` | `string` | `undefined` | Optional project guidance appended to the Create PR dialog's AI metadata system prompt for the generated PR title. Blank or whitespace-only values are treated as unset and keep the default prompt behavior. | @@ -965,6 +971,8 @@ Short-lived token bounds are enforced server-side: Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes (persisted by the Settings modal's primary Save) or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks. General-scope fallback selection remains the global Fallback Model picker in Settings -> General Models. +Settings model lanes can also carry optional thinking/reasoning effort overrides in the same model dropdown. Empty thinking values inherit the global `defaultThinkingLevel`; explicit lane values are cleared by the lane reset action. Runtime thinking precedence is task `thinkingLevel` > lane thinking override > `defaultThinkingLevel`, and the value still flows through pi.ts' existing thinking/reasoning-conflict fallback (Fusion retries without the explicit level when a provider rejects conflicting thinking parameters). + When the planning lane has neither `planningFallback*` nor a global `fallback*` pair configured, triage now derives an **implicit fallback** from the resolved project/global default (execution) model (FN-7719). This lets a retryable primary planner-model failure (e.g. a provider 404/429) recover via one distinct swap instead of permanently failing triage with "no fallback configured" — the operator's chosen primary planner lane is unchanged, and the implicit fallback is skipped when it would equal the primary model or when test mode is active. Z.ai's built-in provider uses the existing `zai` auth entry / `ZAI_API_KEY` environment variable and includes `zai/glm-5.2` as a selectable model in the same dropdowns and workflow lane controls as the other built-in GLM models. If a pi extension also registers the `zai` provider, Fusion preserves the extension's models and re-adds any missing built-in Z.ai models so built-in GLM choices remain available. diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 902829ef53..5ba9cd4012 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -100,6 +100,23 @@ describe("settings key parity", () => { } }); + + it("keeps model-lane thinking overrides in their owning settings scopes", () => { + expect(DEFAULT_PROJECT_SETTINGS.defaultThinkingLevelOverride).toBeUndefined(); + expect(DEFAULT_PROJECT_SETTINGS.titleSummarizerThinkingLevel).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(isProjectSettingsKey("defaultThinkingLevelOverride")).toBe(true); + expect(isProjectSettingsKey("titleSummarizerThinkingLevel")).toBe(true); + expect(isGlobalSettingsKey("executionGlobalThinkingLevel")).toBe(true); + expect(isGlobalSettingsKey("planningGlobalThinkingLevel")).toBe(true); + expect(isGlobalSettingsKey("validatorGlobalThinkingLevel")).toBe(true); + expect(isGlobalSettingsKey("titleSummarizerGlobalThinkingLevel")).toBe(true); + }); + it("defaults persisted thinking logs to disabled", () => { expect(DEFAULT_GLOBAL_SETTINGS.persistAgentThinkingLog).toBe(false); expect(DEFAULT_GLOBAL_SETTINGS.persistAgentThinkingLogPermanent).toBe(false); diff --git a/packages/core/src/__tests__/store-settings.test.ts b/packages/core/src/__tests__/store-settings.test.ts index 9e56c0f441..12ed57ced7 100644 --- a/packages/core/src/__tests__/store-settings.test.ts +++ b/packages/core/src/__tests__/store-settings.test.ts @@ -35,6 +35,48 @@ describe("TaskStore", () => { expect(settings.defaultProvider).toBeUndefined(); expect(settings.defaultModelId).toBeUndefined(); }); + + it("round-trips per-lane thinking overrides and clears them with null-as-delete", async () => { + await harness.store().updateSettings({ + defaultThinkingLevelOverride: "high", + titleSummarizerThinkingLevel: "low", + }); + await harness.store().updateGlobalSettings({ + executionGlobalThinkingLevel: "medium", + titleSummarizerGlobalThinkingLevel: "minimal", + }); + + let settings = await harness.store().getSettings(); + expect(settings.defaultThinkingLevelOverride).toBe("high"); + expect(settings.titleSummarizerThinkingLevel).toBe("low"); + expect(settings.executionGlobalThinkingLevel).toBe("medium"); + expect(settings.titleSummarizerGlobalThinkingLevel).toBe("minimal"); + + const scoped = await harness.store().getSettingsByScope(); + expect(scoped.project.defaultThinkingLevelOverride).toBe("high"); + expect(scoped.project.titleSummarizerThinkingLevel).toBe("low"); + expect(scoped.global.executionGlobalThinkingLevel).toBe("medium"); + expect(scoped.global.titleSummarizerGlobalThinkingLevel).toBe("minimal"); + + await harness.store().updateSettings({ + // @ts-expect-error - null intentionally clears optional project settings. + defaultThinkingLevelOverride: null, + // @ts-expect-error - null intentionally clears optional project settings. + titleSummarizerThinkingLevel: null, + }); + await harness.store().updateGlobalSettings({ + // @ts-expect-error - null intentionally clears optional global settings. + executionGlobalThinkingLevel: null, + // @ts-expect-error - null intentionally clears optional global settings. + titleSummarizerGlobalThinkingLevel: null, + }); + + settings = await harness.store().getSettings(); + expect(settings.defaultThinkingLevelOverride).toBeUndefined(); + expect(settings.titleSummarizerThinkingLevel).toBeUndefined(); + expect(settings.executionGlobalThinkingLevel).toBeUndefined(); + expect(settings.titleSummarizerGlobalThinkingLevel).toBeUndefined(); + }); }); describe("worktreeInitCommand setting", () => { diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 1162086f5c..b8b0e95174 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -182,6 +182,14 @@ export const DEFAULT_GLOBAL_SETTINGS = { validatorGlobalModelId: undefined, titleSummarizerGlobalProvider: undefined, titleSummarizerGlobalModelId: 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`. + */ + executionGlobalThinkingLevel: undefined, + planningGlobalThinkingLevel: undefined, + validatorGlobalThinkingLevel: undefined, + titleSummarizerGlobalThinkingLevel: undefined, // Daemon mode settings daemonToken: undefined, daemonPort: 4040, @@ -400,6 +408,11 @@ export const DEFAULT_PROJECT_SETTINGS = { // Project-level default override (NOT moved — stays project-scoped) defaultProviderOverride: undefined, defaultModelIdOverride: undefined, + /* + FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + Project model lanes can carry optional thinking overrides. Undefined means inherit the global default thinking effort; runtime precedence remains task > lane override > global default. + */ + defaultThinkingLevelOverride: undefined, modelPresets: [], autoSelectModelPreset: false, completionDocumentationMode: "off", @@ -561,6 +574,7 @@ export const DEFAULT_PROJECT_SETTINGS = { // Title-summarizer model lanes stay project-scoped (not moved in U4). titleSummarizerProvider: undefined, titleSummarizerModelId: undefined, + titleSummarizerThinkingLevel: undefined, titleSummarizerFallbackProvider: undefined, titleSummarizerFallbackModelId: undefined, prTitlePromptInstructions: undefined, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5427947c81..d87c191bf5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3470,6 +3470,14 @@ export interface GlobalSettings { /** Global baseline AI model ID for title summarization. * Must be set together with `titleSummarizerGlobalProvider`. */ titleSummarizerGlobalModelId?: string; + /** Optional global execution-lane thinking override. Inherits `defaultThinkingLevel` when unset. */ + executionGlobalThinkingLevel?: ThinkingLevel; + /** Optional global planning-lane thinking override. Inherits `defaultThinkingLevel` when unset. */ + planningGlobalThinkingLevel?: ThinkingLevel; + /** Optional global reviewer-lane thinking override. Inherits `defaultThinkingLevel` when unset. */ + validatorGlobalThinkingLevel?: ThinkingLevel; + /** Optional global summarization-lane thinking override. Inherits `defaultThinkingLevel` when unset. */ + titleSummarizerGlobalThinkingLevel?: ThinkingLevel; /** The daemon authentication token (format: fn_<32 hex chars>). * Used for authenticating CLI clients to the daemon server. */ daemonToken?: string; @@ -4072,6 +4080,12 @@ export interface ProjectSettings { /** Project-level override for the base default AI model ID. * Must be set together with `defaultProviderOverride`. */ defaultModelIdOverride?: string; + /** + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Settings model lanes carry optional thinking overrides that inherit `defaultThinkingLevel` when unset. Runtime precedence is task `thinkingLevel` > lane thinking override > global `defaultThinkingLevel`. + * Optional project default-lane thinking override used when a task does not set its own thinking level. + */ + defaultThinkingLevelOverride?: ThinkingLevel; /** Project-level AI model provider for task execution (executor agent). * This is the execution lane that overrides the global `executionGlobalProvider`. * Must be set together with `executionModelId`. Falls back to @@ -4601,6 +4615,8 @@ export interface ProjectSettings { * Must be set together with `titleSummarizerProvider`. Falls back to planningModelId, * then defaultModelId if not specified. */ titleSummarizerModelId?: string; + /** Optional project summarization-lane thinking override. Inherits `defaultThinkingLevel` when unset. */ + titleSummarizerThinkingLevel?: ThinkingLevel; /** Fallback model provider for title summarization. When unset, falls back to * planning fallback, then global fallback. Must be set together with * `titleSummarizerFallbackModelId`. */ diff --git a/packages/dashboard/app/__tests__/settings-sections.test.tsx b/packages/dashboard/app/__tests__/settings-sections.test.tsx index 215c8f685b..50953fb72c 100644 --- a/packages/dashboard/app/__tests__/settings-sections.test.tsx +++ b/packages/dashboard/app/__tests__/settings-sections.test.tsx @@ -19,6 +19,7 @@ import { NotificationsSection } from "../components/settings/sections/Notificati import { ExperimentalSection } from "../components/settings/sections/ExperimentalSection"; import { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub"; import { ProjectModelsSection } from "../components/settings/sections/ProjectModelsSection"; +import { GlobalModelsSection } from "../components/settings/sections/GlobalModelsSection"; import { PromptsSection } from "../components/settings/sections/PromptsSection"; import { SecretsSection } from "../components/settings/sections/SecretsSection"; import { WorktreesSection } from "../components/settings/sections/WorktreesSection"; @@ -46,16 +47,26 @@ vi.mock("../api", async (importOriginal) => { }; }); vi.mock("../components/CustomModelDropdown", () => ({ - CustomModelDropdown: ({ id, label, value, onChange, menuWidth = "trigger" }: { id?: string; label: string; value?: string; onChange?: (value: string) => void; menuWidth?: "trigger" | "readable" }) => ( - + CustomModelDropdown: ({ id, label, value, onChange, menuWidth = "trigger", showThinkingLevel = false, thinkingLevel = "", onThinkingLevelChange, defaultThinkingLevel }: { id?: string; label: string; value?: string; onChange?: (value: string) => void; menuWidth?: "trigger" | "readable"; showThinkingLevel?: boolean; thinkingLevel?: string; onThinkingLevelChange?: (value: string) => void; defaultThinkingLevel?: string }) => ( +
+ + {showThinkingLevel ? ( + + ) : null} +
), })); @@ -285,6 +296,36 @@ describe("WorktreesSection", () => { }); }); +describe("GlobalModelsSection", () => { + it("wires global model lane thinking overrides", () => { + const updateLaneThinkingValue = vi.fn(); + render( + "medium"} + updateLaneThinkingValue={updateLaneThinkingValue} + resetLaneThinkingValue={vi.fn()} + favoriteProviders={[]} + favoriteModels={[]} + onToggleFavorite={vi.fn()} + onToggleModelFavorite={vi.fn()} + addToast={vi.fn()} + />, + ); + + expect(screen.getByTestId("mock-model-dropdown-global-execution-model")).toHaveAttribute("data-thinking-visible", "true"); + expect(screen.getByTestId("mock-model-dropdown-global-execution-model")).toHaveAttribute("data-thinking-value", "medium"); + expect(screen.getByTestId("mock-model-dropdown-global-execution-model")).toHaveAttribute("data-default-thinking", "low"); + fireEvent.click(screen.getByTestId("mock-thinking-global-execution-model")); + expect(updateLaneThinkingValue).toHaveBeenCalledWith(expect.objectContaining({ laneId: "execution" }), ""); + }); +}); + describe("ProjectModelsSection", () => { const models = { modelLanes: [], @@ -292,6 +333,9 @@ describe("ProjectModelsSection", () => { getLaneValue: () => "", updateLaneValue: vi.fn(), resetLaneValue: vi.fn(), + getLaneThinkingValue: () => "", + updateLaneThinkingValue: vi.fn(), + resetLaneThinkingValue: vi.fn(), availableModels: [], modelsLoading: false, favoriteProviders: [], @@ -331,6 +375,42 @@ describe("ProjectModelsSection", () => { expect(screen.getByTestId("mock-model-dropdown-preset-validator-model")).toHaveAttribute("data-menu-width", "readable"); }); + it("wires project model lane thinking overrides and reset", () => { + const updateLaneThinkingValue = vi.fn(); + const resetLaneThinkingValue = vi.fn(); + const resetLaneValue = vi.fn(); + render( + "high", + updateLaneThinkingValue, + resetLaneThinkingValue, + resetLaneValue, + availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }], + }} + addToast={vi.fn()} + />, + ); + + expect(screen.getByTestId("mock-model-dropdown-defaultModel")).toHaveAttribute("data-thinking-visible", "true"); + expect(screen.getByTestId("mock-model-dropdown-defaultModel")).toHaveAttribute("data-thinking-value", "high"); + expect(screen.getByTestId("mock-model-dropdown-defaultModel")).toHaveAttribute("data-default-thinking", "medium"); + + fireEvent.click(screen.getByTestId("mock-thinking-defaultModel")); + expect(updateLaneThinkingValue).toHaveBeenCalledWith(expect.objectContaining({ laneId: "default" }), ""); + + fireEvent.click(screen.getByText(/Reset/)); + expect(resetLaneValue).toHaveBeenCalledWith(expect.objectContaining({ laneId: "default" })); + expect(resetLaneThinkingValue).toHaveBeenCalledWith(expect.objectContaining({ laneId: "default" })); + }); + it("opts default workflow model lane dropdowns into readable menu width", async () => { vi.mocked(fetchWorkflow).mockResolvedValueOnce({ id: "builtin:coding", diff --git a/packages/dashboard/app/components/CustomModelDropdown.css b/packages/dashboard/app/components/CustomModelDropdown.css index 34653c6e5b..1c5c1cc11f 100644 --- a/packages/dashboard/app/components/CustomModelDropdown.css +++ b/packages/dashboard/app/components/CustomModelDropdown.css @@ -351,3 +351,4 @@ FN-7760 requires the portaled model list to remain the touch-scroll owner on mob width: min(360px, calc(100vw - 32px)); } } + diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 3c8f437413..b7475baf80 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -2404,8 +2404,10 @@ export function SettingsModal({ label: string; globalProviderKey: keyof GlobalSettings; globalModelKey: keyof GlobalSettings; + globalThinkingKey?: keyof GlobalSettings; projectProviderKey: keyof Settings; projectModelKey: keyof Settings; + projectThinkingKey?: keyof Settings; helperText: string; fallbackOrder: string; } @@ -2419,6 +2421,7 @@ export function SettingsModal({ globalModelKey: "defaultModelId", projectProviderKey: "defaultProviderOverride", projectModelKey: "defaultModelIdOverride", + projectThinkingKey: "defaultThinkingLevelOverride", helperText: "Default AI model used for task execution when no per-task override is set.", fallbackOrder: "Project override → Global default lane → Automatic resolution", }, @@ -2427,6 +2430,7 @@ export function SettingsModal({ label: "Execution Model", globalProviderKey: "executionGlobalProvider", globalModelKey: "executionGlobalModelId", + globalThinkingKey: "executionGlobalThinkingLevel", projectProviderKey: "executionProvider", projectModelKey: "executionModelId", helperText: "AI model used for task implementation (executor agent).", @@ -2437,6 +2441,7 @@ export function SettingsModal({ label: "Planning Model", globalProviderKey: "planningGlobalProvider", globalModelKey: "planningGlobalModelId", + globalThinkingKey: "planningGlobalThinkingLevel", projectProviderKey: "planningProvider", projectModelKey: "planningModelId", helperText: "AI model used for task planning.", @@ -2447,6 +2452,7 @@ export function SettingsModal({ label: "Reviewer Model", globalProviderKey: "validatorGlobalProvider", globalModelKey: "validatorGlobalModelId", + globalThinkingKey: "validatorGlobalThinkingLevel", projectProviderKey: "validatorProvider", projectModelKey: "validatorModelId", helperText: "AI model used for code and specification review.", @@ -2457,8 +2463,10 @@ export function SettingsModal({ label: "Title and Git Commit Message Summarization Model", globalProviderKey: "titleSummarizerGlobalProvider", globalModelKey: "titleSummarizerGlobalModelId", + globalThinkingKey: "titleSummarizerGlobalThinkingLevel", projectProviderKey: "titleSummarizerProvider", projectModelKey: "titleSummarizerModelId", + projectThinkingKey: "titleSummarizerThinkingLevel", helperText: "AI model used for auto-generating task titles and merge commit summaries.", fallbackOrder: "Project override → Global summarization lane → Project planning lane → Project default lane → Global default lane → Automatic resolution", }, @@ -2531,6 +2539,29 @@ export function SettingsModal({ })); } + + /** + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * SettingsModal owns lane thinking persistence just like lane model persistence. Empty values clear the override so the engine falls back through task > lane > global default without sections writing settings directly. + */ + function getLaneThinkingValue(lane: ModelLane, scope: "project" | "global" = "project"): string { + const key = scope === "project" ? lane.projectThinkingKey : lane.globalThinkingKey; + return key ? ((form[key as keyof Settings] as string | undefined) ?? "") : ""; + } + + function updateLaneThinkingValue(lane: ModelLane, level: string, scope: "project" | "global" = "project"): void { + const key = scope === "project" ? lane.projectThinkingKey : lane.globalThinkingKey; + if (!key) return; + setForm((f) => ({ + ...f, + [key]: level || undefined, + })); + } + + function resetLaneThinkingValue(lane: ModelLane, scope: "project" | "global" = "project"): void { + updateLaneThinkingValue(lane, "", scope); + } + const openOverlapPathPicker = useCallback((index: number) => { setOverlapPathPickerIndex(index); setOverlapPathPickerPath("."); @@ -3218,6 +3249,9 @@ export function SettingsModal({ availableModels={availableModels} modelsLoading={modelsLoading} globalModelLanes={MODEL_LANES.filter((lane) => lane.laneId !== "default")} + getLaneThinkingValue={(lane) => getLaneThinkingValue(lane, "global")} + updateLaneThinkingValue={(lane, level) => updateLaneThinkingValue(lane, level, "global")} + resetLaneThinkingValue={(lane) => resetLaneThinkingValue(lane, "global")} favoriteProviders={favoriteProviders} favoriteModels={favoriteModels} onToggleFavorite={handleToggleFavorite} @@ -3267,6 +3301,9 @@ export function SettingsModal({ getLaneValue, updateLaneValue, resetLaneValue, + getLaneThinkingValue, + updateLaneThinkingValue, + resetLaneThinkingValue, availableModels, modelsLoading, favoriteProviders, diff --git a/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx index c428ecbcec..f45c27536c 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx @@ -20,6 +20,9 @@ export interface GlobalModelsSectionProps extends SectionBaseProps { modelsLoading: boolean; /** Global model lanes (i.e. MODEL_LANES without the `default` lane). */ globalModelLanes: ModelLane[]; + getLaneThinkingValue: (lane: ModelLane) => string; + updateLaneThinkingValue: (lane: ModelLane, level: string) => void; + resetLaneThinkingValue: (lane: ModelLane) => void; favoriteProviders: string[]; favoriteModels: string[]; onToggleFavorite: (provider: string) => void; @@ -27,7 +30,7 @@ export interface GlobalModelsSectionProps extends SectionBaseProps { addToast: (message: string, type?: ToastType) => void; projectId?: string; } -export function GlobalModelsSection({ scopeBanner, form, setForm, availableModels, modelsLoading, globalModelLanes, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, addToast, projectId, }: GlobalModelsSectionProps) { +export function GlobalModelsSection({ scopeBanner, form, setForm, availableModels, modelsLoading, globalModelLanes, getLaneThinkingValue, updateLaneThinkingValue, resetLaneThinkingValue, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, addToast, projectId, }: GlobalModelsSectionProps) { const { t } = useTranslation("app"); const selectedValue = form.defaultProvider && form.defaultModelId ? `${form.defaultProvider}/${form.defaultModelId}` @@ -103,6 +106,7 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel const provider = form[lane.globalProviderKey as keyof Settings] as string | undefined; const model = form[lane.globalModelKey as keyof Settings] as string | undefined; const value = provider && model ? `${provider}/${model}` : ""; + const thinkingValue = getLaneThinkingValue(lane); return (
{ @@ -112,6 +116,7 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel [lane.globalProviderKey]: undefined, [lane.globalModelKey]: undefined, })); + resetLaneThinkingValue(lane); return; } const slashIdx = selected.indexOf("/"); @@ -120,7 +125,7 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel [lane.globalProviderKey]: selected.slice(0, slashIdx), [lane.globalModelKey]: selected.slice(slashIdx + 1), })); - }} placeholder={t("settings.globalModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/> + }} placeholder={t("settings.globalModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} showThinkingLevel={Boolean(lane.globalThinkingKey)} thinkingLevel={thinkingValue} onThinkingLevelChange={(level) => updateLaneThinkingValue(lane, level)} defaultThinkingLevel={form.defaultThinkingLevel}/> {lane.helperText}
); })} diff --git a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx index 76f40c9293..967d675371 100644 --- a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx @@ -94,6 +94,9 @@ export interface ProjectModelsSectionModelProps { getLaneValue: (lane: ModelLane) => string; updateLaneValue: (lane: ModelLane, value: string) => void; resetLaneValue: (lane: ModelLane) => void; + getLaneThinkingValue: (lane: ModelLane) => string; + updateLaneThinkingValue: (lane: ModelLane, level: string) => void; + resetLaneThinkingValue: (lane: ModelLane) => void; availableModels: ModelInfo[]; modelsLoading: boolean; favoriteProviders: string[]; @@ -129,7 +132,7 @@ export interface ProjectModelsSectionProps extends SectionBaseProps { } export function ProjectModelsSection({ scopeBanner, form, setForm, models, projectId, onOpenWorkflowSettings, registerWorkflowLaneSaver, }: ProjectModelsSectionProps) { const { t } = useTranslation("app"); - const { modelLanes, getLaneStatus, getLaneValue, updateLaneValue, resetLaneValue, availableModels, modelsLoading, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, editingPresetId, setEditingPresetId, presetDraft, setPresetDraft, onSavePresetDraft, confirmDelete, } = models; + const { modelLanes, getLaneStatus, getLaneValue, updateLaneValue, resetLaneValue, getLaneThinkingValue, updateLaneThinkingValue, resetLaneThinkingValue, availableModels, modelsLoading, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, editingPresetId, setEditingPresetId, presetDraft, setPresetDraft, onSavePresetDraft, confirmDelete, } = models; const presets = form.modelPresets || []; const presetOptions = presets.map((preset) => ({ id: preset.id, name: preset.name })); const inUsePresetIds = new Set(Object.values(form.defaultPresetBySize || {}).filter(Boolean)); @@ -296,7 +299,8 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje {projectModelLanes.map((lane) => { const status = getLaneStatus(lane); const value = getLaneValue(lane); - const isOverridden = status === "overridden"; + const thinkingValue = getLaneThinkingValue(lane); + const isOverridden = status === "overridden" || Boolean(thinkingValue); const laneLabel = getProjectLaneLabel(lane); return (
@@ -307,9 +311,9 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
- updateLaneValue(lane, val)} placeholder={lane.laneId === "default" ? "Use global default" : "Use global"} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable"/> + updateLaneValue(lane, val)} placeholder={lane.laneId === "default" ? "Use global default" : "Use global"} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={Boolean(lane.projectThinkingKey)} thinkingLevel={thinkingValue} onThinkingLevelChange={(level) => updateLaneThinkingValue(lane, level)} defaultThinkingLevel={form.defaultThinkingLevel}/>
- {isOverridden && ()} + {isOverridden && ()}
{getProjectLaneHelperText(lane)}{t("settings.projectModels.fallsBackTo", " Falls back to: ")}{lane.fallbackOrder}. diff --git a/packages/dashboard/app/components/settings/sections/context.ts b/packages/dashboard/app/components/settings/sections/context.ts index 17253ea239..b11a6540e7 100644 --- a/packages/dashboard/app/components/settings/sections/context.ts +++ b/packages/dashboard/app/components/settings/sections/context.ts @@ -23,8 +23,10 @@ export interface ModelLane { label: string; globalProviderKey: keyof GlobalSettings; globalModelKey: keyof GlobalSettings; + globalThinkingKey?: keyof GlobalSettings; projectProviderKey: keyof Settings; projectModelKey: keyof Settings; + projectThinkingKey?: keyof Settings; helperText: string; fallbackOrder: string; } diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index 8df218a726..c53b3f63de 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -3,11 +3,15 @@ import { extractRuntimeHint, extractRuntimeModel, resolveExecutorSessionModel, + resolveExecutorThinkingLevel, resolveHeartbeatSessionModels, resolveImplicitPlanningFallbackModel, resolveMergerSessionModel, resolvePlanningSessionModel, + resolvePlanningThinkingLevel, + resolveTitleSummarizerThinkingLevel, resolveValidatorSessionModel, + resolveValidatorThinkingLevel, } from "../agent-session-helpers.js"; const { resolveRuntimeMock } = vi.hoisted(() => ({ @@ -22,6 +26,38 @@ vi.mock("../runtime-resolution.js", async () => { }; }); + +describe("resolve model-lane thinking levels", () => { + it("applies task > execution lane > project default lane > global default precedence", () => { + const settings = { + defaultThinkingLevel: "low", + defaultThinkingLevelOverride: "medium", + executionGlobalThinkingLevel: "high", + } as const; + + expect(resolveExecutorThinkingLevel("xhigh", settings)).toBe("xhigh"); + expect(resolveExecutorThinkingLevel(undefined, settings)).toBe("high"); + expect(resolveExecutorThinkingLevel(undefined, { defaultThinkingLevelOverride: "medium", defaultThinkingLevel: "low" })).toBe("medium"); + expect(resolveExecutorThinkingLevel(undefined, { defaultThinkingLevel: "low" })).toBe("low"); + }); + + it("resolves planning, reviewer, and summarization lane overrides before the global default", () => { + expect(resolvePlanningThinkingLevel({ planningGlobalThinkingLevel: "minimal", defaultThinkingLevel: "high" })).toBe("minimal"); + expect(resolveValidatorThinkingLevel(undefined, { validatorGlobalThinkingLevel: "medium", defaultThinkingLevel: "low" })).toBe("medium"); + expect(resolveValidatorThinkingLevel("xhigh", { validatorGlobalThinkingLevel: "medium", defaultThinkingLevel: "low" })).toBe("xhigh"); + expect(resolveTitleSummarizerThinkingLevel({ + titleSummarizerThinkingLevel: "high", + titleSummarizerGlobalThinkingLevel: "medium", + defaultThinkingLevel: "low", + })).toBe("high"); + expect(resolveTitleSummarizerThinkingLevel({ + titleSummarizerGlobalThinkingLevel: "medium", + defaultThinkingLevelOverride: "minimal", + defaultThinkingLevel: "low", + })).toBe("medium"); + }); +}); + describe("extractRuntimeHint", () => { it("returns undefined for undefined config", () => { expect(extractRuntimeHint(undefined)).toBeUndefined(); diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index 081a14a565..071d1ccf7e 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -23,6 +23,7 @@ import { TEST_MODE_RESOLVED, type ResolvedModelSelection, type Settings, + type ThinkingLevel, } from "@fusion/core"; import { resolveRuntime, buildRuntimeResolutionContext, isMockProviderId, type SessionPurpose } from "./runtime-resolution.js"; import { createLogger } from "./logger.js"; @@ -148,6 +149,65 @@ export function extractRuntimeModel( }; } + +function firstThinkingLevel(...levels: Array): string | undefined { + for (const level of levels) { + if (typeof level === "string" && level.trim().length > 0) { + return level.trim(); + } + } + return undefined; +} + +/** + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Model-lane thinking overrides must resolve through the same session option (`defaultThinkingLevel`) pi.ts already guards with the thinking/reasoning conflict fallback. Keep task-level thinking first, then the lane override, then the global default. + */ +export function resolveExecutorThinkingLevel( + taskThinkingLevel: ThinkingLevel | string | undefined, + settings: Partial | undefined, +): string | undefined { + return firstThinkingLevel( + taskThinkingLevel, + settings?.executionGlobalThinkingLevel, + settings?.defaultThinkingLevelOverride, + settings?.defaultThinkingLevel, + ); +} + +export function resolvePlanningThinkingLevel(settings: Partial | undefined): string | undefined { + return firstThinkingLevel(settings?.planningGlobalThinkingLevel, settings?.defaultThinkingLevelOverride, settings?.defaultThinkingLevel); +} + +export function resolveValidatorThinkingLevel( + taskThinkingLevel: ThinkingLevel | string | undefined, + settings: Partial | undefined, +): string | undefined { + return firstThinkingLevel(taskThinkingLevel, settings?.validatorGlobalThinkingLevel, settings?.defaultThinkingLevelOverride, settings?.defaultThinkingLevel); +} + +export function resolveTitleSummarizerThinkingLevel(settings: Partial | undefined): string | undefined { + return firstThinkingLevel( + settings?.titleSummarizerThinkingLevel, + settings?.titleSummarizerGlobalThinkingLevel, + settings?.defaultThinkingLevelOverride, + settings?.defaultThinkingLevel, + ); +} + +/** + * 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. + */ +export function resolveMergerThinkingLevel(settings: Partial | undefined): string | undefined { + return firstThinkingLevel(settings?.defaultThinkingLevelOverride, settings?.defaultThinkingLevel); +} + function hasCompleteRuntimeModel( model: ResolvedModelSelection, ): model is { provider: string; modelId: string } { diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 54f1a82483..d15f1a33eb 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -84,6 +84,8 @@ import { createResolvedAgentSession, extractRuntimeHint, resolveExecutorSessionModel, + resolveExecutorThinkingLevel, + resolveValidatorThinkingLevel, } from "./agent-session-helpers.js"; import { buildSessionSkillContext } from "./session-skill-context.js"; import type { SkillSelectionContext } from "./skill-resolver.js"; @@ -6658,7 +6660,7 @@ export class TaskExecutor { defaultModelId: settings.defaultModelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: detail.thinkingLevel ?? settings.defaultThinkingLevel, + defaultThinkingLevel: resolveExecutorThinkingLevel(detail.thinkingLevel, settings), taskValidatorProvider: detail.validatorModelProvider, taskValidatorModelId: detail.validatorModelId, projectValidatorProvider: settings.validatorProvider, @@ -10541,7 +10543,7 @@ export class TaskExecutor { ); const executorFallbackProvider = settings.fallbackProvider; const executorFallbackModelId = settings.fallbackModelId; - const executorThinkingLevel = detail.thinkingLevel ?? settings.defaultThinkingLevel; + const executorThinkingLevel = resolveExecutorThinkingLevel(detail.thinkingLevel, settings); // U1 telemetry: now that the session model/provider/node are resolved, // give the agent logger the context it needs to emit usage_events tool @@ -13560,7 +13562,7 @@ export class TaskExecutor { defaultModelId: settings.defaultModelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: latestDetailForReview.thinkingLevel ?? settings.defaultThinkingLevel, + defaultThinkingLevel: resolveValidatorThinkingLevel(latestDetailForReview.thinkingLevel, settings), // Task-level validator override (from task) taskValidatorProvider: latestDetailForReview.validatorModelProvider, taskValidatorModelId: latestDetailForReview.validatorModelId, @@ -13988,7 +13990,7 @@ Do not refactor, rename broadly, or make opportunistic improvements. onToolEnd: logger.onToolEnd, defaultProvider: executorProvider, defaultModelId: executorModelId, - defaultThinkingLevel: settings.defaultThinkingLevel, + defaultThinkingLevel: resolveExecutorThinkingLevel(task.thinkingLevel, settings), runAuditor: createRunAuditor(this.store, this.getRunContextFor(task.id)), settings, taskEnv: extraEnv, @@ -15078,7 +15080,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB defaultModelId: modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: settings.defaultThinkingLevel, + defaultThinkingLevel: resolveExecutorThinkingLevel(task.thinkingLevel, settings), runAuditor: createRunAuditor(this.store, this.getRunContextFor(task.id)), settings, taskEnv: stepEnv, @@ -15096,7 +15098,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB const workflowModelDetails = formatModelMarkerDetails( describeModel(session), - settings.defaultThinkingLevel, + resolveExecutorThinkingLevel(task.thinkingLevel, settings), [ useOverride && attemptLabel === "primary" ? "workflow step override" : "", attemptLabel === "fallback" ? "fallback after timeout" : "", diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 70a0fb2be6..b4b69dda3a 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -59,7 +59,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 } from "./agent-session-helpers.js"; +import { createResolvedAgentSession, resolveMergerSessionModel, resolveMergerThinkingLevel, resolveValidatorThinkingLevel } from "./agent-session-helpers.js"; import { promptWithFallback } from "./pi.js"; import { AgentLogger } from "./agent-logger.js"; import { withRateLimitRetry } from "./rate-limit-retry.js"; @@ -268,7 +268,7 @@ function makeMutatingAgent(store: TaskStore, settings: Settings, taskId: string, defaultModelId: model.modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: settings.defaultThinkingLevel, + defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: audit, settings, // FNXC:McpConfig 2026-06-25-22:48: merger-ai is the production merge path, so the mutating agent resolves enabled MCP servers at session creation and relies on the shared runtime guard for unsupported providers. @@ -296,6 +296,12 @@ 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-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. + const reviewThinkingLevel = validator.provider && validator.modelId + ? resolveValidatorThinkingLevel(undefined, settings) + : resolveMergerThinkingLevel(settings); let captured = ""; const logger = new AgentLogger({ store, @@ -327,7 +333,7 @@ function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, o defaultModelId: model.modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: settings.defaultThinkingLevel, + defaultThinkingLevel: reviewThinkingLevel, runAuditor: audit, settings, // FNXC:McpConfig 2026-06-25-22:48: The production merge reviewer receives the same materialized MCP set as the mutating merge agent, preserving all-lane forwarding without logging server contents. diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 099dc53605..cc66412989 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -109,7 +109,7 @@ import { evaluateAutoMergeFactProviders } from "./auto-merge-fact-providers.js"; import { resolveMergePolicy, type MergeFileScopeMode } from "./merge-trait.js"; import { describeModel, promptWithFallback } from "./pi.js"; import { accumulateSessionTokenUsage } from "./session-token-usage.js"; -import { createResolvedAgentSession, extractRuntimeHint, resolveMergerSessionModel } from "./agent-session-helpers.js"; +import { createResolvedAgentSession, extractRuntimeHint, resolveMergerSessionModel, resolveMergerThinkingLevel } from "./agent-session-helpers.js"; import { createFallbackModelObserver } from "./fallback-model-observer.js"; import { buildSessionSkillContext } from "./session-skill-context.js"; import { resolveMcpServersForStore } from "./mcp-resolution.js"; @@ -1969,7 +1969,7 @@ Do not refactor, rename broadly, or make opportunistic improvements. defaultModelId: mergerSessionModel.modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: settings.defaultThinkingLevel, + defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: createRunAuditor(store, { runId: mergeRunContext?.runId ?? generateSyntheticRunId("merge", taskId), agentId: mergeRunContext?.agentId ?? "merger", @@ -3163,7 +3163,7 @@ ${fileList} defaultModelId: mergerSessionModel.modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: settings.defaultThinkingLevel, + defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: createRunAuditor(store, { runId: generateSyntheticRunId("merge", taskId), agentId: "merger", @@ -3580,7 +3580,7 @@ ${fileList} defaultModelId: mergerSessionModel.modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: settings.defaultThinkingLevel, + defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: createRunAuditor(store, { runId: generateSyntheticRunId("merge", taskId), agentId: "merger", @@ -7084,7 +7084,7 @@ You are assisting with a paused \`git pull --rebase\`. defaultModelId: mergerSessionModel.modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: settings.defaultThinkingLevel, + defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: createRunAuditor(store, { runId: generateSyntheticRunId("merge", taskId), agentId: "merger", @@ -12039,7 +12039,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo defaultModelId: mergerSessionModel.modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: settings.defaultThinkingLevel, + defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: createRunAuditor(store, { runId: generateSyntheticRunId("merge", taskId), agentId: "merger", diff --git a/packages/engine/src/pr-response-run-ops.ts b/packages/engine/src/pr-response-run-ops.ts index fc4ef07183..f9dcab1e04 100644 --- a/packages/engine/src/pr-response-run-ops.ts +++ b/packages/engine/src/pr-response-run-ops.ts @@ -11,7 +11,7 @@ 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 } from "./agent-session-helpers.js"; +import { createResolvedAgentSession, resolveMergerSessionModel, resolveMergerThinkingLevel } from "./agent-session-helpers.js"; import { resolveMcpServersForStore } from "./mcp-resolution.js"; import { promptWithFallback } from "./pi.js"; import { withRateLimitRetry } from "./rate-limit-retry.js"; @@ -125,7 +125,7 @@ export function makePrResponseAgentRunner( defaultModelId: model.modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: settings.defaultThinkingLevel, + defaultThinkingLevel: resolveMergerThinkingLevel(settings), settings, taskId, mcpServers, diff --git a/packages/engine/src/step-session-executor.ts b/packages/engine/src/step-session-executor.ts index d3909ac22a..e2674cfac0 100644 --- a/packages/engine/src/step-session-executor.ts +++ b/packages/engine/src/step-session-executor.ts @@ -26,6 +26,7 @@ import { describeAgentModel, promptWithAutoRetry, resolveExecutorSessionModel, + resolveExecutorThinkingLevel, } from "./agent-session-helpers.js"; import type { AgentActionGateContext } from "./agent-action-gate.js"; import type { SkillSelectionContext } from "./skill-resolver.js"; @@ -1344,7 +1345,7 @@ Follow instructions precisely and avoid unrelated changes.`, defaultModelId: executorModelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - defaultThinkingLevel: taskDetail.thinkingLevel ?? settings.defaultThinkingLevel, + defaultThinkingLevel: resolveExecutorThinkingLevel(taskDetail.thinkingLevel, settings), runAuditor: createRunAuditor(this.store, { runId: generateSyntheticRunId("workflow-step", taskDetail.id), // Column-agent attribution (U4): the effective column agent is the diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index b82ef1fae2..b976415ad8 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -89,6 +89,7 @@ import { extractRuntimeHint, resolveImplicitPlanningFallbackModel, resolvePlanningSessionModel, + resolvePlanningThinkingLevel, } from "./agent-session-helpers.js"; import { mergeEffectiveSettings } from "./effective-settings.js"; import { detectDanglingTaskDocReferences, formatDanglingDiagnostic } from "./spec-validation/task-document-references.js"; @@ -1160,7 +1161,7 @@ export class TriageProcessor { fallbackModelId: hasExplicitPlanningFallback ? settings.planningFallbackModelId : (hasExplicitGlobalFallback ? settings.fallbackModelId : implicitPlanningFallback.modelId), - defaultThinkingLevel: settings.defaultThinkingLevel, + defaultThinkingLevel: resolvePlanningThinkingLevel(settings), runAuditor, settings, // FNXC:McpConfig 2026-06-25-23:17: Primary triage planning is an AI lane, so it receives the store-resolved MCP set while the pi runtime-support guard decides whether to forward it without logging secret material. @@ -1178,7 +1179,7 @@ export class TriageProcessor { }), }); - const modelDesc = formatModelMarkerDetails(describeModel(session), settings.defaultThinkingLevel); + const modelDesc = formatModelMarkerDetails(describeModel(session), resolvePlanningThinkingLevel(settings)); planLog.log(`${task.id}: using model ${modelDesc}`); await this.store.logEntry(task.id, `Triage using model: ${modelDesc}`); await this.store.appendAgentLog( diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 05dad8c1ef..aa73095b4d 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -3967,7 +3967,8 @@ "medium": "Medium", "minimal": "Minimal", "off": "Off", - "xhigh": "Very High" + "xhigh": "Very High", + "defaultWithLevel": "Default ({{level}})" }, "placeholders": { "selectExecutor": "Select executor model…", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index aa98d5cdcc..e2addb9c68 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -3957,7 +3957,8 @@ "medium": "Medio", "minimal": "Mínimo", "off": "Desactivado", - "xhigh": "Muy alto" + "xhigh": "Muy alto", + "defaultWithLevel": "Default ({{level}})" }, "placeholders": { "selectExecutor": "Seleccione modelo de ejecución…", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 8862e1a847..378989540d 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -3957,7 +3957,8 @@ "medium": "Moyen", "minimal": "Minimal", "off": "Désactivé", - "xhigh": "Très élevé" + "xhigh": "Très élevé", + "defaultWithLevel": "Default ({{level}})" }, "placeholders": { "selectExecutor": "Sélectionnez un modèle d'exécuteur…", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 8a69694d1b..73b8431602 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -3957,7 +3957,8 @@ "medium": "중간", "minimal": "최소", "off": "끔", - "xhigh": "매우 높음" + "xhigh": "매우 높음", + "defaultWithLevel": "Default ({{level}})" }, "placeholders": { "selectExecutor": "실행자 모델 선택…", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 830163b1c3..6e202b334d 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -3957,7 +3957,8 @@ "medium": "中等", "minimal": "最小", "off": "关闭", - "xhigh": "极高" + "xhigh": "极高", + "defaultWithLevel": "Default ({{level}})" }, "placeholders": { "selectExecutor": "选择执行器模型…", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index e23b70d3eb..a20f05e378 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -3957,7 +3957,8 @@ "medium": "中等", "minimal": "最小", "off": "關閉", - "xhigh": "極高" + "xhigh": "極高", + "defaultWithLevel": "Default ({{level}})" }, "placeholders": { "selectExecutor": "選擇執行器模型…", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index 1714e43822..77904665e4 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -3937,6 +3937,8 @@ export default interface Resources { "preset": "Preset", "reviewerModel": "Reviewer Model", "reviewerPlaceholder": "Select reviewer model…", + "thinkingDefault": "Default ({{level}})", + "thinkingModel": "Thinking", "title": "Select Models", "useDefault": "Use default", "usingDefault": "Using default" @@ -3980,6 +3982,7 @@ export default interface Resources { "noResults": "No models match '{{filter}}'", "options": { "default": "Default", + "defaultWithLevel": "Default ({{level}})", "high": "High", "low": "Low", "medium": "Medium",