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 diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index 3d90459c70..69f0117c79 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -4,17 +4,30 @@ 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 { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsContext"; +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 type { RecursivePartial } from "@/browser/testUtils"; -import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; +import { readPersistedState, 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(); @@ -34,6 +47,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 ( @@ -43,10 +63,189 @@ 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 ThinkingSetterComponent: React.FC = () => { + const [, setThinkingLevel] = useThinkingLevel(); + return ( + + ); +}; + +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 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; + }, + }; +} + +type WorkspaceAISettingsByAgentCache = Partial< + Record +>; + +function applyWorkspaceStorageOverrides(props: { + workspaceId: string; + modelOverride?: string | null; + thinkingOverride?: "off" | 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); + } + } +} + +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), + }; +} + +function readWorkspaceAISettingsCache(workspaceId: string): WorkspaceAISettingsByAgentCache { + return readPersistedState( + getWorkspaceAISettingsByAgentKey(workspaceId), + {} + ); +} + +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; +}) { + applyWorkspaceStorageOverrides(props); + + return render( + + + {props.children} + + + ); +} + describe("ThinkingContext", () => { // Make getDefaultModel deterministic. // (getDefaultModel reads from the global "model-default" localStorage key.) @@ -61,15 +260,143 @@ describe("ThinkingContext", () => { ), }, }; + metadataMap = new Map(); window.localStorage.clear(); window.localStorage.setItem("model-default", JSON.stringify("openai:default")); }); afterEach(() => { cleanup(); + metadataMap = new Map(); 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" }, + }); + setWorkspaceMetadata(metadata); + + const view = renderWithWorkspaceMetadata({ + workspaceId: testCase.workspaceId, + modelOverride: testCase.override, + children: ( + + + + + + + + ), + }); + + await waitFor(() => { + expect(view.getByTestId("base-model").textContent).toBe(testCase.expected); + }, METADATA_WAIT_OPTIONS); + cleanup(); + } + }); + + 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" }, + }) + ); + + const view = renderWithWorkspaceMetadata({ + workspaceId, + modelOverride: null, + children: ( + + + + ), + }); + + const button = await view.findByTestId("set-thinking-medium", undefined, METADATA_WAIT_OPTIONS); + act(() => { + 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: expectedSettings, + }); + } + }); + + 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" }, + }); + setWorkspaceMetadata(metadata); + + const view = renderWithWorkspaceMetadata({ + workspaceId: testCase.workspaceId, + thinkingOverride: testCase.override, + children: ( + + + + ), + }); + + await waitFor(() => { + expect(view.getByTestId("thinking").textContent).toBe(testCase.expected); + }, METADATA_WAIT_OPTIONS); + cleanup(); + } + }); + test("switching models does not remount children", async () => { const workspaceId = "ws-1"; @@ -141,6 +468,70 @@ 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}` + ); + }, METADATA_WAIT_OPTIONS); + + act(() => { + window.dispatchEvent( + new window.KeyboardEvent("keydown", { key: "T", ctrlKey: true, shiftKey: true }) + ); + }); + + 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: expectedSettings, + }); + } + }); + 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 3f5879fe04..6c268322e2 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"; @@ -48,23 +50,41 @@ 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(); 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; } @@ -80,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); @@ -140,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). @@ -154,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; @@ -168,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( 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; } diff --git a/src/browser/features/Settings/Sections/TasksSection.stories.tsx b/src/browser/features/Settings/Sections/TasksSection.stories.tsx index b41ac0b9d4..724e7e6533 100644 --- a/src/browser/features/Settings/Sections/TasksSection.stories.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.stories.tsx @@ -37,6 +37,7 @@ export const Tasks: Story = { await canvas.findAllByText(/^Plan$/i); await canvas.findAllByText(/^Exec$/i); + await canvas.findByRole("group", { name: "Exec defaults" }); await canvas.findAllByText(/^Explore$/i); await canvas.findAllByText(/^Compact$/i); diff --git a/src/browser/features/Settings/Sections/TasksSection.tsx b/src/browser/features/Settings/Sections/TasksSection.tsx index ad199573f8..770ee7d0b4 100644 --- a/src/browser/features/Settings/Sections/TasksSection.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.tsx @@ -35,11 +35,19 @@ import { DEFAULT_TASK_SETTINGS, TASK_SETTINGS_LIMITS, isPlanSubagentExecutorRouting, + normalizeSubagentAiDefaults, + shouldMirrorAgentDefaultToLegacySubagent, normalizeTaskSettings, type PlanSubagentExecutorRouting, + type SubagentAiDefaults, + 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"; @@ -89,6 +97,99 @@ 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 (!shouldMirrorAgentDefaultToLegacySubagent(agentId)) { + continue; + } + + const entry = agentAiDefaults[agentId]; + if (!entry) { + delete next[agentId]; + continue; + } + + if (entry.modelString === undefined && entry.thinkingLevel === undefined) { + // 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; + } + + next[agentId] = { + modelString: entry.modelString, + thinkingLevel: entry.thinkingLevel, + }; + } + + 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"; @@ -219,10 +320,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 +454,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([]); @@ -246,10 +467,7 @@ export function TasksSection() { const saveTimerRef = useRef | null>(null); const savingRef = useRef(false); - const pendingSaveRef = useRef<{ - taskSettings: TaskSettings; - agentAiDefaults: AgentAiDefaults; - } | null>(null); + const pendingSaveRef = useRef(null); const { models, hiddenModelsForSelector } = useModelsFromSettings(); const [globalDefaultAgentIdRaw, setGlobalDefaultAgentIdRaw] = usePersistedState( @@ -279,6 +497,7 @@ export function TasksSection() { const lastSyncedTaskSettingsRef = useRef(null); const lastSyncedAgentAiDefaultsRef = useRef(null); + const lastSyncedSubagentAiDefaultsRef = useRef(null); useEffect(() => { if (!api) return; @@ -294,11 +513,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 +577,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) { @@ -391,19 +624,21 @@ 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, - }) + .saveConfig(saveBody) .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 +690,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(() => { @@ -475,11 +710,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, - }) + .saveConfig(saveBody) .catch(() => undefined) .finally(() => { savingRef.current = false; @@ -531,7 +764,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 +786,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 +855,10 @@ export function TasksSection() { }), [agentAiDefaults, listedAgents, portableDesktopEnabled] ); + const execSubagentAgent = listedAgents.find( + (agent) => agent.id === "exec" && agent.subagentRunnable && agent.uiSelectable + ); + const newWorkspaceDefaultAgentOptions = useMemo(() => { const options = uiAgents.map((agent) => ({ id: agent.id, @@ -617,6 +879,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 +916,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 +1037,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 rawInheritedThinking = uiExecEntry?.thinkingLevel ?? THINKING_LEVEL_OFF; + const clampedInheritedThinking = enforceThinkingPolicy(effectiveModel, rawInheritedThinking); + const inheritedThinkingLabel = getThinkingOptionLabel(clampedInheritedThinking, effectiveModel); + + return ( +
+
+
Exec
+
+ {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 +1121,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 +1327,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..95082a3977 --- /dev/null +++ b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx @@ -0,0 +1,393 @@ +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 { + shouldMirrorAgentDefaultToLegacySubagent, + type SubagentAiDefaults, +} from "@/common/types/tasks"; +import { getThinkingOptionLabel } from "@/common/types/thinking"; +import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; + +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:foo", + "anthropic:ui-exec", + "openai:gpt-5-pro", + "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 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); + 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(); + + 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(); + }); + + 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("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: { + exec: { 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 () => { + const view = renderTasksSection({ + agentAiDefaults: { + exec: { modelString: "anthropic:ui-exec", thinkingLevel: "medium" }, + }, + subagentAiDefaults: {}, + }); + + const row = await view.findByRole("group", { name: "Exec defaults" }); + + expect(within(row).getByText("Inherits from UI Exec: anthropic:ui-exec")).toBeTruthy(); + expect(within(row).getByText("Inherits from UI Exec: medium")).toBeTruthy(); + expect(within(row).queryByRole("button", { name: "Inherit from UI Exec" })).toBeNull(); + }); + + 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: { + exec: { modelString: "anthropic:ui-exec", thinkingLevel: "medium" }, + }, + 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(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("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: { + exec: { modelString: "openai:subagent-model", thinkingLevel: "high" }, + }, + }); + const row = await view.findByRole("group", { name: "Exec 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 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({}); + }); +}); 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 { diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index a33e4d8a4b..3ff8acd965 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"]); @@ -69,7 +73,14 @@ 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(), muxGovernorUrl: z.string().optional(), muxGovernorToken: z.string().optional(), @@ -85,6 +96,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/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/project.ts b/src/common/types/project.ts index 4236ad7844..ec8853285b 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, @@ -116,8 +120,15 @@ 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; /** 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..c7514289f5 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" } }); + }); + + test("drops blank model strings and invalid thinking levels", () => { + expect( + normalizeSubagentAiDefaults({ + explore: { modelString: " ", thinkingLevel: "invalid" }, + plan: { modelString: " ", thinkingLevel: "medium" }, + }) + ).toEqual({ plan: { 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..a489ddada1 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 }; @@ -28,15 +30,25 @@ export const DEFAULT_TASK_SETTINGS: TaskSettings = { planSubagentDefaultsToOrchestrator: false, }; +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); const result: SubagentAiDefaults = {}; for (const [agentTypeRaw, entryRaw] of Object.entries(record)) { - const agentType = 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; const entry = entryRaw as Record; @@ -58,6 +70,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.test.ts b/src/node/config.test.ts index 81a3f7a20b..918d363255 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -492,6 +492,209 @@ 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 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"), + 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({ + 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..6c31cde1f1 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -20,14 +20,17 @@ import type { UpdateChannel, } from "@/common/types/project"; import type { + AppConfigMigrations, AppConfigOnDisk, BaseProviderConfig as ProviderConfig, ProvidersConfig as CanonicalProvidersConfig, } 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"; @@ -299,6 +302,80 @@ function normalizeAiDefaultsModelStrings; +type AgentAiDefaultsConfig = NonNullable; + +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 extractAgentDefaultsFromLegacySubagents( + 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 parseOptionalPort(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) { return undefined; @@ -486,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)) { @@ -560,6 +638,7 @@ export class Config { if (routeOverridesModified) { parsed.routeOverrides = mergedRouteOverrides; + shouldInvalidateSessionUsageCaches = true; } } } @@ -595,6 +674,7 @@ export class Config { if (normalized !== parsed.defaultModel) { parsed.defaultModel = normalized; configModified = true; + shouldInvalidateSessionUsageCaches = true; } } @@ -612,49 +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; - } - - 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 }); - } + shouldInvalidateSessionUsageCaches = true; } // Config is stored as array of [path, config] pairs. @@ -700,7 +748,75 @@ 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( + extractAgentDefaultsFromLegacySubagents(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 (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, { + 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 }); + } + } + + if (configModified) { + 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 +836,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 @@ -759,8 +870,10 @@ 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, useSSH2Transport: parseOptionalBoolean(parsed.useSSH2Transport), muxGovernorUrl: parseOptionalNonEmptyString(parsed.muxGovernorUrl), @@ -936,21 +1049,31 @@ 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. + // 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); } } + 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.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 b4f9b3112a..ce5da5db4b 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"; @@ -623,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, @@ -707,20 +709,16 @@ 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, 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, }; @@ -959,16 +957,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 @@ -981,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 ?? {}; @@ -998,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]; + } } } 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", diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index ba725522d9..42a0bc1b5e 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"; @@ -123,6 +124,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 +1682,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,13 +1742,400 @@ 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("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"); + 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("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"); + 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("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"); + 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); @@ -7582,6 +8011,7 @@ describe("TaskService", () => { string, { modelString: string; thinkingLevel: ThinkingLevel; enabled?: boolean } >; + subagentAiDefaults?: Record; sendMessageOverride?: ReturnType; aiServiceOverrides?: Parameters[1]; }) { @@ -7668,6 +8098,7 @@ describe("TaskService", () => { : {}), }, agentAiDefaults: Object.keys(agentAiDefaults).length > 0 ? agentAiDefaults : undefined, + subagentAiDefaults: options?.subagentAiDefaults, }); const getInfo = mock(() => ({ @@ -7815,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 38d9a36fc0..784d3c6081 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; @@ -396,6 +397,50 @@ export class TaskService { workspace.aiSettings ); } + + private resolveTaskAISettings(params: { + cfg: ReturnType; + parentMeta: { + aiSettingsByAgent?: Record; + aiSettings?: { model: string; thinkingLevel?: ThinkingLevel }; + }; + agentId: string; + modelString?: string; + thinkingLevel?: ThinkingLevel; + parentRuntimeAiSettings?: { modelString?: string; thinkingLevel?: ThinkingLevel }; + }): { + taskModelString: string; + canonicalModel: string; + effectiveThinkingLevel: ThinkingLevel; + } { + const parentAiSettings = this.resolveWorkspaceAISettings(params.parentMeta, params.agentId); + // 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(); + assert(canonicalModel.length > 0, "resolveTaskAISettings: resolved model must be non-empty"); + + const requestedThinkingLevel: ThinkingLevel = + params.thinkingLevel ?? + subagentDefault?.thinkingLevel ?? + agentDefault?.thinkingLevel ?? + parentRuntimeAiSettings?.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 +1189,14 @@ 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, + parentRuntimeAiSettings: args.parentRuntimeAiSettings, + }); const parentRuntimeConfig = parentMeta.runtimeConfig; const taskRuntimeConfig: RuntimeConfig = parentRuntimeConfig; @@ -3659,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"); @@ -3702,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 } diff --git a/src/node/services/tools/task.test.ts b/src/node/services/tools/task.test.ts index a2336194de..9f6d7c52b4 100644 --- a/src/node/services/tools/task.test.ts +++ b/src/node/services/tools/task.test.ts @@ -98,6 +98,44 @@ describe("task tool", () => { expectQueuedOrRunningTaskToolResult(result, { status: "queued", taskId: "child-task" }); }); + 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; + 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: "med" }, + 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(); + expect(createArgs?.parentRuntimeAiSettings).toEqual({ + modelString: "openai:gpt-4o-mini", + thinkingLevel: "medium", + }); + }); + 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..18feff807b 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, @@ -18,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 @@ -44,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"; @@ -286,12 +303,10 @@ 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); - + // 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) { @@ -306,9 +321,8 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { agentType: requestedAgentId, prompt: launch.prompt, title, - modelString, - thinkingLevel, experiments: config.experiments, + ...(parentRuntimeAiSettings != null ? { parentRuntimeAiSettings } : {}), bestOf: taskGroupId != null ? {