From 80ab9178a66cb20f7a409b33a7ffcb9ecd36d47e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 12:02:48 +0000 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=A4=96=20fix:=20inherit=20calling=20w?= =?UTF-8?q?orkspace=20Exec=20settings=20for=20delegated=20Exec=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep explicit child overrides ahead of the chat's saved Exec choice and global defaults behind it. Preserve raw preference provenance without persisting display-only legacy buckets; cover queueing, nested delegation, reactivation, and handoff. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ --- src/common/types/agentAiSettings.ts | 7 + .../utils/ai/resolveAgentAiSettings.test.ts | 119 +++++++++ src/common/utils/ai/resolveAgentAiSettings.ts | 20 +- src/node/config.test.ts | 64 +++++ src/node/config/index.ts | 25 +- .../resolveNodeAgentAiSettings.test.ts | 16 +- .../resolveNodeAgentAiSettings.ts | 2 + src/node/services/taskService.test.ts | 243 ++++++++++++++++++ src/node/services/taskService.ts | 20 +- 9 files changed, 488 insertions(+), 28 deletions(-) diff --git a/src/common/types/agentAiSettings.ts b/src/common/types/agentAiSettings.ts index f01874da82..ee3875fc7c 100644 --- a/src/common/types/agentAiSettings.ts +++ b/src/common/types/agentAiSettings.ts @@ -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 b95c9dac7d..8126358207 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 c9741f9408..c679bf44d2 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 4f9a56a8a1..d5cae91d0e 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) => { diff --git a/src/node/config/index.ts b/src/node/config/index.ts index 5912473785..4e3a32ce52 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 d7c8b30c9d..e2fc61d0ab 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 37dd826670..7589dceaee 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 29715309dd..9ceb6c80f2 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,230 @@ 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" }); + }, 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("exec subagent uses subagentAiDefaults exec when present", async () => { const config = await createTestConfig(rootDir); stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); @@ -20772,6 +20999,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 b4d5ab7706..0f7297a92e 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,7 @@ export class TaskService implements AgentTaskIntegration { model: coerceNonEmptyString(params.modelString) ?? undefined, thinkingLevel: params.thinkingLevel ?? undefined, }, + parentWorkspaceExecSettings, parentRuntime: params.parentRuntimeAiSettings ? { model: coerceNonEmptyString(params.parentRuntimeAiSettings.modelString) ?? undefined, @@ -2998,6 +3008,7 @@ export class TaskService implements AgentTaskIntegration { ({ taskModelString, canonicalModel, effectiveThinkingLevel, effectiveReasoningMode } = await this.resolveTaskAISettings({ cfg, + parentWorkspaceId, parentMeta, agentId, modelString: args.modelString, @@ -3862,6 +3873,7 @@ export class TaskService implements AgentTaskIntegration { ({ taskModelString, canonicalModel, effectiveThinkingLevel, effectiveReasoningMode } = await this.resolveTaskAISettings({ cfg, + parentWorkspaceId, parentMeta, agentId, modelString: args.modelString, @@ -11590,14 +11602,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: { From 24a324b8002dc5f4f0e2483139a421ee9725cc71 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 11:47:21 +0000 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=A4=96=20fix:=20show=20calling-chat?= =?UTF-8?q?=20Exec=20inheritance=20in=20task=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep delegated Exec defaults symbolic until launch, preserve explicit Standard/Pro choices independently of effort, and cover the full-app desktop/phone settings flow. --- .../ThinkingSelector/ThinkingSelector.tsx | 106 +++++++----- .../Settings/Sections/TasksSection.tsx | 61 ++----- .../Sections/TasksSection.ui.test.tsx | 154 ++++++++++++------ .../stories/App.taskSettings.stories.tsx | 70 ++++++++ 4 files changed, 259 insertions(+), 132 deletions(-) create mode 100644 src/browser/stories/App.taskSettings.stories.tsx diff --git a/src/browser/components/ThinkingSelector/ThinkingSelector.tsx b/src/browser/components/ThinkingSelector/ThinkingSelector.tsx index e5f8c9d34d..c202c87d23 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. 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 10502b4164..b044e45587 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 0000000000..d6477fb49d --- /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), +}; From c698fecac1c8e4ce4be530e34393c92e3f3a8758 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 11:54:08 +0000 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=A4=96=20tests:=20cover=20calling-cha?= =?UTF-8?q?t=20Exec=20inheritance=20through=20full=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive real model picker and send interactions, execute real task tools with a provider-only SDK fake, and check child provider requests plus persisted settings across two Exec selections. The regression intentionally fails on the unfixed baseline: both children use the global fallback. --- tests/ui/agents/execTaskInheritance.test.ts | 185 ++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 tests/ui/agents/execTaskInheritance.test.ts diff --git a/tests/ui/agents/execTaskInheritance.test.ts b/tests/ui/agents/execTaskInheritance.test.ts new file mode 100644 index 0000000000..69a3a92d1f --- /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); +}); From 8840c8798070de98388368c31c1bbc9ccdd852d2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 12:15:43 +0000 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=A4=96=20fix:=20clarify=20the=20fallb?= =?UTF-8?q?ack=20for=20inherited=20Exec=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explain creation-time resolution and the UI Exec fallback when the calling chat has no saved selection. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ --- src/browser/features/Settings/Sections/TasksSection.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/browser/features/Settings/Sections/TasksSection.tsx b/src/browser/features/Settings/Sections/TasksSection.tsx index 850ffb03c9..051bb95327 100644 --- a/src/browser/features/Settings/Sections/TasksSection.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.tsx @@ -1056,8 +1056,9 @@ export function TasksSection() { {agent.id} • {agent.scope} • {renderPolicySummary(agent)}
- Unset fields use the calling chat’s Exec settings. Model capabilities are enforced at - launch. 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.
From 05aa0921d3d4c9700975ca509d859a8c779e2338 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 12:38:21 +0000 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=A4=96=20tests:=20remove=20obsolete?= =?UTF-8?q?=20Config=20session=20locator=20mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove a stale fixture member that blocks the inherited-Exec branch's pre-push typecheck. Session lookup no longer belongs to Config; pinned-order behavior is unchanged and its tests pass. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ --- src/node/services/workspaceService.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 12e71b83f3..3cf746ef27 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -13733,7 +13733,6 @@ describe("WorkspaceService reorderPinned across projects", () => { const mockConfig: Partial = { srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), findWorkspace: mock((id: string) => { const found = findEntry(id); if (!found) return null; From ec6d9d7ef4dcc6b6c7eec9f3b9efd7e006e0f1e5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 12:56:09 +0000 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=A4=96=20tests:=20initialize=20archiv?= =?UTF-8?q?e=20state=20in=20flat=20sidebar=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five flat-list useWorkspaceActions overrides dropped the required archivingWorkspaceIds set from the shared fixture, causing row rendering to throw before their assertions. Match the empty-set default provided by WorkspaceContext without changing product behavior. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$24.82`_ --- .../components/ProjectSidebar/ProjectSidebar.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index e5bf32afd5..ee96b62512 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -957,6 +957,7 @@ describe("ProjectSidebar flat chat list", () => { spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ + archivingWorkspaceIds: new Set(), selectedWorkspace: null, setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: () => @@ -1008,6 +1009,7 @@ describe("ProjectSidebar flat chat list", () => { spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ + archivingWorkspaceIds: new Set(), selectedWorkspace: null, setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: () => @@ -1098,6 +1100,7 @@ describe("ProjectSidebar flat chat list", () => { spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ + archivingWorkspaceIds: new Set(), selectedWorkspace: null, setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: () => @@ -1278,6 +1281,7 @@ describe("ProjectSidebar flat chat list", () => { spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ + archivingWorkspaceIds: new Set(), selectedWorkspace: null, setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: () => @@ -1333,6 +1337,7 @@ describe("ProjectSidebar flat chat list", () => { spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ + archivingWorkspaceIds: new Set(), selectedWorkspace: null, setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: () => From 87254a25dbbe545a0d81e907b4926f3e2c45d0df Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 13:11:32 +0000 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20explicit?= =?UTF-8?q?=20Exec=20delegation=20preferences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve Standard when older workspace Exec settings omit reasoningMode. Keep explicitly authored Exec subagent fields even when they equal global defaults, while retaining legacy-only mirror cleanup and other agents' sparse normalization. Add configuration round-trip and child-launch regressions for both Codex findings. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ --- src/common/types/agentAiDefaults.test.ts | 16 ++++- src/common/types/agentAiDefaults.ts | 19 ++++-- src/common/types/agentAiSettings.ts | 6 +- src/node/config.test.ts | 16 +++++ src/node/services/taskService.test.ts | 84 +++++++++++++++++++++++- src/node/services/taskService.ts | 5 +- 6 files changed, 133 insertions(+), 13 deletions(-) diff --git a/src/common/types/agentAiDefaults.test.ts b/src/common/types/agentAiDefaults.test.ts index 364a4c297b..41289b0c08 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 b2322a6f60..31da1433d5 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 ee3875fc7c..61b3d430e1 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; diff --git a/src/node/config.test.ts b/src/node/config.test.ts index d5cae91d0e..b095094185 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -1909,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/services/taskService.test.ts b/src/node/services/taskService.test.ts index 9ceb6c80f2..69ccc8dd66 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -6478,7 +6478,11 @@ describe("TaskService", () => { ); 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" }); + expect(child?.aiSettings).toEqual({ + model: "openai:gpt-6-astra", + thinkingLevel: "high", + reasoningMode: "standard", + }); }, 20_000); test.each([ @@ -6660,6 +6664,84 @@ describe("TaskService", () => { ); }, 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"); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 0f7297a92e..0b393c253b 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -1955,7 +1955,10 @@ export class TaskService implements AgentTaskIntegration { model: coerceNonEmptyString(params.modelString) ?? undefined, thinkingLevel: params.thinkingLevel ?? undefined, }, - parentWorkspaceExecSettings, + // 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,