diff --git a/src/browser/components/ThinkingSelector/ThinkingSelector.tsx b/src/browser/components/ThinkingSelector/ThinkingSelector.tsx index e5f8c9d34de..c202c87d23b 100644 --- a/src/browser/components/ThinkingSelector/ThinkingSelector.tsx +++ b/src/browser/components/ThinkingSelector/ThinkingSelector.tsx @@ -14,8 +14,10 @@ import { } from "@/browser/utils/fastModeServiceTier"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { cn } from "@/common/lib/utils"; +import assert from "@/common/utils/assert"; import { getThinkingDisplayLabel, + THINKING_LEVELS, type OpenAIReasoningMode, type ThinkingLevel, } from "@/common/types/thinking"; @@ -38,22 +40,26 @@ const THINKING_OPTION_LABELS: Record = { // Friendly option label, provider-aware for xhigh/max so menu rows match the // trigger's branding (e.g. Opus 4.6 calls its top effort "Max", not "Extra High"). -function getThinkingMenuLabel(level: ThinkingLevel, capabilityModel: string): string { - if (level === "xhigh" || level === "max") { +function getThinkingMenuLabel(level: ThinkingLevel, capabilityModel?: string): string { + if (capabilityModel && (level === "xhigh" || level === "max")) { return getThinkingDisplayLabel(level, capabilityModel) === "XHIGH" ? "Extra High" : "Max"; } return THINKING_OPTION_LABELS[level]; } export interface ThinkingInheritOption { - /** Row/trigger label shown while inherit is selected (e.g. "Inherit from UI Exec"). */ + /** Row/trigger label shown while inherit is selected. */ label: string; selected: boolean; onSelect: () => void; } interface ThinkingSelectorControlProps { - modelString: string; + modelString: string | undefined; + /** Delegated preferences may inherit a model that is only known at launch. */ + modelCapabilitiesDeferred?: boolean; + /** Independent of effort/model inheritance; false denotes an explicit mode override. */ + reasoningModeInherited?: boolean; thinkingLevel: ThinkingLevel; onThinkingLevelChange: (level: ThinkingLevel) => void; reasoningMode: OpenAIReasoningMode; @@ -87,35 +93,53 @@ export const ThinkingSelectorControl: React.FC = ( const variant = props.variant ?? "composer"; const inheritSelected = props.inheritOption?.selected === true; - // Resolve mapped aliases so the selector offers the target model's ladder - // (e.g. an alias mapped to GPT-5.6 exposes native max). - const minimum = getMinimum(props.modelString); - const allowed = getAvailableThinkingLevels(props.modelString, minimum, providersConfig); - const effectiveThinkingLevel = enforceThinkingPolicy( - props.modelString, - props.thinkingLevel, - minimum, - providersConfig - ); - // Label from the capability model so mapped aliases (e.g. xai:team-grok -> - // xai:grok-4.6) show the same provider-aware xhigh/max wording as the ladder. - const capabilityModel = resolveModelForMetadata(props.modelString, providersConfig ?? null); - const resolvedRoute = routing.resolveRoute(normalizeToCanonical(props.modelString)).route; - const proModeAvailable = - props.allowProMode !== false && - openaiProModeAvailable(props.modelString, { - providersConfig, - resolvedRouteProvider: resolvedRoute, - }); - const fastModeProvider = - props.allowFastMode !== false && providersConfig != null - ? getFastModeProvider(props.modelString, { - providersConfig, - resolvedRouteProvider: resolvedRoute, - }) - : null; + const modelCapabilitiesDeferred = props.modelCapabilitiesDeferred ?? false; + const reasoningModeInherited = props.reasoningModeInherited === true; + const { allowed, effectiveThinkingLevel, capabilityModel, proModeAvailable, fastModeProvider } = + (() => { + // The calling chat's model is unknown in Settings. Store preferences without + // borrowing global Exec capabilities; the launch path enforces the real model's policy. + if (modelCapabilitiesDeferred) { + return { + allowed: THINKING_LEVELS, + effectiveThinkingLevel: props.thinkingLevel, + capabilityModel: undefined, + proModeAvailable: props.allowProMode !== false, + fastModeProvider: null, + }; + } + + assert(props.modelString, "A model is required unless capabilities are deferred"); + const minimum = getMinimum(props.modelString); + const resolvedRoute = routing.resolveRoute(normalizeToCanonical(props.modelString)).route; + return { + allowed: getAvailableThinkingLevels(props.modelString, minimum, providersConfig), + effectiveThinkingLevel: enforceThinkingPolicy( + props.modelString, + props.thinkingLevel, + minimum, + providersConfig + ), + // Mapped aliases use the target model's ladder and provider-aware labels. + capabilityModel: resolveModelForMetadata(props.modelString, providersConfig ?? null), + proModeAvailable: + props.allowProMode !== false && + openaiProModeAvailable(props.modelString, { + providersConfig, + resolvedRouteProvider: resolvedRoute, + }), + fastModeProvider: + props.allowFastMode !== false && providersConfig != null + ? getFastModeProvider(props.modelString, { + providersConfig, + resolvedRouteProvider: resolvedRoute, + }) + : null, + }; + })(); const fastModeAvailable = fastModeProvider != null; - const proModeActive = proModeAvailable && props.reasoningMode === "pro"; + const proModeActive = + !reasoningModeInherited && proModeAvailable && props.reasoningMode === "pro"; const fastModeActive = fastModeProvider != null && providersConfig?.[fastModeProvider]?.serviceTier === "priority"; const hasMenu = @@ -195,7 +219,9 @@ export const ThinkingSelectorControl: React.FC = ( onClick={() => setIsOpen((previous) => !previous)} > - {getThinkingDisplayLabel(effectiveThinkingLevel, capabilityModel)} + {modelCapabilitiesDeferred + ? THINKING_OPTION_LABELS[effectiveThinkingLevel] + : getThinkingDisplayLabel(effectiveThinkingLevel, capabilityModel)} {proModeActive && ( = ( ? props.inheritOption.label : getThinkingMenuLabel(effectiveThinkingLevel, capabilityModel)} - {proModeActive && ( + {(proModeActive || props.reasoningModeInherited === false) && ( - PRO + {props.reasoningMode === "pro" ? "PRO" : "STANDARD"} )} {fastModeActive && ( @@ -297,6 +323,10 @@ export const ThinkingSelectorControl: React.FC = ( trigger )} + {reasoningModeInherited && ( +
Reasoning mode: Use calling chat’s Exec
+ )} + {isOpen && (
= ( ) : null}
- {props.modelValue === INHERIT && props.inheritedModelDescription ? ( -
{props.inheritedModelDescription}
- ) : null}
@@ -366,9 +358,11 @@ function AiDefaultsControls(props: AiDefaultsControlsProps) { (route-aware Pro mode, provider Fast mode) as the chat input. */} props.onThinkingChange(level)} reasoningMode={props.reasoningModeValue} + reasoningModeInherited={props.reasoningModeInherited} onReasoningModeChange={props.onReasoningModeChange} allowProMode={props.allowProMode} variant="box" @@ -386,13 +380,10 @@ function AiDefaultsControls(props: AiDefaultsControlsProps) { className="h-9 px-2" onClick={() => props.onThinkingChange(INHERIT)} > - {resetThinkingLabel} + Reset ) : null}
- {props.thinkingValue === INHERIT && props.inheritedThinkingDescription ? ( -
{props.inheritedThinkingDescription}
- ) : null} ); @@ -791,20 +782,11 @@ export function TasksSection() { }; const setSubagentReasoningMode = (agentId: string, mode: OpenAIReasoningMode) => { - // Deleting the override falls back to the base (interactive) profile, so - // when that profile is pro, turning the toggle off must persist an explicit - // "standard" or the inherited pro would win and the toggle could never - // disable it. Delete (sparse storage) only when nothing pro is inherited. - const inheritsPro = agentAiDefaults[agentId]?.reasoningMode === "pro"; + // The calling chat is unknown here: Standard must remain explicit even if + // global Exec is Standard, or a Pro calling chat would override the choice. setAgentAiDefaults((prev) => updateAgentSubagentProfile(prev, agentId, (profile) => { - if (mode === "pro") { - profile.reasoningMode = "pro"; - } else if (inheritsPro) { - profile.reasoningMode = "standard"; - } else { - delete profile.reasoningMode; - } + profile.reasoningMode = mode; }) ); }; @@ -1060,13 +1042,6 @@ export function TasksSection() { const entry = agentAiDefaults.exec?.subagent; const modelValue = entry?.modelString ?? INHERIT; const thinkingValue = entry?.thinkingLevel ?? INHERIT; - const uiExecEntry = agentAiDefaults.exec; - const inheritedExecModel = - uiExecEntry?.modelString ?? resolveDefinitionModel(agent) ?? inheritedEffectiveModel; - const effectiveModel = modelValue !== INHERIT ? modelValue : inheritedExecModel; - const rawInheritedThinking = uiExecEntry?.thinkingLevel ?? THINKING_LEVEL_OFF; - const clampedInheritedThinking = enforceThinkingPolicy(effectiveModel, rawInheritedThinking); - const inheritedThinkingLabel = getThinkingOptionLabel(clampedInheritedThinking, effectiveModel); return (
- Unset fields inherit from UI Exec defaults. Enabled and advisor settings stay shared - with UI Exec. + Unset fields use the calling chat’s Exec settings at task creation, falling back to UI + Exec defaults when no chat selection exists. Model capabilities are enforced at launch. + Enabled and advisor settings stay shared with UI Exec.
setSubagentModel("exec", value)} onThinkingChange={(value) => setSubagentThinking("exec", value)} diff --git a/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx index 10502b4164c..b044e455874 100644 --- a/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx @@ -4,8 +4,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { installDom } from "../../../../../tests/ui/dom"; import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; -import { getThinkingOptionLabel } from "@/common/types/thinking"; -import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; let advisorExperimentEnabled = false; @@ -242,42 +240,86 @@ describe("TasksSection Exec subagent defaults", () => { }); }); - test("unset Exec subagent defaults inherit from UI Exec", async () => { + test.each(["openai:gpt-5.6-sol", "xai:grok-code-fast-1"])( + "does not resolve or persist inherited preferences from global Exec model %s", + async (modelString) => { + const view = renderTasksSection({ + agentAiDefaults: { exec: { modelString, thinkingLevel: "medium", reasoningMode: "pro" } }, + }); + const row = await view.findByRole("group", { name: "Exec defaults" }); + const trigger = within(row).getByRole("button", { name: "Reasoning" }); + expect(trigger.textContent).not.toContain("PRO"); + expect(within(row).getByLabelText("Model").value).toBe(""); + fireEvent.click(trigger); + const listbox = within(row).getByRole("listbox", { name: "Reasoning effort" }); + expect(within(listbox).getByRole("option", { selected: true }).textContent).not.toContain( + "Medium" + ); + for (const level of ["Off", "Low", "Medium", "High", "Extra High", "Max"]) { + expect( + within(listbox).getByRole("option", { name: level }).getAttribute("aria-selected") + ).toBe("false"); + } + expect( + within(row) + .getByRole("button", { name: /Pro mode/ }) + .getAttribute("aria-pressed") + ).toBe("mixed"); + expect(within(row).queryByRole("button", { name: /Fast mode/ })).toBeNull(); + expect(view.saveConfig).not.toHaveBeenCalled(); + } + ); + + test("defers inherited-model effort capabilities without persisting a model or mode", async () => { const view = renderTasksSection({ - agentAiDefaults: { - exec: { modelString: "anthropic:ui-exec", thinkingLevel: "medium" }, - }, + agentAiDefaults: { exec: { modelString: "xai:grok-code-fast-1", reasoningMode: "pro" } }, }); - const row = await view.findByRole("group", { name: "Exec defaults" }); - - expect(within(row).getByText("Inherits from UI Exec: anthropic:ui-exec")).toBeTruthy(); - expect(within(row).getByText("Inherits from UI Exec: medium")).toBeTruthy(); - expect(within(row).queryByRole("button", { name: "Inherit from UI Exec" })).toBeNull(); + selectReasoningOption(row, "Max"); + await waitFor(() => + expect(getLatestSavePayload(view.saveConfig).agentAiDefaults.exec?.subagent).toEqual({ + thinkingLevel: "max", + }) + ); + const trigger = within(row).getByRole("button", { name: "Reasoning" }); + expect(trigger.textContent).toContain("Max"); + expect(trigger.textContent).not.toContain("PRO"); + const listbox = within(row).getByRole("listbox", { name: "Reasoning effort" }); + fireEvent.click(within(listbox).getByRole("option", { name: "Extra High" })); + await waitFor(() => + expect(getLatestSavePayload(view.saveConfig).agentAiDefaults.exec?.subagent).toEqual({ + thinkingLevel: "xhigh", + }) + ); + expect(trigger.textContent).toContain("Extra High"); }); - test("clamps inherited Exec subagent thinking hint to the effective model policy", async () => { - const model = "openai:gpt-5-pro"; - const expectedLabel = getThinkingOptionLabel(enforceThinkingPolicy(model, "xhigh"), model); - const unclampedLabel = getThinkingOptionLabel("xhigh", model); - + test("explicit subagent models use their capabilities while mode remains inherited", async () => { const view = renderTasksSection({ agentAiDefaults: { exec: { - modelString: "anthropic:ui-exec", - thinkingLevel: "xhigh", - subagent: { modelString: model }, + reasoningMode: "pro", + subagent: { modelString: "openai:gpt-5.6-sol", thinkingLevel: "high" }, }, }, }); - const row = await view.findByRole("group", { name: "Exec defaults" }); - - expect(within(row).getByText(`Inherits from UI Exec: ${expectedLabel}`)).toBeTruthy(); - if (unclampedLabel !== expectedLabel) { - expect(within(row).queryByText(`Inherits from UI Exec: ${unclampedLabel}`)).toBeNull(); - } - expect(within(row).queryByText("Inherits from UI Exec: Inherit")).toBeNull(); + fireEvent.click(within(row).getByRole("button", { name: "Reasoning" })); + expect( + within(row) + .getByRole("button", { name: /Pro mode/ }) + .getAttribute("aria-pressed") + ).toBe("mixed"); + fireEvent.change(within(row).getByLabelText("Model"), { + target: { value: "xai:grok-code-fast-1" }, + }); + expect(within(row).queryByRole("button", { name: /Pro mode/ })).toBeNull(); + expect(within(row).queryByRole("option", { name: "Max" })).toBeNull(); + await waitFor(() => + expect( + getLatestSavePayload(view.saveConfig).agentAiDefaults.exec?.subagent?.reasoningMode + ).toBeUndefined() + ); }); test("setting only the Exec subagent model writes only the sparse subagent model", async () => { @@ -334,7 +376,7 @@ describe("TasksSection Exec subagent defaults", () => { }); const row = await view.findByRole("group", { name: "Exec defaults" }); - fireEvent.click(within(row).getAllByRole("button", { name: "Inherit from UI Exec" })[0]); + fireEvent.click(within(row).getAllByRole("button", { name: "Reset" })[0]); await waitFor(() => expect(view.saveConfig).toHaveBeenCalled()); const payload = getLatestSavePayload(view.saveConfig); @@ -350,7 +392,7 @@ describe("TasksSection Exec subagent defaults", () => { }); const row = await view.findByRole("group", { name: "Exec defaults" }); - fireEvent.click(within(row).getByRole("button", { name: "Inherit from UI Exec" })); + fireEvent.click(within(row).getByRole("button", { name: "Reset" })); await waitFor(() => expect(view.saveConfig).toHaveBeenCalled()); const payload = getLatestSavePayload(view.saveConfig); @@ -400,28 +442,38 @@ describe("TasksSection Exec subagent defaults", () => { expect(payload.agentAiDefaults.explore?.modelString).toBe("openai:gpt-5.6-sol"); }); - test("disabling inherited Pro mode persists an explicit standard override", async () => { - const view = renderTasksSection({ - agentAiDefaults: { - exec: { modelString: "openai:gpt-5.6-sol", reasoningMode: "pro" }, - }, - }); - - const row = await view.findByRole("group", { name: "Exec defaults" }); - fireEvent.click(within(row).getByRole("button", { name: /Reasoning/ })); - const proToggle = row.querySelector('[data-component="ProModeToggle"]'); - if (!(proToggle instanceof HTMLElement)) throw new Error("Pro mode toggle not rendered"); - // Inherited from UI Exec, so the toggle starts pressed with no override. - expect(proToggle.getAttribute("aria-pressed")).toBe("true"); - fireEvent.click(proToggle); - - await waitFor(() => expect(view.saveConfig).toHaveBeenCalled()); - const payload = getLatestSavePayload(view.saveConfig); - - expect(payload.agentAiDefaults.exec?.subagent?.reasoningMode).toBe("standard"); - // UI Exec's own default stays pro; only the sub-agent override changes. - expect(payload.agentAiDefaults.exec?.reasoningMode).toBe("pro"); - }); + test.each(["standard", "pro"] as const)( + "cycles inherited mode through explicit Pro and Standard regardless of global %s", + async (reasoningMode) => { + const view = renderTasksSection({ + agentAiDefaults: { exec: { modelString: "openai:gpt-5.6-sol", reasoningMode } }, + }); + const row = await view.findByRole("group", { name: "Exec defaults" }); + const trigger = within(row).getByRole("button", { name: "Reasoning" }); + fireEvent.click(trigger); + const proToggle = within(row).getByRole("button", { name: /Pro mode/ }); + expect(proToggle.getAttribute("aria-pressed")).toBe("mixed"); + for (const mode of ["pro", "standard", "pro"] as const) { + fireEvent.click(proToggle); + await waitFor(() => + expect(getLatestSavePayload(view.saveConfig).agentAiDefaults.exec?.subagent).toEqual({ + reasoningMode: mode, + }) + ); + expect(proToggle.getAttribute("aria-pressed")).toBe(String(mode === "pro")); + expect(trigger.textContent).toContain(mode.toUpperCase()); + expect(getLatestSavePayload(view.saveConfig).agentAiDefaults.exec?.reasoningMode).toBe( + reasoningMode + ); + } + const listbox = within(row).getByRole("listbox", { name: "Reasoning effort" }); + fireEvent.click(within(listbox).getByRole("option", { selected: true })); + await waitFor(() => + expect(getLatestSavePayload(view.saveConfig).agentAiDefaults.exec?.subagent).toBeUndefined() + ); + expect(proToggle.getAttribute("aria-pressed")).toBe("mixed"); + } + ); test("disabling Pro mode inherited from a base agent persists an explicit standard override", async () => { // Explore's base is exec (FALLBACK_AGENTS), so ACP resolution inherits @@ -457,7 +509,7 @@ describe("TasksSection Exec subagent defaults", () => { const row = await view.findByRole("group", { name: "Exec defaults" }); fireEvent.click(within(row).getByRole("button", { name: /Reasoning/ })); const listbox = within(row).getByRole("listbox", { name: "Reasoning effort" }); - fireEvent.click(within(listbox).getByRole("option", { name: "Inherit from UI Exec" })); + fireEvent.click(within(listbox).getByRole("option", { name: "Use calling chat’s Exec" })); await waitFor(() => expect(view.saveConfig).toHaveBeenCalled()); const payload = getLatestSavePayload(view.saveConfig); diff --git a/src/browser/stories/App.taskSettings.stories.tsx b/src/browser/stories/App.taskSettings.stories.tsx new file mode 100644 index 00000000000..d6477fb49d4 --- /dev/null +++ b/src/browser/stories/App.taskSettings.stories.tsx @@ -0,0 +1,70 @@ +import { expect, userEvent, within } from "@storybook/test"; +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { expandLeftSidebar } from "./helpers/uiState"; +import { setupSettingsStory } from "@/browser/features/Settings/Sections/settingsStoryUtils"; + +export default { + ...appMeta, + title: "App/TaskSettings", +}; + +function setupTaskSettings() { + expandLeftSidebar(); + return setupSettingsStory({ + // A global Pro default must not make the unknown calling chat look Pro. + agentAiDefaults: { + exec: { modelString: "openai:gpt-5.6-sol", thinkingLevel: "high", reasoningMode: "pro" }, + }, + providersConfig: { + openai: { apiKeySet: true, isEnabled: true, isConfigured: true }, + }, + }); +} + +async function exerciseInheritance(canvasElement: HTMLElement) { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByTestId("settings-button", {}, { timeout: 10000 })); + await userEvent.click(await canvas.findByRole("button", { name: "Agents" })); + const card = await canvas.findByRole("group", { name: "Exec defaults" }); + const controls = within(card); + const trigger = controls.getByRole("button", { name: "Reasoning" }); + await userEvent.click(trigger); + const mode = controls.getByRole("button", { name: /Pro mode/ }); + await expect(mode).toHaveAttribute("aria-pressed", "mixed"); + await expect(controls.queryByRole("button", { name: /Fast mode/ })).toBeNull(); + const levels = within(controls.getByRole("listbox", { name: "Reasoning effort" })); + await userEvent.click(levels.getByRole("option", { name: "Max" })); + await expect(mode).toHaveAttribute("aria-pressed", "mixed"); + await expect(trigger).toHaveTextContent("Max"); + await userEvent.click(mode); + await expect(mode).toHaveAttribute("aria-pressed", "true"); + await userEvent.click(mode); + await expect(mode).toHaveAttribute("aria-pressed", "false"); + await expect(trigger).toHaveTextContent("STANDARD"); + await userEvent.click(levels.getByRole("option", { name: "Use calling chat’s Exec" })); + await expect(mode).toHaveAttribute("aria-pressed", "mixed"); + card.scrollIntoView({ block: "start" }); + + // The test-runner ignores viewport globals/Pixel matrices. Check narrow bounds + // only in the real phone viewport, not its desktop-sized test-runner window. + if (window.innerWidth < 768) { + const menu = controls.getByRole("listbox", { name: "Reasoning effort" }); + await expect(card.getBoundingClientRect().right).toBeLessThanOrEqual(window.innerWidth); + await expect(menu.getBoundingClientRect().right).toBeLessThanOrEqual(window.innerWidth); + await expect(card.scrollWidth).toBeLessThanOrEqual(card.clientWidth); + } +} + +export const Desktop: AppStory = { + globals: { viewport: { value: "desktop", isRotated: false } }, + parameters: { pixel: { matrix: { themes: ["dark", "light"], viewports: ["desktop"] } } }, + render: () => , + play: async ({ canvasElement }) => exerciseInheritance(canvasElement), +}; + +export const Phone: AppStory = { + ...Desktop, + globals: { viewport: { value: "mobile1", isRotated: false } }, + parameters: { pixel: { matrix: { themes: ["dark", "light"], viewports: ["phone"] } } }, + play: async ({ canvasElement }) => exerciseInheritance(canvasElement), +}; diff --git a/src/common/types/agentAiDefaults.test.ts b/src/common/types/agentAiDefaults.test.ts index 364a4c297b8..41289b0c087 100644 --- a/src/common/types/agentAiDefaults.test.ts +++ b/src/common/types/agentAiDefaults.test.ts @@ -36,9 +36,9 @@ describe("normalizeAgentAiDefaults nested subagent profiles", () => { expect(result.explore).toBeUndefined(); }); - test("prunes nested fields equal to the base entry", () => { + test("prunes non-Exec nested fields equal to the base entry", () => { const result = normalizeAgentAiDefaults({ - exec: { + explore: { modelString: "openai:gpt-5.6-sol", thinkingLevel: "high", subagent: { @@ -48,13 +48,23 @@ describe("normalizeAgentAiDefaults nested subagent profiles", () => { }, }); - expect(result.exec).toEqual({ + expect(result.explore).toEqual({ modelString: "openai:gpt-5.6-sol", thinkingLevel: "high", subagent: { thinkingLevel: "xhigh" }, }); }); + test("keeps explicit Exec fields equal to global defaults", () => { + const profile = { + modelString: "openai:gpt-5.6-sol", + thinkingLevel: "high" as const, + reasoningMode: "standard" as const, + }; + const result = normalizeAgentAiDefaults({ exec: { ...profile, subagent: profile } }); + expect(result.exec?.subagent).toEqual(profile); + }); + test("drops an empty nested subagent object", () => { const result = normalizeAgentAiDefaults({ exec: { modelString: "openai:gpt-5.6-sol", subagent: {} }, diff --git a/src/common/types/agentAiDefaults.ts b/src/common/types/agentAiDefaults.ts index b2322a6f60a..31da1433d55 100644 --- a/src/common/types/agentAiDefaults.ts +++ b/src/common/types/agentAiDefaults.ts @@ -32,14 +32,17 @@ function isEmptyProfile(profile: AgentAiSubagentProfile): boolean { * Drops delegated fields equal to the base profile: the delegated profile is a * sparse diff, so equal values must fall through to the base at read time * instead of freezing a copy that a later base edit would silently miss. + * Explicit canonical Exec fields are exempt: absence inherits from the calling + * chat before the global profile. Legacy-only mirrors retain their old cleanup. */ function pruneSubagentProfile( profile: AgentAiSubagentProfile, - base: AgentAiDefaultsEntry + base: AgentAiDefaultsEntry, + explicitFields?: AgentAiSubagentProfile ): AgentAiSubagentProfile | undefined { const pruned: AgentAiSubagentProfile = { ...profile }; for (const field of SUBAGENT_PROFILE_FIELDS) { - if (pruned[field] !== undefined && pruned[field] === base[field]) { + if (explicitFields?.[field] === undefined && pruned[field] === base[field]) { delete pruned[field]; } } @@ -67,9 +70,11 @@ export function normalizeAgentAiDefaults(raw: unknown): AgentAiDefaults { const normalized: AgentAiDefaultsEntry = { ...base, enabled, advisorEnabled }; if (entry.subagent && typeof entry.subagent === "object" && !Array.isArray(entry.subagent)) { + const profile = normalizeProfileFields(entry.subagent as Record); const subagent = pruneSubagentProfile( - normalizeProfileFields(entry.subagent as Record), - normalized + profile, + normalized, + agentId === "exec" ? profile : undefined ); if (subagent) { normalized.subagent = subagent; @@ -132,7 +137,11 @@ export function mergeLegacySubagentAiDefaults( merged.modelString ??= legacyEntry.modelString; merged.thinkingLevel ??= legacyEntry.thinkingLevel; merged.reasoningMode ??= legacyEntry.reasoningMode; - const pruned = pruneSubagentProfile(merged, base); + const pruned = pruneSubagentProfile( + merged, + base, + agentId === "exec" ? base.subagent : undefined + ); if (pruned) { base.subagent = pruned; } else { diff --git a/src/common/types/agentAiSettings.ts b/src/common/types/agentAiSettings.ts index f01874da823..61b3d430e18 100644 --- a/src/common/types/agentAiSettings.ts +++ b/src/common/types/agentAiSettings.ts @@ -42,9 +42,9 @@ export interface AgentAiDefinitionDefaults { * Buckets always carry model + thinkingLevel, and an absent reasoningMode * means "standard" (see WorkspaceAISettingsSchema), so an existing bucket owns * the reasoning choice outright: lower tiers must not re-inject a configured - * pro default over a workspace deliberately running standard. Fallback (tier - * 7) layers built from OTHER workspaces' buckets must not use this mapping; - * there, absent reasoning falls through to the next layer. + * pro default over a workspace deliberately running standard. Calling-chat Exec + * inheritance also preserves this choice. Generic fallback (tier 7) layers must + * not use this mapping; there, absent reasoning falls through to the next layer. */ export function targetWorkspaceBucketToLayer(bucket: { model?: string; @@ -62,6 +62,7 @@ export type AiSettingTier = | "explicit" | "workspace" | "config-subagent" + | "parent-workspace-exec" | "config" | "definition" | "parent-runtime" @@ -95,6 +96,12 @@ export interface ResolveAgentAiSettingsInput { }; /** Tier 2: the target workspace's per-agent bucket (existing target workspaces only). */ targetWorkspaceSettings?: AgentAiSettingsLayerValues; + /** + * Calling-workspace Exec context for Exec sub-agents only: below explicit + * sub-agent defaults, above global Exec defaults. Not an invocation override + * or the active parent runtime model (which may be Plan). + */ + parentWorkspaceExecSettings?: AgentAiSettingsLayerValues; /** Tiers 3 and 5: canonical configured defaults map. */ agentAiDefaults?: AgentAiDefaults; /** Tier 4: the target agent's definition frontmatter `ai` block. */ diff --git a/src/common/utils/ai/resolveAgentAiSettings.test.ts b/src/common/utils/ai/resolveAgentAiSettings.test.ts index b95c9dac7d9..81263582072 100644 --- a/src/common/utils/ai/resolveAgentAiSettings.test.ts +++ b/src/common/utils/ai/resolveAgentAiSettings.test.ts @@ -21,6 +21,17 @@ function base(overrides: Partial): ResolveAgentAiSe } describe("resolveAgentAiSettings precedence", () => { + it("inherits the calling chat Exec model before global Exec defaults", () => { + const result = resolveAgentAiSettings({ + targetAgentId: "exec", + profile: "subagent", + agentAiDefaults: { exec: { modelString: "anthropic:claude-fable-5-1" } }, + parentRuntime: { model: "openai:gpt-5-pro" }, + parentWorkspaceExecSettings: { model: "openai:gpt-6-astra", thinkingLevel: "high" }, + }); + expect(result.selected.model).toBe("openai:gpt-6-astra"); + }); + it("explicit values win independently per field", () => { const result = resolveAgentAiSettings( base({ @@ -243,6 +254,114 @@ describe("resolveAgentAiSettings precedence", () => { }); }); +describe("calling workspace Exec inheritance", () => { + const parent = { model: MODEL_A, thinkingLevel: "high" as const, reasoningMode: "pro" as const }; + const input: ResolveAgentAiSettingsInput = { + targetAgentId: "exec", + profile: "subagent", + parentWorkspaceExecSettings: parent, + agentAiDefaults: { + exec: { modelString: MODEL_B, thinkingLevel: "low", reasoningMode: "standard" }, + }, + parentRuntime: { model: MODEL_C, thinkingLevel: "medium" }, + }; + + it("inherits every supplied field with provenance, even without global config", () => { + for (const agentAiDefaults of [input.agentAiDefaults, undefined]) { + const result = resolveAgentAiSettings({ ...input, agentAiDefaults }); + expect(result.selected).toEqual(parent); + for (const source of Object.values(result.sources)) { + expect(source).toEqual({ tier: "parent-workspace-exec", agentId: "exec" }); + } + expect(result.effective.reasoningMode).toBeUndefined(); + } + }); + + it("keeps explicit invocation, target workspace, and sub-agent profile precedence", () => { + const subagent = { modelString: MODEL_C }; + const withSubagent = { + ...input, + agentAiDefaults: { exec: { modelString: MODEL_B, subagent } }, + }; + expect(resolveAgentAiSettings(withSubagent).sources.model.tier).toBe("config-subagent"); + const workspace = { ...withSubagent, targetWorkspaceSettings: { model: MODEL_B } }; + expect(resolveAgentAiSettings(workspace).sources.model.tier).toBe("workspace"); + const explicit = resolveAgentAiSettings({ ...workspace, explicit: { model: MODEL_C } }); + expect(explicit.sources.model.tier).toBe("explicit"); + expect(explicit.selected.thinkingLevel).toBe("high"); + expect(explicit.sources.thinkingLevel.tier).toBe("parent-workspace-exec"); + }); + + it("resolves partial profile and parent layers independently", () => { + const thinkingOnly = resolveAgentAiSettings({ + ...input, + agentAiDefaults: { exec: { modelString: MODEL_B, subagent: { thinkingLevel: "low" } } }, + }); + expect(thinkingOnly.selected).toEqual({ ...parent, thinkingLevel: "low" }); + const modelOnly = resolveAgentAiSettings({ + ...input, + agentAiDefaults: { exec: { modelString: MODEL_B, subagent: { modelString: MODEL_C } } }, + }); + expect(modelOnly.selected).toEqual({ ...parent, model: MODEL_C }); + const reasoningOnly = resolveAgentAiSettings({ + ...input, + parentWorkspaceExecSettings: { reasoningMode: "pro" }, + }); + expect(reasoningOnly.selected).toEqual({ + model: MODEL_B, + thinkingLevel: "low", + reasoningMode: "pro", + }); + const missing = resolveAgentAiSettings({ ...input, parentWorkspaceExecSettings: undefined }); + expect(missing.sources.model.tier).toBe("config"); + }); + + it("falls through empty parent models without discarding valid fields", () => { + const result = resolveAgentAiSettings({ + ...input, + parentWorkspaceExecSettings: { model: " ", thinkingLevel: "medium" }, + }); + expect(result.selected.model).toBe(MODEL_B); + expect(result.selected.thinkingLevel).toBe("medium"); + expect(result.diagnostics).toHaveLength(1); + }); + + it.each(["plan", "explore", "desktop", "custom"])( + "does not promote Exec context for %s targets or ancestors", + (targetAgentId) => { + for (const ancestors of [undefined, [{ agentId: "exec" }]]) { + const scoped = { ...input, targetAgentId, ancestors }; + expect(resolveAgentAiSettings(scoped)).toEqual( + resolveAgentAiSettings({ ...scoped, parentWorkspaceExecSettings: undefined }) + ); + } + } + ); + + it("leaves interactive Exec unchanged", () => { + const scoped = { ...input, profile: "interactive" as const }; + expect(resolveAgentAiSettings(scoped)).toEqual( + resolveAgentAiSettings({ ...scoped, parentWorkspaceExecSettings: undefined }) + ); + }); + + it("normalizes thinking and gates reasoning against the final model", () => { + const result = resolveAgentAiSettings({ + ...input, + parentWorkspaceExecSettings: { + model: "openai:gpt-5.2", + thinkingLevel: "off", + reasoningMode: "pro", + }, + minThinkingLevelByModel: { "openai:gpt-5.2": "high" }, + }); + expect(result.selected.thinkingLevel).toBe("off"); + expect(result.effective.thinkingLevel).toBe("high"); + expect(result.selected.reasoningMode).toBe("pro"); + expect(result.effective.reasoningMode).toBeUndefined(); + }); +}); + describe("resolveAgentAiSettings normalization and clamping", () => { it("numeric thinking input maps into the resolved model's policy", () => { // gemini-3 allows ["low", "high"]: index 0 is its lowest allowed level. diff --git a/src/common/utils/ai/resolveAgentAiSettings.ts b/src/common/utils/ai/resolveAgentAiSettings.ts index c9741f9408e..c679bf44d24 100644 --- a/src/common/utils/ai/resolveAgentAiSettings.ts +++ b/src/common/utils/ai/resolveAgentAiSettings.ts @@ -5,7 +5,8 @@ * * 1. explicit invocation overrides * 2. target workspace per-agent bucket - * 3. target configured profile (delegated `subagent` override, then base) + * 3. target configured profile (delegated `subagent` override, then calling + * workspace Exec settings for Exec sub-agents only, then base) * 4. target definition frontmatter `ai` defaults * 5. declared ancestors child to root (config profile, then definition * defaults), then the implicit plan/exec fallback (reasoningMode only) @@ -82,8 +83,7 @@ function buildCandidates(input: ResolveAgentAiSettingsInput): Candidate[] { const pushConfig = (agentId: string, reasoningOnly: boolean) => { const entry = defaults[agentId]; - if (!entry) return; - if (input.profile === "subagent" && entry.subagent) { + if (input.profile === "subagent" && entry?.subagent) { candidates.push({ values: { model: entry.subagent.modelString, @@ -94,6 +94,20 @@ function buildCandidates(input: ResolveAgentAiSettingsInput): Candidate[] { reasoningOnly, }); } + // A chat's Exec choice outranks global defaults, but never explicit child + // overrides. Do not promote this context for Exec-derived custom agents. + if ( + input.profile === "subagent" && + input.targetAgentId === "exec" && + agentId === "exec" && + input.parentWorkspaceExecSettings + ) { + candidates.push({ + values: input.parentWorkspaceExecSettings, + source: { tier: "parent-workspace-exec", agentId: "exec" }, + }); + } + if (!entry) return; candidates.push({ values: { model: entry.modelString, diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 4f9a56a8a1f..b0950941853 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -599,6 +599,70 @@ describe("Config", () => { }); }); + describe("display-only legacy AI settings", () => { + it.each([false, true])( + "does not persist synthesized buckets (legacy metadata: %s)", + async (legacyMetadata) => { + const projectPath = path.join(tempDir, "repo"); + const legacySettings = { model: "openai:gpt-5.2", thinkingLevel: "high" as const }; + await config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [ + { + path: projectPath, + ...(legacyMetadata ? {} : { id: "legacy-ai", name: "legacy-ai" }), + agentId: "plan", + aiSettings: legacySettings, + }, + ], + }); + return cfg; + }); + if (legacyMetadata) { + const sessionDir = path.join(config.sessionsDir, "repo"); + fs.mkdirSync(sessionDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionDir, "metadata.json"), + JSON.stringify({ id: "legacy-ai", name: "legacy-ai" }) + ); + } + const metadata = await config.getAllWorkspaceMetadata(); + expect(metadata[0]?.aiSettingsByAgent).toEqual({ + exec: legacySettings, + plan: legacySettings, + }); + expect( + config.loadConfigOrDefault().projects.get(projectPath)?.workspaces[0].aiSettingsByAgent + ).toBeUndefined(); + expect((await config.getAllWorkspaceMetadata())[0]?.aiSettingsByAgent).toEqual( + metadata[0]?.aiSettingsByAgent + ); + expect( + config.loadConfigOrDefault().projects.get(projectPath)?.workspaces[0].aiSettingsByAgent + ).toBeUndefined(); + } + ); + + it("still migrates genuine per-agent settings from legacy metadata", async () => { + const projectPath = path.join(tempDir, "repo"); + const exec = { model: "openai:gpt-5.2", thinkingLevel: "high" as const }; + await config.editConfig((cfg) => { + cfg.projects.set(projectPath, { workspaces: [{ path: projectPath }] }); + return cfg; + }); + const sessionDir = path.join(config.sessionsDir, "repo"); + fs.mkdirSync(sessionDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionDir, "metadata.json"), + JSON.stringify({ id: "legacy-ai", name: "legacy-ai", aiSettingsByAgent: { exec } }) + ); + await config.getAllWorkspaceMetadata(); + expect( + config.loadConfigOrDefault().projects.get(projectPath)?.workspaces[0].aiSettingsByAgent + ).toEqual({ exec }); + }); + }); + describe("workspace tags", () => { it("persists programmatic tags through save/load and metadata mapping", async () => { await config.editConfig((cfg) => { @@ -1845,6 +1909,22 @@ describe("Config", () => { }); describe("agent AI defaults canonical shape", () => { + it("round-trips explicit Exec overrides even when they equal global defaults", async () => { + const profile = { + modelString: "openai:gpt-5.6-sol", + thinkingLevel: "high" as const, + reasoningMode: "standard" as const, + }; + await config.updateAgentAiDefaults({ exec: { ...profile, subagent: profile } }); + expect(new Config(tempDir).loadConfigOrDefault().agentAiDefaults?.exec?.subagent).toEqual( + profile + ); + await config.editConfig((cfg) => cfg); + expect(new Config(tempDir).loadConfigOrDefault().agentAiDefaults?.exec?.subagent).toEqual( + profile + ); + }); + it("preserves explicit gateway-scoped model strings in nested AI defaults", async () => { await config.editConfig((cfg) => { cfg.agentAiDefaults = { diff --git a/src/node/config/index.ts b/src/node/config/index.ts index 59124737850..4e3a32ce52c 100644 --- a/src/node/config/index.ts +++ b/src/node/config/index.ts @@ -3195,6 +3195,7 @@ export class Config { aiSettings: workspace.aiSettings, heartbeat: normalizeWorkspaceMetadataHeartbeat(workspace.heartbeat, config), goalDefaults: workspace.goalDefaults, + // Display defaults stay ephemeral: no raw Exec bucket means no saved Exec choice. aiSettingsByAgent: workspace.aiSettingsByAgent ?? (workspace.aiSettings @@ -3236,22 +3237,6 @@ export class Config { }); } - // Migrate missing runtimeConfig to config for next load - if (!workspace.aiSettingsByAgent) { - const derived = workspace.aiSettings - ? { - plan: workspace.aiSettings, - exec: workspace.aiSettings, - } - : undefined; - if (derived) { - workspace.aiSettingsByAgent = derived; - recordWorkspaceMigration(projectPath, workspace.path, (entry) => { - entry.aiSettingsByAgent ??= derived; - }); - } - } - if (!workspace.runtimeConfig) { workspace.runtimeConfig = metadata.runtimeConfig; recordWorkspaceMigration(projectPath, workspace.path, (entry) => { @@ -3370,6 +3355,7 @@ export class Config { } if (legacyMetadataRaw !== undefined) { const metadata = JSON.parse(legacyMetadataRaw) as WorkspaceMetadata; + const persistedAgentSettings = metadata.aiSettingsByAgent; this.rememberLegacyTaskVariantWorkspace(projectPath, metadata, "metadata"); // Ensure required fields are present @@ -3447,10 +3433,11 @@ export class Config { metadata.createdAt = workspace.createdAt ?? metadata.createdAt; metadata.runtimeConfig = workspace.runtimeConfig ?? metadata.runtimeConfig; - if (!workspace.aiSettingsByAgent && metadata.aiSettingsByAgent) { - workspace.aiSettingsByAgent = metadata.aiSettingsByAgent; + // Migrate genuine metadata buckets, never the display fallback above. + if (!workspace.aiSettingsByAgent && persistedAgentSettings) { + workspace.aiSettingsByAgent = persistedAgentSettings; recordWorkspaceMigration(projectPath, workspace.path, (entry) => { - entry.aiSettingsByAgent ??= metadata.aiSettingsByAgent; + entry.aiSettingsByAgent ??= persistedAgentSettings; }); } diff --git a/src/node/services/agentDefinitions/resolveNodeAgentAiSettings.test.ts b/src/node/services/agentDefinitions/resolveNodeAgentAiSettings.test.ts index d7c8b30c9d0..e2fc61d0ab2 100644 --- a/src/node/services/agentDefinitions/resolveNodeAgentAiSettings.test.ts +++ b/src/node/services/agentDefinitions/resolveNodeAgentAiSettings.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import { resolveAgentAiSettings } from "@/common/utils/ai/resolveAgentAiSettings"; -import { collectDefinitionLayers } from "./resolveNodeAgentAiSettings"; +import { collectDefinitionLayers, resolveNodeAgentAiSettings } from "./resolveNodeAgentAiSettings"; // Unrecognized providers avoid capability clamping (see resolveAgentAiSettings.test.ts). const MODEL_A = "custom:model-a"; @@ -62,3 +62,17 @@ describe("collectDefinitionLayers", () => { expect(resolved.sources.model).toEqual({ tier: "definition", agentId: "exec" }); }); }); + +describe("calling workspace adapter context", () => { + it("forwards Exec context without changing the gateway identity", async () => { + const model = "coder:openai/gpt-5.6"; + const result = await resolveNodeAgentAiSettings({ + agentId: "exec", + profile: "subagent", + cfg: { agentAiDefaults: { exec: { modelString: MODEL_A } } }, + parentWorkspaceExecSettings: { model, thinkingLevel: "high" }, + }); + expect(result.selected.model).toBe(model); + expect(result.sources.model).toEqual({ tier: "parent-workspace-exec", agentId: "exec" }); + }); +}); diff --git a/src/node/services/agentDefinitions/resolveNodeAgentAiSettings.ts b/src/node/services/agentDefinitions/resolveNodeAgentAiSettings.ts index 37dd8266705..7589dceaeed 100644 --- a/src/node/services/agentDefinitions/resolveNodeAgentAiSettings.ts +++ b/src/node/services/agentDefinitions/resolveNodeAgentAiSettings.ts @@ -50,6 +50,7 @@ export interface ResolveNodeAgentAiSettingsParams { reasoningMode?: OpenAIReasoningMode; }; targetWorkspaceSettings?: AgentAiSettingsLayerValues; + parentWorkspaceExecSettings?: AgentAiSettingsLayerValues; parentRuntime?: AgentAiSettingsLayerValues; fallbacks?: readonly AgentAiSettingsLayerValues[]; defaultModel?: string; @@ -177,6 +178,7 @@ export async function resolveNodeAgentAiSettings( agentAiDefaults: params.cfg.agentAiDefaults, targetDefinitionAiDefaults, ancestors, + parentWorkspaceExecSettings: params.parentWorkspaceExecSettings, parentRuntime: params.parentRuntime, fallbacks: params.fallbacks, defaultModel: params.defaultModel, diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 29715309dd6..69ccc8dd66d 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -399,6 +399,7 @@ describe("TaskService", () => { taskService as unknown as { resolveTaskAISettings: (params: { cfg: ReturnType; + parentWorkspaceId: string; parentMeta: Record; agentId: string; modelString?: string; @@ -412,6 +413,7 @@ describe("TaskService", () => { // queued follow-ups and plan→exec continuations to direct OpenAI. const gateway = await resolver({ cfg: config.loadConfigOrDefault(), + parentWorkspaceId: "missing-parent", parentMeta: {}, agentId: "exec", modelString: "coder:openai/claude-sonnet-4-20250514", @@ -421,6 +423,7 @@ describe("TaskService", () => { // Non-gateway strings keep canonical normalization. const direct = await resolver({ cfg: config.loadConfigOrDefault(), + parentWorkspaceId: "missing-parent", parentMeta: {}, agentId: "exec", modelString: "anthropic:claude-sonnet-4-20250514", @@ -6433,6 +6436,312 @@ describe("TaskService", () => { expect(childEntry?.taskThinkingLevel).toBe(expectedThinkingLevel); }, 20_000); + test("exec subagent inherits the calling chat Exec selection while parent is in Plan", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir, { + agentAiDefaults: { exec: { modelString: "anthropic:claude-fable-5-1" } }, + }); + await config.editConfig((cfg) => { + const parent = cfg.projects.get(projectPath)!.workspaces[0]; + parent.agentId = "plan"; + parent.aiSettingsByAgent = { + exec: { model: "openai:gpt-6-astra", thinkingLevel: "high" }, + plan: { model: "openai:gpt-5-pro", thinkingLevel: "medium" }, + }; + return cfg; + }); + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const initStateManager = new RealInitStateManager(config); + const { taskService } = createTaskServiceHarness(config, { + workspaceService, + initStateManager, + }); + const created = await createAgentTask(taskService, parentId, "inherit saved Exec", { + agentType: "exec", + parentRuntimeAiSettings: { modelString: "openai:gpt-5-pro", thinkingLevel: "medium" }, + }); + assert(created.success); + await initStateManager.waitForInit(created.data.taskId); + expect(findWorkspaceInConfig(config, created.data.taskId)?.taskModelString).toBe( + "openai:gpt-6-astra" + ); + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "inherit saved Exec", + expect.objectContaining({ + model: "openai:gpt-6-astra", + agentId: "exec", + thinkingLevel: "high", + }), + { agentInitiated: true } + ); + const child = findWorkspaceInConfig(config, created.data.taskId); + expect(child?.taskModelString).toBe("openai:gpt-6-astra"); + expect(child?.aiSettings).toEqual({ + model: "openai:gpt-6-astra", + thinkingLevel: "high", + reasoningMode: "standard", + }); + }, 20_000); + + test.each([ + { name: "legacy Exec", agentId: " ExEc ", expectedParent: true }, + { name: "legacy agentType Exec", agentType: "exec", expectedParent: true }, + { name: "legacy Plan", agentId: "plan", expectedParent: false }, + { name: "unknown mode", expectedParent: false }, + { + name: "Plan beats stale Exec alias", + agentId: "plan", + agentType: "exec", + expectedParent: false, + }, + { + name: "empty identity does not use legacy alias", + agentId: "", + agentType: "exec", + expectedParent: false, + }, + { + name: "authentic equal Plan and Exec buckets", + agentId: "plan", + equalBuckets: true, + expectedParent: true, + }, + ])("Exec inheritance provenance: $name", async (scenario) => { + const config = await createTestConfig(rootDir); + const globalModel = "openai:gpt-5.2"; + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir, { + agentAiDefaults: { exec: { modelString: globalModel, thinkingLevel: "medium" } }, + }); + await config.editConfig((cfg) => { + const parent = cfg.projects.get(projectPath)!.workspaces[0]; + parent.agentId = scenario.agentId; + parent.agentType = scenario.agentType; + if (scenario.equalBuckets) { + assert(parent.aiSettings); + parent.aiSettingsByAgent = { exec: parent.aiSettings, plan: parent.aiSettings }; + } + return cfg; + }); + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const initStateManager = new RealInitStateManager(config); + const { taskService } = createTaskServiceHarness(config, { + workspaceService, + initStateManager, + }); + const created = await createAgentTask(taskService, parentId, "check provenance", { + agentType: "exec", + }); + assert(created.success); + await initStateManager.waitForInit(created.data.taskId); + const expected = scenario.expectedParent ? "anthropic:claude-opus-4-6" : globalModel; + expect(findWorkspaceInConfig(config, created.data.taskId)?.aiSettings?.model).toBe(expected); + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "check provenance", + expect.objectContaining({ model: expected }), + { agentInitiated: true } + ); + }); + + test("grouped Exec children keep creation-time settings through queueing, reload, and reactivation", async () => { + const config = await createTestConfig(rootDir); + const model = "openai:gpt-5.2"; + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir, { + agentAiDefaults: { exec: { modelString: "anthropic:claude-opus-4-6" } }, + }); + await config.editConfig((cfg) => { + cfg.projects.get(projectPath)!.workspaces[0].aiSettingsByAgent = { + exec: { model, thinkingLevel: "high" }, + }; + cfg.taskSettings = testTaskSettings(1, 3); + return cfg; + }); + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const initStateManager = new RealInitStateManager(config); + const { taskService } = createTaskServiceHarness(config, { + workspaceService, + initStateManager, + }); + const created = await taskService.createMany( + ["first", "queued"].map((prompt) => ({ + parentWorkspaceId: parentId, + kind: "agent" as const, + agentId: "exec", + prompt, + title: prompt, + })) + ); + assert(created.success); + const [first, queued] = created.data; + expect(created.data.map((child) => child.status)).toEqual(["starting", "queued"]); + await waitForWorkspaceTaskStatus(config, first.taskId, "running"); + await initStateManager.waitForInit(first.taskId); + for (const child of created.data) { + expect(findWorkspaceInConfig(config, child.taskId)?.taskModelString).toBe(model); + } + await config.editConfig((cfg) => { + const workspaces = cfg.projects.get(projectPath)!.workspaces; + workspaces[0].aiSettingsByAgent = { + exec: { model: "openai:gpt-5.3-codex", thinkingLevel: "medium" }, + }; + cfg.agentAiDefaults = { exec: { modelString: "anthropic:claude-haiku-4-5" } }; + workspaces.find((workspace) => workspace.id === first.taskId)!.taskStatus = "reported"; + return cfg; + }); + const reloaded = createTaskServiceHarness(config, { + workspaceService, + initStateManager, + }).taskService; + await reloaded.maybeStartQueuedTasks(); + await waitForWorkspaceTaskStatus(config, queued.taskId, "running"); + await initStateManager.waitForInit(queued.taskId); + expect(sendMessage).toHaveBeenCalledWith( + queued.taskId, + "queued", + expect.objectContaining({ model, thinkingLevel: "high" }), + expect.anything() + ); + await config.editConfig((cfg) => { + cfg.projects + .get(projectPath)! + .workspaces.find((workspace) => workspace.id === queued.taskId)!.taskStatus = "reported"; + return cfg; + }); + const reactivated = await reloaded.sendMessageToDescendantAgentTask( + parentId, + first.taskId, + "continue", + "tool-end" + ); + expect(reactivated).toMatchObject({ success: true, data: { delivery: "reactivated" } }); + expect(sendMessage).toHaveBeenLastCalledWith( + first.taskId, + expect.any(String), + expect.objectContaining({ model, thinkingLevel: "high" }), + expect.anything() + ); + expect(findWorkspaceInConfig(config, first.taskId)?.aiSettings?.model).toBe(model); + }, 20_000); + + test("nested Exec delegation inherits the immediate child rather than the root chat", async () => { + const config = await createTestConfig(rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir, { + agentAiDefaults: { exec: { modelString: "anthropic:claude-opus-4-6" } }, + }); + await config.editConfig((cfg) => { + cfg.projects.get(projectPath)!.workspaces[0].aiSettingsByAgent = { + exec: { model: "openai:gpt-5.2", thinkingLevel: "high" }, + }; + return cfg; + }); + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const initStateManager = new RealInitStateManager(config); + const { taskService } = createTaskServiceHarness(config, { + workspaceService, + initStateManager, + }); + const child = await createAgentTask(taskService, parentId, "child", { + agentType: "exec", + modelString: "openai:gpt-5.3-codex", + }); + assert(child.success); + await initStateManager.waitForInit(child.data.taskId); + const grandchild = await createAgentTask(taskService, child.data.taskId, "grandchild", { + agentType: "exec", + }); + assert(grandchild.success); + await initStateManager.waitForInit(grandchild.data.taskId); + expect(sendMessage).toHaveBeenCalledWith( + grandchild.data.taskId, + "grandchild", + expect.objectContaining({ model: "openai:gpt-5.3-codex" }), + { agentInitiated: true } + ); + expect(findWorkspaceInConfig(config, grandchild.data.taskId)?.taskModelString).toBe( + "openai:gpt-5.3-codex" + ); + }, 20_000); + + test.each([false, true])( + "Exec inheritance preserves omitted Standard reasoning (legacy: %s)", + async (legacy) => { + const config = await createTestConfig(rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir, { + agentAiDefaults: { exec: { modelString: "openai:gpt-5.6-sol", reasoningMode: "pro" } }, + }); + await config.editConfig((cfg) => { + const parent = cfg.projects.get(projectPath)!.workspaces[0]; + parent.agentId = "exec"; + const settings = { model: "openai:gpt-5.6-sol", thinkingLevel: "high" as const }; + if (legacy) parent.aiSettings = settings; + else parent.aiSettingsByAgent = { exec: settings }; + return cfg; + }); + const initStateManager = new RealInitStateManager(config); + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { + workspaceService, + initStateManager, + }); + const created = await createAgentTask(taskService, parentId, "inherit Standard", { + agentType: "exec", + }); + assert(created.success); + await initStateManager.waitForInit(created.data.taskId); + expect(findWorkspaceInConfig(config, created.data.taskId)?.aiSettings?.reasoningMode).toBe( + "standard" + ); + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "inherit Standard", + expect.objectContaining({ reasoningMode: "standard" }), + { agentInitiated: true } + ); + } + ); + + test("explicit Exec overrides equal to global defaults still beat the calling chat", async () => { + const config = await createTestConfig(rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + const profile = { + modelString: "openai:gpt-5.6-sol", + thinkingLevel: "high" as const, + reasoningMode: "standard" as const, + }; + await config.updateAgentAiDefaults({ exec: { ...profile, subagent: profile } }); + await config.editConfig((cfg) => { + cfg.projects.get(projectPath)!.workspaces[0].aiSettingsByAgent = { + exec: { model: "openai:gpt-5.2", thinkingLevel: "medium", reasoningMode: "pro" }, + }; + return cfg; + }); + const initStateManager = new RealInitStateManager(config); + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { + workspaceService, + initStateManager, + }); + const created = await createAgentTask(taskService, parentId, "keep explicit overrides", { + agentType: "exec", + }); + assert(created.success); + await initStateManager.waitForInit(created.data.taskId); + const expected = { + model: profile.modelString, + thinkingLevel: profile.thinkingLevel, + reasoningMode: profile.reasoningMode, + }; + expect(findWorkspaceInConfig(config, created.data.taskId)?.aiSettings).toEqual(expected); + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "keep explicit overrides", + expect.objectContaining(expected), + { agentInitiated: true } + ); + }); + test("exec subagent uses subagentAiDefaults exec when present", async () => { const config = await createTestConfig(rootDir); stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); @@ -20772,6 +21081,22 @@ describe("TaskService", () => { expect(updatedTask?.taskStatus).toBe("running"); }); + test("plan handoff uses the transitioning workspace Exec choice, not its parent", async () => { + const { config, childId, sendMessage, internal } = await setupPlanModeStreamEndHarness({ + childAiSettingsByAgent: { exec: { model: "openai:gpt-5.2", thinkingLevel: "high" } }, + parentAiSettingsByAgent: { exec: { model: "openai:gpt-5.3-codex", thinkingLevel: "medium" } }, + agentAiDefaults: { exec: { modelString: "anthropic:claude-opus-4-6" } }, + }); + await internal.handleStreamEnd(makeSuccessfulProposePlanStreamEndEvent(childId)); + expect(sendMessage).toHaveBeenCalledWith( + childId, + expect.any(String), + expect.objectContaining({ agentId: "exec", model: "openai:gpt-5.2", thinkingLevel: "high" }), + expect.objectContaining({ synthetic: true }) + ); + expect(findWorkspaceInConfig(config, childId)?.taskModelString).toBe("openai:gpt-5.2"); + }); + test("plan handoff preserves a pro mode persisted under the plan agent bucket", async () => { // A PRO toggle during the plan phase lands in aiSettingsByAgent.plan; // legacy workspace.aiSettings still holds the original standard setting. diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index b4d5ab7706d..0b393c253bc 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -1924,6 +1924,7 @@ export class TaskService implements AgentTaskIntegration { */ private async resolveTaskAISettings(params: { cfg: ReturnType; + parentWorkspaceId: string; parentMeta: TaskParentAiMeta; agentId: string; modelString?: string; @@ -1937,6 +1938,14 @@ export class TaskService implements AgentTaskIntegration { effectiveThinkingLevel: ThinkingLevel; effectiveReasoningMode?: OpenAIReasoningMode; }> { + // Display metadata synthesizes Exec/Plan buckets from legacy aiSettings. + // Only raw persisted Exec choices may outrank global Exec defaults. + const parent = findWorkspaceEntry(params.cfg, params.parentWorkspaceId)?.workspace; + const parentWorkspaceExecSettings = + parent?.aiSettingsByAgent?.exec ?? + (normalizeAgentId(parent?.agentId ?? parent?.agentType, "") === "exec" + ? parent?.aiSettings + : undefined); const resolved = await resolveNodeAgentAiSettings({ agentId: params.agentId, profile: "subagent", @@ -1946,6 +1955,10 @@ export class TaskService implements AgentTaskIntegration { model: coerceNonEmptyString(params.modelString) ?? undefined, thinkingLevel: params.thinkingLevel ?? undefined, }, + // A saved workspace's omitted reasoning mode means Standard, not inheritance. + parentWorkspaceExecSettings: parentWorkspaceExecSettings + ? targetWorkspaceBucketToLayer(parentWorkspaceExecSettings) + : undefined, parentRuntime: params.parentRuntimeAiSettings ? { model: coerceNonEmptyString(params.parentRuntimeAiSettings.modelString) ?? undefined, @@ -2998,6 +3011,7 @@ export class TaskService implements AgentTaskIntegration { ({ taskModelString, canonicalModel, effectiveThinkingLevel, effectiveReasoningMode } = await this.resolveTaskAISettings({ cfg, + parentWorkspaceId, parentMeta, agentId, modelString: args.modelString, @@ -3862,6 +3876,7 @@ export class TaskService implements AgentTaskIntegration { ({ taskModelString, canonicalModel, effectiveThinkingLevel, effectiveReasoningMode } = await this.resolveTaskAISettings({ cfg, + parentWorkspaceId, parentMeta, agentId, modelString: args.modelString, @@ -11590,14 +11605,12 @@ export class TaskService implements AgentTaskIntegration { }); } - // Same delegated resolution as Task.create: configured exec sub-agent/agent - // defaults win, then the plan phase's frozen task settings (parent - // runtime), then the plan workspace's own buckets — a PRO toggle during - // the plan phase persists under the plan agent's bucket - // (aiSettingsByAgent), which the shared fallback layers carry over. + // Resolve a new Exec phase from this workspace, not its parent. A Plan-only + // PRO preference still falls through via the existing workspace fallback. const { taskModelString, canonicalModel, effectiveThinkingLevel, effectiveReasoningMode } = await this.resolveTaskAISettings({ cfg: this.config.loadConfigOrDefault(), + parentWorkspaceId: args.workspaceId, parentMeta: args.entry.workspace, agentId: targetAgentId, parentRuntimeAiSettings: { diff --git a/tests/ui/agents/execTaskInheritance.test.ts b/tests/ui/agents/execTaskInheritance.test.ts new file mode 100644 index 00000000000..69a3a92d1f1 --- /dev/null +++ b/tests/ui/agents/execTaskInheritance.test.ts @@ -0,0 +1,185 @@ +import "../dom"; + +import { waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { MockLanguageModelV3, simulateReadableStream } from "ai/test"; + +import { KNOWN_MODELS } from "@/common/constants/knownModels"; +import { formatModelDisplayName } from "@/common/utils/ai/modelDisplay"; +import { Err, Ok } from "@/common/types/result"; +import { ProviderModelFactory } from "@/node/services/providerModelFactory"; +import { shouldRunIntegrationTests } from "../../testUtils"; +import { setupProviders } from "../../ipc/setup"; +import { createAppHarness } from "../harness"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; +const MODEL_A = KNOWN_MODELS.OPUS.id; +const MODEL_B = KNOWN_MODELS.SONNET.id; +const FALLBACK_MODEL = KNOWN_MODELS.HAIKU.id; + +async function selectAgent(container: HTMLElement, agentId: string): Promise { + const user = userEvent.setup({ document: container.ownerDocument }); + await user.click(within(container).getByRole("button", { name: "Select agent" })); + const option = await waitFor(() => { + const row = container.querySelector(`[data-agent-id="${agentId}"]`); + if (!row) throw new Error(`Agent ${agentId} not found`); + return row; + }); + await user.click(option); +} + +async function selectModel(container: HTMLElement, model: string): Promise { + const user = userEvent.setup({ document: container.ownerDocument }); + const group = container.querySelector('[data-component="ModelSelectorGroup"]'); + if (!group) throw new Error("Model picker not found"); + await user.click(within(group).getByRole("combobox")); + const input = await within(container).findByPlaceholderText("Search [provider:model-name]"); + await user.clear(input); + await user.type(input, model); + const displayName = formatModelDisplayName(model.split(":")[1]); + await user.click(await within(container).findByText(displayName)); + await waitFor(() => expect(group.textContent).toContain(displayName)); +} + +async function sendMessage(container: HTMLElement, text: string): Promise { + const user = userEvent.setup({ document: container.ownerDocument }); + const textarea = await within(container).findByRole("textbox", { name: "Message Claude" }); + await user.type(textarea, text); + await user.click(within(container).getByRole("button", { name: "Send message" })); +} + +function finish(reason: "stop" | "tool-calls"): LanguageModelV3StreamPart { + return { + type: "finish", + finishReason: { unified: reason, raw: reason }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }; +} + +describeIntegration("Calling-chat Exec inheritance", () => { + test("routes new children through the picked Exec model without changing existing children", async () => { + const requests = new Map(); + const delegated = new Set(); + // MockAiRouter emits canned tool results without executing them. Mock only the + // provider instead so picker -> send -> task -> child runs the real backend. + const app = await createAppHarness({ + branchPrefix: "exec-inheritance", + aiMode: "none", + beforeRenderEnvironment: async (env) => { + await setupProviders(env, { anthropic: { apiKey: "provider-free-test-key" } }); + await env.orpc.config.updateAgentAiDefaults({ + agentAiDefaults: { + exec: { modelString: FALLBACK_MODEL }, + }, + }); + // Background status/summary generation is unrelated to task inheritance. + jest + .spyOn(ProviderModelFactory.prototype, "createModel") + .mockResolvedValue( + Err({ type: "unknown", raw: "Side-channel generation disabled in provider-free test" }) + ); + jest + .spyOn(ProviderModelFactory.prototype, "resolveAndCreateModel") + .mockImplementation((modelString, _thinkingLevel, _providerOptions, options) => + Promise.resolve( + Ok({ + effectiveModelString: modelString, + canonicalModelString: modelString, + canonicalProviderName: "anthropic", + canonicalModelId: modelString.split(":")[1], + wireProviderName: "anthropic", + routedThroughGateway: false, + model: new MockLanguageModelV3({ + provider: "anthropic", + modelId: modelString.split(":")[1], + doStream: (request) => { + const workspaceId = options?.workspaceId; + if (!workspaceId) + throw new Error("Expected a workspace-scoped provider request"); + requests.set(workspaceId, modelString); + const lastUser = request.prompt.findLast((message) => message.role === "user"); + const text = lastUser?.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""); + const chunks: LanguageModelV3StreamPart[] = []; + if (text?.startsWith("Delegate ") && !delegated.has(text)) { + delegated.add(text); + chunks.push( + { + type: "tool-call", + toolCallId: `delegate-${delegated.size}`, + toolName: "task", + input: JSON.stringify({ + agentId: "exec", + title: `Reviewer ${delegated.size}`, + prompt: "Return a brief report without changing files.", + run_in_background: false, + }), + }, + finish("tool-calls") + ); + } else { + chunks.push( + { type: "text-start", id: "answer" }, + { type: "text-delta", id: "answer", delta: "Finished reviewing." }, + { type: "text-end", id: "answer" }, + finish("stop") + ); + } + return Promise.resolve({ stream: simulateReadableStream({ chunks }) }); + }, + }), + }) + ) + ); + }, + }); + + try { + await selectAgent(app.view.container, "exec"); + await selectModel(app.view.container, MODEL_A); + // Do not pre-seed workspace AI settings or wait for their persistence: the + // very first send must save the selected Exec model before task execution. + await sendMessage(app.view.container, "Delegate the first review."); + await app.chat.expectTranscriptContains("Finished reviewing."); + await app.chat.expectStreamComplete(); + + const firstChildren = (await app.env.orpc.workspace.list()).filter( + (workspace) => workspace.parentWorkspaceId === app.workspaceId + ); + expect(firstChildren).toHaveLength(1); + const firstId = firstChildren[0].id; + + await selectModel(app.view.container, MODEL_B); + await sendMessage(app.view.container, "Delegate the second review."); + await waitFor( + () => expect([...requests.keys()].filter((id) => id !== app.workspaceId)).toHaveLength(2), + { timeout: 30_000 } + ); + await app.chat.expectStreamComplete(); + + const children = (await app.env.orpc.workspace.list()).filter( + (workspace) => workspace.parentWorkspaceId === app.workspaceId + ); + expect(children).toHaveLength(2); + const second = children.find((workspace) => workspace.id !== firstId); + if (!second) throw new Error("Second child was not created"); + + // Inspect the actual child provider requests, not just picker display state. + expect(requests.get(app.workspaceId)).toBe(MODEL_B); + expect([requests.get(firstId), requests.get(second.id)]).toEqual([MODEL_A, MODEL_B]); + const first = await app.env.orpc.workspace.getInfo({ workspaceId: firstId }); + const secondInfo = await app.env.orpc.workspace.getInfo({ workspaceId: second.id }); + expect(first?.aiSettingsByAgent?.exec?.model).toBe(MODEL_A); + expect(secondInfo?.aiSettingsByAgent?.exec?.model).toBe(MODEL_B); + } finally { + await app.dispose(); + jest.restoreAllMocks(); + } + }, 120_000); +});