diff --git a/src/browser/App.tsx b/src/browser/App.tsx index f599e83d551..5c1073a1ef2 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -88,6 +88,7 @@ import { clearPendingWorkspaceAiSettings, markPendingWorkspaceAiSettings, resolveEffectiveComposerModel, + updateWorkspaceAgentAISettings, } from "@/browser/utils/workspaceAiSettingsSync"; import { AuthTokenModal } from "@/browser/components/AuthTokenModal/AuthTokenModal"; @@ -580,12 +581,11 @@ function AppInner() { reasoningMode, }); - api.workspace - .updateAgentAISettings({ - workspaceId, - agentId: normalizedAgentId, - aiSettings: { model, thinkingLevel: normalized, reasoningMode }, - }) + updateWorkspaceAgentAISettings(api, { + workspaceId, + agentId: normalizedAgentId, + aiSettings: { model, thinkingLevel: normalized, reasoningMode }, + }) .then((result) => { if (!result.success) { clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); @@ -661,12 +661,11 @@ function AppInner() { reasoningMode: next, }); - api.workspace - .updateAgentAISettings({ - workspaceId, - agentId: normalizedAgentId, - aiSettings: { model, thinkingLevel, reasoningMode: next }, - }) + updateWorkspaceAgentAISettings(api, { + workspaceId, + agentId: normalizedAgentId, + aiSettings: { model, thinkingLevel, reasoningMode: next }, + }) .then((result) => { if (!result.success) { clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx index aab70da5409..f00615ee80a 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx @@ -3,6 +3,7 @@ import { act, cleanup, render, waitFor } from "@testing-library/react"; import { installDom } from "../../../../tests/ui/dom"; import { AgentProvider } from "@/browser/contexts/AgentContext"; +import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import { consumeWorkspaceModelChange } from "@/browser/utils/modelChange"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { @@ -27,14 +28,43 @@ const noop = () => { // intentional noop for tests }; -function SyncHarness(props: { workspaceId: string; agentId: string }) { +const DEFAULT_AGENTS: AgentDefinitionDescriptor[] = [ + { + id: "exec", + scope: "built-in", + name: "Exec", + uiSelectable: true, + subagentRunnable: false, + }, + { + id: "plan", + scope: "built-in", + name: "Plan", + uiSelectable: true, + subagentRunnable: false, + }, + { + id: "auto", + scope: "built-in", + name: "Auto", + uiSelectable: true, + subagentRunnable: false, + }, +]; + +function SyncHarness(props: { + workspaceId: string; + agentId: string; + agents?: AgentDefinitionDescriptor[]; +}) { + const agents = props.agents ?? DEFAULT_AGENTS; return ( agent.id === props.agentId), + agents, loaded: true, loadFailed: false, refresh: () => Promise.resolve(), @@ -48,8 +78,14 @@ function SyncHarness(props: { workspaceId: string; agentId: string }) { ); } -function renderSync(props: { workspaceId: string; agentId: string }) { - return render(); +function renderSync(props: { + workspaceId: string; + agentId: string; + agents?: AgentDefinitionDescriptor[]; +}) { + return render( + + ); } describe("WorkspaceModeAISync", () => { @@ -95,7 +131,7 @@ describe("WorkspaceModeAISync", () => { expect(consumeWorkspaceModelChange(workspaceId, planModel)).toBe("agent"); }); - test("prefers configured agent defaults over workspace-by-agent overrides", async () => { + test("prefers a hydrated workspace bucket over configured agent defaults", async () => { const workspaceId = nextWorkspaceId(); const configuredModel = "anthropic:claude-haiku-4-5"; @@ -116,15 +152,145 @@ describe("WorkspaceModeAISync", () => { renderSync({ workspaceId, agentId: "exec" }); await waitFor(() => { - expect(readPersistedState(getModelKey(workspaceId), "")).toBe(configuredModel); - expect(readPersistedState(getThinkingLevelKey(workspaceId), "high")).toBe(configuredThinking); + expect(readPersistedState(getModelKey(workspaceId), "")).toBe(workspaceModel); + expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe(workspaceThinking); }); }); - test("ignores workspace-by-agent values when settings are inherit", async () => { + test("preserves a hydrated workspace bucket when descriptors arrive", async () => { + const workspaceId = nextWorkspaceId(); + const hydratedModel = "anthropic:claude-sonnet-4-6"; + const hydratedThinking = "high"; + const definitionModel = "openai:gpt-5.6-sol"; + const agents: AgentDefinitionDescriptor[] = [ + { + id: "exec", + scope: "built-in", + name: "Exec", + uiSelectable: true, + subagentRunnable: false, + ownAiDefaults: { model: definitionModel, thinkingLevel: "low" }, + }, + ]; + + updatePersistedState(AGENT_AI_DEFAULTS_KEY, {}); + updatePersistedState(getWorkspaceAISettingsByAgentKey(workspaceId), { + exec: { model: hydratedModel, thinkingLevel: hydratedThinking }, + }); + updatePersistedState(getModelKey(workspaceId), hydratedModel); + updatePersistedState(getThinkingLevelKey(workspaceId), hydratedThinking); + + const { rerender } = renderSync({ workspaceId, agentId: "exec", agents: [] }); + await waitFor(() => { + expect(readPersistedState(getModelKey(workspaceId), "")).toBe(hydratedModel); + }); + + rerender(); + + await waitFor(() => { + expect(readPersistedState(getModelKey(workspaceId), "")).toBe(hydratedModel); + expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe(hydratedThinking); + }); + }); + test("applies custom agent definition defaults on an explicit switch", async () => { const workspaceId = nextWorkspaceId(); + const existingModel = "anthropic:claude-sonnet-4-5"; + const definitionModel = "openai:gpt-5.6-sol"; + const agents: AgentDefinitionDescriptor[] = [ + { + id: "plan", + scope: "built-in", + name: "Plan", + uiSelectable: true, + subagentRunnable: false, + }, + { + id: "researcher", + scope: "project", + name: "Researcher", + uiSelectable: true, + subagentRunnable: false, + base: "exec", + aiDefaults: { model: definitionModel, thinkingLevel: "high" }, + ownAiDefaults: { model: definitionModel, thinkingLevel: "high" }, + }, + ]; + + updatePersistedState(AGENT_AI_DEFAULTS_KEY, {}); + updatePersistedState(getModelKey(workspaceId), existingModel); + updatePersistedState(getThinkingLevelKey(workspaceId), "off"); + + const { rerender } = renderSync({ workspaceId, agentId: "plan", agents }); - const existingModel = "some-legacy-model"; + await waitFor(() => { + expect(readPersistedState(getModelKey(workspaceId), "")).toBe(existingModel); + }); + + rerender(); + + await waitFor(() => { + expect(readPersistedState(getModelKey(workspaceId), "")).toBe(definitionModel); + expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe("high"); + }); + expect(consumeWorkspaceModelChange(workspaceId, definitionModel)).toBe("agent"); + }); + + test("configured base defaults outrank inherited definition defaults", async () => { + const workspaceId = nextWorkspaceId(); + const existingModel = "anthropic:claude-sonnet-4-5"; + const agents: AgentDefinitionDescriptor[] = [ + { + id: "plan", + scope: "built-in", + name: "Plan", + uiSelectable: true, + subagentRunnable: false, + }, + { + id: "exec", + scope: "built-in", + name: "Exec", + uiSelectable: true, + subagentRunnable: false, + aiDefaults: { thinkingLevel: "low" }, + ownAiDefaults: { thinkingLevel: "low" }, + }, + { + id: "researcher", + scope: "project", + name: "Researcher", + uiSelectable: true, + subagentRunnable: false, + base: "exec", + // Effective UI defaults include exec's inherited definition value, but + // the child has no definition default of its own. + aiDefaults: { thinkingLevel: "low" }, + }, + ]; + + updatePersistedState(AGENT_AI_DEFAULTS_KEY, { + exec: { thinkingLevel: "high" }, + }); + updatePersistedState(getModelKey(workspaceId), existingModel); + updatePersistedState(getThinkingLevelKey(workspaceId), "off"); + + const { rerender } = renderSync({ workspaceId, agentId: "plan", agents }); + await waitFor(() => { + expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe("off"); + }); + + rerender(); + + await waitFor(() => { + expect(readPersistedState(getModelKey(workspaceId), "")).toBe(existingModel); + expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe("high"); + }); + }); + + test("restores a hydrated workspace bucket when settings inherit", async () => { + const workspaceId = nextWorkspaceId(); + + const existingModel = "anthropic:claude-sonnet-4-5"; const existingThinking = "off"; // Inherit in Settings removes explicit per-agent defaults from AGENT_AI_DEFAULTS_KEY. @@ -139,8 +305,8 @@ describe("WorkspaceModeAISync", () => { renderSync({ workspaceId, agentId: "exec" }); await waitFor(() => { - expect(readPersistedState(getModelKey(workspaceId), "")).toBe(existingModel); - expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe(existingThinking); + expect(readPersistedState(getModelKey(workspaceId), "")).toBe("openai:gpt-5.2"); + expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe("medium"); }); }); @@ -179,10 +345,10 @@ describe("WorkspaceModeAISync", () => { expect(consumeWorkspaceModelChange(workspaceId, execWorkspaceModel)).toBe("agent"); }); - test("ignores same-agent workspace overrides when agent defaults are missing", async () => { + test("restores a hydrated custom-agent bucket during background sync", async () => { const workspaceId = nextWorkspaceId(); - const existingModel = "some-legacy-model"; + const existingModel = "anthropic:claude-sonnet-4-5"; const existingThinking = "high"; updatePersistedState(AGENT_AI_DEFAULTS_KEY, { @@ -198,8 +364,8 @@ describe("WorkspaceModeAISync", () => { renderSync({ workspaceId, agentId: "custom" }); await waitFor(() => { - expect(readPersistedState(getModelKey(workspaceId), "")).toBe(existingModel); - expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe(existingThinking); + expect(readPersistedState(getModelKey(workspaceId), "")).toBe("openai:gpt-5.2-pro"); + expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe("medium"); }); }); diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx index 8fd18e89926..238c5bd5a9d 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx @@ -54,9 +54,8 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { prevAgentIdRef.current = normalizedAgentId; prevWorkspaceIdRef.current = workspaceId; - // Read at call time rather than subscribing: this cache only feeds explicit agent - // switches, yet every model/thinking/pro-mode change rewrites it, so a subscription - // would re-run this effect and re-apply the mode default over the user's own pick. + // Read at call time rather than subscribing: every model/thinking/pro-mode change + // rewrites this cache, so a subscription would re-run the effect on its own updates. const workspaceByAgent = readPersistedState( getWorkspaceAISettingsByAgentKey(workspaceId), {} @@ -67,21 +66,19 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { const reasoningKey = getReasoningModeKey(workspaceId); const existingReasoning = readPersistedState(reasoningKey, "standard"); - const { resolvedModel, resolvedThinking, resolvedReasoningMode } = - resolveWorkspaceAiSettingsForAgent({ - agentId: normalizedAgentId, - agentAiDefaults, - // Keep deterministic handoff behavior: background sync should trust the - // currently active workspace model, but explicit mode switches should - // restore the selected agent's per-workspace override (if any). - workspaceByAgent, - useWorkspaceByAgentFallback: isExplicitAgentSwitch, - fallbackModel, - existingModel, - existingThinking, - existingReasoningMode: existingReasoning, - agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])), - }); + const resolvedSettings = resolveWorkspaceAiSettingsForAgent({ + agentId: normalizedAgentId, + agentAiDefaults, + workspaceByAgent, + fallbackModel, + existingModel, + existingThinking, + existingReasoningMode: existingReasoning, + agents, + mode: isExplicitAgentSwitch ? "explicit-switch" : "background-sync", + }); + if (!resolvedSettings) return; + const { resolvedModel, resolvedThinking, resolvedReasoningMode } = resolvedSettings; if (existingModel !== resolvedModel) { setWorkspaceModelWithOrigin( diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index 03e48b84c5a..f683d26340e 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -8,7 +8,14 @@ import { GlobalWindow } from "happy-dom"; import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; import { CUSTOM_EVENTS } from "@/common/constants/events"; -import { GLOBAL_SCOPE_ID, getAgentIdKey, getProjectScopeId } from "@/common/constants/storage"; +import { shouldApplyWorkspaceAgentIdFromBackend } from "@/browser/utils/workspaceAiSettingsSync"; +import { + GLOBAL_SCOPE_ID, + getAgentIdKey, + getModelKey, + getProjectScopeId, + getThinkingLevelKey, +} from "@/common/constants/storage"; import { requireTestModule } from "@/browser/testUtils"; import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; @@ -21,12 +28,37 @@ import type * as RouterContextModule from "./RouterContext"; import type * as WorkspaceContextModule from "./WorkspaceContext"; let mockAgentDefinitions: AgentDefinitionDescriptor[] = []; -let mockWorkspaceMetadata = new Map(); +let rejectAgentDefinitions = false; +let mockWorkspaceMetadata = new Map< + string, + { parentWorkspaceId?: string; agentId?: string; agentType?: string } +>(); +let updateAgentAISettingsCalls: Array<{ + workspaceId: string; + agentId: string; + aiSettings: { model: string; thinkingLevel?: string; reasoningMode?: string } | null; + persistSelectedAgentId?: boolean | null; +}> = []; +interface UpdateAgentAISettingsResult { + success: boolean; + error?: string; + data?: undefined; +} +let deferUpdateAgentAISettings = false; +let resolveUpdateAgentAISettings: ((result: UpdateAgentAISettingsResult) => void) | null = null; + +// Function-boundary read: flow analysis narrows the module let to null after +// an explicit reset and cannot see the mock's runtime reassignment, so tests +// that reset-and-recapture must read through this accessor. +function getDeferredUpdateResolver(): ((result: UpdateAgentAISettingsResult) => void) | null { + return resolveUpdateAgentAISettings; +} let APIProvider!: typeof APIModule.APIProvider; let RouterProvider!: typeof RouterContextModule.RouterProvider; let ProjectProvider!: typeof ProjectContextModule.ProjectProvider; let WorkspaceProvider!: typeof WorkspaceContextModule.WorkspaceProvider; +let useWorkspaceMetadata!: typeof WorkspaceContextModule.useWorkspaceMetadata; let AgentProvider!: typeof AgentContextModule.AgentProvider; let useAgent!: typeof AgentContextModule.useAgent; let isolatedModuleDir: string | null = null; @@ -88,8 +120,9 @@ async function importIsolatedAgentModules() { ({ ProjectProvider } = requireTestModule<{ ProjectProvider: typeof ProjectContextModule.ProjectProvider; }>(isolatedProjectPath)); - ({ WorkspaceProvider } = requireTestModule<{ + ({ WorkspaceProvider, useWorkspaceMetadata } = requireTestModule<{ WorkspaceProvider: typeof WorkspaceContextModule.WorkspaceProvider; + useWorkspaceMetadata: typeof WorkspaceContextModule.useWorkspaceMetadata; }>(isolatedWorkspacePath)); ({ AgentProvider, useAgent } = requireTestModule<{ AgentProvider: typeof AgentContextModule.AgentProvider; @@ -153,9 +186,28 @@ function Harness(props: HarnessProps) { return null; } +function MetadataLayoutHarness(props: { + workspaceId: string; + onChange: (metadata: FrontendWorkspaceMetadata | undefined) => void; +}) { + const { workspaceMetadata } = useWorkspaceMetadata(); + const metadata = workspaceMetadata.get(props.workspaceId); + + React.useLayoutEffect(() => { + props.onChange(metadata); + }, [metadata, props]); + + return null; +} + function createWorkspaceMetadata( workspaceId: string, - overrides: { parentWorkspaceId?: string; agentId?: string } = {} + overrides: { + parentWorkspaceId?: string; + agentId?: string; + agentType?: string; + aiSettingsByAgent?: FrontendWorkspaceMetadata["aiSettingsByAgent"]; + } = {} ): FrontendWorkspaceMetadata { return { id: workspaceId, @@ -169,6 +221,38 @@ function createWorkspaceMetadata( }; } +interface WorkspaceMetadataEvent { + workspaceId: string; + metadata: FrontendWorkspaceMetadata | null; +} + +// Push-based onMetadata channel so tests can deliver backend echoes mid-flight. +let emitWorkspaceMetadata: ((event: WorkspaceMetadataEvent) => void) | null = null; + +function createWorkspaceMetadataIterable(): AsyncIterable { + const queue: WorkspaceMetadataEvent[] = []; + let notify: (() => void) | null = null; + emitWorkspaceMetadata = (event) => { + queue.push(event); + notify?.(); + }; + return { + [Symbol.asyncIterator](): AsyncIterator { + return { + next: async () => { + while (queue.length === 0) { + await new Promise((resolve) => { + notify = resolve; + }); + notify = null; + } + return { done: false, value: queue.shift()! }; + }, + }; + }, + }; +} + function createEmptyAsyncIterable(): AsyncIterable { return { [Symbol.asyncIterator](): AsyncIterator { @@ -187,11 +271,14 @@ function createApiClient(): APIClient { return { agents: { - list: () => Promise.resolve(mockAgentDefinitions), + list: () => + rejectAgentDefinitions + ? Promise.reject(new Error("agent definitions unavailable")) + : Promise.resolve(mockAgentDefinitions), }, workspace: { list: () => Promise.resolve(workspaceMetadata), - onMetadata: () => Promise.resolve(createEmptyAsyncIterable()), + onMetadata: () => Promise.resolve(createWorkspaceMetadataIterable()), onChat: () => Promise.resolve(createEmptyAsyncIterable()), getSessionUsage: () => Promise.resolve(undefined), activity: { @@ -200,6 +287,17 @@ function createApiClient(): APIClient { }, truncateHistory: () => Promise.resolve({ success: true as const, data: undefined }), interruptStream: () => Promise.resolve({ success: true as const, data: undefined }), + updateAgentAISettings: ( + input: (typeof updateAgentAISettingsCalls)[number] + ): Promise => { + updateAgentAISettingsCalls.push(input); + if (deferUpdateAgentAISettings) { + return new Promise((resolve) => { + resolveUpdateAgentAISettings = resolve; + }); + } + return Promise.resolve({ success: true, data: undefined }); + }, }, projects: { list: () => Promise.resolve([]), @@ -221,12 +319,19 @@ function renderAgentHarness(props: { projectPath: string; workspaceId?: string; onChange: (value: AgentContextValue) => void; + onMetadataLayout?: (metadata: FrontendWorkspaceMetadata | undefined) => void; }) { return render( + {props.workspaceId && props.onMetadataLayout ? ( + + ) : null} @@ -245,7 +350,12 @@ describe("AgentContext", () => { beforeEach(async () => { isolatedModuleDir = await importIsolatedAgentModules(); mockAgentDefinitions = []; + rejectAgentDefinitions = false; mockWorkspaceMetadata = new Map(); + updateAgentAISettingsCalls = []; + deferUpdateAgentAISettings = false; + resolveUpdateAgentAISettings = null; + emitWorkspaceMetadata = null; originalWindow = globalThis.window; originalDocument = globalThis.document; @@ -362,6 +472,457 @@ describe("AgentContext", () => { }); }); + test("built-in workspace switching survives agent descriptor load failure", async () => { + const projectPath = "/tmp/project"; + const workspaceId = "main-workspace"; + rejectAgentDefinitions = true; + mockWorkspaceMetadata.set(workspaceId, {}); + window.localStorage.setItem(getAgentIdKey(workspaceId), JSON.stringify("exec")); + + let contextValue: AgentContextValue | undefined; + renderAgentHarness({ + workspaceId, + projectPath, + onChange: (value) => (contextValue = value), + }); + + await waitFor(() => { + expect(contextValue?.loadFailed).toBe(true); + }); + + contextValue?.setAgentId("plan"); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("plan"); + }); + expect(updateAgentAISettingsCalls).toHaveLength(1); + expect(updateAgentAISettingsCalls[0]).toMatchObject({ + workspaceId, + agentId: "plan", + persistSelectedAgentId: true, + }); + }); + + test("workspace agent selection persists to the backend", async () => { + const projectPath = "/tmp/project"; + const workspaceId = "main-workspace"; + mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT]; + mockWorkspaceMetadata.set(workspaceId, {}); + window.localStorage.setItem(getAgentIdKey(workspaceId), JSON.stringify("exec")); + + let contextValue: AgentContextValue | undefined; + + renderAgentHarness({ + workspaceId, + projectPath, + onChange: (value) => (contextValue = value), + }); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); + }); + + contextValue?.setAgentId("plan"); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("plan"); + }); + expect(updateAgentAISettingsCalls).toHaveLength(1); + expect(updateAgentAISettingsCalls[0]).toMatchObject({ + workspaceId, + agentId: "plan", + persistSelectedAgentId: true, + }); + // The switch persists its resolved settings alongside the selection so a + // fresh client can hydrate the bucket even when the target agent had none. + expect(typeof updateAgentAISettingsCalls[0]?.aiSettings?.model).toBe("string"); + expect(updateAgentAISettingsCalls[0]?.aiSettings?.thinkingLevel).toBeDefined(); + + // Re-selecting the current agent is a no-op and must not hit the backend. + contextValue?.setAgentId("plan"); + expect(updateAgentAISettingsCalls).toHaveLength(1); + }); + + test("stale metadata cannot overwrite settings during an agent switch", async () => { + const projectPath = "/tmp/project"; + const workspaceId = "main-workspace"; + mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT]; + mockWorkspaceMetadata.set(workspaceId, { agentId: "exec" }); + window.localStorage.setItem(getAgentIdKey(workspaceId), JSON.stringify("exec")); + window.localStorage.setItem(getModelKey(workspaceId), JSON.stringify("openai:selected")); + window.localStorage.setItem(getThinkingLevelKey(workspaceId), JSON.stringify("high")); + deferUpdateAgentAISettings = true; + + let contextValue: AgentContextValue | undefined; + let latestMetadata: FrontendWorkspaceMetadata | undefined; + + renderAgentHarness({ + workspaceId, + projectPath, + onChange: (value) => (contextValue = value), + onMetadataLayout: (metadata) => (latestMetadata = metadata), + }); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); + }); + + contextValue?.setAgentId("plan"); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("plan"); + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); + expect(updateAgentAISettingsCalls[0]?.aiSettings).toMatchObject({ + model: "openai:selected", + thinkingLevel: "high", + }); + + emitWorkspaceMetadata?.({ + workspaceId, + metadata: createWorkspaceMetadata(workspaceId, { + agentId: "exec", + aiSettingsByAgent: { + plan: { model: "openai:stale", thinkingLevel: "low" }, + }, + }), + }); + + await waitFor(() => { + expect(latestMetadata?.aiSettingsByAgent?.plan?.model).toBe("openai:stale"); + }); + expect(contextValue?.agentId).toBe("plan"); + expect(window.localStorage.getItem(getModelKey(workspaceId))).toBe( + JSON.stringify("openai:selected") + ); + expect(window.localStorage.getItem(getThinkingLevelKey(workspaceId))).toBe( + JSON.stringify("high") + ); + + getDeferredUpdateResolver()?.({ success: true, data: undefined }); + }); + + test("workspace agent selection persists definition AI defaults", async () => { + const projectPath = "/tmp/project"; + const workspaceId = "main-workspace"; + const researcherAgent: AgentDefinitionDescriptor = { + id: "researcher", + scope: "project", + name: "Researcher", + uiSelectable: true, + subagentRunnable: false, + base: "exec", + aiDefaults: { model: "openai:gpt-5.6-sol", thinkingLevel: "high" }, + ownAiDefaults: { model: "openai:gpt-5.6-sol", thinkingLevel: "high" }, + }; + mockAgentDefinitions = [EXEC_AGENT, researcherAgent]; + mockWorkspaceMetadata.set(workspaceId, {}); + window.localStorage.setItem(getAgentIdKey(workspaceId), JSON.stringify("exec")); + window.localStorage.setItem( + getModelKey(workspaceId), + JSON.stringify("anthropic:claude-opus-4-6") + ); + window.localStorage.setItem(getThinkingLevelKey(workspaceId), JSON.stringify("off")); + + let contextValue: AgentContextValue | undefined; + + renderAgentHarness({ + workspaceId, + projectPath, + onChange: (value) => (contextValue = value), + }); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); + }); + + contextValue?.setAgentId("researcher"); + + await waitFor(() => { + expect(updateAgentAISettingsCalls).toHaveLength(1); + }); + expect(updateAgentAISettingsCalls[0]).toMatchObject({ + workspaceId, + agentId: "researcher", + aiSettings: { + model: "openai:gpt-5.6-sol", + thinkingLevel: "high", + }, + persistSelectedAgentId: true, + }); + }); + + test("rejected persistence reverts the local agent selection", async () => { + const projectPath = "/tmp/project"; + const workspaceId = "main-workspace"; + mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT]; + mockWorkspaceMetadata.set(workspaceId, {}); + window.localStorage.setItem(getAgentIdKey(workspaceId), JSON.stringify("exec")); + deferUpdateAgentAISettings = true; + + let contextValue: AgentContextValue | undefined; + + renderAgentHarness({ + workspaceId, + projectPath, + onChange: (value) => (contextValue = value), + }); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); + }); + + const toasts: Array<{ workspaceId: string; message: string }> = []; + const toastListener = (event: Event) => + toasts.push((event as CustomEvent<{ workspaceId: string; message: string }>).detail); + window.addEventListener(CUSTOM_EVENTS.AGENT_SWITCH_ERROR_TOAST, toastListener); + + try { + contextValue?.setAgentId("plan"); + + // Optimistic switch happens immediately... + await waitFor(() => { + expect(contextValue?.agentId).toBe("plan"); + }); + await waitFor(() => { + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); + + // ...and a typed rejection reverts it: the backend refused the selection + // and kept the previous agent, and sends carrying the rejected selection + // are refused by the same gate before they can re-persist it, so no + // self-heal is coming. + resolveUpdateAgentAISettings?.({ success: false, error: "unpriced model" }); + + await waitFor(() => { + expect(toasts).toEqual([{ workspaceId, message: "unpriced model" }]); + }); + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); + }); + // The echo guard is released so backend agent updates apply again + // (probing with a non-matching agent does not mutate the guard). + expect(shouldApplyWorkspaceAgentIdFromBackend(workspaceId, "exec")).toBe(true); + } finally { + window.removeEventListener(CUSTOM_EVENTS.AGENT_SWITCH_ERROR_TOAST, toastListener); + } + }); + + test("rejection does not revert a newer agent selection", async () => { + const projectPath = "/tmp/project"; + const workspaceId = "main-workspace"; + mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT, REVIEW_PROJECT_AGENT]; + mockWorkspaceMetadata.set(workspaceId, {}); + window.localStorage.setItem(getAgentIdKey(workspaceId), JSON.stringify("exec")); + deferUpdateAgentAISettings = true; + + let contextValue: AgentContextValue | undefined; + + renderAgentHarness({ + workspaceId, + projectPath, + onChange: (value) => (contextValue = value), + }); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); + }); + + const toasts: Array<{ workspaceId: string; message: string }> = []; + const toastListener = (event: Event) => + toasts.push((event as CustomEvent<{ workspaceId: string; message: string }>).detail); + window.addEventListener(CUSTOM_EVENTS.AGENT_SWITCH_ERROR_TOAST, toastListener); + + try { + contextValue?.setAgentId("plan"); + await waitFor(() => { + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); + const rejectPlanSwitch = resolveUpdateAgentAISettings; + resolveUpdateAgentAISettings = null; + + // The user moves on before the rejection lands; the newer choice wins + // over the revert. + contextValue?.setAgentId("review"); + await waitFor(() => { + expect(contextValue?.agentId).toBe("review"); + }); + + rejectPlanSwitch?.({ success: false, error: "unpriced model" }); + + // The toast proves the rejection handler (including any revert) ran. + await waitFor(() => { + expect(toasts).toHaveLength(1); + }); + expect(contextValue?.agentId).toBe("review"); + + await waitFor(() => { + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); + getDeferredUpdateResolver()?.({ success: true, data: undefined }); + await waitFor(() => { + expect(shouldApplyWorkspaceAgentIdFromBackend(workspaceId, "plan")).toBe(true); + }); + } finally { + window.removeEventListener(CUSTOM_EVENTS.AGENT_SWITCH_ERROR_TOAST, toastListener); + } + }); + + test("chained rejections restore the backend's authoritative agent", async () => { + const projectPath = "/tmp/project"; + const workspaceId = "main-workspace"; + mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT, REVIEW_PROJECT_AGENT]; + // Backend still stores exec: neither chained switch gets accepted. + mockWorkspaceMetadata.set(workspaceId, { agentId: "exec" }); + window.localStorage.setItem(getAgentIdKey(workspaceId), JSON.stringify("exec")); + deferUpdateAgentAISettings = true; + + let contextValue: AgentContextValue | undefined; + + renderAgentHarness({ + workspaceId, + projectPath, + onChange: (value) => (contextValue = value), + }); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); + }); + + contextValue?.setAgentId("plan"); + await waitFor(() => { + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); + const rejectPlanSwitch = resolveUpdateAgentAISettings; + resolveUpdateAgentAISettings = null; + + contextValue?.setAgentId("review"); + await waitFor(() => { + expect(contextValue?.agentId).toBe("review"); + }); + + // plan's rejection is skipped (a newer switch is active). Once that + // serialized write settles, review's rejection must restore the backend's + // agent (exec), not its captured previous agent (the also-rejected plan). + rejectPlanSwitch?.({ success: false, error: "unpriced model" }); + await waitFor(() => { + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); + getDeferredUpdateResolver()?.({ success: false, error: "unpriced model" }); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); + }); + }); + + test("rejection rollback uses metadata committed before passive effects", async () => { + const projectPath = "/tmp/project"; + const workspaceId = "main-workspace"; + mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT, REVIEW_PROJECT_AGENT]; + mockWorkspaceMetadata.set(workspaceId, { agentId: "exec" }); + window.localStorage.setItem(getAgentIdKey(workspaceId), JSON.stringify("exec")); + deferUpdateAgentAISettings = true; + + let contextValue: AgentContextValue | undefined; + let rejectReviewOnPlanCommit: (() => void) | null = null; + + renderAgentHarness({ + workspaceId, + projectPath, + onChange: (value) => (contextValue = value), + onMetadataLayout: (metadata) => { + if (metadata?.agentId !== "plan") return; + const reject = rejectReviewOnPlanCommit; + rejectReviewOnPlanCommit = null; + reject?.(); + }, + }); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); + }); + + // exec→plan is accepted by the backend. + contextValue?.setAgentId("plan"); + await waitFor(() => { + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); + const acceptPlanSwitch = getDeferredUpdateResolver(); + resolveUpdateAgentAISettings = null; + + // plan→review is selected BEFORE the acceptance echo arrives, so its + // render-time closure still sees the pre-echo backend state (exec). + contextValue?.setAgentId("review"); + acceptPlanSwitch?.({ success: true, data: undefined }); + await waitFor(() => { + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); + const rejectReviewSwitch = getDeferredUpdateResolver(); + rejectReviewOnPlanCommit = () => + rejectReviewSwitch?.({ success: false, error: "unpriced model" }); + + // Reject review from a layout effect triggered by the accepted plan echo. + // This is after plan metadata commits to WorkspaceContext/WorkspaceStore but + // before AgentContext passive effects can refresh a render-fed ref. + emitWorkspaceMetadata?.({ + workspaceId, + metadata: createWorkspaceMetadata(workspaceId, { + agentId: "plan", + aiSettingsByAgent: { plan: { model: "openai:echoed-plan", thinkingLevel: "low" } }, + }), + }); + + await waitFor(() => { + expect(rejectReviewOnPlanCommit).toBeNull(); + expect(contextValue?.agentId).toBe("plan"); + }); + }); + + test("chained rejections resolve a legacy agentType baseline", async () => { + const projectPath = "/tmp/project"; + const workspaceId = "main-workspace"; + mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT, REVIEW_PROJECT_AGENT]; + // Upgraded workspace: the authoritative selection exists only in the + // legacy agentType field. + mockWorkspaceMetadata.set(workspaceId, { agentType: "exec" }); + window.localStorage.setItem(getAgentIdKey(workspaceId), JSON.stringify("exec")); + deferUpdateAgentAISettings = true; + + let contextValue: AgentContextValue | undefined; + + renderAgentHarness({ + workspaceId, + projectPath, + onChange: (value) => (contextValue = value), + }); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); + }); + + contextValue?.setAgentId("plan"); + await waitFor(() => { + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); + const rejectPlanSwitch = resolveUpdateAgentAISettings; + resolveUpdateAgentAISettings = null; + + contextValue?.setAgentId("review"); + await waitFor(() => { + expect(contextValue?.agentId).toBe("review"); + }); + + rejectPlanSwitch?.({ success: false, error: "unpriced model" }); + await waitFor(() => { + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); + getDeferredUpdateResolver()?.({ success: false, error: "unpriced model" }); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); + }); + }); + test("shortcut actions do not override a locked workspace agent", async () => { const projectPath = "/tmp/project"; const lockedWorkspaceId = "locked-workspace"; diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx index 873ddec5693..5aaa8b41a7d 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -13,18 +13,40 @@ import { import { useAPI } from "@/browser/contexts/API"; import { useWorkspaceMetadata } from "@/browser/contexts/WorkspaceContext"; -import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; +import { readPersistedState, usePersistedState } from "@/browser/hooks/usePersistedState"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { + AGENT_AI_DEFAULTS_KEY, getAgentIdKey, + getModelKey, getProjectScopeId, getDisableWorkspaceAgentsKey, + getReasoningModeKey, + getThinkingLevelKey, + getWorkspaceAISettingsByAgentKey, GLOBAL_SCOPE_ID, } from "@/common/constants/storage"; +import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; +import { + resolveWorkspaceAiSettingsForAgent, + type WorkspaceAISettingsCache, +} from "@/browser/utils/workspaceModeAi"; +import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; +import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; +import { getErrorMessage } from "@/common/utils/errors"; import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import { sortAgentsStable } from "@/browser/utils/agents"; import { normalizeAgentId, resolveRemovedBuiltinAgentId } from "@/common/utils/agentIds"; +import { + clearPendingWorkspaceAgentId, + clearPendingWorkspaceAiSettings, + markPendingWorkspaceAgentId, + markPendingWorkspaceAiSettings, + revertRejectedAgentSwitch, + updateWorkspaceAgentAISettings, +} from "@/browser/utils/workspaceAiSettingsSync"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; export interface AgentContextValue { @@ -82,6 +104,7 @@ function AgentProviderWithState(props: { }) { const { api } = useAPI(); const { workspaceMetadata } = useWorkspaceMetadata(); + const workspaceStore = useWorkspaceStoreRaw(); const currentMeta = props.workspaceId ? workspaceMetadata.get(props.workspaceId) : undefined; const scopeId = getScopeId(props.workspaceId, props.projectPath); @@ -120,23 +143,169 @@ function AgentProviderWithState(props: { } }, [disableWorkspaceAgents, setDisableWorkspaceAgents]); + // Child/subagent workspaces keep the backend-assigned agent; their selection + // is locked, so local changes must never be written back. + const isCurrentAgentLocked = currentMeta?.parentWorkspaceId != null; + + const workspaceId = props.workspaceId; + + // Declared before setAgentId: switches resolve the target agent's settings + // (base-chain aware) to persist them with the selection. + const [agents, setAgents] = useState([]); + const [loaded, setLoaded] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + const setAgentId: Dispatch> = useCallback( (value) => { + // usePersistedState runs the updater synchronously, so `next` is + // available right after the call. + let next: string | null = null; + let previous: string | null = null; setAgentIdRaw((prev) => { const explicitPrevAgentId = typeof prev === "string" && prev.trim().length > 0 ? prev : globalDefaultAgentId; - const previousAgentId = coerceAgentId(isProjectScope ? explicitPrevAgentId : prev); - const next = typeof value === "function" ? value(previousAgentId) : value; - return coerceAgentId(next); + previous = coerceAgentId(isProjectScope ? explicitPrevAgentId : prev); + next = coerceAgentId(typeof value === "function" ? value(previous) : value); + return next; }); + + // Persist workspace mode changes so the selection is remembered + // per-workspace across clients, not just in this client's localStorage. + if ( + !api || + !workspaceId || + isCurrentAgentLocked || + next == null || + previous == null || + next === previous + ) { + return; + } + const nextAgentId: string = next; + const previousAgentId: string = previous; + + // Read the carried-over settings before WorkspaceModeAISync reacts to + // the optimistic switch; they seed the resolver as the previously + // active values. + const modelKey = getModelKey(workspaceId); + const thinkingKey = getThinkingLevelKey(workspaceId); + const reasoningKey = getReasoningModeKey(workspaceId); + const previousModel = readPersistedState(modelKey, getDefaultModel()); + const previousThinking = readPersistedState(thinkingKey, "off"); + const previousReasoning = readPersistedState(reasoningKey, "standard"); + + // Resolve the switch's effective settings exactly as WorkspaceModeAISync + // will apply them locally, and persist them with the selection: an + // agent-only write leaves a fresh client with nothing to hydrate when + // the target agent has no bucket or configured default, diverging from + // the originating client's carried-over model until the next send. + const agentAiDefaults = readPersistedState(AGENT_AI_DEFAULTS_KEY, {}); + const workspaceByAgent = readPersistedState( + getWorkspaceAISettingsByAgentKey(workspaceId), + {} + ); + const resolvedSettings = resolveWorkspaceAiSettingsForAgent({ + agentId: nextAgentId, + agentAiDefaults, + workspaceByAgent, + fallbackModel: getDefaultModel(), + existingModel: previousModel, + existingThinking: previousThinking, + existingReasoningMode: previousReasoning, + agents, + mode: "explicit-switch", + }); + if (!resolvedSettings) { + setAgentIdRaw(previousAgentId); + return; + } + const { resolvedModel, resolvedThinking, resolvedReasoningMode } = resolvedSettings; + + // The local update above is authoritative for this client and the write + // below is best-effort: every send carries the selection and re-persists + // it backend-side (maybePersistAISettingsFromOptions), so a transport + // failure self-heals on the next send instead of triggering a local + // rollback. A typed rejection cannot self-heal that way: the backend + // evaluated and refused this selection (e.g. the budgeted-goal pricing + // gate) and the same gate refuses sends before they re-persist settings, + // so a rejection restores the backend-authoritative (or pre-switch) + // selection instead (revertRejectedAgentSwitch). + + // The picker closes on selection, so a rejected switch would otherwise + // be silent (e.g. budgeted-goal pricing gate). + const notifySwitchRejected = (message: string) => { + window.dispatchEvent( + createCustomEvent(CUSTOM_EVENTS.AGENT_SWITCH_ERROR_TOAST, { + workspaceId, + message: + message.trim().length > 0 ? message : `Failed to switch to the ${nextAgentId} agent.`, + }) + ); + }; + + const revertRejectedSwitch = () => { + revertRejectedAgentSwitch({ + workspaceId, + rejectedAgentId: nextAgentId, + applied: { + model: resolvedModel, + thinkingLevel: resolvedThinking, + reasoningMode: resolvedReasoningMode, + }, + previous: { + agentId: previousAgentId, + model: previousModel, + thinkingLevel: previousThinking, + reasoningMode: previousReasoning, + }, + backendMetadata: workspaceStore.getWorkspaceMetadata(workspaceId), + }); + }; + + const nextAiSettings = { + model: resolvedModel, + thinkingLevel: resolvedThinking, + ...(resolvedReasoningMode != null ? { reasoningMode: resolvedReasoningMode } : {}), + }; + markPendingWorkspaceAgentId(workspaceId, nextAgentId); + markPendingWorkspaceAiSettings(workspaceId, nextAgentId, nextAiSettings); + updateWorkspaceAgentAISettings(api, { + workspaceId, + agentId: nextAgentId, + aiSettings: nextAiSettings, + persistSelectedAgentId: true, + }) + .then((result) => { + if (!result.success) { + notifySwitchRejected(typeof result.error === "string" ? result.error : ""); + revertRejectedSwitch(); + } + // Release the guards on every settled write: no-op writes (backend + // already on these values) and failed writes emit no metadata echo, + // and stuck guards would block future backend seeds. For changed + // writes the echo is ordered after any stale broadcast, so releasing + // on the response cannot strand stale values. + clearPendingWorkspaceAgentId(workspaceId, nextAgentId); + clearPendingWorkspaceAiSettings(workspaceId, nextAgentId); + }) + .catch((error) => { + notifySwitchRejected(getErrorMessage(error)); + clearPendingWorkspaceAgentId(workspaceId, nextAgentId); + clearPendingWorkspaceAiSettings(workspaceId, nextAgentId); + }); }, - [globalDefaultAgentId, isProjectScope, setAgentIdRaw] + [ + agents, + api, + globalDefaultAgentId, + isCurrentAgentLocked, + isProjectScope, + setAgentIdRaw, + workspaceId, + workspaceStore, + ] ); - const [agents, setAgents] = useState([]); - const [loaded, setLoaded] = useState(false); - const [loadFailed, setLoadFailed] = useState(false); - const isMountedRef = useRef(true); useEffect(() => { @@ -230,11 +399,8 @@ function AgentProviderWithState(props: { } }, [fetchAgents, props.projectPath, props.workspaceId, disableWorkspaceAgents]); - // Project-scoped providers should inherit the global default agent until a - // project-scoped preference is explicitly set. Child/subagent workspaces keep - // the backend-assigned agent so local persisted overrides cannot drift. - const isCurrentAgentLocked = currentMeta?.parentWorkspaceId != null; - + // Project-scoped providers inherit the global default agent until a + // project-scoped preference is explicitly set. // For locked workspaces, use the backend-assigned agent — persisted localStorage // may contain a stale selection from before locking, and the picker is disabled // so there's no in-UI recovery path. diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 0820b9f333a..5a3b44a9281 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -32,6 +32,7 @@ import { clearPendingWorkspaceAiSettings, getWorkspaceAiSettingsFromMetadata, markPendingWorkspaceAiSettings, + updateWorkspaceAgentAISettings, } from "@/browser/utils/workspaceAiSettingsSync"; import { useOptionalWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds"; @@ -183,12 +184,11 @@ export const ThinkingProvider: React.FC = (props) => { // click through levels quickly (tests reproduce this by cycling to xhigh). markPendingWorkspaceAiSettings(workspaceId, normalizedAgentId, settings); - api.workspace - .updateAgentAISettings({ - workspaceId, - agentId: normalizedAgentId, - aiSettings: settings, - }) + updateWorkspaceAgentAISettings(api, { + workspaceId, + agentId: normalizedAgentId, + aiSettings: settings, + }) .then((result) => { if (!result.success) { clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index 75b87d71887..d766b0e0401 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -16,11 +16,16 @@ import { getRightSidebarLayoutKey, getTerminalTitlesKey, getThinkingLevelKey, + getWorkspaceAISettingsByAgentKey, } from "@/common/constants/storage"; import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import type { RecursivePartial } from "@/browser/testUtils"; import { readPersistedState } from "@/browser/hooks/usePersistedState"; +import { + clearPendingWorkspaceAgentId, + markPendingWorkspaceAgentId, +} from "@/browser/utils/workspaceAiSettingsSync"; import { getProjectRouteId } from "@/common/utils/projectRouteId"; import type { RightSidebarLayoutState } from "@/browser/utils/rightSidebarLayout"; @@ -520,8 +525,145 @@ describe("WorkspaceContext", () => { "xhigh" ); }); - test("stale metadata does not override a main workspace agent selection", async () => { + test("backend agentId seeds a main workspace agent selection", async () => { const workspaceId = "ws-agent-main"; + + createMockAPI({ + workspace: { + list: () => + Promise.resolve([createWorkspaceMetadata({ id: workspaceId, agentId: "plan" })]), + }, + localStorage: { + // Backend value wins over a stale local selection from another client. + [getAgentIdKey(workspaceId)]: JSON.stringify("exec"), + }, + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); + expect(readPersistedState(getAgentIdKey(workspaceId), undefined)).toBe( + "plan" + ); + }); + + test("does not hydrate another agent's settings when the active agent has no bucket", async () => { + const workspaceId = "ws-agent-no-bucket"; + + createMockAPI({ + workspace: { + list: () => + Promise.resolve([ + createWorkspaceMetadata({ + id: workspaceId, + agentId: "custom", + aiSettingsByAgent: { + exec: { model: "openai:gpt-5.2", thinkingLevel: "low" }, + }, + }), + ]), + }, + localStorage: { + [getAgentIdKey(workspaceId)]: JSON.stringify("custom"), + // Locally resolved settings for the bucket-less active agent. + [getModelKey(workspaceId)]: JSON.stringify("openai:custom-model"), + [getThinkingLevelKey(workspaceId)]: JSON.stringify("high"), + }, + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); + + // exec's bucket must not overwrite the active agent's resolved settings. + expect(JSON.parse(globalThis.localStorage.getItem(getModelKey(workspaceId))!)).toBe( + "openai:custom-model" + ); + expect(JSON.parse(globalThis.localStorage.getItem(getThinkingLevelKey(workspaceId))!)).toBe( + "high" + ); + }); + + test("legacy shared aiSettings hydrate a custom active agent", async () => { + const workspaceId = "ws-agent-legacy-custom"; + + createMockAPI({ + workspace: { + list: () => + Promise.resolve([ + createWorkspaceMetadata({ + id: workspaceId, + agentId: "custom", + // Legacy metadata: shared settings only, no per-agent buckets. + aiSettings: { model: "openai:legacy-model", thinkingLevel: "low" }, + }), + ]), + }, + localStorage: { + [getAgentIdKey(workspaceId)]: JSON.stringify("custom"), + [getModelKey(workspaceId)]: JSON.stringify("openai:local-default"), + }, + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); + + // Backend dispatch resolution treats legacy shared settings as a fallback + // for whichever agent is selected; the composer must agree instead of + // staying on the local default model. + expect(JSON.parse(globalThis.localStorage.getItem(getModelKey(workspaceId))!)).toBe( + "openai:legacy-model" + ); + expect(JSON.parse(globalThis.localStorage.getItem(getThinkingLevelKey(workspaceId))!)).toBe( + "low" + ); + }); + + test("legacy shared aiSettings fill a missing active bucket in a partial modern map", async () => { + const workspaceId = "ws-agent-legacy-coexist"; + + createMockAPI({ + workspace: { + list: () => + Promise.resolve([ + createWorkspaceMetadata({ + id: workspaceId, + agentId: "custom", + // Upgraded workspace: another agent already wrote a modern + // bucket, but the active custom agent has none. + aiSettings: { model: "openai:legacy-model", thinkingLevel: "low" }, + aiSettingsByAgent: { + exec: { model: "openai:gpt-5.2", thinkingLevel: "high" }, + }, + }), + ]), + }, + localStorage: { + [getAgentIdKey(workspaceId)]: JSON.stringify("custom"), + [getModelKey(workspaceId)]: JSON.stringify("openai:local-default"), + }, + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); + + // The active agent hydrates from the legacy fallback, matching backend + // dispatch resolution... + expect(JSON.parse(globalThis.localStorage.getItem(getModelKey(workspaceId))!)).toBe( + "openai:legacy-model" + ); + // ...while real per-agent buckets are preserved, not overwritten. + const byAgent = JSON.parse( + globalThis.localStorage.getItem(getWorkspaceAISettingsByAgentKey(workspaceId))! + ) as Record; + expect(byAgent.exec?.model).toBe("openai:gpt-5.2"); + expect(byAgent.custom?.model).toBe("openai:legacy-model"); + }); + + test("stale metadata does not clobber a pending local agent switch", async () => { + const workspaceId = "ws-agent-pending"; let emitMetadata: | ((event: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }) => void) | null = null; @@ -532,13 +674,16 @@ describe("WorkspaceContext", () => { onMetadata: () => Promise.resolve( (async function* () { - const event = await new Promise<{ - workspaceId: string; - metadata: FrontendWorkspaceMetadata | null; - }>((resolve) => { - emitMetadata = resolve; - }); - yield event; + while (true) { + const event = await new Promise<{ + workspaceId: string; + metadata: FrontendWorkspaceMetadata | null; + }>((resolve) => { + emitMetadata = resolve; + }); + emitMetadata = null; + yield event; + } })() as unknown as Awaited> ), }, @@ -547,23 +692,50 @@ describe("WorkspaceContext", () => { }, }); + // Simulate a local mode switch whose backend write hasn't echoed yet. + markPendingWorkspaceAgentId(workspaceId, "exec"); + const ctx = await setup(); await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); await waitFor(() => expect(emitMetadata).toBeTruthy()); - expect(ctx().workspaceMetadata.get(workspaceId)?.agentId).toBeUndefined(); + // A stale broadcast carrying the previous agent must not revert the switch. act(() => { emitMetadata?.({ workspaceId, metadata: createWorkspaceMetadata({ id: workspaceId, agentId: "plan" }), }); }); - await waitFor(() => expect(ctx().workspaceMetadata.get(workspaceId)?.agentId).toBe("plan")); expect(readPersistedState(getAgentIdKey(workspaceId), undefined)).toBe( "exec" ); + + // The backend echo applies, but the guard remains until its write settles. + await waitFor(() => expect(emitMetadata).toBeTruthy()); + act(() => { + emitMetadata?.({ + workspaceId, + metadata: createWorkspaceMetadata({ id: workspaceId, agentId: "exec" }), + }); + }); + await waitFor(() => expect(ctx().workspaceMetadata.get(workspaceId)?.agentId).toBe("exec")); + clearPendingWorkspaceAgentId(workspaceId, "exec"); + + // Once the write settles, later backend updates apply again. + await waitFor(() => expect(emitMetadata).toBeTruthy()); + act(() => { + emitMetadata?.({ + workspaceId, + metadata: createWorkspaceMetadata({ id: workspaceId, agentId: "plan" }), + }); + }); + await waitFor(() => + expect(readPersistedState(getAgentIdKey(workspaceId), undefined)).toBe( + "plan" + ) + ); }); test("child workspace metadata still seeds the locked backend agent", async () => { diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index e4f1307c9c7..b4869b99e06 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -65,7 +65,10 @@ import { import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults"; import { isWorkspaceArchived } from "@/common/utils/archive"; import { reassignPinnedTimestamps } from "@/common/utils/pin"; -import { shouldApplyWorkspaceAiSettingsFromBackend } from "@/browser/utils/workspaceAiSettingsSync"; +import { + shouldApplyWorkspaceAgentIdFromBackend, + shouldApplyWorkspaceAiSettingsFromBackend, +} from "@/browser/utils/workspaceAiSettingsSync"; import { isAbortError } from "@/browser/utils/isAbortError"; import { findAdjacentWorkspaceId } from "@/browser/utils/ui/workspaceDomNav"; import { useRouter } from "@/browser/contexts/RouterContext"; @@ -161,12 +164,6 @@ function migrateLocalGatewayPrefsToBackend( } } -function shouldSeedWorkspaceAgentIdFromBackend(metadata: FrontendWorkspaceMetadata): boolean { - // Main workspaces own their live agent selection in localStorage. Child/task - // workspaces are backend-defined and locked, so they must re-seed from metadata. - return metadata.parentWorkspaceId != null; -} - /** * Seed per-workspace localStorage from backend workspace metadata. * @@ -183,24 +180,56 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat const workspaceId = metadata.id; + // Seed the active agent from backend metadata so the last used mode follows + // the workspace across clients. Child/task workspaces are backend-defined and + // locked, so they always re-seed; main workspaces persist local mode changes + // to the backend and are protected from stale broadcasts by the pending-echo + // guard (shouldApplyWorkspaceAgentIdFromBackend). const metadataAgentId = resolvePersistedAgentId(metadata, ""); - if (shouldSeedWorkspaceAgentIdFromBackend(metadata) && metadataAgentId.length > 0) { - const key = getAgentIdKey(workspaceId); + if (metadataAgentId.length > 0) { const normalized = normalizeAgentId(metadataAgentId); - const existing = readPersistedState(key, undefined); - if (existing !== normalized) { - updatePersistedState(key, normalized); + const isLockedChildWorkspace = metadata.parentWorkspaceId != null; + if (isLockedChildWorkspace || shouldApplyWorkspaceAgentIdFromBackend(workspaceId, normalized)) { + const key = getAgentIdKey(workspaceId); + const existing = readPersistedState(key, undefined); + if (existing !== normalized) { + updatePersistedState(key, normalized); + } } } - const aiByAgent = - metadata.aiSettingsByAgent ?? - (metadata.aiSettings + // Read after the backend agent-id seeding above so a metadata-driven agent + // selection applies before settings hydration keys off of it. + const activeAgentId = readPersistedState( + getAgentIdKey(workspaceId), + WORKSPACE_DEFAULTS.agentId + ); + + // Legacy-only metadata predates per-agent buckets. Backend dispatch + // resolution (resolveNodeAgentAiSettings) treats the shared legacy blob as a + // fallback layer for whichever agent is selected — including custom agents — + // so synthesize a bucket for the active agent too, not just plan/exec. + // Otherwise a fresh client hydrating a legacy workspace with a custom active + // agent sits on the local default model while backend dispatches (heartbeats, + // continuations) keep resolving the legacy settings. Real per-agent buckets + // are never borrowed across agents. + const modernByAgent = metadata.aiSettingsByAgent; + const aiByAgent = modernByAgent + ? metadata.aiSettings && !modernByAgent[activeAgentId] + ? // Coexistence: a partial modern map can lack the active agent while + // the legacy shared blob exists (e.g. only another agent wrote a + // modern bucket). Backend resolvers still fall back to the legacy + // workspaceEntry.aiSettings for the selected agent, so overlay it for + // the active agent only, preserving every real per-agent entry. + { ...modernByAgent, [activeAgentId]: metadata.aiSettings } + : modernByAgent + : metadata.aiSettings ? { plan: metadata.aiSettings, exec: metadata.aiSettings, + [activeAgentId]: metadata.aiSettings, } - : undefined); + : undefined; if (!aiByAgent) { return; @@ -238,11 +267,11 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat } // Seed the active agent into the existing keys to avoid UI flash. - const activeAgentId = readPersistedState( - getAgentIdKey(workspaceId), - WORKSPACE_DEFAULTS.agentId - ); - const active = nextByAgent[activeAgentId] ?? nextByAgent.exec ?? nextByAgent.plan; + // Only hydrate from the ACTIVE agent's own bucket. Falling back to another + // agent's bucket would overwrite the locally resolved settings of an agent + // that has no persisted bucket yet (e.g. right after an agent-only switch), + // and WorkspaceModeAISync does not re-run to correct such an overwrite. + const active = nextByAgent[activeAgentId]; if (!active) { return; } diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 310a318d695..7fc6f473e42 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -56,8 +56,13 @@ import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; import { clearPendingWorkspaceAiSettings, markPendingWorkspaceAiSettings, + sendWorkspaceMessage, + updateWorkspaceAgentAISettings, } from "@/browser/utils/workspaceAiSettingsSync"; -import { resolveWorkspaceAiSettingsForAgent } from "@/browser/utils/workspaceModeAi"; +import { + getCreationWorkspaceAiSyncState, + resolveWorkspaceAiSettingsForAgent, +} from "@/browser/utils/workspaceModeAi"; import { getModelKey, getReasoningModeKey, @@ -959,12 +964,11 @@ const ChatInputInner: React.FC = (props) => { reasoningMode, }); - api.workspace - .updateAgentAISettings({ - workspaceId, - agentId: normalizedAgentId, - aiSettings: { model: selectedModel, thinkingLevel, reasoningMode }, - }) + updateWorkspaceAgentAISettings(api, { + workspaceId, + agentId: normalizedAgentId, + aiSettings: { model: selectedModel, thinkingLevel, reasoningMode }, + }) .then((result) => { if (!result.success) { clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); @@ -1280,10 +1284,12 @@ const ChatInputInner: React.FC = (props) => { const normalizedAgentId = normalizeAgentId(agentId, "exec"); - const isExplicitAgentSwitch = - prevCreationAgentIdRef.current !== null && - prevCreationScopeIdRef.current === scopeId && - prevCreationAgentIdRef.current !== normalizedAgentId; + const { isExplicitAgentSwitch, mode } = getCreationWorkspaceAiSyncState({ + previousAgentId: prevCreationAgentIdRef.current, + previousScopeId: prevCreationScopeIdRef.current, + agentId: normalizedAgentId, + scopeId, + }); // Update refs for the next run (even if no model changes). prevCreationAgentIdRef.current = normalizedAgentId; @@ -1309,7 +1315,8 @@ const ChatInputInner: React.FC = (props) => { existingModel, existingThinking, existingReasoningMode: existingReasoning, - agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])), + agents, + mode, }); if (existingModel !== resolvedModel) { @@ -2171,6 +2178,25 @@ const ChatInputInner: React.FC = (props) => { window.removeEventListener(CUSTOM_EVENTS.GOAL_CHILD_BUDGET_TOAST, handler as EventListener); }, [variant, workspaceId, pushToast]); + // Surface rejected agent switches (e.g. budgeted-goal pricing gate): the + // mode picker closes immediately, so the snap-back needs an explanation. + useEffect(() => { + if (variant !== "workspace") return; + + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ workspaceId: string; message: string }>).detail; + if (detail?.workspaceId !== workspaceId || !detail.message) { + return; + } + + pushToast({ type: "error", message: detail.message }); + }; + + window.addEventListener(CUSTOM_EVENTS.AGENT_SWITCH_ERROR_TOAST, handler as EventListener); + return () => + window.removeEventListener(CUSTOM_EVENTS.AGENT_SWITCH_ERROR_TOAST, handler as EventListener); + }, [variant, workspaceId, pushToast]); + // Show toast feedback for analytics rebuild command palette action. useEffect(() => { const handler = (event: Event) => { @@ -3183,7 +3209,7 @@ const ChatInputInner: React.FC = (props) => { props.onMessageSendStarted?.(overrides?.queueDispatchMode ?? "tool-end"); - const result = await api.workspace.sendMessage({ + const result = await sendWorkspaceMessage(api, { workspaceId: props.workspaceId, message: finalMessageText, options: sendOptions, diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 80d9e475830..a0a27f6b0df 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -16,6 +16,10 @@ import { } from "@/common/types/thinking"; import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings"; import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; +import { + sendWorkspaceMessage, + updateWorkspaceAgentAISettings, +} from "@/browser/utils/workspaceAiSettingsSync"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { @@ -622,18 +626,16 @@ export function useCreationWorkspace({ // is portable across devices even before the first stream starts. Initial /goal commands do // not send a normal user message, so they await this write before setting the goal; that lets // the backend kickoff continuation use the same model/agent selected in creation. - const initialAiSettingsPersisted = api.workspace - .updateAgentAISettings({ - workspaceId: metadata.id, - agentId: settings.agentId, - aiSettings: { - model: settings.model, - thinkingLevel: settings.thinkingLevel, - reasoningMode: settings.reasoningMode, - }, - persistSelectedAgentId: true, - }) - .catch(() => null); + const initialAiSettingsPersisted = updateWorkspaceAgentAISettings(api, { + workspaceId: metadata.id, + agentId: settings.agentId, + aiSettings: { + model: settings.model, + thinkingLevel: settings.thinkingLevel, + reasoningMode: settings.reasoningMode, + }, + persistSelectedAgentId: true, + }).catch(() => null); const isDraftScope = typeof draftId === "string" && draftId.trim().length > 0; const pendingScopeId = projectPath @@ -804,24 +806,22 @@ export function useCreationWorkspace({ // A transport-level rejection (e.g. oRPC disconnect) must flow through // the same failure branch as success:false: the outer catch would skip // the staged-draft transfer and the creation draft is already cleared. - const sendResult = await api.workspace - .sendMessage({ - workspaceId: metadata.id, - message: appendStagedAttachmentNotice(messageText, stagingOutcome.staged), - options: { - ...sendMessageOptions, - ...optionsOverride, - ...(muxMetadataWithNotice ? { muxMetadata: muxMetadataWithNotice } : {}), - additionalSystemInstructions: additionalSystemInstructions.length - ? additionalSystemInstructions - : undefined, - fileParts: fileParts && fileParts.length > 0 ? fileParts : undefined, - }, - }) - .catch((sendErr: unknown): { success: false; error: SendMessageError } => ({ - success: false, - error: { type: "unknown", raw: getErrorMessage(sendErr) }, - })); + const sendResult = await sendWorkspaceMessage(api, { + workspaceId: metadata.id, + message: appendStagedAttachmentNotice(messageText, stagingOutcome.staged), + options: { + ...sendMessageOptions, + ...optionsOverride, + ...(muxMetadataWithNotice ? { muxMetadata: muxMetadataWithNotice } : {}), + additionalSystemInstructions: additionalSystemInstructions.length + ? additionalSystemInstructions + : undefined, + fileParts: fileParts && fileParts.length > 0 ? fileParts : undefined, + }, + }).catch((sendErr: unknown): { success: false; error: SendMessageError } => ({ + success: false, + error: { type: "unknown", raw: getErrorMessage(sendErr) }, + })); if (!sendResult.success) { if (createdWorkspaceId) { diff --git a/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx b/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx index e3c954092a0..4cbb8dbb4c0 100644 --- a/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx @@ -3,6 +3,7 @@ import { AlertTriangle, RefreshCw } from "lucide-react"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { useAPI } from "@/browser/contexts/API"; import { useWorkspaceState } from "@/browser/stores/WorkspaceStore"; +import { resumeWorkspaceStream } from "@/browser/utils/workspaceAiSettingsSync"; import { getLastMainRetryCandidateMessage } from "@/common/utils/messages/retryEligibility"; import { KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; import { VIM_ENABLED_KEY } from "@/common/constants/storage"; @@ -201,7 +202,7 @@ export const RetryBarrier: React.FC = (props) => { manualRetryRollbackBaselineMessageCountRef.current = workspaceState.messages.length; } - const resumeResult = await api.workspace.resumeStream({ + const resumeResult = await resumeWorkspaceStream(api, { workspaceId: props.workspaceId, options, }); diff --git a/src/browser/features/Tools/AskUserQuestionToolCall.tsx b/src/browser/features/Tools/AskUserQuestionToolCall.tsx index d859c2a1203..d5b29fa397d 100644 --- a/src/browser/features/Tools/AskUserQuestionToolCall.tsx +++ b/src/browser/features/Tools/AskUserQuestionToolCall.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore import { useAPI } from "@/browser/contexts/API"; import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; +import { resumeWorkspaceStream } from "@/browser/utils/workspaceAiSettingsSync"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions"; import { useAutoResizeTextarea } from "@/browser/hooks/useAutoResizeTextarea"; @@ -533,7 +534,7 @@ export function AskUserQuestionToolCall(props: { } } - const resumeResult = await api.workspace.resumeStream({ + const resumeResult = await resumeWorkspaceStream(api, { workspaceId, options: sendOptions, }); diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index ffc00bc4133..1a9d130332e 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -14,6 +14,7 @@ import type { SendMessageOptions } from "@/common/orpc/types"; import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import { AgentProvider } from "@/browser/contexts/AgentContext"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { shouldApplyWorkspaceAgentIdFromBackend } from "@/browser/utils/workspaceAiSettingsSync"; import { AGENT_AI_DEFAULTS_KEY, getAgentIdKey, @@ -59,11 +60,33 @@ interface MockApi { mode?: "destructive" | "append-compaction-boundary" | null; deletePlanFile?: boolean; }) => Promise; - sendMessage: (args: SendMessageArgs) => Promise<{ success: true; data: undefined }>; + sendMessage: ( + args: SendMessageArgs + ) => Promise< + { success: true; data: Record } | { success: false; error: string } + >; + updateAgentAISettings: (args: { + workspaceId: string; + agentId: string; + aiSettings: { model: string; thinkingLevel: string; reasoningMode?: string } | null; + persistSelectedAgentId?: boolean; + }) => Promise<{ success: boolean; error?: string }>; }; } +let updateAgentAISettingsCalls: Array<{ + workspaceId: string; + agentId: string; + aiSettings: { model: string; thinkingLevel: string; reasoningMode?: string } | null; + persistSelectedAgentId?: boolean; +}> = []; + let mockApi: MockApi | null = null; +// Workspace metadata visible to the component (useOptionalWorkspaceContext mock). +let mockWorkspaceMetadataByWorkspace = new Map< + string, + { runtimeConfig?: unknown; agentId?: string; agentType?: string } +>(); let startHereCalls: Array<{ workspaceId: string | undefined; @@ -117,7 +140,10 @@ async function installProposePlanModuleMocks() { await mock.module("@/browser/contexts/WorkspaceContext", () => ({ ...actualWorkspaceContextModule, useWorkspaceContext: () => ({ - workspaceMetadata: new Map(), + workspaceMetadata: mockWorkspaceMetadataByWorkspace, + }), + useOptionalWorkspaceContext: () => ({ + workspaceMetadata: mockWorkspaceMetadataByWorkspace, }), })); await mock.module("@/browser/hooks/useReviews", () => ({ @@ -203,27 +229,34 @@ function createTestAgent( uiSelectable: true, subagentRunnable: true, aiDefaults: { model, thinkingLevel }, + ownAiDefaults: { model, thinkingLevel }, }; } const TEST_AGENTS = [ createTestAgent("exec", "Exec", "openai:gpt-5.2", "low"), createTestAgent("plan", "Plan", "anthropic:claude-sonnet-4-5", "high"), + createTestAgent("auto", "Auto", "openai:gpt-5.6-sol", "medium"), ]; const noop = () => { // intentional noop for tests }; -function renderToolCall(content: JSX.Element, agentId = "plan") { +function renderToolCall( + content: JSX.Element, + agentId = "plan", + agents: AgentDefinitionDescriptor[] = TEST_AGENTS, + loaded = true +) { return render( entry.id === agentId), - agents: TEST_AGENTS, - loaded: true, + currentAgent: agents.find((entry) => entry.id === agentId), + agents, + loaded, loadFailed: false, refresh: () => Promise.resolve(), refreshing: false, @@ -244,6 +277,7 @@ function createMockApi( getPlanContent?: MockApi["workspace"]["getPlanContent"]; replaceChatHistory?: MockApi["workspace"]["replaceChatHistory"]; sendMessage?: MockApi["workspace"]["sendMessage"]; + updateAgentAISettings?: MockApi["workspace"]["updateAgentAISettings"]; } = {} ): MockApi { return { @@ -258,8 +292,13 @@ function createMockApi( })), replaceChatHistory: overrides.replaceChatHistory ?? (() => Promise.resolve({ success: true, data: undefined })), - sendMessage: - overrides.sendMessage ?? (() => Promise.resolve({ success: true, data: undefined })), + sendMessage: overrides.sendMessage ?? (() => Promise.resolve({ success: true, data: {} })), + updateAgentAISettings: (args) => { + updateAgentAISettingsCalls.push(args); + return overrides.updateAgentAISettings + ? overrides.updateAgentAISettings(args) + : Promise.resolve({ success: true }); + }, }, }; } @@ -289,7 +328,7 @@ function startInPlanMode(workspaceId = WORKSPACE_ID, model?: string, thinkingLev function recordSendMessage(calls: SendMessageArgs[]): MockApi["workspace"]["sendMessage"] { return (args) => { calls.push(args); - return Promise.resolve({ success: true, data: undefined }); + return Promise.resolve({ success: true, data: {} }); }; } @@ -313,7 +352,9 @@ describe("ProposePlanToolCall", () => { beforeEach(async () => { startHereCalls = []; selectableDiffRendererCalls = []; + updateAgentAISettingsCalls = []; mockApi = null; + mockWorkspaceMetadataByWorkspace = new Map(); cleanupDom = installDom(); await installProposePlanModuleMocks(); }); @@ -477,6 +518,33 @@ describe("ProposePlanToolCall", () => { expect(view.getAllByRole("button", { name: "Annotate" }).length).toBe(2); }); + test("disables Implement until the exec descriptor is available", async () => { + startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); + const sendMessageCalls: SendMessageArgs[] = []; + mockApi = createMockApi({ sendMessage: recordSendMessage(sendMessageCalls) }); + + const view = renderToolCall( + , + "plan", + [], + false + ); + + const implement = view.getByRole("button", { name: "Implement" }); + expect(implement.hasAttribute("disabled")).toBe(true); + fireEvent.click(implement); + await Promise.resolve(); + + expect(sendMessageCalls).toHaveLength(0); + expect(updateAgentAISettingsCalls).toHaveLength(0); + }); + test("switches to exec and sends a message when clicking Implement", async () => { const execModel = "openai:gpt-5.2"; const execThinking = "low"; @@ -518,6 +586,127 @@ describe("ProposePlanToolCall", () => { expect(JSON.parse(window.localStorage.getItem(modelKey)!)).toBe(execModel); expect(JSON.parse(window.localStorage.getItem(thinkingKey)!)).toBe(execThinking); } + + // The send itself carries and persists the switch backend-side; the + // component must not issue a separate settings write that could clobber + // a newer selection. + expect(updateAgentAISettingsCalls).toHaveLength(0); + // Guard released after the send settles so backend agent updates apply. + await waitFor(() => + expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) + ); + }); + + test("uses exec definition defaults for Implement without saved overrides", async () => { + const execModel = "openai:gpt-5.2"; + const execThinking = "low"; + + startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); + updatePersistedState(AGENT_AI_DEFAULTS_KEY, {}); + + const sendMessageCalls: SendMessageArgs[] = []; + mockApi = createMockApi({ sendMessage: recordSendMessage(sendMessageCalls) }); + + const view = renderCompletedPlan(); + fireEvent.click(view.getByRole("button", { name: "Implement" })); + + await waitFor(() => expect(sendMessageCalls.length).toBe(1)); + expect(sendMessageCalls[0]?.options.agentId).toBe("exec"); + expect(sendMessageCalls[0]?.options.model).toBe(execModel); + expect(sendMessageCalls[0]?.options.thinkingLevel).toBe(execThinking); + }); + + test("typed rejection reverts the optimistic Implement switch", async () => { + startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); + + const sendMessageCalls: SendMessageArgs[] = []; + mockApi = createMockApi({ + sendMessage: (args) => { + sendMessageCalls.push(args); + return Promise.resolve({ success: false as const, error: "send rejected" }); + }, + }); + + const view = renderCompletedPlan(); + + fireEvent.click(view.getByRole("button", { name: "Implement" })); + + await waitFor(() => expect(sendMessageCalls.length).toBe(1)); + + // A typed rejection cannot self-heal: the same gate refuses the next send + // before it can re-persist the switch, so the optimistic switch reverts + // to the pre-click selection. + await waitFor(() => + expect(JSON.parse(window.localStorage.getItem(getAgentIdKey(WORKSPACE_ID))!)).toBe("plan") + ); + expect(JSON.parse(window.localStorage.getItem(getModelKey(WORKSPACE_ID))!)).toBe( + "anthropic:claude-sonnet-4-5" + ); + expect(JSON.parse(window.localStorage.getItem(getThinkingLevelKey(WORKSPACE_ID))!)).toBe( + "high" + ); + // The guard must be released: a differing backend agent update has to + // apply again instead of being rejected forever (probing with a + // non-matching agent does not mutate). + await waitFor(() => + expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) + ); + // No compensating backend write. + expect(updateAgentAISettingsCalls).toHaveLength(0); + }); + + test("rejected Implement restores the backend agent over a pending picker agent", async () => { + startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); + // Backend still stores plan; a rejected-in-flight picker switch left the + // local selection on "review" — the captured pre-action agent is NOT what + // the backend stores. + mockWorkspaceMetadataByWorkspace.set(WORKSPACE_ID, { agentId: "plan" }); + window.localStorage.setItem(getAgentIdKey(WORKSPACE_ID), JSON.stringify("review")); + + const sendMessageCalls: SendMessageArgs[] = []; + mockApi = createMockApi({ + sendMessage: (args) => { + sendMessageCalls.push(args); + return Promise.resolve({ success: false as const, error: "send rejected" }); + }, + }); + + const view = renderCompletedPlan(); + + fireEvent.click(view.getByRole("button", { name: "Implement" })); + + await waitFor(() => expect(sendMessageCalls.length).toBe(1)); + + // The revert lands on the backend-authoritative agent, not the captured + // optimistic "review" selection the backend never accepted. + await waitFor(() => + expect(JSON.parse(window.localStorage.getItem(getAgentIdKey(WORKSPACE_ID))!)).toBe("plan") + ); + }); + + test("transport-failed Implement send keeps the optimistic switch", async () => { + startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); + + const sendMessageCalls: SendMessageArgs[] = []; + mockApi = createMockApi({ + sendMessage: (args) => { + sendMessageCalls.push(args); + return Promise.reject(new Error("network down")); + }, + }); + + const view = renderCompletedPlan(); + + fireEvent.click(view.getByRole("button", { name: "Implement" })); + + await waitFor(() => expect(sendMessageCalls.length).toBe(1)); + await waitFor(() => + expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) + ); + // Transport failures self-heal (the next successful send re-persists the + // selection), so the switch stays. + expect(JSON.parse(window.localStorage.getItem(getAgentIdKey(WORKSPACE_ID))!)).toBe("exec"); + expect(updateAgentAISettingsCalls).toHaveLength(0); }); test("uses workspace-by-agent override for Implement when exec defaults inherit", async () => { @@ -568,7 +757,7 @@ describe("ProposePlanToolCall", () => { sendMessage: (args) => { calls.push("sendMessage"); sendMessageCalls.push(args); - return Promise.resolve({ success: true, data: undefined }); + return Promise.resolve({ success: true, data: {} }); }, }); diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index 4cf57c0c89c..dd7222aafcb 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -38,6 +38,7 @@ import { useAPI } from "@/browser/contexts/API"; import { useAgent } from "@/browser/contexts/AgentContext"; import { useOpenInEditor } from "@/browser/hooks/useOpenInEditor"; import { useOptionalWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; +import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; import { usePopoverError } from "@/browser/hooks/usePopoverError"; import { PopoverError } from "@/browser/components/PopoverError/PopoverError"; import { @@ -54,6 +55,13 @@ import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePer import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; import { + clearPendingWorkspaceAgentId, + markPendingWorkspaceAgentId, + revertRejectedAgentSwitch, + sendWorkspaceMessage, +} from "@/browser/utils/workspaceAiSettingsSync"; +import { + hasWorkspaceAiTargetDescriptor, resolveWorkspaceAiSettingsForAgent, type WorkspaceAISettingsCache, } from "@/browser/utils/workspaceModeAi"; @@ -187,10 +195,13 @@ export const ProposePlanToolCall: React.FC = (props) = // also implicitly scopes lookups away from neighbouring tool calls/transcripts. const planContentRef = useRef(null); const { api } = useAPI(); - const { agentId: currentAgentId, agents } = useAgent(); + const { agentId: currentAgentId, agents, loaded: agentsLoaded } = useAgent(); const isAutoMode = currentAgentId === "auto"; + const canResolveExec = agentsLoaded && hasWorkspaceAiTargetDescriptor("exec", agents); + const canResolveAuto = agentsLoaded && hasWorkspaceAiTargetDescriptor("auto", agents); const openInEditor = useOpenInEditor(); const workspaceContext = useOptionalWorkspaceContext(); + const workspaceStore = useWorkspaceStoreRaw(); const editorError = usePopoverError(); const editButtonRef = useRef(null); @@ -475,7 +486,12 @@ export const ProposePlanToolCall: React.FC = (props) = const resolveAndPersistTargetAgentSettings = (args: { workspaceId: string; targetAgentId: "auto" | "exec"; - }): { resolvedModel: string; resolvedThinking: ThinkingLevel } => { + }): { + resolvedModel: string; + resolvedThinking: ThinkingLevel; + /** Undo this switch after a typed send rejection (transport failures keep it). */ + revertSelection: () => void; + } | null => { const modelKey = getModelKey(args.workspaceId); const thinkingKey = getThinkingLevelKey(args.workspaceId); const reasoningKey = getReasoningModeKey(args.workspaceId); @@ -490,21 +506,26 @@ export const ProposePlanToolCall: React.FC = (props) = {} ); - const { resolvedModel, resolvedThinking, resolvedReasoningMode } = - resolveWorkspaceAiSettingsForAgent({ - agentId: args.targetAgentId, - agentAiDefaults, - // Propose-plan actions are explicit mode switches; honor any per-agent - // workspace override before inheriting the previously active plan settings. - workspaceByAgent, - useWorkspaceByAgentFallback: true, - fallbackModel, - existingModel, - existingThinking, - existingReasoningMode: existingReasoning, - agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])), - }); + const resolvedSettings = resolveWorkspaceAiSettingsForAgent({ + agentId: args.targetAgentId, + agentAiDefaults, + workspaceByAgent, + fallbackModel, + existingModel, + existingThinking, + existingReasoningMode: existingReasoning, + agents, + mode: "explicit-switch", + }); + if (!resolvedSettings) return null; + const { resolvedModel, resolvedThinking, resolvedReasoningMode } = resolvedSettings; + const previousAgentId = + readPersistedState(getAgentIdKey(args.workspaceId), null) ?? currentAgentId; + + // The follow-up send persists this switch to the backend; guard the interim + // against stale metadata broadcasts re-seeding the previous agent. + markPendingWorkspaceAgentId(args.workspaceId, args.targetAgentId); updatePersistedState(getAgentIdKey(args.workspaceId), args.targetAgentId); if (existingModel !== resolvedModel) { @@ -518,11 +539,32 @@ export const ProposePlanToolCall: React.FC = (props) = updatePersistedState(reasoningKey, resolvedReasoningMode); } - return { resolvedModel, resolvedThinking }; + return { + resolvedModel, + resolvedThinking, + revertSelection: () => + revertRejectedAgentSwitch({ + workspaceId: args.workspaceId, + rejectedAgentId: args.targetAgentId, + applied: { + model: resolvedModel, + thinkingLevel: resolvedThinking, + reasoningMode: resolvedReasoningMode, + }, + previous: { + agentId: previousAgentId, + model: existingModel, + thinkingLevel: existingThinking, + reasoningMode: existingReasoning, + }, + backendMetadata: + workspaceStore.getWorkspaceMetadata(args.workspaceId) ?? workspaceMetadata, + }), + }; }; const handleImplement = async () => { - if (!workspaceId || !api) return; + if (!workspaceId || !api || !canResolveExec) return; if (isImplementingRef.current) return; isImplementingRef.current = true; @@ -530,6 +572,7 @@ export const ProposePlanToolCall: React.FC = (props) = setIsImplementing(true); } + const targetAgentId = "exec"; try { let shouldReplaceChatHistory = false; @@ -548,14 +591,20 @@ export const ProposePlanToolCall: React.FC = (props) = }); } - const targetAgentId = "exec"; - const { resolvedModel, resolvedThinking } = resolveAndPersistTargetAgentSettings({ + const targetSettings = resolveAndPersistTargetAgentSettings({ workspaceId, targetAgentId, }); + if (!targetSettings) return; + const { resolvedModel, resolvedThinking, revertSelection } = targetSettings; const sendMessageOptions = getSendOptionsFromStorage(workspaceId); - await api.workspace.sendMessage({ + // The send carries the switch and persists it backend-side best-effort + // (maybePersistAISettingsFromOptions). A transport-failed send keeps the + // local switch (the next send re-persists it), but a typed rejection + // (e.g. the budgeted-goal pricing gate) refuses every send before + // persistence — no self-heal is coming — so it reverts the switch. + const result = await sendWorkspaceMessage(api, { workspaceId, message: "Implement the plan", options: { @@ -565,9 +614,16 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: resolvedThinking, }, }); + if (!result.success) { + revertSelection(); + } } catch { // Best-effort: user can retry manually if sending fails. } finally { + // Release the echo guard on every outcome: successful writes echo the + // authoritative agent (no-op writes emit none), failed sends never echo, + // and a stuck guard would block backend agent seeds indefinitely. + clearPendingWorkspaceAgentId(workspaceId, targetAgentId); isImplementingRef.current = false; if (isMountedRef.current) { setIsImplementing(false); @@ -575,7 +631,7 @@ export const ProposePlanToolCall: React.FC = (props) = } }; const handleContinueInAuto = async () => { - if (!workspaceId || !api) return; + if (!workspaceId || !api || !canResolveAuto) return; if (isContinuingInAutoRef.current) return; isContinuingInAutoRef.current = true; @@ -583,6 +639,7 @@ export const ProposePlanToolCall: React.FC = (props) = setIsContinuingInAuto(true); } + const targetAgentId = "auto"; try { let shouldReplaceChatHistory = false; @@ -601,14 +658,17 @@ export const ProposePlanToolCall: React.FC = (props) = }); } - const targetAgentId = "auto"; - const { resolvedModel, resolvedThinking } = resolveAndPersistTargetAgentSettings({ + const targetSettings = resolveAndPersistTargetAgentSettings({ workspaceId, targetAgentId, }); + if (!targetSettings) return; + const { resolvedModel, resolvedThinking, revertSelection } = targetSettings; const sendMessageOptions = getSendOptionsFromStorage(workspaceId); - await api.workspace.sendMessage({ + // See handleImplement: transport failures keep the switch; typed + // rejections revert it. + const result = await sendWorkspaceMessage(api, { workspaceId, message: "Implement the plan", options: { @@ -618,9 +678,14 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: resolvedThinking, }, }); + if (!result.success) { + revertSelection(); + } } catch { // Best-effort: user can retry manually if sending fails. } finally { + // See handleImplement: release the echo guard on every outcome. + clearPendingWorkspaceAgentId(workspaceId, targetAgentId); isContinuingInAutoRef.current = false; if (isMountedRef.current) { setIsContinuingInAuto(false); @@ -698,7 +763,7 @@ export const ProposePlanToolCall: React.FC = (props) = ? { label: "Implement", onClick: () => void handleImplement(), - disabled: !api || isImplementing || isContinuingInAuto, + disabled: !api || !canResolveExec || isImplementing || isContinuingInAuto, icon: , tooltip: implementReplacesChatHistory ? "Replace chat history with this plan, switch to Exec, and start implementing" @@ -711,7 +776,7 @@ export const ProposePlanToolCall: React.FC = (props) = ? { label: "Continue in Auto", onClick: () => void handleContinueInAuto(), - disabled: !api || isContinuingInAuto || isImplementing, + disabled: !api || !canResolveAuto || isContinuingInAuto || isImplementing, icon: , tooltip: implementReplacesChatHistory ? "Replace chat history with this plan, switch to Auto, and let it decide the executor" diff --git a/src/browser/hooks/useResumeStream.ts b/src/browser/hooks/useResumeStream.ts index c2512b1b8ea..9e2f3721544 100644 --- a/src/browser/hooks/useResumeStream.ts +++ b/src/browser/hooks/useResumeStream.ts @@ -1,6 +1,7 @@ import { useRef, useState } from "react"; import { useAPI } from "@/browser/contexts/API"; import { useWorkspaceState } from "@/browser/stores/WorkspaceStore"; +import { resumeWorkspaceStream } from "@/browser/utils/workspaceAiSettingsSync"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions"; import { formatSendMessageError } from "@/common/utils/errors/formatSendError"; @@ -75,7 +76,7 @@ export function useResumeStream( options = applyCompactionOverrides(options, lastUserMessage.compactionRequest.parsed); } - const result = await api.workspace.resumeStream({ workspaceId, options }); + const result = await resumeWorkspaceStream(api, { workspaceId, options }); if (!result.success) { const formatted = formatSendMessageError(result.error); applyIfCurrent(() => diff --git a/src/browser/utils/agents.ts b/src/browser/utils/agents.ts index e17c07f04d0..3b91f4c8f16 100644 --- a/src/browser/utils/agents.ts +++ b/src/browser/utils/agents.ts @@ -4,6 +4,10 @@ import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; // Only includes agents that are uiSelectable by default. const BUILTIN_AGENT_ORDER: readonly string[] = ["exec", "plan"]; +export function isBuiltInSelectableAgentId(agentId: string): boolean { + return BUILTIN_AGENT_ORDER.includes(agentId); +} + /** * Sort agents with stable ordering: built-ins first (exec, plan), * then custom agents alphabetically by name. diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 152d9739f8a..5ec762a3f18 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -74,6 +74,7 @@ import { getStagedAttachments, } from "@/browser/features/ChatInput/stagedAttachments"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; +import { sendWorkspaceMessage } from "@/browser/utils/workspaceAiSettingsSync"; // ============================================================================ // Workspace Creation @@ -152,15 +153,13 @@ export async function forkWorkspace(options: ForkOptions): Promise { const sendMessageOptions = options.sendMessageOptions; if (startMessage && sendMessageOptions) { requestAnimationFrame(() => { - client.workspace - .sendMessage({ - workspaceId: result.metadata.id, - message: startMessage, - options: sendMessageOptions, - }) - .catch(() => { - // Best-effort: the user can send the message manually if this fails. - }); + sendWorkspaceMessage(client, { + workspaceId: result.metadata.id, + message: startMessage, + options: sendMessageOptions, + }).catch(() => { + // Best-effort: the user can send the message manually if this fails. + }); }); } @@ -531,7 +530,7 @@ export async function processSlashCommand( // Keep workflow outputs model-visible but UI-hidden: rawCommand drives transcript display, // while the XML block below gives the main agent the completed workflow result. setWorkflowSendingState(true); - const sendResult = await activeClient.workspace.sendMessage({ + const sendResult = await sendWorkspaceMessage(activeClient, { workspaceId, message: workflowResultMessage, options: { @@ -1546,15 +1545,13 @@ export async function createNewWorkspace( const client = options.client; if (startMessage && sendMessageOptions) { requestAnimationFrame(() => { - client.workspace - .sendMessage({ - workspaceId: result.metadata.id, - message: startMessage, - options: sendMessageOptions, - }) - .catch(() => { - // Best-effort: the user can send the message manually if this fails. - }); + sendWorkspaceMessage(client, { + workspaceId: result.metadata.id, + message: startMessage, + options: sendMessageOptions, + }).catch(() => { + // Best-effort: the user can send the message manually if this fails. + }); }); } @@ -1684,7 +1681,7 @@ export async function executeCompaction( ): Promise { const { messageText, metadata, sendOptions } = prepareCompactionMessage(options); - const result = await options.api.workspace.sendMessage({ + const result = await sendWorkspaceMessage(options.api, { workspaceId: options.workspaceId, message: messageText, options: { diff --git a/src/browser/utils/workspaceAiSettingsSync.test.ts b/src/browser/utils/workspaceAiSettingsSync.test.ts new file mode 100644 index 00000000000..332335a616a --- /dev/null +++ b/src/browser/utils/workspaceAiSettingsSync.test.ts @@ -0,0 +1,286 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { installDom } from "../../../tests/ui/dom"; +import { + getAgentIdKey, + getModelKey, + getReasoningModeKey, + getThinkingLevelKey, +} from "@/common/constants/storage"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { APIClient } from "@/browser/contexts/API"; +import { + clearPendingWorkspaceAgentId, + markPendingWorkspaceAgentId, + revertRejectedAgentSwitch, + resumeWorkspaceStream, + sendWorkspaceMessage, + shouldApplyWorkspaceAgentIdFromBackend, + updateWorkspaceAgentAISettings, +} from "./workspaceAiSettingsSync"; + +const WORKSPACE_ID = "ws-revert"; + +function makeMetadata( + overrides: Partial = {} +): FrontendWorkspaceMetadata { + return { + id: WORKSPACE_ID, + projectPath: "/tmp/project", + projectName: "project", + name: "main", + namedWorkspacePath: `/tmp/project/${WORKSPACE_ID}`, + createdAt: "2025-01-01T00:00:00.000Z", + runtimeConfig: { type: "local", srcBaseDir: "/tmp/.mux/src" }, + ...overrides, + }; +} + +function seed(key: string, value: unknown): void { + window.localStorage.setItem(key, JSON.stringify(value)); +} + +function read(key: string): unknown { + const raw = window.localStorage.getItem(key); + return raw == null ? null : JSON.parse(raw); +} + +describe("workspace agent persistence guard", () => { + test("retains the latest selection until every older write settles", () => { + markPendingWorkspaceAgentId(WORKSPACE_ID, "plan"); + markPendingWorkspaceAgentId(WORKSPACE_ID, "review"); + + // The latest echo applies, but it must not consume the only ordering guard. + expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "review")).toBe(true); + expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(false); + + // Even when the latest write settles first, the older write can still echo. + clearPendingWorkspaceAgentId(WORKSPACE_ID, "review"); + expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(false); + + clearPendingWorkspaceAgentId(WORKSPACE_ID, "plan"); + expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true); + }); + + test("commits settings updates and sends in initiation order", async () => { + let resolvePlan!: () => void; + const planCommit = new Promise((resolve) => { + resolvePlan = resolve; + }); + const started: string[] = []; + let persistedAgentId = "exec"; + const api = { + workspace: { + updateAgentAISettings: async ( + input: Parameters[0] + ) => { + started.push(input.agentId); + await planCommit; + persistedAgentId = input.agentId; + return { success: true as const, data: undefined }; + }, + sendMessage: (input: Parameters[0]) => { + started.push(input.options.agentId ?? "missing"); + persistedAgentId = input.options.agentId ?? persistedAgentId; + return Promise.resolve({ success: true as const, data: {} }); + }, + }, + }; + + const planWrite = updateWorkspaceAgentAISettings(api, { + workspaceId: WORKSPACE_ID, + agentId: "plan", + aiSettings: { model: "openai:plan", thinkingLevel: "high" }, + persistSelectedAgentId: true, + }); + const execSend = sendWorkspaceMessage(api, { + workspaceId: WORKSPACE_ID, + message: "Implement the plan", + options: { agentId: "exec", model: "openai:exec", thinkingLevel: "medium" }, + }); + + await Promise.resolve(); + expect(started).toEqual(["plan"]); + expect(persistedAgentId).toBe("exec"); + + resolvePlan(); + await Promise.all([planWrite, execSend]); + + expect(started).toEqual(["plan", "exec"]); + expect(persistedAgentId).toBe("exec"); + }); + + test("commits resumes and later settings updates in initiation order", async () => { + let resolveResume!: () => void; + const resumeCommit = new Promise((resolve) => { + resolveResume = resolve; + }); + const started: string[] = []; + let persistedAgentId = "exec"; + const api = { + workspace: { + resumeStream: async (input: Parameters[0]) => { + started.push(input.options.agentId ?? "missing"); + await resumeCommit; + persistedAgentId = input.options.agentId ?? persistedAgentId; + return { success: true as const, data: { started: true } }; + }, + updateAgentAISettings: ( + input: Parameters[0] + ) => { + started.push(input.agentId); + persistedAgentId = input.agentId; + return Promise.resolve({ success: true as const, data: undefined }); + }, + }, + }; + + const execResume = resumeWorkspaceStream(api, { + workspaceId: WORKSPACE_ID, + options: { agentId: "exec", model: "openai:exec", thinkingLevel: "medium" }, + }); + const planWrite = updateWorkspaceAgentAISettings(api, { + workspaceId: WORKSPACE_ID, + agentId: "plan", + aiSettings: { model: "openai:plan", thinkingLevel: "high" }, + persistSelectedAgentId: true, + }); + + await Promise.resolve(); + expect(started).toEqual(["exec"]); + + resolveResume(); + await Promise.all([execResume, planWrite]); + + expect(started).toEqual(["exec", "plan"]); + expect(persistedAgentId).toBe("plan"); + }); +}); + +describe("revertRejectedAgentSwitch", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanupDom?.(); + cleanupDom = null; + }); + + test("hydrates the backend bucket when the backend already stores the rejected agent", () => { + // A transport-failed switch previously left the renderer diverged; the + // user switched back to the backend's agent, carrying over unpriced + // settings, and that write was rejected. Identity needs no change, but + // the settings must still restore from the backend's own bucket. + seed(getAgentIdKey(WORKSPACE_ID), "exec"); + seed(getModelKey(WORKSPACE_ID), "openai:unpriced-x"); + seed(getThinkingLevelKey(WORKSPACE_ID), "high"); + seed(getReasoningModeKey(WORKSPACE_ID), "standard"); + + revertRejectedAgentSwitch({ + workspaceId: WORKSPACE_ID, + rejectedAgentId: "exec", + applied: { model: "openai:unpriced-x", thinkingLevel: "high", reasoningMode: "standard" }, + previous: { + agentId: "plan", + model: "openai:unpriced-x", + thinkingLevel: "high", + reasoningMode: "standard", + }, + backendMetadata: makeMetadata({ + agentId: "exec", + aiSettingsByAgent: { exec: { model: "openai:priced", thinkingLevel: "low" } }, + }), + }); + + expect(read(getAgentIdKey(WORKSPACE_ID))).toBe("exec"); + expect(read(getModelKey(WORKSPACE_ID))).toBe("openai:priced"); + expect(read(getThinkingLevelKey(WORKSPACE_ID))).toBe("low"); + expect(read(getReasoningModeKey(WORKSPACE_ID))).toBe("standard"); + }); + + test("falls back to the legacy shared blob for the restore target's settings", () => { + seed(getAgentIdKey(WORKSPACE_ID), "exec"); + seed(getModelKey(WORKSPACE_ID), "openai:unpriced-x"); + seed(getThinkingLevelKey(WORKSPACE_ID), "high"); + + revertRejectedAgentSwitch({ + workspaceId: WORKSPACE_ID, + rejectedAgentId: "exec", + applied: { model: "openai:unpriced-x", thinkingLevel: "high", reasoningMode: "standard" }, + previous: { + agentId: "plan", + model: "openai:unpriced-x", + thinkingLevel: "high", + reasoningMode: "standard", + }, + backendMetadata: makeMetadata({ + agentId: "exec", + aiSettings: { model: "openai:legacy-priced", thinkingLevel: "off" }, + }), + }); + + expect(read(getModelKey(WORKSPACE_ID))).toBe("openai:legacy-priced"); + expect(read(getThinkingLevelKey(WORKSPACE_ID))).toBe("off"); + }); + + test("atomically hydrates another agent after the rejected agent was edited", () => { + seed(getAgentIdKey(WORKSPACE_ID), "plan"); + // The user edits plan's model while its persistence request is in flight. + // Reverting identity to exec must not leave that plan model in the shared composer. + seed(getModelKey(WORKSPACE_ID), "openai:user-picked-for-plan"); + seed(getThinkingLevelKey(WORKSPACE_ID), "high"); + seed(getReasoningModeKey(WORKSPACE_ID), "standard"); + + revertRejectedAgentSwitch({ + workspaceId: WORKSPACE_ID, + rejectedAgentId: "plan", + applied: { model: "openai:unpriced-x", thinkingLevel: "high", reasoningMode: "standard" }, + previous: { + agentId: "exec", + model: "openai:old-exec", + thinkingLevel: "off", + reasoningMode: "standard", + }, + backendMetadata: makeMetadata({ + agentId: "exec", + aiSettingsByAgent: { + exec: { model: "openai:priced-exec", thinkingLevel: "low", reasoningMode: "pro" }, + }, + }), + }); + + expect(read(getAgentIdKey(WORKSPACE_ID))).toBe("exec"); + expect(read(getModelKey(WORKSPACE_ID))).toBe("openai:priced-exec"); + expect(read(getThinkingLevelKey(WORKSPACE_ID))).toBe("low"); + expect(read(getReasoningModeKey(WORKSPACE_ID))).toBe("pro"); + }); + + test("newer user edits are never clobbered by the revert", () => { + seed(getAgentIdKey(WORKSPACE_ID), "exec"); + // The user picked a different model after the rejected switch wrote its + // settings; only keys still holding the applied values may be restored. + seed(getModelKey(WORKSPACE_ID), "openai:user-picked"); + seed(getThinkingLevelKey(WORKSPACE_ID), "high"); + + revertRejectedAgentSwitch({ + workspaceId: WORKSPACE_ID, + rejectedAgentId: "exec", + applied: { model: "openai:unpriced-x", thinkingLevel: "high", reasoningMode: "standard" }, + previous: { + agentId: "plan", + model: "openai:old", + thinkingLevel: "off", + reasoningMode: "standard", + }, + backendMetadata: makeMetadata({ + agentId: "exec", + aiSettingsByAgent: { exec: { model: "openai:priced", thinkingLevel: "low" } }, + }), + }); + + expect(read(getModelKey(WORKSPACE_ID))).toBe("openai:user-picked"); + expect(read(getThinkingLevelKey(WORKSPACE_ID))).toBe("low"); + }); +}); diff --git a/src/browser/utils/workspaceAiSettingsSync.ts b/src/browser/utils/workspaceAiSettingsSync.ts index 69beb191693..387bc9e2be6 100644 --- a/src/browser/utils/workspaceAiSettingsSync.ts +++ b/src/browser/utils/workspaceAiSettingsSync.ts @@ -1,6 +1,17 @@ import { normalizeModelPreference } from "@/browser/utils/messages/buildSendMessageOptions"; +import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; +import { + getAgentIdKey, + getModelKey, + getReasoningModeKey, + getThinkingLevelKey, +} from "@/common/constants/storage"; +import { normalizeAgentId, resolvePersistedAgentId } from "@/common/utils/agentIds"; +import { serializeWorkspaceAiSettingsWrite } from "@/common/utils/ai/workspaceAiSettingsWrite"; import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { APIClient } from "@/browser/contexts/API"; interface WorkspaceAiSettingsSnapshot { model: string; @@ -37,6 +48,50 @@ export function resolveEffectiveComposerModel( return normalizeModelPreference(preferredModel, metadataModel ?? defaultModel); } +interface WorkspaceSendApi { + workspace: Pick; +} + +interface WorkspaceResumeApi { + workspace: Pick; +} + +interface WorkspaceAiSettingsUpdateApi { + workspace: Pick; +} +type SendMessageInput = Parameters[0]; +type ResumeStreamInput = Parameters[0]; +type UpdateAgentAISettingsInput = Parameters[0]; + +export function updateWorkspaceAgentAISettings( + api: WorkspaceAiSettingsUpdateApi, + input: UpdateAgentAISettingsInput +): ReturnType { + return serializeWorkspaceAiSettingsWrite(input.workspaceId, () => + api.workspace.updateAgentAISettings(input) + ); +} + +export function sendWorkspaceMessage( + api: WorkspaceSendApi, + input: SendMessageInput +): ReturnType { + const send = () => api.workspace.sendMessage(input); + return input.options.skipAiSettingsPersistence === true + ? send() + : serializeWorkspaceAiSettingsWrite(input.workspaceId, send); +} + +export function resumeWorkspaceStream( + api: WorkspaceResumeApi, + input: ResumeStreamInput +): ReturnType { + const resume = () => api.workspace.resumeStream(input); + return input.options.skipAiSettingsPersistence === true + ? resume() + : serializeWorkspaceAiSettingsWrite(input.workspaceId, resume); +} + const pendingAiSettingsByWorkspace = new Map(); function getPendingKey(workspaceId: string, agentId: string): string { @@ -88,3 +143,163 @@ export function shouldApplyWorkspaceAiSettingsFromBackend( return false; } + +// Same pending-echo protection as AI settings, but retain the latest selection +// until every overlapping persistence write settles. A matching latest echo +// cannot consume the guard while an older write can still broadcast later. +interface PendingAgentIdState { + latestAgentId: string; + pendingCount: number; + countsByAgentId: Map; +} + +const pendingAgentIdByWorkspace = new Map(); + +export function markPendingWorkspaceAgentId(workspaceId: string, agentId: string): void { + if (!workspaceId || !agentId) { + return; + } + const pending = pendingAgentIdByWorkspace.get(workspaceId) ?? { + latestAgentId: agentId, + pendingCount: 0, + countsByAgentId: new Map(), + }; + pending.latestAgentId = agentId; + pending.pendingCount += 1; + pending.countsByAgentId.set(agentId, (pending.countsByAgentId.get(agentId) ?? 0) + 1); + pendingAgentIdByWorkspace.set(workspaceId, pending); +} + +export function clearPendingWorkspaceAgentId(workspaceId: string, agentId: string): void { + const pending = pendingAgentIdByWorkspace.get(workspaceId); + const count = pending?.countsByAgentId.get(agentId) ?? 0; + if (!pending || count === 0) { + return; + } + + if (count === 1) { + pending.countsByAgentId.delete(agentId); + } else { + pending.countsByAgentId.set(agentId, count - 1); + } + pending.pendingCount -= 1; + if (pending.pendingCount === 0) { + pendingAgentIdByWorkspace.delete(workspaceId); + } +} + +export function shouldApplyWorkspaceAgentIdFromBackend( + workspaceId: string, + incomingAgentId: string +): boolean { + const pending = pendingAgentIdByWorkspace.get(workspaceId); + return !pending || pending.latestAgentId === incomingAgentId; +} + +/** + * Restore local selection state after the backend issued a typed rejection for + * an optimistic agent switch (e.g. the budgeted-goal pricing gate). + * + * Only typed rejections revert. Transport failures keep the optimistic + * selection: the next send re-persists it (maybePersistAISettingsFromOptions), + * whereas a typed rejection cannot self-heal because the same gate refuses + * subsequent sends before they re-persist settings. + * + * The restore target prefers the backend's authoritative agent id (from + * fresh workspace metadata read at settle time, resolved through the legacy + * agentType compat path) over the locally captured pre-switch agent: with + * chained or overlapping optimistic switches, a captured "previous" can + * itself be a rejected or superseded agent while the backend stores another. + * Settings restore from the restore target's own metadata bucket (or the + * legacy shared blob, matching backend dispatch fallback), else from the + * captured pre-switch values when the target IS the captured agent. + * + * A newer agent selection always wins. When identity reverts, the shared composer + * must atomically hydrate the restore target; edits made while the rejected agent + * was active remain in that agent's cache instead of leaking across identities. + */ +export function revertRejectedAgentSwitch(args: { + workspaceId: string; + rejectedAgentId: string; + applied: { model: string; thinkingLevel: ThinkingLevel; reasoningMode: OpenAIReasoningMode }; + previous: { + agentId: string; + model: string; + thinkingLevel: ThinkingLevel; + reasoningMode: OpenAIReasoningMode; + }; + /** Fresh workspace metadata at settle time (authoritative backend state). */ + backendMetadata?: FrontendWorkspaceMetadata | null; +}): void { + const agentKey = getAgentIdKey(args.workspaceId); + const rawCurrent = readPersistedState(agentKey, null); + if (rawCurrent == null) { + return; + } + const currentAgentId = normalizeAgentId(rawCurrent); + if (currentAgentId !== normalizeAgentId(args.rejectedAgentId)) { + return; + } + + const previousAgentId = normalizeAgentId(args.previous.agentId); + const backendResolved = resolvePersistedAgentId(args.backendMetadata ?? undefined, ""); + const restoreAgentId = + backendResolved.length > 0 ? normalizeAgentId(backendResolved) : previousAgentId; + + // Authoritative settings for the restore target: its modern bucket, else the + // legacy shared blob (the same fallback backend dispatch resolution uses), + // else the captured pre-switch values when the target IS the captured agent. + // This runs even when no agent-id write is needed: the backend may already + // store the rejected agent id while the rejected SETTINGS came from a + // divergent carried-over selection. + const backendBucket = + args.backendMetadata?.aiSettingsByAgent?.[restoreAgentId] ?? args.backendMetadata?.aiSettings; + const restore = backendBucket + ? { + model: backendBucket.model, + thinkingLevel: backendBucket.thinkingLevel, + reasoningMode: backendBucket.reasoningMode ?? ("standard" as const), + } + : restoreAgentId === previousAgentId + ? { + model: args.previous.model, + thinkingLevel: args.previous.thinkingLevel, + reasoningMode: args.previous.reasoningMode, + } + : null; + + const isRestoringAnotherAgent = restoreAgentId !== currentAgentId; + + // Restore settings before the agent id so explicit-switch resolution runs + // against restored values instead of the rejected ones. A cross-agent revert + // is atomic: every shared composer key must belong to the restored identity. + // Same-agent repair keeps the per-key guards so newer edits still win. + if (restore) { + if ( + isRestoringAnotherAgent || + readPersistedState(getModelKey(args.workspaceId), null) === args.applied.model + ) { + setWorkspaceModelWithOrigin(args.workspaceId, restore.model, "sync"); + } + if ( + isRestoringAnotherAgent || + readPersistedState(getThinkingLevelKey(args.workspaceId), null) === + args.applied.thinkingLevel + ) { + updatePersistedState(getThinkingLevelKey(args.workspaceId), restore.thinkingLevel); + } + if ( + isRestoringAnotherAgent || + readPersistedState( + getReasoningModeKey(args.workspaceId), + null + ) === args.applied.reasoningMode + ) { + updatePersistedState(getReasoningModeKey(args.workspaceId), restore.reasoningMode); + } + } + + if (isRestoringAnotherAgent) { + updatePersistedState(agentKey, restoreAgentId); + } +} diff --git a/src/browser/utils/workspaceModeAi.test.ts b/src/browser/utils/workspaceModeAi.test.ts index 30dcae0fe3f..ad99a38486d 100644 --- a/src/browser/utils/workspaceModeAi.test.ts +++ b/src/browser/utils/workspaceModeAi.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; -import { resolveWorkspaceAiSettingsForAgent } from "./workspaceModeAi"; +import type { AgentAncestorDescriptor } from "@/common/utils/ai/agentAncestorLayers"; +import { + getCreationWorkspaceAiSyncState, + resolveWorkspaceAiSettingsForAgent, +} from "./workspaceModeAi"; describe("resolveWorkspaceAiSettingsForAgent", () => { test("uses global agent defaults when configured", () => { @@ -57,14 +61,155 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { }); }); - test("ignores workspace-by-agent fallback when disabled", () => { + test("a saved workspace bucket beats configured defaults on explicit switches", () => { + const result = resolveWorkspaceAiSettingsForAgent({ + agentId: "exec", + agentAiDefaults: { + exec: { modelString: "openai:configured-default", thinkingLevel: "medium" }, + }, + workspaceByAgent: { + exec: { model: "anthropic:workspace-bucket", thinkingLevel: "high" }, + }, + useWorkspaceByAgentFallback: true, + fallbackModel: "openai:gpt-5.2-mini", + existingModel: "anthropic:claude-opus-4-6", + existingThinking: "off", + }); + + // Matches backend dispatch/ACP layering: the workspace's own bucket + // precedes configured defaults, so switching away and back cannot + // overwrite the workspace's last-used settings with a global default. + expect(result.resolvedModel).toBe("anthropic:workspace-bucket"); + expect(result.resolvedThinking).toBe("high"); + }); + + test("uses target definition defaults before carried-over settings", () => { + const result = resolveWorkspaceAiSettingsForAgent({ + agentId: "researcher", + agentAiDefaults: {}, + fallbackModel: "openai:gpt-5.2-mini", + existingModel: "anthropic:claude-opus-4-6", + existingThinking: "off", + agentDescriptorById: new Map([ + [ + "researcher", + { + base: "exec", + definitionAiDefaults: { model: "openai:gpt-5.6-sol", thinkingLevel: "high" }, + }, + ], + ]), + }); + + expect(result.resolvedModel).toBe("openai:gpt-5.6-sol"); + expect(result.resolvedThinking).toBe("high"); + }); + + test("a saved workspace bucket beats target definition defaults", () => { + const result = resolveWorkspaceAiSettingsForAgent({ + agentId: "researcher", + agentAiDefaults: {}, + workspaceByAgent: { + researcher: { model: "anthropic:claude-opus-4-6", thinkingLevel: "medium" }, + }, + useWorkspaceByAgentFallback: true, + fallbackModel: "openai:gpt-5.2-mini", + existingModel: "openai:gpt-5.3-codex", + existingThinking: "off", + agentDescriptorById: new Map([ + [ + "researcher", + { + base: "exec", + definitionAiDefaults: { model: "openai:gpt-5.6-sol", thinkingLevel: "high" }, + }, + ], + ]), + }); + + expect(result.resolvedModel).toBe("anthropic:claude-opus-4-6"); + expect(result.resolvedThinking).toBe("medium"); + }); + + test("configured overrides beat target definition defaults", () => { + const result = resolveWorkspaceAiSettingsForAgent({ + agentId: "researcher", + agentAiDefaults: { + researcher: { modelString: "anthropic:claude-opus-4-6", thinkingLevel: "medium" }, + }, + fallbackModel: "openai:gpt-5.2-mini", + existingModel: "openai:gpt-5.3-codex", + existingThinking: "off", + agentDescriptorById: new Map([ + [ + "researcher", + { + base: "exec", + definitionAiDefaults: { model: "openai:gpt-5.6-sol", thinkingLevel: "high" }, + }, + ], + ]), + }); + + expect(result.resolvedModel).toBe("anthropic:claude-opus-4-6"); + expect(result.resolvedThinking).toBe("medium"); + }); + + test("inherits missing definition fields from the declared ancestor chain", () => { + const result = resolveWorkspaceAiSettingsForAgent({ + agentId: "researcher", + agentAiDefaults: {}, + fallbackModel: "openai:gpt-5.2-mini", + existingModel: "anthropic:claude-opus-4-6", + existingThinking: "off", + agentDescriptorById: new Map([ + ["researcher", { base: "analysis", definitionAiDefaults: { model: "openai:gpt-5.6-sol" } }], + ["analysis", { base: "exec", definitionAiDefaults: { thinkingLevel: "high" } }], + ]), + }); + + expect(result.resolvedModel).toBe("openai:gpt-5.6-sol"); + expect(result.resolvedThinking).toBe("high"); + }); + + test("uses embedded defaults from a non-selectable declared ancestor", () => { + const result = resolveWorkspaceAiSettingsForAgent({ + agentId: "researcher", + agentAiDefaults: {}, + fallbackModel: "openai:gpt-5.2-mini", + existingModel: "anthropic:claude-opus-4-6", + existingThinking: "off", + agents: [ + { + id: "researcher", + base: "analysis", + aiAncestors: [ + { + agentId: "analysis", + definitionAiDefaults: { + model: "openai:gpt-5.6-sol", + thinkingLevel: "high", + }, + }, + { agentId: "exec" }, + ], + }, + ], + mode: "explicit-switch", + }); + + expect(result?.resolvedModel).toBe("openai:gpt-5.6-sol"); + expect(result?.resolvedThinking).toBe("high"); + }); + + test("ignores workspace buckets during creation sync", () => { const result = resolveWorkspaceAiSettingsForAgent({ agentId: "exec", agentAiDefaults: {}, workspaceByAgent: { exec: { model: "openai:gpt-5.2", thinkingLevel: "medium" }, }, - useWorkspaceByAgentFallback: false, + mode: "creation-sync", fallbackModel: "openai:gpt-5.2-mini", existingModel: "anthropic:claude-opus-4-6", existingThinking: "off", @@ -279,21 +424,31 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { expect(result.resolvedReasoningMode).toBe("pro"); }); - test("inherits the workspace's current pro mode during background sync", () => { + test("a hydrated bucket owns background sync over configured defaults", () => { const result = resolveWorkspaceAiSettingsForAgent({ agentId: "exec", - agentAiDefaults: {}, + agentAiDefaults: { + exec: { + modelString: "anthropic:claude-haiku-4-5", + thinkingLevel: "off", + reasoningMode: "pro", + }, + }, workspaceByAgent: { - exec: { model: "openai:gpt-5.6-sol", thinkingLevel: "medium", reasoningMode: "standard" }, + exec: { model: "openai:gpt-5.6-sol", thinkingLevel: "medium" }, }, useWorkspaceByAgentFallback: false, fallbackModel: "openai:gpt-5.2-mini", - existingModel: "openai:gpt-5.6-sol", + existingModel: "anthropic:claude-haiku-4-5", existingThinking: "off", existingReasoningMode: "pro", }); - expect(result.resolvedReasoningMode).toBe("pro"); + expect(result).toEqual({ + resolvedModel: "openai:gpt-5.6-sol", + resolvedThinking: "medium", + resolvedReasoningMode: "standard", + }); }); test("defaults legacy per-agent entries without reasoningMode to standard on explicit switches", () => { @@ -370,6 +525,45 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { }); }); + test("preserves a creation model chosen before descriptors arrive", () => { + const initial = getCreationWorkspaceAiSyncState({ + previousAgentId: null, + previousScopeId: null, + agentId: "exec", + scopeId: "project:/repo", + }); + const descriptorArrival = getCreationWorkspaceAiSyncState({ + previousAgentId: "exec", + previousScopeId: "project:/repo", + agentId: "exec", + scopeId: "project:/repo", + }); + + expect(initial.mode).toBe("creation-sync"); + expect(descriptorArrival.mode).toBe("background-sync"); + + const result = resolveWorkspaceAiSettingsForAgent({ + agentId: "exec", + agentAiDefaults: {}, + fallbackModel: "openai:gpt-5.2", + existingModel: "anthropic:claude-opus-4-6", + existingThinking: "high", + agents: [ + { + id: "exec", + ownAiDefaults: { model: "openai:gpt-5.3-codex", thinkingLevel: "off" }, + }, + ], + mode: descriptorArrival.mode, + }); + + expect(result).toEqual({ + resolvedModel: "anthropic:claude-opus-4-6", + resolvedThinking: "high", + resolvedReasoningMode: "standard", + }); + }); + test("guards non-string persisted model values", () => { const result = resolveWorkspaceAiSettingsForAgent({ agentId: "exec", diff --git a/src/browser/utils/workspaceModeAi.ts b/src/browser/utils/workspaceModeAi.ts index 24f52a92734..f699a2687b8 100644 --- a/src/browser/utils/workspaceModeAi.ts +++ b/src/browser/utils/workspaceModeAi.ts @@ -1,5 +1,7 @@ import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; -import type { AiSettingSource } from "@/common/types/agentAiSettings"; +import { isBuiltInSelectableAgentId } from "@/browser/utils/agents"; +import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; +import { targetWorkspaceBucketToLayer, type AiSettingSource } from "@/common/types/agentAiSettings"; import { coerceOpenAIReasoningMode, coerceThinkingLevel, @@ -7,7 +9,10 @@ import { type ThinkingLevel, } from "@/common/types/thinking"; import { normalizeAgentId as normalizeWorkspaceAgentId } from "@/common/utils/agentIds"; -import { collectDeclaredAncestorLayers } from "@/common/utils/ai/agentAncestorLayers"; +import { + collectDeclaredAncestorLayers, + type AgentAncestorDescriptor, +} from "@/common/utils/ai/agentAncestorLayers"; import { resolveAgentAiSettings } from "@/common/utils/ai/resolveAgentAiSettings"; export type WorkspaceAISettingsCache = Partial< @@ -58,81 +63,156 @@ export function resolveConfiguredAiDefaults( }; } -// Keep agent -> model/thinking precedence in one place so mode switches that send immediately -// (like propose_plan Implement / Continue in Auto) resolve the same settings as sync effects. -export function resolveWorkspaceAiSettingsForAgent(args: { +type WorkspaceAiResolutionMode = "explicit-switch" | "background-sync" | "creation-sync"; + +type WorkspaceAgentDescriptor = Pick< + AgentDefinitionDescriptor, + "id" | "base" | "ownAiDefaults" | "aiAncestors" +>; + +interface WorkspaceAiResolutionArgs { agentId: string; agentAiDefaults: AgentAiDefaults; workspaceByAgent?: WorkspaceAISettingsCache; - useWorkspaceByAgentFallback?: boolean; fallbackModel: string; existingModel: string; existingThinking: ThinkingLevel; existingReasoningMode?: OpenAIReasoningMode; - /** Agent id -> base id, for base-chain reasoning-mode inheritance (custom agents). */ + agents?: readonly WorkspaceAgentDescriptor[]; + /** Compatibility inputs for pure resolver tests and non-UI adapters. */ + useWorkspaceByAgentFallback?: boolean; agentBaseById?: ReadonlyMap; -}): { + agentDescriptorById?: ReadonlyMap; + mode?: WorkspaceAiResolutionMode; +} + +interface ResolvedWorkspaceAiSettings { resolvedModel: string; resolvedThinking: ThinkingLevel; resolvedReasoningMode: OpenAIReasoningMode; -} { +} + +function buildAgentDescriptorLookup( + args: WorkspaceAiResolutionArgs, + includeDefinitionDefaults: boolean +): Map { + const descriptors = new Map(); + for (const agent of args.agents ?? []) { + descriptors.set(agent.id, { + base: agent.base, + ...(includeDefinitionDefaults && agent.ownAiDefaults + ? { definitionAiDefaults: agent.ownAiDefaults } + : {}), + }); + } + for (const [id, descriptor] of args.agentDescriptorById ?? []) { + descriptors.set(id, { + base: descriptor.base, + ...(includeDefinitionDefaults && descriptor.definitionAiDefaults + ? { definitionAiDefaults: descriptor.definitionAiDefaults } + : {}), + }); + } + for (const [id, base] of args.agentBaseById ?? []) { + descriptors.set(id, { ...descriptors.get(id), base }); + } + return descriptors; +} + +export function hasWorkspaceAiTargetDescriptor( + agentId: string, + agents: readonly WorkspaceAgentDescriptor[] +): boolean { + const normalizedAgentId = normalizeAgentId(agentId); + return agents.some((agent) => normalizeAgentId(agent.id) === normalizedAgentId); +} + +interface CreationWorkspaceAiSyncState { + isExplicitAgentSwitch: boolean; + mode: "creation-sync" | "background-sync"; +} + +export function getCreationWorkspaceAiSyncState(args: { + previousAgentId: string | null; + previousScopeId: string | null; + agentId: string; + scopeId: string; +}): CreationWorkspaceAiSyncState { + const hasPriorSelection = args.previousAgentId !== null && args.previousScopeId === args.scopeId; + const isExplicitAgentSwitch = hasPriorSelection && args.previousAgentId !== args.agentId; + + return { + isExplicitAgentSwitch, + // Definition defaults seed the initial selection and explicit switches only. + // Later descriptor arrival must preserve any model the user already selected. + mode: !hasPriorSelection || isExplicitAgentSwitch ? "creation-sync" : "background-sync", + }; +} + +// Keep agent -> model/thinking precedence in one place so explicit switches, +// background sync, and workspace creation agree on descriptor availability. +export function resolveWorkspaceAiSettingsForAgent( + args: WorkspaceAiResolutionArgs & { mode: "explicit-switch" } +): ResolvedWorkspaceAiSettings | null; +export function resolveWorkspaceAiSettingsForAgent( + args: WorkspaceAiResolutionArgs & { mode?: "background-sync" | "creation-sync" } +): ResolvedWorkspaceAiSettings; +export function resolveWorkspaceAiSettingsForAgent( + args: WorkspaceAiResolutionArgs +): ResolvedWorkspaceAiSettings; +export function resolveWorkspaceAiSettingsForAgent( + args: WorkspaceAiResolutionArgs +): ResolvedWorkspaceAiSettings | null { const normalizedAgentId = normalizeAgentId(args.agentId); - const workspaceOverride = args.workspaceByAgent?.[normalizedAgentId]; + const mode = + args.mode ?? + (args.useWorkspaceByAgentFallback === true + ? "explicit-switch" + : args.useWorkspaceByAgentFallback === false + ? "background-sync" + : "creation-sync"); + if ( + mode === "explicit-switch" && + args.agents != null && + !hasWorkspaceAiTargetDescriptor(normalizedAgentId, args.agents) && + !isBuiltInSelectableAgentId(normalizedAgentId) + ) { + return null; + } - // Field-wise across the agent's own entry then its base chain: an agent - // inheriting GPT-5.6 + pro from its base must resolve both together even - // when the active workspace runs a different provider's model. - const configuredDefaults = resolveConfiguredAiDefaults( - normalizedAgentId, - args.agentAiDefaults, - args.agentBaseById + const workspaceOverride = args.workspaceByAgent?.[normalizedAgentId]; + const includeDefinitionDefaults = mode !== "background-sync"; + const descriptorsById = buildAgentDescriptorLookup(args, includeDefinitionDefaults); + const targetDescriptor = args.agents?.find( + (agent) => normalizeAgentId(agent.id) === normalizedAgentId ); - const configuredModel = configuredDefaults.modelString; - const workspaceOverrideModel = - args.useWorkspaceByAgentFallback && typeof workspaceOverride?.model === "string" - ? workspaceOverride.model - : undefined; - const inheritedModelCandidate = - workspaceOverrideModel ?? - (typeof args.existingModel === "string" ? args.existingModel : undefined) ?? - ""; - const inheritedModel = inheritedModelCandidate.trim(); - const resolvedModel = - configuredModel && configuredModel.length > 0 - ? configuredModel - : inheritedModel.length > 0 - ? inheritedModel - : args.fallbackModel; - - // Persisted workspace settings can be stale/corrupt; re-validate inherited values - // so mode sync keeps self-healing behavior instead of propagating invalid options. - const workspaceOverrideThinking = args.useWorkspaceByAgentFallback - ? coerceThinkingLevel(workspaceOverride?.thinkingLevel) - : undefined; - const inheritedThinking = workspaceOverrideThinking ?? coerceThinkingLevel(args.existingThinking); - const resolvedThinking = configuredDefaults.thinkingLevel ?? inheritedThinking ?? "off"; - - // An existing per-agent bucket owns the reasoning choice outright (matching - // targetWorkspaceBucketToLayer): a configured Pro default must not re-inject - // itself over a workspace deliberately toggled to Standard (every composer - // change rewrites the bucket, so its presence marks a workspace-level pick). - // Explicit switches restore the bucket's saved mode; background sync trusts - // the live workspace mode, which hydration seeds from the backend bucket. - // Absent reasoningMode on an existing entry (legacy entry saved before pro - // mode shipped) means "standard", matching the WorkspaceContext seeding - // semantics, instead of inheriting a possibly-pro workspace mode from the - // previously active agent. - // Without a bucket entry, configured defaults (and the base chain) apply, - // matching ACP resolution and the Settings card display, else the - // workspace's current mode carries over. - const resolvedReasoningMode = - workspaceOverride != null - ? args.useWorkspaceByAgentFallback - ? (coerceOpenAIReasoningMode(workspaceOverride.reasoningMode) ?? "standard") - : (coerceOpenAIReasoningMode(args.existingReasoningMode) ?? "standard") - : (configuredDefaults.reasoningMode ?? - coerceOpenAIReasoningMode(args.existingReasoningMode) ?? - "standard"); - - return { resolvedModel, resolvedThinking, resolvedReasoningMode }; + const ancestors = + includeDefinitionDefaults && targetDescriptor?.aiAncestors + ? targetDescriptor.aiAncestors + : collectDeclaredAncestorLayers(normalizedAgentId, descriptorsById); + const resolved = resolveAgentAiSettings({ + targetAgentId: normalizedAgentId, + profile: "interactive", + targetWorkspaceSettings: + mode !== "creation-sync" && workspaceOverride != null + ? targetWorkspaceBucketToLayer(workspaceOverride) + : undefined, + agentAiDefaults: args.agentAiDefaults, + targetDefinitionAiDefaults: descriptorsById.get(normalizedAgentId)?.definitionAiDefaults, + ancestors, + parentRuntime: { + model: typeof args.existingModel === "string" ? args.existingModel : undefined, + thinkingLevel: coerceThinkingLevel(args.existingThinking), + reasoningMode: coerceOpenAIReasoningMode(args.existingReasoningMode), + }, + defaultModel: args.fallbackModel, + }); + + const resolvedReasoningMode = resolved.selected.reasoningMode ?? "standard"; + + return { + resolvedModel: resolved.selected.model, + resolvedThinking: resolved.selected.thinkingLevel, + resolvedReasoningMode, + }; } diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index 290c0e67525..6b6bfd14c0e 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -129,6 +129,13 @@ export const CUSTOM_EVENTS = { */ GOAL_CHILD_BUDGET_TOAST: "mux:goalChildBudgetToast", + /** + * Event to show a toast when a workspace agent switch is rejected by the + * backend (e.g. budgeted-goal pricing gate or an unwritable config). + * Detail: { workspaceId: string, message: string } + */ + AGENT_SWITCH_ERROR_TOAST: "mux:agentSwitchErrorToast", + REVEAL_TIMELINE_ANCHOR: "mux:revealTimelineAnchor", /** @@ -205,6 +212,10 @@ export interface CustomEventPayloads { workspaceId: string; message: string; }; + [CUSTOM_EVENTS.AGENT_SWITCH_ERROR_TOAST]: { + workspaceId: string; + message: string; + }; [CUSTOM_EVENTS.REVEAL_TIMELINE_ANCHOR]: { workspaceId: string; messageId?: string; diff --git a/src/common/orpc/schemas/agentDefinition.ts b/src/common/orpc/schemas/agentDefinition.ts index 7c0037a42ac..e56e01c8c7c 100644 --- a/src/common/orpc/schemas/agentDefinition.ts +++ b/src/common/orpc/schemas/agentDefinition.ts @@ -101,6 +101,20 @@ export const AgentDefinitionDescriptorSchema = z // Base agent ID for inheritance (e.g., "exec", "plan", or custom agent) base: AgentIdSchema.optional(), aiDefaults: AgentDefinitionAiDefaultsSchema.optional(), + // This agent ID's defaults merged field-wise across same-ID scope refinements. + // Named base-agent defaults remain separate hops; aiDefaults is effective UI display data. + ownAiDefaults: AgentDefinitionAiDefaultsSchema.optional(), + // Complete declared base chain, including non-selectable ancestors omitted from discovery. + aiAncestors: z + .array( + z + .object({ + agentId: AgentIdSchema, + definitionAiDefaults: AgentDefinitionAiDefaultsSchema.optional(), + }) + .strict() + ) + .optional(), // Tool configuration (for UI display / inheritance computation) tools: AgentDefinitionToolsSchema.optional(), // Agent Plugins: contributing plugin name (absent for non-plugin agents) diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 8a87a56e0f5..b810d1b2d2a 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1447,7 +1447,9 @@ export const workspace = { input: z.object({ workspaceId: z.string(), agentId: AgentIdSchema, - aiSettings: WorkspaceAISettingsSchema, + // Null persists only the selected agent (with persistSelectedAgentId), + // leaving the agent's stored model/thinking settings untouched. + aiSettings: WorkspaceAISettingsSchema.nullish(), persistSelectedAgentId: z.boolean().nullish(), }), output: ResultSchema(z.void(), z.string()), diff --git a/src/common/utils/ai/workspaceAiSettingsWrite.ts b/src/common/utils/ai/workspaceAiSettingsWrite.ts new file mode 100644 index 00000000000..7dadc2e968e --- /dev/null +++ b/src/common/utils/ai/workspaceAiSettingsWrite.ts @@ -0,0 +1,17 @@ +const workspaceAiSettingsWriteChains = new Map>(); + +/** Keep client writes that can persist workspace AI state in initiation order. */ +export function serializeWorkspaceAiSettingsWrite( + workspaceId: string, + write: () => Promise +): Promise { + const previous = workspaceAiSettingsWriteChains.get(workspaceId) ?? Promise.resolve(); + const result = previous.then(write, write); + workspaceAiSettingsWriteChains.set(workspaceId, result); + + return result.finally(() => { + if (workspaceAiSettingsWriteChains.get(workspaceId) === result) { + workspaceAiSettingsWriteChains.delete(workspaceId); + } + }); +} diff --git a/src/node/acp/agent.ts b/src/node/acp/agent.ts index ec10041fbbc..6f0c726f231 100644 --- a/src/node/acp/agent.ts +++ b/src/node/acp/agent.ts @@ -65,6 +65,11 @@ import { targetWorkspaceBucketToLayer } from "@/common/types/agentAiSettings"; import { InvalidExplicitAiSettingError } from "@/common/utils/ai/resolveAgentAiSettings"; import type { ServerConnection } from "./serverConnection"; import { SessionManager } from "./sessionManager"; +import { + sendAcpWorkspaceMessage, + updateAcpWorkspaceAgentAISettings, + updateAcpWorkspaceModeAISettings, +} from "./workspaceAiSettingsSync"; import { buildAcpAvailableCommands, mapSkillsByName, @@ -728,7 +733,7 @@ export class MuxAgent implements Agent { delegatedToolNames ); - const sendResult = await this.server.client.workspace.sendMessage({ + const sendResult = await sendAcpWorkspaceMessage(this.server.client, { workspaceId: args.workspaceId, message: args.message, options: { @@ -936,7 +941,7 @@ export class MuxAgent implements Agent { let response = `Created forked workspace \`${newWorkspaceId}\`.`; if (parsedCommand.startMessage != null && parsedCommand.startMessage.trim().length > 0) { - const startMessageResult = await this.server.client.workspace.sendMessage({ + const startMessageResult = await sendAcpWorkspaceMessage(this.server.client, { workspaceId: newWorkspaceId, message: parsedCommand.startMessage, options: { @@ -1000,7 +1005,7 @@ export class MuxAgent implements Agent { let response = `Created workspace \`${displayName}\` (id: \`${newWorkspaceId}\`).`; if (hasStartMessage && parsedCommand.startMessage != null) { - const startMessageResult = await this.server.client.workspace.sendMessage({ + const startMessageResult = await sendAcpWorkspaceMessage(this.server.client, { workspaceId: newWorkspaceId, message: parsedCommand.startMessage, options: { @@ -2165,7 +2170,7 @@ export class MuxAgent implements Agent { aiSettings: ResolvedAiSettings ): Promise { if (agentId === "plan" || agentId === "exec") { - const updateModeResult = await this.server.client.workspace.updateModeAISettings({ + const updateModeResult = await updateAcpWorkspaceModeAISettings(this.server.client, { workspaceId, mode: agentId, aiSettings, @@ -2178,7 +2183,7 @@ export class MuxAgent implements Agent { return; } - const updateAgentResult = await this.server.client.workspace.updateAgentAISettings({ + const updateAgentResult = await updateAcpWorkspaceAgentAISettings(this.server.client, { workspaceId, agentId, aiSettings, diff --git a/src/node/acp/configOptions.ts b/src/node/acp/configOptions.ts index 698bc6a3ec4..c57db6691a9 100644 --- a/src/node/acp/configOptions.ts +++ b/src/node/acp/configOptions.ts @@ -9,6 +9,10 @@ import { getBuiltInAgentDefinitions } from "@/node/services/agentDefinitions/bui import { resolveAgentVisibility } from "@/node/services/agentDefinitions/agentVisibility"; import type { ORPCClient } from "./serverConnection"; import { resolveAgentAiSettings, type ResolvedAiSettings } from "./resolveAgentAiSettings"; +import { + updateAcpWorkspaceAgentAISettings, + updateAcpWorkspaceModeAISettings, +} from "./workspaceAiSettingsSync"; export const AGENT_MODE_CONFIG_ID = "agentMode"; const MODEL_CONFIG_ID = "model"; @@ -243,10 +247,25 @@ async function persistAgentAiSettings( client: ORPCClient, workspaceId: string, agentId: string, - aiSettings: ResolvedAiSettings + aiSettings: ResolvedAiSettings, + options?: { persistSelectedAgentId?: boolean } ): Promise { + // Selected-agent persistence must go through updateAgentAISettings: the + // mode variant cannot record the workspace's selected agent, which ACP mode + // switches need so reconnects and other clients hydrate the new mode. + if (options?.persistSelectedAgentId === true) { + const updateResult = await updateAcpWorkspaceAgentAISettings(client, { + workspaceId, + agentId, + aiSettings, + persistSelectedAgentId: true, + }); + ensureUpdateSucceeded(updateResult, "workspace.updateAgentAISettings"); + return; + } + if (isModeAgentId(agentId)) { - const updateModeResult = await client.workspace.updateModeAISettings({ + const updateModeResult = await updateAcpWorkspaceModeAISettings(client, { workspaceId, mode: agentId, aiSettings, @@ -255,7 +274,7 @@ async function persistAgentAiSettings( return; } - const updateAgentResult = await client.workspace.updateAgentAISettings({ + const updateAgentResult = await updateAcpWorkspaceAgentAISettings(client, { workspaceId, agentId, aiSettings, @@ -382,7 +401,14 @@ export async function handleSetConfigOption( : {}), }; - await persistAgentAiSettings(client, trimmedWorkspaceId, nextAgentId, normalizedAiSettings); + // Child workspaces keep their creation-time agent as their locked + // identity, and backend continuation/heartbeat dispatch resolves the + // persisted workspaceEntry.agentId directly — persisting a session-local + // ACP mode change there would redirect later scheduled work to the wrong + // agent. Keep mode changes session-local (settings only) for children. + await persistAgentAiSettings(client, trimmedWorkspaceId, nextAgentId, normalizedAiSettings, { + persistSelectedAgentId: workspace.parentWorkspaceId == null, + }); if (args?.onAgentModeChanged != null) { await args.onAgentModeChanged(nextAgentId, normalizedAiSettings); } diff --git a/src/node/acp/resolveAgentAiSettings.ts b/src/node/acp/resolveAgentAiSettings.ts index 2775af2f445..bad714aac92 100644 --- a/src/node/acp/resolveAgentAiSettings.ts +++ b/src/node/acp/resolveAgentAiSettings.ts @@ -65,7 +65,10 @@ export async function resolveAcpAgentAiSettings( const agentDef = agents.find((agent) => agent.id === trimmedAgentId); const agentDefsById = new Map( - agents.map((agent) => [agent.id, { base: agent.base, definitionAiDefaults: agent.aiDefaults }]) + agents.map((agent) => [ + agent.id, + { base: agent.base, definitionAiDefaults: agent.ownAiDefaults }, + ]) ); return resolveAgentAiSettingsShared({ @@ -74,7 +77,7 @@ export async function resolveAcpAgentAiSettings( explicit: extras?.explicit, targetWorkspaceSettings: extras?.targetWorkspaceSettings, agentAiDefaults: config.agentAiDefaults, - targetDefinitionAiDefaults: agentDef?.aiDefaults, + targetDefinitionAiDefaults: agentDef?.ownAiDefaults, ancestors: collectDeclaredAncestorLayers(trimmedAgentId, agentDefsById), parentRuntime: extras?.parentRuntime, }); diff --git a/src/node/acp/workspaceAiSettingsSync.test.ts b/src/node/acp/workspaceAiSettingsSync.test.ts new file mode 100644 index 00000000000..ce9a2590411 --- /dev/null +++ b/src/node/acp/workspaceAiSettingsSync.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import type { ORPCClient } from "./serverConnection"; +import { + sendAcpWorkspaceMessage, + updateAcpWorkspaceAgentAISettings, +} from "./workspaceAiSettingsSync"; + +describe("ACP workspace AI settings writes", () => { + test("preserves initiation order across sends and settings updates", async () => { + let resolveSend!: () => void; + const sendCommit = new Promise((resolve) => { + resolveSend = resolve; + }); + const started: string[] = []; + let persistedAgentId = "plan"; + const client = { + workspace: { + sendMessage: async (input: { options: { agentId?: string } }) => { + started.push(input.options.agentId ?? "missing"); + await sendCommit; + persistedAgentId = input.options.agentId ?? persistedAgentId; + return { success: true as const, data: {} }; + }, + updateAgentAISettings: (input: { agentId: string }) => { + started.push(input.agentId); + persistedAgentId = input.agentId; + return Promise.resolve({ success: true as const, data: undefined }); + }, + }, + } as unknown as ORPCClient; + + const planSend = sendAcpWorkspaceMessage(client, { + workspaceId: "workspace-1", + message: "Plan", + options: { agentId: "plan", model: "openai:plan", thinkingLevel: "high" }, + }); + const execUpdate = updateAcpWorkspaceAgentAISettings(client, { + workspaceId: "workspace-1", + agentId: "exec", + aiSettings: { model: "openai:exec", thinkingLevel: "medium" }, + persistSelectedAgentId: true, + }); + + await Promise.resolve(); + expect(started).toEqual(["plan"]); + + resolveSend(); + await Promise.all([planSend, execUpdate]); + + expect(started).toEqual(["plan", "exec"]); + expect(persistedAgentId).toBe("exec"); + }); +}); diff --git a/src/node/acp/workspaceAiSettingsSync.ts b/src/node/acp/workspaceAiSettingsSync.ts new file mode 100644 index 00000000000..5e2483d28f2 --- /dev/null +++ b/src/node/acp/workspaceAiSettingsSync.ts @@ -0,0 +1,34 @@ +import { serializeWorkspaceAiSettingsWrite } from "@/common/utils/ai/workspaceAiSettingsWrite"; +import type { ORPCClient } from "./serverConnection"; + +type SendMessageInput = Parameters[0]; +type UpdateAgentAISettingsInput = Parameters[0]; +type UpdateModeAISettingsInput = Parameters[0]; + +export function sendAcpWorkspaceMessage( + client: ORPCClient, + input: SendMessageInput +): ReturnType { + const send = () => client.workspace.sendMessage(input); + return input.options.skipAiSettingsPersistence === true + ? send() + : serializeWorkspaceAiSettingsWrite(input.workspaceId, send); +} + +export function updateAcpWorkspaceAgentAISettings( + client: ORPCClient, + input: UpdateAgentAISettingsInput +): ReturnType { + return serializeWorkspaceAiSettingsWrite(input.workspaceId, () => + client.workspace.updateAgentAISettings(input) + ); +} + +export function updateAcpWorkspaceModeAISettings( + client: ORPCClient, + input: UpdateModeAISettingsInput +): ReturnType { + return serializeWorkspaceAiSettingsWrite(input.workspaceId, () => + client.workspace.updateModeAISettings(input) + ); +} diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index 62db8bbac95..b904f6fefc7 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -57,6 +57,118 @@ describe("router workspace goal validation", () => { }); }); +describe("router agent definition routes", () => { + test("exposes same-ID lower-scope AI defaults in the winning descriptor", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "xum-router-agents-test-")); + const previousXumRoot = process.env.XUM_ROOT; + const previousMuxRoot = process.env.MUX_ROOT; + + try { + const xumRoot = path.join(tempDir, "xum-home"); + const projectPath = path.join(tempDir, "project"); + const projectAgentsRoot = path.join(projectPath, ".xum", "agents"); + const globalAgentsRoot = path.join(xumRoot, "agents"); + process.env.XUM_ROOT = xumRoot; + delete process.env.MUX_ROOT; + + fs.mkdirSync(projectAgentsRoot, { recursive: true }); + fs.mkdirSync(globalAgentsRoot, { recursive: true }); + fs.writeFileSync( + path.join(globalAgentsRoot, "exec.md"), + "---\nname: Global Exec\nai:\n model: custom:global-exec\n thinkingLevel: low\n---\nGlobal exec.\n" + ); + fs.writeFileSync( + path.join(projectAgentsRoot, "exec.md"), + "---\nname: Project Exec\nbase: exec\nai:\n thinkingLevel: high\n---\nProject exec.\n" + ); + + const context = { + config: new Config(xumRoot), + experimentsService: { + isExperimentEnabled: mock(() => false), + }, + } as unknown as ORPCContext; + const client = createRouterClient(router(), { context }); + + const agents = await client.agents.list({ projectPath }); + const exec = agents.find((agent) => agent.id === "exec"); + + expect(exec?.scope).toBe("project"); + expect(exec?.ownAiDefaults).toEqual({ + model: "custom:global-exec", + thinkingLevel: "high", + }); + } finally { + if (previousXumRoot === undefined) delete process.env.XUM_ROOT; + else process.env.XUM_ROOT = previousXumRoot; + if (previousMuxRoot === undefined) delete process.env.MUX_ROOT; + else process.env.MUX_ROOT = previousMuxRoot; + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + +describe("router agent definition ancestry", () => { + test("embeds disabled base defaults in selectable child descriptors", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "xum-router-agent-ancestry-test-")); + const previousXumRoot = process.env.XUM_ROOT; + const previousMuxRoot = process.env.MUX_ROOT; + + try { + const xumRoot = path.join(tempDir, "xum-home"); + const projectPath = path.join(tempDir, "project"); + const projectAgentsRoot = path.join(projectPath, ".xum", "agents"); + process.env.XUM_ROOT = xumRoot; + delete process.env.MUX_ROOT; + + fs.mkdirSync(projectAgentsRoot, { recursive: true }); + fs.writeFileSync( + path.join(projectAgentsRoot, "analysis.md"), + "---\nname: Analysis\nbase: exec\nai:\n model: openai:gpt-5.6-sol\n thinkingLevel: high\n---\nAnalyze.\n" + ); + fs.writeFileSync( + path.join(projectAgentsRoot, "researcher.md"), + "---\nname: Researcher\nbase: analysis\n---\nResearch.\n" + ); + + const config = new Config(xumRoot); + await config.editConfig((current) => ({ + ...current, + agentAiDefaults: { + ...current.agentAiDefaults, + analysis: { enabled: false }, + }, + })); + const context = { + config, + experimentsService: { + isExperimentEnabled: mock(() => false), + }, + } as unknown as ORPCContext; + const client = createRouterClient(router(), { context }); + + const agents = await client.agents.list({ projectPath }); + const researcher = agents.find((agent) => agent.id === "researcher"); + + expect(agents.some((agent) => agent.id === "analysis")).toBe(false); + expect(researcher?.aiAncestors?.map((ancestor) => ancestor.agentId)).toEqual([ + "analysis", + "exec", + ]); + expect(researcher?.aiAncestors?.[0]?.definitionAiDefaults).toEqual({ + model: "openai:gpt-5.6-sol", + thinkingLevel: "high", + }); + } finally { + if (previousXumRoot === undefined) delete process.env.XUM_ROOT; + else process.env.XUM_ROOT = previousXumRoot; + if (previousMuxRoot === undefined) delete process.env.MUX_ROOT; + else process.env.MUX_ROOT = previousMuxRoot; + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + describe("router agent skill routes", () => { test("subproject workspaces inherit parent skills with nearest precedence", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-router-skills-test-")); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index c425ea2be02..64ab7dbcd33 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -108,6 +108,8 @@ import { resolveAgentFrontmatter, } from "@/node/services/agentDefinitions/agentDefinitionsService"; import { isAgentEffectivelyDisabled } from "@/node/services/agentDefinitions/agentEnablement"; +import { resolveAgentInheritanceChain } from "@/node/services/agentDefinitions/resolveAgentInheritanceChain"; +import { collectDefinitionLayers } from "@/node/services/agentDefinitions/resolveNodeAgentAiSettings"; import { resolveAgentVisibility } from "@/node/services/agentDefinitions/agentVisibility"; import { isWorkspaceArchived } from "@/common/utils/archive"; import assert from "node:assert/strict"; @@ -1826,14 +1828,28 @@ export const router = (authToken?: string) => { const resolved = await Promise.all( descriptors.map(async (descriptor) => { try { - const resolvedFrontmatter = await resolveAgentFrontmatter( + const skipScopesAbove = getSkipScopesAboveForKnownScope(descriptor.scope); + const [resolvedFrontmatter, agentDefinition] = await Promise.all([ + resolveAgentFrontmatter(runtime, discoveryPath, descriptor.id, { + includeAgentPlugins, + skipScopesAbove, + }), + readAgentDefinition(runtime, discoveryPath, descriptor.id, { + includeAgentPlugins, + skipScopesAbove, + }), + ]); + const inheritanceChain = await resolveAgentInheritanceChain({ runtime, - discoveryPath, + workspacePath: discoveryPath, + agentId: descriptor.id, + agentDefinition, + workspaceId: input.workspaceId ?? discoveryPath, + includeAgentPlugins, + }); + const { targetDefinitionAiDefaults, ancestors } = collectDefinitionLayers( descriptor.id, - { - includeAgentPlugins, - skipScopesAbove: getSkipScopesAboveForKnownScope(descriptor.scope), - } + inheritanceChain ); const effectivelyDisabled = isAgentEffectivelyDisabled({ @@ -1858,6 +1874,8 @@ export const router = (authToken?: string) => { kind: "resolved" as const, descriptor, resolvedFrontmatter, + targetDefinitionAiDefaults, + ancestors, uiSelectableBase, }; } catch { @@ -1871,7 +1889,7 @@ export const router = (authToken?: string) => { return []; } if (entry.kind === "fallback") { - return [entry.descriptor]; + return [{ ...entry.descriptor, ownAiDefaults: entry.descriptor.aiDefaults }]; } return [ @@ -1884,6 +1902,8 @@ export const router = (authToken?: string) => { subagentRunnable: entry.resolvedFrontmatter.subagent?.runnable ?? false, base: entry.resolvedFrontmatter.base, aiDefaults: entry.resolvedFrontmatter.ai, + ownAiDefaults: entry.targetDefinitionAiDefaults, + aiAncestors: entry.ancestors, tools: entry.resolvedFrontmatter.tools, }, ]; @@ -4689,7 +4709,7 @@ export const router = (authToken?: string) => { return context.workspaceService.updateAgentAISettings( input.workspaceId, input.agentId, - input.aiSettings, + input.aiSettings ?? null, { persistSelectedAgentId: input.persistSelectedAgentId === true } ); }), diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 77081b1d03c..3b3f0cc1aed 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -141,6 +141,7 @@ const mockInitStateManager: Partial = { clearInMemoryState: mock(() => undefined), }; const mockExtensionMetadataService: Partial = { + getSnapshot: mock(() => Promise.resolve(null)), setStreaming: mock(() => Promise.resolve({ recency: Date.now(), @@ -11582,6 +11583,245 @@ describe("WorkspaceService maybePersistAISettingsFromOptions", () => { expect(persistSpy).toHaveBeenCalledTimes(1); }); + test("refuses agent-only switch to an unpriced stored agent for budgeted goals", async () => { + workspaceService.setWorkspaceGoalService({ + getGoal: mock(() => Promise.resolve({ status: "active", budgetCents: 500 })), + } as unknown as WorkspaceGoalService); + ( + workspaceService as unknown as { config: { loadConfigOrDefault: () => unknown } } + ).config.loadConfigOrDefault = mock(() => ({ + projects: new Map([ + [ + "/tmp/proj", + { + workspaces: [ + { + id: "ws", + path: "/tmp/proj/ws", + name: "ws", + aiSettingsByAgent: { + reviewer: { model: "openai:not-priced-model", thinkingLevel: "off" }, + }, + }, + ], + }, + ], + ]), + })); + + const result = await workspaceService.updateAgentAISettings("ws", "reviewer", null, { + persistSelectedAgentId: true, + }); + + expect(result).toEqual({ + success: false, + error: "Target model has no pricing data. Pick a priced model before switching.", + }); + }); + + test("refuses agent-only switch when the configured agent default is unpriced", async () => { + workspaceService.setWorkspaceGoalService({ + getGoal: mock(() => Promise.resolve({ status: "active", budgetCents: 500 })), + } as unknown as WorkspaceGoalService); + ( + workspaceService as unknown as { config: { loadConfigOrDefault: () => unknown } } + ).config.loadConfigOrDefault = mock(() => ({ + projects: new Map([ + ["/tmp/proj", { workspaces: [{ id: "ws", path: "/tmp/proj/ws", name: "ws" }] }], + ]), + // No workspace bucket: continuation dispatch would resolve this + // configured default, so the switch must gate on it too. + agentAiDefaults: { reviewer: { modelString: "openai:not-priced-model" } }, + })); + + const result = await workspaceService.updateAgentAISettings("ws", "reviewer", null, { + persistSelectedAgentId: true, + }); + + expect(result).toEqual({ + success: false, + error: "Target model has no pricing data. Pick a priced model before switching.", + }); + }); + + test("refuses switch to plan when plan's stored model is unpriced even though exec is priced", async () => { + workspaceService.setWorkspaceGoalService({ + getGoal: mock(() => Promise.resolve({ status: "active", budgetCents: 500 })), + } as unknown as WorkspaceGoalService); + ( + workspaceService as unknown as { config: { loadConfigOrDefault: () => unknown } } + ).config.loadConfigOrDefault = mock(() => ({ + projects: new Map([ + [ + "/tmp/proj", + { + workspaces: [ + { + id: "ws", + path: "/tmp/proj/ws", + name: "ws", + aiSettingsByAgent: { + // Goal continuations remap plan -> exec (priced), but + // heartbeats dispatch the persisted plan agent as-is. + exec: { model: "openai:gpt-4o-mini", thinkingLevel: "off" }, + plan: { model: "openai:not-priced-model", thinkingLevel: "off" }, + }, + }, + ], + }, + ], + ]), + })); + + const result = await workspaceService.updateAgentAISettings("ws", "plan", null, { + persistSelectedAgentId: true, + }); + + expect(result).toEqual({ + success: false, + error: "Target model has no pricing data. Pick a priced model before switching.", + }); + }); + + test("refuses agent-only switch when only the activity snapshot model is unpriced", async () => { + workspaceService.setWorkspaceGoalService({ + getGoal: mock(() => Promise.resolve({ status: "active", budgetCents: 500 })), + } as unknown as WorkspaceGoalService); + // No bucket, configured default, or legacy settings for the target agent: + // heartbeats then fall back to the activity snapshot's last-used model, + // so the gate must reject when that fallback is unpriced. + ( + workspaceService as unknown as { + extensionMetadata: Pick; + } + ).extensionMetadata = { + getSnapshot: mock(() => + Promise.resolve({ + recency: Date.now(), + streaming: false, + lastModel: "openai:not-priced-model", + lastThinkingLevel: null, + agentStatus: null, + }) + ), + } as unknown as ExtensionMetadataService; + + const result = await workspaceService.updateAgentAISettings("ws", "reviewer", null, { + persistSelectedAgentId: true, + }); + + expect(result).toEqual({ + success: false, + error: "Target model has no pricing data. Pick a priced model before switching.", + }); + }); + + test("refuses mode switch with settings when the remapped continuation bucket is unpriced", async () => { + workspaceService.setWorkspaceGoalService({ + getGoal: mock(() => Promise.resolve({ status: "active", budgetCents: 500 })), + } as unknown as WorkspaceGoalService); + ( + workspaceService as unknown as { config: { loadConfigOrDefault: () => unknown } } + ).config.loadConfigOrDefault = mock(() => ({ + projects: new Map([ + [ + "/tmp/proj", + { + workspaces: [ + { + id: "ws", + path: "/tmp/proj/ws", + name: "ws", + aiSettingsByAgent: { + // Continuations remap the persisted plan agent to exec — a + // bucket the submitted plan settings do not cover. + exec: { model: "openai:not-priced-model", thinkingLevel: "off" }, + }, + }, + ], + }, + ], + ]), + })); + + const result = await workspaceService.updateAgentAISettings( + "ws", + "plan", + { model: "openai:gpt-4o-mini", thinkingLevel: "off" }, + { persistSelectedAgentId: true } + ); + + expect(result).toEqual({ + success: false, + error: "Target model has no pricing data. Pick a priced model before switching.", + }); + }); + + test("allows mode switch whose submitted settings replace the unpriced stored bucket", async () => { + const persistSpy = mock(() => Promise.resolve({ success: true as const, data: true })); + workspaceService.setWorkspaceGoalService({ + getGoal: mock(() => Promise.resolve({ status: "active", budgetCents: 500 })), + } as unknown as WorkspaceGoalService); + ( + workspaceService as unknown as { config: { loadConfigOrDefault: () => unknown } } + ).config.loadConfigOrDefault = mock(() => ({ + projects: new Map([ + [ + "/tmp/proj", + { + workspaces: [ + { + id: "ws", + path: "/tmp/proj/ws", + name: "ws", + aiSettingsByAgent: { + // Stale stored bucket: the submitted priced settings are + // about to overwrite it, so the gate must resolve post-write + // state instead of rejecting against this value. + exec: { model: "openai:not-priced-model", thinkingLevel: "off" }, + }, + }, + ], + }, + ], + ]), + })); + ( + workspaceService as unknown as { + persistWorkspaceAISettingsForAgent: (...args: unknown[]) => unknown; + } + ).persistWorkspaceAISettingsForAgent = persistSpy; + + const result = await workspaceService.updateAgentAISettings( + "ws", + "exec", + { model: "openai:gpt-4o-mini", thinkingLevel: "off" }, + { persistSelectedAgentId: true } + ); + + expect(result.success).toBe(true); + expect(persistSpy).toHaveBeenCalledTimes(1); + }); + + test("allows agent-only switch when the target agent has no stored model", async () => { + const persistSpy = mock(() => Promise.resolve({ success: true as const, data: true })); + workspaceService.setWorkspaceGoalService({ + getGoal: mock(() => Promise.resolve({ status: "active", budgetCents: 500 })), + } as unknown as WorkspaceGoalService); + ( + workspaceService as unknown as { + persistWorkspaceAISettingsForAgent: (...args: unknown[]) => unknown; + } + ).persistWorkspaceAISettingsForAgent = persistSpy; + + const result = await workspaceService.updateAgentAISettings("ws", "reviewer", null, { + persistSelectedAgentId: true, + }); + + expect(result.success).toBe(true); + expect(persistSpy).toHaveBeenCalledTimes(1); + }); + test("persists agent AI settings for custom agent", async () => { const persistSpy = mock(() => Promise.resolve({ success: true as const, data: true })); @@ -16984,7 +17224,7 @@ describe("WorkspaceService fork", () => { } }); - test("auto-generated fork names normalize legacy fork families before the validation fallback", async () => { + test("forks inherit the latest persisted agent settings after setup", async () => { const sourceWorkspaceId = "source-workspace"; const newWorkspaceId = "forked-workspace"; const sourceProjectPath = path.join(tempDir, "project"); @@ -16996,7 +17236,28 @@ describe("WorkspaceService fork", () => { projectName: "project", runtimeConfig: { type: "local" }, namedWorkspacePath: path.join(sourceProjectPath, "Feature-fork-2"), + agentType: " Researcher ", + aiSettingsByAgent: { + researcher: { model: "openai:gpt-5.6-sol", thinkingLevel: "high" }, + exec: { model: "anthropic:claude-sonnet-4-6", thinkingLevel: "medium" }, + }, + aiSettings: { model: "google:gemini-2.5-pro", thinkingLevel: "low" }, + }; + const latestAgentId = "exec"; + const latestAiSettingsByAgent = { + exec: { model: "openai:gpt-5.3-codex", thinkingLevel: "xhigh" as const }, }; + const latestAiSettings = { + model: "anthropic:claude-opus-4-6", + thinkingLevel: "high" as const, + }; + const latestSourceMetadata: FrontendWorkspaceMetadata = { + ...sourceMetadata, + agentId: latestAgentId, + aiSettingsByAgent: latestAiSettingsByAgent, + aiSettings: latestAiSettings, + }; + let metadataReads = 0; const forkedWorkspacePath = path.join(sourceProjectPath, "feature-1"); await fsPromises.mkdir(sourceProjectPath, { recursive: true }); @@ -17012,7 +17273,9 @@ describe("WorkspaceService fork", () => { const mockAIService = { isStreaming: mock(() => false), - getWorkspaceMetadata: mock(() => Promise.resolve(Ok(sourceMetadata))), + getWorkspaceMetadata: mock(() => + Promise.resolve(Ok(metadataReads++ === 0 ? sourceMetadata : latestSourceMetadata)) + ), // eslint-disable-next-line @typescript-eslint/no-empty-function on: mock(() => {}), // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -17083,6 +17346,18 @@ describe("WorkspaceService fork", () => { expect(result.data.metadata.name).toBe("feature-1"); expect(result.data.metadata.forkFamilyBaseName).toBe("Feature"); expect(result.data.metadata.namedWorkspacePath).toBe(forkedWorkspacePath); + expect(result.data.metadata.agentId).toBe(latestAgentId); + expect(result.data.metadata.aiSettingsByAgent).toEqual(latestAiSettingsByAgent); + expect(result.data.metadata.aiSettings).toEqual(latestAiSettings); + + const persistedMetadata = (await config.getAllWorkspaceMetadata()).find( + (workspace) => workspace.id === newWorkspaceId + ); + expect(persistedMetadata).toMatchObject({ + agentId: latestAgentId, + aiSettingsByAgent: latestAiSettingsByAgent, + aiSettings: latestAiSettings, + }); } finally { orchestrateForkSpy.mockRestore(); copyPlanSpy.mockRestore(); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 717c81b798f..4d3532cb1c4 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -230,7 +230,7 @@ import { coerceThinkingLevel, type ThinkingLevel, } from "@/common/types/thinking"; -import { normalizeAgentId } from "@/common/utils/agentIds"; +import { normalizeAgentId, resolvePersistedAgentId } from "@/common/utils/agentIds"; import { HEARTBEAT_CONTEXT_MODE_VALUES, HEARTBEAT_DEFAULT_CONTEXT_MODE, @@ -10065,13 +10065,19 @@ export class WorkspaceService extends EventEmitter { async updateAgentAISettings( workspaceId: string, agentId: string, - aiSettings: WorkspaceAISettings, + // Null persists only the selected agent (with persistSelectedAgentId), + // leaving the agent's stored settings untouched. + aiSettings: WorkspaceAISettings | null, options?: { persistSelectedAgentId?: boolean } ): Promise> { try { - const normalized = this.normalizeWorkspaceAISettings(aiSettings); - if (!normalized.success) { - return Err(normalized.error); + let normalizedSettings: WorkspaceAISettings | null = null; + if (aiSettings != null) { + const normalized = this.normalizeWorkspaceAISettings(aiSettings); + if (!normalized.success) { + return Err(normalized.error); + } + normalizedSettings = normalized.data; } if (this.workspaceGoalService) { @@ -10081,23 +10087,58 @@ export class WorkspaceService extends EventEmitter { // un-pauses or raises the budget. Letting them switch to an unpriced // model in the meantime silently records 0 cost on the next stream // and budget enforcement quietly stops working. - if ( - hasBudgetedResumableGoal(goal) && - !modelHasPricingData( - normalized.data.model, + if (hasBudgetedResumableGoal(goal)) { + // Selected-agent changes redirect backend dispatches even when + // settings are supplied (ACP mode switches), so gate every dispatch + // surface's fully resolved model: goal continuations remap + // plan/compact to exec — a bucket the submitted settings do not + // cover — while heartbeats resolve the persisted agent as-is and + // add the activity snapshot's last-used model as a fallback layer. + // Overlay the about-to-be-written bucket so a priced submission is + // not rejected against its own stale stored bucket. + const gatedModels: string[] = []; + if (normalizedSettings != null) { + gatedModels.push(normalizedSettings.model); + } + if (options?.persistSelectedAgentId === true) { + const pendingBucket = + normalizedSettings != null + ? { + agentId: normalizeAgentId(agentId, WORKSPACE_DEFAULTS.agentId), + settings: normalizedSettings, + } + : null; + const kickoff = await this.resolveContinuationKickoffSendOptionsForAgent( + workspaceId, + agentId, + pendingBucket + ); + if (kickoff?.model != null && !gatedModels.includes(kickoff.model)) { + gatedModels.push(kickoff.model); + } + const heartbeat = await this.resolveHeartbeatAiSettings( + workspaceId, + agentId, + pendingBucket + ); + if (!gatedModels.includes(heartbeat.resolved.selected.model)) { + gatedModels.push(heartbeat.resolved.selected.model); + } + } + const providersConfig = typeof this.config.loadProvidersConfig === "function" ? this.config.loadProvidersConfig() - : null - ) - ) { - return Err(UNPRICED_TARGET_MODEL_GOAL_MESSAGE); + : null; + if (gatedModels.some((model) => !modelHasPricingData(model, providersConfig))) { + return Err(UNPRICED_TARGET_MODEL_GOAL_MESSAGE); + } } } const persistResult = await this.persistWorkspaceAISettingsForAgent( workspaceId, agentId, - normalized.data, + normalizedSettings, { emitMetadata: true, ...(options?.persistSelectedAgentId === true ? { persistSelectedAgentId: true } : {}), @@ -10115,7 +10156,7 @@ export class WorkspaceService extends EventEmitter { status: "completed", data: { agentId, - model: normalized.data.model, + model: normalizedSettings?.model, mode: parsedMode.success ? parsedMode.data : undefined, }, }); @@ -10527,6 +10568,15 @@ export class WorkspaceService extends EventEmitter { // Compute namedWorkspacePath for frontend metadata const namedWorkspacePath = targetRuntime.getWorkspacePath(foundProjectPath, resolvedName); + // Fork setup can take long enough for the source selection to change. Snapshot + // persisted settings immediately before registering the fork, not before cloning. + const latestSourceMetadataResult = + await this.aiService.getWorkspaceMetadata(sourceWorkspaceId); + const latestSourceMetadata = + latestSourceMetadataResult.success && latestSourceMetadataResult.data.kind !== "scratch" + ? latestSourceMetadataResult.data + : sourceMetadata; + const sourceAgentId = resolvePersistedAgentId(latestSourceMetadata, ""); const metadata: FrontendWorkspaceMetadata = { id: newWorkspaceId, @@ -10537,6 +10587,14 @@ export class WorkspaceService extends EventEmitter { createdAt: new Date().toISOString(), runtimeConfig: forkedRuntimeConfig, namedWorkspacePath, + // Persist the source selection so other clients and background continuations hydrate the fork identically. + ...(sourceAgentId === "" ? {} : { agentId: sourceAgentId }), + ...(latestSourceMetadata.aiSettingsByAgent == null + ? {} + : { aiSettingsByAgent: { ...latestSourceMetadata.aiSettingsByAgent } }), + ...(latestSourceMetadata.aiSettings == null + ? {} + : { aiSettings: { ...latestSourceMetadata.aiSettings } }), // Preserve sub-project cwd/prompt context when forking via /fork. subProjectPath: sourceMetadata.subProjectPath, // Forks with a continue message stay pending until the first accepted user send @@ -14183,6 +14241,26 @@ export class WorkspaceService extends EventEmitter { workspaceId.trim().length > 0, "getGoalContinuationKickoffSendOptions requires workspaceId" ); + return this.resolveContinuationKickoffSendOptionsForAgent(workspaceId, null); + } + + /** + * Send options a goal-continuation kickoff would use for the given selected + * agent — or for the persisted selected agent when `overrideAgentId` is + * null. Also backs the budgeted-goal pricing gate for agent-only switches, + * which must gate the same fully resolved model (bucket, + * configured/definition defaults, legacy fallback) that dispatch selects. + * Heartbeats resolve differently (no plan/compact→exec remap plus an + * activity-snapshot fallback), so the gate probes that surface via + * resolveHeartbeatAiSettings instead. + */ + private async resolveContinuationKickoffSendOptionsForAgent( + workspaceId: string, + overrideAgentId: string | null, + // Bucket an in-flight updateAgentAISettings is about to write: the + // pricing gate passes it so resolution reflects post-write state. + pendingBucket?: { agentId: string; settings: WorkspaceAISettings } | null + ): Promise { const config = this.config.loadConfigOrDefault(); const workspaceMatch = this.config.findWorkspace(workspaceId); if (!workspaceMatch) { @@ -14197,12 +14275,18 @@ export class WorkspaceService extends EventEmitter { // sendMessage call runs, so resolve kickoff options from the persisted selected // agent instead of assuming the default exec agent. Plan/compact are UI modes, // not continuation-capable agents, so fall back to exec for the actual kickoff. - const persistedAgentId = normalizeAgentId(workspaceEntry?.agentId, WORKSPACE_DEFAULTS.agentId); + const persistedAgentId = normalizeAgentId( + overrideAgentId ?? workspaceEntry?.agentId, + WORKSPACE_DEFAULTS.agentId + ); const agentId = persistedAgentId === "plan" || persistedAgentId === "compact" ? WORKSPACE_DEFAULTS.agentId : persistedAgentId; - const selectedAgentSettings = workspaceEntry?.aiSettingsByAgent?.[agentId]; + const selectedAgentSettings = + pendingBucket?.agentId === agentId + ? pendingBucket.settings + : workspaceEntry?.aiSettingsByAgent?.[agentId]; // Unified interactive resolution: the workspace's own bucket, then // configured/definition defaults and the declared base chain, then the @@ -14761,12 +14845,23 @@ export class WorkspaceService extends EventEmitter { : String(error); } - private async buildHeartbeatSendOptions(workspaceId: string): Promise<{ - sendOptions: SendMessageOptions; - heartbeatMessage: string | undefined; - contextMode: HeartbeatContextMode; - schedulePolicy: HeartbeatSchedulePolicy; - intervalMs: number; + /** + * Heartbeat-surface AI settings for the given selected agent — or for the + * persisted selected agent when `overrideAgentId` is null. Unlike goal + * continuations, heartbeats keep plan/compact as-is and fall back to the + * activity snapshot's last-used model. The budgeted-goal pricing gate for + * agent-only switches probes this exact resolution so gated models cannot + * drift from what heartbeats actually dispatch. + */ + private async resolveHeartbeatAiSettings( + workspaceId: string, + overrideAgentId: string | null, + // Bucket an in-flight updateAgentAISettings is about to write: the + // pricing gate passes it so resolution reflects post-write state. + pendingBucket?: { agentId: string; settings: WorkspaceAISettings } | null + ): Promise<{ + agentId: string; + resolved: Awaited>; }> { const config = this.config.loadConfigOrDefault(); const workspaceMatch = this.config.findWorkspace(workspaceId); @@ -14783,13 +14878,18 @@ export class WorkspaceService extends EventEmitter { const activity = await this.extensionMetadata.getSnapshot(workspaceId); - const rawAgentId = workspaceEntry?.agentId; - const agentId = normalizeAgentId(rawAgentId, WORKSPACE_DEFAULTS.agentId); - const agentSettings = workspaceEntry?.aiSettingsByAgent?.[agentId]; + const agentId = normalizeAgentId( + overrideAgentId ?? workspaceEntry?.agentId, + WORKSPACE_DEFAULTS.agentId + ); + const agentSettings = + pendingBucket?.agentId === agentId + ? pendingBucket.settings + : workspaceEntry?.aiSettingsByAgent?.[agentId]; - // Unified interactive resolution for the workspace's selected agent: its - // bucket, configured/definition defaults and the declared base chain, then - // the legacy workspace settings and activity snapshot as fallback layers. + // Unified interactive resolution for the selected agent: its bucket, + // configured/definition defaults and the declared base chain, then the + // legacy workspace settings and activity snapshot as fallback layers. const resolved = await resolveNodeAgentAiSettings({ agentId, profile: "interactive", @@ -14818,6 +14918,31 @@ export class WorkspaceService extends EventEmitter { definitionContext: await this.getAgentDefinitionContext(workspaceId), }); + return { agentId, resolved }; + } + + private async buildHeartbeatSendOptions(workspaceId: string): Promise<{ + sendOptions: SendMessageOptions; + heartbeatMessage: string | undefined; + contextMode: HeartbeatContextMode; + schedulePolicy: HeartbeatSchedulePolicy; + intervalMs: number; + }> { + const config = this.config.loadConfigOrDefault(); + const workspaceMatch = this.config.findWorkspace(workspaceId); + + const workspaceEntry = workspaceMatch + ? (() => { + const project = config.projects.get(workspaceMatch.projectPath); + return ( + project?.workspaces.find((workspace) => workspace.id === workspaceId) ?? + project?.workspaces.find((workspace) => workspace.path === workspaceMatch.workspacePath) + ); + })() + : undefined; + + const { agentId, resolved } = await this.resolveHeartbeatAiSettings(workspaceId, null); + return { sendOptions: { model: resolved.selected.model, diff --git a/tests/ipc/acp.configOptions.test.ts b/tests/ipc/acp.configOptions.test.ts index baa1cb71fed..98a5b845b62 100644 --- a/tests/ipc/acp.configOptions.test.ts +++ b/tests/ipc/acp.configOptions.test.ts @@ -53,6 +53,7 @@ function createHarness( initial: WorkspaceState, options?: { agents?: AgentDescriptor[]; + parentWorkspaceId?: string; } ): { client: ORPCClient; @@ -66,6 +67,7 @@ function createHarness( workspaceId: string; agentId: string; aiSettings: WorkspaceAiSettings; + persistSelectedAgentId?: boolean; }>; } { let workspaceState: WorkspaceState = { @@ -83,6 +85,7 @@ function createHarness( workspaceId: string; agentId: string; aiSettings: WorkspaceAiSettings; + persistSelectedAgentId?: boolean; }> = []; const availableAgents = options?.agents ?? DEFAULT_AGENT_DESCRIPTORS; @@ -100,6 +103,9 @@ function createHarness( agentId: workspaceState.agentId, aiSettings: workspaceState.aiSettings, aiSettingsByAgent: workspaceState.aiSettingsByAgent, + ...(options?.parentWorkspaceId != null + ? { parentWorkspaceId: options.parentWorkspaceId } + : {}), }), updateModeAISettings: async (input: { workspaceId: string; @@ -124,6 +130,7 @@ function createHarness( workspaceId: string; agentId: string; aiSettings: WorkspaceAiSettings; + persistSelectedAgentId?: boolean; }) => { updateAgentCalls.push(input); @@ -263,8 +270,58 @@ describe("ACP config options", () => { activeAgentId: "plan", }); + // Mode switches persist through updateAgentAISettings so the selected + // agent is recorded alongside its settings. + expect(harness.updateAgentCalls).toHaveLength(1); + expect(harness.updateAgentCalls[0]?.aiSettings.reasoningMode).toBe("pro"); + }); + + it("persists the selected agent when switching modes", async () => { + const harness = createHarness({ + agentId: "plan", + aiSettings: { model: "anthropic:claude-opus-4-6", thinkingLevel: "high" }, + aiSettingsByAgent: { + plan: { model: "anthropic:claude-opus-4-6", thinkingLevel: "high" }, + exec: { model: "openai:gpt-5.2", thinkingLevel: "low" }, + }, + }); + + await handleSetConfigOption(harness.client, "ws-1", AGENT_MODE_CONFIG_ID, "exec", { + activeAgentId: "plan", + }); + + // The selected agent must be persisted (not just the mode's settings) so + // reconnects and other clients hydrate the new mode. + expect(harness.updateModeCalls).toHaveLength(0); + expect(harness.updateAgentCalls).toHaveLength(1); + expect(harness.updateAgentCalls[0]?.agentId).toBe("exec"); + expect(harness.updateAgentCalls[0]?.persistSelectedAgentId).toBe(true); + }); + + it("keeps mode changes session-local for child workspaces", async () => { + const harness = createHarness( + { + agentId: "plan", + aiSettings: { model: "anthropic:claude-opus-4-6", thinkingLevel: "high" }, + aiSettingsByAgent: { + plan: { model: "anthropic:claude-opus-4-6", thinkingLevel: "high" }, + exec: { model: "openai:gpt-5.2", thinkingLevel: "low" }, + }, + }, + { parentWorkspaceId: "ws-parent" } + ); + + await handleSetConfigOption(harness.client, "ws-1", AGENT_MODE_CONFIG_ID, "exec", { + activeAgentId: "plan", + }); + + // A child's creation-time agent is its locked identity and backend + // scheduled dispatch reads the persisted agentId directly, so the switch + // must stay session-local: settings-only writes, no selected-agent + // persistence. expect(harness.updateModeCalls).toHaveLength(1); - expect(harness.updateModeCalls[0]?.aiSettings.reasoningMode).toBe("pro"); + expect(harness.updateModeCalls[0]?.mode).toBe("exec"); + expect(harness.updateAgentCalls).toHaveLength(0); }); it("preserves pro reasoning mode across model and thinking level changes", async () => { diff --git a/tests/ipc/workspace/aiSettings.test.ts b/tests/ipc/workspace/aiSettings.test.ts index 71e1480b04e..e3e06d7b899 100644 --- a/tests/ipc/workspace/aiSettings.test.ts +++ b/tests/ipc/workspace/aiSettings.test.ts @@ -56,6 +56,51 @@ describe("workspace.updateAgentAISettings", () => { } }, 60000); + test("persists only the selected agent when aiSettings is null", async () => { + const env: TestEnvironment = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + const branchName = generateBranchName("agent-only"); + const createResult = await createWorkspace(env, tempGitRepo, branchName); + if (!createResult.success) { + throw new Error(`Workspace creation failed: ${createResult.error}`); + } + + const workspaceId = createResult.metadata.id; + expect(workspaceId).toBeTruthy(); + + const client = resolveOrpcClient(env); + const seedResult = await client.workspace.updateAgentAISettings({ + workspaceId: workspaceId!, + agentId: "exec", + aiSettings: { model: "openai:gpt-5.2", thinkingLevel: "xhigh" }, + persistSelectedAgentId: true, + }); + expect(seedResult.success).toBe(true); + + // Mode switch without settings: remembers the agent, leaves settings alone. + const switchResult = await client.workspace.updateAgentAISettings({ + workspaceId: workspaceId!, + agentId: "plan", + aiSettings: null, + persistSelectedAgentId: true, + }); + expect(switchResult.success).toBe(true); + + const info = await client.workspace.getInfo({ workspaceId: workspaceId! }); + expect(info?.agentId).toBe("plan"); + expect(info?.aiSettingsByAgent?.plan).toBeUndefined(); + expect(info?.aiSettingsByAgent?.exec).toEqual({ + model: "openai:gpt-5.2", + thinkingLevel: "xhigh", + }); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, 60000); + test("keeps ask-scoped settings separate from exec when persisting agent settings", async () => { const env: TestEnvironment = await createTestEnvironment(); const tempGitRepo = await createTempGitRepo();