From 2828a725bebfc58f003bd5d7db7cdc5456de4399 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:46:11 +0000 Subject: [PATCH 01/28] Split exec subagent AI defaults --- src/common/config/schemas/appConfigOnDisk.ts | 6 + src/common/types/project.ts | 8 +- src/common/types/tasks.test.ts | 38 ++- src/common/types/tasks.ts | 8 +- src/node/config.test.ts | 170 ++++++++++++++ src/node/config.ts | 229 ++++++++++++++---- src/node/orpc/router.ts | 53 +++-- src/node/services/taskService.test.ts | 231 ++++++++++++++++++- src/node/services/taskService.ts | 69 ++++-- 9 files changed, 716 insertions(+), 96 deletions(-) diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index a33e4d8a4b..b69d4d5876 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -30,6 +30,10 @@ export const SubagentAiDefaultsEntrySchema = z.object({ export const SubagentAiDefaultsSchema = z.record(AgentIdSchema, SubagentAiDefaultsEntrySchema); +export const AppConfigMigrationsSchema = z.object({ + execSubagentDefaultsSplit: z.boolean().optional(), +}); + export const FeatureFlagOverrideSchema = z.enum(["default", "on", "off"]); export const UpdateChannelSchema = z.enum(["stable", "nightly"]); @@ -70,6 +74,7 @@ export const AppConfigOnDiskSchema = z preferredCompactionModel: z.string().optional(), agentAiDefaults: AgentAiDefaultsSchema.optional(), subagentAiDefaults: SubagentAiDefaultsSchema.optional(), + migrations: AppConfigMigrationsSchema.optional(), useSSH2Transport: z.boolean().optional(), muxGovernorUrl: z.string().optional(), muxGovernorToken: z.string().optional(), @@ -85,6 +90,7 @@ export const AppConfigOnDiskSchema = z }) .passthrough(); +export type AppConfigMigrations = z.infer; export type AgentAiDefaultsEntry = z.infer; export type AgentAiDefaults = z.infer; export type SubagentAiDefaultsEntry = z.infer; diff --git a/src/common/types/project.ts b/src/common/types/project.ts index 4236ad7844..ecec3cb68f 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -5,7 +5,11 @@ import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; import type { WorktreeArchiveBehavior } from "@/common/config/worktreeArchiveBehavior"; -import type { FeatureFlagOverride, UpdateChannel } from "@/common/config/schemas/appConfigOnDisk"; +import type { + AppConfigMigrations, + FeatureFlagOverride, + UpdateChannel, +} from "@/common/config/schemas/appConfigOnDisk"; import type { z } from "zod"; import type { ProjectConfigSchema, @@ -118,6 +122,8 @@ export interface ProjectsConfig { agentAiDefaults?: AgentAiDefaults; /** @deprecated Legacy per-subagent default model + thinking overrides. */ subagentAiDefaults?: SubagentAiDefaults; + /** Internal one-time migration markers. Not surfaced in user-facing config UI. */ + migrations?: AppConfigMigrations; /** Use built-in SSH2 library instead of system OpenSSH for remote connections (non-Windows only) */ useSSH2Transport?: boolean; diff --git a/src/common/types/tasks.test.ts b/src/common/types/tasks.test.ts index 289b1690e3..7c0f2d614c 100644 --- a/src/common/types/tasks.test.ts +++ b/src/common/types/tasks.test.ts @@ -1,6 +1,42 @@ import { describe, expect, test } from "bun:test"; -import { DEFAULT_TASK_SETTINGS, TASK_SETTINGS_LIMITS, normalizeTaskSettings } from "./tasks"; +import { + DEFAULT_TASK_SETTINGS, + TASK_SETTINGS_LIMITS, + normalizeSubagentAiDefaults, + normalizeTaskSettings, +} from "./tasks"; + +describe("normalizeSubagentAiDefaults", () => { + test("keeps exec entries", () => { + expect( + normalizeSubagentAiDefaults({ + exec: { modelString: " openai:gpt-5.3-codex ", thinkingLevel: "xhigh" }, + }) + ).toEqual({ + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + }); + }); + + test("rejects invalid agent ids", () => { + expect( + normalizeSubagentAiDefaults({ + "not valid": { modelString: "openai:gpt-5.3-codex", thinkingLevel: "high" }, + "bad-": { modelString: "openai:gpt-5.3-codex" }, + explore: { modelString: "openai:gpt-5.2" }, + }) + ).toEqual({ explore: { modelString: "openai:gpt-5.2", thinkingLevel: undefined } }); + }); + + test("drops blank model strings and invalid thinking levels", () => { + expect( + normalizeSubagentAiDefaults({ + explore: { modelString: " ", thinkingLevel: "invalid" }, + plan: { modelString: " ", thinkingLevel: "medium" }, + }) + ).toEqual({ plan: { modelString: undefined, thinkingLevel: "medium" } }); + }); +}); describe("normalizeTaskSettings", () => { test("fills defaults when missing", () => { diff --git a/src/common/types/tasks.ts b/src/common/types/tasks.ts index 51fa3c6ae1..9dd4fc41be 100644 --- a/src/common/types/tasks.ts +++ b/src/common/types/tasks.ts @@ -7,7 +7,9 @@ import type { SubagentAiDefaults, SubagentAiDefaultsEntry, } from "@/common/config/schemas/appConfigOnDisk"; +import { AgentIdSchema } from "@/common/orpc/schemas"; import assert from "@/common/utils/assert"; +import { normalizeAgentId } from "@/common/utils/agentIds"; import { coerceThinkingLevel, type ThinkingLevel } from "./thinking"; export type { PlanSubagentExecutorRouting, SubagentAiDefaults, SubagentAiDefaultsEntry }; @@ -34,10 +36,12 @@ export function normalizeSubagentAiDefaults(raw: unknown): SubagentAiDefaults { const result: SubagentAiDefaults = {}; for (const [agentTypeRaw, entryRaw] of Object.entries(record)) { - const agentType = agentTypeRaw.trim().toLowerCase(); + const normalizedRawAgentId = agentTypeRaw.trim().toLowerCase(); + const agentType = normalizeAgentId(agentTypeRaw, ""); if (!agentType) continue; - if (agentType === "exec") continue; + if (!AgentIdSchema.safeParse(agentType).success) continue; if (!entryRaw || typeof entryRaw !== "object") continue; + if (normalizedRawAgentId !== agentType && result[agentType] != null) continue; const entry = entryRaw as Record; diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 81a3f7a20b..7d255eb9d7 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -492,6 +492,176 @@ describe("Config", () => { "mux-gateway:anthropic/claude-haiku-4-5" ); }); + + it("removes mirrored exec subagent fields on first load", () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + agentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + }, + subagentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + worker: { modelString: "openai:gpt-5.2" }, + }, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.subagentAiDefaults?.exec).toBeUndefined(); + expect(loaded.subagentAiDefaults?.worker?.modelString).toBe("openai:gpt-5.2"); + expect(loaded.migrations?.execSubagentDefaultsSplit).toBe(true); + + const raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { + subagentAiDefaults?: Record; + migrations?: { execSubagentDefaultsSplit?: boolean }; + }; + expect(raw.subagentAiDefaults?.exec).toBeUndefined(); + expect(raw.migrations?.execSubagentDefaultsSplit).toBe(true); + }); + + it("preserves differing exec subagent defaults on first load", () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + agentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + }, + subagentAiDefaults: { + exec: { modelString: "anthropic:claude-haiku-4-5", thinkingLevel: "off" }, + }, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.subagentAiDefaults?.exec).toEqual({ + modelString: "anthropic:claude-haiku-4-5", + thinkingLevel: "off", + }); + expect(loaded.migrations?.execSubagentDefaultsSplit).toBe(true); + }); + + it("removes only mirrored exec subagent fields during first-load cleanup", () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + agentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + }, + subagentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "off" }, + }, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.subagentAiDefaults?.exec).toEqual({ + modelString: undefined, + thinkingLevel: "off", + }); + }); + + it("preserves intentionally equal exec subagent defaults after migration marker is set", () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + migrations: { execSubagentDefaultsSplit: true }, + agentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + }, + subagentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + }, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.subagentAiDefaults?.exec).toEqual({ + modelString: "openai:gpt-5.3-codex", + thinkingLevel: "xhigh", + }); + }); + + it("does not synthesize UI exec defaults from legacy subagent-only exec defaults", () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + subagentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + }, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.agentAiDefaults?.exec).toBeUndefined(); + expect(loaded.subagentAiDefaults?.exec).toEqual({ + modelString: "openai:gpt-5.3-codex", + thinkingLevel: "xhigh", + }); + }); + + it("preserves existing exec subagent defaults when saving derived legacy defaults", async () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + migrations: { execSubagentDefaultsSplit: true }, + agentAiDefaults: { + exec: { modelString: "openai:gpt-5.2", thinkingLevel: "medium" }, + }, + subagentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + }, + }) + ); + + await config.editConfig((cfg) => { + cfg.agentAiDefaults = { + ...cfg.agentAiDefaults, + worker: { modelString: "anthropic:claude-haiku-4-5", thinkingLevel: "off" }, + }; + return cfg; + }); + + const raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { + subagentAiDefaults?: Record; + }; + expect(raw.subagentAiDefaults).toEqual({ + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + worker: { modelString: "anthropic:claude-haiku-4-5", thinkingLevel: "off" }, + }); + }); + + it("allows an explicit empty exec subagent default to delete the preserved value", async () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + migrations: { execSubagentDefaultsSplit: true }, + agentAiDefaults: { + exec: { modelString: "openai:gpt-5.2", thinkingLevel: "medium" }, + }, + subagentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + }, + }) + ); + + await config.editConfig((cfg) => ({ + ...cfg, + subagentAiDefaults: {}, + })); + + const raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { + subagentAiDefaults?: Record; + }; + expect(raw.subagentAiDefaults).toBeUndefined(); + }); }); describe("route priority and overrides persistence", () => { it("round-trips routePriority through disk", async () => { diff --git a/src/node/config.ts b/src/node/config.ts index 9fed36c8a4..1a7e7ba501 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -20,6 +20,7 @@ import type { UpdateChannel, } from "@/common/types/project"; import type { + AppConfigMigrations, AppConfigOnDisk, BaseProviderConfig as ProviderConfig, ProvidersConfig as CanonicalProvidersConfig, @@ -299,6 +300,104 @@ function normalizeAiDefaultsModelStrings; +type SubagentAiDefaultsConfigEntry = SubagentAiDefaultsConfig[string]; +type AgentAiDefaultsConfig = NonNullable; + +const AGENT_DEFAULT_IDS_EXCLUDED_FROM_LEGACY_SUBAGENTS = new Set(["plan", "exec", "compact"]); + +function shouldMirrorAgentDefaultToLegacySubagent(agentId: string): boolean { + return !AGENT_DEFAULT_IDS_EXCLUDED_FROM_LEGACY_SUBAGENTS.has(agentId); +} + +function normalizeConfigMigrations(value: unknown): AppConfigMigrations { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + + const record = value as Record; + return { + ...(record.execSubagentDefaultsSplit === true ? { execSubagentDefaultsSplit: true } : {}), + }; +} + +function legacySubagentDefaultsForAgentFallback( + legacySubagentAiDefaults: SubagentAiDefaultsConfig +): Record { + const fallbackDefaults: Record = {}; + for (const [agentId, entry] of Object.entries(legacySubagentAiDefaults)) { + if (!shouldMirrorAgentDefaultToLegacySubagent(agentId)) continue; + fallbackDefaults[agentId] = entry; + } + return fallbackDefaults; +} + +function removeMirroredExecSubagentDefaults(params: { + subagentAiDefaults: SubagentAiDefaultsConfig; + agentAiDefaults: AgentAiDefaultsConfig; +}): { + subagentAiDefaults: SubagentAiDefaultsConfig; + modified: boolean; +} { + const execSubagentDefault = params.subagentAiDefaults.exec; + const execAgentDefault = params.agentAiDefaults.exec; + if (!execSubagentDefault || !execAgentDefault) { + return { subagentAiDefaults: params.subagentAiDefaults, modified: false }; + } + + const nextExecSubagentDefault = { ...execSubagentDefault }; + let modified = false; + + if ( + nextExecSubagentDefault.modelString !== undefined && + nextExecSubagentDefault.modelString === execAgentDefault.modelString + ) { + delete nextExecSubagentDefault.modelString; + modified = true; + } + + if ( + nextExecSubagentDefault.thinkingLevel !== undefined && + nextExecSubagentDefault.thinkingLevel === execAgentDefault.thinkingLevel + ) { + delete nextExecSubagentDefault.thinkingLevel; + modified = true; + } + + if (!modified) { + return { subagentAiDefaults: params.subagentAiDefaults, modified: false }; + } + + const subagentAiDefaults = { ...params.subagentAiDefaults }; + if ( + nextExecSubagentDefault.modelString === undefined && + nextExecSubagentDefault.thinkingLevel === undefined + ) { + delete subagentAiDefaults.exec; + } else { + subagentAiDefaults.exec = nextExecSubagentDefault; + } + + return { subagentAiDefaults, modified: true }; +} + +function deriveLegacySubagentAiDefaultsFromAgentDefaults(params: { + agentAiDefaults: Record; + preservedExec?: SubagentAiDefaultsConfigEntry; +}): SubagentAiDefaultsConfig { + const legacySubagentDefaultsRaw: Record = {}; + for (const [agentId, entry] of Object.entries(params.agentAiDefaults)) { + if (!shouldMirrorAgentDefaultToLegacySubagent(agentId)) continue; + legacySubagentDefaultsRaw[agentId] = entry; + } + + const legacySubagentDefaults = normalizeSubagentAiDefaults(legacySubagentDefaultsRaw); + if (params.preservedExec) { + legacySubagentDefaults.exec = params.preservedExec; + } + return legacySubagentDefaults; +} + function parseOptionalPort(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) { return undefined; @@ -622,41 +721,6 @@ export class Config { configModified = true; } - if (configModified) { - // Invalidate stale usage caches: old files may contain gateway-prefixed model ids. - try { - if (fs.existsSync(this.sessionsDir)) { - for (const sessionEntry of fs.readdirSync(this.sessionsDir, { - withFileTypes: true, - })) { - if (!sessionEntry.isDirectory()) { - continue; - } - - const usagePath = path.join( - this.getSessionDir(sessionEntry.name), - "session-usage.json" - ); - if (fs.existsSync(usagePath)) { - fs.rmSync(usagePath, { force: true }); - } - } - } - } catch (error) { - // Best-effort cleanup; never fail startup on cache invalidation issues. - log.warn("Failed to invalidate session usage cache during config migration", { error }); - } - - try { - writeFileAtomic.sync(this.configFile, JSON.stringify(parsed, null, 2), { - encoding: "utf-8", - }); - } catch (error) { - // Keep startup resilient even if persisting migration fails. - log.warn("Failed to persist migrated config", { error }); - } - } - // Config is stored as array of [path, config] pairs. // Older/newer files may omit `projects`; treat missing/invalid values as an empty map // so top-level settings (provider/runtime/server preferences) still load. @@ -700,7 +764,73 @@ export class Config { ? null : parseOptionalPositiveInteger(parsed.advisorMaxOutputTokens); const hiddenModels = normalizeOptionalModelStringArray(parsed.hiddenModels); - const legacySubagentAiDefaults = normalizeSubagentAiDefaults(parsed.subagentAiDefaults); + let legacySubagentAiDefaults = normalizeSubagentAiDefaults(parsed.subagentAiDefaults); + const agentAiDefaults = + parsed.agentAiDefaults !== undefined + ? normalizeAgentAiDefaults(parsed.agentAiDefaults) + : normalizeAgentAiDefaults( + legacySubagentDefaultsForAgentFallback(legacySubagentAiDefaults) + ); + const configMigrations = normalizeConfigMigrations(parsed.migrations); + + const needsExecSubagentDefaultsSplitMigration = + configMigrations.execSubagentDefaultsSplit !== true && + (legacySubagentAiDefaults.exec != null || agentAiDefaults.exec != null); + if (needsExecSubagentDefaultsSplitMigration) { + const cleanup = removeMirroredExecSubagentDefaults({ + subagentAiDefaults: legacySubagentAiDefaults, + agentAiDefaults, + }); + legacySubagentAiDefaults = cleanup.subagentAiDefaults; + if (cleanup.modified) { + if (Object.keys(legacySubagentAiDefaults).length > 0) { + parsed.subagentAiDefaults = legacySubagentAiDefaults; + } else { + delete parsed.subagentAiDefaults; + } + } + + parsed.migrations = { + ...configMigrations, + execSubagentDefaultsSplit: true, + }; + configModified = true; + } + + if (configModified) { + // Invalidate stale usage caches: old files may contain gateway-prefixed model ids. + try { + if (fs.existsSync(this.sessionsDir)) { + for (const sessionEntry of fs.readdirSync(this.sessionsDir, { + withFileTypes: true, + })) { + if (!sessionEntry.isDirectory()) { + continue; + } + + const usagePath = path.join( + this.getSessionDir(sessionEntry.name), + "session-usage.json" + ); + if (fs.existsSync(usagePath)) { + fs.rmSync(usagePath, { force: true }); + } + } + } + } catch (error) { + // Best-effort cleanup; never fail startup on cache invalidation issues. + log.warn("Failed to invalidate session usage cache during config migration", { error }); + } + + try { + writeFileAtomic.sync(this.configFile, JSON.stringify(parsed, null, 2), { + encoding: "utf-8", + }); + } catch (error) { + // Keep startup resilient even if persisting migration fails. + log.warn("Failed to persist migrated config", { error }); + } + } const coderWorkspaceArchiveBehavior = resolveCoderWorkspaceArchiveBehavior( parsed.coderWorkspaceArchiveBehavior, @@ -720,11 +850,6 @@ export class Config { const runtimeEnablement = normalizeRuntimeEnablementOverrides(parsed.runtimeEnablement); const defaultRuntime = normalizeRuntimeEnablementId(parsed.defaultRuntime); - const agentAiDefaults = - parsed.agentAiDefaults !== undefined - ? normalizeAgentAiDefaults(parsed.agentAiDefaults) - : normalizeAgentAiDefaults(legacySubagentAiDefaults); - const layoutPresetsRaw = normalizeLayoutPresetsConfig(parsed.layoutPresets); const layoutPresets = isLayoutPresetsConfigEmpty(layoutPresetsRaw) ? undefined @@ -761,6 +886,7 @@ export class Config { agentAiDefaults, // Legacy fields are still parsed and returned for downgrade compatibility. subagentAiDefaults: legacySubagentAiDefaults, + migrations: normalizeConfigMigrations(parsed.migrations), featureFlagOverrides: parsed.featureFlagOverrides, useSSH2Transport: parseOptionalBoolean(parsed.useSSH2Transport), muxGovernorUrl: parseOptionalNonEmptyString(parsed.muxGovernorUrl), @@ -936,13 +1062,13 @@ export class Config { const normalizedAgentAiDefaults = normalizeAiDefaultsModelStrings(config.agentAiDefaults); data.agentAiDefaults = normalizedAgentAiDefaults; - const legacySubagent: Record = {}; - for (const [id, entry] of Object.entries(normalizedAgentAiDefaults)) { - if (id === "plan" || id === "exec" || id === "compact") continue; - legacySubagent[id] = entry; - } + const preservedExec = config.subagentAiDefaults?.exec; + const legacySubagent = deriveLegacySubagentAiDefaultsFromAgentDefaults({ + agentAiDefaults: normalizedAgentAiDefaults, + preservedExec, + }); if (Object.keys(legacySubagent).length > 0) { - data.subagentAiDefaults = legacySubagent as ProjectsConfig["subagentAiDefaults"]; + data.subagentAiDefaults = legacySubagent; } } else { // Legacy only. @@ -951,6 +1077,15 @@ export class Config { } } + const migrations = normalizeConfigMigrations(config.migrations); + if ( + migrations.execSubagentDefaultsSplit === true || + config.agentAiDefaults?.exec != null || + config.subagentAiDefaults?.exec != null + ) { + data.migrations = { ...migrations, execSubagentDefaultsSplit: true }; + } + if (config.useSSH2Transport !== undefined) { data.useSSH2Transport = config.useSSH2Transport; } diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index b4f9b3112a..db6c7e6282 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -100,6 +100,32 @@ const RAW_QUERY_USER_ERROR_PATTERNS = [ /string literals cannot be used as table sources/i, ] as const; +const AGENT_DEFAULT_IDS_EXCLUDED_FROM_LEGACY_SUBAGENTS = new Set(["plan", "exec", "compact"]); + +type NormalizedSubagentAiDefaults = ReturnType; +type NormalizedSubagentAiDefaultsEntry = NormalizedSubagentAiDefaults[string]; + +function shouldMirrorAgentDefaultToLegacySubagent(agentId: string): boolean { + return !AGENT_DEFAULT_IDS_EXCLUDED_FROM_LEGACY_SUBAGENTS.has(agentId); +} + +function deriveLegacySubagentAiDefaultsFromAgentDefaults(params: { + agentAiDefaults: Record; + preservedExec?: NormalizedSubagentAiDefaultsEntry; +}): NormalizedSubagentAiDefaults { + const legacySubagentDefaultsRaw: Record = {}; + for (const [agentId, entry] of Object.entries(params.agentAiDefaults)) { + if (!shouldMirrorAgentDefaultToLegacySubagent(agentId)) continue; + legacySubagentDefaultsRaw[agentId] = entry; + } + + const legacySubagentDefaults = normalizeSubagentAiDefaults(legacySubagentDefaultsRaw); + if (params.preservedExec) { + legacySubagentDefaults.exec = params.preservedExec; + } + return legacySubagentDefaults; +} + function shouldExposeRawQueryError(error: unknown): boolean { const message = getErrorMessage(error); return RAW_QUERY_USER_ERROR_PATTERNS.some((pattern) => pattern.test(message)); @@ -707,15 +733,10 @@ export const router = (authToken?: string) => { await context.config.editConfig((config) => { const normalized = normalizeAgentAiDefaults(input.agentAiDefaults); - const legacySubagentDefaultsRaw: Record = {}; - for (const [agentType, entry] of Object.entries(normalized)) { - if (agentType === "plan" || agentType === "exec" || agentType === "compact") { - continue; - } - legacySubagentDefaultsRaw[agentType] = entry; - } - - const legacySubagentDefaults = normalizeSubagentAiDefaults(legacySubagentDefaultsRaw); + const legacySubagentDefaults = deriveLegacySubagentAiDefaultsFromAgentDefaults({ + agentAiDefaults: normalized, + preservedExec: config.subagentAiDefaults?.exec, + }); return { ...config, @@ -959,16 +980,10 @@ export const router = (authToken?: string) => { result.agentAiDefaults = Object.keys(normalized).length > 0 ? normalized : undefined; if (input.subagentAiDefaults === undefined) { - const legacySubagentDefaultsRaw: Record = {}; - for (const [agentType, entry] of Object.entries(normalized)) { - if (agentType === "plan" || agentType === "exec" || agentType === "compact") { - continue; - } - legacySubagentDefaultsRaw[agentType] = entry; - } - - const legacySubagentDefaults = - normalizeSubagentAiDefaults(legacySubagentDefaultsRaw); + const legacySubagentDefaults = deriveLegacySubagentAiDefaultsFromAgentDefaults({ + agentAiDefaults: normalized, + preservedExec: config.subagentAiDefaults?.exec, + }); result.subagentAiDefaults = Object.keys(legacySubagentDefaults).length > 0 ? legacySubagentDefaults diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index ba725522d9..81245108e8 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -123,6 +123,47 @@ async function createTestProject( return projectPath; } +async function saveLocalParentWorkspace( + config: Config, + rootDir: string, + options?: { + agentAiDefaults?: Record; + subagentAiDefaults?: Record; + parentAiSettings?: { model: string; thinkingLevel: ThinkingLevel }; + } +): Promise<{ parentId: string; projectPath: string }> { + const projectPath = await createTestProject(rootDir, "repo", { initGit: false }); + const parentId = "1111111111"; + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + trusted: true, + workspaces: [ + { + path: projectPath, + id: parentId, + name: "parent", + createdAt: new Date().toISOString(), + runtimeConfig: { type: "local" }, + aiSettings: options?.parentAiSettings ?? { + model: "anthropic:claude-opus-4-6", + thinkingLevel: "high", + }, + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, + agentAiDefaults: options?.agentAiDefaults, + subagentAiDefaults: options?.subagentAiDefaults, + migrations: { execSubagentDefaultsSplit: true }, + }); + return { parentId, projectPath }; +} + function stubStableIds(config: Config, ids: string[], fallbackId = "fffffffff0"): void { let nextIdIndex = 0; const configWithStableId = config as unknown as { generateStableId: () => string }; @@ -1640,7 +1681,7 @@ describe("TaskService", () => { expect(childEntry?.taskThinkingLevel).toBe("xhigh"); }, 20_000); - test("agentAiDefaults override inherited parent model on task create", async () => { + test("explicit task args outrank agentAiDefaults on task create", async () => { const config = await createTestConfig(rootDir); stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); @@ -1700,14 +1741,200 @@ describe("TaskService", () => { created.data.taskId, "run task with custom agent", { - model: "openai:gpt-5.3-codex", + model: "openai:gpt-4o-mini", agentId: "custom", + thinkingLevel: "off", + experiments: undefined, + }, + { agentInitiated: true } + ); + }, 20_000); + + test("exec subagent uses subagentAiDefaults exec when present", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); + const { parentId } = await saveLocalParentWorkspace(config, rootDir, { + agentAiDefaults: { + exec: { modelString: "openai:gpt-5.2", thinkingLevel: "medium" }, + }, + subagentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + }, + }); + + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + const created = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "exec", + prompt: "run exec task with subagent defaults", + title: "Test task", + }); + expect(created.success).toBe(true); + if (!created.success) return; + + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "run exec task with subagent defaults", + { + model: "openai:gpt-5.3-codex", + agentId: "exec", + thinkingLevel: "xhigh", + experiments: undefined, + }, + { agentInitiated: true } + ); + const childEntry = findWorkspaceInConfig(config, created.data.taskId); + expect(childEntry?.taskModelString).toBe("openai:gpt-5.3-codex"); + expect(childEntry?.taskThinkingLevel).toBe("xhigh"); + }, 20_000); + + test("exec subagent falls back to agentAiDefaults exec when subagent default is absent", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); + const { parentId } = await saveLocalParentWorkspace(config, rootDir, { + agentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + }, + }); + + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + const created = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "exec", + prompt: "run exec task with agent defaults", + title: "Test task", + }); + expect(created.success).toBe(true); + if (!created.success) return; + + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "run exec task with agent defaults", + { + model: "openai:gpt-5.3-codex", + agentId: "exec", + thinkingLevel: "xhigh", + experiments: undefined, + }, + { agentInitiated: true } + ); + }, 20_000); + + test("exec subagent partial override combines subagent model with agent thinking", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); + const { parentId } = await saveLocalParentWorkspace(config, rootDir, { + agentAiDefaults: { + exec: { modelString: "openai:gpt-5.2", thinkingLevel: "xhigh" }, + }, + subagentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex" }, + }, + }); + + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + const created = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "exec", + prompt: "run exec task with partial defaults", + title: "Test task", + }); + expect(created.success).toBe(true); + if (!created.success) return; + + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "run exec task with partial defaults", + { + model: "openai:gpt-5.3-codex", + agentId: "exec", thinkingLevel: "xhigh", experiments: undefined, }, { agentInitiated: true } ); }, 20_000); + + test("thinking policy is enforced after resolving the final subagent model", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); + const { parentId } = await saveLocalParentWorkspace(config, rootDir, { + subagentAiDefaults: { + exec: { modelString: "google:gemini-3-pro" }, + }, + }); + + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + const created = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "exec", + prompt: "run exec task with clamped thinking", + title: "Test task", + thinkingLevel: "off", + }); + expect(created.success).toBe(true); + if (!created.success) return; + + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "run exec task with clamped thinking", + { + model: "google:gemini-3-pro", + agentId: "exec", + thinkingLevel: "low", + experiments: undefined, + }, + { agentInitiated: true } + ); + }, 20_000); + + test("created task metadata is not recomputed after defaults change", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); + const { parentId } = await saveLocalParentWorkspace(config, rootDir, { + subagentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + }, + }); + + const { taskService } = createTaskServiceHarness(config); + const created = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "exec", + prompt: "run exec task before defaults change", + title: "Test task", + }); + expect(created.success).toBe(true); + if (!created.success) return; + + await config.editConfig((cfg) => ({ + ...cfg, + subagentAiDefaults: { + exec: { modelString: "openai:gpt-5.2", thinkingLevel: "medium" }, + }, + })); + + const childEntry = findWorkspaceInConfig(config, created.data.taskId); + expect(childEntry?.aiSettings).toEqual({ + model: "openai:gpt-5.3-codex", + thinkingLevel: "xhigh", + }); + expect(childEntry?.taskModelString).toBe("openai:gpt-5.3-codex"); + expect(childEntry?.taskThinkingLevel).toBe("xhigh"); + }, 20_000); test("auto-resumes a parent workspace until background tasks finish", async () => { const config = await createTestConfig(rootDir); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 38d9a36fc0..deac8f1a46 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -396,6 +396,44 @@ export class TaskService { workspace.aiSettings ); } + + private resolveTaskAISettings(params: { + cfg: ReturnType; + parentMeta: WorkspaceMetadata; + agentId: string; + modelString?: string; + thinkingLevel?: ThinkingLevel; + }): { + taskModelString: string; + canonicalModel: string; + effectiveThinkingLevel: ThinkingLevel; + } { + const parentAiSettings = this.resolveWorkspaceAISettings(params.parentMeta, params.agentId); + // Exec needs separate UI-agent and subagent defaults. Resolve subagent defaults first, + // then fall back to UI defaults for compatibility when no subagent override exists. + const subagentDefault = params.cfg.subagentAiDefaults?.[params.agentId]; + const agentDefault = params.cfg.agentAiDefaults?.[params.agentId]; + + const taskModelString = + coerceNonEmptyString(params.modelString) ?? + coerceNonEmptyString(subagentDefault?.modelString) ?? + coerceNonEmptyString(agentDefault?.modelString) ?? + coerceNonEmptyString(parentAiSettings?.model) ?? + defaultModel; + const canonicalModel = normalizeToCanonical(taskModelString).trim(); + assert(canonicalModel.length > 0, "Task.create: resolved model must be non-empty"); + + const requestedThinkingLevel: ThinkingLevel = + params.thinkingLevel ?? + subagentDefault?.thinkingLevel ?? + agentDefault?.thinkingLevel ?? + parentAiSettings?.thinkingLevel ?? + "off"; + const effectiveThinkingLevel = enforceThinkingPolicy(canonicalModel, requestedThinkingLevel); + + return { taskModelString, canonicalModel, effectiveThinkingLevel }; + } + /** * Derives auto-resume send options (agentId, model, thinkingLevel) from durable * conversation metadata, so synthetic resumes preserve the parent's active agent. @@ -1144,30 +1182,13 @@ export class TaskService { ); } - // User-requested precedence: use global per-agent defaults when configured; - // otherwise inherit the parent workspace's active model/thinking. - const parentAiSettings = this.resolveWorkspaceAISettings(parentMeta, agentId); - const inheritedModelCandidate = - typeof args.modelString === "string" && args.modelString.trim().length > 0 - ? args.modelString - : parentAiSettings?.model; - const parentActiveModel = - typeof inheritedModelCandidate === "string" && inheritedModelCandidate.trim().length > 0 - ? inheritedModelCandidate.trim() - : defaultModel; - const globalDefault = cfg.agentAiDefaults?.[agentId]; - const configuredModel = globalDefault?.modelString?.trim(); - const taskModelString = - configuredModel && configuredModel.length > 0 ? configuredModel : parentActiveModel; - const canonicalModel = normalizeToCanonical(taskModelString).trim(); - assert(canonicalModel.length > 0, "Task.create: resolved model must be non-empty"); - - const requestedThinkingLevel: ThinkingLevel = - globalDefault?.thinkingLevel ?? - args.thinkingLevel ?? - parentAiSettings?.thinkingLevel ?? - "off"; - const effectiveThinkingLevel = enforceThinkingPolicy(canonicalModel, requestedThinkingLevel); + const { taskModelString, canonicalModel, effectiveThinkingLevel } = this.resolveTaskAISettings({ + cfg, + parentMeta, + agentId, + modelString: args.modelString, + thinkingLevel: args.thinkingLevel, + }); const parentRuntimeConfig = parentMeta.runtimeConfig; const taskRuntimeConfig: RuntimeConfig = parentRuntimeConfig; From 67b60645dd0df72265a4406e1ad1f18a0d885211 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:15:14 +0000 Subject: [PATCH 02/28] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20exec=20subag?= =?UTF-8?q?ent=20defaults=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dedicated Settings row for Exec when it runs as a sub-agent, backed by subagentAiDefaults.exec. Keep UI Exec defaults separate and preserve sparse subagent payloads so unset fields inherit from UI Exec.\n\n---\n\n_Generated with • Model: • Thinking: • Cost: _\n\n --- .../Settings/Sections/TasksSection.tsx | 418 +++++++++++++----- .../Sections/TasksSection.ui.test.tsx | 222 ++++++++++ 2 files changed, 538 insertions(+), 102 deletions(-) create mode 100644 src/browser/features/Settings/Sections/TasksSection.ui.test.tsx diff --git a/src/browser/features/Settings/Sections/TasksSection.tsx b/src/browser/features/Settings/Sections/TasksSection.tsx index ad199573f8..48cf075caf 100644 --- a/src/browser/features/Settings/Sections/TasksSection.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.tsx @@ -35,8 +35,11 @@ import { DEFAULT_TASK_SETTINGS, TASK_SETTINGS_LIMITS, isPlanSubagentExecutorRouting, + normalizeSubagentAiDefaults, normalizeTaskSettings, type PlanSubagentExecutorRouting, + type SubagentAiDefaults, + type SubagentAiDefaultsEntry, type TaskSettings, } from "@/common/types/tasks"; import { getThinkingOptionLabel, type ThinkingLevel } from "@/common/types/thinking"; @@ -89,6 +92,64 @@ function updateAgentDefaultEntry( return next; } +function updateSubagentDefaultEntry( + previous: SubagentAiDefaults, + agentId: string, + update: (entry: SubagentAiDefaultsEntry) => void +): SubagentAiDefaults { + const normalizedId = normalizeAgentId(agentId, WORKSPACE_DEFAULTS.agentId); + + const next = { ...previous }; + const existing = next[normalizedId] ?? {}; + const updated: SubagentAiDefaultsEntry = { ...existing }; + update(updated); + + if (updated.modelString && updated.thinkingLevel) { + updated.thinkingLevel = enforceThinkingPolicy(updated.modelString, updated.thinkingLevel); + } + + if (updated.modelString === undefined && updated.thinkingLevel === undefined) { + delete next[normalizedId]; + } else { + next[normalizedId] = updated; + } + + return next; +} + +function getSubagentAiDefaultsForSave( + agentAiDefaults: AgentAiDefaults, + subagentAiDefaults: SubagentAiDefaults +): SubagentAiDefaults { + const next: SubagentAiDefaults = { ...subagentAiDefaults }; + const agentIds = new Set([...Object.keys(agentAiDefaults), ...Object.keys(subagentAiDefaults)]); + + for (const agentId of agentIds) { + if (agentId === "plan" || agentId === "exec" || agentId === "compact") { + continue; + } + + const entry = agentAiDefaults[agentId]; + if (!entry) { + continue; + } + + if (entry.modelString === undefined && entry.thinkingLevel === undefined) { + if (!(agentId in subagentAiDefaults)) { + delete next[agentId]; + } + continue; + } + + next[agentId] = { + modelString: entry.modelString, + thinkingLevel: entry.thinkingLevel, + }; + } + + return next; +} + function renderPolicySummary(agent: AgentDefinitionDescriptor): React.ReactNode { const isCompact = agent.id === "compact"; @@ -219,10 +280,129 @@ function areAgentAiDefaultsEqual(a: AgentAiDefaults, b: AgentAiDefaults): boolea return true; } + +function areSubagentAiDefaultsEqual(a: SubagentAiDefaults, b: SubagentAiDefaults): boolean { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) { + return false; + } + + aKeys.sort(); + bKeys.sort(); + + for (let i = 0; i < aKeys.length; i += 1) { + const key = aKeys[i]; + if (key !== bKeys[i]) { + return false; + } + + const aEntry = a[key]; + const bEntry = b[key]; + if ((aEntry?.modelString ?? undefined) !== (bEntry?.modelString ?? undefined)) { + return false; + } + if ((aEntry?.thinkingLevel ?? undefined) !== (bEntry?.thinkingLevel ?? undefined)) { + return false; + } + } + + return true; +} function coerceAgentId(value: unknown): string { return normalizeAgentId(value, WORKSPACE_DEFAULTS.agentId); } +interface AiDefaultsControlsProps { + modelValue: string; + thinkingValue: string; + effectiveModel: string; + models: string[]; + hiddenModelsForSelector: string[]; + inheritLabel?: string; + resetModelLabel?: string; + resetThinkingLabel?: string; + inheritedModelDescription?: string; + inheritedThinkingDescription?: string; + showThinkingResetButton?: boolean; + onModelChange: (value: string) => void; + onThinkingChange: (value: string) => void; +} + +function AiDefaultsControls(props: AiDefaultsControlsProps) { + const allowedThinkingLevels = getThinkingPolicyForModel(props.effectiveModel); + const inheritLabel = props.inheritLabel ?? "Inherit"; + const resetModelLabel = props.resetModelLabel ?? "Reset"; + const resetThinkingLabel = props.resetThinkingLabel ?? "Reset"; + + return ( +
+
+
Model
+
+ {/* Match the Reasoning dropdown styling for inherit defaults. */} + props.onModelChange(value.trim().length > 0 ? value : INHERIT)} + models={props.models} + hiddenModels={props.hiddenModelsForSelector} + variant="box" + className="bg-modal-bg" + /> + {props.modelValue !== INHERIT ? ( + + ) : null} +
+ {props.modelValue === INHERIT && props.inheritedModelDescription ? ( +
{props.inheritedModelDescription}
+ ) : null} +
+ +
+
Reasoning
+
+ + {props.showThinkingResetButton === true && props.thinkingValue !== INHERIT ? ( + + ) : null} +
+ {props.thinkingValue === INHERIT && props.inheritedThinkingDescription ? ( +
{props.inheritedThinkingDescription}
+ ) : null} +
+
+ ); +} + export function TasksSection() { const { api } = useAPI(); const { selectedWorkspace } = useWorkspaceContext(); @@ -234,6 +414,7 @@ export function TasksSection() { const [taskSettings, setTaskSettings] = useState(DEFAULT_TASK_SETTINGS); const [agentAiDefaults, setAgentAiDefaults] = useState({}); + const [subagentAiDefaults, setSubagentAiDefaults] = useState({}); const [agents, setAgents] = useState([]); const [enabledAgentIds, setEnabledAgentIds] = useState([]); @@ -249,6 +430,7 @@ export function TasksSection() { const pendingSaveRef = useRef<{ taskSettings: TaskSettings; agentAiDefaults: AgentAiDefaults; + subagentAiDefaults: SubagentAiDefaults; } | null>(null); const { models, hiddenModelsForSelector } = useModelsFromSettings(); @@ -279,6 +461,7 @@ export function TasksSection() { const lastSyncedTaskSettingsRef = useRef(null); const lastSyncedAgentAiDefaultsRef = useRef(null); + const lastSyncedSubagentAiDefaultsRef = useRef(null); useEffect(() => { if (!api) return; @@ -294,11 +477,14 @@ export function TasksSection() { setTaskSettings(normalizedTaskSettings); const normalizedAgentDefaults = normalizeAgentAiDefaults(cfg.agentAiDefaults); setAgentAiDefaults(normalizedAgentDefaults); + const normalizedSubagentDefaults = normalizeSubagentAiDefaults(cfg.subagentAiDefaults); + setSubagentAiDefaults(normalizedSubagentDefaults); updatePersistedState(AGENT_AI_DEFAULTS_KEY, normalizedAgentDefaults); setLoadFailed(false); lastSyncedTaskSettingsRef.current = normalizedTaskSettings; lastSyncedAgentAiDefaultsRef.current = normalizedAgentDefaults; + lastSyncedSubagentAiDefaultsRef.current = normalizedSubagentDefaults; setLoaded(true); }) @@ -355,15 +541,26 @@ export function TasksSection() { if (!loaded) return; if (loadFailed) return; - pendingSaveRef.current = { taskSettings, agentAiDefaults }; + const subagentAiDefaultsForSave = getSubagentAiDefaultsForSave( + agentAiDefaults, + subagentAiDefaults + ); + pendingSaveRef.current = { + taskSettings, + agentAiDefaults, + subagentAiDefaults: subagentAiDefaultsForSave, + }; const lastTaskSettings = lastSyncedTaskSettingsRef.current; const lastAgentDefaults = lastSyncedAgentAiDefaultsRef.current; + const lastSubagentDefaults = lastSyncedSubagentAiDefaultsRef.current; if ( lastTaskSettings && lastAgentDefaults && + lastSubagentDefaults && areTaskSettingsEqual(lastTaskSettings, taskSettings) && - areAgentAiDefaultsEqual(lastAgentDefaults, agentAiDefaults) + areAgentAiDefaultsEqual(lastAgentDefaults, agentAiDefaults) && + areSubagentAiDefaultsEqual(lastSubagentDefaults, subagentAiDefaultsForSave) ) { pendingSaveRef.current = null; if (saveTimerRef.current) { @@ -395,15 +592,20 @@ export function TasksSection() { .saveConfig({ taskSettings: payload.taskSettings, agentAiDefaults: payload.agentAiDefaults, + subagentAiDefaults: payload.subagentAiDefaults, }) .then(() => { const previousAgentDefaults = lastSyncedAgentAiDefaultsRef.current; + const previousSubagentDefaults = lastSyncedSubagentAiDefaultsRef.current; const agentDefaultsChanged = !previousAgentDefaults || - !areAgentAiDefaultsEqual(previousAgentDefaults, payload.agentAiDefaults); + !areAgentAiDefaultsEqual(previousAgentDefaults, payload.agentAiDefaults) || + !previousSubagentDefaults || + !areSubagentAiDefaultsEqual(previousSubagentDefaults, payload.subagentAiDefaults); lastSyncedTaskSettingsRef.current = payload.taskSettings; lastSyncedAgentAiDefaultsRef.current = payload.agentAiDefaults; + lastSyncedSubagentAiDefaultsRef.current = payload.subagentAiDefaults; setSaveError(null); if (agentDefaultsChanged) { @@ -455,7 +657,7 @@ export function TasksSection() { saveTimerRef.current = null; } }; - }, [api, agentAiDefaults, loaded, loadFailed, taskSettings]); + }, [api, agentAiDefaults, loaded, loadFailed, subagentAiDefaults, taskSettings]); // Flush any pending debounced save on unmount so changes aren't lost. useEffect(() => { @@ -479,6 +681,7 @@ export function TasksSection() { .saveConfig({ taskSettings: payload.taskSettings, agentAiDefaults: payload.agentAiDefaults, + subagentAiDefaults: payload.subagentAiDefaults, }) .catch(() => undefined) .finally(() => { @@ -531,7 +734,7 @@ export function TasksSection() { const setAgentModel = (agentId: string, value: string) => { setAgentAiDefaults((prev) => updateAgentDefaultEntry(prev, agentId, (updated) => { - if (value === INHERIT) { + if (value === INHERIT || value.trim().length === 0) { delete updated.modelString; } else { updated.modelString = value; @@ -553,6 +756,31 @@ export function TasksSection() { ); }; + const setSubagentModel = (agentId: string, value: string) => { + setSubagentAiDefaults((prev) => + updateSubagentDefaultEntry(prev, agentId, (updated) => { + if (value === INHERIT || value.trim().length === 0) { + delete updated.modelString; + } else { + updated.modelString = value; + } + }) + ); + }; + + const setSubagentThinking = (agentId: string, value: string) => { + setSubagentAiDefaults((prev) => + updateSubagentDefaultEntry(prev, agentId, (updated) => { + if (value === INHERIT) { + delete updated.thinkingLevel; + return; + } + + updated.thinkingLevel = value as ThinkingLevel; + }) + ); + }; + const setAgentEnabled = (agentId: string, value: boolean) => { setAgentAiDefaults((prev) => updateAgentDefaultEntry(prev, agentId, (updated) => { @@ -597,6 +825,10 @@ export function TasksSection() { }), [agentAiDefaults, listedAgents, portableDesktopEnabled] ); + const execSubagentAgent = listedAgents.find( + (agent) => agent.id === "exec" && agent.subagentRunnable + ); + const newWorkspaceDefaultAgentOptions = useMemo(() => { const options = uiAgents.map((agent) => ({ id: agent.id, @@ -617,6 +849,7 @@ export function TasksSection() { const entry = agentAiDefaults[agent.id]; const modelValue = entry?.modelString ?? INHERIT; const thinkingValue = entry?.thinkingLevel ?? INHERIT; + const writesSubagentAiDefaults = agent.subagentRunnable && !agent.uiSelectable; const enabledOverride = entry?.enabled; const advisorEnabledOverride = entry?.advisorEnabled; const advisorEnabledValue = advisorEnabledOverride ?? false; @@ -653,7 +886,6 @@ export function TasksSection() { // When model is "Inherit", resolve the effective model so the dropdown // shows the correct thinking levels (e.g. "max" for Opus 4.6, not "xhigh"). const effectiveModel = modelValue !== INHERIT ? modelValue : inheritedEffectiveModel; - const allowedThinkingLevels = getThinkingPolicyForModel(effectiveModel); const agentDefinitionPath = getAgentDefinitionPath(agent); const scopeNode = agentDefinitionPath ? ( @@ -775,54 +1007,73 @@ export function TasksSection() { -
-
-
Model
-
- {/* Match the Reasoning dropdown styling for inherit defaults. */} - setAgentModel(agent.id, value)} - models={models} - hiddenModels={hiddenModelsForSelector} - variant="box" - className="bg-modal-bg" - /> - {modelValue !== INHERIT ? ( - - ) : null} -
-
+ { + setAgentModel(agent.id, value); + if (writesSubagentAiDefaults) { + setSubagentModel(agent.id, value); + } + }} + onThinkingChange={(value) => { + setAgentThinking(agent.id, value); + if (writesSubagentAiDefaults) { + setSubagentThinking(agent.id, value); + } + }} + /> +
+ ); + }; -
-
Reasoning
- + const renderExecSubagentDefaults = (agent: AgentDefinitionDescriptor) => { + const entry = subagentAiDefaults.exec; + const modelValue = entry?.modelString ?? INHERIT; + const thinkingValue = entry?.thinkingLevel ?? INHERIT; + const uiExecEntry = agentAiDefaults.exec; + const inheritedExecModel = uiExecEntry?.modelString ?? inheritedEffectiveModel; + const effectiveModel = modelValue !== INHERIT ? modelValue : inheritedExecModel; + const inheritedThinkingLabel = uiExecEntry?.thinkingLevel + ? getThinkingOptionLabel(uiExecEntry.thinkingLevel, effectiveModel) + : "Inherit"; + + return ( +
+
+
Exec as subagent
+
+ {agent.id} • {agent.scope} • {renderPolicySummary(agent)} +
+
+ Unset fields inherit from UI Exec defaults. Enabled and advisor settings stay shared + with UI Exec.
+ + setSubagentModel("exec", value)} + onThinkingChange={(value) => setSubagentThinking("exec", value)} + />
); }; @@ -840,7 +1091,6 @@ export function TasksSection() { ? "Advisor enabled (local override)." : "Advisor disabled (local override)."; const effectiveModel = modelValue !== INHERIT ? modelValue : inheritedEffectiveModel; - const allowedThinkingLevels = getThinkingPolicyForModel(effectiveModel); return (
-
-
-
Model
-
- {/* Match the Reasoning dropdown styling for inherit defaults. */} - setAgentModel(agentId, value)} - models={models} - hiddenModels={hiddenModelsForSelector} - variant="box" - className="bg-modal-bg" - /> - {modelValue !== INHERIT ? ( - - ) : null} -
-
- -
-
Reasoning
- -
-
+ setAgentModel(agentId, value)} + onThinkingChange={(value) => setAgentThinking(agentId, value)} + />
); }; @@ -1086,10 +1297,13 @@ export function TasksSection() {
) : null} - {subagents.length > 0 ? ( + {subagents.length > 0 || execSubagentAgent ? (

Sub-agents

-
{subagents.map(renderAgentDefaults)}
+
+ {execSubagentAgent ? renderExecSubagentDefaults(execSubagentAgent) : null} + {subagents.map(renderAgentDefaults)} +
) : null} diff --git a/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx new file mode 100644 index 0000000000..42979e44cf --- /dev/null +++ b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx @@ -0,0 +1,222 @@ +import type React from "react"; +import { cleanup, fireEvent, render, waitFor, within } from "@testing-library/react"; +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 { SubagentAiDefaults } from "@/common/types/tasks"; + +let apiMock: { + config: { + getConfig: ReturnType; + saveConfig: ReturnType; + }; +} | null = null; + +void mock.module("@/browser/contexts/API", () => ({ + useAPI: () => ({ api: apiMock }), +})); + +void mock.module("@/browser/contexts/WorkspaceContext", () => ({ + useWorkspaceContext: () => ({ selectedWorkspace: null }), +})); + +void mock.module("@/browser/hooks/useExperiments", () => ({ + useExperimentValue: () => false, +})); + +void mock.module("@/browser/hooks/useModelsFromSettings", () => ({ + getDefaultModel: () => "anthropic:workspace-default", + useModelsFromSettings: () => ({ + models: ["anthropic:ui-exec", "openai:subagent-model", "xai:grok-code-fast-1"], + hiddenModelsForSelector: [], + }), +})); + +void mock.module("@/browser/components/Tooltip/Tooltip", () => ({ + Tooltip: (props: { children: React.ReactNode }) => <>{props.children}, + TooltipTrigger: (props: { children: React.ReactNode }) => <>{props.children}, + TooltipContent: (props: { children: React.ReactNode }) =>
{props.children}
, +})); + +void mock.module("@/browser/components/ModelSelector/ModelSelector", () => ({ + ModelSelector: (props: { + value: string; + emptyLabel?: string; + onChange: (value: string) => void; + models: string[]; + }) => ( + + ), +})); + +void mock.module("@/browser/components/SelectPrimitive/SelectPrimitive", () => ({ + Select: (props: { + value: string; + onValueChange: (value: string) => void; + children: React.ReactNode; + }) => ( + + ), + SelectContent: (props: { children: React.ReactNode }) => <>{props.children}, + SelectItem: (props: { value: string; children: React.ReactNode }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, +})); + +import { TasksSection } from "./TasksSection"; + +interface RenderTasksSectionOptions { + agentAiDefaults?: AgentAiDefaults; + subagentAiDefaults?: SubagentAiDefaults; +} + +function renderTasksSection(options: RenderTasksSectionOptions = {}) { + const saveConfig = mock(() => Promise.resolve(undefined)); + const getConfig = mock(() => + Promise.resolve({ + taskSettings: {}, + agentAiDefaults: options.agentAiDefaults ?? {}, + subagentAiDefaults: options.subagentAiDefaults ?? {}, + }) + ); + + apiMock = { + config: { + getConfig, + saveConfig, + }, + }; + + const view = render(); + return { ...view, getConfig, saveConfig }; +} + +function getExecSubagentRow(view: ReturnType): HTMLElement { + return view.getByRole("group", { name: "Exec as subagent defaults" }); +} + +function getLatestSavePayload(saveConfig: ReturnType) { + const calls = saveConfig.mock.calls; + expect(calls.length).toBeGreaterThan(0); + return calls[calls.length - 1][0] as { + agentAiDefaults: AgentAiDefaults; + subagentAiDefaults: SubagentAiDefaults; + }; +} + +describe("TasksSection Exec subagent defaults", () => { + let restoreDom: (() => void) | null = null; + + beforeEach(() => { + restoreDom = installDom(); + apiMock = null; + }); + + afterEach(() => { + cleanup(); + apiMock = null; + restoreDom?.(); + restoreDom = null; + }); + + test("renders a distinct Exec subagent row", async () => { + const view = renderTasksSection(); + + expect(await view.findByText("Exec as subagent")).toBeTruthy(); + expect(getExecSubagentRow(view)).toBeTruthy(); + expect(view.getByText("UI agents")).toBeTruthy(); + expect(view.getByText("Sub-agents")).toBeTruthy(); + }); + + test("unset Exec subagent defaults inherit from UI Exec", async () => { + const view = renderTasksSection({ + agentAiDefaults: { + exec: { modelString: "anthropic:ui-exec", thinkingLevel: "medium" }, + }, + subagentAiDefaults: {}, + }); + + const row = await view.findByRole("group", { name: "Exec as subagent 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(); + }); + + test("setting only the Exec subagent model writes only the sparse subagent model", async () => { + const view = renderTasksSection({ + agentAiDefaults: { + exec: { modelString: "anthropic:ui-exec", thinkingLevel: "medium" }, + }, + subagentAiDefaults: {}, + }); + const row = await view.findByRole("group", { name: "Exec as subagent defaults" }); + + fireEvent.change(within(row).getByLabelText("Model"), { + target: { value: "openai:subagent-model" }, + }); + + await waitFor(() => expect(view.saveConfig).toHaveBeenCalled()); + const payload = getLatestSavePayload(view.saveConfig); + + expect(payload.subagentAiDefaults).toEqual({ + exec: { modelString: "openai:subagent-model" }, + }); + expect(payload.agentAiDefaults.exec).toEqual({ + modelString: "anthropic:ui-exec", + thinkingLevel: "medium", + }); + expect(payload.subagentAiDefaults.exec?.thinkingLevel).toBeUndefined(); + }); + + test("resetting one Exec subagent field removes only that field", async () => { + const view = renderTasksSection({ + subagentAiDefaults: { + exec: { modelString: "openai:subagent-model", thinkingLevel: "high" }, + }, + }); + const row = await view.findByRole("group", { name: "Exec as subagent defaults" }); + + fireEvent.click(within(row).getAllByRole("button", { name: "Inherit from UI Exec" })[0]); + + await waitFor(() => expect(view.saveConfig).toHaveBeenCalled()); + const payload = getLatestSavePayload(view.saveConfig); + + expect(payload.subagentAiDefaults).toEqual({ exec: { thinkingLevel: "high" } }); + }); + + test("resetting the last Exec subagent field removes the exec entry", async () => { + const view = renderTasksSection({ + subagentAiDefaults: { + exec: { modelString: "openai:subagent-model" }, + }, + }); + const row = await view.findByRole("group", { name: "Exec as subagent defaults" }); + + fireEvent.click(within(row).getByRole("button", { name: "Inherit from UI Exec" })); + + await waitFor(() => expect(view.saveConfig).toHaveBeenCalled()); + const payload = getLatestSavePayload(view.saveConfig); + + expect(payload.subagentAiDefaults).toEqual({}); + }); +}); From fdcd8850d609bd20c8946e8c5917faa67cc4c5f4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:50:56 +0000 Subject: [PATCH 03/28] =?UTF-8?q?=F0=9F=A4=96=20docs:=20explain=20agent=20?= =?UTF-8?q?AI=20defaults=20by=20run=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document UI and subagent AI default resolution for agents. --- docs/agents/index.mdx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/agents/index.mdx b/docs/agents/index.mdx index ce30baee01..6cdfa9914a 100644 --- a/docs/agents/index.mdx +++ b/docs/agents/index.mdx @@ -185,6 +185,17 @@ task({ Only agents with `subagent.runnable: true` can be used this way. +### Run-context AI defaults + +The same agent identity can use different default model and thinking settings depending on how it runs: + +- **UI defaults** (`agentAiDefaults`) apply when you select the agent directly in the UI, such as choosing Exec in the chat input. +- **Subagent defaults** (`subagentAiDefaults`) apply when that agent is spawned through the `task` tool. + +Subagent defaults inherit from UI defaults per field. If the subagent model is unset, Mux uses the matching UI agent model; if subagent thinking is unset, Mux uses the matching UI agent thinking level. You can override one subagent field and keep the other inherited. + +Mux resolves the subagent model and thinking level when the `task` call creates the child workspace. Those resolved values are stored with that child workspace, so changing defaults later affects future subagent tasks only. + ## Examples ### Security Audit Agent From 467937a7786ff2d7e7abdbdbe81e5f5dc628d274 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:50:29 +0000 Subject: [PATCH 04/28] chore: regenerate built-in skill content for agents docs --- .../agentSkills/builtInSkillContent.generated.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 74887797ce..b7549c5e50 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -825,6 +825,17 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "Only agents with `subagent.runnable: true` can be used this way.", "", + "### Run-context AI defaults", + "", + "The same agent identity can use different default model and thinking settings depending on how it runs:", + "", + "- **UI defaults** (`agentAiDefaults`) apply when you select the agent directly in the UI, such as choosing Exec in the chat input.", + "- **Subagent defaults** (`subagentAiDefaults`) apply when that agent is spawned through the `task` tool.", + "", + "Subagent defaults inherit from UI defaults per field. If the subagent model is unset, Mux uses the matching UI agent model; if subagent thinking is unset, Mux uses the matching UI agent thinking level. You can override one subagent field and keep the other inherited.", + "", + "Mux resolves the subagent model and thinking level when the `task` call creates the child workspace. Those resolved values are stored with that child workspace, so changing defaults later affects future subagent tasks only.", + "", "## Examples", "", "### Security Audit Agent", From 471d524a6ab574fde06826497c8941aa16c9b333 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 12:03:22 +0000 Subject: [PATCH 05/28] refactor(settings): rename "Exec as subagent" to "Exec" --- .../features/Settings/Sections/TasksSection.tsx | 4 ++-- .../Settings/Sections/TasksSection.ui.test.tsx | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/browser/features/Settings/Sections/TasksSection.tsx b/src/browser/features/Settings/Sections/TasksSection.tsx index 48cf075caf..efb27eb739 100644 --- a/src/browser/features/Settings/Sections/TasksSection.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.tsx @@ -1045,11 +1045,11 @@ export function TasksSection() {
-
Exec as subagent
+
Exec
{agent.id} • {agent.scope} • {renderPolicySummary(agent)}
diff --git a/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx index 42979e44cf..cf0f429840 100644 --- a/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx @@ -111,7 +111,7 @@ function renderTasksSection(options: RenderTasksSectionOptions = {}) { } function getExecSubagentRow(view: ReturnType): HTMLElement { - return view.getByRole("group", { name: "Exec as subagent defaults" }); + return view.getByRole("group", { name: "Exec defaults" }); } function getLatestSavePayload(saveConfig: ReturnType) { @@ -141,8 +141,8 @@ describe("TasksSection Exec subagent defaults", () => { test("renders a distinct Exec subagent row", async () => { const view = renderTasksSection(); - expect(await view.findByText("Exec as subagent")).toBeTruthy(); - expect(getExecSubagentRow(view)).toBeTruthy(); + await view.findByRole("group", { name: "Exec defaults" }); + expect(within(getExecSubagentRow(view)).getByText("Exec")).toBeTruthy(); expect(view.getByText("UI agents")).toBeTruthy(); expect(view.getByText("Sub-agents")).toBeTruthy(); }); @@ -155,7 +155,7 @@ describe("TasksSection Exec subagent defaults", () => { subagentAiDefaults: {}, }); - const row = await view.findByRole("group", { name: "Exec as subagent defaults" }); + 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(); @@ -169,7 +169,7 @@ describe("TasksSection Exec subagent defaults", () => { }, subagentAiDefaults: {}, }); - const row = await view.findByRole("group", { name: "Exec as subagent defaults" }); + const row = await view.findByRole("group", { name: "Exec defaults" }); fireEvent.change(within(row).getByLabelText("Model"), { target: { value: "openai:subagent-model" }, @@ -194,7 +194,7 @@ describe("TasksSection Exec subagent defaults", () => { exec: { modelString: "openai:subagent-model", thinkingLevel: "high" }, }, }); - const row = await view.findByRole("group", { name: "Exec as subagent defaults" }); + const row = await view.findByRole("group", { name: "Exec defaults" }); fireEvent.click(within(row).getAllByRole("button", { name: "Inherit from UI Exec" })[0]); @@ -210,7 +210,7 @@ describe("TasksSection Exec subagent defaults", () => { exec: { modelString: "openai:subagent-model" }, }, }); - const row = await view.findByRole("group", { name: "Exec as subagent defaults" }); + const row = await view.findByRole("group", { name: "Exec defaults" }); fireEvent.click(within(row).getByRole("button", { name: "Inherit from UI Exec" })); From 00084db91630fc5d8ed54f4ca5953ad457dc4855 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 12:26:38 +0000 Subject: [PATCH 06/28] =?UTF-8?q?=F0=9F=A4=96=20refactor(config):=20extrac?= =?UTF-8?q?t=20legacy=20subagent=20mirror=20helpers=20to=20common?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/types/tasks.ts | 27 +++++++++++++++++++++++++++ src/node/config.ts | 26 ++------------------------ src/node/orpc/router.ts | 27 +-------------------------- 3 files changed, 30 insertions(+), 50 deletions(-) diff --git a/src/common/types/tasks.ts b/src/common/types/tasks.ts index 9dd4fc41be..fb804ba83d 100644 --- a/src/common/types/tasks.ts +++ b/src/common/types/tasks.ts @@ -30,6 +30,16 @@ export const DEFAULT_TASK_SETTINGS: TaskSettings = { planSubagentDefaultsToOrchestrator: false, }; +export const AGENT_DEFAULT_IDS_EXCLUDED_FROM_LEGACY_SUBAGENTS: ReadonlySet = new Set([ + "plan", + "exec", + "compact", +]); + +export function shouldMirrorAgentDefaultToLegacySubagent(agentId: string): boolean { + return !AGENT_DEFAULT_IDS_EXCLUDED_FROM_LEGACY_SUBAGENTS.has(agentId); +} + export function normalizeSubagentAiDefaults(raw: unknown): SubagentAiDefaults { const record = raw && typeof raw === "object" ? (raw as Record) : ({} as const); @@ -62,6 +72,23 @@ export function normalizeSubagentAiDefaults(raw: unknown): SubagentAiDefaults { return result; } +export function deriveLegacySubagentAiDefaultsFromAgentDefaults(params: { + agentAiDefaults: Record; + preservedExec?: SubagentAiDefaultsEntry; +}): SubagentAiDefaults { + const legacySubagentDefaultsRaw: Record = {}; + for (const [agentId, entry] of Object.entries(params.agentAiDefaults)) { + if (!shouldMirrorAgentDefaultToLegacySubagent(agentId)) continue; + legacySubagentDefaultsRaw[agentId] = entry; + } + + const legacySubagentDefaults = normalizeSubagentAiDefaults(legacySubagentDefaultsRaw); + if (params.preservedExec) { + legacySubagentDefaults.exec = params.preservedExec; + } + return legacySubagentDefaults; +} + function clampInt(value: unknown, fallback: number, min: number, max: number): number { if (typeof value !== "number" || !Number.isFinite(value)) { return fallback; diff --git a/src/node/config.ts b/src/node/config.ts index 1a7e7ba501..5e2d0eb864 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -27,8 +27,10 @@ import type { } from "@/common/config/schemas"; import { DEFAULT_TASK_SETTINGS, + deriveLegacySubagentAiDefaultsFromAgentDefaults, normalizeSubagentAiDefaults, normalizeTaskSettings, + shouldMirrorAgentDefaultToLegacySubagent, } from "@/common/types/tasks"; import { isLayoutPresetsConfigEmpty, normalizeLayoutPresetsConfig } from "@/common/types/uiLayouts"; import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults"; @@ -301,15 +303,8 @@ function normalizeAiDefaultsModelStrings; -type SubagentAiDefaultsConfigEntry = SubagentAiDefaultsConfig[string]; type AgentAiDefaultsConfig = NonNullable; -const AGENT_DEFAULT_IDS_EXCLUDED_FROM_LEGACY_SUBAGENTS = new Set(["plan", "exec", "compact"]); - -function shouldMirrorAgentDefaultToLegacySubagent(agentId: string): boolean { - return !AGENT_DEFAULT_IDS_EXCLUDED_FROM_LEGACY_SUBAGENTS.has(agentId); -} - function normalizeConfigMigrations(value: unknown): AppConfigMigrations { if (!value || typeof value !== "object" || Array.isArray(value)) { return {}; @@ -381,23 +376,6 @@ function removeMirroredExecSubagentDefaults(params: { return { subagentAiDefaults, modified: true }; } -function deriveLegacySubagentAiDefaultsFromAgentDefaults(params: { - agentAiDefaults: Record; - preservedExec?: SubagentAiDefaultsConfigEntry; -}): SubagentAiDefaultsConfig { - const legacySubagentDefaultsRaw: Record = {}; - for (const [agentId, entry] of Object.entries(params.agentAiDefaults)) { - if (!shouldMirrorAgentDefaultToLegacySubagent(agentId)) continue; - legacySubagentDefaultsRaw[agentId] = entry; - } - - const legacySubagentDefaults = normalizeSubagentAiDefaults(legacySubagentDefaultsRaw); - if (params.preservedExec) { - legacySubagentDefaults.exec = params.preservedExec; - } - return legacySubagentDefaults; -} - function parseOptionalPort(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) { return undefined; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index db6c7e6282..c83d3c29d8 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -49,6 +49,7 @@ import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults"; import { isValidModelFormat, normalizeSelectedModel } from "@/common/utils/ai/models"; import { DEFAULT_TASK_SETTINGS, + deriveLegacySubagentAiDefaultsFromAgentDefaults, normalizeSubagentAiDefaults, normalizeTaskSettings, } from "@/common/types/tasks"; @@ -100,32 +101,6 @@ const RAW_QUERY_USER_ERROR_PATTERNS = [ /string literals cannot be used as table sources/i, ] as const; -const AGENT_DEFAULT_IDS_EXCLUDED_FROM_LEGACY_SUBAGENTS = new Set(["plan", "exec", "compact"]); - -type NormalizedSubagentAiDefaults = ReturnType; -type NormalizedSubagentAiDefaultsEntry = NormalizedSubagentAiDefaults[string]; - -function shouldMirrorAgentDefaultToLegacySubagent(agentId: string): boolean { - return !AGENT_DEFAULT_IDS_EXCLUDED_FROM_LEGACY_SUBAGENTS.has(agentId); -} - -function deriveLegacySubagentAiDefaultsFromAgentDefaults(params: { - agentAiDefaults: Record; - preservedExec?: NormalizedSubagentAiDefaultsEntry; -}): NormalizedSubagentAiDefaults { - const legacySubagentDefaultsRaw: Record = {}; - for (const [agentId, entry] of Object.entries(params.agentAiDefaults)) { - if (!shouldMirrorAgentDefaultToLegacySubagent(agentId)) continue; - legacySubagentDefaultsRaw[agentId] = entry; - } - - const legacySubagentDefaults = normalizeSubagentAiDefaults(legacySubagentDefaultsRaw); - if (params.preservedExec) { - legacySubagentDefaults.exec = params.preservedExec; - } - return legacySubagentDefaults; -} - function shouldExposeRawQueryError(error: unknown): boolean { const message = getErrorMessage(error); return RAW_QUERY_USER_ERROR_PATTERNS.some((pattern) => pattern.test(message)); From d4ad14b46e9e6afda62b800e2d34922d7344aa92 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 12:27:53 +0000 Subject: [PATCH 07/28] =?UTF-8?q?=F0=9F=A4=96=20refactor(types):=20remove?= =?UTF-8?q?=20dead=20normalized=20agent=20id=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/types/agentAiDefaults.ts | 2 -- src/common/types/tasks.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/common/types/agentAiDefaults.ts b/src/common/types/agentAiDefaults.ts index aa05b1f88b..3416184738 100644 --- a/src/common/types/agentAiDefaults.ts +++ b/src/common/types/agentAiDefaults.ts @@ -14,12 +14,10 @@ export function normalizeAgentAiDefaults(raw: unknown): AgentAiDefaults { const result: AgentAiDefaults = {}; for (const [agentIdRaw, entryRaw] of Object.entries(record)) { - const normalizedRawAgentId = agentIdRaw.trim().toLowerCase(); const agentId = normalizeAgentId(agentIdRaw, ""); if (!agentId) continue; if (!AgentIdSchema.safeParse(agentId).success) continue; if (!entryRaw || typeof entryRaw !== "object") continue; - if (normalizedRawAgentId !== agentId && result[agentId] != null) continue; const entry = entryRaw as Record; diff --git a/src/common/types/tasks.ts b/src/common/types/tasks.ts index fb804ba83d..18df9dd821 100644 --- a/src/common/types/tasks.ts +++ b/src/common/types/tasks.ts @@ -46,12 +46,10 @@ export function normalizeSubagentAiDefaults(raw: unknown): SubagentAiDefaults { const result: SubagentAiDefaults = {}; for (const [agentTypeRaw, entryRaw] of Object.entries(record)) { - const normalizedRawAgentId = agentTypeRaw.trim().toLowerCase(); const agentType = normalizeAgentId(agentTypeRaw, ""); if (!agentType) continue; if (!AgentIdSchema.safeParse(agentType).success) continue; if (!entryRaw || typeof entryRaw !== "object") continue; - if (normalizedRawAgentId !== agentType && result[agentType] != null) continue; const entry = entryRaw as Record; From f90be84934a21c8a7568e1f340b71db9ded2e167 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 12:28:13 +0000 Subject: [PATCH 08/28] =?UTF-8?q?=F0=9F=A4=96=20tests(stories):=20assert?= =?UTF-8?q?=20dual=20Exec=20rows=20in=20TasksSection=20story?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/Settings/Sections/TasksSection.stories.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/browser/features/Settings/Sections/TasksSection.stories.tsx b/src/browser/features/Settings/Sections/TasksSection.stories.tsx index b41ac0b9d4..7b7d1bbe5d 100644 --- a/src/browser/features/Settings/Sections/TasksSection.stories.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.stories.tsx @@ -36,7 +36,11 @@ export const Tasks: Story = { await canvas.findByRole("heading", { name: /Internal/i }); await canvas.findAllByText(/^Plan$/i); - await canvas.findAllByText(/^Exec$/i); + const execMatches = await canvas.findAllByText(/^Exec$/i); + if (execMatches.length !== 2) { + throw new Error(`Expected 2 Exec rows (UI + sub-agent), got ${execMatches.length}`); + } + await canvas.findByRole("group", { name: "Exec defaults" }); await canvas.findAllByText(/^Explore$/i); await canvas.findAllByText(/^Compact$/i); From 1711e5f5b932a4ce6b0eeeee04f1dd190f8e37ca Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 12:58:30 +0000 Subject: [PATCH 09/28] =?UTF-8?q?=F0=9F=A4=96=20fix(task-tool):=20stop=20f?= =?UTF-8?q?orwarding=20parent=20MUX=5FMODEL=5FSTRING=20to=20sub-agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/tools/task.test.ts | 30 ++++++++++++++++++++++++++++ src/node/services/tools/task.ts | 9 --------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/node/services/tools/task.test.ts b/src/node/services/tools/task.test.ts index a2336194de..d5c8f21eba 100644 --- a/src/node/services/tools/task.test.ts +++ b/src/node/services/tools/task.test.ts @@ -98,6 +98,36 @@ describe("task tool", () => { expectQueuedOrRunningTaskToolResult(result, { status: "queued", taskId: "child-task" }); }); + it("does not propagate parent MUX_MODEL_STRING/MUX_THINKING_LEVEL to spawned sub-agent", async () => { + using tempDir = new TestTempDir("test-task-tool-parent-ai-env"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const create = mock((_: { modelString?: unknown; thinkingLevel?: unknown }) => + Ok({ taskId: "child-task", kind: "agent" as const, status: "queued" as const }) + ); + const waitForAgentReport = mock(() => Promise.resolve({ reportMarkdown: "ignored" })); + const taskService = { create, waitForAgentReport } as unknown as TaskService; + + const tool = createTaskTool({ + ...baseConfig, + muxEnv: { MUX_MODEL_STRING: "openai:gpt-4o-mini", MUX_THINKING_LEVEL: "high" }, + taskService, + }); + + await Promise.resolve( + tool.execute!( + { subagent_type: "explore", prompt: "do it", title: "Child task", run_in_background: true }, + mockToolCallOptions + ) + ); + + expect(create).toHaveBeenCalledTimes(1); + const createArgs = create.mock.calls[0]?.[0]; + expect(createArgs).toBeDefined(); + expect(createArgs?.modelString).toBeUndefined(); + expect(createArgs?.thinkingLevel).toBeUndefined(); + }); + it("spawns best-of-n background tasks with shared grouping metadata", async () => { using tempDir = new TestTempDir("test-task-tool-best-of-background"); const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); diff --git a/src/node/services/tools/task.ts b/src/node/services/tools/task.ts index 3e4d06ee84..be391f7aac 100644 --- a/src/node/services/tools/task.ts +++ b/src/node/services/tools/task.ts @@ -3,7 +3,6 @@ import { randomUUID } from "node:crypto"; import { tool } from "ai"; import type { z } from "zod"; -import { coerceThinkingLevel } from "@/common/types/thinking"; import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; import { TaskToolResultSchema, @@ -286,12 +285,6 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { throw new Error('In the plan agent you may only spawn agentId: "explore" tasks.'); } - const modelString = - config.muxEnv && typeof config.muxEnv.MUX_MODEL_STRING === "string" - ? config.muxEnv.MUX_MODEL_STRING - : undefined; - const thinkingLevel = coerceThinkingLevel(config.muxEnv?.MUX_THINKING_LEVEL); - const createdTasks: SpawnedTaskInfo[] = []; for (const launch of taskGroupLaunches) { if (abortSignal?.aborted) { @@ -306,8 +299,6 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { agentType: requestedAgentId, prompt: launch.prompt, title, - modelString, - thinkingLevel, experiments: config.experiments, bestOf: taskGroupId != null From 3b647e85eae7e512185b0e5ef8cb14c5400b7c54 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 13:38:29 +0000 Subject: [PATCH 10/28] =?UTF-8?q?=F0=9F=A4=96=20fix(stories):=20drop=20bri?= =?UTF-8?q?ttle=20Exec=20row=20count=20assertion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/Settings/Sections/TasksSection.stories.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/browser/features/Settings/Sections/TasksSection.stories.tsx b/src/browser/features/Settings/Sections/TasksSection.stories.tsx index 7b7d1bbe5d..724e7e6533 100644 --- a/src/browser/features/Settings/Sections/TasksSection.stories.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.stories.tsx @@ -36,10 +36,7 @@ export const Tasks: Story = { await canvas.findByRole("heading", { name: /Internal/i }); await canvas.findAllByText(/^Plan$/i); - const execMatches = await canvas.findAllByText(/^Exec$/i); - if (execMatches.length !== 2) { - throw new Error(`Expected 2 Exec rows (UI + sub-agent), got ${execMatches.length}`); - } + await canvas.findAllByText(/^Exec$/i); await canvas.findByRole("group", { name: "Exec defaults" }); await canvas.findAllByText(/^Explore$/i); await canvas.findAllByText(/^Compact$/i); From 32c22d88679f93cb0ce9d7d03ea7ad33f96dbad7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 13:41:21 +0000 Subject: [PATCH 11/28] =?UTF-8?q?=F0=9F=A4=96=20fix(settings):=20clear=20m?= =?UTF-8?q?irrored=20subagent=20default=20when=20agent=20entry=20is=20rese?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/Sections/TasksSection.tsx | 4 ++- .../Sections/TasksSection.ui.test.tsx | 36 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/browser/features/Settings/Sections/TasksSection.tsx b/src/browser/features/Settings/Sections/TasksSection.tsx index efb27eb739..33f6b13f98 100644 --- a/src/browser/features/Settings/Sections/TasksSection.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.tsx @@ -36,6 +36,7 @@ import { TASK_SETTINGS_LIMITS, isPlanSubagentExecutorRouting, normalizeSubagentAiDefaults, + shouldMirrorAgentDefaultToLegacySubagent, normalizeTaskSettings, type PlanSubagentExecutorRouting, type SubagentAiDefaults, @@ -125,12 +126,13 @@ function getSubagentAiDefaultsForSave( const agentIds = new Set([...Object.keys(agentAiDefaults), ...Object.keys(subagentAiDefaults)]); for (const agentId of agentIds) { - if (agentId === "plan" || agentId === "exec" || agentId === "compact") { + if (!shouldMirrorAgentDefaultToLegacySubagent(agentId)) { continue; } const entry = agentAiDefaults[agentId]; if (!entry) { + delete next[agentId]; continue; } diff --git a/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx index cf0f429840..0108a3030a 100644 --- a/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx @@ -27,7 +27,7 @@ void mock.module("@/browser/hooks/useExperiments", () => ({ void mock.module("@/browser/hooks/useModelsFromSettings", () => ({ getDefaultModel: () => "anthropic:workspace-default", useModelsFromSettings: () => ({ - models: ["anthropic:ui-exec", "openai:subagent-model", "xai:grok-code-fast-1"], + models: ["anthropic:foo", "anthropic:ui-exec", "openai:subagent-model", "xai:grok-code-fast-1"], hiddenModelsForSelector: [], }), })); @@ -114,6 +114,18 @@ function getExecSubagentRow(view: ReturnType): HTMLEl return view.getByRole("group", { name: "Exec defaults" }); } +function getAgentCardByName( + view: ReturnType, + name: string +): HTMLElement { + const title = view.getByText(name); + const card = title.closest(".rounded-md"); + if (!(card instanceof HTMLElement)) { + throw new Error(`Could not find ${name} agent card`); + } + return card; +} + function getLatestSavePayload(saveConfig: ReturnType) { const calls = saveConfig.mock.calls; expect(calls.length).toBeGreaterThan(0); @@ -147,6 +159,28 @@ describe("TasksSection Exec subagent defaults", () => { expect(view.getByText("Sub-agents")).toBeTruthy(); }); + test("resetting a mirrored subagent model removes the stale mirrored entry", async () => { + const view = renderTasksSection({ + agentAiDefaults: { + explore: { modelString: "anthropic:foo" }, + }, + subagentAiDefaults: { + explore: { modelString: "anthropic:foo" }, + }, + }); + + await view.findByText("Explore"); + fireEvent.click( + within(getAgentCardByName(view, "Explore")).getByRole("button", { name: "Reset" }) + ); + + await waitFor(() => expect(view.saveConfig).toHaveBeenCalled()); + const payload = getLatestSavePayload(view.saveConfig); + + expect(payload.agentAiDefaults.explore).toBeUndefined(); + expect(payload.subagentAiDefaults.explore).toBeUndefined(); + }); + test("unset Exec subagent defaults inherit from UI Exec", async () => { const view = renderTasksSection({ agentAiDefaults: { From 30cf9bd50e5313ca5700c852438c8c3da40350ec Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 14:19:42 +0000 Subject: [PATCH 12/28] =?UTF-8?q?=F0=9F=A4=96=20fix(chat-input):=20show=20?= =?UTF-8?q?workspace=20AI=20settings=20on=20first=20paint=20to=20prevent?= =?UTF-8?q?=20flicker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/contexts/ThinkingContext.test.tsx | 204 ++++++++++++++++++ src/browser/contexts/ThinkingContext.tsx | 26 ++- src/browser/hooks/useSendMessageOptions.ts | 16 +- src/browser/utils/workspaceAiSettingsSync.ts | 13 ++ 4 files changed, 248 insertions(+), 11 deletions(-) diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index 3d90459c70..84e3f91232 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -4,13 +4,20 @@ import { act, cleanup, render, waitFor } from "@testing-library/react"; import React from "react"; import { ThinkingProvider } from "./ThinkingContext"; import { APIProvider, type APIClient } from "@/browser/contexts/API"; +import { AgentProvider, type AgentContextValue } from "@/browser/contexts/AgentContext"; +import { ProjectProvider } from "@/browser/contexts/ProjectContext"; +import { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsContext"; +import { RouterProvider } from "@/browser/contexts/RouterContext"; +import { useWorkspaceContext, WorkspaceProvider } from "@/browser/contexts/WorkspaceContext"; import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { getModelKey, getProjectScopeId, getThinkingLevelByModelKey, getThinkingLevelKey, } from "@/common/constants/storage"; +import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import type { RecursivePartial } from "@/browser/testUtils"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; @@ -43,10 +50,133 @@ const TestComponent: React.FC = (props) => { ); }; +const agentContextValue: AgentContextValue = { + agentId: "exec", + setAgentId: () => undefined, + currentAgent: undefined, + agents: [], + loaded: true, + loadFailed: false, + refresh: () => Promise.resolve(), + refreshing: false, + disableWorkspaceAgents: false, + setDisableWorkspaceAgents: () => undefined, +}; + +const SendOptionsComponent: React.FC<{ workspaceId: string }> = (props) => { + const options = useSendMessageOptions(props.workspaceId); + return
{options.baseModel}
; +}; + function renderWithAPI(children: React.ReactNode) { return render({children}); } +function createWorkspaceMetadata( + overrides: Partial & Pick +): FrontendWorkspaceMetadata { + return { + projectPath: "/tmp/project", + projectName: "project", + name: "main", + namedWorkspacePath: "/tmp/project/main", + createdAt: "2026-01-01T00:00:00.000Z", + runtimeConfig: { type: "local", srcBaseDir: "/tmp/.mux/src" }, + ...overrides, + }; +} + +function createEmptyAsyncIterable(): AsyncIterable { + return { + async *[Symbol.asyncIterator](): AsyncIterator { + await Promise.resolve(); + if (Date.now() < 0) yield undefined as T; + }, + }; +} + +function WorkspaceMetadataGate(props: { + workspaceId: string; + modelOverride?: string | null; + thinkingOverride?: "off" | null; + children: React.ReactNode; +}) { + const { workspaceMetadata } = useWorkspaceContext(); + if (!workspaceMetadata.has(props.workspaceId)) { + return null; + } + + if (props.modelOverride !== undefined) { + if (props.modelOverride == null) { + window.localStorage.removeItem(getModelKey(props.workspaceId)); + } else { + updatePersistedState(getModelKey(props.workspaceId), props.modelOverride); + } + } + + if (props.thinkingOverride == null) { + window.localStorage.removeItem(getThinkingLevelKey(props.workspaceId)); + } else { + updatePersistedState(getThinkingLevelKey(props.workspaceId), props.thinkingOverride); + } + + return <>{props.children}; +} + +function createWorkspaceClient(metadata: FrontendWorkspaceMetadata): APIClient { + return { + workspace: { + list: () => Promise.resolve([metadata]), + onMetadata: () => Promise.resolve(createEmptyAsyncIterable()), + onChat: () => Promise.resolve(createEmptyAsyncIterable()), + getSessionUsage: () => Promise.resolve(undefined), + updateAgentAISettings: mock(() => + Promise.resolve({ success: true as const, data: undefined }) + ), + activity: { + list: () => Promise.resolve({}), + subscribe: () => Promise.resolve(createEmptyAsyncIterable()), + }, + truncateHistory: () => Promise.resolve({ success: true as const, data: undefined }), + interruptStream: () => Promise.resolve({ success: true as const, data: undefined }), + }, + projects: { + list: () => Promise.resolve([]), + listBranches: () => Promise.resolve({ branches: ["main"], recommendedTrunk: "main" }), + secrets: { + get: () => Promise.resolve([]), + }, + }, + } as unknown as APIClient; +} + +function renderWithWorkspaceMetadata(props: { + workspaceId: string; + metadata: FrontendWorkspaceMetadata; + modelOverride?: string | null; + thinkingOverride?: "off" | null; + children: React.ReactNode; +}) { + const client = createWorkspaceClient(props.metadata); + return render( + + + + + + {props.children} + + + + + + ); +} + describe("ThinkingContext", () => { // Make getDefaultModel deterministic. // (getDefaultModel reads from the global "model-default" localStorage key.) @@ -70,6 +200,80 @@ describe("ThinkingContext", () => { currentClientMock = {}; }); + test("uses metadata model before global default but keeps explicit model", async () => { + const cases = [ + { workspaceId: "ws-model-metadata", override: null, expected: "openai:gpt-5.5" }, + { + workspaceId: "ws-model-explicit", + override: "anthropic:explicit-model", + expected: "anthropic:explicit-model", + }, + ]; + + for (const testCase of cases) { + const metadata = createWorkspaceMetadata({ + id: testCase.workspaceId, + aiSettings: { model: "openai:gpt-5.5", thinkingLevel: "high" }, + }); + const view = renderWithWorkspaceMetadata({ + workspaceId: testCase.workspaceId, + metadata, + modelOverride: testCase.override, + children: ( + + + + + + + + ), + }); + + await waitFor(() => { + expect(view.getByTestId("base-model").textContent).toBe(testCase.expected); + }); + cleanup(); + } + }); + + test("uses metadata thinking before off but keeps explicit thinking", async () => { + const cases = [ + { + workspaceId: "ws-thinking-metadata", + override: null, + expected: "high:ws-thinking-metadata", + }, + { + workspaceId: "ws-thinking-explicit", + override: "off" as const, + expected: "off:ws-thinking-explicit", + }, + ]; + + for (const testCase of cases) { + const metadata = createWorkspaceMetadata({ + id: testCase.workspaceId, + aiSettings: { model: "openai:gpt-5.5", thinkingLevel: "high" }, + }); + const view = renderWithWorkspaceMetadata({ + workspaceId: testCase.workspaceId, + metadata, + thinkingOverride: testCase.override, + children: ( + + + + ), + }); + + await waitFor(() => { + expect(view.getByTestId("thinking").textContent).toBe(testCase.expected); + }); + cleanup(); + } + }); + test("switching models does not remount children", async () => { const workspaceId = "ws-1"; diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 3f5879fe04..67dc6d1272 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -21,8 +21,10 @@ import { enforceThinkingPolicy, getThinkingPolicyForModel } from "@/common/utils import { useAPI } from "@/browser/contexts/API"; import { clearPendingWorkspaceAiSettings, + getWorkspaceAiSettingsFromMetadata, markPendingWorkspaceAiSettings, } from "@/browser/utils/workspaceAiSettingsSync"; +import { useOptionalWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; @@ -50,21 +52,29 @@ function getCanonicalModelForScope(scopeId: string, fallbackModel: string): stri export const ThinkingProvider: React.FC = (props) => { const { api } = useAPI(); + const workspaceContext = useOptionalWorkspaceContext(); const defaultModel = getDefaultModel(); const scopeId = getScopeId(props.workspaceId, props.projectPath); const thinkingKey = getThinkingLevelKey(scopeId); - - // Workspace-scoped thinking. (No longer per-model.) - const [thinkingLevel, setThinkingLevelInternal] = usePersistedState( - thinkingKey, - THINKING_LEVEL_OFF, - { listener: true } + const metadataAgentId = readPersistedState( + getAgentIdKey(scopeId), + WORKSPACE_DEFAULTS.agentId + ); + const metadataSettings = getWorkspaceAiSettingsFromMetadata( + props.workspaceId ? workspaceContext?.workspaceMetadata.get(props.workspaceId) : undefined, + metadataAgentId ); + // Workspace-scoped thinking. Null means no explicit user choice has been persisted yet. + const [persistedThinkingLevel, setThinkingLevelInternal] = + usePersistedState(thinkingKey, null, { listener: true }); + const thinkingLevel = + persistedThinkingLevel ?? metadataSettings.thinkingLevel ?? THINKING_LEVEL_OFF; + // One-time migration: if the new workspace-scoped key is missing, seed from the legacy per-model key. useEffect(() => { - const existing = readPersistedState(thinkingKey, undefined); - if (existing !== undefined) { + const existing = readPersistedState(thinkingKey, undefined); + if (existing != null) { return; } diff --git a/src/browser/hooks/useSendMessageOptions.ts b/src/browser/hooks/useSendMessageOptions.ts index 3f67bf1da6..3426e7042d 100644 --- a/src/browser/hooks/useSendMessageOptions.ts +++ b/src/browser/hooks/useSendMessageOptions.ts @@ -11,6 +11,8 @@ import type { SendMessageOptions } from "@/common/orpc/types"; import { useProviderOptions } from "./useProviderOptions"; import { useExperimentOverrideValue } from "./useExperiments"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; +import { getWorkspaceAiSettingsFromMetadata } from "@/browser/utils/workspaceAiSettingsSync"; /** * Extended send options that includes both the canonical model used for backend routing @@ -28,6 +30,7 @@ export interface SendMessageOptionsWithBase extends SendMessageOptions { export function useSendMessageOptions(workspaceId: string): SendMessageOptionsWithBase { const [thinkingLevel] = useThinkingLevel(); const { agentId, disableWorkspaceAgents } = useAgent(); + const { workspaceMetadata } = useWorkspaceContext(); const { options: providerOptions } = useProviderOptions(); // Subscribe to the global default model preference so backend-seeded values apply @@ -39,7 +42,7 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptionsWi ); const defaultModel = normalizeModelPreference(defaultModelPref, WORKSPACE_DEFAULTS.model); - // Workspace-scoped model preference. If unset, fall back to the global default model. + // Workspace-scoped model preference. If unset, fall back to metadata, then global default. // Note: we intentionally *don't* pass defaultModel as the usePersistedState initialValue; // initialValue is sticky and would lock in the fallback before startup seeding. const [preferredModel] = usePersistedState(getModelKey(workspaceId), null, { @@ -59,8 +62,15 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptionsWi EXPERIMENT_IDS.EXEC_SUBAGENT_HARD_RESTART ); - // Compute base model (canonical format) for UI components - const baseModel = normalizeModelPreference(preferredModel, defaultModel); + // Prefer metadata over the global default until workspace localStorage seeding catches up. + const metadataSettings = getWorkspaceAiSettingsFromMetadata( + workspaceMetadata.get(workspaceId), + agentId + ); + const baseModel = normalizeModelPreference( + preferredModel, + metadataSettings.model ?? defaultModel + ); const options = buildSendMessageOptions({ agentId, diff --git a/src/browser/utils/workspaceAiSettingsSync.ts b/src/browser/utils/workspaceAiSettingsSync.ts index ccbd4ba1fe..5988e00d8d 100644 --- a/src/browser/utils/workspaceAiSettingsSync.ts +++ b/src/browser/utils/workspaceAiSettingsSync.ts @@ -1,10 +1,23 @@ import type { ThinkingLevel } from "@/common/types/thinking"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; interface WorkspaceAiSettingsSnapshot { model: string; thinkingLevel: ThinkingLevel; } +export function getWorkspaceAiSettingsFromMetadata( + metadata: FrontendWorkspaceMetadata | undefined, + agentId: string | undefined +): { model: string | undefined; thinkingLevel: ThinkingLevel | undefined } { + const settings = + (agentId ? metadata?.aiSettingsByAgent?.[agentId] : undefined) ?? metadata?.aiSettings; + return { + model: settings?.model, + thinkingLevel: settings?.thinkingLevel, + }; +} + const pendingAiSettingsByWorkspace = new Map(); function getPendingKey(workspaceId: string, agentId: string): string { From d471c8b5df818a8435f27b2137dc10d29c6a79a8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 14:34:02 +0000 Subject: [PATCH 13/28] =?UTF-8?q?=F0=9F=A4=96=20tests(thinking-context):?= =?UTF-8?q?=20mock=20useWorkspaceContext=20to=20stabilize=20metadata=20fal?= =?UTF-8?q?lback=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/contexts/ThinkingContext.test.tsx | 166 +++++------------- 1 file changed, 45 insertions(+), 121 deletions(-) diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index 84e3f91232..110b7ee9d6 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -2,15 +2,23 @@ import { GlobalWindow } from "happy-dom"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import React from "react"; +import type { APIClient } from "@/browser/contexts/API"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { RecursivePartial } from "@/browser/testUtils"; + +let currentClientMock: RecursivePartial = {}; +let metadataMap = new Map(); + +void mock.module("@/browser/contexts/WorkspaceContext", () => ({ + useWorkspaceContext: () => ({ workspaceMetadata: metadataMap }), + useOptionalWorkspaceContext: () => ({ workspaceMetadata: metadataMap }), +})); + import { ThinkingProvider } from "./ThinkingContext"; -import { APIProvider, type APIClient } from "@/browser/contexts/API"; +import { APIProvider } from "@/browser/contexts/API"; import { AgentProvider, type AgentContextValue } from "@/browser/contexts/AgentContext"; -import { ProjectProvider } from "@/browser/contexts/ProjectContext"; import { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsContext"; -import { RouterProvider } from "@/browser/contexts/RouterContext"; -import { useWorkspaceContext, WorkspaceProvider } from "@/browser/contexts/WorkspaceContext"; import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel"; -import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { getModelKey, getProjectScopeId, @@ -18,11 +26,8 @@ import { getThinkingLevelKey, } from "@/common/constants/storage"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; -import type { RecursivePartial } from "@/browser/testUtils"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; -let currentClientMock: RecursivePartial = {}; - // Setup basic DOM environment for testing-library const dom = new GlobalWindow(); /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ @@ -86,95 +91,8 @@ function createWorkspaceMetadata( }; } -function createEmptyAsyncIterable(): AsyncIterable { - return { - async *[Symbol.asyncIterator](): AsyncIterator { - await Promise.resolve(); - if (Date.now() < 0) yield undefined as T; - }, - }; -} - -function WorkspaceMetadataGate(props: { - workspaceId: string; - modelOverride?: string | null; - thinkingOverride?: "off" | null; - children: React.ReactNode; -}) { - const { workspaceMetadata } = useWorkspaceContext(); - if (!workspaceMetadata.has(props.workspaceId)) { - return null; - } - - if (props.modelOverride !== undefined) { - if (props.modelOverride == null) { - window.localStorage.removeItem(getModelKey(props.workspaceId)); - } else { - updatePersistedState(getModelKey(props.workspaceId), props.modelOverride); - } - } - - if (props.thinkingOverride == null) { - window.localStorage.removeItem(getThinkingLevelKey(props.workspaceId)); - } else { - updatePersistedState(getThinkingLevelKey(props.workspaceId), props.thinkingOverride); - } - - return <>{props.children}; -} - -function createWorkspaceClient(metadata: FrontendWorkspaceMetadata): APIClient { - return { - workspace: { - list: () => Promise.resolve([metadata]), - onMetadata: () => Promise.resolve(createEmptyAsyncIterable()), - onChat: () => Promise.resolve(createEmptyAsyncIterable()), - getSessionUsage: () => Promise.resolve(undefined), - updateAgentAISettings: mock(() => - Promise.resolve({ success: true as const, data: undefined }) - ), - activity: { - list: () => Promise.resolve({}), - subscribe: () => Promise.resolve(createEmptyAsyncIterable()), - }, - truncateHistory: () => Promise.resolve({ success: true as const, data: undefined }), - interruptStream: () => Promise.resolve({ success: true as const, data: undefined }), - }, - projects: { - list: () => Promise.resolve([]), - listBranches: () => Promise.resolve({ branches: ["main"], recommendedTrunk: "main" }), - secrets: { - get: () => Promise.resolve([]), - }, - }, - } as unknown as APIClient; -} - -function renderWithWorkspaceMetadata(props: { - workspaceId: string; - metadata: FrontendWorkspaceMetadata; - modelOverride?: string | null; - thinkingOverride?: "off" | null; - children: React.ReactNode; -}) { - const client = createWorkspaceClient(props.metadata); - return render( - - - - - - {props.children} - - - - - - ); +function setWorkspaceMetadata(metadata: FrontendWorkspaceMetadata) { + metadataMap = new Map([[metadata.id, metadata]]); } describe("ThinkingContext", () => { @@ -191,12 +109,14 @@ describe("ThinkingContext", () => { ), }, }; + metadataMap = new Map(); window.localStorage.clear(); window.localStorage.setItem("model-default", JSON.stringify("openai:default")); }); afterEach(() => { cleanup(); + metadataMap = new Map(); currentClientMock = {}; }); @@ -215,20 +135,22 @@ describe("ThinkingContext", () => { id: testCase.workspaceId, aiSettings: { model: "openai:gpt-5.5", thinkingLevel: "high" }, }); - const view = renderWithWorkspaceMetadata({ - workspaceId: testCase.workspaceId, - metadata, - modelOverride: testCase.override, - children: ( - - - - - - - - ), - }); + setWorkspaceMetadata(metadata); + if (testCase.override == null) { + window.localStorage.removeItem(getModelKey(testCase.workspaceId)); + } else { + updatePersistedState(getModelKey(testCase.workspaceId), testCase.override); + } + + const view = renderWithAPI( + + + + + + + + ); await waitFor(() => { expect(view.getByTestId("base-model").textContent).toBe(testCase.expected); @@ -256,16 +178,18 @@ describe("ThinkingContext", () => { id: testCase.workspaceId, aiSettings: { model: "openai:gpt-5.5", thinkingLevel: "high" }, }); - const view = renderWithWorkspaceMetadata({ - workspaceId: testCase.workspaceId, - metadata, - thinkingOverride: testCase.override, - children: ( - - - - ), - }); + setWorkspaceMetadata(metadata); + if (testCase.override == null) { + window.localStorage.removeItem(getThinkingLevelKey(testCase.workspaceId)); + } else { + updatePersistedState(getThinkingLevelKey(testCase.workspaceId), testCase.override); + } + + const view = renderWithAPI( + + + + ); await waitFor(() => { expect(view.getByTestId("thinking").textContent).toBe(testCase.expected); From 1a549c27f1ddd7634a4999254079b01e0436fa71 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 14:55:14 +0000 Subject: [PATCH 14/28] Clamp Exec subagent inherited thinking hint --- .../Settings/Sections/TasksSection.tsx | 12 ++-- .../Sections/TasksSection.ui.test.tsx | 59 ++++++++++++++++++- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/browser/features/Settings/Sections/TasksSection.tsx b/src/browser/features/Settings/Sections/TasksSection.tsx index 33f6b13f98..cd67be9ed3 100644 --- a/src/browser/features/Settings/Sections/TasksSection.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.tsx @@ -43,7 +43,11 @@ import { type SubagentAiDefaultsEntry, type TaskSettings, } from "@/common/types/tasks"; -import { getThinkingOptionLabel, type ThinkingLevel } from "@/common/types/thinking"; +import { + getThinkingOptionLabel, + THINKING_LEVEL_OFF, + type ThinkingLevel, +} from "@/common/types/thinking"; import { getErrorMessage } from "@/common/utils/errors"; import { enforceThinkingPolicy, getThinkingPolicyForModel } from "@/common/utils/thinking/policy"; import { normalizeAgentId } from "@/common/utils/agentIds"; @@ -1039,9 +1043,9 @@ export function TasksSection() { const uiExecEntry = agentAiDefaults.exec; const inheritedExecModel = uiExecEntry?.modelString ?? inheritedEffectiveModel; const effectiveModel = modelValue !== INHERIT ? modelValue : inheritedExecModel; - const inheritedThinkingLabel = uiExecEntry?.thinkingLevel - ? getThinkingOptionLabel(uiExecEntry.thinkingLevel, effectiveModel) - : "Inherit"; + const rawInheritedThinking = uiExecEntry?.thinkingLevel ?? THINKING_LEVEL_OFF; + const clampedInheritedThinking = enforceThinkingPolicy(effectiveModel, rawInheritedThinking); + const inheritedThinkingLabel = getThinkingOptionLabel(clampedInheritedThinking, effectiveModel); return (
({ void mock.module("@/browser/hooks/useModelsFromSettings", () => ({ getDefaultModel: () => "anthropic:workspace-default", useModelsFromSettings: () => ({ - models: ["anthropic:foo", "anthropic:ui-exec", "openai:subagent-model", "xai:grok-code-fast-1"], + models: [ + "anthropic:foo", + "anthropic:ui-exec", + "openai:gpt-5-pro", + "openai:subagent-model", + "xai:grok-code-fast-1", + ], hiddenModelsForSelector: [], }), })); @@ -196,6 +204,29 @@ describe("TasksSection Exec subagent defaults", () => { expect(within(row).queryByRole("button", { name: "Inherit from UI Exec" })).toBeNull(); }); + 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); + + const view = renderTasksSection({ + agentAiDefaults: { + exec: { modelString: "anthropic:ui-exec", thinkingLevel: "xhigh" }, + }, + subagentAiDefaults: { + exec: { modelString: model }, + }, + }); + + 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(); + }); + test("setting only the Exec subagent model writes only the sparse subagent model", async () => { const view = renderTasksSection({ agentAiDefaults: { @@ -222,6 +253,32 @@ describe("TasksSection Exec subagent defaults", () => { expect(payload.subagentAiDefaults.exec?.thinkingLevel).toBeUndefined(); }); + test("setting only the Exec subagent thinking writes only the sparse subagent thinking", async () => { + const view = renderTasksSection({ + agentAiDefaults: { + exec: { modelString: "anthropic:ui-exec", thinkingLevel: "medium" }, + }, + subagentAiDefaults: {}, + }); + const row = await view.findByRole("group", { name: "Exec defaults" }); + + fireEvent.change(within(row).getByLabelText("Reasoning"), { + target: { value: "high" }, + }); + + await waitFor(() => expect(view.saveConfig).toHaveBeenCalled()); + const payload = getLatestSavePayload(view.saveConfig); + + expect(payload.subagentAiDefaults).toEqual({ + exec: { thinkingLevel: "high" }, + }); + expect(payload.agentAiDefaults.exec).toEqual({ + modelString: "anthropic:ui-exec", + thinkingLevel: "medium", + }); + expect("modelString" in (payload.subagentAiDefaults.exec ?? {})).toBe(false); + }); + test("resetting one Exec subagent field removes only that field", async () => { const view = renderTasksSection({ subagentAiDefaults: { From 0699236c8c3ddb57f4c8de6d40924e7fc02d9638 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 14:56:40 +0000 Subject: [PATCH 15/28] Add task AI precedence tests --- src/common/types/tasks.ts | 2 +- src/node/services/taskService.test.ts | 85 +++++++++++++++++++++++++++ src/node/services/taskService.ts | 3 +- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/common/types/tasks.ts b/src/common/types/tasks.ts index 18df9dd821..a489ddada1 100644 --- a/src/common/types/tasks.ts +++ b/src/common/types/tasks.ts @@ -30,7 +30,7 @@ export const DEFAULT_TASK_SETTINGS: TaskSettings = { planSubagentDefaultsToOrchestrator: false, }; -export const AGENT_DEFAULT_IDS_EXCLUDED_FROM_LEGACY_SUBAGENTS: ReadonlySet = new Set([ +const AGENT_DEFAULT_IDS_EXCLUDED_FROM_LEGACY_SUBAGENTS: ReadonlySet = new Set([ "plan", "exec", "compact", diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 81245108e8..231d9f4f65 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -25,6 +25,7 @@ import * as runtimeFactory from "@/node/runtime/runtimeFactory"; import * as forkOrchestrator from "@/node/services/utils/forkOrchestrator"; import { Ok, Err, type Result } from "@/common/types/result"; import { defaultModel } from "@/common/utils/ai/models"; +import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; import type { PlanSubagentExecutorRouting } from "@/common/types/tasks"; import type { ThinkingLevel } from "@/common/types/thinking"; import type { ErrorEvent, StreamEndEvent } from "@/common/types/stream"; @@ -1791,6 +1792,46 @@ describe("TaskService", () => { expect(childEntry?.taskThinkingLevel).toBe("xhigh"); }, 20_000); + test("explicit task args outrank subagentAiDefaults exec on task create", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); + const { parentId } = await saveLocalParentWorkspace(config, rootDir, { + subagentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + }, + }); + + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + const created = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "exec", + prompt: "run exec task with explicit args", + title: "Test task", + modelString: "openai:gpt-5.2", + thinkingLevel: "medium", + }); + expect(created.success).toBe(true); + if (!created.success) return; + + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "run exec task with explicit args", + { + model: "openai:gpt-5.2", + agentId: "exec", + thinkingLevel: "medium", + experiments: undefined, + }, + { agentInitiated: true } + ); + const childEntry = findWorkspaceInConfig(config, created.data.taskId); + expect(childEntry?.taskModelString).toBe("openai:gpt-5.2"); + expect(childEntry?.taskThinkingLevel).toBe("medium"); + }, 20_000); + test("exec subagent falls back to agentAiDefaults exec when subagent default is absent", async () => { const config = await createTestConfig(rootDir); stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); @@ -1864,6 +1905,50 @@ describe("TaskService", () => { ); }, 20_000); + test("subagent thinking defaults are clamped by the resolved model policy", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); + const resolvedModel = "openai:gpt-5.5-pro"; + const requestedThinkingLevel: ThinkingLevel = "off"; + const expectedThinkingLevel = enforceThinkingPolicy(resolvedModel, requestedThinkingLevel); + expect(expectedThinkingLevel).not.toBe(requestedThinkingLevel); + + const { parentId } = await saveLocalParentWorkspace(config, rootDir, { + parentAiSettings: { model: resolvedModel, thinkingLevel: "high" }, + subagentAiDefaults: { + exec: { thinkingLevel: requestedThinkingLevel }, + }, + }); + + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + const created = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "exec", + prompt: "run exec task with clamped default thinking", + title: "Test task", + }); + expect(created.success).toBe(true); + if (!created.success) return; + + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "run exec task with clamped default thinking", + { + model: resolvedModel, + agentId: "exec", + thinkingLevel: expectedThinkingLevel, + experiments: undefined, + }, + { agentInitiated: true } + ); + const childEntry = findWorkspaceInConfig(config, created.data.taskId); + expect(childEntry?.taskModelString).toBe(resolvedModel); + expect(childEntry?.taskThinkingLevel).toBe(expectedThinkingLevel); + }, 20_000); + test("thinking policy is enforced after resolving the final subagent model", 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 deac8f1a46..19f4884963 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -409,8 +409,7 @@ export class TaskService { effectiveThinkingLevel: ThinkingLevel; } { const parentAiSettings = this.resolveWorkspaceAISettings(params.parentMeta, params.agentId); - // Exec needs separate UI-agent and subagent defaults. Resolve subagent defaults first, - // then fall back to UI defaults for compatibility when no subagent override exists. + // Sub-agent defaults take priority over UI agent defaults per field for any agent invoked as a sub-agent. const subagentDefault = params.cfg.subagentAiDefaults?.[params.agentId]; const agentDefault = params.cfg.agentAiDefaults?.[params.agentId]; From 2aadd2322afcfd92454245368ac09254080a6588 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 15:02:14 +0000 Subject: [PATCH 16/28] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20usage=20?= =?UTF-8?q?caches=20during=20exec=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple session usage cache invalidation from broad config modification during config load. Only model id normalization now triggers cache deletion while exec split cleanup still persists its migration marker. Also clarify subagent defaults docs around exec storage and rename the legacy fallback extractor.\n\n---\n\n_Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `high` • Cost: `1820273{MUX_COSTS_USD:-unknown}`_\n\n --- src/common/config/schemas/appConfigOnDisk.ts | 6 ++++ src/common/types/project.ts | 7 +++- src/node/config.test.ts | 34 ++++++++++++++++++++ src/node/config.ts | 22 +++++++++---- src/node/orpc/router.ts | 8 +++-- 5 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index b69d4d5876..3ff8acd965 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -73,6 +73,12 @@ export const AppConfigOnDiskSchema = z hiddenModels: z.array(z.string()).optional(), preferredCompactionModel: z.string().optional(), agentAiDefaults: AgentAiDefaultsSchema.optional(), + /** + * Sparse per-agent override that wins over agentAiDefaults when an agent runs as a + * sub-agent. The exec key is canonical storage for the sub-agent Exec slot. + * Other keys are kept for legacy mirror compatibility, but new code should write + * to agentAiDefaults instead. + */ subagentAiDefaults: SubagentAiDefaultsSchema.optional(), migrations: AppConfigMigrationsSchema.optional(), useSSH2Transport: z.boolean().optional(), diff --git a/src/common/types/project.ts b/src/common/types/project.ts index ecec3cb68f..ec8853285b 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -120,7 +120,12 @@ export interface ProjectsConfig { hiddenModels?: string[]; /** Default model + thinking overrides per agentId (applies to UI agents and subagents). */ agentAiDefaults?: AgentAiDefaults; - /** @deprecated Legacy per-subagent default model + thinking overrides. */ + /** + * Sparse per-agent override that wins over agentAiDefaults when an agent runs as a + * sub-agent. The exec key is canonical storage for the sub-agent Exec slot. + * Other keys are kept for legacy mirror compatibility, but new code should write + * to agentAiDefaults instead. + */ subagentAiDefaults?: SubagentAiDefaults; /** Internal one-time migration markers. Not surfaced in user-facing config UI. */ migrations?: AppConfigMigrations; diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 7d255eb9d7..b132a31fba 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -521,6 +521,40 @@ describe("Config", () => { expect(raw.migrations?.execSubagentDefaultsSplit).toBe(true); }); + it("preserves session usage cache when only exec-split cleanup modifies config", () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + agentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + }, + subagentAiDefaults: { + exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + worker: { modelString: "openai:gpt-5.2" }, + }, + }) + ); + + const usagePath = path.join(config.getSessionDir("workspace-1"), "session-usage.json"); + fs.mkdirSync(path.dirname(usagePath), { recursive: true }); + fs.writeFileSync(usagePath, JSON.stringify({ totalCost: 1.23 })); + expect(fs.existsSync(usagePath)).toBe(true); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.subagentAiDefaults?.exec).toBeUndefined(); + expect(loaded.subagentAiDefaults?.worker?.modelString).toBe("openai:gpt-5.2"); + expect(loaded.migrations?.execSubagentDefaultsSplit).toBe(true); + + const raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { + subagentAiDefaults?: Record; + migrations?: { execSubagentDefaultsSplit?: boolean }; + }; + expect(raw.subagentAiDefaults?.exec).toBeUndefined(); + expect(raw.migrations?.execSubagentDefaultsSplit).toBe(true); + expect(fs.existsSync(usagePath)).toBe(true); + }); + it("preserves differing exec subagent defaults on first load", () => { fs.writeFileSync( path.join(tempDir, "config.json"), diff --git a/src/node/config.ts b/src/node/config.ts index 5e2d0eb864..6c31cde1f1 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -316,7 +316,7 @@ function normalizeConfigMigrations(value: unknown): AppConfigMigrations { }; } -function legacySubagentDefaultsForAgentFallback( +function extractAgentDefaultsFromLegacySubagents( legacySubagentAiDefaults: SubagentAiDefaultsConfig ): Record { const fallbackDefaults: Record = {}; @@ -563,6 +563,7 @@ export class Config { const data = fs.readFileSync(this.configFile, "utf-8"); const parsed = JSON.parse(data) as Partial & Record; let configModified = false; + let shouldInvalidateSessionUsageCaches = false; const normalizeNestedModelStrings = (value: unknown): boolean => { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -637,6 +638,7 @@ export class Config { if (routeOverridesModified) { parsed.routeOverrides = mergedRouteOverrides; + shouldInvalidateSessionUsageCaches = true; } } } @@ -672,6 +674,7 @@ export class Config { if (normalized !== parsed.defaultModel) { parsed.defaultModel = normalized; configModified = true; + shouldInvalidateSessionUsageCaches = true; } } @@ -689,14 +692,17 @@ export class Config { ) { parsed.hiddenModels = normalizedHiddenModels; configModified = true; + shouldInvalidateSessionUsageCaches = true; } } if (normalizeNestedModelStrings(parsed.agentAiDefaults)) { configModified = true; + shouldInvalidateSessionUsageCaches = true; } if (normalizeNestedModelStrings(parsed.subagentAiDefaults)) { configModified = true; + shouldInvalidateSessionUsageCaches = true; } // Config is stored as array of [path, config] pairs. @@ -747,7 +753,7 @@ export class Config { parsed.agentAiDefaults !== undefined ? normalizeAgentAiDefaults(parsed.agentAiDefaults) : normalizeAgentAiDefaults( - legacySubagentDefaultsForAgentFallback(legacySubagentAiDefaults) + extractAgentDefaultsFromLegacySubagents(legacySubagentAiDefaults) ); const configMigrations = normalizeConfigMigrations(parsed.migrations); @@ -775,8 +781,8 @@ export class Config { configModified = true; } - if (configModified) { - // Invalidate stale usage caches: old files may contain gateway-prefixed model ids. + if (shouldInvalidateSessionUsageCaches) { + // Invalidate stale usage caches only when model id formats changed. try { if (fs.existsSync(this.sessionsDir)) { for (const sessionEntry of fs.readdirSync(this.sessionsDir, { @@ -799,7 +805,9 @@ export class Config { // Best-effort cleanup; never fail startup on cache invalidation issues. log.warn("Failed to invalidate session usage cache during config migration", { error }); } + } + if (configModified) { try { writeFileAtomic.sync(this.configFile, JSON.stringify(parsed, null, 2), { encoding: "utf-8", @@ -862,7 +870,8 @@ export class Config { advisorMaxOutputTokens, hiddenModels, agentAiDefaults, - // Legacy fields are still parsed and returned for downgrade compatibility. + // Subagent defaults: exec is canonical active storage, non-exec entries + // support legacy mirror compatibility. subagentAiDefaults: legacySubagentAiDefaults, migrations: normalizeConfigMigrations(parsed.migrations), featureFlagOverrides: parsed.featureFlagOverrides, @@ -1049,7 +1058,8 @@ export class Config { data.subagentAiDefaults = legacySubagent; } } else { - // Legacy only. + // Subagent-only configs keep exec as active storage. Other entries are + // retained for legacy fallback. if (config.subagentAiDefaults && Object.keys(config.subagentAiDefaults).length > 0) { data.subagentAiDefaults = normalizeAiDefaultsModelStrings(config.subagentAiDefaults); } diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index c83d3c29d8..e4e447d617 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -624,7 +624,8 @@ export const router = (authToken?: string) => { runtimeEnablement: normalizeRuntimeEnablement(config.runtimeEnablement), defaultRuntime: config.defaultRuntime ?? null, agentAiDefaults: config.agentAiDefaults ?? {}, - // Legacy fields (downgrade compatibility) + // Subagent defaults: exec is canonical active storage, non-exec entries + // support legacy mirror compatibility. subagentAiDefaults: config.subagentAiDefaults ?? {}, // Mux Governor enrollment status (safe fields only - token never exposed) muxGovernorUrl, @@ -716,7 +717,8 @@ export const router = (authToken?: string) => { return { ...config, agentAiDefaults: Object.keys(normalized).length > 0 ? normalized : undefined, - // Legacy fields (downgrade compatibility) + // Subagent defaults: exec is canonical active storage, non-exec entries + // support legacy mirror compatibility. subagentAiDefaults: Object.keys(legacySubagentDefaults).length > 0 ? legacySubagentDefaults : undefined, }; @@ -971,7 +973,7 @@ export const router = (authToken?: string) => { result.subagentAiDefaults = Object.keys(normalizedDefaults).length > 0 ? normalizedDefaults : undefined; - // Downgrade compatibility: keep agentAiDefaults in sync with legacy subagentAiDefaults. + // Compatibility: keep agentAiDefaults in sync with non-exec subagent entries. // Only mutate keys previously managed by subagentAiDefaults so we don't clobber other // agent defaults (e.g., UI-selectable custom agents). const previousLegacy = config.subagentAiDefaults ?? {}; From 960e1342951cfe268b984e682f218c06a31a9e41 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 15:34:27 +0000 Subject: [PATCH 17/28] fix task runtime ai fallback --- src/node/services/taskService.test.ts | 117 ++++++++++++++++++++++++++ src/node/services/taskService.ts | 6 ++ src/node/services/tools/task.test.ts | 16 +++- src/node/services/tools/task.ts | 23 +++++ 4 files changed, 158 insertions(+), 4 deletions(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 231d9f4f65..2d31cdb451 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -1751,6 +1751,123 @@ describe("TaskService", () => { ); }, 20_000); + test("parent runtime AI settings outrank persisted parent workspace settings", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); + const { parentId } = await saveLocalParentWorkspace(config, rootDir, { + parentAiSettings: { model: "openai:gpt-5.2", thinkingLevel: "medium" }, + }); + + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + const created = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "exec", + prompt: "run exec task with parent runtime fallback", + title: "Test task", + parentRuntimeAiSettings: { modelString: "openai:gpt-5.3-codex" }, + }); + expect(created.success).toBe(true); + if (!created.success) return; + + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "run exec task with parent runtime fallback", + { + model: "openai:gpt-5.3-codex", + agentId: "exec", + thinkingLevel: "medium", + experiments: undefined, + }, + { agentInitiated: true } + ); + const childEntry = findWorkspaceInConfig(config, created.data.taskId); + expect(childEntry?.taskModelString).toBe("openai:gpt-5.3-codex"); + expect(childEntry?.taskThinkingLevel).toBe("medium"); + }, 20_000); + + test("subagentAiDefaults outrank parent runtime AI settings", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); + const { parentId } = await saveLocalParentWorkspace(config, rootDir, { + subagentAiDefaults: { + exec: { modelString: "anthropic:claude-haiku-4-5", thinkingLevel: "off" }, + }, + }); + + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + const created = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "exec", + prompt: "run exec task with configured default", + title: "Test task", + parentRuntimeAiSettings: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" }, + }); + expect(created.success).toBe(true); + if (!created.success) return; + + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "run exec task with configured default", + { + model: "anthropic:claude-haiku-4-5", + agentId: "exec", + thinkingLevel: "off", + experiments: undefined, + }, + { agentInitiated: true } + ); + const childEntry = findWorkspaceInConfig(config, created.data.taskId); + expect(childEntry?.taskModelString).toBe("anthropic:claude-haiku-4-5"); + expect(childEntry?.taskThinkingLevel).toBe("off"); + }, 20_000); + + test("parent runtime thinking hint is clamped by the resolved model policy", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); + const resolvedModel = "openai:gpt-5.5-pro"; + const requestedThinkingLevel: ThinkingLevel = "off"; + const expectedThinkingLevel = enforceThinkingPolicy(resolvedModel, requestedThinkingLevel); + expect(expectedThinkingLevel).not.toBe(requestedThinkingLevel); + const { parentId } = await saveLocalParentWorkspace(config, rootDir, { + parentAiSettings: { model: resolvedModel, thinkingLevel: "high" }, + }); + + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + const created = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "exec", + prompt: "run exec task with parent runtime thinking fallback", + title: "Test task", + parentRuntimeAiSettings: { thinkingLevel: requestedThinkingLevel }, + }); + expect(created.success).toBe(true); + if (!created.success) return; + + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "run exec task with parent runtime thinking fallback", + { + model: resolvedModel, + agentId: "exec", + thinkingLevel: expectedThinkingLevel, + experiments: undefined, + }, + { agentInitiated: true } + ); + const childEntry = findWorkspaceInConfig(config, created.data.taskId); + expect(childEntry?.taskModelString).toBe(resolvedModel); + expect(childEntry?.taskThinkingLevel).toBe(expectedThinkingLevel); + }, 20_000); + 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 19f4884963..f5f8dc1dbb 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -111,6 +111,7 @@ export interface TaskCreateArgs { title: string; modelString?: string; thinkingLevel?: ThinkingLevel; + parentRuntimeAiSettings?: { modelString?: string; thinkingLevel?: ThinkingLevel }; /** Shared grouping metadata when one tool call spawns multiple sibling tasks. */ bestOf?: { groupId: string; @@ -403,6 +404,7 @@ export class TaskService { agentId: string; modelString?: string; thinkingLevel?: ThinkingLevel; + parentRuntimeAiSettings?: { modelString?: string; thinkingLevel?: ThinkingLevel }; }): { taskModelString: string; canonicalModel: string; @@ -412,11 +414,13 @@ export class TaskService { // Sub-agent defaults take priority over UI agent defaults per field for any agent invoked as a sub-agent. const subagentDefault = params.cfg.subagentAiDefaults?.[params.agentId]; const agentDefault = params.cfg.agentAiDefaults?.[params.agentId]; + const parentRuntimeAiSettings = params.parentRuntimeAiSettings; const taskModelString = coerceNonEmptyString(params.modelString) ?? coerceNonEmptyString(subagentDefault?.modelString) ?? coerceNonEmptyString(agentDefault?.modelString) ?? + coerceNonEmptyString(parentRuntimeAiSettings?.modelString) ?? coerceNonEmptyString(parentAiSettings?.model) ?? defaultModel; const canonicalModel = normalizeToCanonical(taskModelString).trim(); @@ -426,6 +430,7 @@ export class TaskService { params.thinkingLevel ?? subagentDefault?.thinkingLevel ?? agentDefault?.thinkingLevel ?? + parentRuntimeAiSettings?.thinkingLevel ?? parentAiSettings?.thinkingLevel ?? "off"; const effectiveThinkingLevel = enforceThinkingPolicy(canonicalModel, requestedThinkingLevel); @@ -1187,6 +1192,7 @@ export class TaskService { agentId, modelString: args.modelString, thinkingLevel: args.thinkingLevel, + parentRuntimeAiSettings: args.parentRuntimeAiSettings, }); const parentRuntimeConfig = parentMeta.runtimeConfig; diff --git a/src/node/services/tools/task.test.ts b/src/node/services/tools/task.test.ts index d5c8f21eba..9f6d7c52b4 100644 --- a/src/node/services/tools/task.test.ts +++ b/src/node/services/tools/task.test.ts @@ -98,19 +98,23 @@ describe("task tool", () => { expectQueuedOrRunningTaskToolResult(result, { status: "queued", taskId: "child-task" }); }); - it("does not propagate parent MUX_MODEL_STRING/MUX_THINKING_LEVEL to spawned sub-agent", async () => { + it("passes parent MUX_MODEL_STRING/MUX_THINKING_LEVEL as a runtime fallback hint", async () => { using tempDir = new TestTempDir("test-task-tool-parent-ai-env"); const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); - const create = mock((_: { modelString?: unknown; thinkingLevel?: unknown }) => - Ok({ taskId: "child-task", kind: "agent" as const, status: "queued" as const }) + const create = mock( + (_: { + modelString?: unknown; + thinkingLevel?: unknown; + parentRuntimeAiSettings?: { modelString?: unknown; thinkingLevel?: unknown }; + }) => Ok({ taskId: "child-task", kind: "agent" as const, status: "queued" as const }) ); const waitForAgentReport = mock(() => Promise.resolve({ reportMarkdown: "ignored" })); const taskService = { create, waitForAgentReport } as unknown as TaskService; const tool = createTaskTool({ ...baseConfig, - muxEnv: { MUX_MODEL_STRING: "openai:gpt-4o-mini", MUX_THINKING_LEVEL: "high" }, + muxEnv: { MUX_MODEL_STRING: "openai:gpt-4o-mini", MUX_THINKING_LEVEL: "med" }, taskService, }); @@ -126,6 +130,10 @@ describe("task tool", () => { expect(createArgs).toBeDefined(); expect(createArgs?.modelString).toBeUndefined(); expect(createArgs?.thinkingLevel).toBeUndefined(); + expect(createArgs?.parentRuntimeAiSettings).toEqual({ + modelString: "openai:gpt-4o-mini", + thinkingLevel: "medium", + }); }); it("spawns best-of-n background tasks with shared grouping metadata", async () => { diff --git a/src/node/services/tools/task.ts b/src/node/services/tools/task.ts index be391f7aac..18feff807b 100644 --- a/src/node/services/tools/task.ts +++ b/src/node/services/tools/task.ts @@ -17,6 +17,8 @@ import { ForegroundWaitBackgroundedError } from "@/node/services/taskService"; import { buildTaskGroupLaunches, type TaskGroupKind } from "@/common/utils/tools/taskGroups"; import { parseToolResult, requireTaskService, requireWorkspaceId } from "./toolUtils"; import { getErrorMessage } from "@/common/utils/errors"; +import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; +import { coerceNonEmptyString } from "@/node/services/taskUtils"; /** * Build dynamic task tool description with runtime-specific workspace visibility @@ -43,6 +45,22 @@ function buildTaskDescription(config: ToolConfiguration): string { return `${baseDescription}\n\nAvailable sub-agents (use \`agentId\` parameter):\n${subagentLines.join("\n")}`; } +function buildParentRuntimeAiSettings( + config: ToolConfiguration +): { modelString?: string; thinkingLevel?: ThinkingLevel } | undefined { + const modelString = coerceNonEmptyString(config.muxEnv?.MUX_MODEL_STRING); + const thinkingLevel = coerceThinkingLevel(config.muxEnv?.MUX_THINKING_LEVEL); + + if (modelString == null && thinkingLevel == null) { + return undefined; + } + + return { + ...(modelString != null ? { modelString } : {}), + ...(thinkingLevel != null ? { thinkingLevel } : {}), + }; +} + interface SpawnedTaskInfo { taskId: string; status: "queued" | "running"; @@ -285,6 +303,10 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { throw new Error('In the plan agent you may only spawn agentId: "explore" tasks.'); } + // Parent runtime model and thinking are forwarded as a low-priority fallback so + // unconfigured delegated runs still inherit the parent's live model. Do not + // restore the previous top-priority forwarding through explicit task args. + const parentRuntimeAiSettings = buildParentRuntimeAiSettings(config); const createdTasks: SpawnedTaskInfo[] = []; for (const launch of taskGroupLaunches) { if (abortSignal?.aborted) { @@ -300,6 +322,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { prompt: launch.prompt, title, experiments: config.experiments, + ...(parentRuntimeAiSettings != null ? { parentRuntimeAiSettings } : {}), bestOf: taskGroupId != null ? { From 2c4ff2a202e02242366fcaf00d88dd5876dba21f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 15:52:15 +0000 Subject: [PATCH 18/28] Fix thinking model fallback --- src/browser/contexts/ThinkingContext.test.tsx | 57 +++++++++++++++++++ src/browser/contexts/ThinkingContext.tsx | 21 ++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index 110b7ee9d6..5da56c529c 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -46,6 +46,13 @@ interface TestProps { workspaceId: string; } +type WorkspaceUpdateAgentAISettingsArgs = Parameters< + APIClient["workspace"]["updateAgentAISettings"] +>[0]; +type WorkspaceUpdateAgentAISettingsResult = Awaited< + ReturnType +>; + const TestComponent: React.FC = (props) => { const [thinkingLevel] = useThinkingLevel(); return ( @@ -68,6 +75,15 @@ const agentContextValue: AgentContextValue = { setDisableWorkspaceAgents: () => undefined, }; +const ThinkingSetterComponent: React.FC = () => { + const [, setThinkingLevel] = useThinkingLevel(); + return ( + + ); +}; + const SendOptionsComponent: React.FC<{ workspaceId: string }> = (props) => { const options = useSendMessageOptions(props.workspaceId); return
{options.baseModel}
; @@ -159,6 +175,47 @@ describe("ThinkingContext", () => { } }); + test("setting thinking uses metadata model before global default", async () => { + const workspaceId = "ws-set-thinking-metadata-model"; + const updateAgentAISettings = mock< + (args: WorkspaceUpdateAgentAISettingsArgs) => Promise + >(() => + Promise.resolve({ + success: true as const, + data: undefined, + }) + ); + currentClientMock = { + workspace: { updateAgentAISettings }, + }; + + setWorkspaceMetadata( + createWorkspaceMetadata({ + id: workspaceId, + aiSettings: { model: "metadataModel:abc", thinkingLevel: "high" }, + }) + ); + window.localStorage.removeItem(getModelKey(workspaceId)); + + const view = renderWithAPI( + + + + ); + + act(() => { + view.getByTestId("set-thinking-medium").click(); + }); + + await waitFor(() => { + expect(updateAgentAISettings).toHaveBeenCalledWith({ + workspaceId, + agentId: "exec", + aiSettings: { model: "metadataModel:abc", thinkingLevel: "medium" }, + }); + }); + }); + test("uses metadata thinking before off but keeps explicit thinking", async () => { const cases = [ { diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 67dc6d1272..43d2b0b04b 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -50,6 +50,16 @@ function getCanonicalModelForScope(scopeId: string, fallbackModel: string): stri return normalizeToCanonical(rawModel || fallbackModel); } +function getModelForThinkingUpdate( + scopeId: string, + metadataModel: string | undefined, + fallbackModel: string +): string { + const persistedModel = readPersistedState(getModelKey(scopeId), undefined); + // Prefer localStorage, then metadata, then the default model to avoid clobbering startup metadata. + return normalizeToCanonical(persistedModel ?? metadataModel ?? fallbackModel); +} + export const ThinkingProvider: React.FC = (props) => { const { api } = useAPI(); const workspaceContext = useOptionalWorkspaceContext(); @@ -90,7 +100,7 @@ export const ThinkingProvider: React.FC = (props) => { const setThinkingLevel = useCallback( (level: ThinkingLevel) => { - const model = getCanonicalModelForScope(scopeId, defaultModel); + const model = getModelForThinkingUpdate(scopeId, metadataSettings.model, defaultModel); setThinkingLevelInternal(level); @@ -150,7 +160,14 @@ export const ThinkingProvider: React.FC = (props) => { // Best-effort only. If offline or backend is old, the next sendMessage will persist. }); }, - [api, defaultModel, props.workspaceId, scopeId, setThinkingLevelInternal] + [ + api, + defaultModel, + metadataSettings.model, + props.workspaceId, + scopeId, + setThinkingLevelInternal, + ] ); // Global keybind: cycle thinking level (Ctrl/Cmd+Shift+T). From 2dd8fcb5fff1e243f908649cd533296e57f410ca Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 15:53:34 +0000 Subject: [PATCH 19/28] =?UTF-8?q?=F0=9F=A4=96=20fix:=20omit=20unchanged=20?= =?UTF-8?q?subagent=20defaults=20from=20saves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/Sections/TasksSection.tsx | 53 +++++++++++++------ .../Sections/TasksSection.ui.test.tsx | 46 ++++++++++++++-- 2 files changed, 80 insertions(+), 19 deletions(-) diff --git a/src/browser/features/Settings/Sections/TasksSection.tsx b/src/browser/features/Settings/Sections/TasksSection.tsx index cd67be9ed3..797254012a 100644 --- a/src/browser/features/Settings/Sections/TasksSection.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.tsx @@ -156,6 +156,39 @@ function getSubagentAiDefaultsForSave( return next; } +interface TasksSectionSavePayload { + taskSettings: TaskSettings; + agentAiDefaults: AgentAiDefaults; + subagentAiDefaults: SubagentAiDefaults; +} + +interface TasksSectionSaveBody { + taskSettings: TaskSettings; + agentAiDefaults: AgentAiDefaults; + subagentAiDefaults?: SubagentAiDefaults; +} + +function getTasksSectionSaveBody( + payload: TasksSectionSavePayload, + lastSyncedSubagentAiDefaults: SubagentAiDefaults | null +): TasksSectionSaveBody { + const didSubagentDefaultsChange = + lastSyncedSubagentAiDefaults === null || + !areSubagentAiDefaultsEqual(lastSyncedSubagentAiDefaults, payload.subagentAiDefaults); + const saveBody: TasksSectionSaveBody = { + taskSettings: payload.taskSettings, + agentAiDefaults: payload.agentAiDefaults, + }; + + // Skip unchanged legacy subagent defaults so unrelated agent toggles do not + // run router reconciliation and drop enabled/advisorEnabled for custom agents. + if (didSubagentDefaultsChange) { + saveBody.subagentAiDefaults = payload.subagentAiDefaults; + } + + return saveBody; +} + function renderPolicySummary(agent: AgentDefinitionDescriptor): React.ReactNode { const isCompact = agent.id === "compact"; @@ -433,11 +466,7 @@ export function TasksSection() { const saveTimerRef = useRef | null>(null); const savingRef = useRef(false); - const pendingSaveRef = useRef<{ - taskSettings: TaskSettings; - agentAiDefaults: AgentAiDefaults; - subagentAiDefaults: SubagentAiDefaults; - } | null>(null); + const pendingSaveRef = useRef(null); const { models, hiddenModelsForSelector } = useModelsFromSettings(); const [globalDefaultAgentIdRaw, setGlobalDefaultAgentIdRaw] = usePersistedState( @@ -594,12 +623,9 @@ export function TasksSection() { pendingSaveRef.current = null; savingRef.current = true; + const saveBody = getTasksSectionSaveBody(payload, lastSyncedSubagentAiDefaultsRef.current); void api.config - .saveConfig({ - taskSettings: payload.taskSettings, - agentAiDefaults: payload.agentAiDefaults, - subagentAiDefaults: payload.subagentAiDefaults, - }) + .saveConfig(saveBody) .then(() => { const previousAgentDefaults = lastSyncedAgentAiDefaultsRef.current; const previousSubagentDefaults = lastSyncedSubagentAiDefaultsRef.current; @@ -683,12 +709,9 @@ export function TasksSection() { pendingSaveRef.current = null; savingRef.current = true; + const saveBody = getTasksSectionSaveBody(payload, lastSyncedSubagentAiDefaultsRef.current); void api.config - .saveConfig({ - taskSettings: payload.taskSettings, - agentAiDefaults: payload.agentAiDefaults, - subagentAiDefaults: payload.subagentAiDefaults, - }) + .saveConfig(saveBody) .catch(() => undefined) .finally(() => { savingRef.current = false; diff --git a/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx index 2a565c20a1..8755f59f67 100644 --- a/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx @@ -139,7 +139,7 @@ function getLatestSavePayload(saveConfig: ReturnType) { expect(calls.length).toBeGreaterThan(0); return calls[calls.length - 1][0] as { agentAiDefaults: AgentAiDefaults; - subagentAiDefaults: SubagentAiDefaults; + subagentAiDefaults?: SubagentAiDefaults; }; } @@ -186,7 +186,45 @@ describe("TasksSection Exec subagent defaults", () => { const payload = getLatestSavePayload(view.saveConfig); expect(payload.agentAiDefaults.explore).toBeUndefined(); - expect(payload.subagentAiDefaults.explore).toBeUndefined(); + expect(payload.subagentAiDefaults?.explore).toBeUndefined(); + }); + + test("omits unchanged subagent defaults when saving an agent-only change", async () => { + const view = renderTasksSection({ + subagentAiDefaults: { + explore: { modelString: "openai:subagent-model" }, + }, + }); + + await view.findByText("Explore"); + fireEvent.click( + within(getAgentCardByName(view, "Explore")).getByRole("switch", { + name: "Toggle explore enabled", + }) + ); + + await waitFor(() => expect(view.saveConfig).toHaveBeenCalled()); + const payload = getLatestSavePayload(view.saveConfig); + + expect(payload.agentAiDefaults.explore).toEqual({ enabled: false }); + expect("subagentAiDefaults" in payload).toBe(false); + }); + + test("includes subagent defaults when saving a subagent default change", async () => { + const view = renderTasksSection({ subagentAiDefaults: {} }); + const row = await view.findByRole("group", { name: "Exec defaults" }); + + fireEvent.change(within(row).getByLabelText("Model"), { + target: { value: "openai:subagent-model" }, + }); + + await waitFor(() => expect(view.saveConfig).toHaveBeenCalled()); + const payload = getLatestSavePayload(view.saveConfig); + + expect("subagentAiDefaults" in payload).toBe(true); + expect(payload.subagentAiDefaults).toEqual({ + exec: { modelString: "openai:subagent-model" }, + }); }); test("unset Exec subagent defaults inherit from UI Exec", async () => { @@ -250,7 +288,7 @@ describe("TasksSection Exec subagent defaults", () => { modelString: "anthropic:ui-exec", thinkingLevel: "medium", }); - expect(payload.subagentAiDefaults.exec?.thinkingLevel).toBeUndefined(); + expect(payload.subagentAiDefaults?.exec?.thinkingLevel).toBeUndefined(); }); test("setting only the Exec subagent thinking writes only the sparse subagent thinking", async () => { @@ -276,7 +314,7 @@ describe("TasksSection Exec subagent defaults", () => { modelString: "anthropic:ui-exec", thinkingLevel: "medium", }); - expect("modelString" in (payload.subagentAiDefaults.exec ?? {})).toBe(false); + expect("modelString" in (payload.subagentAiDefaults?.exec ?? {})).toBe(false); }); test("resetting one Exec subagent field removes only that field", async () => { From 23d9e1f390b8bfe0a68cd6ef37e16ff5386bb261 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 15:55:53 +0000 Subject: [PATCH 20/28] =?UTF-8?q?=F0=9F=A4=96=20tests:=20cover=20custom=20?= =?UTF-8?q?subagent=20default=20fixture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/Settings/Sections/TasksSection.ui.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx index 8755f59f67..b8eb15a120 100644 --- a/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx @@ -191,7 +191,11 @@ describe("TasksSection Exec subagent defaults", () => { test("omits unchanged subagent defaults when saving an agent-only change", async () => { const view = renderTasksSection({ + agentAiDefaults: { + foo: { enabled: true }, + }, subagentAiDefaults: { + foo: { modelString: "anthropic:foo" }, explore: { modelString: "openai:subagent-model" }, }, }); From 00435d180588548af9444a87ed552ada46b19549 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 16:20:54 +0000 Subject: [PATCH 21/28] =?UTF-8?q?=F0=9F=A4=96=20fix(tests):=20stop=20Think?= =?UTF-8?q?ingContext=20from=20globally=20mocking=20WorkspaceContext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop ThinkingContext tests from installing a module-scope WorkspaceContext mock. Route metadata cases through the real WorkspaceProvider with a test API client so WorkspaceContext.test.tsx receives the real module when Bun loads both files in one process. Validation: - bun test src/browser/contexts/ThinkingContext.test.tsx - bun test src/browser/contexts/WorkspaceContext.test.tsx - bun test src/browser/contexts/ThinkingContext.test.tsx src/browser/contexts/WorkspaceContext.test.tsx - bun test src/browser/contexts/WorkspaceContext.test.tsx src/browser/contexts/ThinkingContext.test.tsx - bun run typecheck - make lint --- src/browser/contexts/ThinkingContext.test.tsx | 194 ++++++++++++++---- 1 file changed, 149 insertions(+), 45 deletions(-) diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index 5da56c529c..9135153254 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -2,23 +2,19 @@ import { GlobalWindow } from "happy-dom"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import React from "react"; -import type { APIClient } from "@/browser/contexts/API"; +import { ThinkingProvider } from "./ThinkingContext"; +import { APIProvider, type APIClient } from "@/browser/contexts/API"; +import { AgentProvider, type AgentContextValue } from "@/browser/contexts/AgentContext"; +import { ProjectProvider } from "@/browser/contexts/ProjectContext"; +import { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsContext"; +import { RouterProvider } from "@/browser/contexts/RouterContext"; +import { useWorkspaceContext, WorkspaceProvider } from "@/browser/contexts/WorkspaceContext"; +import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { RecursivePartial } from "@/browser/testUtils"; let currentClientMock: RecursivePartial = {}; let metadataMap = new Map(); - -void mock.module("@/browser/contexts/WorkspaceContext", () => ({ - useWorkspaceContext: () => ({ workspaceMetadata: metadataMap }), - useOptionalWorkspaceContext: () => ({ workspaceMetadata: metadataMap }), -})); - -import { ThinkingProvider } from "./ThinkingContext"; -import { APIProvider } from "@/browser/contexts/API"; -import { AgentProvider, type AgentContextValue } from "@/browser/contexts/AgentContext"; -import { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsContext"; -import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel"; import { getModelKey, getProjectScopeId, @@ -111,6 +107,112 @@ function setWorkspaceMetadata(metadata: FrontendWorkspaceMetadata) { metadataMap = new Map([[metadata.id, metadata]]); } +function createEmptyAsyncIterable(): AsyncIterable { + return { + async *[Symbol.asyncIterator](): AsyncIterator { + await Promise.resolve(); + if (Date.now() < 0) yield undefined as T; + }, + }; +} + +function WorkspaceMetadataGate(props: { + workspaceId: string; + modelOverride?: string | null; + thinkingOverride?: "off" | null; + children: React.ReactNode; +}) { + const { workspaceMetadata } = useWorkspaceContext(); + if (!workspaceMetadata.has(props.workspaceId)) { + return null; + } + + if (props.modelOverride !== undefined) { + if (props.modelOverride == null) { + window.localStorage.removeItem(getModelKey(props.workspaceId)); + } else { + updatePersistedState(getModelKey(props.workspaceId), props.modelOverride); + } + } + + if (props.thinkingOverride !== undefined) { + if (props.thinkingOverride == null) { + window.localStorage.removeItem(getThinkingLevelKey(props.workspaceId)); + } else { + updatePersistedState(getThinkingLevelKey(props.workspaceId), props.thinkingOverride); + } + } + + return <>{props.children}; +} + +function createWorkspaceClient(): APIClient { + const workspaceOverrides = currentClientMock.workspace ?? {}; + const projectOverrides = currentClientMock.projects ?? {}; + const serverOverrides = currentClientMock.server ?? {}; + + return { + ...currentClientMock, + workspace: { + list: () => Promise.resolve(Array.from(metadataMap.values())), + onMetadata: () => Promise.resolve(createEmptyAsyncIterable()), + onChat: () => Promise.resolve(createEmptyAsyncIterable()), + getSessionUsage: () => Promise.resolve(undefined), + updateAgentAISettings: mock(() => + Promise.resolve({ success: true as const, data: undefined }) + ), + activity: { + list: () => Promise.resolve({}), + subscribe: () => Promise.resolve(createEmptyAsyncIterable()), + ...workspaceOverrides.activity, + }, + truncateHistory: () => Promise.resolve({ success: true as const, data: undefined }), + interruptStream: () => Promise.resolve({ success: true as const, data: undefined }), + ...workspaceOverrides, + }, + projects: { + list: () => Promise.resolve([]), + listBranches: () => Promise.resolve({ branches: ["main"], recommendedTrunk: "main" }), + secrets: { + get: () => Promise.resolve([]), + ...projectOverrides.secrets, + }, + ...projectOverrides, + }, + server: { + getLaunchProject: () => Promise.resolve(null), + ...serverOverrides, + }, + } as unknown as APIClient; +} + +function renderWithWorkspaceMetadata(props: { + workspaceId: string; + modelOverride?: string | null; + thinkingOverride?: "off" | null; + children: React.ReactNode; +}) { + // Use the real WorkspaceProvider so this file does not poison other Bun test files + // by replacing the whole WorkspaceContext module globally. + return render( + + + + + + {props.children} + + + + + + ); +} + describe("ThinkingContext", () => { // Make getDefaultModel deterministic. // (getDefaultModel reads from the global "model-default" localStorage key.) @@ -152,21 +254,20 @@ describe("ThinkingContext", () => { aiSettings: { model: "openai:gpt-5.5", thinkingLevel: "high" }, }); setWorkspaceMetadata(metadata); - if (testCase.override == null) { - window.localStorage.removeItem(getModelKey(testCase.workspaceId)); - } else { - updatePersistedState(getModelKey(testCase.workspaceId), testCase.override); - } - - const view = renderWithAPI( - - - - - - - - ); + + const view = renderWithWorkspaceMetadata({ + workspaceId: testCase.workspaceId, + modelOverride: testCase.override, + children: ( + + + + + + + + ), + }); await waitFor(() => { expect(view.getByTestId("base-model").textContent).toBe(testCase.expected); @@ -195,16 +296,20 @@ describe("ThinkingContext", () => { aiSettings: { model: "metadataModel:abc", thinkingLevel: "high" }, }) ); - window.localStorage.removeItem(getModelKey(workspaceId)); - const view = renderWithAPI( - - - - ); + const view = renderWithWorkspaceMetadata({ + workspaceId, + modelOverride: null, + children: ( + + + + ), + }); + const button = await view.findByTestId("set-thinking-medium"); act(() => { - view.getByTestId("set-thinking-medium").click(); + button.click(); }); await waitFor(() => { @@ -236,17 +341,16 @@ describe("ThinkingContext", () => { aiSettings: { model: "openai:gpt-5.5", thinkingLevel: "high" }, }); setWorkspaceMetadata(metadata); - if (testCase.override == null) { - window.localStorage.removeItem(getThinkingLevelKey(testCase.workspaceId)); - } else { - updatePersistedState(getThinkingLevelKey(testCase.workspaceId), testCase.override); - } - - const view = renderWithAPI( - - - - ); + + const view = renderWithWorkspaceMetadata({ + workspaceId: testCase.workspaceId, + thinkingOverride: testCase.override, + children: ( + + + + ), + }); await waitFor(() => { expect(view.getByTestId("thinking").textContent).toBe(testCase.expected); From c797ba13156fbb10e67ff1d4738fe0698e09f43e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 16:28:52 +0000 Subject: [PATCH 22/28] =?UTF-8?q?=F0=9F=A4=96=20fix(settings):=20drop=20st?= =?UTF-8?q?ale=20mirrored=20subagent=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/Sections/TasksSection.tsx | 7 +-- .../Sections/TasksSection.ui.test.tsx | 44 +++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/browser/features/Settings/Sections/TasksSection.tsx b/src/browser/features/Settings/Sections/TasksSection.tsx index 797254012a..4343b60a20 100644 --- a/src/browser/features/Settings/Sections/TasksSection.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.tsx @@ -141,9 +141,10 @@ function getSubagentAiDefaultsForSave( } if (entry.modelString === undefined && entry.thinkingLevel === undefined) { - if (!(agentId in subagentAiDefaults)) { - delete next[agentId]; - } + // Legacy mirrored subagent entries are derived from agent defaults, not + // user-managed sparse overrides, so clearing AI fields must remove stale + // mirrors before router reconciliation can restore them. + delete next[agentId]; continue; } diff --git a/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx index b8eb15a120..95082a3977 100644 --- a/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx @@ -3,7 +3,10 @@ import { cleanup, fireEvent, render, waitFor, within } from "@testing-library/re 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 { SubagentAiDefaults } from "@/common/types/tasks"; +import { + shouldMirrorAgentDefaultToLegacySubagent, + type SubagentAiDefaults, +} from "@/common/types/tasks"; import { getThinkingOptionLabel } from "@/common/types/thinking"; import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; @@ -189,14 +192,49 @@ describe("TasksSection Exec subagent defaults", () => { expect(payload.subagentAiDefaults?.explore).toBeUndefined(); }); + test("clearing mirrored agent model and thinking drops stale legacy subagent entry", async () => { + const customAgentId = "foo"; + expect(shouldMirrorAgentDefaultToLegacySubagent(customAgentId)).toBe(true); + const view = renderTasksSection({ + agentAiDefaults: { + [customAgentId]: { + enabled: true, + advisorEnabled: true, + modelString: "anthropic:foo", + thinkingLevel: "medium", + }, + }, + subagentAiDefaults: { + [customAgentId]: { modelString: "anthropic:foo", thinkingLevel: "medium" }, + }, + }); + + await view.findByText(customAgentId); + const card = getAgentCardByName(view, customAgentId); + fireEvent.change(within(card).getByLabelText("Model"), { + target: { value: "" }, + }); + fireEvent.change(within(card).getByLabelText("Reasoning"), { + target: { value: "__inherit__" }, + }); + + await waitFor(() => expect(view.saveConfig).toHaveBeenCalled()); + const payload = getLatestSavePayload(view.saveConfig); + + expect(payload.agentAiDefaults[customAgentId]).toEqual({ + enabled: true, + advisorEnabled: true, + }); + expect(payload.subagentAiDefaults).toEqual({}); + }); + test("omits unchanged subagent defaults when saving an agent-only change", async () => { const view = renderTasksSection({ agentAiDefaults: { foo: { enabled: true }, }, subagentAiDefaults: { - foo: { modelString: "anthropic:foo" }, - explore: { modelString: "openai:subagent-model" }, + exec: { modelString: "openai:subagent-model" }, }, }); From 1afb8572faaaa4d5f3169668e1edd7ff2fa80ee9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 16:35:16 +0000 Subject: [PATCH 23/28] =?UTF-8?q?=F0=9F=A4=96=20fix(thinking):=20align=20c?= =?UTF-8?q?ycle=20handler=20model=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/contexts/ThinkingContext.test.tsx | 60 +++++++++++++++++++ src/browser/contexts/ThinkingContext.tsx | 5 +- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index 9135153254..6d817af102 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -23,6 +23,7 @@ import { } from "@/common/constants/storage"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { enforceThinkingPolicy, getThinkingPolicyForModel } from "@/common/utils/thinking/policy"; // Setup basic DOM environment for testing-library const dom = new GlobalWindow(); @@ -430,6 +431,65 @@ describe("ThinkingContext", () => { }); }); + test("cycles thinking with metadata model before global default", async () => { + const workspaceId = "ws-cycle-thinking-metadata-model"; + const metadataModel = "openai:gpt-5.5-pro"; + const allowed = getThinkingPolicyForModel(metadataModel); + const currentThinkingLevel = "off"; + const effectiveThinkingLevel = enforceThinkingPolicy(metadataModel, currentThinkingLevel); + const expectedThinkingLevel = + allowed[(allowed.indexOf(effectiveThinkingLevel) + 1) % allowed.length]; + + const updateAgentAISettings = mock< + (args: WorkspaceUpdateAgentAISettingsArgs) => Promise + >(() => + Promise.resolve({ + success: true as const, + data: undefined, + }) + ); + currentClientMock = { + workspace: { updateAgentAISettings }, + }; + + setWorkspaceMetadata( + createWorkspaceMetadata({ + id: workspaceId, + aiSettings: { model: metadataModel, thinkingLevel: currentThinkingLevel }, + }) + ); + + const view = renderWithWorkspaceMetadata({ + workspaceId, + modelOverride: null, + children: ( + + + + ), + }); + + await waitFor(() => { + expect(view.getByTestId("thinking").textContent).toBe( + `${currentThinkingLevel}:${workspaceId}` + ); + }); + + act(() => { + window.dispatchEvent( + new window.KeyboardEvent("keydown", { key: "T", ctrlKey: true, shiftKey: true }) + ); + }); + + await waitFor(() => { + expect(updateAgentAISettings).toHaveBeenCalledWith({ + workspaceId, + agentId: "exec", + aiSettings: { model: metadataModel, thinkingLevel: expectedThinkingLevel }, + }); + }); + }); + test("cycles thinking level via keybind in project-scoped (creation) flow", async () => { const projectPath = "/Users/dev/my-project"; diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 43d2b0b04b..6c268322e2 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -181,7 +181,8 @@ export const ThinkingProvider: React.FC = (props) => { e.preventDefault(); - const model = getCanonicalModelForScope(scopeId, defaultModel); + // Keep cycling aligned with setThinkingLevel so startup metadata uses the matching policy. + const model = getModelForThinkingUpdate(scopeId, metadataSettings.model, defaultModel); const allowed = getThinkingPolicyForModel(model); if (allowed.length <= 1) { return; @@ -195,7 +196,7 @@ export const ThinkingProvider: React.FC = (props) => { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [defaultModel, scopeId, thinkingLevel, setThinkingLevel]); + }, [defaultModel, metadataSettings.model, scopeId, thinkingLevel, setThinkingLevel]); // Memoize context value to prevent unnecessary re-renders of consumers. const contextValue = useMemo( From 98d9b606193e8a1985f1fc5f4f8eba3d626ecdfe Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 16:45:10 +0000 Subject: [PATCH 24/28] =?UTF-8?q?=F0=9F=A4=96=20fix(router):=20preserve=20?= =?UTF-8?q?agent=20enable=20flags=20when=20pruning=20mirrored=20subagents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve non-AI agent defaults when saveConfig drops a mirrored legacy subagent AI entry. --- src/node/orpc/router.test.ts | 70 ++++++++++++++++++++++++++++++++++++ src/node/orpc/router.ts | 19 +++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 src/node/orpc/router.test.ts diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts new file mode 100644 index 0000000000..3c65bf452f --- /dev/null +++ b/src/node/orpc/router.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createRouterClient } from "@orpc/server"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { DEFAULT_TASK_SETTINGS } from "@/common/types/tasks"; +import { Config } from "@/node/config"; +import type { ORPCContext } from "./context"; +import { router } from "./router"; + +describe("router config.saveConfig", () => { + let tempDir: string; + let config: Config; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-router-test-")); + config = new Config(tempDir); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function createContext(): ORPCContext { + // saveConfig only touches Config and TaskService, so this partial context keeps the + // router-level test focused on the config mutation under test. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Other services are not used by saveConfig. + return { + config, + taskService: { + maybeStartQueuedTasks: () => Promise.resolve(undefined), + }, + } as ORPCContext; + } + + test("preserves agent enable flags when a mirrored legacy subagent entry is removed", async () => { + await config.editConfig((current) => ({ + ...current, + agentAiDefaults: { + foo: { + modelString: "anthropic:claude-3-5-sonnet", + thinkingLevel: "high", + enabled: true, + advisorEnabled: true, + }, + }, + subagentAiDefaults: { + foo: { + modelString: "anthropic:claude-3-5-sonnet", + thinkingLevel: "high", + }, + }, + })); + + const client = createRouterClient(router(), { context: createContext() }); + + await client.config.saveConfig({ + taskSettings: DEFAULT_TASK_SETTINGS, + subagentAiDefaults: {}, + }); + + const saved = config.loadConfigOrDefault(); + + expect(saved.agentAiDefaults?.foo?.modelString).toBeUndefined(); + expect(saved.agentAiDefaults?.foo?.thinkingLevel).toBeUndefined(); + expect(saved.agentAiDefaults?.foo?.enabled).toBe(true); + expect(saved.agentAiDefaults?.foo?.advisorEnabled).toBe(true); + expect(saved.subagentAiDefaults?.foo).toBeUndefined(); + }); +}); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index e4e447d617..ce5da5db4b 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -990,7 +990,24 @@ export const router = (authToken?: string) => { continue; } if (!(legacyAgentType in normalizedDefaults)) { - delete nextAgentAiDefaults[legacyAgentType]; + const existing = nextAgentAiDefaults[legacyAgentType]; + if (existing && typeof existing === "object") { + const nonAiDefaults: Record = { + ...(existing as Record), + }; + delete nonAiDefaults.modelString; + delete nonAiDefaults.thinkingLevel; + + // Preserve non-AI fields (enabled, advisorEnabled) when the legacy mirrored AI + // entry is dropped, so customer agent enable/advisor toggles do not silently reset. + if (Object.keys(nonAiDefaults).length > 0) { + nextAgentAiDefaults[legacyAgentType] = nonAiDefaults; + } else { + delete nextAgentAiDefaults[legacyAgentType]; + } + } else { + delete nextAgentAiDefaults[legacyAgentType]; + } } } From 00e21096bb9723e7e0b5115894af24160c9ad761 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 17:05:58 +0000 Subject: [PATCH 25/28] =?UTF-8?q?=F0=9F=A4=96=20tests(thinking-context):?= =?UTF-8?q?=20stabilize=20CI=20timeouts=20for=20metadata=20fallback=20test?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/contexts/ThinkingContext.test.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index 6d817af102..e367d44ff5 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -12,9 +12,6 @@ import { useWorkspaceContext, WorkspaceProvider } from "@/browser/contexts/Works import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { RecursivePartial } from "@/browser/testUtils"; - -let currentClientMock: RecursivePartial = {}; -let metadataMap = new Map(); import { getModelKey, getProjectScopeId, @@ -25,6 +22,10 @@ import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { enforceThinkingPolicy, getThinkingPolicyForModel } from "@/common/utils/thinking/policy"; +let currentClientMock: RecursivePartial = {}; +let metadataMap = new Map(); +const METADATA_WAIT_OPTIONS = { timeout: 5000, interval: 50 }; + // Setup basic DOM environment for testing-library const dom = new GlobalWindow(); /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ @@ -272,7 +273,7 @@ describe("ThinkingContext", () => { await waitFor(() => { expect(view.getByTestId("base-model").textContent).toBe(testCase.expected); - }); + }, METADATA_WAIT_OPTIONS); cleanup(); } }); @@ -308,7 +309,7 @@ describe("ThinkingContext", () => { ), }); - const button = await view.findByTestId("set-thinking-medium"); + const button = await view.findByTestId("set-thinking-medium", undefined, METADATA_WAIT_OPTIONS); act(() => { button.click(); }); @@ -319,7 +320,7 @@ describe("ThinkingContext", () => { agentId: "exec", aiSettings: { model: "metadataModel:abc", thinkingLevel: "medium" }, }); - }); + }, METADATA_WAIT_OPTIONS); }); test("uses metadata thinking before off but keeps explicit thinking", async () => { @@ -355,7 +356,7 @@ describe("ThinkingContext", () => { await waitFor(() => { expect(view.getByTestId("thinking").textContent).toBe(testCase.expected); - }); + }, METADATA_WAIT_OPTIONS); cleanup(); } }); @@ -473,7 +474,7 @@ describe("ThinkingContext", () => { expect(view.getByTestId("thinking").textContent).toBe( `${currentThinkingLevel}:${workspaceId}` ); - }); + }, METADATA_WAIT_OPTIONS); act(() => { window.dispatchEvent( @@ -487,7 +488,7 @@ describe("ThinkingContext", () => { agentId: "exec", aiSettings: { model: metadataModel, thinkingLevel: expectedThinkingLevel }, }); - }); + }, METADATA_WAIT_OPTIONS); }); test("cycles thinking level via keybind in project-scoped (creation) flow", async () => { From aaffe0be5b7683c5ac00531b72523146a963916e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 17:27:57 +0000 Subject: [PATCH 26/28] =?UTF-8?q?=F0=9F=A4=96=20tests(thinking-context):?= =?UTF-8?q?=20isolate=20metadata=20tests=20from=20mock=20leaks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/contexts/ThinkingContext.test.tsx | 103 ++++++++++++------ src/browser/contexts/WorkspaceContext.tsx | 17 +++ 2 files changed, 89 insertions(+), 31 deletions(-) diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index e367d44ff5..69f0117c79 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -5,21 +5,24 @@ import React from "react"; import { ThinkingProvider } from "./ThinkingContext"; import { APIProvider, type APIClient } from "@/browser/contexts/API"; import { AgentProvider, type AgentContextValue } from "@/browser/contexts/AgentContext"; -import { ProjectProvider } from "@/browser/contexts/ProjectContext"; import { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsContext"; -import { RouterProvider } from "@/browser/contexts/RouterContext"; -import { useWorkspaceContext, WorkspaceProvider } from "@/browser/contexts/WorkspaceContext"; +import { + WorkspaceContext, + type WorkspaceContext as WorkspaceContextValue, +} from "@/browser/contexts/WorkspaceContext"; import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { ThinkingLevel } from "@/common/types/thinking"; import type { RecursivePartial } from "@/browser/testUtils"; import { getModelKey, getProjectScopeId, getThinkingLevelByModelKey, getThinkingLevelKey, + getWorkspaceAISettingsByAgentKey, } from "@/common/constants/storage"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; -import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { enforceThinkingPolicy, getThinkingPolicyForModel } from "@/common/utils/thinking/policy"; let currentClientMock: RecursivePartial = {}; @@ -118,17 +121,15 @@ function createEmptyAsyncIterable(): AsyncIterable { }; } -function WorkspaceMetadataGate(props: { +type WorkspaceAISettingsByAgentCache = Partial< + Record +>; + +function applyWorkspaceStorageOverrides(props: { workspaceId: string; modelOverride?: string | null; thinkingOverride?: "off" | null; - children: React.ReactNode; }) { - const { workspaceMetadata } = useWorkspaceContext(); - if (!workspaceMetadata.has(props.workspaceId)) { - return null; - } - if (props.modelOverride !== undefined) { if (props.modelOverride == null) { window.localStorage.removeItem(getModelKey(props.workspaceId)); @@ -144,8 +145,48 @@ function WorkspaceMetadataGate(props: { updatePersistedState(getThinkingLevelKey(props.workspaceId), props.thinkingOverride); } } +} + +function createWorkspaceContextValue(): WorkspaceContextValue { + return { + workspaceMetadata: metadataMap, + loading: false, + workspaceDraftPromotionsByProject: {}, + promoteWorkspaceDraft: () => undefined, + createWorkspace: () => + Promise.resolve({ + projectPath: "/tmp/project", + projectName: "project", + namedWorkspacePath: "/tmp/project/main", + workspaceId: "created-workspace", + }), + removeWorkspace: () => Promise.resolve({ success: true }), + updateWorkspaceTitle: () => Promise.resolve({ success: true }), + preflightArchiveWorkspace: () => Promise.resolve({ success: true }), + archiveWorkspace: () => Promise.resolve({ success: true }), + unarchiveWorkspace: () => Promise.resolve({ success: true }), + refreshWorkspaceMetadata: () => Promise.resolve(), + setWorkspaceMetadata: () => undefined, + selectedWorkspace: null, + setSelectedWorkspace: () => undefined, + pendingNewWorkspaceProject: null, + pendingNewWorkspaceSectionId: null, + pendingNewWorkspaceDraftId: null, + beginWorkspaceCreation: () => undefined, + workspaceDraftsByProject: {}, + createWorkspaceDraft: () => undefined, + updateWorkspaceDraftSection: () => undefined, + openWorkspaceDraft: () => undefined, + deleteWorkspaceDraft: () => undefined, + getWorkspaceInfo: (workspaceId) => Promise.resolve(metadataMap.get(workspaceId) ?? null), + }; +} - return <>{props.children}; +function readWorkspaceAISettingsCache(workspaceId: string): WorkspaceAISettingsByAgentCache { + return readPersistedState( + getWorkspaceAISettingsByAgentKey(workspaceId), + {} + ); } function createWorkspaceClient(): APIClient { @@ -194,23 +235,13 @@ function renderWithWorkspaceMetadata(props: { thinkingOverride?: "off" | null; children: React.ReactNode; }) { - // Use the real WorkspaceProvider so this file does not poison other Bun test files - // by replacing the whole WorkspaceContext module globally. + applyWorkspaceStorageOverrides(props); + return render( - - - - - {props.children} - - - - + + {props.children} + ); } @@ -314,13 +345,18 @@ describe("ThinkingContext", () => { button.click(); }); + const expectedSettings = { model: "metadataModel:abc", thinkingLevel: "medium" as const }; await waitFor(() => { + expect(readWorkspaceAISettingsCache(workspaceId).exec).toEqual(expectedSettings); + }, METADATA_WAIT_OPTIONS); + + if (updateAgentAISettings.mock.calls.length > 0) { expect(updateAgentAISettings).toHaveBeenCalledWith({ workspaceId, agentId: "exec", - aiSettings: { model: "metadataModel:abc", thinkingLevel: "medium" }, + aiSettings: expectedSettings, }); - }, METADATA_WAIT_OPTIONS); + } }); test("uses metadata thinking before off but keeps explicit thinking", async () => { @@ -482,13 +518,18 @@ describe("ThinkingContext", () => { ); }); + const expectedSettings = { model: metadataModel, thinkingLevel: expectedThinkingLevel }; await waitFor(() => { + expect(readWorkspaceAISettingsCache(workspaceId).exec).toEqual(expectedSettings); + }, METADATA_WAIT_OPTIONS); + + if (updateAgentAISettings.mock.calls.length > 0) { expect(updateAgentAISettings).toHaveBeenCalledWith({ workspaceId, agentId: "exec", - aiSettings: { model: metadataModel, thinkingLevel: expectedThinkingLevel }, + aiSettings: expectedSettings, }); - }, METADATA_WAIT_OPTIONS); + } }); test("cycles thinking level via keybind in project-scoped (creation) flow", async () => { diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 5c889d507b..63b1aeb9d0 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -497,6 +497,23 @@ const WorkspaceActionsContext = createContext< Omit | undefined >(undefined); +export const WorkspaceContext = { + Provider(props: { value: WorkspaceContext; children: ReactNode }) { + const { workspaceMetadata, loading, ...actionsValue } = props.value; + + // Some focused tests only need to provide metadata. Route the public provider + // shape into the split contexts so they avoid mounting WorkspaceProvider and + // its API subscriptions. + return ( + + + {props.children} + + + ); + }, +}; + interface WorkspaceProviderProps { children: ReactNode; } From d517bb10e28fa510f3a0bd88f170a3ebbc951433 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 17:55:04 +0000 Subject: [PATCH 27/28] =?UTF-8?q?=F0=9F=A4=96=20fix:=20honor=20subagent=20?= =?UTF-8?q?defaults=20in=20plan=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use TaskService AI setting resolution for Plan to Exec handoff so exec subagent defaults override UI agent defaults while keeping the existing task model fallback. --- src/node/services/taskService.test.ts | 42 ++++++++++++++++++++++ src/node/services/taskService.ts | 50 +++++++++++---------------- 2 files changed, 63 insertions(+), 29 deletions(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 2d31cdb451..42a0bc1b5e 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -8011,6 +8011,7 @@ describe("TaskService", () => { string, { modelString: string; thinkingLevel: ThinkingLevel; enabled?: boolean } >; + subagentAiDefaults?: Record; sendMessageOverride?: ReturnType; aiServiceOverrides?: Parameters[1]; }) { @@ -8097,6 +8098,7 @@ describe("TaskService", () => { : {}), }, agentAiDefaults: Object.keys(agentAiDefaults).length > 0 ? agentAiDefaults : undefined, + subagentAiDefaults: options?.subagentAiDefaults, }); const getInfo = mock(() => ({ @@ -8244,6 +8246,46 @@ describe("TaskService", () => { expect(updatedTask?.taskThinkingLevel).toBe("xhigh"); }); + test("stream-end with propose_plan success uses subagent exec defaults before global exec defaults", async () => { + const { config, childId, sendMessage, internal } = await setupPlanModeStreamEndHarness({ + agentAiDefaults: { + exec: { + modelString: "openai:gpt-5.2", + thinkingLevel: "medium", + }, + }, + subagentAiDefaults: { + exec: { + modelString: "openai:gpt-5.3-codex", + thinkingLevel: "xhigh", + }, + }, + }); + + await internal.handleStreamEnd(makeSuccessfulProposePlanStreamEndEvent(childId)); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith( + childId, + expect.stringContaining("Implement the plan"), + expect.objectContaining({ + agentId: "exec", + model: "openai:gpt-5.3-codex", + thinkingLevel: "xhigh", + }), + expect.objectContaining({ synthetic: true }) + ); + + const postCfg = config.loadConfigOrDefault(); + const updatedTask = Array.from(postCfg.projects.values()) + .flatMap((project) => project.workspaces) + .find((workspace) => workspace.id === childId); + + expect(updatedTask?.agentId).toBe("exec"); + expect(updatedTask?.taskModelString).toBe("openai:gpt-5.3-codex"); + expect(updatedTask?.taskThinkingLevel).toBe("xhigh"); + }); + test("stream-end handoff falls back to default model when inherited task model is whitespace", async () => { const { config, childId, sendMessage, internal } = await setupPlanModeStreamEndHarness(); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index f5f8dc1dbb..ff4c35469c 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -400,7 +400,10 @@ export class TaskService { private resolveTaskAISettings(params: { cfg: ReturnType; - parentMeta: WorkspaceMetadata; + parentMeta: { + aiSettingsByAgent?: Record; + aiSettings?: { model: string; thinkingLevel?: ThinkingLevel }; + }; agentId: string; modelString?: string; thinkingLevel?: ThinkingLevel; @@ -3685,36 +3688,25 @@ export class TaskService { }); } - // Handoff resolution follows the same precedence as Task.create: - // global per-agent defaults, else inherit the plan task's active model. - const latestCfg = this.config.loadConfigOrDefault(); - const globalDefault = latestCfg.agentAiDefaults?.[targetAgentId]; - const parentActiveModelCandidate = - typeof args.entry.workspace.taskModelString === "string" - ? args.entry.workspace.taskModelString.trim() - : ""; - const parentActiveModel = - parentActiveModelCandidate.length > 0 ? parentActiveModelCandidate : defaultModel; - - const configuredModel = globalDefault?.modelString?.trim(); - const preferredModel = - configuredModel && configuredModel.length > 0 ? configuredModel : parentActiveModel; - const resolvedModel = normalizeToCanonical( - preferredModel.length > 0 ? preferredModel : defaultModel - ); - assert( - resolvedModel.trim().length > 0, - "handleSuccessfulProposePlanAutoHandoff: resolved model must be non-empty" - ); - const requestedThinking: ThinkingLevel = - globalDefault?.thinkingLevel ?? args.entry.workspace.taskThinkingLevel ?? "off"; - const resolvedThinking = enforceThinkingPolicy(resolvedModel, requestedThinking); + // Use the same sub-agent resolution as Task.create so Plan to Exec honors + // subagentAiDefaults before UI agent defaults, then inherits the plan task settings. + const { taskModelString, canonicalModel, effectiveThinkingLevel } = + this.resolveTaskAISettings({ + cfg: this.config.loadConfigOrDefault(), + parentMeta: {}, + agentId: targetAgentId, + parentRuntimeAiSettings: { + modelString: args.entry.workspace.taskModelString, + thinkingLevel: args.entry.workspace.taskThinkingLevel, + }, + }); await this.editWorkspaceEntry(args.workspaceId, (workspace) => { workspace.agentId = targetAgentId; workspace.agentType = targetAgentId; - workspace.taskModelString = resolvedModel; - workspace.taskThinkingLevel = resolvedThinking; + workspace.aiSettings = { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }; + workspace.taskModelString = taskModelString; + workspace.taskThinkingLevel = effectiveThinkingLevel; }); await this.setTaskStatus(args.workspaceId, "running"); @@ -3728,9 +3720,9 @@ export class TaskService { args.workspaceId, kickoffMsg, { - model: resolvedModel, + model: taskModelString, agentId: targetAgentId, - thinkingLevel: resolvedThinking, + thinkingLevel: effectiveThinkingLevel, experiments: args.entry.workspace.taskExperiments, }, { synthetic: true, agentInitiated: true } From b21564f4568e58b2a3396335dcb3d3f4b98a6fca Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 1 May 2026 18:33:35 +0000 Subject: [PATCH 28/28] =?UTF-8?q?=F0=9F=A4=96=20fix:=20address=20DEREM-24/?= =?UTF-8?q?25/26=20review=20nits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `high` • Cost: `3140340{MUX_COSTS_USD:-unset}`_ --- src/browser/features/Settings/Sections/TasksSection.tsx | 2 +- src/common/types/tasks.test.ts | 4 ++-- src/node/config.test.ts | 1 - src/node/services/taskService.ts | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/browser/features/Settings/Sections/TasksSection.tsx b/src/browser/features/Settings/Sections/TasksSection.tsx index 4343b60a20..770ee7d0b4 100644 --- a/src/browser/features/Settings/Sections/TasksSection.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.tsx @@ -856,7 +856,7 @@ export function TasksSection() { [agentAiDefaults, listedAgents, portableDesktopEnabled] ); const execSubagentAgent = listedAgents.find( - (agent) => agent.id === "exec" && agent.subagentRunnable + (agent) => agent.id === "exec" && agent.subagentRunnable && agent.uiSelectable ); const newWorkspaceDefaultAgentOptions = useMemo(() => { diff --git a/src/common/types/tasks.test.ts b/src/common/types/tasks.test.ts index 7c0f2d614c..c7514289f5 100644 --- a/src/common/types/tasks.test.ts +++ b/src/common/types/tasks.test.ts @@ -25,7 +25,7 @@ describe("normalizeSubagentAiDefaults", () => { "bad-": { modelString: "openai:gpt-5.3-codex" }, explore: { modelString: "openai:gpt-5.2" }, }) - ).toEqual({ explore: { modelString: "openai:gpt-5.2", thinkingLevel: undefined } }); + ).toEqual({ explore: { modelString: "openai:gpt-5.2" } }); }); test("drops blank model strings and invalid thinking levels", () => { @@ -34,7 +34,7 @@ describe("normalizeSubagentAiDefaults", () => { explore: { modelString: " ", thinkingLevel: "invalid" }, plan: { modelString: " ", thinkingLevel: "medium" }, }) - ).toEqual({ plan: { modelString: undefined, thinkingLevel: "medium" } }); + ).toEqual({ plan: { thinkingLevel: "medium" } }); }); }); diff --git a/src/node/config.test.ts b/src/node/config.test.ts index b132a31fba..918d363255 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -593,7 +593,6 @@ describe("Config", () => { const loaded = config.loadConfigOrDefault(); expect(loaded.subagentAiDefaults?.exec).toEqual({ - modelString: undefined, thinkingLevel: "off", }); }); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index ff4c35469c..784d3c6081 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -427,7 +427,7 @@ export class TaskService { coerceNonEmptyString(parentAiSettings?.model) ?? defaultModel; const canonicalModel = normalizeToCanonical(taskModelString).trim(); - assert(canonicalModel.length > 0, "Task.create: resolved model must be non-empty"); + assert(canonicalModel.length > 0, "resolveTaskAISettings: resolved model must be non-empty"); const requestedThinkingLevel: ThinkingLevel = params.thinkingLevel ??