From dfbedb12c48873509c99c3f4758dc967a11d12a4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:27:26 +0000 Subject: [PATCH 01/36] =?UTF-8?q?=F0=9F=A4=96=20feat:=20remember=20last=20?= =?UTF-8?q?used=20model=20and=20mode=20per=20workspace=20across=20clients?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model settings already synced through backend metadata, but the active agent (mode) was client-local: sends persisted workspaceEntry.agentId, yet main workspaces never re-seeded it and picker switches never wrote it back, so a fresh client always fell back to exec and seeded the wrong agent's model. - AgentContext now persists workspace mode switches via updateAgentAISettings(persistSelectedAgentId), with aiSettings allowed to be null so a mode switch cannot clobber stored settings. - WorkspaceContext seeds agentId from metadata for main workspaces too, guarded by a pending-echo check (same pattern as model settings) so stale broadcasts cannot revert an in-flight local switch. This supersedes the #3178 gate, which existed because local switches were never written back. - ProposePlanToolCall marks the pending agent before its follow-up send persists the switch. --- _Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- src/browser/contexts/AgentContext.test.tsx | 44 ++++++++++++ src/browser/contexts/AgentContext.tsx | 52 +++++++++++--- .../contexts/WorkspaceContext.test.tsx | 72 ++++++++++++++++--- src/browser/contexts/WorkspaceContext.tsx | 29 ++++---- .../features/Tools/ProposePlanToolCall.tsx | 4 ++ src/browser/utils/workspaceAiSettingsSync.ts | 35 +++++++++ src/common/orpc/schemas/api.ts | 4 +- src/node/orpc/router.ts | 2 +- src/node/services/workspaceService.ts | 56 ++++++++------- tests/ipc/workspace/aiSettings.test.ts | 45 ++++++++++++ 10 files changed, 286 insertions(+), 57 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index 03e48b84c5a..1a5a287e022 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -22,6 +22,12 @@ import type * as WorkspaceContextModule from "./WorkspaceContext"; let mockAgentDefinitions: AgentDefinitionDescriptor[] = []; let mockWorkspaceMetadata = new Map(); +let updateAgentAISettingsCalls: Array<{ + workspaceId: string; + agentId: string; + aiSettings: { model: string } | null; + persistSelectedAgentId?: boolean | null; +}> = []; let APIProvider!: typeof APIModule.APIProvider; let RouterProvider!: typeof RouterContextModule.RouterProvider; @@ -200,6 +206,10 @@ 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]) => { + updateAgentAISettingsCalls.push(input); + return Promise.resolve({ success: true as const, data: undefined }); + }, }, projects: { list: () => Promise.resolve([]), @@ -246,6 +256,7 @@ describe("AgentContext", () => { isolatedModuleDir = await importIsolatedAgentModules(); mockAgentDefinitions = []; mockWorkspaceMetadata = new Map(); + updateAgentAISettingsCalls = []; originalWindow = globalThis.window; originalDocument = globalThis.document; @@ -362,6 +373,39 @@ describe("AgentContext", () => { }); }); + 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).toEqual([ + { workspaceId, agentId: "plan", aiSettings: null, persistSelectedAgentId: true }, + ]); + + // Re-selecting the current agent is a no-op and must not hit the backend. + contextValue?.setAgentId("plan"); + expect(updateAgentAISettingsCalls).toHaveLength(1); + }); + 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..2553f55ee56 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -25,6 +25,10 @@ import { import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import { sortAgentsStable } from "@/browser/utils/agents"; import { normalizeAgentId, resolveRemovedBuiltinAgentId } from "@/common/utils/agentIds"; +import { + clearPendingWorkspaceAgentId, + markPendingWorkspaceAgentId, +} from "@/browser/utils/workspaceAiSettingsSync"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; export interface AgentContextValue { @@ -120,17 +124,52 @@ 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; const setAgentId: Dispatch> = useCallback( (value) => { + // usePersistedState runs the updater synchronously, so `next` is + // available right after the call. + let next: string | null = null; + let changed = false; 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); + next = coerceAgentId(typeof value === "function" ? value(previousAgentId) : value); + changed = next !== previousAgentId; + 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 || !changed) { + return; + } + const nextAgentId: string = next; + + markPendingWorkspaceAgentId(workspaceId, nextAgentId); + api.workspace + .updateAgentAISettings({ + workspaceId, + agentId: nextAgentId, + aiSettings: null, + persistSelectedAgentId: true, + }) + .then((result) => { + if (!result.success) { + clearPendingWorkspaceAgentId(workspaceId, nextAgentId); + } + }) + .catch(() => { + // Best-effort only: the next sendMessage persists the same selection. + clearPendingWorkspaceAgentId(workspaceId, nextAgentId); + }); }, - [globalDefaultAgentId, isProjectScope, setAgentIdRaw] + [api, globalDefaultAgentId, isCurrentAgentLocked, isProjectScope, setAgentIdRaw, workspaceId] ); const [agents, setAgents] = useState([]); @@ -230,11 +269,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/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index 75b87d71887..1f3dea58460 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -21,6 +21,7 @@ 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 { markPendingWorkspaceAgentId } from "@/browser/utils/workspaceAiSettingsSync"; import { getProjectRouteId } from "@/common/utils/projectRouteId"; import type { RightSidebarLayoutState } from "@/browser/utils/rightSidebarLayout"; @@ -520,8 +521,30 @@ 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("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 +555,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 +573,49 @@ 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 of the pending value clears the guard... + await waitFor(() => expect(emitMetadata).toBeTruthy()); + act(() => { + emitMetadata?.({ + workspaceId, + metadata: createWorkspaceMetadata({ id: workspaceId, agentId: "exec" }), + }); + }); + await waitFor(() => expect(ctx().workspaceMetadata.get(workspaceId)?.agentId).toBe("exec")); + + // ...so 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 acc1130b362..2c1b2d9cd62 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,13 +180,21 @@ 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); + } } } diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index 4cf57c0c89c..d48416365c2 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -53,6 +53,7 @@ import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; +import { markPendingWorkspaceAgentId } from "@/browser/utils/workspaceAiSettingsSync"; import { resolveWorkspaceAiSettingsForAgent, type WorkspaceAISettingsCache, @@ -505,6 +506,9 @@ export const ProposePlanToolCall: React.FC = (props) = agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])), }); + // 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) { diff --git a/src/browser/utils/workspaceAiSettingsSync.ts b/src/browser/utils/workspaceAiSettingsSync.ts index 69beb191693..56907a254fe 100644 --- a/src/browser/utils/workspaceAiSettingsSync.ts +++ b/src/browser/utils/workspaceAiSettingsSync.ts @@ -88,3 +88,38 @@ export function shouldApplyWorkspaceAiSettingsFromBackend( return false; } + +// Same pending-echo protection as AI settings, but for the workspace's active +// agent selection: a local mode switch must not be reverted by a stale +// metadata broadcast that raced the persistence write. +const pendingAgentIdByWorkspace = new Map(); + +export function markPendingWorkspaceAgentId(workspaceId: string, agentId: string): void { + if (!workspaceId || !agentId) { + return; + } + pendingAgentIdByWorkspace.set(workspaceId, agentId); +} + +export function clearPendingWorkspaceAgentId(workspaceId: string, agentId: string): void { + // Clear only the matching entry so a failed write cannot wipe a newer + // pending selection from a rapid follow-up switch. + if (pendingAgentIdByWorkspace.get(workspaceId) === agentId) { + pendingAgentIdByWorkspace.delete(workspaceId); + } +} + +export function shouldApplyWorkspaceAgentIdFromBackend( + workspaceId: string, + incomingAgentId: string +): boolean { + const pending = pendingAgentIdByWorkspace.get(workspaceId); + if (!pending) { + return true; + } + if (pending === incomingAgentId) { + pendingAgentIdByWorkspace.delete(workspaceId); + return true; + } + return false; +} diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 865027734a5..1e1607edaed 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1424,7 +1424,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/node/orpc/router.ts b/src/node/orpc/router.ts index b05207ff6c7..17c8a4be9c0 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -4592,7 +4592,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.ts b/src/node/services/workspaceService.ts index 482a164e647..8462300ba60 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -9676,39 +9676,45 @@ 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); - } - - if (this.workspaceGoalService) { - const goal = await this.workspaceGoalService.getGoal(workspaceId); - // Use the resumable check rather than active-only: a paused or - // budget-limited budgeted goal will resume accounting when the user - // 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, - typeof this.config.loadProvidersConfig === "function" - ? this.config.loadProvidersConfig() - : null - ) - ) { - return Err(UNPRICED_TARGET_MODEL_GOAL_MESSAGE); + 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) { + const goal = await this.workspaceGoalService.getGoal(workspaceId); + // Use the resumable check rather than active-only: a paused or + // budget-limited budgeted goal will resume accounting when the user + // 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( + normalizedSettings.model, + typeof this.config.loadProvidersConfig === "function" + ? this.config.loadProvidersConfig() + : null + ) + ) { + return Err(UNPRICED_TARGET_MODEL_GOAL_MESSAGE); + } } } const persistResult = await this.persistWorkspaceAISettingsForAgent( workspaceId, agentId, - normalized.data, + normalizedSettings, { emitMetadata: true, ...(options?.persistSelectedAgentId === true ? { persistSelectedAgentId: true } : {}), @@ -9726,7 +9732,7 @@ export class WorkspaceService extends EventEmitter { status: "completed", data: { agentId, - model: normalized.data.model, + model: normalizedSettings?.model, mode: parsedMode.success ? parsedMode.data : undefined, }, }); 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(); From 4103151aff703e5e3d668fbc99933febfe5e3ca1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:55:09 +0000 Subject: [PATCH 02/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20converge=20agent=20?= =?UTF-8?q?selection=20when=20persistence=20or=20follow-up=20send=20fails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review feedback on #3968: - AgentContext rolls the optimistic local mode switch back when the backend write fails (unless the user re-switched meanwhile), so a client cannot silently diverge from the backend-authoritative agent. - ProposePlan handlers clear the pending agent guard when the follow-up send fails (failed Result or throw); a stuck guard would block all backend agent seeds for the workspace. --- _Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- src/browser/contexts/AgentContext.test.tsx | 58 ++++++++++++++++++- src/browser/contexts/AgentContext.tsx | 27 +++++---- .../Tools/ProposePlanToolCall.test.tsx | 30 +++++++++- .../features/Tools/ProposePlanToolCall.tsx | 19 +++++- 4 files changed, 118 insertions(+), 16 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index 1a5a287e022..8a51988a32d 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -28,6 +28,13 @@ let updateAgentAISettingsCalls: Array<{ aiSettings: { model: string } | null; persistSelectedAgentId?: boolean | null; }> = []; +interface UpdateAgentAISettingsResult { + success: boolean; + error?: string; + data?: undefined; +} +let deferUpdateAgentAISettings = false; +let resolveUpdateAgentAISettings: ((result: UpdateAgentAISettingsResult) => void) | null = null; let APIProvider!: typeof APIModule.APIProvider; let RouterProvider!: typeof RouterContextModule.RouterProvider; @@ -206,9 +213,16 @@ 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]) => { + updateAgentAISettings: ( + input: (typeof updateAgentAISettingsCalls)[number] + ): Promise => { updateAgentAISettingsCalls.push(input); - return Promise.resolve({ success: true as const, data: undefined }); + if (deferUpdateAgentAISettings) { + return new Promise((resolve) => { + resolveUpdateAgentAISettings = resolve; + }); + } + return Promise.resolve({ success: true, data: undefined }); }, }, projects: { @@ -257,6 +271,8 @@ describe("AgentContext", () => { mockAgentDefinitions = []; mockWorkspaceMetadata = new Map(); updateAgentAISettingsCalls = []; + deferUpdateAgentAISettings = false; + resolveUpdateAgentAISettings = null; originalWindow = globalThis.window; originalDocument = globalThis.document; @@ -406,6 +422,44 @@ describe("AgentContext", () => { expect(updateAgentAISettingsCalls).toHaveLength(1); }); + test("failed persistence rolls back 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"); + }); + + contextValue?.setAgentId("plan"); + + // Optimistic switch happens immediately... + await waitFor(() => { + expect(contextValue?.agentId).toBe("plan"); + }); + await waitFor(() => { + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); + + // ...then the backend rejects the write and the selection rolls back. + resolveUpdateAgentAISettings?.({ success: false, error: "offline" }); + + 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 2553f55ee56..efc4a7f0a86 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -134,22 +134,32 @@ function AgentProviderWithState(props: { // usePersistedState runs the updater synchronously, so `next` is // available right after the call. let next: string | null = null; - let changed = false; + let previous: string | null = null; setAgentIdRaw((prev) => { const explicitPrevAgentId = typeof prev === "string" && prev.trim().length > 0 ? prev : globalDefaultAgentId; - const previousAgentId = coerceAgentId(isProjectScope ? explicitPrevAgentId : prev); - next = coerceAgentId(typeof value === "function" ? value(previousAgentId) : value); - changed = next !== previousAgentId; + 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 || !changed) { + if (!api || !workspaceId || isCurrentAgentLocked || next == null || next === previous) { return; } const nextAgentId: string = next; + const previousAgentId: string | null = previous; + + // Optimistic local update above; on persistence failure roll the local + // selection back (unless it changed again meanwhile) so this client + // cannot silently diverge from the backend-authoritative agent. + const rollback = () => { + clearPendingWorkspaceAgentId(workspaceId, nextAgentId); + if (previousAgentId != null) { + setAgentIdRaw((current) => (current === nextAgentId ? previousAgentId : current)); + } + }; markPendingWorkspaceAgentId(workspaceId, nextAgentId); api.workspace @@ -161,13 +171,10 @@ function AgentProviderWithState(props: { }) .then((result) => { if (!result.success) { - clearPendingWorkspaceAgentId(workspaceId, nextAgentId); + rollback(); } }) - .catch(() => { - // Best-effort only: the next sendMessage persists the same selection. - clearPendingWorkspaceAgentId(workspaceId, nextAgentId); - }); + .catch(rollback); }, [api, globalDefaultAgentId, isCurrentAgentLocked, isProjectScope, setAgentIdRaw, workspaceId] ); diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index ffc00bc4133..4d69079cc55 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,7 +60,9 @@ 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: undefined } | { success: false; error: string }>; }; } @@ -520,6 +523,31 @@ describe("ProposePlanToolCall", () => { } }); + test("clears the pending agent guard when the Implement send fails", 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)); + + // The failed send cannot persist the switch, so 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) + ); + }); + test("uses workspace-by-agent override for Implement when exec defaults inherit", async () => { const execWorkspaceModel = "openai:gpt-5.2-pro"; const execWorkspaceThinking = "medium"; diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index d48416365c2..d698a6e8232 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -53,7 +53,10 @@ import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; -import { markPendingWorkspaceAgentId } from "@/browser/utils/workspaceAiSettingsSync"; +import { + clearPendingWorkspaceAgentId, + markPendingWorkspaceAgentId, +} from "@/browser/utils/workspaceAiSettingsSync"; import { resolveWorkspaceAiSettingsForAgent, type WorkspaceAISettingsCache, @@ -559,7 +562,7 @@ export const ProposePlanToolCall: React.FC = (props) = }); const sendMessageOptions = getSendOptionsFromStorage(workspaceId); - await api.workspace.sendMessage({ + const sendResult = await api.workspace.sendMessage({ workspaceId, message: "Implement the plan", options: { @@ -569,8 +572,14 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: resolvedThinking, }, }); + if (!sendResult.success) { + // The send was what would persist the switch; without it the guard + // would block backend agent seeds for this workspace indefinitely. + clearPendingWorkspaceAgentId(workspaceId, targetAgentId); + } } catch { // Best-effort: user can retry manually if sending fails. + clearPendingWorkspaceAgentId(workspaceId, "exec"); } finally { isImplementingRef.current = false; if (isMountedRef.current) { @@ -612,7 +621,7 @@ export const ProposePlanToolCall: React.FC = (props) = }); const sendMessageOptions = getSendOptionsFromStorage(workspaceId); - await api.workspace.sendMessage({ + const sendResult = await api.workspace.sendMessage({ workspaceId, message: "Implement the plan", options: { @@ -622,8 +631,12 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: resolvedThinking, }, }); + if (!sendResult.success) { + clearPendingWorkspaceAgentId(workspaceId, targetAgentId); + } } catch { // Best-effort: user can retry manually if sending fails. + clearPendingWorkspaceAgentId(workspaceId, "auto"); } finally { isContinuingInAutoRef.current = false; if (isMountedRef.current) { From bf11f76b9dc786db19bb56367a8d84b35d7b1e57 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:31:39 +0000 Subject: [PATCH 03/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20address=20round-2?= =?UTF-8?q?=20Codex=20findings=20on=20agent-selection=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Release the pending agent guard deterministically once persistence or the ProposePlan follow-up send settles: a successful no-op write emits no metadata echo, which previously stranded the guard and blocked all later cross-client agent updates. Real echoes are ordered after stale broadcasts, so releasing on the response cannot strand a stale value. - Never hydrate another agent's settings: workspace seeding now only applies model/thinking from the ACTIVE agent's own bucket instead of falling back to exec/plan, which overwrote locally resolved settings for bucket-less agents after an agent-only switch. - Apply the budgeted-goal pricing gate to agent-only switches using the target agent's stored model (bucket, then legacy settings), matching what heartbeat/goal-continuation dispatch would resolve. --- _Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- src/browser/contexts/AgentContext.tsx | 10 +++- .../contexts/WorkspaceContext.test.tsx | 37 +++++++++++++ src/browser/contexts/WorkspaceContext.tsx | 6 +- .../Tools/ProposePlanToolCall.test.tsx | 6 ++ .../features/Tools/ProposePlanToolCall.tsx | 19 +++---- src/node/services/workspaceService.test.ts | 55 +++++++++++++++++++ src/node/services/workspaceService.ts | 50 ++++++++++++++--- 7 files changed, 161 insertions(+), 22 deletions(-) diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx index efc4a7f0a86..63747daed35 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -170,9 +170,15 @@ function AgentProviderWithState(props: { persistSelectedAgentId: true, }) .then((result) => { - if (!result.success) { - rollback(); + if (result.success) { + // A no-op write (backend already on this agent) emits no metadata + // echo, so release the guard deterministically. For changed writes + // the echo is ordered after any stale broadcast, so releasing on + // the response cannot strand a stale value. + clearPendingWorkspaceAgentId(workspaceId, nextAgentId); + return; } + rollback(); }) .catch(rollback); }, diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index 1f3dea58460..8b873b5cfd5 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -543,6 +543,43 @@ describe("WorkspaceContext", () => { ); }); + 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("stale metadata does not clobber a pending local agent switch", async () => { const workspaceId = "ws-agent-pending"; let emitMetadata: diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 2c1b2d9cd62..c9df55fea96 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -247,7 +247,11 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat 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/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index 4d69079cc55..9c436a15338 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -521,6 +521,12 @@ describe("ProposePlanToolCall", () => { expect(JSON.parse(window.localStorage.getItem(modelKey)!)).toBe(execModel); expect(JSON.parse(window.localStorage.getItem(thinkingKey)!)).toBe(execThinking); } + + // The guard is released once the send settles (a successful no-op + // persistence emits no echo), so backend agent updates apply again. + await waitFor(() => + expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) + ); }); test("clears the pending agent guard when the Implement send fails", async () => { diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index d698a6e8232..09b04fd0ccf 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -562,7 +562,7 @@ export const ProposePlanToolCall: React.FC = (props) = }); const sendMessageOptions = getSendOptionsFromStorage(workspaceId); - const sendResult = await api.workspace.sendMessage({ + await api.workspace.sendMessage({ workspaceId, message: "Implement the plan", options: { @@ -572,11 +572,11 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: resolvedThinking, }, }); - if (!sendResult.success) { - // The send was what would persist the switch; without it the guard - // would block backend agent seeds for this workspace indefinitely. - clearPendingWorkspaceAgentId(workspaceId, targetAgentId); - } + // Success: the send persisted the switch, and a no-op persistence emits + // no metadata echo, so release the guard once the send settles (a real + // echo is ordered after any stale broadcast). Failure: nothing will echo + // and a stuck guard would block backend agent seeds indefinitely. + clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } catch { // Best-effort: user can retry manually if sending fails. clearPendingWorkspaceAgentId(workspaceId, "exec"); @@ -621,7 +621,7 @@ export const ProposePlanToolCall: React.FC = (props) = }); const sendMessageOptions = getSendOptionsFromStorage(workspaceId); - const sendResult = await api.workspace.sendMessage({ + await api.workspace.sendMessage({ workspaceId, message: "Implement the plan", options: { @@ -631,9 +631,8 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: resolvedThinking, }, }); - if (!sendResult.success) { - clearPendingWorkspaceAgentId(workspaceId, targetAgentId); - } + // See handleImplement: release the guard once the send settles. + clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } catch { // Best-effort: user can retry manually if sending fails. clearPendingWorkspaceAgentId(workspaceId, "auto"); diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index fab07f6fcd2..563c609dd4e 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -9262,6 +9262,61 @@ 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: { + 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("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", "plan", 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 })); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 8462300ba60..31776f9cbc3 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -9673,6 +9673,28 @@ export class WorkspaceService extends EventEmitter { return this.updateAgentAISettings(workspaceId, mode, aiSettings); } + /** + * Model a selected-agent switch would make authoritative for backend + * dispatches (heartbeats, goal continuations): the target agent's stored + * workspace bucket, then the legacy shared workspace settings. + */ + private getStoredWorkspaceAgentModel(workspaceId: string, agentId: string): string | undefined { + const found = this.config.findWorkspace(workspaceId); + if (!found) { + return undefined; + } + const normalizedAgentId = normalizeAgentId(agentId, ""); + if (!normalizedAgentId) { + return undefined; + } + const entry = this.findFreshWorkspaceEntry(this.config.loadConfigOrDefault(), { + projectPath: found.projectPath, + workspaceId, + workspacePath: found.workspacePath, + }); + return entry?.aiSettingsByAgent?.[normalizedAgentId]?.model ?? entry?.aiSettings?.model; + } + async updateAgentAISettings( workspaceId: string, agentId: string, @@ -9689,18 +9711,28 @@ export class WorkspaceService extends EventEmitter { return Err(normalized.error); } normalizedSettings = normalized.data; + } - if (this.workspaceGoalService) { - const goal = await this.workspaceGoalService.getGoal(workspaceId); - // Use the resumable check rather than active-only: a paused or - // budget-limited budgeted goal will resume accounting when the user - // 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 (this.workspaceGoalService) { + const goal = await this.workspaceGoalService.getGoal(workspaceId); + // Use the resumable check rather than active-only: a paused or + // budget-limited budgeted goal will resume accounting when the user + // 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)) { + // Agent-only switches (null aiSettings) still redirect heartbeat and + // goal-continuation dispatches to the target agent's stored settings, + // so gate on the model the switch would make authoritative. + const gatedModel = + normalizedSettings?.model ?? + (options?.persistSelectedAgentId === true + ? this.getStoredWorkspaceAgentModel(workspaceId, agentId) + : undefined); if ( - hasBudgetedResumableGoal(goal) && + gatedModel != null && !modelHasPricingData( - normalizedSettings.model, + gatedModel, typeof this.config.loadProvidersConfig === "function" ? this.config.loadProvidersConfig() : null From cd2228a7348edb5b06a13254a1bbdd9f837a37f7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:44:25 +0000 Subject: [PATCH 04/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20gate=20agent-only?= =?UTF-8?q?=20switches=20on=20the=20fully=20resolved=20dispatch=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 Codex finding: the pricing gate only looked at the stored workspace bucket/legacy settings, but goal-continuation kickoff also resolves configured and definition defaults. Extract the kickoff resolution into resolveContinuationKickoffSendOptionsForAgent (shared by getGoalContinuationKickoffSendOptions) and gate the agent-only switch on the exact model that resolution selects, including the plan/compact -> exec dispatch remap. --- _Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- src/node/services/workspaceService.test.ts | 31 ++++++++++++-- src/node/services/workspaceService.ts | 50 +++++++++++----------- 2 files changed, 52 insertions(+), 29 deletions(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 563c609dd4e..90dd9e32417 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -9279,7 +9279,7 @@ describe("WorkspaceService maybePersistAISettingsFromOptions", () => { path: "/tmp/proj/ws", name: "ws", aiSettingsByAgent: { - plan: { model: "openai:not-priced-model", thinkingLevel: "off" }, + reviewer: { model: "openai:not-priced-model", thinkingLevel: "off" }, }, }, ], @@ -9288,7 +9288,32 @@ describe("WorkspaceService maybePersistAISettingsFromOptions", () => { ]), })); - const result = await workspaceService.updateAgentAISettings("ws", "plan", null, { + 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, }); @@ -9309,7 +9334,7 @@ describe("WorkspaceService maybePersistAISettingsFromOptions", () => { } ).persistWorkspaceAISettingsForAgent = persistSpy; - const result = await workspaceService.updateAgentAISettings("ws", "plan", null, { + const result = await workspaceService.updateAgentAISettings("ws", "reviewer", null, { persistSelectedAgentId: true, }); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 31776f9cbc3..5bcfa1c5580 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -9673,28 +9673,6 @@ export class WorkspaceService extends EventEmitter { return this.updateAgentAISettings(workspaceId, mode, aiSettings); } - /** - * Model a selected-agent switch would make authoritative for backend - * dispatches (heartbeats, goal continuations): the target agent's stored - * workspace bucket, then the legacy shared workspace settings. - */ - private getStoredWorkspaceAgentModel(workspaceId: string, agentId: string): string | undefined { - const found = this.config.findWorkspace(workspaceId); - if (!found) { - return undefined; - } - const normalizedAgentId = normalizeAgentId(agentId, ""); - if (!normalizedAgentId) { - return undefined; - } - const entry = this.findFreshWorkspaceEntry(this.config.loadConfigOrDefault(), { - projectPath: found.projectPath, - workspaceId, - workspacePath: found.workspacePath, - }); - return entry?.aiSettingsByAgent?.[normalizedAgentId]?.model ?? entry?.aiSettings?.model; - } - async updateAgentAISettings( workspaceId: string, agentId: string, @@ -9722,12 +9700,14 @@ export class WorkspaceService extends EventEmitter { // and budget enforcement quietly stops working. if (hasBudgetedResumableGoal(goal)) { // Agent-only switches (null aiSettings) still redirect heartbeat and - // goal-continuation dispatches to the target agent's stored settings, - // so gate on the model the switch would make authoritative. + // goal-continuation dispatches to the target agent, so gate on the + // same fully resolved model (bucket, configured/definition defaults, + // legacy fallback) that dispatch resolution would select. const gatedModel = normalizedSettings?.model ?? (options?.persistSelectedAgentId === true - ? this.getStoredWorkspaceAgentModel(workspaceId, agentId) + ? (await this.resolveContinuationKickoffSendOptionsForAgent(workspaceId, agentId)) + ?.model : undefined); if ( gatedModel != null && @@ -13482,6 +13462,21 @@ export class WorkspaceService extends EventEmitter { workspaceId.trim().length > 0, "getGoalContinuationKickoffSendOptions requires workspaceId" ); + return this.resolveContinuationKickoffSendOptionsForAgent(workspaceId, null); + } + + /** + * Send options a backend continuation dispatch (goal kickoff, heartbeat) + * 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 this + * dispatch path would select. + */ + private async resolveContinuationKickoffSendOptionsForAgent( + workspaceId: string, + overrideAgentId: string | null + ): Promise { const config = this.config.loadConfigOrDefault(); const workspaceMatch = this.config.findWorkspace(workspaceId); if (!workspaceMatch) { @@ -13496,7 +13491,10 @@ 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 From badc083c6130ffc9d20b1207c88a54a9d0fd0fc5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:59:38 +0000 Subject: [PATCH 05/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20address=20round-4?= =?UTF-8?q?=20Codex=20findings=20on=20switch=20durability=20and=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProposePlan explicitly persists the agent selection after a successful send (send-side persistence only logs failures), releasing the pending guard afterwards either way so clients converge on the backend agent. - The budgeted-goal pricing gate probes both dispatch surfaces: the continuation kickoff (plan/compact remapped to exec) and the heartbeat surface, which resolves the persisted agent without the remap. --- _Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- .../Tools/ProposePlanToolCall.test.tsx | 32 ++++++++- .../features/Tools/ProposePlanToolCall.tsx | 48 ++++++++++--- src/node/services/workspaceService.test.ts | 39 +++++++++++ src/node/services/workspaceService.ts | 68 +++++++++++-------- 4 files changed, 149 insertions(+), 38 deletions(-) diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index 9c436a15338..a842517eacf 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -63,9 +63,22 @@ interface MockApi { sendMessage: ( args: SendMessageArgs ) => Promise<{ success: true; data: undefined } | { success: false; error: string }>; + updateAgentAISettings: (args: { + workspaceId: string; + agentId: string; + aiSettings: null; + persistSelectedAgentId?: boolean; + }) => Promise<{ success: boolean; error?: string }>; }; } +let updateAgentAISettingsCalls: Array<{ + workspaceId: string; + agentId: string; + aiSettings: null; + persistSelectedAgentId?: boolean; +}> = []; + let mockApi: MockApi | null = null; let startHereCalls: Array<{ @@ -263,6 +276,10 @@ function createMockApi( overrides.replaceChatHistory ?? (() => Promise.resolve({ success: true, data: undefined })), sendMessage: overrides.sendMessage ?? (() => Promise.resolve({ success: true, data: undefined })), + updateAgentAISettings: (args) => { + updateAgentAISettingsCalls.push(args); + return Promise.resolve({ success: true }); + }, }, }; } @@ -316,6 +333,7 @@ describe("ProposePlanToolCall", () => { beforeEach(async () => { startHereCalls = []; selectableDiffRendererCalls = []; + updateAgentAISettingsCalls = []; mockApi = null; cleanupDom = installDom(); await installProposePlanModuleMocks(); @@ -522,8 +540,16 @@ describe("ProposePlanToolCall", () => { expect(JSON.parse(window.localStorage.getItem(thinkingKey)!)).toBe(execThinking); } - // The guard is released once the send settles (a successful no-op - // persistence emits no echo), so backend agent updates apply again. + // A successful send persists the selection explicitly (send-side + // persistence is best-effort) and then releases the guard so backend + // agent updates apply again. + await waitFor(() => expect(updateAgentAISettingsCalls).toHaveLength(1)); + expect(updateAgentAISettingsCalls[0]).toEqual({ + workspaceId: WORKSPACE_ID, + agentId: "exec", + aiSettings: null, + persistSelectedAgentId: true, + }); await waitFor(() => expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) ); @@ -552,6 +578,8 @@ describe("ProposePlanToolCall", () => { await waitFor(() => expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) ); + // No explicit persistence for a switch whose send never went through. + expect(updateAgentAISettingsCalls).toHaveLength(0); }); test("uses workspace-by-agent override for Implement when exec defaults inherit", async () => { diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index 09b04fd0ccf..12029c1b8aa 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -562,7 +562,7 @@ export const ProposePlanToolCall: React.FC = (props) = }); const sendMessageOptions = getSendOptionsFromStorage(workspaceId); - await api.workspace.sendMessage({ + const sendResult = await api.workspace.sendMessage({ workspaceId, message: "Implement the plan", options: { @@ -572,11 +572,27 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: resolvedThinking, }, }); - // Success: the send persisted the switch, and a no-op persistence emits - // no metadata echo, so release the guard once the send settles (a real - // echo is ordered after any stale broadcast). Failure: nothing will echo - // and a stuck guard would block backend agent seeds indefinitely. - clearPendingWorkspaceAgentId(workspaceId, targetAgentId); + if (sendResult.success) { + // A successful send does not guarantee the switch is durable: its + // settings persistence is best-effort (failures only log). Persist the + // selection explicitly, then release the guard either way — backend + // echoes carry the authoritative agent, and a successful no-op write + // emits no echo to release it for us. + try { + await api.workspace.updateAgentAISettings({ + workspaceId, + agentId: targetAgentId, + aiSettings: null, + persistSelectedAgentId: true, + }); + } finally { + clearPendingWorkspaceAgentId(workspaceId, targetAgentId); + } + } else { + // Failed send: nothing will echo, and a stuck guard would block + // backend agent seeds indefinitely. + clearPendingWorkspaceAgentId(workspaceId, targetAgentId); + } } catch { // Best-effort: user can retry manually if sending fails. clearPendingWorkspaceAgentId(workspaceId, "exec"); @@ -621,7 +637,7 @@ export const ProposePlanToolCall: React.FC = (props) = }); const sendMessageOptions = getSendOptionsFromStorage(workspaceId); - await api.workspace.sendMessage({ + const sendResult = await api.workspace.sendMessage({ workspaceId, message: "Implement the plan", options: { @@ -631,8 +647,22 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: resolvedThinking, }, }); - // See handleImplement: release the guard once the send settles. - clearPendingWorkspaceAgentId(workspaceId, targetAgentId); + // See handleImplement: persist the selection explicitly (send-side + // persistence is best-effort), then release the guard. + if (sendResult.success) { + try { + await api.workspace.updateAgentAISettings({ + workspaceId, + agentId: targetAgentId, + aiSettings: null, + persistSelectedAgentId: true, + }); + } finally { + clearPendingWorkspaceAgentId(workspaceId, targetAgentId); + } + } else { + clearPendingWorkspaceAgentId(workspaceId, targetAgentId); + } } catch { // Best-effort: user can retry manually if sending fails. clearPendingWorkspaceAgentId(workspaceId, "auto"); diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 90dd9e32417..7f56ff9ef5a 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -9323,6 +9323,45 @@ describe("WorkspaceService maybePersistAISettingsFromOptions", () => { }); }); + 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("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({ diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5bcfa1c5580..7365916ed9e 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -9699,25 +9699,35 @@ export class WorkspaceService extends EventEmitter { // model in the meantime silently records 0 cost on the next stream // and budget enforcement quietly stops working. if (hasBudgetedResumableGoal(goal)) { - // Agent-only switches (null aiSettings) still redirect heartbeat and - // goal-continuation dispatches to the target agent, so gate on the - // same fully resolved model (bucket, configured/definition defaults, - // legacy fallback) that dispatch resolution would select. - const gatedModel = - normalizedSettings?.model ?? - (options?.persistSelectedAgentId === true - ? (await this.resolveContinuationKickoffSendOptionsForAgent(workspaceId, agentId)) - ?.model - : undefined); - if ( - gatedModel != null && - !modelHasPricingData( - gatedModel, - typeof this.config.loadProvidersConfig === "function" - ? this.config.loadProvidersConfig() - : null - ) - ) { + // Agent-only switches (null aiSettings) still redirect backend + // dispatches to the target agent, so gate every dispatch surface's + // fully resolved model: goal continuations remap plan/compact to + // exec, while heartbeats resolve the persisted agent as-is. + const gatedModels: string[] = []; + if (normalizedSettings != null) { + gatedModels.push(normalizedSettings.model); + } else if (options?.persistSelectedAgentId === true) { + const kickoff = await this.resolveContinuationKickoffSendOptionsForAgent( + workspaceId, + agentId + ); + if (kickoff?.model != null) { + gatedModels.push(kickoff.model); + } + const heartbeat = await this.resolveContinuationKickoffSendOptionsForAgent( + workspaceId, + agentId, + { remapUiModes: false } + ); + if (heartbeat?.model != null && heartbeat.model !== kickoff?.model) { + gatedModels.push(heartbeat.model); + } + } + const providersConfig = + typeof this.config.loadProvidersConfig === "function" + ? this.config.loadProvidersConfig() + : null; + if (gatedModels.some((model) => !modelHasPricingData(model, providersConfig))) { return Err(UNPRICED_TARGET_MODEL_GOAL_MESSAGE); } } @@ -13466,16 +13476,19 @@ export class WorkspaceService extends EventEmitter { } /** - * Send options a backend continuation dispatch (goal kickoff, heartbeat) - * 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 this - * dispatch path would select. + * 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 the persisted agent WITHOUT the plan/compact→exec + * remap (buildHeartbeatSendOptions), so the gate probes that surface with + * `remapUiModes: false`. */ private async resolveContinuationKickoffSendOptionsForAgent( workspaceId: string, - overrideAgentId: string | null + overrideAgentId: string | null, + opts?: { remapUiModes?: boolean } ): Promise { const config = this.config.loadConfigOrDefault(); const workspaceMatch = this.config.findWorkspace(workspaceId); @@ -13496,7 +13509,8 @@ export class WorkspaceService extends EventEmitter { WORKSPACE_DEFAULTS.agentId ); const agentId = - persistedAgentId === "plan" || persistedAgentId === "compact" + (opts?.remapUiModes ?? true) && + (persistedAgentId === "plan" || persistedAgentId === "compact") ? WORKSPACE_DEFAULTS.agentId : persistedAgentId; const selectedAgentSettings = workspaceEntry?.aiSettingsByAgent?.[agentId]; From 2d9d468281921eaeb71c5bbf4af7b3e311d4d5d4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:27:04 +0000 Subject: [PATCH 06/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20harden=20mode-switc?= =?UTF-8?q?h=20rollback=20and=20heartbeat=20pricing=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 5 follow-ups: - ProposePlan now honors the explicit selection persistence Result: a failed or thrown updateAgentAISettings (and a failed send) rolls the optimistic plan→exec/auto switch back — agent id plus the pre-switch model/thinking/reasoning settings — unless the user already moved on. - AgentContext rollback restores the pre-switch model settings alongside the agent id, so WorkspaceModeAISync's fallback cannot leave the restored agent on the target agent's just-applied settings. - The budgeted-goal pricing gate probes the heartbeat surface through the real heartbeat resolution (extracted resolveHeartbeatAiSettings, shared with buildHeartbeatSendOptions), which includes the activity snapshot's last-used model fallback the previous probe missed. _Generated with `mux` • Model: claude-x-large-4.6 • Thinking: max_ --- src/browser/contexts/AgentContext.test.tsx | 59 +++++++++++- src/browser/contexts/AgentContext.tsx | 39 +++++++- .../Tools/ProposePlanToolCall.test.tsx | 50 +++++++++- .../features/Tools/ProposePlanToolCall.tsx | 96 ++++++++++++++++--- src/node/services/workspaceService.test.ts | 34 +++++++ src/node/services/workspaceService.ts | 82 +++++++++++----- 6 files changed, 315 insertions(+), 45 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index 8a51988a32d..ade5e317679 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -8,7 +8,13 @@ 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 { + 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"; @@ -460,6 +466,57 @@ describe("AgentContext", () => { }); }); + test("failed persistence restores the pre-switch model settings with the agent", 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")); + window.localStorage.setItem(getModelKey(workspaceId), JSON.stringify("openai:exec-model")); + window.localStorage.setItem(getThinkingLevelKey(workspaceId), JSON.stringify("high")); + 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(contextValue?.agentId).toBe("plan"); + }); + + // WorkspaceModeAISync reacts to the optimistic switch by applying the + // target agent's settings before the backend write settles. + window.localStorage.setItem(getModelKey(workspaceId), JSON.stringify("openai:plan-model")); + window.localStorage.setItem(getThinkingLevelKey(workspaceId), JSON.stringify("off")); + + await waitFor(() => { + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); + resolveUpdateAgentAISettings?.({ success: false, error: "offline" }); + + // Rollback restores the previous agent AND its resolved settings, so the + // sync effect's fallback cannot leave exec on plan's just-applied model. + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); + }); + expect(window.localStorage.getItem(getModelKey(workspaceId))).toBe( + JSON.stringify("openai:exec-model") + ); + expect(window.localStorage.getItem(getThinkingLevelKey(workspaceId))).toBe( + JSON.stringify("high") + ); + }); + 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 63747daed35..b7e6ea4615d 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -13,15 +13,25 @@ import { import { useAPI } from "@/browser/contexts/API"; import { useWorkspaceMetadata } from "@/browser/contexts/WorkspaceContext"; -import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { + readPersistedState, + updatePersistedState, + usePersistedState, +} from "@/browser/hooks/usePersistedState"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { getAgentIdKey, + getModelKey, getProjectScopeId, getDisableWorkspaceAgentsKey, + getReasoningModeKey, + getThinkingLevelKey, GLOBAL_SCOPE_ID, } from "@/common/constants/storage"; +import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; +import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; +import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import { sortAgentsStable } from "@/browser/utils/agents"; import { normalizeAgentId, resolveRemovedBuiltinAgentId } from "@/common/utils/agentIds"; @@ -151,14 +161,37 @@ function AgentProviderWithState(props: { const nextAgentId: string = next; const previousAgentId: string | null = previous; + // Snapshot the resolved settings before WorkspaceModeAISync reacts to + // the optimistic switch: it replaces model/thinking/reasoning for the + // target agent, and on rollback its fallback for a previous agent with + // no bucket or configured defaults would be the target agent's newly + // applied settings rather than these. + 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"); + // Optimistic local update above; on persistence failure roll the local // selection back (unless it changed again meanwhile) so this client // cannot silently diverge from the backend-authoritative agent. const rollback = () => { clearPendingWorkspaceAgentId(workspaceId, nextAgentId); - if (previousAgentId != null) { - setAgentIdRaw((current) => (current === nextAgentId ? previousAgentId : current)); + if (previousAgentId == null) { + return; + } + // scopeId === workspaceId here: persistence only runs workspace-scoped. + const current = readPersistedState(getAgentIdKey(workspaceId), null); + if (current !== nextAgentId) { + return; } + // Restore settings before the agent id so the sync effect's rollback + // run reads them as the existing (fallback) values. + setWorkspaceModelWithOrigin(workspaceId, previousModel, "sync"); + updatePersistedState(thinkingKey, previousThinking); + updatePersistedState(reasoningKey, previousReasoning); + setAgentIdRaw(previousAgentId); }; markPendingWorkspaceAgentId(workspaceId, nextAgentId); diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index a842517eacf..9277ffa8081 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -260,6 +260,7 @@ function createMockApi( getPlanContent?: MockApi["workspace"]["getPlanContent"]; replaceChatHistory?: MockApi["workspace"]["replaceChatHistory"]; sendMessage?: MockApi["workspace"]["sendMessage"]; + updateAgentAISettings?: MockApi["workspace"]["updateAgentAISettings"]; } = {} ): MockApi { return { @@ -278,7 +279,9 @@ function createMockApi( overrides.sendMessage ?? (() => Promise.resolve({ success: true, data: undefined })), updateAgentAISettings: (args) => { updateAgentAISettingsCalls.push(args); - return Promise.resolve({ success: true }); + return overrides.updateAgentAISettings + ? overrides.updateAgentAISettings(args) + : Promise.resolve({ success: true }); }, }, }; @@ -580,6 +583,51 @@ describe("ProposePlanToolCall", () => { ); // No explicit persistence for a switch whose send never went through. expect(updateAgentAISettingsCalls).toHaveLength(0); + // The optimistic switch rolls back to the pre-transition state. + 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" + ); + }); + + test("rolls back the optimistic switch when explicit selection persistence fails", async () => { + startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); + + const sendMessageCalls: SendMessageArgs[] = []; + mockApi = createMockApi({ + sendMessage: recordSendMessage(sendMessageCalls), + // Both the send-side best-effort write and this authoritative retry can + // fail (e.g. unwritable config); the returned Result must be honored. + updateAgentAISettings: () => Promise.resolve({ success: false, error: "unwritable config" }), + }); + + const view = renderCompletedPlan(); + + fireEvent.click(view.getByRole("button", { name: "Implement" })); + + await waitFor(() => expect(sendMessageCalls.length).toBe(1)); + await waitFor(() => expect(updateAgentAISettingsCalls).toHaveLength(1)); + + // The backend kept the previous agent, so the local switch (and the + // target-agent settings applied with it) must roll back to plan. + 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" + ); + // Guard released after the persistence attempt settles. + await waitFor(() => + expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) + ); }); test("uses workspace-by-agent override for Implement when exec defaults inherit", async () => { diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index 12029c1b8aa..97a2720d786 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -137,6 +137,32 @@ function isLegacyProposePlanArgs(args: unknown): args is LegacyProposePlanToolAr return args !== null && typeof args === "object" && "title" in args && "plan" in args; } +interface TargetAgentSwitchSnapshot { + previousAgentId: string; + model: string; + thinkingLevel: ThinkingLevel; + reasoningMode: OpenAIReasoningMode; +} + +// A failed plan→exec/auto transition (send failure, or rejected/failed +// selection persistence) leaves the backend on the previous agent. Restore +// the optimistic local switch — settings first, then the agent id — unless +// the user already moved on to another agent meanwhile. +function rollbackTargetAgentSwitch( + workspaceId: string, + targetAgentId: string, + snapshot: TargetAgentSwitchSnapshot +): void { + const agentKey = getAgentIdKey(workspaceId); + if (readPersistedState(agentKey, null) !== targetAgentId) { + return; + } + setWorkspaceModelWithOrigin(workspaceId, snapshot.model, "sync"); + updatePersistedState(getThinkingLevelKey(workspaceId), snapshot.thinkingLevel); + updatePersistedState(getReasoningModeKey(workspaceId), snapshot.reasoningMode); + updatePersistedState(agentKey, snapshot.previousAgentId); +} + interface ProposePlanToolCallProps { args: Record; result?: unknown; @@ -479,7 +505,11 @@ export const ProposePlanToolCall: React.FC = (props) = const resolveAndPersistTargetAgentSettings = (args: { workspaceId: string; targetAgentId: "auto" | "exec"; - }): { resolvedModel: string; resolvedThinking: ThinkingLevel } => { + }): { + resolvedModel: string; + resolvedThinking: ThinkingLevel; + snapshot: TargetAgentSwitchSnapshot; + } => { const modelKey = getModelKey(args.workspaceId); const thinkingKey = getThinkingLevelKey(args.workspaceId); const reasoningKey = getReasoningModeKey(args.workspaceId); @@ -509,6 +539,10 @@ export const ProposePlanToolCall: React.FC = (props) = agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])), }); + // propose_plan renders in plan mode, so an unset key means plan. + const previousAgentId = + readPersistedState(getAgentIdKey(args.workspaceId), null) ?? "plan"; + // 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); @@ -525,7 +559,16 @@ export const ProposePlanToolCall: React.FC = (props) = updatePersistedState(reasoningKey, resolvedReasoningMode); } - return { resolvedModel, resolvedThinking }; + return { + resolvedModel, + resolvedThinking, + snapshot: { + previousAgentId, + model: existingModel, + thinkingLevel: existingThinking, + reasoningMode: existingReasoning, + }, + }; }; const handleImplement = async () => { @@ -537,6 +580,8 @@ export const ProposePlanToolCall: React.FC = (props) = setIsImplementing(true); } + const targetAgentId = "exec"; + let switchSnapshot: TargetAgentSwitchSnapshot | null = null; try { let shouldReplaceChatHistory = false; @@ -555,11 +600,11 @@ export const ProposePlanToolCall: React.FC = (props) = }); } - const targetAgentId = "exec"; - const { resolvedModel, resolvedThinking } = resolveAndPersistTargetAgentSettings({ + const { resolvedModel, resolvedThinking, snapshot } = resolveAndPersistTargetAgentSettings({ workspaceId, targetAgentId, }); + switchSnapshot = snapshot; const sendMessageOptions = getSendOptionsFromStorage(workspaceId); const sendResult = await api.workspace.sendMessage({ @@ -577,25 +622,36 @@ export const ProposePlanToolCall: React.FC = (props) = // settings persistence is best-effort (failures only log). Persist the // selection explicitly, then release the guard either way — backend // echoes carry the authoritative agent, and a successful no-op write - // emits no echo to release it for us. + // emits no echo to release it for us. A failed or rejected persistence + // leaves the backend on the previous agent, so restore the optimistic + // local switch instead of silently diverging. try { - await api.workspace.updateAgentAISettings({ + const persistResult = await api.workspace.updateAgentAISettings({ workspaceId, agentId: targetAgentId, aiSettings: null, persistSelectedAgentId: true, }); + if (!persistResult.success) { + rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); + } + } catch { + rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); } finally { clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } } else { - // Failed send: nothing will echo, and a stuck guard would block - // backend agent seeds indefinitely. + // Failed send: the switch never reached the backend, nothing will + // echo, and a stuck guard would block backend agent seeds indefinitely. + rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } } catch { // Best-effort: user can retry manually if sending fails. - clearPendingWorkspaceAgentId(workspaceId, "exec"); + if (switchSnapshot != null) { + rollbackTargetAgentSwitch(workspaceId, targetAgentId, switchSnapshot); + } + clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } finally { isImplementingRef.current = false; if (isMountedRef.current) { @@ -612,6 +668,8 @@ export const ProposePlanToolCall: React.FC = (props) = setIsContinuingInAuto(true); } + const targetAgentId = "auto"; + let switchSnapshot: TargetAgentSwitchSnapshot | null = null; try { let shouldReplaceChatHistory = false; @@ -630,11 +688,11 @@ export const ProposePlanToolCall: React.FC = (props) = }); } - const targetAgentId = "auto"; - const { resolvedModel, resolvedThinking } = resolveAndPersistTargetAgentSettings({ + const { resolvedModel, resolvedThinking, snapshot } = resolveAndPersistTargetAgentSettings({ workspaceId, targetAgentId, }); + switchSnapshot = snapshot; const sendMessageOptions = getSendOptionsFromStorage(workspaceId); const sendResult = await api.workspace.sendMessage({ @@ -648,24 +706,34 @@ export const ProposePlanToolCall: React.FC = (props) = }, }); // See handleImplement: persist the selection explicitly (send-side - // persistence is best-effort), then release the guard. + // persistence is best-effort), roll the optimistic switch back when the + // transition fails, then release the guard. if (sendResult.success) { try { - await api.workspace.updateAgentAISettings({ + const persistResult = await api.workspace.updateAgentAISettings({ workspaceId, agentId: targetAgentId, aiSettings: null, persistSelectedAgentId: true, }); + if (!persistResult.success) { + rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); + } + } catch { + rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); } finally { clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } } else { + rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } } catch { // Best-effort: user can retry manually if sending fails. - clearPendingWorkspaceAgentId(workspaceId, "auto"); + if (switchSnapshot != null) { + rollbackTargetAgentSwitch(workspaceId, targetAgentId, switchSnapshot); + } + clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } finally { isContinuingInAutoRef.current = false; if (isMountedRef.current) { diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 7f56ff9ef5a..42eb8dfcc08 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(), @@ -9362,6 +9363,39 @@ describe("WorkspaceService maybePersistAISettingsFromOptions", () => { }); }); + 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("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({ diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 7365916ed9e..f127c4dc069 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -9702,7 +9702,8 @@ export class WorkspaceService extends EventEmitter { // Agent-only switches (null aiSettings) still redirect backend // dispatches to the target agent, so gate every dispatch surface's // fully resolved model: goal continuations remap plan/compact to - // exec, while heartbeats resolve the persisted agent as-is. + // exec, while heartbeats resolve the persisted agent as-is and add + // the activity snapshot's last-used model as a fallback layer. const gatedModels: string[] = []; if (normalizedSettings != null) { gatedModels.push(normalizedSettings.model); @@ -9714,13 +9715,9 @@ export class WorkspaceService extends EventEmitter { if (kickoff?.model != null) { gatedModels.push(kickoff.model); } - const heartbeat = await this.resolveContinuationKickoffSendOptionsForAgent( - workspaceId, - agentId, - { remapUiModes: false } - ); - if (heartbeat?.model != null && heartbeat.model !== kickoff?.model) { - gatedModels.push(heartbeat.model); + const heartbeat = await this.resolveHeartbeatAiSettings(workspaceId, agentId); + if (heartbeat.resolved.selected.model !== kickoff?.model) { + gatedModels.push(heartbeat.resolved.selected.model); } } const providersConfig = @@ -13481,14 +13478,13 @@ export class WorkspaceService extends EventEmitter { * 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 the persisted agent WITHOUT the plan/compact→exec - * remap (buildHeartbeatSendOptions), so the gate probes that surface with - * `remapUiModes: false`. + * 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, - opts?: { remapUiModes?: boolean } + overrideAgentId: string | null ): Promise { const config = this.config.loadConfigOrDefault(); const workspaceMatch = this.config.findWorkspace(workspaceId); @@ -13509,8 +13505,7 @@ export class WorkspaceService extends EventEmitter { WORKSPACE_DEFAULTS.agentId ); const agentId = - (opts?.remapUiModes ?? true) && - (persistedAgentId === "plan" || persistedAgentId === "compact") + persistedAgentId === "plan" || persistedAgentId === "compact" ? WORKSPACE_DEFAULTS.agentId : persistedAgentId; const selectedAgentSettings = workspaceEntry?.aiSettingsByAgent?.[agentId]; @@ -14066,12 +14061,20 @@ 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 + ): Promise<{ + agentId: string; + resolved: Awaited>; }> { const config = this.config.loadConfigOrDefault(); const workspaceMatch = this.config.findWorkspace(workspaceId); @@ -14088,13 +14091,15 @@ export class WorkspaceService extends EventEmitter { const activity = await this.extensionMetadata.getSnapshot(workspaceId); - const rawAgentId = workspaceEntry?.agentId; - const agentId = normalizeAgentId(rawAgentId, WORKSPACE_DEFAULTS.agentId); + const agentId = normalizeAgentId( + overrideAgentId ?? workspaceEntry?.agentId, + WORKSPACE_DEFAULTS.agentId + ); const agentSettings = 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", @@ -14123,6 +14128,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, From c53bc5bc8ae4b6d00e879e85e35d8358e4847ea7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:50:43 +0000 Subject: [PATCH 07/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20restore=20backend?= =?UTF-8?q?=20agent=20on=20failed=20sends,=20persist=20ACP=20mode=20switch?= =?UTF-8?q?es,=20surface=20switch=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/contexts/AgentContext.test.tsx | 37 ++++++++++++------- src/browser/contexts/AgentContext.tsx | 19 +++++++++- src/browser/features/ChatInput/index.tsx | 19 ++++++++++ .../Tools/ProposePlanToolCall.test.tsx | 11 +++++- .../features/Tools/ProposePlanToolCall.tsx | 35 +++++++++++++++--- src/common/constants/events.ts | 11 ++++++ src/node/acp/configOptions.ts | 21 ++++++++++- tests/ipc/acp.configOptions.test.ts | 31 +++++++++++++++- 8 files changed, 158 insertions(+), 26 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index ade5e317679..098949ce58c 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -448,22 +448,33 @@ describe("AgentContext", () => { expect(contextValue?.agentId).toBe("exec"); }); - contextValue?.setAgentId("plan"); + 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); - // Optimistic switch happens immediately... - await waitFor(() => { - expect(contextValue?.agentId).toBe("plan"); - }); - await waitFor(() => { - expect(resolveUpdateAgentAISettings).not.toBeNull(); - }); + try { + contextValue?.setAgentId("plan"); - // ...then the backend rejects the write and the selection rolls back. - resolveUpdateAgentAISettings?.({ success: false, error: "offline" }); + // Optimistic switch happens immediately... + await waitFor(() => { + expect(contextValue?.agentId).toBe("plan"); + }); + await waitFor(() => { + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); - await waitFor(() => { - expect(contextValue?.agentId).toBe("exec"); - }); + // ...then the backend rejects the write and the selection rolls back. + resolveUpdateAgentAISettings?.({ success: false, error: "offline" }); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); + }); + // The rejection surfaces to the user instead of silently snapping back. + expect(toasts).toEqual([{ workspaceId, message: "offline" }]); + } finally { + window.removeEventListener(CUSTOM_EVENTS.AGENT_SWITCH_ERROR_TOAST, toastListener); + } }); test("failed persistence restores the pre-switch model settings with the agent", async () => { diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx index b7e6ea4615d..5718e24184f 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -32,6 +32,7 @@ import { import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; 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"; @@ -194,6 +195,18 @@ function AgentProviderWithState(props: { setAgentIdRaw(previousAgentId); }; + // The picker closes on selection, so a rejected switch would otherwise + // just snap back with no explanation (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.`, + }) + ); + }; + markPendingWorkspaceAgentId(workspaceId, nextAgentId); api.workspace .updateAgentAISettings({ @@ -211,9 +224,13 @@ function AgentProviderWithState(props: { clearPendingWorkspaceAgentId(workspaceId, nextAgentId); return; } + notifySwitchRejected(typeof result.error === "string" ? result.error : ""); rollback(); }) - .catch(rollback); + .catch((error) => { + notifySwitchRejected(getErrorMessage(error)); + rollback(); + }); }, [api, globalDefaultAgentId, isCurrentAgentLocked, isProjectScope, setAgentIdRaw, workspaceId] ); diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index ce73cf4c36f..7da7af63f9e 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -2177,6 +2177,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) => { diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index 9277ffa8081..77c7eb0560c 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -581,8 +581,15 @@ describe("ProposePlanToolCall", () => { await waitFor(() => expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) ); - // No explicit persistence for a switch whose send never went through. - expect(updateAgentAISettingsCalls).toHaveLength(0); + // The send persists the target agent pre-dispatch, so the failed send + // restores the prior selection backend-side instead of persisting exec. + await waitFor(() => expect(updateAgentAISettingsCalls).toHaveLength(1)); + expect(updateAgentAISettingsCalls[0]).toEqual({ + workspaceId: WORKSPACE_ID, + agentId: "plan", + aiSettings: null, + persistSelectedAgentId: true, + }); // The optimistic switch rolls back to the pre-transition state. await waitFor(() => expect(JSON.parse(window.localStorage.getItem(getAgentIdKey(WORKSPACE_ID))!)).toBe("plan") diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index 97a2720d786..8ba16f36477 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -571,6 +571,29 @@ export const ProposePlanToolCall: React.FC = (props) = }; }; + // A send can fail after WorkspaceService already persisted the target agent + // pre-dispatch (admission/startup failures), leaving the backend on the + // target while nothing echoes. Restore the prior selection backend-side too + // (best-effort — the authoritative metadata echo reconciles clients if this + // write fails) before rolling the local switch back. + const rollbackFailedSendAgentSwitch = async ( + targetAgentId: string, + snapshot: TargetAgentSwitchSnapshot + ) => { + if (!workspaceId || !api) return; + try { + await api.workspace.updateAgentAISettings({ + workspaceId, + agentId: snapshot.previousAgentId, + aiSettings: null, + persistSelectedAgentId: true, + }); + } catch { + // Best-effort restore only. + } + rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); + }; + const handleImplement = async () => { if (!workspaceId || !api) return; if (isImplementingRef.current) return; @@ -641,15 +664,15 @@ export const ProposePlanToolCall: React.FC = (props) = clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } } else { - // Failed send: the switch never reached the backend, nothing will - // echo, and a stuck guard would block backend agent seeds indefinitely. - rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); + // Failed send: nothing will echo and a stuck guard would block + // backend agent seeds indefinitely. + await rollbackFailedSendAgentSwitch(targetAgentId, snapshot); clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } } catch { // Best-effort: user can retry manually if sending fails. if (switchSnapshot != null) { - rollbackTargetAgentSwitch(workspaceId, targetAgentId, switchSnapshot); + await rollbackFailedSendAgentSwitch(targetAgentId, switchSnapshot); } clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } finally { @@ -725,13 +748,13 @@ export const ProposePlanToolCall: React.FC = (props) = clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } } else { - rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); + await rollbackFailedSendAgentSwitch(targetAgentId, snapshot); clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } } catch { // Best-effort: user can retry manually if sending fails. if (switchSnapshot != null) { - rollbackTargetAgentSwitch(workspaceId, targetAgentId, switchSnapshot); + await rollbackFailedSendAgentSwitch(targetAgentId, switchSnapshot); } clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } finally { 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/node/acp/configOptions.ts b/src/node/acp/configOptions.ts index 698bc6a3ec4..a7de0d0b2da 100644 --- a/src/node/acp/configOptions.ts +++ b/src/node/acp/configOptions.ts @@ -243,8 +243,23 @@ 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 client.workspace.updateAgentAISettings({ + workspaceId, + agentId, + aiSettings, + persistSelectedAgentId: true, + }); + ensureUpdateSucceeded(updateResult, "workspace.updateAgentAISettings"); + return; + } + if (isModeAgentId(agentId)) { const updateModeResult = await client.workspace.updateModeAISettings({ workspaceId, @@ -382,7 +397,9 @@ export async function handleSetConfigOption( : {}), }; - await persistAgentAiSettings(client, trimmedWorkspaceId, nextAgentId, normalizedAiSettings); + await persistAgentAiSettings(client, trimmedWorkspaceId, nextAgentId, normalizedAiSettings, { + persistSelectedAgentId: true, + }); if (args?.onAgentModeChanged != null) { await args.onAgentModeChanged(nextAgentId, normalizedAiSettings); } diff --git a/tests/ipc/acp.configOptions.test.ts b/tests/ipc/acp.configOptions.test.ts index baa1cb71fed..4d7e06febb9 100644 --- a/tests/ipc/acp.configOptions.test.ts +++ b/tests/ipc/acp.configOptions.test.ts @@ -66,6 +66,7 @@ function createHarness( workspaceId: string; agentId: string; aiSettings: WorkspaceAiSettings; + persistSelectedAgentId?: boolean; }>; } { let workspaceState: WorkspaceState = { @@ -83,6 +84,7 @@ function createHarness( workspaceId: string; agentId: string; aiSettings: WorkspaceAiSettings; + persistSelectedAgentId?: boolean; }> = []; const availableAgents = options?.agents ?? DEFAULT_AGENT_DESCRIPTORS; @@ -124,6 +126,7 @@ function createHarness( workspaceId: string; agentId: string; aiSettings: WorkspaceAiSettings; + persistSelectedAgentId?: boolean; }) => { updateAgentCalls.push(input); @@ -263,8 +266,32 @@ describe("ACP config options", () => { activeAgentId: "plan", }); - expect(harness.updateModeCalls).toHaveLength(1); - expect(harness.updateModeCalls[0]?.aiSettings.reasoningMode).toBe("pro"); + // 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("preserves pro reasoning mode across model and thinking level changes", async () => { From 9793b2969bd7086ad730be5c04569fb92f9272e3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:15:32 +0000 Subject: [PATCH 08/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20guard=20stale=20pos?= =?UTF-8?q?t-send=20agent=20writes,=20gate=20ACP=20mode-switch=20dispatch,?= =?UTF-8?q?=20keep=20child=20ACP=20modes=20session-local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tools/ProposePlanToolCall.test.tsx | 56 ++++++++++++ .../features/Tools/ProposePlanToolCall.tsx | 84 +++++++++--------- src/node/acp/configOptions.ts | 7 +- src/node/services/workspaceService.test.ts | 87 +++++++++++++++++++ src/node/services/workspaceService.ts | 56 +++++++++--- tests/ipc/acp.configOptions.test.ts | 30 +++++++ 6 files changed, 265 insertions(+), 55 deletions(-) diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index 77c7eb0560c..e8c2f1b2075 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -602,6 +602,62 @@ describe("ProposePlanToolCall", () => { ); }); + test("keeps a newer agent pick when the Implement send fails", async () => { + startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); + + const sendMessageCalls: SendMessageArgs[] = []; + mockApi = createMockApi({ + sendMessage: (args) => { + sendMessageCalls.push(args); + // The user picks another agent while the send is in flight; the + // failure compensation must not clobber that newer choice. + updatePersistedState(getAgentIdKey(WORKSPACE_ID), "reviewer"); + 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)); + // Guard release still happens for this action's target. + await waitFor(() => + expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) + ); + // No compensating backend restore and no local rollback: the newer pick wins. + expect(updateAgentAISettingsCalls).toHaveLength(0); + expect(JSON.parse(window.localStorage.getItem(getAgentIdKey(WORKSPACE_ID))!)).toBe("reviewer"); + }); + + test("skips explicit selection persistence when the user switched agents mid-send", async () => { + startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); + + const sendMessageCalls: SendMessageArgs[] = []; + mockApi = createMockApi({ + sendMessage: (args) => { + sendMessageCalls.push(args); + // A newer pick lands while the send is in flight: persisting this + // action's exec target afterwards would overwrite it on the backend + // and echo the UI back to exec. + updatePersistedState(getAgentIdKey(WORKSPACE_ID), "reviewer"); + return Promise.resolve({ success: true as const, data: undefined }); + }, + }); + + const view = renderCompletedPlan(); + + fireEvent.click(view.getByRole("button", { name: "Implement" })); + + await waitFor(() => expect(sendMessageCalls.length).toBe(1)); + // Guard released without any write for this action's target. + await waitFor(() => + expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) + ); + expect(updateAgentAISettingsCalls).toHaveLength(0); + expect(JSON.parse(window.localStorage.getItem(getAgentIdKey(WORKSPACE_ID))!)).toBe("reviewer"); + }); + test("rolls back the optimistic switch when explicit selection persistence fails", async () => { startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index 8ba16f36477..33f709165cc 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -581,6 +581,11 @@ export const ProposePlanToolCall: React.FC = (props) = snapshot: TargetAgentSwitchSnapshot ) => { if (!workspaceId || !api) return; + // A newer pick made while the send was in flight is authoritative: + // restoring the pre-send agent (locally or backend-side) would clobber it. + if (readPersistedState(getAgentIdKey(workspaceId), null) !== targetAgentId) { + return; + } try { await api.workspace.updateAgentAISettings({ workspaceId, @@ -594,6 +599,37 @@ export const ProposePlanToolCall: React.FC = (props) = rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); }; + // A successful send does not guarantee the switch is durable: its settings + // persistence is best-effort (failures only log). Persist the selection + // explicitly — unless the user already picked another agent mid-send, in + // which case that newer write is authoritative and persisting this action's + // target would overwrite it (and its metadata echo would flip the UI back). + // A failed or rejected persistence leaves the backend on the previous + // agent, so restore the optimistic local switch instead of silently + // diverging. + const persistTargetAgentSwitchAfterSend = async ( + targetAgentId: string, + snapshot: TargetAgentSwitchSnapshot + ) => { + if (!workspaceId || !api) return; + if (readPersistedState(getAgentIdKey(workspaceId), null) !== targetAgentId) { + return; + } + try { + const persistResult = await api.workspace.updateAgentAISettings({ + workspaceId, + agentId: targetAgentId, + aiSettings: null, + persistSelectedAgentId: true, + }); + if (!persistResult.success) { + rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); + } + } catch { + rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); + } + }; + const handleImplement = async () => { if (!workspaceId || !api) return; if (isImplementingRef.current) return; @@ -641,28 +677,11 @@ export const ProposePlanToolCall: React.FC = (props) = }, }); if (sendResult.success) { - // A successful send does not guarantee the switch is durable: its - // settings persistence is best-effort (failures only log). Persist the - // selection explicitly, then release the guard either way — backend - // echoes carry the authoritative agent, and a successful no-op write - // emits no echo to release it for us. A failed or rejected persistence - // leaves the backend on the previous agent, so restore the optimistic - // local switch instead of silently diverging. - try { - const persistResult = await api.workspace.updateAgentAISettings({ - workspaceId, - agentId: targetAgentId, - aiSettings: null, - persistSelectedAgentId: true, - }); - if (!persistResult.success) { - rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); - } - } catch { - rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); - } finally { - clearPendingWorkspaceAgentId(workspaceId, targetAgentId); - } + // Release the guard either way — backend echoes carry the + // authoritative agent, and a successful no-op write emits no echo to + // release it for us. + await persistTargetAgentSwitchAfterSend(targetAgentId, snapshot); + clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } else { // Failed send: nothing will echo and a stuck guard would block // backend agent seeds indefinitely. @@ -728,25 +747,10 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: resolvedThinking, }, }); - // See handleImplement: persist the selection explicitly (send-side - // persistence is best-effort), roll the optimistic switch back when the - // transition fails, then release the guard. + // See handleImplement: persist explicitly, then release the guard. if (sendResult.success) { - try { - const persistResult = await api.workspace.updateAgentAISettings({ - workspaceId, - agentId: targetAgentId, - aiSettings: null, - persistSelectedAgentId: true, - }); - if (!persistResult.success) { - rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); - } - } catch { - rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); - } finally { - clearPendingWorkspaceAgentId(workspaceId, targetAgentId); - } + await persistTargetAgentSwitchAfterSend(targetAgentId, snapshot); + clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } else { await rollbackFailedSendAgentSwitch(targetAgentId, snapshot); clearPendingWorkspaceAgentId(workspaceId, targetAgentId); diff --git a/src/node/acp/configOptions.ts b/src/node/acp/configOptions.ts index a7de0d0b2da..6c8a29f3966 100644 --- a/src/node/acp/configOptions.ts +++ b/src/node/acp/configOptions.ts @@ -397,8 +397,13 @@ export async function handleSetConfigOption( : {}), }; + // 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: true, + persistSelectedAgentId: workspace.parentWorkspaceId == null, }); if (args?.onAgentModeChanged != null) { await args.onAgentModeChanged(nextAgentId, normalizedAiSettings); diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 42eb8dfcc08..4757537173f 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -9396,6 +9396,93 @@ describe("WorkspaceService maybePersistAISettingsFromOptions", () => { }); }); + 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({ diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index f127c4dc069..36676bfbaf9 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -9699,24 +9699,40 @@ export class WorkspaceService extends EventEmitter { // model in the meantime silently records 0 cost on the next stream // and budget enforcement quietly stops working. if (hasBudgetedResumableGoal(goal)) { - // Agent-only switches (null aiSettings) still redirect backend - // dispatches to the target agent, so gate every dispatch surface's - // fully resolved model: goal continuations remap plan/compact to - // exec, while heartbeats resolve the persisted agent as-is and add - // the activity snapshot's last-used model as a fallback layer. + // 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); - } else if (options?.persistSelectedAgentId === true) { + } + if (options?.persistSelectedAgentId === true) { + const pendingBucket = + normalizedSettings != null + ? { + agentId: normalizeAgentId(agentId, WORKSPACE_DEFAULTS.agentId), + settings: normalizedSettings, + } + : null; const kickoff = await this.resolveContinuationKickoffSendOptionsForAgent( workspaceId, - agentId + agentId, + pendingBucket ); - if (kickoff?.model != null) { + if (kickoff?.model != null && !gatedModels.includes(kickoff.model)) { gatedModels.push(kickoff.model); } - const heartbeat = await this.resolveHeartbeatAiSettings(workspaceId, agentId); - if (heartbeat.resolved.selected.model !== kickoff?.model) { + const heartbeat = await this.resolveHeartbeatAiSettings( + workspaceId, + agentId, + pendingBucket + ); + if (!gatedModels.includes(heartbeat.resolved.selected.model)) { gatedModels.push(heartbeat.resolved.selected.model); } } @@ -13484,7 +13500,10 @@ export class WorkspaceService extends EventEmitter { */ private async resolveContinuationKickoffSendOptionsForAgent( workspaceId: string, - overrideAgentId: string | null + 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); @@ -13508,7 +13527,10 @@ export class WorkspaceService extends EventEmitter { 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 @@ -14071,7 +14093,10 @@ export class WorkspaceService extends EventEmitter { */ private async resolveHeartbeatAiSettings( workspaceId: string, - overrideAgentId: string | null + 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>; @@ -14095,7 +14120,10 @@ export class WorkspaceService extends EventEmitter { overrideAgentId ?? workspaceEntry?.agentId, WORKSPACE_DEFAULTS.agentId ); - const agentSettings = workspaceEntry?.aiSettingsByAgent?.[agentId]; + const agentSettings = + pendingBucket?.agentId === agentId + ? pendingBucket.settings + : workspaceEntry?.aiSettingsByAgent?.[agentId]; // Unified interactive resolution for the selected agent: its bucket, // configured/definition defaults and the declared base chain, then the diff --git a/tests/ipc/acp.configOptions.test.ts b/tests/ipc/acp.configOptions.test.ts index 4d7e06febb9..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; @@ -102,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; @@ -294,6 +298,32 @@ describe("ACP config options", () => { 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]?.mode).toBe("exec"); + expect(harness.updateAgentCalls).toHaveLength(0); + }); + it("preserves pro reasoning mode across model and thinking level changes", async () => { const harness = createHarness({ agentId: "exec", From 9f54ba0f678a6560629909a6bc2af5ea28d83e7b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:27:32 +0000 Subject: [PATCH 09/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20honor=20the=20faile?= =?UTF-8?q?d-send=20restore=20result=20before=20local=20rollback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tools/ProposePlanToolCall.test.tsx | 29 +++++++++++++++++++ .../features/Tools/ProposePlanToolCall.tsx | 14 +++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index e8c2f1b2075..42c887345b7 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -602,6 +602,35 @@ describe("ProposePlanToolCall", () => { ); }); + test("keeps the local switch when the failed-send backend restore is rejected", 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" }); + }, + // The restore itself is refused (e.g. the prior agent now fails the + // budgeted-goal pricing gate): the backend stays on exec with no echo + // coming, so the local switch must not roll back and silently diverge. + updateAgentAISettings: () => Promise.resolve({ success: false, error: "unpriced model" }), + }); + + const view = renderCompletedPlan(); + + fireEvent.click(view.getByRole("button", { name: "Implement" })); + + await waitFor(() => expect(sendMessageCalls.length).toBe(1)); + await waitFor(() => expect(updateAgentAISettingsCalls).toHaveLength(1)); + expect(updateAgentAISettingsCalls[0]?.agentId).toBe("plan"); + // Guard released; the local selection stays on the target the backend kept. + await waitFor(() => + expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) + ); + expect(JSON.parse(window.localStorage.getItem(getAgentIdKey(WORKSPACE_ID))!)).toBe("exec"); + }); + test("keeps a newer agent pick when the Implement send fails", async () => { startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index 33f709165cc..d86460ec73a 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -587,14 +587,24 @@ export const ProposePlanToolCall: React.FC = (props) = return; } try { - await api.workspace.updateAgentAISettings({ + const restoreResult = await api.workspace.updateAgentAISettings({ workspaceId, agentId: snapshot.previousAgentId, aiSettings: null, persistSelectedAgentId: true, }); + if (!restoreResult.success) { + // The backend refused the restore (e.g. the prior agent now fails the + // budgeted-goal pricing gate) and stays on the target agent with no + // echo coming — keep the local switch so local and backend state stay + // converged. + return; + } } catch { - // Best-effort restore only. + // Transport failure: backend state is unknown and no echo is coming + // now. Keep the local switch; after the pending guard is released, the + // next metadata delivery re-seeds the authoritative backend agent. + return; } rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); }; From efae88b8dbfcc811889ffaf2cbe498247e3bcf29 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:44:27 +0000 Subject: [PATCH 10/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20persist=20resolved?= =?UTF-8?q?=20settings=20with=20picker=20switches,=20reconcile=20failed-se?= =?UTF-8?q?nd=20agent=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/contexts/AgentContext.test.tsx | 15 +++-- src/browser/contexts/AgentContext.tsx | 57 +++++++++++++++++-- .../Tools/ProposePlanToolCall.test.tsx | 41 ++++++++++++- .../features/Tools/ProposePlanToolCall.tsx | 29 ++++++---- 4 files changed, 120 insertions(+), 22 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index 098949ce58c..a86de33caa9 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -31,7 +31,7 @@ let mockWorkspaceMetadata = new Map = []; interface UpdateAgentAISettingsResult { @@ -419,9 +419,16 @@ describe("AgentContext", () => { await waitFor(() => { expect(contextValue?.agentId).toBe("plan"); }); - expect(updateAgentAISettingsCalls).toEqual([ - { workspaceId, agentId: "plan", aiSettings: null, persistSelectedAgentId: true }, - ]); + 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"); diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx index 5718e24184f..a4b93a1ef27 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -21,16 +21,23 @@ import { 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 { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; +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"; @@ -140,6 +147,13 @@ function AgentProviderWithState(props: { 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 @@ -174,6 +188,29 @@ function AgentProviderWithState(props: { 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 { resolvedModel, resolvedThinking, resolvedReasoningMode } = + resolveWorkspaceAiSettingsForAgent({ + agentId: nextAgentId, + agentAiDefaults, + workspaceByAgent, + useWorkspaceByAgentFallback: true, + fallbackModel: getDefaultModel(), + existingModel: previousModel, + existingThinking: previousThinking, + existingReasoningMode: previousReasoning, + agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])), + }); + // Optimistic local update above; on persistence failure roll the local // selection back (unless it changed again meanwhile) so this client // cannot silently diverge from the backend-authoritative agent. @@ -212,7 +249,11 @@ function AgentProviderWithState(props: { .updateAgentAISettings({ workspaceId, agentId: nextAgentId, - aiSettings: null, + aiSettings: { + model: resolvedModel, + thinkingLevel: resolvedThinking, + ...(resolvedReasoningMode != null ? { reasoningMode: resolvedReasoningMode } : {}), + }, persistSelectedAgentId: true, }) .then((result) => { @@ -232,13 +273,17 @@ function AgentProviderWithState(props: { rollback(); }); }, - [api, globalDefaultAgentId, isCurrentAgentLocked, isProjectScope, setAgentIdRaw, workspaceId] + [ + agents, + api, + globalDefaultAgentId, + isCurrentAgentLocked, + isProjectScope, + setAgentIdRaw, + workspaceId, + ] ); - const [agents, setAgents] = useState([]); - const [loaded, setLoaded] = useState(false); - const [loadFailed, setLoadFailed] = useState(false); - const isMountedRef = useRef(true); useEffect(() => { diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index 42c887345b7..f5b461cbd84 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -69,6 +69,7 @@ interface MockApi { aiSettings: null; persistSelectedAgentId?: boolean; }) => Promise<{ success: boolean; error?: string }>; + getInfo: (args: { workspaceId: string }) => Promise<{ agentId?: string | null } | null>; }; } @@ -261,6 +262,7 @@ function createMockApi( replaceChatHistory?: MockApi["workspace"]["replaceChatHistory"]; sendMessage?: MockApi["workspace"]["sendMessage"]; updateAgentAISettings?: MockApi["workspace"]["updateAgentAISettings"]; + getInfo?: MockApi["workspace"]["getInfo"]; } = {} ): MockApi { return { @@ -283,6 +285,7 @@ function createMockApi( ? overrides.updateAgentAISettings(args) : Promise.resolve({ success: true }); }, + getInfo: overrides.getInfo ?? (() => Promise.resolve(null)), }, }; } @@ -612,9 +615,10 @@ describe("ProposePlanToolCall", () => { return Promise.resolve({ success: false as const, error: "send rejected" }); }, // The restore itself is refused (e.g. the prior agent now fails the - // budgeted-goal pricing gate): the backend stays on exec with no echo - // coming, so the local switch must not roll back and silently diverge. + // budgeted-goal pricing gate) while the backend holds the target exec: + // the local switch must not roll back and silently diverge. updateAgentAISettings: () => Promise.resolve({ success: false, error: "unpriced model" }), + getInfo: () => Promise.resolve({ agentId: "exec" }), }); const view = renderCompletedPlan(); @@ -631,6 +635,39 @@ describe("ProposePlanToolCall", () => { expect(JSON.parse(window.localStorage.getItem(getAgentIdKey(WORKSPACE_ID))!)).toBe("exec"); }); + test("rolls back when the rejected restore reveals the target was never persisted", 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" }); + }, + // Restore refused while the backend still holds plan (the send failed + // before persisting exec): local state must reconcile back to plan + // instead of keeping an exec the backend never had. + updateAgentAISettings: () => Promise.resolve({ success: false, error: "unwritable config" }), + getInfo: () => Promise.resolve({ agentId: "plan" }), + }); + + const view = renderCompletedPlan(); + + fireEvent.click(view.getByRole("button", { name: "Implement" })); + + await waitFor(() => expect(sendMessageCalls.length).toBe(1)); + await waitFor(() => expect(updateAgentAISettingsCalls).toHaveLength(1)); + 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" + ); + await waitFor(() => + expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) + ); + }); + test("keeps a newer agent pick when the Implement send fails", async () => { startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index d86460ec73a..32b2637e0e8 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -572,10 +572,11 @@ export const ProposePlanToolCall: React.FC = (props) = }; // A send can fail after WorkspaceService already persisted the target agent - // pre-dispatch (admission/startup failures), leaving the backend on the - // target while nothing echoes. Restore the prior selection backend-side too - // (best-effort — the authoritative metadata echo reconciles clients if this - // write fails) before rolling the local switch back. + // pre-dispatch (admission/startup failures) — or before it did (send-time + // pricing gate, best-effort settings write) — while nothing echoes. Restore + // the prior selection backend-side; when that restore is refused, reconcile + // with the authoritative backend agent instead of guessing which side the + // failed send left the backend on. const rollbackFailedSendAgentSwitch = async ( targetAgentId: string, snapshot: TargetAgentSwitchSnapshot @@ -593,11 +594,8 @@ export const ProposePlanToolCall: React.FC = (props) = aiSettings: null, persistSelectedAgentId: true, }); - if (!restoreResult.success) { - // The backend refused the restore (e.g. the prior agent now fails the - // budgeted-goal pricing gate) and stays on the target agent with no - // echo coming — keep the local switch so local and backend state stay - // converged. + if (restoreResult.success) { + rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); return; } } catch { @@ -606,7 +604,18 @@ export const ProposePlanToolCall: React.FC = (props) = // next metadata delivery re-seeds the authoritative backend agent. return; } - rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); + // The backend refused the restore (e.g. the prior agent now fails the + // budgeted-goal pricing gate). Only roll back if the backend is actually + // elsewhere than the target — keeping local state converged with whichever + // agent the failed send left persisted. + try { + const info = await api.workspace.getInfo({ workspaceId }); + if (info?.agentId != null && info.agentId !== targetAgentId) { + rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); + } + } catch { + // Keep the local switch; the next metadata delivery reconciles. + } }; // A successful send does not guarantee the switch is durable: its settings From ddc7902cab3bbcc169607ebebe8b45557cff998e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:59:39 +0000 Subject: [PATCH 11/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20resolve=20legacy=20?= =?UTF-8?q?agent=20identity,=20retry=20settings=20with=20selection,=20snap?= =?UTF-8?q?shot=20effective=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tools/ProposePlanToolCall.test.tsx | 74 +++++++++++++++++-- .../features/Tools/ProposePlanToolCall.tsx | 70 ++++++++++++------ 2 files changed, 117 insertions(+), 27 deletions(-) diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index f5b461cbd84..ad2a3ee4c32 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -66,17 +66,19 @@ interface MockApi { updateAgentAISettings: (args: { workspaceId: string; agentId: string; - aiSettings: null; + aiSettings: { model: string; thinkingLevel: string; reasoningMode?: string } | null; persistSelectedAgentId?: boolean; }) => Promise<{ success: boolean; error?: string }>; - getInfo: (args: { workspaceId: string }) => Promise<{ agentId?: string | null } | null>; + getInfo: (args: { + workspaceId: string; + }) => Promise<{ agentId?: string | null; agentType?: string | null } | null>; }; } let updateAgentAISettingsCalls: Array<{ workspaceId: string; agentId: string; - aiSettings: null; + aiSettings: { model: string; thinkingLevel: string; reasoningMode?: string } | null; persistSelectedAgentId?: boolean; }> = []; @@ -550,10 +552,12 @@ describe("ProposePlanToolCall", () => { // persistence is best-effort) and then releases the guard so backend // agent updates apply again. await waitFor(() => expect(updateAgentAISettingsCalls).toHaveLength(1)); - expect(updateAgentAISettingsCalls[0]).toEqual({ + // The retry re-persists the resolved settings with the selection so a + // transiently failed send-side write cannot leave the bucket stale. + expect(updateAgentAISettingsCalls[0]).toMatchObject({ workspaceId: WORKSPACE_ID, agentId: "exec", - aiSettings: null, + aiSettings: { model: execModel, thinkingLevel: execThinking }, persistSelectedAgentId: true, }); await waitFor(() => @@ -668,6 +672,66 @@ describe("ProposePlanToolCall", () => { ); }); + test("reconciles using legacy agentType when the rejected restore leaves plan persisted", 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" }); + }, + updateAgentAISettings: () => Promise.resolve({ success: false, error: "unwritable config" }), + // Legacy workspaces may persist only agentType — reconciliation must + // resolve it like metadata seeding does instead of reading only agentId. + getInfo: () => Promise.resolve({ agentType: "plan" }), + }); + + const view = renderCompletedPlan(); + + fireEvent.click(view.getByRole("button", { name: "Implement" })); + + await waitFor(() => expect(sendMessageCalls.length).toBe(1)); + await waitFor(() => expect(updateAgentAISettingsCalls).toHaveLength(1)); + 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" + ); + }); + + test("restores the effective exec default when no agent key was persisted", async () => { + // No persisted agent key: the workspace's effective agent is the + // provider default (exec), so a failed transition must restore exec — + // rolling back to plan would switch the workspace to a mode it never had. + const sendMessageCalls: SendMessageArgs[] = []; + mockApi = createMockApi({ + sendMessage: (args) => { + sendMessageCalls.push(args); + return Promise.resolve({ success: false as const, error: "send rejected" }); + }, + }); + + const view = renderPlanToolCall( + { + status: "completed", + result: { success: true, planPath: PLAN_PATH, planContent: PLAN_CONTENT }, + isLatest: true, + }, + "exec" + ); + + fireEvent.click(view.getByRole("button", { name: "Implement" })); + + await waitFor(() => expect(sendMessageCalls.length).toBe(1)); + await waitFor(() => expect(updateAgentAISettingsCalls).toHaveLength(1)); + expect(updateAgentAISettingsCalls[0]?.agentId).toBe("exec"); + await waitFor(() => + expect(JSON.parse(window.localStorage.getItem(getAgentIdKey(WORKSPACE_ID))!)).toBe("exec") + ); + }); + test("keeps a newer agent pick when the Implement send fails", async () => { startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index 32b2637e0e8..c5c6b28db8e 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -57,6 +57,7 @@ import { clearPendingWorkspaceAgentId, markPendingWorkspaceAgentId, } from "@/browser/utils/workspaceAiSettingsSync"; +import { resolvePersistedAgentId } from "@/common/utils/agentIds"; import { resolveWorkspaceAiSettingsForAgent, type WorkspaceAISettingsCache, @@ -508,6 +509,11 @@ export const ProposePlanToolCall: React.FC = (props) = }): { resolvedModel: string; resolvedThinking: ThinkingLevel; + resolvedSettings: { + model: string; + thinkingLevel: ThinkingLevel; + reasoningMode?: OpenAIReasoningMode; + }; snapshot: TargetAgentSwitchSnapshot; } => { const modelKey = getModelKey(args.workspaceId); @@ -539,9 +545,12 @@ export const ProposePlanToolCall: React.FC = (props) = agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])), }); - // propose_plan renders in plan mode, so an unset key means plan. + // An unset key resolves to the provider's effective agent (workspace + // default exec), not plan: a latest historical propose_plan card can + // still offer Implement while no agent key was ever persisted, and a + // failure rollback must not switch the workspace to plan. const previousAgentId = - readPersistedState(getAgentIdKey(args.workspaceId), null) ?? "plan"; + 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. @@ -562,6 +571,11 @@ export const ProposePlanToolCall: React.FC = (props) = return { resolvedModel, resolvedThinking, + resolvedSettings: { + model: resolvedModel, + thinkingLevel: resolvedThinking, + ...(resolvedReasoningMode != null ? { reasoningMode: resolvedReasoningMode } : {}), + }, snapshot: { previousAgentId, model: existingModel, @@ -610,7 +624,10 @@ export const ProposePlanToolCall: React.FC = (props) = // agent the failed send left persisted. try { const info = await api.workspace.getInfo({ workspaceId }); - if (info?.agentId != null && info.agentId !== targetAgentId) { + // Legacy workspaces may persist only agentType — resolve both identity + // fields the same way metadata seeding does. + const backendAgentId = info == null ? "" : resolvePersistedAgentId(info, ""); + if (backendAgentId.length > 0 && backendAgentId !== targetAgentId) { rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); } } catch { @@ -619,16 +636,23 @@ export const ProposePlanToolCall: React.FC = (props) = }; // A successful send does not guarantee the switch is durable: its settings - // persistence is best-effort (failures only log). Persist the selection - // explicitly — unless the user already picked another agent mid-send, in - // which case that newer write is authoritative and persisting this action's - // target would overwrite it (and its metadata echo would flip the UI back). - // A failed or rejected persistence leaves the backend on the previous - // agent, so restore the optimistic local switch instead of silently - // diverging. + // persistence is best-effort (failures only log). Retry the resolved + // settings together with the selection — an agent-only retry would leave + // the target bucket stale after a transient send-side write failure while + // this renderer already switched to the resolved settings. Skip when the + // user already picked another agent mid-send: that newer write is + // authoritative and persisting this action's target would overwrite it + // (and its metadata echo would flip the UI back). A failed or rejected + // persistence leaves the backend on the previous agent, so restore the + // optimistic local switch instead of silently diverging. const persistTargetAgentSwitchAfterSend = async ( targetAgentId: string, - snapshot: TargetAgentSwitchSnapshot + snapshot: TargetAgentSwitchSnapshot, + resolvedSettings: { + model: string; + thinkingLevel: ThinkingLevel; + reasoningMode?: OpenAIReasoningMode; + } ) => { if (!workspaceId || !api) return; if (readPersistedState(getAgentIdKey(workspaceId), null) !== targetAgentId) { @@ -638,7 +662,7 @@ export const ProposePlanToolCall: React.FC = (props) = const persistResult = await api.workspace.updateAgentAISettings({ workspaceId, agentId: targetAgentId, - aiSettings: null, + aiSettings: resolvedSettings, persistSelectedAgentId: true, }); if (!persistResult.success) { @@ -678,10 +702,11 @@ export const ProposePlanToolCall: React.FC = (props) = }); } - const { resolvedModel, resolvedThinking, snapshot } = resolveAndPersistTargetAgentSettings({ - workspaceId, - targetAgentId, - }); + const { resolvedModel, resolvedThinking, resolvedSettings, snapshot } = + resolveAndPersistTargetAgentSettings({ + workspaceId, + targetAgentId, + }); switchSnapshot = snapshot; const sendMessageOptions = getSendOptionsFromStorage(workspaceId); @@ -699,7 +724,7 @@ export const ProposePlanToolCall: React.FC = (props) = // Release the guard either way — backend echoes carry the // authoritative agent, and a successful no-op write emits no echo to // release it for us. - await persistTargetAgentSwitchAfterSend(targetAgentId, snapshot); + await persistTargetAgentSwitchAfterSend(targetAgentId, snapshot, resolvedSettings); clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } else { // Failed send: nothing will echo and a stuck guard would block @@ -749,10 +774,11 @@ export const ProposePlanToolCall: React.FC = (props) = }); } - const { resolvedModel, resolvedThinking, snapshot } = resolveAndPersistTargetAgentSettings({ - workspaceId, - targetAgentId, - }); + const { resolvedModel, resolvedThinking, resolvedSettings, snapshot } = + resolveAndPersistTargetAgentSettings({ + workspaceId, + targetAgentId, + }); switchSnapshot = snapshot; const sendMessageOptions = getSendOptionsFromStorage(workspaceId); @@ -768,7 +794,7 @@ export const ProposePlanToolCall: React.FC = (props) = }); // See handleImplement: persist explicitly, then release the guard. if (sendResult.success) { - await persistTargetAgentSwitchAfterSend(targetAgentId, snapshot); + await persistTargetAgentSwitchAfterSend(targetAgentId, snapshot, resolvedSettings); clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } else { await rollbackFailedSendAgentSwitch(targetAgentId, snapshot); From 6e1c8c8fb2ecdc5dca3c9814a04c416e967d1f52 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:20:04 +0000 Subject: [PATCH 12/36] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20drop=20client-?= =?UTF-8?q?side=20agent-switch=20rollback=20and=20reconcile=20compensation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner design decision: agent/model selection is optimistic local state with best-effort persistence. Every send carries the selection and re-persists it backend-side (maybePersistAISettingsFromOptions), so failed or misordered writes self-heal on the next send. Remove the snapshot/rollback/reconcile machinery from AgentContext and ProposePlanToolCall accordingly; keep the optimistic switch, agent-default resolution, pending-echo guard, and the rejection toast. --- src/browser/contexts/AgentContext.test.tsx | 73 +---- src/browser/contexts/AgentContext.tsx | 62 ++--- .../Tools/ProposePlanToolCall.test.tsx | 257 +----------------- .../features/Tools/ProposePlanToolCall.tsx | 210 ++------------ 4 files changed, 60 insertions(+), 542 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index a86de33caa9..25d3ec1f3db 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -8,13 +8,8 @@ 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, - getModelKey, - getProjectScopeId, - getThinkingLevelKey, -} from "@/common/constants/storage"; +import { shouldApplyWorkspaceAgentIdFromBackend } from "@/browser/utils/workspaceAiSettingsSync"; +import { GLOBAL_SCOPE_ID, getAgentIdKey, getProjectScopeId } from "@/common/constants/storage"; import { requireTestModule } from "@/browser/testUtils"; import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; @@ -435,7 +430,7 @@ describe("AgentContext", () => { expect(updateAgentAISettingsCalls).toHaveLength(1); }); - test("failed persistence rolls back the local agent selection", async () => { + test("failed persistence keeps the local agent selection", async () => { const projectPath = "/tmp/project"; const workspaceId = "main-workspace"; mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT]; @@ -471,70 +466,22 @@ describe("AgentContext", () => { expect(resolveUpdateAgentAISettings).not.toBeNull(); }); - // ...then the backend rejects the write and the selection rolls back. + // ...and a rejected write keeps it: persistence is best-effort (the + // next send re-persists the selection), so only a toast surfaces. resolveUpdateAgentAISettings?.({ success: false, error: "offline" }); await waitFor(() => { - expect(contextValue?.agentId).toBe("exec"); + expect(toasts).toEqual([{ workspaceId, message: "offline" }]); }); - // The rejection surfaces to the user instead of silently snapping back. - expect(toasts).toEqual([{ workspaceId, message: "offline" }]); + expect(contextValue?.agentId).toBe("plan"); + // 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("failed persistence restores the pre-switch model settings with the agent", 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")); - window.localStorage.setItem(getModelKey(workspaceId), JSON.stringify("openai:exec-model")); - window.localStorage.setItem(getThinkingLevelKey(workspaceId), JSON.stringify("high")); - 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(contextValue?.agentId).toBe("plan"); - }); - - // WorkspaceModeAISync reacts to the optimistic switch by applying the - // target agent's settings before the backend write settles. - window.localStorage.setItem(getModelKey(workspaceId), JSON.stringify("openai:plan-model")); - window.localStorage.setItem(getThinkingLevelKey(workspaceId), JSON.stringify("off")); - - await waitFor(() => { - expect(resolveUpdateAgentAISettings).not.toBeNull(); - }); - resolveUpdateAgentAISettings?.({ success: false, error: "offline" }); - - // Rollback restores the previous agent AND its resolved settings, so the - // sync effect's fallback cannot leave exec on plan's just-applied model. - await waitFor(() => { - expect(contextValue?.agentId).toBe("exec"); - }); - expect(window.localStorage.getItem(getModelKey(workspaceId))).toBe( - JSON.stringify("openai:exec-model") - ); - expect(window.localStorage.getItem(getThinkingLevelKey(workspaceId))).toBe( - JSON.stringify("high") - ); - }); - 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 a4b93a1ef27..8c5bd236edd 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -13,11 +13,7 @@ import { import { useAPI } from "@/browser/contexts/API"; import { useWorkspaceMetadata } from "@/browser/contexts/WorkspaceContext"; -import { - readPersistedState, - updatePersistedState, - usePersistedState, -} from "@/browser/hooks/usePersistedState"; +import { readPersistedState, usePersistedState } from "@/browser/hooks/usePersistedState"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { @@ -32,7 +28,6 @@ import { GLOBAL_SCOPE_ID, } from "@/common/constants/storage"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; -import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; import { resolveWorkspaceAiSettingsForAgent, type WorkspaceAISettingsCache, @@ -174,13 +169,10 @@ function AgentProviderWithState(props: { return; } const nextAgentId: string = next; - const previousAgentId: string | null = previous; - // Snapshot the resolved settings before WorkspaceModeAISync reacts to - // the optimistic switch: it replaces model/thinking/reasoning for the - // target agent, and on rollback its fallback for a previous agent with - // no bucket or configured defaults would be the target agent's newly - // applied settings rather than these. + // 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); @@ -211,29 +203,14 @@ function AgentProviderWithState(props: { agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])), }); - // Optimistic local update above; on persistence failure roll the local - // selection back (unless it changed again meanwhile) so this client - // cannot silently diverge from the backend-authoritative agent. - const rollback = () => { - clearPendingWorkspaceAgentId(workspaceId, nextAgentId); - if (previousAgentId == null) { - return; - } - // scopeId === workspaceId here: persistence only runs workspace-scoped. - const current = readPersistedState(getAgentIdKey(workspaceId), null); - if (current !== nextAgentId) { - return; - } - // Restore settings before the agent id so the sync effect's rollback - // run reads them as the existing (fallback) values. - setWorkspaceModelWithOrigin(workspaceId, previousModel, "sync"); - updatePersistedState(thinkingKey, previousThinking); - updatePersistedState(reasoningKey, previousReasoning); - setAgentIdRaw(previousAgentId); - }; + // 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 failed or + // rejected write self-heals on the next send instead of triggering a + // local rollback. // The picker closes on selection, so a rejected switch would otherwise - // just snap back with no explanation (e.g. budgeted-goal pricing gate). + // be silent (e.g. budgeted-goal pricing gate). const notifySwitchRejected = (message: string) => { window.dispatchEvent( createCustomEvent(CUSTOM_EVENTS.AGENT_SWITCH_ERROR_TOAST, { @@ -257,20 +234,19 @@ function AgentProviderWithState(props: { persistSelectedAgentId: true, }) .then((result) => { - if (result.success) { - // A no-op write (backend already on this agent) emits no metadata - // echo, so release the guard deterministically. For changed writes - // the echo is ordered after any stale broadcast, so releasing on - // the response cannot strand a stale value. - clearPendingWorkspaceAgentId(workspaceId, nextAgentId); - return; + if (!result.success) { + notifySwitchRejected(typeof result.error === "string" ? result.error : ""); } - notifySwitchRejected(typeof result.error === "string" ? result.error : ""); - rollback(); + // Release the guard on every settled write: no-op writes (backend + // already on this agent) and failed writes emit no metadata echo, + // and a stuck guard would block future backend agent seeds. For + // changed writes the echo is ordered after any stale broadcast, so + // releasing on the response cannot strand a stale value. + clearPendingWorkspaceAgentId(workspaceId, nextAgentId); }) .catch((error) => { notifySwitchRejected(getErrorMessage(error)); - rollback(); + clearPendingWorkspaceAgentId(workspaceId, nextAgentId); }); }, [ diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index ad2a3ee4c32..b05ee0a8164 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -69,9 +69,6 @@ interface MockApi { aiSettings: { model: string; thinkingLevel: string; reasoningMode?: string } | null; persistSelectedAgentId?: boolean; }) => Promise<{ success: boolean; error?: string }>; - getInfo: (args: { - workspaceId: string; - }) => Promise<{ agentId?: string | null; agentType?: string | null } | null>; }; } @@ -264,7 +261,6 @@ function createMockApi( replaceChatHistory?: MockApi["workspace"]["replaceChatHistory"]; sendMessage?: MockApi["workspace"]["sendMessage"]; updateAgentAISettings?: MockApi["workspace"]["updateAgentAISettings"]; - getInfo?: MockApi["workspace"]["getInfo"]; } = {} ): MockApi { return { @@ -287,7 +283,6 @@ function createMockApi( ? overrides.updateAgentAISettings(args) : Promise.resolve({ success: true }); }, - getInfo: overrides.getInfo ?? (() => Promise.resolve(null)), }, }; } @@ -548,18 +543,11 @@ describe("ProposePlanToolCall", () => { expect(JSON.parse(window.localStorage.getItem(thinkingKey)!)).toBe(execThinking); } - // A successful send persists the selection explicitly (send-side - // persistence is best-effort) and then releases the guard so backend - // agent updates apply again. - await waitFor(() => expect(updateAgentAISettingsCalls).toHaveLength(1)); - // The retry re-persists the resolved settings with the selection so a - // transiently failed send-side write cannot leave the bucket stale. - expect(updateAgentAISettingsCalls[0]).toMatchObject({ - workspaceId: WORKSPACE_ID, - agentId: "exec", - aiSettings: { model: execModel, thinkingLevel: execThinking }, - persistSelectedAgentId: true, - }); + // 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) ); @@ -588,239 +576,10 @@ describe("ProposePlanToolCall", () => { await waitFor(() => expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) ); - // The send persists the target agent pre-dispatch, so the failed send - // restores the prior selection backend-side instead of persisting exec. - await waitFor(() => expect(updateAgentAISettingsCalls).toHaveLength(1)); - expect(updateAgentAISettingsCalls[0]).toEqual({ - workspaceId: WORKSPACE_ID, - agentId: "plan", - aiSettings: null, - persistSelectedAgentId: true, - }); - // The optimistic switch rolls back to the pre-transition state. - 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" - ); - }); - - test("keeps the local switch when the failed-send backend restore is rejected", 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" }); - }, - // The restore itself is refused (e.g. the prior agent now fails the - // budgeted-goal pricing gate) while the backend holds the target exec: - // the local switch must not roll back and silently diverge. - updateAgentAISettings: () => Promise.resolve({ success: false, error: "unpriced model" }), - getInfo: () => Promise.resolve({ agentId: "exec" }), - }); - - const view = renderCompletedPlan(); - - fireEvent.click(view.getByRole("button", { name: "Implement" })); - - await waitFor(() => expect(sendMessageCalls.length).toBe(1)); - await waitFor(() => expect(updateAgentAISettingsCalls).toHaveLength(1)); - expect(updateAgentAISettingsCalls[0]?.agentId).toBe("plan"); - // Guard released; the local selection stays on the target the backend kept. - await waitFor(() => - expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) - ); - expect(JSON.parse(window.localStorage.getItem(getAgentIdKey(WORKSPACE_ID))!)).toBe("exec"); - }); - - test("rolls back when the rejected restore reveals the target was never persisted", 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" }); - }, - // Restore refused while the backend still holds plan (the send failed - // before persisting exec): local state must reconcile back to plan - // instead of keeping an exec the backend never had. - updateAgentAISettings: () => Promise.resolve({ success: false, error: "unwritable config" }), - getInfo: () => Promise.resolve({ agentId: "plan" }), - }); - - const view = renderCompletedPlan(); - - fireEvent.click(view.getByRole("button", { name: "Implement" })); - - await waitFor(() => expect(sendMessageCalls.length).toBe(1)); - await waitFor(() => expect(updateAgentAISettingsCalls).toHaveLength(1)); - 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" - ); - await waitFor(() => - expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) - ); - }); - - test("reconciles using legacy agentType when the rejected restore leaves plan persisted", 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" }); - }, - updateAgentAISettings: () => Promise.resolve({ success: false, error: "unwritable config" }), - // Legacy workspaces may persist only agentType — reconciliation must - // resolve it like metadata seeding does instead of reading only agentId. - getInfo: () => Promise.resolve({ agentType: "plan" }), - }); - - const view = renderCompletedPlan(); - - fireEvent.click(view.getByRole("button", { name: "Implement" })); - - await waitFor(() => expect(sendMessageCalls.length).toBe(1)); - await waitFor(() => expect(updateAgentAISettingsCalls).toHaveLength(1)); - 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" - ); - }); - - test("restores the effective exec default when no agent key was persisted", async () => { - // No persisted agent key: the workspace's effective agent is the - // provider default (exec), so a failed transition must restore exec — - // rolling back to plan would switch the workspace to a mode it never had. - const sendMessageCalls: SendMessageArgs[] = []; - mockApi = createMockApi({ - sendMessage: (args) => { - sendMessageCalls.push(args); - return Promise.resolve({ success: false as const, error: "send rejected" }); - }, - }); - - const view = renderPlanToolCall( - { - status: "completed", - result: { success: true, planPath: PLAN_PATH, planContent: PLAN_CONTENT }, - isLatest: true, - }, - "exec" - ); - - fireEvent.click(view.getByRole("button", { name: "Implement" })); - - await waitFor(() => expect(sendMessageCalls.length).toBe(1)); - await waitFor(() => expect(updateAgentAISettingsCalls).toHaveLength(1)); - expect(updateAgentAISettingsCalls[0]?.agentId).toBe("exec"); - await waitFor(() => - expect(JSON.parse(window.localStorage.getItem(getAgentIdKey(WORKSPACE_ID))!)).toBe("exec") - ); - }); - - test("keeps a newer agent pick when the Implement send fails", async () => { - startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); - - const sendMessageCalls: SendMessageArgs[] = []; - mockApi = createMockApi({ - sendMessage: (args) => { - sendMessageCalls.push(args); - // The user picks another agent while the send is in flight; the - // failure compensation must not clobber that newer choice. - updatePersistedState(getAgentIdKey(WORKSPACE_ID), "reviewer"); - 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)); - // Guard release still happens for this action's target. - await waitFor(() => - expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) - ); - // No compensating backend restore and no local rollback: the newer pick wins. - expect(updateAgentAISettingsCalls).toHaveLength(0); - expect(JSON.parse(window.localStorage.getItem(getAgentIdKey(WORKSPACE_ID))!)).toBe("reviewer"); - }); - - test("skips explicit selection persistence when the user switched agents mid-send", async () => { - startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); - - const sendMessageCalls: SendMessageArgs[] = []; - mockApi = createMockApi({ - sendMessage: (args) => { - sendMessageCalls.push(args); - // A newer pick lands while the send is in flight: persisting this - // action's exec target afterwards would overwrite it on the backend - // and echo the UI back to exec. - updatePersistedState(getAgentIdKey(WORKSPACE_ID), "reviewer"); - return Promise.resolve({ success: true as const, data: undefined }); - }, - }); - - const view = renderCompletedPlan(); - - fireEvent.click(view.getByRole("button", { name: "Implement" })); - - await waitFor(() => expect(sendMessageCalls.length).toBe(1)); - // Guard released without any write for this action's target. - await waitFor(() => - expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) - ); + // No compensating backend write and no local rollback: the switch stays + // and the next send re-persists it. expect(updateAgentAISettingsCalls).toHaveLength(0); - expect(JSON.parse(window.localStorage.getItem(getAgentIdKey(WORKSPACE_ID))!)).toBe("reviewer"); - }); - - test("rolls back the optimistic switch when explicit selection persistence fails", async () => { - startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); - - const sendMessageCalls: SendMessageArgs[] = []; - mockApi = createMockApi({ - sendMessage: recordSendMessage(sendMessageCalls), - // Both the send-side best-effort write and this authoritative retry can - // fail (e.g. unwritable config); the returned Result must be honored. - updateAgentAISettings: () => Promise.resolve({ success: false, error: "unwritable config" }), - }); - - const view = renderCompletedPlan(); - - fireEvent.click(view.getByRole("button", { name: "Implement" })); - - await waitFor(() => expect(sendMessageCalls.length).toBe(1)); - await waitFor(() => expect(updateAgentAISettingsCalls).toHaveLength(1)); - - // The backend kept the previous agent, so the local switch (and the - // target-agent settings applied with it) must roll back to plan. - 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" - ); - // Guard released after the persistence attempt settles. - await waitFor(() => - expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true) - ); + expect(JSON.parse(window.localStorage.getItem(getAgentIdKey(WORKSPACE_ID))!)).toBe("exec"); }); test("uses workspace-by-agent override for Implement when exec defaults inherit", async () => { diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index c5c6b28db8e..4061f92bc78 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -57,7 +57,6 @@ import { clearPendingWorkspaceAgentId, markPendingWorkspaceAgentId, } from "@/browser/utils/workspaceAiSettingsSync"; -import { resolvePersistedAgentId } from "@/common/utils/agentIds"; import { resolveWorkspaceAiSettingsForAgent, type WorkspaceAISettingsCache, @@ -138,32 +137,6 @@ function isLegacyProposePlanArgs(args: unknown): args is LegacyProposePlanToolAr return args !== null && typeof args === "object" && "title" in args && "plan" in args; } -interface TargetAgentSwitchSnapshot { - previousAgentId: string; - model: string; - thinkingLevel: ThinkingLevel; - reasoningMode: OpenAIReasoningMode; -} - -// A failed plan→exec/auto transition (send failure, or rejected/failed -// selection persistence) leaves the backend on the previous agent. Restore -// the optimistic local switch — settings first, then the agent id — unless -// the user already moved on to another agent meanwhile. -function rollbackTargetAgentSwitch( - workspaceId: string, - targetAgentId: string, - snapshot: TargetAgentSwitchSnapshot -): void { - const agentKey = getAgentIdKey(workspaceId); - if (readPersistedState(agentKey, null) !== targetAgentId) { - return; - } - setWorkspaceModelWithOrigin(workspaceId, snapshot.model, "sync"); - updatePersistedState(getThinkingLevelKey(workspaceId), snapshot.thinkingLevel); - updatePersistedState(getReasoningModeKey(workspaceId), snapshot.reasoningMode); - updatePersistedState(agentKey, snapshot.previousAgentId); -} - interface ProposePlanToolCallProps { args: Record; result?: unknown; @@ -509,12 +482,6 @@ export const ProposePlanToolCall: React.FC = (props) = }): { resolvedModel: string; resolvedThinking: ThinkingLevel; - resolvedSettings: { - model: string; - thinkingLevel: ThinkingLevel; - reasoningMode?: OpenAIReasoningMode; - }; - snapshot: TargetAgentSwitchSnapshot; } => { const modelKey = getModelKey(args.workspaceId); const thinkingKey = getThinkingLevelKey(args.workspaceId); @@ -545,13 +512,6 @@ export const ProposePlanToolCall: React.FC = (props) = agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])), }); - // An unset key resolves to the provider's effective agent (workspace - // default exec), not plan: a latest historical propose_plan card can - // still offer Implement while no agent key was ever persisted, and a - // failure rollback must not switch the workspace to plan. - 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); @@ -568,109 +528,7 @@ export const ProposePlanToolCall: React.FC = (props) = updatePersistedState(reasoningKey, resolvedReasoningMode); } - return { - resolvedModel, - resolvedThinking, - resolvedSettings: { - model: resolvedModel, - thinkingLevel: resolvedThinking, - ...(resolvedReasoningMode != null ? { reasoningMode: resolvedReasoningMode } : {}), - }, - snapshot: { - previousAgentId, - model: existingModel, - thinkingLevel: existingThinking, - reasoningMode: existingReasoning, - }, - }; - }; - - // A send can fail after WorkspaceService already persisted the target agent - // pre-dispatch (admission/startup failures) — or before it did (send-time - // pricing gate, best-effort settings write) — while nothing echoes. Restore - // the prior selection backend-side; when that restore is refused, reconcile - // with the authoritative backend agent instead of guessing which side the - // failed send left the backend on. - const rollbackFailedSendAgentSwitch = async ( - targetAgentId: string, - snapshot: TargetAgentSwitchSnapshot - ) => { - if (!workspaceId || !api) return; - // A newer pick made while the send was in flight is authoritative: - // restoring the pre-send agent (locally or backend-side) would clobber it. - if (readPersistedState(getAgentIdKey(workspaceId), null) !== targetAgentId) { - return; - } - try { - const restoreResult = await api.workspace.updateAgentAISettings({ - workspaceId, - agentId: snapshot.previousAgentId, - aiSettings: null, - persistSelectedAgentId: true, - }); - if (restoreResult.success) { - rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); - return; - } - } catch { - // Transport failure: backend state is unknown and no echo is coming - // now. Keep the local switch; after the pending guard is released, the - // next metadata delivery re-seeds the authoritative backend agent. - return; - } - // The backend refused the restore (e.g. the prior agent now fails the - // budgeted-goal pricing gate). Only roll back if the backend is actually - // elsewhere than the target — keeping local state converged with whichever - // agent the failed send left persisted. - try { - const info = await api.workspace.getInfo({ workspaceId }); - // Legacy workspaces may persist only agentType — resolve both identity - // fields the same way metadata seeding does. - const backendAgentId = info == null ? "" : resolvePersistedAgentId(info, ""); - if (backendAgentId.length > 0 && backendAgentId !== targetAgentId) { - rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); - } - } catch { - // Keep the local switch; the next metadata delivery reconciles. - } - }; - - // A successful send does not guarantee the switch is durable: its settings - // persistence is best-effort (failures only log). Retry the resolved - // settings together with the selection — an agent-only retry would leave - // the target bucket stale after a transient send-side write failure while - // this renderer already switched to the resolved settings. Skip when the - // user already picked another agent mid-send: that newer write is - // authoritative and persisting this action's target would overwrite it - // (and its metadata echo would flip the UI back). A failed or rejected - // persistence leaves the backend on the previous agent, so restore the - // optimistic local switch instead of silently diverging. - const persistTargetAgentSwitchAfterSend = async ( - targetAgentId: string, - snapshot: TargetAgentSwitchSnapshot, - resolvedSettings: { - model: string; - thinkingLevel: ThinkingLevel; - reasoningMode?: OpenAIReasoningMode; - } - ) => { - if (!workspaceId || !api) return; - if (readPersistedState(getAgentIdKey(workspaceId), null) !== targetAgentId) { - return; - } - try { - const persistResult = await api.workspace.updateAgentAISettings({ - workspaceId, - agentId: targetAgentId, - aiSettings: resolvedSettings, - persistSelectedAgentId: true, - }); - if (!persistResult.success) { - rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); - } - } catch { - rollbackTargetAgentSwitch(workspaceId, targetAgentId, snapshot); - } + return { resolvedModel, resolvedThinking }; }; const handleImplement = async () => { @@ -683,7 +541,6 @@ export const ProposePlanToolCall: React.FC = (props) = } const targetAgentId = "exec"; - let switchSnapshot: TargetAgentSwitchSnapshot | null = null; try { let shouldReplaceChatHistory = false; @@ -702,15 +559,17 @@ export const ProposePlanToolCall: React.FC = (props) = }); } - const { resolvedModel, resolvedThinking, resolvedSettings, snapshot } = - resolveAndPersistTargetAgentSettings({ - workspaceId, - targetAgentId, - }); - switchSnapshot = snapshot; + const { resolvedModel, resolvedThinking } = resolveAndPersistTargetAgentSettings({ + workspaceId, + targetAgentId, + }); const sendMessageOptions = getSendOptionsFromStorage(workspaceId); - const sendResult = await api.workspace.sendMessage({ + // The send carries the switch and persists it backend-side best-effort + // (maybePersistAISettingsFromOptions). A failed send keeps the local + // switch (the next send re-persists it), so there is no client-side + // rollback or reconciliation. + await api.workspace.sendMessage({ workspaceId, message: "Implement the plan", options: { @@ -720,25 +579,13 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: resolvedThinking, }, }); - if (sendResult.success) { - // Release the guard either way — backend echoes carry the - // authoritative agent, and a successful no-op write emits no echo to - // release it for us. - await persistTargetAgentSwitchAfterSend(targetAgentId, snapshot, resolvedSettings); - clearPendingWorkspaceAgentId(workspaceId, targetAgentId); - } else { - // Failed send: nothing will echo and a stuck guard would block - // backend agent seeds indefinitely. - await rollbackFailedSendAgentSwitch(targetAgentId, snapshot); - clearPendingWorkspaceAgentId(workspaceId, targetAgentId); - } } catch { // Best-effort: user can retry manually if sending fails. - if (switchSnapshot != null) { - await rollbackFailedSendAgentSwitch(targetAgentId, switchSnapshot); - } - clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } 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); @@ -755,7 +602,6 @@ export const ProposePlanToolCall: React.FC = (props) = } const targetAgentId = "auto"; - let switchSnapshot: TargetAgentSwitchSnapshot | null = null; try { let shouldReplaceChatHistory = false; @@ -774,15 +620,15 @@ export const ProposePlanToolCall: React.FC = (props) = }); } - const { resolvedModel, resolvedThinking, resolvedSettings, snapshot } = - resolveAndPersistTargetAgentSettings({ - workspaceId, - targetAgentId, - }); - switchSnapshot = snapshot; + const { resolvedModel, resolvedThinking } = resolveAndPersistTargetAgentSettings({ + workspaceId, + targetAgentId, + }); const sendMessageOptions = getSendOptionsFromStorage(workspaceId); - const sendResult = await api.workspace.sendMessage({ + // See handleImplement: the send persists the switch best-effort; no + // client-side rollback. + await api.workspace.sendMessage({ workspaceId, message: "Implement the plan", options: { @@ -792,21 +638,11 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: resolvedThinking, }, }); - // See handleImplement: persist explicitly, then release the guard. - if (sendResult.success) { - await persistTargetAgentSwitchAfterSend(targetAgentId, snapshot, resolvedSettings); - clearPendingWorkspaceAgentId(workspaceId, targetAgentId); - } else { - await rollbackFailedSendAgentSwitch(targetAgentId, snapshot); - clearPendingWorkspaceAgentId(workspaceId, targetAgentId); - } } catch { // Best-effort: user can retry manually if sending fails. - if (switchSnapshot != null) { - await rollbackFailedSendAgentSwitch(targetAgentId, switchSnapshot); - } - clearPendingWorkspaceAgentId(workspaceId, targetAgentId); } finally { + // See handleImplement: release the echo guard on every outcome. + clearPendingWorkspaceAgentId(workspaceId, targetAgentId); isContinuingInAutoRef.current = false; if (isMountedRef.current) { setIsContinuingInAuto(false); From d318f2fa8df891120f4137919d7e4fda754583c5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:16:19 +0000 Subject: [PATCH 13/36] revert rejected agent switches locally; hydrate legacy shared settings for custom agents --- src/browser/contexts/AgentContext.test.tsx | 68 +++++++++++++++++-- src/browser/contexts/AgentContext.tsx | 55 +++++++++++++-- .../contexts/WorkspaceContext.test.tsx | 36 ++++++++++ src/browser/contexts/WorkspaceContext.tsx | 20 ++++-- 4 files changed, 164 insertions(+), 15 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index 25d3ec1f3db..07b3e8fd313 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -430,7 +430,7 @@ describe("AgentContext", () => { expect(updateAgentAISettingsCalls).toHaveLength(1); }); - test("failed persistence keeps the local agent selection", async () => { + test("rejected persistence reverts the local agent selection", async () => { const projectPath = "/tmp/project"; const workspaceId = "main-workspace"; mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT]; @@ -466,14 +466,18 @@ describe("AgentContext", () => { expect(resolveUpdateAgentAISettings).not.toBeNull(); }); - // ...and a rejected write keeps it: persistence is best-effort (the - // next send re-persists the selection), so only a toast surfaces. - resolveUpdateAgentAISettings?.({ success: false, error: "offline" }); + // ...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: "offline" }]); + expect(toasts).toEqual([{ workspaceId, message: "unpriced model" }]); + }); + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); }); - expect(contextValue?.agentId).toBe("plan"); // 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); @@ -482,6 +486,58 @@ describe("AgentContext", () => { } }); + 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"); + } finally { + window.removeEventListener(CUSTOM_EVENTS.AGENT_SWITCH_ERROR_TOAST, toastListener); + } + }); + 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 8c5bd236edd..0571eb7e68d 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -13,7 +13,11 @@ import { import { useAPI } from "@/browser/contexts/API"; import { useWorkspaceMetadata } from "@/browser/contexts/WorkspaceContext"; -import { readPersistedState, usePersistedState } from "@/browser/hooks/usePersistedState"; +import { + readPersistedState, + updatePersistedState, + usePersistedState, +} from "@/browser/hooks/usePersistedState"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { @@ -28,6 +32,7 @@ import { GLOBAL_SCOPE_ID, } from "@/common/constants/storage"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; +import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; import { resolveWorkspaceAiSettingsForAgent, type WorkspaceAISettingsCache, @@ -165,10 +170,18 @@ function AgentProviderWithState(props: { // 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 || next === previous) { + 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 @@ -205,9 +218,12 @@ function AgentProviderWithState(props: { // 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 failed or - // rejected write self-heals on the next send instead of triggering a - // local rollback. + // 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 pre-switch selection instead. // The picker closes on selection, so a rejected switch would otherwise // be silent (e.g. budgeted-goal pricing gate). @@ -221,6 +237,34 @@ function AgentProviderWithState(props: { ); }; + // Undo exactly what this switch wrote, and only while it is still in + // effect: any agent/model/thinking change the user made after the + // optimistic switch wins over the revert. + const revertRejectedSwitch = () => { + const currentAgentId = coerceAgentId( + readPersistedState(getAgentIdKey(workspaceId), null) + ); + if (currentAgentId !== nextAgentId) { + return; + } + // Restore settings before the agent id so WorkspaceModeAISync + // re-resolves the previous agent from pre-switch values instead of + // carrying over the rejected ones. + if (readPersistedState(modelKey, getDefaultModel()) === resolvedModel) { + setWorkspaceModelWithOrigin(workspaceId, previousModel, "sync"); + } + if (readPersistedState(thinkingKey, "off") === resolvedThinking) { + updatePersistedState(thinkingKey, previousThinking); + } + if ( + readPersistedState(reasoningKey, "standard") === + resolvedReasoningMode + ) { + updatePersistedState(reasoningKey, previousReasoning); + } + setAgentIdRaw(previousAgentId); + }; + markPendingWorkspaceAgentId(workspaceId, nextAgentId); api.workspace .updateAgentAISettings({ @@ -236,6 +280,7 @@ function AgentProviderWithState(props: { .then((result) => { if (!result.success) { notifySwitchRejected(typeof result.error === "string" ? result.error : ""); + revertRejectedSwitch(); } // Release the guard on every settled write: no-op writes (backend // already on this agent) and failed writes emit no metadata echo, diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index 8b873b5cfd5..42881af3bdb 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -580,6 +580,42 @@ describe("WorkspaceContext", () => { ); }); + 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("stale metadata does not clobber a pending local agent switch", async () => { const workspaceId = "ws-agent-pending"; let emitMetadata: diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index c9df55fea96..d38bdf36b9d 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -198,12 +198,28 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat } } + // 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 aiByAgent = metadata.aiSettingsByAgent ?? (metadata.aiSettings ? { plan: metadata.aiSettings, exec: metadata.aiSettings, + [activeAgentId]: metadata.aiSettings, } : undefined); @@ -243,10 +259,6 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat } // Seed the active agent into the existing keys to avoid UI flash. - const activeAgentId = readPersistedState( - getAgentIdKey(workspaceId), - WORKSPACE_DEFAULTS.agentId - ); // 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), From dbf12e2c2a0a08a3e691cb7192bfa7497cdc4900 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:41:35 +0000 Subject: [PATCH 14/36] revert typed-rejected plan-action switches; reconcile chained rejections to backend agent --- src/browser/contexts/AgentContext.test.tsx | 52 +++++++++++ src/browser/contexts/AgentContext.tsx | 58 ++++++------- .../Tools/ProposePlanToolCall.test.tsx | 47 ++++++++-- .../features/Tools/ProposePlanToolCall.tsx | 66 ++++++++++---- src/browser/utils/workspaceAiSettingsSync.ts | 87 +++++++++++++++++++ 5 files changed, 256 insertions(+), 54 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index 07b3e8fd313..5cf959c0bdc 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -37,6 +37,13 @@ interface UpdateAgentAISettingsResult { 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; @@ -538,6 +545,51 @@ describe("AgentContext", () => { } }); + 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(resolveUpdateAgentAISettings).not.toBeNull(); + }); + const rejectReviewSwitch = getDeferredUpdateResolver(); + + // plan's rejection is skipped (a newer switch is active); 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" }); + rejectReviewSwitch?.({ 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 0571eb7e68d..884fffea925 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -13,11 +13,7 @@ import { import { useAPI } from "@/browser/contexts/API"; import { useWorkspaceMetadata } from "@/browser/contexts/WorkspaceContext"; -import { - readPersistedState, - updatePersistedState, - usePersistedState, -} from "@/browser/hooks/usePersistedState"; +import { readPersistedState, usePersistedState } from "@/browser/hooks/usePersistedState"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { @@ -32,7 +28,6 @@ import { GLOBAL_SCOPE_ID, } from "@/common/constants/storage"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; -import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; import { resolveWorkspaceAiSettingsForAgent, type WorkspaceAISettingsCache, @@ -46,6 +41,7 @@ import { normalizeAgentId, resolveRemovedBuiltinAgentId } from "@/common/utils/a import { clearPendingWorkspaceAgentId, markPendingWorkspaceAgentId, + revertRejectedAgentSwitch, } from "@/browser/utils/workspaceAiSettingsSync"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; @@ -146,6 +142,11 @@ function AgentProviderWithState(props: { // is locked, so local changes must never be written back. const isCurrentAgentLocked = currentMeta?.parentWorkspaceId != null; + // Authoritative restore baseline for rejected switches: with chained + // rejected switches, a captured "previous" can itself be a rejected agent, + // while the backend still stores the last accepted one. + const backendAgentId = currentMeta?.agentId ?? null; + const workspaceId = props.workspaceId; // Declared before setAgentId: switches resolve the target agent's settings @@ -223,7 +224,8 @@ function AgentProviderWithState(props: { // 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 pre-switch selection instead. + // 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). @@ -237,32 +239,23 @@ function AgentProviderWithState(props: { ); }; - // Undo exactly what this switch wrote, and only while it is still in - // effect: any agent/model/thinking change the user made after the - // optimistic switch wins over the revert. const revertRejectedSwitch = () => { - const currentAgentId = coerceAgentId( - readPersistedState(getAgentIdKey(workspaceId), null) - ); - if (currentAgentId !== nextAgentId) { - return; - } - // Restore settings before the agent id so WorkspaceModeAISync - // re-resolves the previous agent from pre-switch values instead of - // carrying over the rejected ones. - if (readPersistedState(modelKey, getDefaultModel()) === resolvedModel) { - setWorkspaceModelWithOrigin(workspaceId, previousModel, "sync"); - } - if (readPersistedState(thinkingKey, "off") === resolvedThinking) { - updatePersistedState(thinkingKey, previousThinking); - } - if ( - readPersistedState(reasoningKey, "standard") === - resolvedReasoningMode - ) { - updatePersistedState(reasoningKey, previousReasoning); - } - setAgentIdRaw(previousAgentId); + revertRejectedAgentSwitch({ + workspaceId, + rejectedAgentId: nextAgentId, + applied: { + model: resolvedModel, + thinkingLevel: resolvedThinking, + reasoningMode: resolvedReasoningMode, + }, + previous: { + agentId: previousAgentId, + model: previousModel, + thinkingLevel: previousThinking, + reasoningMode: previousReasoning, + }, + backendAgentId, + }); }; markPendingWorkspaceAgentId(workspaceId, nextAgentId); @@ -297,6 +290,7 @@ function AgentProviderWithState(props: { [ agents, api, + backendAgentId, globalDefaultAgentId, isCurrentAgentLocked, isProjectScope, diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index b05ee0a8164..a6362b34418 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -553,7 +553,7 @@ describe("ProposePlanToolCall", () => { ); }); - test("clears the pending agent guard when the Implement send fails", async () => { + test("typed rejection reverts the optimistic Implement switch", async () => { startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); const sendMessageCalls: SendMessageArgs[] = []; @@ -570,16 +570,51 @@ describe("ProposePlanToolCall", () => { await waitFor(() => expect(sendMessageCalls.length).toBe(1)); - // The failed send cannot persist the switch, so 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). + // 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 and no local rollback: the switch stays - // and the next send re-persists it. + // No compensating backend write. expect(updateAgentAISettingsCalls).toHaveLength(0); + }); + + 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 () => { diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index 4061f92bc78..6834f2b2df9 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -56,6 +56,7 @@ import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; import { clearPendingWorkspaceAgentId, markPendingWorkspaceAgentId, + revertRejectedAgentSwitch, } from "@/browser/utils/workspaceAiSettingsSync"; import { resolveWorkspaceAiSettingsForAgent, @@ -482,6 +483,8 @@ export const ProposePlanToolCall: React.FC = (props) = }): { resolvedModel: string; resolvedThinking: ThinkingLevel; + /** Undo this switch after a typed send rejection (transport failures keep it). */ + revertSelection: () => void; } => { const modelKey = getModelKey(args.workspaceId); const thinkingKey = getThinkingLevelKey(args.workspaceId); @@ -512,6 +515,9 @@ export const ProposePlanToolCall: React.FC = (props) = agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])), }); + 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); @@ -528,7 +534,26 @@ 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, + }, + }), + }; }; const handleImplement = async () => { @@ -559,17 +584,19 @@ export const ProposePlanToolCall: React.FC = (props) = }); } - const { resolvedModel, resolvedThinking } = resolveAndPersistTargetAgentSettings({ - workspaceId, - targetAgentId, - }); + const { resolvedModel, resolvedThinking, revertSelection } = + resolveAndPersistTargetAgentSettings({ + workspaceId, + targetAgentId, + }); const sendMessageOptions = getSendOptionsFromStorage(workspaceId); // The send carries the switch and persists it backend-side best-effort - // (maybePersistAISettingsFromOptions). A failed send keeps the local - // switch (the next send re-persists it), so there is no client-side - // rollback or reconciliation. - await api.workspace.sendMessage({ + // (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 api.workspace.sendMessage({ workspaceId, message: "Implement the plan", options: { @@ -579,6 +606,9 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: resolvedThinking, }, }); + if (!result.success) { + revertSelection(); + } } catch { // Best-effort: user can retry manually if sending fails. } finally { @@ -620,15 +650,16 @@ export const ProposePlanToolCall: React.FC = (props) = }); } - const { resolvedModel, resolvedThinking } = resolveAndPersistTargetAgentSettings({ - workspaceId, - targetAgentId, - }); + const { resolvedModel, resolvedThinking, revertSelection } = + resolveAndPersistTargetAgentSettings({ + workspaceId, + targetAgentId, + }); const sendMessageOptions = getSendOptionsFromStorage(workspaceId); - // See handleImplement: the send persists the switch best-effort; no - // client-side rollback. - await api.workspace.sendMessage({ + // See handleImplement: transport failures keep the switch; typed + // rejections revert it. + const result = await api.workspace.sendMessage({ workspaceId, message: "Implement the plan", options: { @@ -638,6 +669,9 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: resolvedThinking, }, }); + if (!result.success) { + revertSelection(); + } } catch { // Best-effort: user can retry manually if sending fails. } finally { diff --git a/src/browser/utils/workspaceAiSettingsSync.ts b/src/browser/utils/workspaceAiSettingsSync.ts index 56907a254fe..9ebaaeb6126 100644 --- a/src/browser/utils/workspaceAiSettingsSync.ts +++ b/src/browser/utils/workspaceAiSettingsSync.ts @@ -1,4 +1,13 @@ 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 } from "@/common/utils/agentIds"; import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; @@ -123,3 +132,81 @@ export function shouldApplyWorkspaceAgentIdFromBackend( } return false; } + +/** + * 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 over the + * locally captured pre-switch agent: with chained optimistic switches (A→B→C + * where both writes are rejected), the last switch's captured "previous" is + * the also-rejected B while the backend still stores A. Captured pre-switch + * settings only apply when the restore target IS the captured agent; a + * different target's agent-id write triggers the normal explicit-switch + * resolution (WorkspaceModeAISync), which hydrates that agent's own bucket. + * + * Only state the rejected switch itself wrote is undone: newer user changes + * (a different agent, or edited model/thinking/reasoning) always win. + */ +export function revertRejectedAgentSwitch(args: { + workspaceId: string; + rejectedAgentId: string; + applied: { model: string; thinkingLevel: ThinkingLevel; reasoningMode: OpenAIReasoningMode }; + previous: { + agentId: string; + model: string; + thinkingLevel: ThinkingLevel; + reasoningMode: OpenAIReasoningMode; + }; + /** Authoritative backend agent id at rejection time, when known. */ + backendAgentId?: string | 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 restoreAgentId = + typeof args.backendAgentId === "string" && args.backendAgentId.trim().length > 0 + ? normalizeAgentId(args.backendAgentId) + : previousAgentId; + + // Restore settings before the agent id so explicit-switch resolution runs + // against pre-switch values instead of the rejected ones. + if (restoreAgentId === previousAgentId) { + if ( + readPersistedState(getModelKey(args.workspaceId), null) === args.applied.model + ) { + setWorkspaceModelWithOrigin(args.workspaceId, args.previous.model, "sync"); + } + if ( + readPersistedState(getThinkingLevelKey(args.workspaceId), null) === + args.applied.thinkingLevel + ) { + updatePersistedState(getThinkingLevelKey(args.workspaceId), args.previous.thinkingLevel); + } + if ( + readPersistedState( + getReasoningModeKey(args.workspaceId), + null + ) === args.applied.reasoningMode + ) { + updatePersistedState(getReasoningModeKey(args.workspaceId), args.previous.reasoningMode); + } + } + + if (restoreAgentId !== currentAgentId) { + updatePersistedState(agentKey, restoreAgentId); + } +} From 4fd98525e711763189318e4c13caee16ed7115a4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:58:08 +0000 Subject: [PATCH 15/36] resolve legacy agent identity for rejection baselines; overlay legacy settings onto partial per-agent maps --- src/browser/contexts/AgentContext.test.tsx | 50 ++++++++++++++++++- src/browser/contexts/AgentContext.tsx | 13 +++-- .../contexts/WorkspaceContext.test.tsx | 43 ++++++++++++++++ src/browser/contexts/WorkspaceContext.tsx | 16 ++++-- .../Tools/ProposePlanToolCall.test.tsx | 40 ++++++++++++++- .../features/Tools/ProposePlanToolCall.tsx | 9 ++++ 6 files changed, 161 insertions(+), 10 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index 5cf959c0bdc..d86d667f73e 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -22,7 +22,10 @@ import type * as RouterContextModule from "./RouterContext"; import type * as WorkspaceContextModule from "./WorkspaceContext"; let mockAgentDefinitions: AgentDefinitionDescriptor[] = []; -let mockWorkspaceMetadata = new Map(); +let mockWorkspaceMetadata = new Map< + string, + { parentWorkspaceId?: string; agentId?: string; agentType?: string } +>(); let updateAgentAISettingsCalls: Array<{ workspaceId: string; agentId: string; @@ -176,7 +179,7 @@ function Harness(props: HarnessProps) { function createWorkspaceMetadata( workspaceId: string, - overrides: { parentWorkspaceId?: string; agentId?: string } = {} + overrides: { parentWorkspaceId?: string; agentId?: string; agentType?: string } = {} ): FrontendWorkspaceMetadata { return { id: workspaceId, @@ -590,6 +593,49 @@ describe("AgentContext", () => { }); }); + 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(resolveUpdateAgentAISettings).not.toBeNull(); + }); + const rejectReviewSwitch = getDeferredUpdateResolver(); + + rejectPlanSwitch?.({ success: false, error: "unpriced model" }); + rejectReviewSwitch?.({ 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 884fffea925..2792b4f7d4c 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -37,7 +37,11 @@ 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 { + normalizeAgentId, + resolvePersistedAgentId, + resolveRemovedBuiltinAgentId, +} from "@/common/utils/agentIds"; import { clearPendingWorkspaceAgentId, markPendingWorkspaceAgentId, @@ -144,8 +148,11 @@ function AgentProviderWithState(props: { // Authoritative restore baseline for rejected switches: with chained // rejected switches, a captured "previous" can itself be a rejected agent, - // while the backend still stores the last accepted one. - const backendAgentId = currentMeta?.agentId ?? null; + // while the backend still stores the last accepted one. Resolved through + // the legacy compat resolver (agentType-only metadata), like metadata + // seeding. + const resolvedBackendAgentId = resolvePersistedAgentId(currentMeta, ""); + const backendAgentId = resolvedBackendAgentId.length > 0 ? resolvedBackendAgentId : null; const workspaceId = props.workspaceId; diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index 42881af3bdb..c14999e1a62 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -16,6 +16,7 @@ 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"; @@ -616,6 +617,48 @@ describe("WorkspaceContext", () => { ); }); + 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: diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index d38bdf36b9d..d002b466e08 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -213,15 +213,23 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat // 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 aiByAgent = - metadata.aiSettingsByAgent ?? - (metadata.aiSettings + 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; diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index a6362b34418..86398211287 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -80,6 +80,11 @@ let updateAgentAISettingsCalls: Array<{ }> = []; 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; @@ -133,7 +138,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", () => ({ @@ -338,6 +346,7 @@ describe("ProposePlanToolCall", () => { selectableDiffRendererCalls = []; updateAgentAISettingsCalls = []; mockApi = null; + mockWorkspaceMetadataByWorkspace = new Map(); cleanupDom = installDom(); await installProposePlanModuleMocks(); }); @@ -592,6 +601,35 @@ describe("ProposePlanToolCall", () => { 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"); diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index 6834f2b2df9..096db0a0243 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -51,6 +51,7 @@ import { } from "@/common/constants/storage"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { resolvePersistedAgentId } from "@/common/utils/agentIds"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; import { @@ -205,6 +206,13 @@ export const ProposePlanToolCall: React.FC = (props) = : undefined; const runtimeConfig = workspaceMetadata?.runtimeConfig; + // Authoritative restore baseline for typed send rejections: a pending + // picker switch may itself be rejected, so the captured pre-action agent is + // not necessarily what the backend stores. Resolved through the legacy + // compat resolver (agentType-only metadata), like metadata seeding. + const resolvedBackendAgentId = resolvePersistedAgentId(workspaceMetadata, ""); + const backendAgentId = resolvedBackendAgentId.length > 0 ? resolvedBackendAgentId : null; + // Fresh content from disk for the latest plan (external edit detection) // Only use cache for completed tools (page reload case) - not for in-flight tools // which may have stale cache from a previous propose_plan call @@ -552,6 +560,7 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: existingThinking, reasoningMode: existingReasoning, }, + backendAgentId, }), }; }; From 7268a3e82390c8386f5b58abac445420911f6f5b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:24:54 +0000 Subject: [PATCH 16/36] reconcile rejected switches from settle-time metadata; workspace buckets precede configured defaults --- src/browser/contexts/AgentContext.test.tsx | 112 ++++++++++++++- src/browser/contexts/AgentContext.tsx | 25 ++-- .../features/Tools/ProposePlanToolCall.tsx | 17 +-- .../utils/workspaceAiSettingsSync.test.ts | 133 ++++++++++++++++++ src/browser/utils/workspaceAiSettingsSync.ts | 59 +++++--- src/browser/utils/workspaceModeAi.test.ts | 22 +++ src/browser/utils/workspaceModeAi.ts | 30 ++-- 7 files changed, 343 insertions(+), 55 deletions(-) create mode 100644 src/browser/utils/workspaceAiSettingsSync.test.ts diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index d86d667f73e..defbe6f1a2a 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -9,7 +9,12 @@ import { GlobalWindow } from "happy-dom"; import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { shouldApplyWorkspaceAgentIdFromBackend } from "@/browser/utils/workspaceAiSettingsSync"; -import { GLOBAL_SCOPE_ID, getAgentIdKey, getProjectScopeId } from "@/common/constants/storage"; +import { + GLOBAL_SCOPE_ID, + getAgentIdKey, + getProjectScopeId, + getWorkspaceAISettingsByAgentKey, +} from "@/common/constants/storage"; import { requireTestModule } from "@/browser/testUtils"; import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; @@ -179,7 +184,12 @@ function Harness(props: HarnessProps) { function createWorkspaceMetadata( workspaceId: string, - overrides: { parentWorkspaceId?: string; agentId?: string; agentType?: string } = {} + overrides: { + parentWorkspaceId?: string; + agentId?: string; + agentType?: string; + aiSettingsByAgent?: FrontendWorkspaceMetadata["aiSettingsByAgent"]; + } = {} ): FrontendWorkspaceMetadata { return { id: workspaceId, @@ -193,6 +203,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 { @@ -215,7 +257,7 @@ function createApiClient(): APIClient { }, workspace: { list: () => Promise.resolve(workspaceMetadata), - onMetadata: () => Promise.resolve(createEmptyAsyncIterable()), + onMetadata: () => Promise.resolve(createWorkspaceMetadataIterable()), onChat: () => Promise.resolve(createEmptyAsyncIterable()), getSessionUsage: () => Promise.resolve(undefined), activity: { @@ -284,6 +326,7 @@ describe("AgentContext", () => { updateAgentAISettingsCalls = []; deferUpdateAgentAISettings = false; resolveUpdateAgentAISettings = null; + emitWorkspaceMetadata = null; originalWindow = globalThis.window; originalDocument = globalThis.document; @@ -593,6 +636,69 @@ describe("AgentContext", () => { }); }); + test("rejection rollback uses fresh backend metadata from a mid-flight echo", 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; + + renderAgentHarness({ + workspaceId, + projectPath, + onChange: (value) => (contextValue = value), + }); + + 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 goes in flight BEFORE the acceptance echo arrives, so its + // render-time closure still sees the pre-echo backend state (exec). + contextValue?.setAgentId("review"); + await waitFor(() => { + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); + const rejectReviewSwitch = getDeferredUpdateResolver(); + + acceptPlanSwitch?.({ success: true, data: undefined }); + + // Backend echo for the accepted switch lands mid-flight; the distinctive + // bucket makes the metadata flush observable. + emitWorkspaceMetadata?.({ + workspaceId, + metadata: createWorkspaceMetadata(workspaceId, { + agentId: "plan", + aiSettingsByAgent: { plan: { model: "openai:echoed-plan", thinkingLevel: "low" } }, + }), + }); + await waitFor(() => { + const raw = window.localStorage.getItem(getWorkspaceAISettingsByAgentKey(workspaceId)); + const cache = + raw == null ? null : (JSON.parse(raw) as Partial>); + expect(cache?.plan?.model).toBe("openai:echoed-plan"); + }); + + rejectReviewSwitch?.({ success: false, error: "unpriced model" }); + + // The rollback reads metadata at settle time: it restores the accepted + // plan agent, not the stale render-time exec baseline. + await waitFor(() => { + expect(contextValue?.agentId).toBe("plan"); + }); + }); + test("chained rejections resolve a legacy agentType baseline", async () => { const projectPath = "/tmp/project"; const workspaceId = "main-workspace"; diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx index 2792b4f7d4c..53393a054f3 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -37,11 +37,7 @@ 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, - resolvePersistedAgentId, - resolveRemovedBuiltinAgentId, -} from "@/common/utils/agentIds"; +import { normalizeAgentId, resolveRemovedBuiltinAgentId } from "@/common/utils/agentIds"; import { clearPendingWorkspaceAgentId, markPendingWorkspaceAgentId, @@ -146,13 +142,15 @@ function AgentProviderWithState(props: { // is locked, so local changes must never be written back. const isCurrentAgentLocked = currentMeta?.parentWorkspaceId != null; - // Authoritative restore baseline for rejected switches: with chained - // rejected switches, a captured "previous" can itself be a rejected agent, - // while the backend still stores the last accepted one. Resolved through - // the legacy compat resolver (agentType-only metadata), like metadata - // seeding. - const resolvedBackendAgentId = resolvePersistedAgentId(currentMeta, ""); - const backendAgentId = resolvedBackendAgentId.length > 0 ? resolvedBackendAgentId : null; + // Fresh authoritative metadata for rejection rollbacks, read at settle time + // via a ref: an accepted A→B write can update backend metadata while a + // later B→C switch is in flight, and C's rejection must restore B, not a + // render-time snapshot of A. (The pending guard may suppress B's + // localStorage echo, but the metadata map itself stays current.) + const currentMetaRef = useRef(currentMeta); + useEffect(() => { + currentMetaRef.current = currentMeta; + }); const workspaceId = props.workspaceId; @@ -261,7 +259,7 @@ function AgentProviderWithState(props: { thinkingLevel: previousThinking, reasoningMode: previousReasoning, }, - backendAgentId, + backendMetadata: currentMetaRef.current, }); }; @@ -297,7 +295,6 @@ function AgentProviderWithState(props: { [ agents, api, - backendAgentId, globalDefaultAgentId, isCurrentAgentLocked, isProjectScope, diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index 096db0a0243..92052a0427e 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -51,7 +51,6 @@ import { } from "@/common/constants/storage"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; -import { resolvePersistedAgentId } from "@/common/utils/agentIds"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; import { @@ -206,12 +205,14 @@ export const ProposePlanToolCall: React.FC = (props) = : undefined; const runtimeConfig = workspaceMetadata?.runtimeConfig; - // Authoritative restore baseline for typed send rejections: a pending - // picker switch may itself be rejected, so the captured pre-action agent is - // not necessarily what the backend stores. Resolved through the legacy - // compat resolver (agentType-only metadata), like metadata seeding. - const resolvedBackendAgentId = resolvePersistedAgentId(workspaceMetadata, ""); - const backendAgentId = resolvedBackendAgentId.length > 0 ? resolvedBackendAgentId : null; + // Fresh authoritative metadata for rejection rollbacks (see + // revertRejectedAgentSwitch): read at settle time via a ref so an in-flight + // picker write that lands mid-action cannot leave a stale baseline in the + // send handler's closure. + const workspaceMetadataRef = useRef(workspaceMetadata); + useEffect(() => { + workspaceMetadataRef.current = workspaceMetadata; + }); // Fresh content from disk for the latest plan (external edit detection) // Only use cache for completed tools (page reload case) - not for in-flight tools @@ -560,7 +561,7 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: existingThinking, reasoningMode: existingReasoning, }, - backendAgentId, + backendMetadata: workspaceMetadataRef.current, }), }; }; diff --git a/src/browser/utils/workspaceAiSettingsSync.test.ts b/src/browser/utils/workspaceAiSettingsSync.test.ts new file mode 100644 index 00000000000..0e8ed7e6fb1 --- /dev/null +++ b/src/browser/utils/workspaceAiSettingsSync.test.ts @@ -0,0 +1,133 @@ +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 { revertRejectedAgentSwitch } 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("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("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 9ebaaeb6126..754ad763617 100644 --- a/src/browser/utils/workspaceAiSettingsSync.ts +++ b/src/browser/utils/workspaceAiSettingsSync.ts @@ -7,7 +7,7 @@ import { getReasoningModeKey, getThinkingLevelKey, } from "@/common/constants/storage"; -import { normalizeAgentId } from "@/common/utils/agentIds"; +import { normalizeAgentId, resolvePersistedAgentId } from "@/common/utils/agentIds"; import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; @@ -142,13 +142,14 @@ export function shouldApplyWorkspaceAgentIdFromBackend( * 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 over the - * locally captured pre-switch agent: with chained optimistic switches (A→B→C - * where both writes are rejected), the last switch's captured "previous" is - * the also-rejected B while the backend still stores A. Captured pre-switch - * settings only apply when the restore target IS the captured agent; a - * different target's agent-id write triggers the normal explicit-switch - * resolution (WorkspaceModeAISync), which hydrates that agent's own bucket. + * 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. * * Only state the rejected switch itself wrote is undone: newer user changes * (a different agent, or edited model/thinking/reasoning) always win. @@ -163,8 +164,8 @@ export function revertRejectedAgentSwitch(args: { thinkingLevel: ThinkingLevel; reasoningMode: OpenAIReasoningMode; }; - /** Authoritative backend agent id at rejection time, when known. */ - backendAgentId?: string | null; + /** Fresh workspace metadata at settle time (authoritative backend state). */ + backendMetadata?: FrontendWorkspaceMetadata | null; }): void { const agentKey = getAgentIdKey(args.workspaceId); const rawCurrent = readPersistedState(agentKey, null); @@ -177,24 +178,46 @@ export function revertRejectedAgentSwitch(args: { } const previousAgentId = normalizeAgentId(args.previous.agentId); + const backendResolved = resolvePersistedAgentId(args.backendMetadata ?? undefined, ""); const restoreAgentId = - typeof args.backendAgentId === "string" && args.backendAgentId.trim().length > 0 - ? normalizeAgentId(args.backendAgentId) - : previousAgentId; + 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; // Restore settings before the agent id so explicit-switch resolution runs - // against pre-switch values instead of the rejected ones. - if (restoreAgentId === previousAgentId) { + // against restored values instead of the rejected ones. Per-key guard: only + // undo state the rejected switch itself wrote, so newer user changes win. + if (restore) { if ( readPersistedState(getModelKey(args.workspaceId), null) === args.applied.model ) { - setWorkspaceModelWithOrigin(args.workspaceId, args.previous.model, "sync"); + setWorkspaceModelWithOrigin(args.workspaceId, restore.model, "sync"); } if ( readPersistedState(getThinkingLevelKey(args.workspaceId), null) === args.applied.thinkingLevel ) { - updatePersistedState(getThinkingLevelKey(args.workspaceId), args.previous.thinkingLevel); + updatePersistedState(getThinkingLevelKey(args.workspaceId), restore.thinkingLevel); } if ( readPersistedState( @@ -202,7 +225,7 @@ export function revertRejectedAgentSwitch(args: { null ) === args.applied.reasoningMode ) { - updatePersistedState(getReasoningModeKey(args.workspaceId), args.previous.reasoningMode); + updatePersistedState(getReasoningModeKey(args.workspaceId), restore.reasoningMode); } } diff --git a/src/browser/utils/workspaceModeAi.test.ts b/src/browser/utils/workspaceModeAi.test.ts index 30dcae0fe3f..42d32fbad9e 100644 --- a/src/browser/utils/workspaceModeAi.test.ts +++ b/src/browser/utils/workspaceModeAi.test.ts @@ -57,6 +57,28 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { }); }); + 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("ignores workspace-by-agent fallback when disabled", () => { const result = resolveWorkspaceAiSettingsForAgent({ agentId: "exec", diff --git a/src/browser/utils/workspaceModeAi.ts b/src/browser/utils/workspaceModeAi.ts index 24f52a92734..87f47722d00 100644 --- a/src/browser/utils/workspaceModeAi.ts +++ b/src/browser/utils/workspaceModeAi.ts @@ -92,25 +92,31 @@ export function resolveWorkspaceAiSettingsForAgent(args: { args.useWorkspaceByAgentFallback && typeof workspaceOverride?.model === "string" ? workspaceOverride.model : undefined; - const inheritedModelCandidate = - workspaceOverrideModel ?? - (typeof args.existingModel === "string" ? args.existingModel : undefined) ?? - ""; - const inheritedModel = inheritedModelCandidate.trim(); + const overrideModel = workspaceOverrideModel?.trim(); + const existingModel = (typeof args.existingModel === "string" ? args.existingModel : "").trim(); + // The workspace's own saved bucket wins on explicit switches, matching + // backend dispatch and ACP resolution (bucket → configured/base-chain + // defaults → current workspace value): persisting a switch must not + // overwrite the workspace's last-used settings with a global default. const resolvedModel = - configuredModel && configuredModel.length > 0 - ? configuredModel - : inheritedModel.length > 0 - ? inheritedModel - : args.fallbackModel; + overrideModel && overrideModel.length > 0 + ? overrideModel + : configuredModel && configuredModel.length > 0 + ? configuredModel + : existingModel.length > 0 + ? existingModel + : 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"; + const resolvedThinking = + workspaceOverrideThinking ?? + configuredDefaults.thinkingLevel ?? + coerceThinkingLevel(args.existingThinking) ?? + "off"; // An existing per-agent bucket owns the reasoning choice outright (matching // targetWorkspaceBucketToLayer): a configured Pro default must not re-inject From 982336066104af612a94489f9a8d3ff58b3601af Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:59:08 +0000 Subject: [PATCH 17/36] fix: include agent definition AI defaults when resolving explicit switches --- src/browser/contexts/AgentContext.test.tsx | 51 +++++++++++ src/browser/contexts/AgentContext.tsx | 7 +- src/browser/utils/workspaceModeAi.test.ts | 90 ++++++++++++++++++++ src/browser/utils/workspaceModeAi.ts | 99 ++++++++-------------- 4 files changed, 184 insertions(+), 63 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index defbe6f1a2a..8c854828bb8 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -12,7 +12,9 @@ import { shouldApplyWorkspaceAgentIdFromBackend } from "@/browser/utils/workspac import { GLOBAL_SCOPE_ID, getAgentIdKey, + getModelKey, getProjectScopeId, + getThinkingLevelKey, getWorkspaceAISettingsByAgentKey, } from "@/common/constants/storage"; import { requireTestModule } from "@/browser/testUtils"; @@ -483,6 +485,55 @@ describe("AgentContext", () => { expect(updateAgentAISettingsCalls).toHaveLength(1); }); + 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" }, + }; + 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"; diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx index 53393a054f3..6ea7ca8ad99 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -219,7 +219,12 @@ function AgentProviderWithState(props: { existingModel: previousModel, existingThinking: previousThinking, existingReasoningMode: previousReasoning, - agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])), + agentDescriptorById: new Map( + agents.map((agent) => [ + agent.id, + { base: agent.base, definitionAiDefaults: agent.aiDefaults }, + ]) + ), }); // The local update above is authoritative for this client and the write diff --git a/src/browser/utils/workspaceModeAi.test.ts b/src/browser/utils/workspaceModeAi.test.ts index 42d32fbad9e..611fcd7cac8 100644 --- a/src/browser/utils/workspaceModeAi.test.ts +++ b/src/browser/utils/workspaceModeAi.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; +import type { AgentAncestorDescriptor } from "@/common/utils/ai/agentAncestorLayers"; import { resolveWorkspaceAiSettingsForAgent } from "./workspaceModeAi"; describe("resolveWorkspaceAiSettingsForAgent", () => { @@ -79,6 +80,95 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { 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("ignores workspace-by-agent fallback when disabled", () => { const result = resolveWorkspaceAiSettingsForAgent({ agentId: "exec", diff --git a/src/browser/utils/workspaceModeAi.ts b/src/browser/utils/workspaceModeAi.ts index 87f47722d00..ae290929d6d 100644 --- a/src/browser/utils/workspaceModeAi.ts +++ b/src/browser/utils/workspaceModeAi.ts @@ -1,5 +1,5 @@ import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; -import type { AiSettingSource } from "@/common/types/agentAiSettings"; +import { targetWorkspaceBucketToLayer, type AiSettingSource } from "@/common/types/agentAiSettings"; import { coerceOpenAIReasoningMode, coerceThinkingLevel, @@ -7,7 +7,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< @@ -71,6 +74,7 @@ export function resolveWorkspaceAiSettingsForAgent(args: { existingReasoningMode?: OpenAIReasoningMode; /** Agent id -> base id, for base-chain reasoning-mode inheritance (custom agents). */ agentBaseById?: ReadonlyMap; + agentDescriptorById?: ReadonlyMap; }): { resolvedModel: string; resolvedThinking: ThinkingLevel; @@ -78,67 +82,38 @@ export function resolveWorkspaceAiSettingsForAgent(args: { } { const normalizedAgentId = normalizeAgentId(args.agentId); const workspaceOverride = args.workspaceByAgent?.[normalizedAgentId]; + const descriptorsById = new Map(args.agentDescriptorById ?? []); + for (const [id, base] of args.agentBaseById ?? []) { + descriptorsById.set(id, { ...descriptorsById.get(id), base }); + } + const resolved = resolveAgentAiSettings({ + targetAgentId: normalizedAgentId, + profile: "interactive", + targetWorkspaceSettings: + args.useWorkspaceByAgentFallback && workspaceOverride != null + ? targetWorkspaceBucketToLayer(workspaceOverride) + : undefined, + agentAiDefaults: args.agentAiDefaults, + targetDefinitionAiDefaults: descriptorsById.get(normalizedAgentId)?.definitionAiDefaults, + ancestors: collectDeclaredAncestorLayers(normalizedAgentId, descriptorsById), + parentRuntime: { + model: typeof args.existingModel === "string" ? args.existingModel : undefined, + thinkingLevel: coerceThinkingLevel(args.existingThinking), + reasoningMode: coerceOpenAIReasoningMode(args.existingReasoningMode), + }, + defaultModel: args.fallbackModel, + }); - // 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 configuredModel = configuredDefaults.modelString; - const workspaceOverrideModel = - args.useWorkspaceByAgentFallback && typeof workspaceOverride?.model === "string" - ? workspaceOverride.model - : undefined; - const overrideModel = workspaceOverrideModel?.trim(); - const existingModel = (typeof args.existingModel === "string" ? args.existingModel : "").trim(); - // The workspace's own saved bucket wins on explicit switches, matching - // backend dispatch and ACP resolution (bucket → configured/base-chain - // defaults → current workspace value): persisting a switch must not - // overwrite the workspace's last-used settings with a global default. - const resolvedModel = - overrideModel && overrideModel.length > 0 - ? overrideModel - : configuredModel && configuredModel.length > 0 - ? configuredModel - : existingModel.length > 0 - ? existingModel - : 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 resolvedThinking = - workspaceOverrideThinking ?? - configuredDefaults.thinkingLevel ?? - coerceThinkingLevel(args.existingThinking) ?? - "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. + // Background sync trusts the live workspace mode, which hydration seeds from + // the backend bucket, instead of restoring the saved bucket or configured mode. const resolvedReasoningMode = - workspaceOverride != null - ? args.useWorkspaceByAgentFallback - ? (coerceOpenAIReasoningMode(workspaceOverride.reasoningMode) ?? "standard") - : (coerceOpenAIReasoningMode(args.existingReasoningMode) ?? "standard") - : (configuredDefaults.reasoningMode ?? - coerceOpenAIReasoningMode(args.existingReasoningMode) ?? - "standard"); + workspaceOverride != null && !args.useWorkspaceByAgentFallback + ? (coerceOpenAIReasoningMode(args.existingReasoningMode) ?? "standard") + : (resolved.selected.reasoningMode ?? "standard"); - return { resolvedModel, resolvedThinking, resolvedReasoningMode }; + return { + resolvedModel: resolved.selected.model, + resolvedThinking: resolved.selected.thinkingLevel, + resolvedReasoningMode, + }; } From d8de5ea540a9228940bd6517fcdb5c8609fd77f4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:14:56 +0000 Subject: [PATCH 18/36] fix: refresh rollback baseline synchronously with metadata updates --- src/browser/contexts/AgentContext.test.tsx | 53 +++++++++++++------ src/browser/contexts/AgentContext.tsx | 15 ++---- .../features/Tools/ProposePlanToolCall.tsx | 14 ++--- 3 files changed, 46 insertions(+), 36 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index 8c854828bb8..f3b215b9f15 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -15,7 +15,6 @@ import { getModelKey, getProjectScopeId, getThinkingLevelKey, - getWorkspaceAISettingsByAgentKey, } from "@/common/constants/storage"; import { requireTestModule } from "@/browser/testUtils"; import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; @@ -58,6 +57,7 @@ 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; @@ -119,8 +119,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; @@ -184,6 +185,20 @@ 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: { @@ -300,12 +315,19 @@ function renderAgentHarness(props: { projectPath: string; workspaceId?: string; onChange: (value: AgentContextValue) => void; + onMetadataLayout?: (metadata: FrontendWorkspaceMetadata | undefined) => void; }) { return render( + {props.workspaceId && props.onMetadataLayout ? ( + + ) : null} @@ -687,7 +709,7 @@ describe("AgentContext", () => { }); }); - test("rejection rollback uses fresh backend metadata from a mid-flight echo", async () => { + 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]; @@ -696,11 +718,18 @@ describe("AgentContext", () => { 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(() => { @@ -722,11 +751,14 @@ describe("AgentContext", () => { expect(resolveUpdateAgentAISettings).not.toBeNull(); }); const rejectReviewSwitch = getDeferredUpdateResolver(); + rejectReviewOnPlanCommit = () => + rejectReviewSwitch?.({ success: false, error: "unpriced model" }); acceptPlanSwitch?.({ success: true, data: undefined }); - // Backend echo for the accepted switch lands mid-flight; the distinctive - // bucket makes the metadata flush observable. + // 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, { @@ -734,18 +766,9 @@ describe("AgentContext", () => { aiSettingsByAgent: { plan: { model: "openai:echoed-plan", thinkingLevel: "low" } }, }), }); - await waitFor(() => { - const raw = window.localStorage.getItem(getWorkspaceAISettingsByAgentKey(workspaceId)); - const cache = - raw == null ? null : (JSON.parse(raw) as Partial>); - expect(cache?.plan?.model).toBe("openai:echoed-plan"); - }); - - rejectReviewSwitch?.({ success: false, error: "unpriced model" }); - // The rollback reads metadata at settle time: it restores the accepted - // plan agent, not the stale render-time exec baseline. await waitFor(() => { + expect(rejectReviewOnPlanCommit).toBeNull(); expect(contextValue?.agentId).toBe("plan"); }); }); diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx index 6ea7ca8ad99..9efc256026f 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -13,6 +13,7 @@ import { import { useAPI } from "@/browser/contexts/API"; import { useWorkspaceMetadata } from "@/browser/contexts/WorkspaceContext"; +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"; @@ -100,6 +101,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); @@ -142,16 +144,6 @@ function AgentProviderWithState(props: { // is locked, so local changes must never be written back. const isCurrentAgentLocked = currentMeta?.parentWorkspaceId != null; - // Fresh authoritative metadata for rejection rollbacks, read at settle time - // via a ref: an accepted A→B write can update backend metadata while a - // later B→C switch is in flight, and C's rejection must restore B, not a - // render-time snapshot of A. (The pending guard may suppress B's - // localStorage echo, but the metadata map itself stays current.) - const currentMetaRef = useRef(currentMeta); - useEffect(() => { - currentMetaRef.current = currentMeta; - }); - const workspaceId = props.workspaceId; // Declared before setAgentId: switches resolve the target agent's settings @@ -264,7 +256,7 @@ function AgentProviderWithState(props: { thinkingLevel: previousThinking, reasoningMode: previousReasoning, }, - backendMetadata: currentMetaRef.current, + backendMetadata: workspaceStore.getWorkspaceMetadata(workspaceId), }); }; @@ -305,6 +297,7 @@ function AgentProviderWithState(props: { isProjectScope, setAgentIdRaw, workspaceId, + workspaceStore, ] ); diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index 92052a0427e..e24144be7c7 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 { @@ -196,6 +197,7 @@ export const ProposePlanToolCall: React.FC = (props) = const isAutoMode = currentAgentId === "auto"; const openInEditor = useOpenInEditor(); const workspaceContext = useOptionalWorkspaceContext(); + const workspaceStore = useWorkspaceStoreRaw(); const editorError = usePopoverError(); const editButtonRef = useRef(null); @@ -205,15 +207,6 @@ export const ProposePlanToolCall: React.FC = (props) = : undefined; const runtimeConfig = workspaceMetadata?.runtimeConfig; - // Fresh authoritative metadata for rejection rollbacks (see - // revertRejectedAgentSwitch): read at settle time via a ref so an in-flight - // picker write that lands mid-action cannot leave a stale baseline in the - // send handler's closure. - const workspaceMetadataRef = useRef(workspaceMetadata); - useEffect(() => { - workspaceMetadataRef.current = workspaceMetadata; - }); - // Fresh content from disk for the latest plan (external edit detection) // Only use cache for completed tools (page reload case) - not for in-flight tools // which may have stale cache from a previous propose_plan call @@ -561,7 +554,8 @@ export const ProposePlanToolCall: React.FC = (props) = thinkingLevel: existingThinking, reasoningMode: existingReasoning, }, - backendMetadata: workspaceMetadataRef.current, + backendMetadata: + workspaceStore.getWorkspaceMetadata(args.workspaceId) ?? workspaceMetadata, }), }; }; From 022191d9115f6d10bda2b74623e8ca7045733012 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:56:15 +0000 Subject: [PATCH 19/36] fix: apply agent definition defaults during workspace sync --- .../WorkspaceModeAISync.test.tsx | 68 +++++++++++++++++-- .../WorkspaceModeAISync.tsx | 7 +- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx index aab70da5409..7c82b740bb1 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,19 @@ const noop = () => { // intentional noop for tests }; -function SyncHarness(props: { workspaceId: string; agentId: string }) { +function SyncHarness(props: { + workspaceId: string; + agentId: string; + agents?: AgentDefinitionDescriptor[]; +}) { + const agents = props.agents ?? []; return ( agent.id === props.agentId), + agents, loaded: true, loadFailed: false, refresh: () => Promise.resolve(), @@ -48,8 +54,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", () => { @@ -121,10 +133,52 @@ describe("WorkspaceModeAISync", () => { }); }); + 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" }, + }, + ]; + + updatePersistedState(AGENT_AI_DEFAULTS_KEY, {}); + updatePersistedState(getModelKey(workspaceId), existingModel); + updatePersistedState(getThinkingLevelKey(workspaceId), "off"); + + const { rerender } = renderSync({ workspaceId, agentId: "plan", agents }); + + 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("ignores workspace-by-agent values when settings are inherit", async () => { const workspaceId = nextWorkspaceId(); - const existingModel = "some-legacy-model"; + const existingModel = "anthropic:claude-sonnet-4-5"; const existingThinking = "off"; // Inherit in Settings removes explicit per-agent defaults from AGENT_AI_DEFAULTS_KEY. @@ -182,7 +236,7 @@ describe("WorkspaceModeAISync", () => { test("ignores same-agent workspace overrides when agent defaults are missing", async () => { const workspaceId = nextWorkspaceId(); - const existingModel = "some-legacy-model"; + const existingModel = "anthropic:claude-sonnet-4-5"; const existingThinking = "high"; updatePersistedState(AGENT_AI_DEFAULTS_KEY, { diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx index 8fd18e89926..94da9c86ad7 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx @@ -80,7 +80,12 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { existingModel, existingThinking, existingReasoningMode: existingReasoning, - agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])), + agentDescriptorById: new Map( + agents.map((agent) => [ + agent.id, + { base: agent.base, definitionAiDefaults: agent.aiDefaults }, + ]) + ), }); if (existingModel !== resolvedModel) { From 8af10b76dbcac28ded1d67d036510b7dfdf2e1ce Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:24:51 +0000 Subject: [PATCH 20/36] fix: preserve agent settings when forking workspaces --- src/node/services/workspaceService.test.ts | 20 +++++++++++++++++++- src/node/services/workspaceService.ts | 11 ++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 4757537173f..abb95a8812a 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -14904,7 +14904,7 @@ describe("WorkspaceService fork", () => { } }); - test("auto-generated fork names normalize legacy fork families before the validation fallback", async () => { + test("forks inherit persisted agent settings while normalizing legacy fork families", async () => { const sourceWorkspaceId = "source-workspace"; const newWorkspaceId = "forked-workspace"; const sourceProjectPath = path.join(tempDir, "project"); @@ -14916,6 +14916,12 @@ 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 forkedWorkspacePath = path.join(sourceProjectPath, "feature-1"); @@ -15003,6 +15009,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("researcher"); + expect(result.data.metadata.aiSettingsByAgent).toEqual(sourceMetadata.aiSettingsByAgent); + expect(result.data.metadata.aiSettings).toEqual(sourceMetadata.aiSettings); + + const persistedMetadata = (await config.getAllWorkspaceMetadata()).find( + (workspace) => workspace.id === newWorkspaceId + ); + expect(persistedMetadata).toMatchObject({ + agentId: "researcher", + aiSettingsByAgent: sourceMetadata.aiSettingsByAgent, + aiSettings: sourceMetadata.aiSettings, + }); } finally { orchestrateForkSpy.mockRestore(); copyPlanSpy.mockRestore(); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 36676bfbaf9..548d517b002 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -229,7 +229,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, @@ -10179,6 +10179,7 @@ export class WorkspaceService extends EventEmitter { // Compute namedWorkspacePath for frontend metadata const namedWorkspacePath = targetRuntime.getWorkspacePath(foundProjectPath, resolvedName); + const sourceAgentId = resolvePersistedAgentId(sourceMetadata, ""); const metadata: FrontendWorkspaceMetadata = { id: newWorkspaceId, @@ -10189,6 +10190,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 }), + ...(sourceMetadata.aiSettingsByAgent == null + ? {} + : { aiSettingsByAgent: { ...sourceMetadata.aiSettingsByAgent } }), + ...(sourceMetadata.aiSettings == null + ? {} + : { aiSettings: { ...sourceMetadata.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 From becb79cee177dec4193f130bbf8534cbb988f5d8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:43:40 +0000 Subject: [PATCH 21/36] fix: restore rejected agent settings atomically --- .../utils/workspaceAiSettingsSync.test.ts | 32 +++++++++++++++++++ src/browser/utils/workspaceAiSettingsSync.ts | 19 +++++++---- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/browser/utils/workspaceAiSettingsSync.test.ts b/src/browser/utils/workspaceAiSettingsSync.test.ts index 0e8ed7e6fb1..8c6971c4417 100644 --- a/src/browser/utils/workspaceAiSettingsSync.test.ts +++ b/src/browser/utils/workspaceAiSettingsSync.test.ts @@ -104,6 +104,38 @@ describe("revertRejectedAgentSwitch", () => { 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 diff --git a/src/browser/utils/workspaceAiSettingsSync.ts b/src/browser/utils/workspaceAiSettingsSync.ts index 754ad763617..74295097051 100644 --- a/src/browser/utils/workspaceAiSettingsSync.ts +++ b/src/browser/utils/workspaceAiSettingsSync.ts @@ -151,8 +151,9 @@ export function shouldApplyWorkspaceAgentIdFromBackend( * legacy shared blob, matching backend dispatch fallback), else from the * captured pre-switch values when the target IS the captured agent. * - * Only state the rejected switch itself wrote is undone: newer user changes - * (a different agent, or edited model/thinking/reasoning) always win. + * 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; @@ -204,22 +205,28 @@ export function revertRejectedAgentSwitch(args: { } : null; + const isRestoringAnotherAgent = restoreAgentId !== currentAgentId; + // Restore settings before the agent id so explicit-switch resolution runs - // against restored values instead of the rejected ones. Per-key guard: only - // undo state the rejected switch itself wrote, so newer user changes win. + // 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 + args.applied.thinkingLevel ) { updatePersistedState(getThinkingLevelKey(args.workspaceId), restore.thinkingLevel); } if ( + isRestoringAnotherAgent || readPersistedState( getReasoningModeKey(args.workspaceId), null @@ -229,7 +236,7 @@ export function revertRejectedAgentSwitch(args: { } } - if (restoreAgentId !== currentAgentId) { + if (isRestoringAnotherAgent) { updatePersistedState(agentKey, restoreAgentId); } } From f6f87f27c329659c411186a90a9eb1dffc189e42 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:47:42 +0000 Subject: [PATCH 22/36] fix: resolve browser agent defaults per definition hop --- .../WorkspaceModeAISync.test.tsx | 53 +++++++++++++++++++ .../WorkspaceModeAISync.tsx | 2 +- src/browser/contexts/AgentContext.test.tsx | 1 + src/browser/contexts/AgentContext.tsx | 2 +- src/common/orpc/schemas/agentDefinition.ts | 3 ++ src/node/acp/resolveAgentAiSettings.ts | 7 ++- src/node/orpc/router.ts | 3 +- 7 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx index 7c82b740bb1..d9916f3c32b 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx @@ -153,6 +153,7 @@ describe("WorkspaceModeAISync", () => { subagentRunnable: false, base: "exec", aiDefaults: { model: definitionModel, thinkingLevel: "high" }, + ownAiDefaults: { model: definitionModel, thinkingLevel: "high" }, }, ]; @@ -175,6 +176,58 @@ describe("WorkspaceModeAISync", () => { 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("ignores workspace-by-agent values when settings are inherit", async () => { const workspaceId = nextWorkspaceId(); diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx index 94da9c86ad7..8902652f641 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx @@ -83,7 +83,7 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { agentDescriptorById: new Map( agents.map((agent) => [ agent.id, - { base: agent.base, definitionAiDefaults: agent.aiDefaults }, + { base: agent.base, definitionAiDefaults: agent.ownAiDefaults }, ]) ), }); diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index f3b215b9f15..ff8c13d958a 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -518,6 +518,7 @@ describe("AgentContext", () => { 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, {}); diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx index 9efc256026f..13b841d84d4 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -214,7 +214,7 @@ function AgentProviderWithState(props: { agentDescriptorById: new Map( agents.map((agent) => [ agent.id, - { base: agent.base, definitionAiDefaults: agent.aiDefaults }, + { base: agent.base, definitionAiDefaults: agent.ownAiDefaults }, ]) ), }); diff --git a/src/common/orpc/schemas/agentDefinition.ts b/src/common/orpc/schemas/agentDefinition.ts index 06f1b3a81af..362a6d4c128 100644 --- a/src/common/orpc/schemas/agentDefinition.ts +++ b/src/common/orpc/schemas/agentDefinition.ts @@ -101,6 +101,9 @@ export const AgentDefinitionDescriptorSchema = z // Base agent ID for inheritance (e.g., "exec", "plan", or custom agent) base: AgentIdSchema.optional(), aiDefaults: AgentDefinitionAiDefaultsSchema.optional(), + // This descriptor's unmerged frontmatter defaults. Resolution consumers use + // this field per hop; aiDefaults remains the effective value for UI display. + ownAiDefaults: AgentDefinitionAiDefaultsSchema.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/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/orpc/router.ts b/src/node/orpc/router.ts index 17c8a4be9c0..d1911bb01d9 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1850,7 +1850,7 @@ export const router = (authToken?: string) => { return []; } if (entry.kind === "fallback") { - return [entry.descriptor]; + return [{ ...entry.descriptor, ownAiDefaults: entry.descriptor.aiDefaults }]; } return [ @@ -1863,6 +1863,7 @@ export const router = (authToken?: string) => { subagentRunnable: entry.resolvedFrontmatter.subagent?.runnable ?? false, base: entry.resolvedFrontmatter.base, aiDefaults: entry.resolvedFrontmatter.ai, + ownAiDefaults: entry.descriptor.aiDefaults, tools: entry.resolvedFrontmatter.tools, }, ]; From e891acd1a2076b27a9e67a6f5845d66ae010f487 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:51:52 +0000 Subject: [PATCH 23/36] fix: retain agent switch ordering across overlapping writes --- .../contexts/WorkspaceContext.test.tsx | 10 ++-- .../utils/workspaceAiSettingsSync.test.ts | 25 +++++++++- src/browser/utils/workspaceAiSettingsSync.ts | 49 +++++++++++++------ 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index c14999e1a62..d766b0e0401 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -22,7 +22,10 @@ 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 { markPendingWorkspaceAgentId } from "@/browser/utils/workspaceAiSettingsSync"; +import { + clearPendingWorkspaceAgentId, + markPendingWorkspaceAgentId, +} from "@/browser/utils/workspaceAiSettingsSync"; import { getProjectRouteId } from "@/common/utils/projectRouteId"; import type { RightSidebarLayoutState } from "@/browser/utils/rightSidebarLayout"; @@ -709,7 +712,7 @@ describe("WorkspaceContext", () => { "exec" ); - // The backend echo of the pending value clears the guard... + // The backend echo applies, but the guard remains until its write settles. await waitFor(() => expect(emitMetadata).toBeTruthy()); act(() => { emitMetadata?.({ @@ -718,8 +721,9 @@ describe("WorkspaceContext", () => { }); }); await waitFor(() => expect(ctx().workspaceMetadata.get(workspaceId)?.agentId).toBe("exec")); + clearPendingWorkspaceAgentId(workspaceId, "exec"); - // ...so later backend updates apply again. + // Once the write settles, later backend updates apply again. await waitFor(() => expect(emitMetadata).toBeTruthy()); act(() => { emitMetadata?.({ diff --git a/src/browser/utils/workspaceAiSettingsSync.test.ts b/src/browser/utils/workspaceAiSettingsSync.test.ts index 8c6971c4417..988d67cc1e3 100644 --- a/src/browser/utils/workspaceAiSettingsSync.test.ts +++ b/src/browser/utils/workspaceAiSettingsSync.test.ts @@ -7,7 +7,12 @@ import { getThinkingLevelKey, } from "@/common/constants/storage"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import { revertRejectedAgentSwitch } from "./workspaceAiSettingsSync"; +import { + clearPendingWorkspaceAgentId, + markPendingWorkspaceAgentId, + revertRejectedAgentSwitch, + shouldApplyWorkspaceAgentIdFromBackend, +} from "./workspaceAiSettingsSync"; const WORKSPACE_ID = "ws-revert"; @@ -35,6 +40,24 @@ function read(key: string): unknown { 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); + }); +}); + describe("revertRejectedAgentSwitch", () => { let cleanupDom: (() => void) | null = null; diff --git a/src/browser/utils/workspaceAiSettingsSync.ts b/src/browser/utils/workspaceAiSettingsSync.ts index 74295097051..51532a59825 100644 --- a/src/browser/utils/workspaceAiSettingsSync.ts +++ b/src/browser/utils/workspaceAiSettingsSync.ts @@ -98,22 +98,46 @@ export function shouldApplyWorkspaceAiSettingsFromBackend( return false; } -// Same pending-echo protection as AI settings, but for the workspace's active -// agent selection: a local mode switch must not be reverted by a stale -// metadata broadcast that raced the persistence write. -const pendingAgentIdByWorkspace = new Map(); +// 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; } - pendingAgentIdByWorkspace.set(workspaceId, agentId); + 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 { - // Clear only the matching entry so a failed write cannot wipe a newer - // pending selection from a rapid follow-up switch. - if (pendingAgentIdByWorkspace.get(workspaceId) === agentId) { + 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); } } @@ -123,14 +147,7 @@ export function shouldApplyWorkspaceAgentIdFromBackend( incomingAgentId: string ): boolean { const pending = pendingAgentIdByWorkspace.get(workspaceId); - if (!pending) { - return true; - } - if (pending === incomingAgentId) { - pendingAgentIdByWorkspace.delete(workspaceId); - return true; - } - return false; + return !pending || pending.latestAgentId === incomingAgentId; } /** From fc1f24152c651d512e3c9f4f5d2be66ae8e06434 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:29:25 +0000 Subject: [PATCH 24/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20serialize=20workspa?= =?UTF-8?q?ce=20AI=20settings=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with xum • Model: openai:gpt-5.6-sol • Thinking: high • Cost: --- src/browser/App.tsx | 11 +++--- src/browser/contexts/AgentContext.test.tsx | 35 +++++++++++------- src/browser/contexts/AgentContext.tsx | 6 ++-- src/browser/contexts/ThinkingContext.tsx | 6 ++-- src/browser/features/ChatInput/index.tsx | 6 ++-- .../ChatInput/useCreationWorkspace.ts | 7 ++-- .../utils/workspaceAiSettingsSync.test.ts | 36 +++++++++++++++++++ src/browser/utils/workspaceAiSettingsSync.ts | 18 ++++++++++ 8 files changed, 100 insertions(+), 25 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index f599e83d551..e83939a889c 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -88,6 +88,7 @@ import { clearPendingWorkspaceAiSettings, markPendingWorkspaceAiSettings, resolveEffectiveComposerModel, + serializeWorkspaceAiSettingsWrite, } from "@/browser/utils/workspaceAiSettingsSync"; import { AuthTokenModal } from "@/browser/components/AuthTokenModal/AuthTokenModal"; @@ -580,12 +581,13 @@ function AppInner() { reasoningMode, }); - api.workspace - .updateAgentAISettings({ + serializeWorkspaceAiSettingsWrite(workspaceId, () => + api.workspace.updateAgentAISettings({ workspaceId, agentId: normalizedAgentId, aiSettings: { model, thinkingLevel: normalized, reasoningMode }, }) + ) .then((result) => { if (!result.success) { clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); @@ -661,12 +663,13 @@ function AppInner() { reasoningMode: next, }); - api.workspace - .updateAgentAISettings({ + serializeWorkspaceAiSettingsWrite(workspaceId, () => + api.workspace.updateAgentAISettings({ workspaceId, agentId: normalizedAgentId, aiSettings: { model, thinkingLevel, reasoningMode: next }, }) + ) .then((result) => { if (!result.success) { clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index ff8c13d958a..6f45542833f 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -660,6 +660,14 @@ describe("AgentContext", () => { 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); } @@ -695,15 +703,17 @@ describe("AgentContext", () => { contextValue?.setAgentId("review"); await waitFor(() => { - expect(resolveUpdateAgentAISettings).not.toBeNull(); + expect(contextValue?.agentId).toBe("review"); }); - const rejectReviewSwitch = getDeferredUpdateResolver(); - // plan's rejection is skipped (a newer switch is active); review's - // rejection must restore the backend's agent (exec), not its captured - // previous agent (the also-rejected plan). + // 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" }); - rejectReviewSwitch?.({ success: false, error: "unpriced model" }); + await waitFor(() => { + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); + getDeferredUpdateResolver()?.({ success: false, error: "unpriced model" }); await waitFor(() => { expect(contextValue?.agentId).toBe("exec"); @@ -745,9 +755,10 @@ describe("AgentContext", () => { const acceptPlanSwitch = getDeferredUpdateResolver(); resolveUpdateAgentAISettings = null; - // plan→review goes in flight BEFORE the acceptance echo arrives, so its + // 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(); }); @@ -755,8 +766,6 @@ describe("AgentContext", () => { rejectReviewOnPlanCommit = () => rejectReviewSwitch?.({ success: false, error: "unpriced model" }); - acceptPlanSwitch?.({ success: true, data: undefined }); - // 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. @@ -805,12 +814,14 @@ describe("AgentContext", () => { contextValue?.setAgentId("review"); await waitFor(() => { - expect(resolveUpdateAgentAISettings).not.toBeNull(); + expect(contextValue?.agentId).toBe("review"); }); - const rejectReviewSwitch = getDeferredUpdateResolver(); rejectPlanSwitch?.({ success: false, error: "unpriced model" }); - rejectReviewSwitch?.({ success: false, error: "unpriced model" }); + await waitFor(() => { + expect(resolveUpdateAgentAISettings).not.toBeNull(); + }); + getDeferredUpdateResolver()?.({ success: false, error: "unpriced model" }); await waitFor(() => { expect(contextValue?.agentId).toBe("exec"); diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx index 13b841d84d4..08bbbcd9465 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -43,6 +43,7 @@ import { clearPendingWorkspaceAgentId, markPendingWorkspaceAgentId, revertRejectedAgentSwitch, + serializeWorkspaceAiSettingsWrite, } from "@/browser/utils/workspaceAiSettingsSync"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; @@ -261,8 +262,8 @@ function AgentProviderWithState(props: { }; markPendingWorkspaceAgentId(workspaceId, nextAgentId); - api.workspace - .updateAgentAISettings({ + serializeWorkspaceAiSettingsWrite(workspaceId, () => + api.workspace.updateAgentAISettings({ workspaceId, agentId: nextAgentId, aiSettings: { @@ -272,6 +273,7 @@ function AgentProviderWithState(props: { }, persistSelectedAgentId: true, }) + ) .then((result) => { if (!result.success) { notifySwitchRejected(typeof result.error === "string" ? result.error : ""); diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 0820b9f333a..c227dc83dcb 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -32,6 +32,7 @@ import { clearPendingWorkspaceAiSettings, getWorkspaceAiSettingsFromMetadata, markPendingWorkspaceAiSettings, + serializeWorkspaceAiSettingsWrite, } from "@/browser/utils/workspaceAiSettingsSync"; import { useOptionalWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds"; @@ -183,12 +184,13 @@ export const ThinkingProvider: React.FC = (props) => { // click through levels quickly (tests reproduce this by cycling to xhigh). markPendingWorkspaceAiSettings(workspaceId, normalizedAgentId, settings); - api.workspace - .updateAgentAISettings({ + serializeWorkspaceAiSettingsWrite(workspaceId, () => + api.workspace.updateAgentAISettings({ workspaceId, agentId: normalizedAgentId, aiSettings: settings, }) + ) .then((result) => { if (!result.success) { clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 7da7af63f9e..4624472491b 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -56,6 +56,7 @@ import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; import { clearPendingWorkspaceAiSettings, markPendingWorkspaceAiSettings, + serializeWorkspaceAiSettingsWrite, } from "@/browser/utils/workspaceAiSettingsSync"; import { resolveWorkspaceAiSettingsForAgent } from "@/browser/utils/workspaceModeAi"; import { @@ -962,12 +963,13 @@ const ChatInputInner: React.FC = (props) => { reasoningMode, }); - api.workspace - .updateAgentAISettings({ + serializeWorkspaceAiSettingsWrite(workspaceId, () => + api.workspace.updateAgentAISettings({ workspaceId, agentId: normalizedAgentId, aiSettings: { model: selectedModel, thinkingLevel, reasoningMode }, }) + ) .then((result) => { if (!result.success) { clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 80d9e475830..b497e5f311e 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -16,6 +16,7 @@ import { } from "@/common/types/thinking"; import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings"; import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; +import { serializeWorkspaceAiSettingsWrite } from "@/browser/utils/workspaceAiSettingsSync"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { @@ -622,8 +623,8 @@ 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({ + const initialAiSettingsPersisted = serializeWorkspaceAiSettingsWrite(metadata.id, () => + api.workspace.updateAgentAISettings({ workspaceId: metadata.id, agentId: settings.agentId, aiSettings: { @@ -633,7 +634,7 @@ export function useCreationWorkspace({ }, persistSelectedAgentId: true, }) - .catch(() => null); + ).catch(() => null); const isDraftScope = typeof draftId === "string" && draftId.trim().length > 0; const pendingScopeId = projectPath diff --git a/src/browser/utils/workspaceAiSettingsSync.test.ts b/src/browser/utils/workspaceAiSettingsSync.test.ts index 988d67cc1e3..a3194547510 100644 --- a/src/browser/utils/workspaceAiSettingsSync.test.ts +++ b/src/browser/utils/workspaceAiSettingsSync.test.ts @@ -11,6 +11,7 @@ import { clearPendingWorkspaceAgentId, markPendingWorkspaceAgentId, revertRejectedAgentSwitch, + serializeWorkspaceAiSettingsWrite, shouldApplyWorkspaceAgentIdFromBackend, } from "./workspaceAiSettingsSync"; @@ -56,6 +57,41 @@ describe("workspace agent persistence guard", () => { clearPendingWorkspaceAgentId(WORKSPACE_ID, "plan"); expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true); }); + + test("commits overlapping selections in initiation order", async () => { + let resolvePlan!: () => void; + let resolveReview!: () => void; + const planCommit = new Promise((resolve) => { + resolvePlan = resolve; + }); + const reviewCommit = new Promise((resolve) => { + resolveReview = resolve; + }); + const started: string[] = []; + let persistedAgentId = "exec"; + + const planWrite = serializeWorkspaceAiSettingsWrite(WORKSPACE_ID, async () => { + started.push("plan"); + await planCommit; + persistedAgentId = "plan"; + }); + const reviewWrite = serializeWorkspaceAiSettingsWrite(WORKSPACE_ID, async () => { + started.push("review"); + await reviewCommit; + persistedAgentId = "review"; + }); + + resolveReview(); + await Promise.resolve(); + expect(started).toEqual(["plan"]); + expect(persistedAgentId).toBe("exec"); + + resolvePlan(); + await Promise.all([planWrite, reviewWrite]); + + expect(started).toEqual(["plan", "review"]); + expect(persistedAgentId).toBe("review"); + }); }); describe("revertRejectedAgentSwitch", () => { diff --git a/src/browser/utils/workspaceAiSettingsSync.ts b/src/browser/utils/workspaceAiSettingsSync.ts index 51532a59825..77bc8716dc9 100644 --- a/src/browser/utils/workspaceAiSettingsSync.ts +++ b/src/browser/utils/workspaceAiSettingsSync.ts @@ -46,6 +46,24 @@ export function resolveEffectiveComposerModel( return normalizeModelPreference(preferredModel, metadataModel ?? defaultModel); } +const workspaceAiSettingsWriteChains = new Map>(); + +/** Keep direct renderer writes in initiation order so the latest local choice commits last. */ +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); + } + }); +} + const pendingAiSettingsByWorkspace = new Map(); function getPendingKey(workspaceId: string, agentId: string): string { From d5f1f4279384878c2e6540c228eeea2e43cae76f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:03:03 +0000 Subject: [PATCH 25/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20agent=20?= =?UTF-8?q?definition=20defaults=20across=20switches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _Generated with `xum` • Model: `openai:gpt-5.6-sol` • Thinking: `high` • Cost: `$254.64`_ --- .../Tools/ProposePlanToolCall.test.tsx | 20 ++++++++ .../features/Tools/ProposePlanToolCall.tsx | 7 ++- src/common/orpc/schemas/agentDefinition.ts | 4 +- src/node/orpc/router.test.ts | 51 +++++++++++++++++++ src/node/orpc/router.ts | 31 ++++++++--- 5 files changed, 103 insertions(+), 10 deletions(-) diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index 86398211287..e2c04158172 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -227,6 +227,7 @@ function createTestAgent( uiSelectable: true, subagentRunnable: true, aiDefaults: { model, thinkingLevel }, + ownAiDefaults: { model, thinkingLevel }, }; } @@ -562,6 +563,25 @@ describe("ProposePlanToolCall", () => { ); }); + 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"); diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index e24144be7c7..3138db53b74 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -514,7 +514,12 @@ export const ProposePlanToolCall: React.FC = (props) = existingModel, existingThinking, existingReasoningMode: existingReasoning, - agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])), + agentDescriptorById: new Map( + agents.map((agent) => [ + agent.id, + { base: agent.base, definitionAiDefaults: agent.ownAiDefaults }, + ]) + ), }); const previousAgentId = diff --git a/src/common/orpc/schemas/agentDefinition.ts b/src/common/orpc/schemas/agentDefinition.ts index 362a6d4c128..b8c7be1fc15 100644 --- a/src/common/orpc/schemas/agentDefinition.ts +++ b/src/common/orpc/schemas/agentDefinition.ts @@ -101,8 +101,8 @@ export const AgentDefinitionDescriptorSchema = z // Base agent ID for inheritance (e.g., "exec", "plan", or custom agent) base: AgentIdSchema.optional(), aiDefaults: AgentDefinitionAiDefaultsSchema.optional(), - // This descriptor's unmerged frontmatter defaults. Resolution consumers use - // this field per hop; aiDefaults remains the effective value for UI display. + // 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(), // Tool configuration (for UI display / inheritance computation) tools: AgentDefinitionToolsSchema.optional(), diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index f27c352639a..684723a17b8 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -57,6 +57,57 @@ 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 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 d1911bb01d9..ceacb627a51 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -107,6 +107,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"; @@ -1805,14 +1807,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 } = collectDefinitionLayers( descriptor.id, - { - includeAgentPlugins, - skipScopesAbove: getSkipScopesAboveForKnownScope(descriptor.scope), - } + inheritanceChain ); const effectivelyDisabled = isAgentEffectivelyDisabled({ @@ -1837,6 +1853,7 @@ export const router = (authToken?: string) => { kind: "resolved" as const, descriptor, resolvedFrontmatter, + targetDefinitionAiDefaults, uiSelectableBase, }; } catch { @@ -1863,7 +1880,7 @@ export const router = (authToken?: string) => { subagentRunnable: entry.resolvedFrontmatter.subagent?.runnable ?? false, base: entry.resolvedFrontmatter.base, aiDefaults: entry.resolvedFrontmatter.ai, - ownAiDefaults: entry.descriptor.aiDefaults, + ownAiDefaults: entry.targetDefinitionAiDefaults, tools: entry.resolvedFrontmatter.tools, }, ]; From b27cf159e4918ad475c67a87c5765b12c29a1b0d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:35:17 +0000 Subject: [PATCH 26/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20serialize=20workspa?= =?UTF-8?q?ce=20AI=20persistence=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/App.tsx | 26 +++-- .../WorkspaceModeAISync.test.tsx | 61 +++++++++++- .../WorkspaceModeAISync.tsx | 33 +++---- src/browser/contexts/AgentContext.tsx | 57 +++++------ src/browser/contexts/ThinkingContext.tsx | 14 ++- src/browser/features/ChatInput/index.tsx | 20 ++-- .../ChatInput/useCreationWorkspace.ts | 61 ++++++------ .../Tools/ProposePlanToolCall.test.tsx | 52 ++++++++-- .../features/Tools/ProposePlanToolCall.tsx | 74 +++++++------- src/browser/utils/chatCommands.ts | 37 ++++--- .../utils/workspaceAiSettingsSync.test.ts | 51 ++++++---- src/browser/utils/workspaceAiSettingsSync.ts | 34 ++++++- src/browser/utils/workspaceModeAi.ts | 99 ++++++++++++++++--- 13 files changed, 403 insertions(+), 216 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index e83939a889c..5c1073a1ef2 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -88,7 +88,7 @@ import { clearPendingWorkspaceAiSettings, markPendingWorkspaceAiSettings, resolveEffectiveComposerModel, - serializeWorkspaceAiSettingsWrite, + updateWorkspaceAgentAISettings, } from "@/browser/utils/workspaceAiSettingsSync"; import { AuthTokenModal } from "@/browser/components/AuthTokenModal/AuthTokenModal"; @@ -581,13 +581,11 @@ function AppInner() { reasoningMode, }); - serializeWorkspaceAiSettingsWrite(workspaceId, () => - 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); @@ -663,13 +661,11 @@ function AppInner() { reasoningMode: next, }); - serializeWorkspaceAiSettingsWrite(workspaceId, () => - 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 d9916f3c32b..fb7880fba06 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx @@ -28,12 +28,36 @@ const noop = () => { // intentional noop for tests }; +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 ?? []; + const agents = props.agents ?? DEFAULT_AGENTS; return ( { }); }); + 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"; diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx index 8902652f641..04bca93d225 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx @@ -67,26 +67,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, - agentDescriptorById: new Map( - agents.map((agent) => [ - agent.id, - { base: agent.base, definitionAiDefaults: agent.ownAiDefaults }, - ]) - ), - }); + 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.tsx b/src/browser/contexts/AgentContext.tsx index 08bbbcd9465..0cba51dd2da 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -43,7 +43,7 @@ import { clearPendingWorkspaceAgentId, markPendingWorkspaceAgentId, revertRejectedAgentSwitch, - serializeWorkspaceAiSettingsWrite, + updateWorkspaceAgentAISettings, } from "@/browser/utils/workspaceAiSettingsSync"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; @@ -202,23 +202,22 @@ function AgentProviderWithState(props: { getWorkspaceAISettingsByAgentKey(workspaceId), {} ); - const { resolvedModel, resolvedThinking, resolvedReasoningMode } = - resolveWorkspaceAiSettingsForAgent({ - agentId: nextAgentId, - agentAiDefaults, - workspaceByAgent, - useWorkspaceByAgentFallback: true, - fallbackModel: getDefaultModel(), - existingModel: previousModel, - existingThinking: previousThinking, - existingReasoningMode: previousReasoning, - agentDescriptorById: new Map( - agents.map((agent) => [ - agent.id, - { base: agent.base, definitionAiDefaults: agent.ownAiDefaults }, - ]) - ), - }); + 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 @@ -262,18 +261,16 @@ function AgentProviderWithState(props: { }; markPendingWorkspaceAgentId(workspaceId, nextAgentId); - serializeWorkspaceAiSettingsWrite(workspaceId, () => - api.workspace.updateAgentAISettings({ - workspaceId, - agentId: nextAgentId, - aiSettings: { - model: resolvedModel, - thinkingLevel: resolvedThinking, - ...(resolvedReasoningMode != null ? { reasoningMode: resolvedReasoningMode } : {}), - }, - persistSelectedAgentId: true, - }) - ) + updateWorkspaceAgentAISettings(api, { + workspaceId, + agentId: nextAgentId, + aiSettings: { + model: resolvedModel, + thinkingLevel: resolvedThinking, + ...(resolvedReasoningMode != null ? { reasoningMode: resolvedReasoningMode } : {}), + }, + persistSelectedAgentId: true, + }) .then((result) => { if (!result.success) { notifySwitchRejected(typeof result.error === "string" ? result.error : ""); diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index c227dc83dcb..5a3b44a9281 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -32,7 +32,7 @@ import { clearPendingWorkspaceAiSettings, getWorkspaceAiSettingsFromMetadata, markPendingWorkspaceAiSettings, - serializeWorkspaceAiSettingsWrite, + updateWorkspaceAgentAISettings, } from "@/browser/utils/workspaceAiSettingsSync"; import { useOptionalWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds"; @@ -184,13 +184,11 @@ export const ThinkingProvider: React.FC = (props) => { // click through levels quickly (tests reproduce this by cycling to xhigh). markPendingWorkspaceAiSettings(workspaceId, normalizedAgentId, settings); - serializeWorkspaceAiSettingsWrite(workspaceId, () => - 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/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 4624472491b..12680cb3b17 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -56,7 +56,8 @@ import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; import { clearPendingWorkspaceAiSettings, markPendingWorkspaceAiSettings, - serializeWorkspaceAiSettingsWrite, + sendWorkspaceMessage, + updateWorkspaceAgentAISettings, } from "@/browser/utils/workspaceAiSettingsSync"; import { resolveWorkspaceAiSettingsForAgent } from "@/browser/utils/workspaceModeAi"; import { @@ -963,13 +964,11 @@ const ChatInputInner: React.FC = (props) => { reasoningMode, }); - serializeWorkspaceAiSettingsWrite(workspaceId, () => - 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); @@ -1314,7 +1313,8 @@ const ChatInputInner: React.FC = (props) => { existingModel, existingThinking, existingReasoningMode: existingReasoning, - agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])), + agents, + mode: "creation-sync", }); if (existingModel !== resolvedModel) { @@ -3210,7 +3210,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 b497e5f311e..a0a27f6b0df 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -16,7 +16,10 @@ import { } from "@/common/types/thinking"; import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings"; import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; -import { serializeWorkspaceAiSettingsWrite } from "@/browser/utils/workspaceAiSettingsSync"; +import { + sendWorkspaceMessage, + updateWorkspaceAgentAISettings, +} from "@/browser/utils/workspaceAiSettingsSync"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { @@ -623,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 = serializeWorkspaceAiSettingsWrite(metadata.id, () => - 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 @@ -805,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/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index e2c04158172..1a9d130332e 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -62,7 +62,9 @@ interface MockApi { }) => Promise; sendMessage: ( args: SendMessageArgs - ) => Promise<{ success: true; data: undefined } | { success: false; error: string }>; + ) => Promise< + { success: true; data: Record } | { success: false; error: string } + >; updateAgentAISettings: (args: { workspaceId: string; agentId: string; @@ -234,21 +236,27 @@ function createTestAgent( 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, @@ -284,8 +292,7 @@ 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 @@ -321,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: {} }); }; } @@ -511,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"; @@ -723,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 3138db53b74..dd7222aafcb 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -58,8 +58,10 @@ import { clearPendingWorkspaceAgentId, markPendingWorkspaceAgentId, revertRejectedAgentSwitch, + sendWorkspaceMessage, } from "@/browser/utils/workspaceAiSettingsSync"; import { + hasWorkspaceAiTargetDescriptor, resolveWorkspaceAiSettingsForAgent, type WorkspaceAISettingsCache, } from "@/browser/utils/workspaceModeAi"; @@ -193,8 +195,10 @@ 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(); @@ -487,7 +491,7 @@ export const ProposePlanToolCall: React.FC = (props) = 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); @@ -502,25 +506,19 @@ 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, - agentDescriptorById: new Map( - agents.map((agent) => [ - agent.id, - { base: agent.base, definitionAiDefaults: agent.ownAiDefaults }, - ]) - ), - }); + 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; @@ -566,7 +564,7 @@ export const ProposePlanToolCall: React.FC = (props) = }; const handleImplement = async () => { - if (!workspaceId || !api) return; + if (!workspaceId || !api || !canResolveExec) return; if (isImplementingRef.current) return; isImplementingRef.current = true; @@ -593,11 +591,12 @@ export const ProposePlanToolCall: React.FC = (props) = }); } - const { resolvedModel, resolvedThinking, revertSelection } = - resolveAndPersistTargetAgentSettings({ - workspaceId, - targetAgentId, - }); + const targetSettings = resolveAndPersistTargetAgentSettings({ + workspaceId, + targetAgentId, + }); + if (!targetSettings) return; + const { resolvedModel, resolvedThinking, revertSelection } = targetSettings; const sendMessageOptions = getSendOptionsFromStorage(workspaceId); // The send carries the switch and persists it backend-side best-effort @@ -605,7 +604,7 @@ export const ProposePlanToolCall: React.FC = (props) = // 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 api.workspace.sendMessage({ + const result = await sendWorkspaceMessage(api, { workspaceId, message: "Implement the plan", options: { @@ -632,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; @@ -659,16 +658,17 @@ export const ProposePlanToolCall: React.FC = (props) = }); } - const { resolvedModel, resolvedThinking, revertSelection } = - resolveAndPersistTargetAgentSettings({ - workspaceId, - targetAgentId, - }); + const targetSettings = resolveAndPersistTargetAgentSettings({ + workspaceId, + targetAgentId, + }); + if (!targetSettings) return; + const { resolvedModel, resolvedThinking, revertSelection } = targetSettings; const sendMessageOptions = getSendOptionsFromStorage(workspaceId); // See handleImplement: transport failures keep the switch; typed // rejections revert it. - const result = await api.workspace.sendMessage({ + const result = await sendWorkspaceMessage(api, { workspaceId, message: "Implement the plan", options: { @@ -763,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" @@ -776,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/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 index a3194547510..739c899d3bf 100644 --- a/src/browser/utils/workspaceAiSettingsSync.test.ts +++ b/src/browser/utils/workspaceAiSettingsSync.test.ts @@ -7,12 +7,14 @@ import { getThinkingLevelKey, } from "@/common/constants/storage"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { APIClient } from "@/browser/contexts/API"; import { clearPendingWorkspaceAgentId, markPendingWorkspaceAgentId, revertRejectedAgentSwitch, - serializeWorkspaceAiSettingsWrite, + sendWorkspaceMessage, shouldApplyWorkspaceAgentIdFromBackend, + updateWorkspaceAgentAISettings, } from "./workspaceAiSettingsSync"; const WORKSPACE_ID = "ws-revert"; @@ -58,39 +60,52 @@ describe("workspace agent persistence guard", () => { expect(shouldApplyWorkspaceAgentIdFromBackend(WORKSPACE_ID, "plan")).toBe(true); }); - test("commits overlapping selections in initiation order", async () => { + test("commits settings updates and sends in initiation order", async () => { let resolvePlan!: () => void; - let resolveReview!: () => void; const planCommit = new Promise((resolve) => { resolvePlan = resolve; }); - const reviewCommit = new Promise((resolve) => { - resolveReview = 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 = serializeWorkspaceAiSettingsWrite(WORKSPACE_ID, async () => { - started.push("plan"); - await planCommit; - persistedAgentId = "plan"; + const planWrite = updateWorkspaceAgentAISettings(api, { + workspaceId: WORKSPACE_ID, + agentId: "plan", + aiSettings: { model: "openai:plan", thinkingLevel: "high" }, + persistSelectedAgentId: true, }); - const reviewWrite = serializeWorkspaceAiSettingsWrite(WORKSPACE_ID, async () => { - started.push("review"); - await reviewCommit; - persistedAgentId = "review"; + const execSend = sendWorkspaceMessage(api, { + workspaceId: WORKSPACE_ID, + message: "Implement the plan", + options: { agentId: "exec", model: "openai:exec", thinkingLevel: "medium" }, }); - resolveReview(); await Promise.resolve(); expect(started).toEqual(["plan"]); expect(persistedAgentId).toBe("exec"); resolvePlan(); - await Promise.all([planWrite, reviewWrite]); + await Promise.all([planWrite, execSend]); - expect(started).toEqual(["plan", "review"]); - expect(persistedAgentId).toBe("review"); + expect(started).toEqual(["plan", "exec"]); + expect(persistedAgentId).toBe("exec"); }); }); diff --git a/src/browser/utils/workspaceAiSettingsSync.ts b/src/browser/utils/workspaceAiSettingsSync.ts index 77bc8716dc9..a6b8463b4af 100644 --- a/src/browser/utils/workspaceAiSettingsSync.ts +++ b/src/browser/utils/workspaceAiSettingsSync.ts @@ -10,6 +10,7 @@ import { import { normalizeAgentId, resolvePersistedAgentId } from "@/common/utils/agentIds"; 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; @@ -48,8 +49,18 @@ export function resolveEffectiveComposerModel( const workspaceAiSettingsWriteChains = new Map>(); -/** Keep direct renderer writes in initiation order so the latest local choice commits last. */ -export function serializeWorkspaceAiSettingsWrite( +interface WorkspaceSendApi { + workspace: Pick; +} + +interface WorkspaceAiSettingsUpdateApi { + workspace: Pick; +} +type SendMessageInput = Parameters[0]; +type UpdateAgentAISettingsInput = Parameters[0]; + +/** Keep browser writes that can persist workspace AI state in initiation order. */ +function serializeWorkspaceAiSettingsWrite( workspaceId: string, write: () => Promise ): Promise { @@ -64,6 +75,25 @@ export function serializeWorkspaceAiSettingsWrite( }); } +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); +} + const pendingAiSettingsByWorkspace = new Map(); function getPendingKey(workspaceId: string, agentId: string): string { diff --git a/src/browser/utils/workspaceModeAi.ts b/src/browser/utils/workspaceModeAi.ts index ae290929d6d..8409a43e2fe 100644 --- a/src/browser/utils/workspaceModeAi.ts +++ b/src/browser/utils/workspaceModeAi.ts @@ -1,4 +1,5 @@ import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; +import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import { targetWorkspaceBucketToLayer, type AiSettingSource } from "@/common/types/agentAiSettings"; import { coerceOpenAIReasoningMode, @@ -61,36 +62,104 @@ 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; + +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; -} { - const normalizedAgentId = normalizeAgentId(args.agentId); - const workspaceOverride = args.workspaceByAgent?.[normalizedAgentId]; - const descriptorsById = new Map(args.agentDescriptorById ?? []); +} + +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 ?? []) { - descriptorsById.set(id, { ...descriptorsById.get(id), base }); + 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); +} + +// 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 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) + ) { + return null; + } + + const workspaceOverride = args.workspaceByAgent?.[normalizedAgentId]; + const descriptorsById = buildAgentDescriptorLookup(args, mode !== "background-sync"); const resolved = resolveAgentAiSettings({ targetAgentId: normalizedAgentId, profile: "interactive", targetWorkspaceSettings: - args.useWorkspaceByAgentFallback && workspaceOverride != null + mode === "explicit-switch" && workspaceOverride != null ? targetWorkspaceBucketToLayer(workspaceOverride) : undefined, agentAiDefaults: args.agentAiDefaults, @@ -104,10 +173,10 @@ export function resolveWorkspaceAiSettingsForAgent(args: { defaultModel: args.fallbackModel, }); - // Background sync trusts the live workspace mode, which hydration seeds from - // the backend bucket, instead of restoring the saved bucket or configured mode. + // A hydrated per-agent bucket owns the active background runtime. Descriptor + // arrival must not reinterpret an absent reasoning value as a new default. const resolvedReasoningMode = - workspaceOverride != null && !args.useWorkspaceByAgentFallback + workspaceOverride != null && mode === "background-sync" ? (coerceOpenAIReasoningMode(args.existingReasoningMode) ?? "standard") : (resolved.selected.reasoningMode ?? "standard"); From fb043665d28245cc56f150361df2088205afd8ac Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:05:13 +0000 Subject: [PATCH 27/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20creation?= =?UTF-8?q?=20model=20after=20descriptor=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/features/ChatInput/index.tsx | 17 +++++---- src/browser/utils/workspaceModeAi.test.ts | 44 ++++++++++++++++++++++- src/browser/utils/workspaceModeAi.ts | 22 ++++++++++++ 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 42bf87b8961..7fc6f473e42 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -59,7 +59,10 @@ import { sendWorkspaceMessage, updateWorkspaceAgentAISettings, } from "@/browser/utils/workspaceAiSettingsSync"; -import { resolveWorkspaceAiSettingsForAgent } from "@/browser/utils/workspaceModeAi"; +import { + getCreationWorkspaceAiSyncState, + resolveWorkspaceAiSettingsForAgent, +} from "@/browser/utils/workspaceModeAi"; import { getModelKey, getReasoningModeKey, @@ -1281,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; @@ -1311,7 +1316,7 @@ const ChatInputInner: React.FC = (props) => { existingThinking, existingReasoningMode: existingReasoning, agents, - mode: "creation-sync", + mode, }); if (existingModel !== resolvedModel) { diff --git a/src/browser/utils/workspaceModeAi.test.ts b/src/browser/utils/workspaceModeAi.test.ts index 611fcd7cac8..bb1e9dc6552 100644 --- a/src/browser/utils/workspaceModeAi.test.ts +++ b/src/browser/utils/workspaceModeAi.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test"; import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import type { AgentAncestorDescriptor } from "@/common/utils/ai/agentAncestorLayers"; -import { resolveWorkspaceAiSettingsForAgent } from "./workspaceModeAi"; +import { + getCreationWorkspaceAiSyncState, + resolveWorkspaceAiSettingsForAgent, +} from "./workspaceModeAi"; describe("resolveWorkspaceAiSettingsForAgent", () => { test("uses global agent defaults when configured", () => { @@ -482,6 +485,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 8409a43e2fe..45691895bf7 100644 --- a/src/browser/utils/workspaceModeAi.ts +++ b/src/browser/utils/workspaceModeAi.ts @@ -123,6 +123,28 @@ export function hasWorkspaceAiTargetDescriptor( 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( From a8d89f84cc4acd0d7b6c3696512ae91cb003cf69 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:07:49 +0000 Subject: [PATCH 28/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20serialize=20workspa?= =?UTF-8?q?ce=20stream=20resumes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Messages/ChatBarrier/RetryBarrier.tsx | 3 +- .../Tools/AskUserQuestionToolCall.tsx | 3 +- src/browser/hooks/useResumeStream.ts | 3 +- .../utils/workspaceAiSettingsSync.test.ts | 47 +++++++++++++++++++ src/browser/utils/workspaceAiSettingsSync.ts | 15 ++++++ 5 files changed, 68 insertions(+), 3 deletions(-) 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/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/workspaceAiSettingsSync.test.ts b/src/browser/utils/workspaceAiSettingsSync.test.ts index 739c899d3bf..332335a616a 100644 --- a/src/browser/utils/workspaceAiSettingsSync.test.ts +++ b/src/browser/utils/workspaceAiSettingsSync.test.ts @@ -12,6 +12,7 @@ import { clearPendingWorkspaceAgentId, markPendingWorkspaceAgentId, revertRejectedAgentSwitch, + resumeWorkspaceStream, sendWorkspaceMessage, shouldApplyWorkspaceAgentIdFromBackend, updateWorkspaceAgentAISettings, @@ -107,6 +108,52 @@ describe("workspace agent persistence guard", () => { 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", () => { diff --git a/src/browser/utils/workspaceAiSettingsSync.ts b/src/browser/utils/workspaceAiSettingsSync.ts index a6b8463b4af..2cbea471263 100644 --- a/src/browser/utils/workspaceAiSettingsSync.ts +++ b/src/browser/utils/workspaceAiSettingsSync.ts @@ -53,10 +53,15 @@ interface WorkspaceSendApi { workspace: Pick; } +interface WorkspaceResumeApi { + workspace: Pick; +} + interface WorkspaceAiSettingsUpdateApi { workspace: Pick; } type SendMessageInput = Parameters[0]; +type ResumeStreamInput = Parameters[0]; type UpdateAgentAISettingsInput = Parameters[0]; /** Keep browser writes that can persist workspace AI state in initiation order. */ @@ -94,6 +99,16 @@ export function sendWorkspaceMessage( : 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 { From e8c902e68ccdc89e6c14a2c37e6deb7411836cbd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:14:51 +0000 Subject: [PATCH 29/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20snapshot=20latest?= =?UTF-8?q?=20settings=20when=20forking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/workspaceService.test.ts | 33 ++++++++++++++++------ src/node/services/workspaceService.ts | 18 ++++++++---- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 4760a8c825a..3b3f0cc1aed 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -17224,7 +17224,7 @@ describe("WorkspaceService fork", () => { } }); - test("forks inherit persisted agent settings while normalizing legacy fork families", 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"); @@ -17243,6 +17243,21 @@ describe("WorkspaceService fork", () => { }, 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 }); @@ -17258,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 @@ -17329,17 +17346,17 @@ 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("researcher"); - expect(result.data.metadata.aiSettingsByAgent).toEqual(sourceMetadata.aiSettingsByAgent); - expect(result.data.metadata.aiSettings).toEqual(sourceMetadata.aiSettings); + 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: "researcher", - aiSettingsByAgent: sourceMetadata.aiSettingsByAgent, - aiSettings: sourceMetadata.aiSettings, + agentId: latestAgentId, + aiSettingsByAgent: latestAiSettingsByAgent, + aiSettings: latestAiSettings, }); } finally { orchestrateForkSpy.mockRestore(); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 227715dadac..4d3532cb1c4 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -10568,7 +10568,15 @@ export class WorkspaceService extends EventEmitter { // Compute namedWorkspacePath for frontend metadata const namedWorkspacePath = targetRuntime.getWorkspacePath(foundProjectPath, resolvedName); - const sourceAgentId = resolvePersistedAgentId(sourceMetadata, ""); + // 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, @@ -10581,12 +10589,12 @@ export class WorkspaceService extends EventEmitter { namedWorkspacePath, // Persist the source selection so other clients and background continuations hydrate the fork identically. ...(sourceAgentId === "" ? {} : { agentId: sourceAgentId }), - ...(sourceMetadata.aiSettingsByAgent == null + ...(latestSourceMetadata.aiSettingsByAgent == null ? {} - : { aiSettingsByAgent: { ...sourceMetadata.aiSettingsByAgent } }), - ...(sourceMetadata.aiSettings == null + : { aiSettingsByAgent: { ...latestSourceMetadata.aiSettingsByAgent } }), + ...(latestSourceMetadata.aiSettings == null ? {} - : { aiSettings: { ...sourceMetadata.aiSettings } }), + : { 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 From 25acf9250ec3f595d56f7cdf604cdefd701cb2f8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:23:20 +0000 Subject: [PATCH 30/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20serialize=20ACP=20w?= =?UTF-8?q?orkspace=20AI=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/utils/workspaceAiSettingsSync.ts | 19 +------ .../utils/ai/workspaceAiSettingsWrite.ts | 17 ++++++ src/node/acp/agent.ts | 15 ++++-- src/node/acp/configOptions.ts | 10 ++-- src/node/acp/workspaceAiSettingsSync.test.ts | 53 +++++++++++++++++++ src/node/acp/workspaceAiSettingsSync.ts | 34 ++++++++++++ 6 files changed, 122 insertions(+), 26 deletions(-) create mode 100644 src/common/utils/ai/workspaceAiSettingsWrite.ts create mode 100644 src/node/acp/workspaceAiSettingsSync.test.ts create mode 100644 src/node/acp/workspaceAiSettingsSync.ts diff --git a/src/browser/utils/workspaceAiSettingsSync.ts b/src/browser/utils/workspaceAiSettingsSync.ts index 2cbea471263..387bc9e2be6 100644 --- a/src/browser/utils/workspaceAiSettingsSync.ts +++ b/src/browser/utils/workspaceAiSettingsSync.ts @@ -8,6 +8,7 @@ import { 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"; @@ -47,8 +48,6 @@ export function resolveEffectiveComposerModel( return normalizeModelPreference(preferredModel, metadataModel ?? defaultModel); } -const workspaceAiSettingsWriteChains = new Map>(); - interface WorkspaceSendApi { workspace: Pick; } @@ -64,22 +63,6 @@ type SendMessageInput = Parameters[0]; type ResumeStreamInput = Parameters[0]; type UpdateAgentAISettingsInput = Parameters[0]; -/** Keep browser writes that can persist workspace AI state in initiation order. */ -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); - } - }); -} - export function updateWorkspaceAgentAISettings( api: WorkspaceAiSettingsUpdateApi, input: UpdateAgentAISettingsInput 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 6c8a29f3966..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"; @@ -250,7 +254,7 @@ async function persistAgentAiSettings( // 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 client.workspace.updateAgentAISettings({ + const updateResult = await updateAcpWorkspaceAgentAISettings(client, { workspaceId, agentId, aiSettings, @@ -261,7 +265,7 @@ async function persistAgentAiSettings( } if (isModeAgentId(agentId)) { - const updateModeResult = await client.workspace.updateModeAISettings({ + const updateModeResult = await updateAcpWorkspaceModeAISettings(client, { workspaceId, mode: agentId, aiSettings, @@ -270,7 +274,7 @@ async function persistAgentAiSettings( return; } - const updateAgentResult = await client.workspace.updateAgentAISettings({ + const updateAgentResult = await updateAcpWorkspaceAgentAISettings(client, { workspaceId, agentId, aiSettings, 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) + ); +} From 7f95564b0c990dc01d85791c4d31a7864e5d65c1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:19:04 +0000 Subject: [PATCH 31/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20built-in=20a?= =?UTF-8?q?gent=20switching=20available?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/contexts/AgentContext.test.tsx | 38 +++++++++++++++++++++- src/browser/utils/agents.ts | 4 +++ src/browser/utils/workspaceModeAi.ts | 4 ++- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index 6f45542833f..5fc8987c318 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -28,6 +28,7 @@ import type * as RouterContextModule from "./RouterContext"; import type * as WorkspaceContextModule from "./WorkspaceContext"; let mockAgentDefinitions: AgentDefinitionDescriptor[] = []; +let rejectAgentDefinitions = false; let mockWorkspaceMetadata = new Map< string, { parentWorkspaceId?: string; agentId?: string; agentType?: string } @@ -270,7 +271,10 @@ 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), @@ -346,6 +350,7 @@ describe("AgentContext", () => { beforeEach(async () => { isolatedModuleDir = await importIsolatedAgentModules(); mockAgentDefinitions = []; + rejectAgentDefinitions = false; mockWorkspaceMetadata = new Map(); updateAgentAISettingsCalls = []; deferUpdateAgentAISettings = false; @@ -467,6 +472,37 @@ 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"; 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/workspaceModeAi.ts b/src/browser/utils/workspaceModeAi.ts index 45691895bf7..3a9db53ff82 100644 --- a/src/browser/utils/workspaceModeAi.ts +++ b/src/browser/utils/workspaceModeAi.ts @@ -1,4 +1,5 @@ import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; +import { isBuiltInSelectableAgentId } from "@/browser/utils/agents"; import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import { targetWorkspaceBucketToLayer, type AiSettingSource } from "@/common/types/agentAiSettings"; import { @@ -170,7 +171,8 @@ export function resolveWorkspaceAiSettingsForAgent( if ( mode === "explicit-switch" && args.agents != null && - !hasWorkspaceAiTargetDescriptor(normalizedAgentId, args.agents) + !hasWorkspaceAiTargetDescriptor(normalizedAgentId, args.agents) && + !isBuiltInSelectableAgentId(normalizedAgentId) ) { return null; } From c3598ac5ef046458c7c47d2239d46c3e533a1063 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:25:13 +0000 Subject: [PATCH 32/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20guard=20switched=20?= =?UTF-8?q?agent=20settings=20from=20stale=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/contexts/AgentContext.test.tsx | 59 ++++++++++++++++++++++ src/browser/contexts/AgentContext.tsx | 26 ++++++---- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index 5fc8987c318..f683d26340e 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -543,6 +543,65 @@ describe("AgentContext", () => { 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"; diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx index 0cba51dd2da..5aaa8b41a7d 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -41,7 +41,9 @@ import { sortAgentsStable } from "@/browser/utils/agents"; import { normalizeAgentId, resolveRemovedBuiltinAgentId } from "@/common/utils/agentIds"; import { clearPendingWorkspaceAgentId, + clearPendingWorkspaceAiSettings, markPendingWorkspaceAgentId, + markPendingWorkspaceAiSettings, revertRejectedAgentSwitch, updateWorkspaceAgentAISettings, } from "@/browser/utils/workspaceAiSettingsSync"; @@ -260,15 +262,17 @@ function AgentProviderWithState(props: { }); }; + const nextAiSettings = { + model: resolvedModel, + thinkingLevel: resolvedThinking, + ...(resolvedReasoningMode != null ? { reasoningMode: resolvedReasoningMode } : {}), + }; markPendingWorkspaceAgentId(workspaceId, nextAgentId); + markPendingWorkspaceAiSettings(workspaceId, nextAgentId, nextAiSettings); updateWorkspaceAgentAISettings(api, { workspaceId, agentId: nextAgentId, - aiSettings: { - model: resolvedModel, - thinkingLevel: resolvedThinking, - ...(resolvedReasoningMode != null ? { reasoningMode: resolvedReasoningMode } : {}), - }, + aiSettings: nextAiSettings, persistSelectedAgentId: true, }) .then((result) => { @@ -276,16 +280,18 @@ function AgentProviderWithState(props: { notifySwitchRejected(typeof result.error === "string" ? result.error : ""); revertRejectedSwitch(); } - // Release the guard on every settled write: no-op writes (backend - // already on this agent) and failed writes emit no metadata echo, - // and a stuck guard would block future backend agent seeds. For - // changed writes the echo is ordered after any stale broadcast, so - // releasing on the response cannot strand a stale value. + // 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); }); }, [ From 2fd2af98472f07c5b6b42b89838904d19a498bda Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:36:55 +0000 Subject: [PATCH 33/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20hidden?= =?UTF-8?q?=20agent=20ancestry=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/utils/workspaceModeAi.test.ts | 30 +++++++++++ src/browser/utils/workspaceModeAi.ts | 17 ++++-- src/common/orpc/schemas/agentDefinition.ts | 11 ++++ src/node/orpc/router.test.ts | 61 ++++++++++++++++++++++ src/node/orpc/router.ts | 4 +- 5 files changed, 119 insertions(+), 4 deletions(-) diff --git a/src/browser/utils/workspaceModeAi.test.ts b/src/browser/utils/workspaceModeAi.test.ts index bb1e9dc6552..2554ec311de 100644 --- a/src/browser/utils/workspaceModeAi.test.ts +++ b/src/browser/utils/workspaceModeAi.test.ts @@ -172,6 +172,36 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { 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-by-agent fallback when disabled", () => { const result = resolveWorkspaceAiSettingsForAgent({ agentId: "exec", diff --git a/src/browser/utils/workspaceModeAi.ts b/src/browser/utils/workspaceModeAi.ts index 3a9db53ff82..a25448fdfce 100644 --- a/src/browser/utils/workspaceModeAi.ts +++ b/src/browser/utils/workspaceModeAi.ts @@ -65,7 +65,10 @@ export function resolveConfiguredAiDefaults( type WorkspaceAiResolutionMode = "explicit-switch" | "background-sync" | "creation-sync"; -type WorkspaceAgentDescriptor = Pick; +type WorkspaceAgentDescriptor = Pick< + AgentDefinitionDescriptor, + "id" | "base" | "ownAiDefaults" | "aiAncestors" +>; interface WorkspaceAiResolutionArgs { agentId: string; @@ -178,7 +181,15 @@ export function resolveWorkspaceAiSettingsForAgent( } const workspaceOverride = args.workspaceByAgent?.[normalizedAgentId]; - const descriptorsById = buildAgentDescriptorLookup(args, mode !== "background-sync"); + const includeDefinitionDefaults = mode !== "background-sync"; + const descriptorsById = buildAgentDescriptorLookup(args, includeDefinitionDefaults); + const targetDescriptor = args.agents?.find( + (agent) => normalizeAgentId(agent.id) === normalizedAgentId + ); + const ancestors = + includeDefinitionDefaults && targetDescriptor?.aiAncestors + ? targetDescriptor.aiAncestors + : collectDeclaredAncestorLayers(normalizedAgentId, descriptorsById); const resolved = resolveAgentAiSettings({ targetAgentId: normalizedAgentId, profile: "interactive", @@ -188,7 +199,7 @@ export function resolveWorkspaceAiSettingsForAgent( : undefined, agentAiDefaults: args.agentAiDefaults, targetDefinitionAiDefaults: descriptorsById.get(normalizedAgentId)?.definitionAiDefaults, - ancestors: collectDeclaredAncestorLayers(normalizedAgentId, descriptorsById), + ancestors, parentRuntime: { model: typeof args.existingModel === "string" ? args.existingModel : undefined, thinkingLevel: coerceThinkingLevel(args.existingThinking), diff --git a/src/common/orpc/schemas/agentDefinition.ts b/src/common/orpc/schemas/agentDefinition.ts index afc30e5f63c..e56e01c8c7c 100644 --- a/src/common/orpc/schemas/agentDefinition.ts +++ b/src/common/orpc/schemas/agentDefinition.ts @@ -104,6 +104,17 @@ export const AgentDefinitionDescriptorSchema = z // 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/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index a390cad5d7b..b904f6fefc7 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -108,6 +108,67 @@ describe("router agent definition routes", () => { }); }); +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 9dfd3e6a346..64ab7dbcd33 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1847,7 +1847,7 @@ export const router = (authToken?: string) => { workspaceId: input.workspaceId ?? discoveryPath, includeAgentPlugins, }); - const { targetDefinitionAiDefaults } = collectDefinitionLayers( + const { targetDefinitionAiDefaults, ancestors } = collectDefinitionLayers( descriptor.id, inheritanceChain ); @@ -1875,6 +1875,7 @@ export const router = (authToken?: string) => { descriptor, resolvedFrontmatter, targetDefinitionAiDefaults, + ancestors, uiSelectableBase, }; } catch { @@ -1902,6 +1903,7 @@ export const router = (authToken?: string) => { base: entry.resolvedFrontmatter.base, aiDefaults: entry.resolvedFrontmatter.ai, ownAiDefaults: entry.targetDefinitionAiDefaults, + aiAncestors: entry.ancestors, tools: entry.resolvedFrontmatter.tools, }, ]; From 9cd6bf6082ff8bcc10f84707c22dda7e38b922dc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:22:35 +0000 Subject: [PATCH 34/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20honor=20workspace?= =?UTF-8?q?=20buckets=20during=20background=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkspaceModeAISync.test.tsx | 18 +++++++------- .../WorkspaceModeAISync.tsx | 5 ++-- src/browser/utils/workspaceModeAi.test.ts | 24 +++++++++++++------ src/browser/utils/workspaceModeAi.ts | 9 ++----- 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx index fb7880fba06..f00615ee80a 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx @@ -131,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"; @@ -152,8 +152,8 @@ 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); }); }); @@ -287,7 +287,7 @@ describe("WorkspaceModeAISync", () => { }); }); - test("ignores workspace-by-agent values when settings are inherit", async () => { + test("restores a hydrated workspace bucket when settings inherit", async () => { const workspaceId = nextWorkspaceId(); const existingModel = "anthropic:claude-sonnet-4-5"; @@ -305,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"); }); }); @@ -345,7 +345,7 @@ 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 = "anthropic:claude-sonnet-4-5"; @@ -364,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 04bca93d225..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), {} diff --git a/src/browser/utils/workspaceModeAi.test.ts b/src/browser/utils/workspaceModeAi.test.ts index 2554ec311de..ad99a38486d 100644 --- a/src/browser/utils/workspaceModeAi.test.ts +++ b/src/browser/utils/workspaceModeAi.test.ts @@ -202,14 +202,14 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { expect(result?.resolvedThinking).toBe("high"); }); - test("ignores workspace-by-agent fallback when disabled", () => { + 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", @@ -424,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", () => { diff --git a/src/browser/utils/workspaceModeAi.ts b/src/browser/utils/workspaceModeAi.ts index a25448fdfce..f699a2687b8 100644 --- a/src/browser/utils/workspaceModeAi.ts +++ b/src/browser/utils/workspaceModeAi.ts @@ -194,7 +194,7 @@ export function resolveWorkspaceAiSettingsForAgent( targetAgentId: normalizedAgentId, profile: "interactive", targetWorkspaceSettings: - mode === "explicit-switch" && workspaceOverride != null + mode !== "creation-sync" && workspaceOverride != null ? targetWorkspaceBucketToLayer(workspaceOverride) : undefined, agentAiDefaults: args.agentAiDefaults, @@ -208,12 +208,7 @@ export function resolveWorkspaceAiSettingsForAgent( defaultModel: args.fallbackModel, }); - // A hydrated per-agent bucket owns the active background runtime. Descriptor - // arrival must not reinterpret an absent reasoning value as a new default. - const resolvedReasoningMode = - workspaceOverride != null && mode === "background-sync" - ? (coerceOpenAIReasoningMode(args.existingReasoningMode) ?? "standard") - : (resolved.selected.reasoningMode ?? "standard"); + const resolvedReasoningMode = resolved.selected.reasoningMode ?? "standard"; return { resolvedModel: resolved.selected.model, From 96209923fe3fea9dd8fa54d926d2727e64545e38 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:14:56 +0000 Subject: [PATCH 35/36] refactor: persist workspace selections only on send --- src/browser/App.tsx | 60 +- .../WorkspaceModeAISync.test.tsx | 198 +----- .../WorkspaceModeAISync.tsx | 33 +- src/browser/contexts/AgentContext.test.tsx | 573 +----------------- src/browser/contexts/AgentContext.tsx | 194 +----- src/browser/contexts/ThinkingContext.test.tsx | 22 +- src/browser/contexts/ThinkingContext.tsx | 40 +- .../contexts/WorkspaceContext.test.tsx | 222 ++----- src/browser/contexts/WorkspaceContext.tsx | 109 ++-- src/browser/features/ChatInput/index.tsx | 74 +-- .../ChatInput/useCreationWorkspace.ts | 60 +- .../Messages/ChatBarrier/RetryBarrier.tsx | 3 +- .../Tools/AskUserQuestionToolCall.tsx | 3 +- .../Tools/ProposePlanToolCall.test.tsx | 209 +------ .../features/Tools/ProposePlanToolCall.tsx | 119 +--- src/browser/hooks/useResumeStream.ts | 3 +- src/browser/utils/agents.ts | 4 - src/browser/utils/chatCommands.ts | 37 +- .../utils/workspaceAiSettingsSync.test.ts | 286 --------- src/browser/utils/workspaceAiSettingsSync.ts | 274 --------- src/browser/utils/workspaceModeAi.test.ts | 214 +------ src/browser/utils/workspaceModeAi.ts | 213 +++---- src/common/constants/events.ts | 11 - src/common/orpc/schemas/agentDefinition.ts | 14 - src/common/orpc/schemas/api.ts | 4 +- .../utils/ai/workspaceAiSettingsWrite.ts | 17 - src/node/acp/agent.ts | 61 +- src/node/acp/configOptions.ts | 169 ++---- src/node/acp/resolveAgentAiSettings.ts | 7 +- src/node/acp/workspaceAiSettingsSync.test.ts | 53 -- src/node/acp/workspaceAiSettingsSync.ts | 34 -- src/node/orpc/router.test.ts | 112 ---- src/node/orpc/router.ts | 36 +- src/node/services/workspaceService.test.ts | 372 ++---------- src/node/services/workspaceService.ts | 204 ++----- tests/ipc/acp.configOptions.test.ts | 125 +--- tests/ipc/acp.promptCorrelation.test.ts | 29 + tests/ipc/workspace/aiSettings.test.ts | 45 -- 38 files changed, 521 insertions(+), 3722 deletions(-) delete mode 100644 src/browser/utils/workspaceAiSettingsSync.test.ts delete mode 100644 src/common/utils/ai/workspaceAiSettingsWrite.ts delete mode 100644 src/node/acp/workspaceAiSettingsSync.test.ts delete mode 100644 src/node/acp/workspaceAiSettingsSync.ts diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 5c1073a1ef2..d81bb7fdd29 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -84,12 +84,7 @@ import { getRuntimeTypeForTelemetry } from "@/common/telemetry"; import { useStartWorkspaceCreation } from "./hooks/useStartWorkspaceCreation"; import { useAPI } from "@/browser/contexts/API"; import { requestActiveTurnThinkingLevel } from "@/browser/utils/activeTurnThinking"; -import { - clearPendingWorkspaceAiSettings, - markPendingWorkspaceAiSettings, - resolveEffectiveComposerModel, - updateWorkspaceAgentAISettings, -} from "@/browser/utils/workspaceAiSettingsSync"; +import { resolveEffectiveComposerModel } from "@/browser/utils/workspaceAiSettingsSync"; import { AuthTokenModal } from "@/browser/components/AuthTokenModal/AuthTokenModal"; import { ScratchPage } from "@/browser/components/ScratchPage/ScratchPage"; @@ -540,8 +535,6 @@ function AppInner() { const normalized = THINKING_LEVELS.includes(level) ? level : "off"; const model = getModelForWorkspace(workspaceId); const key = getThinkingLevelKey(workspaceId); - // Carry the current pro-mode choice: the backend replaces the agent's - // settings wholesale, so omitting reasoningMode would wipe it. const reasoningMode = getReasoningModeForWorkspace(workspaceId); // Use the utility function which handles localStorage and event dispatch @@ -573,29 +566,7 @@ function AppInner() { {} ); - // Persist to backend so the palette change follows the workspace across devices. if (api) { - markPendingWorkspaceAiSettings(workspaceId, normalizedAgentId, { - model, - thinkingLevel: normalized, - reasoningMode, - }); - - updateWorkspaceAgentAISettings(api, { - workspaceId, - agentId: normalizedAgentId, - aiSettings: { model, thinkingLevel: normalized, reasoningMode }, - }) - .then((result) => { - if (!result.success) { - clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); - } - }) - .catch(() => { - clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); - // Best-effort only. - }); - // Mid-turn change: also apply to the active turn's next model step so // the palette/keybind path behaves like the selector (ThinkingProvider). requestActiveTurnThinkingLevel(api, workspaceId, normalized); @@ -613,9 +584,7 @@ function AppInner() { [api, getModelForWorkspace, getReasoningModeForWorkspace] ); - // Palette toggle for the OpenAI pro reasoning mode. Persists like the - // thinking-level palette action: localStorage first (ThinkingProvider listens), - // then best-effort backend sync with the full settings payload. + // Keep palette choices local until a user message sends the full settings. const toggleReasoningModeFromPalette = useCallback( (workspaceId: string) => { if (!workspaceId) { @@ -653,31 +622,8 @@ function AppInner() { }, {} ); - - if (api) { - markPendingWorkspaceAiSettings(workspaceId, normalizedAgentId, { - model, - thinkingLevel, - reasoningMode: next, - }); - - updateWorkspaceAgentAISettings(api, { - workspaceId, - agentId: normalizedAgentId, - aiSettings: { model, thinkingLevel, reasoningMode: next }, - }) - .then((result) => { - if (!result.success) { - clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); - } - }) - .catch(() => { - clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); - // Best-effort only. - }); - } }, - [api, getModelForWorkspace, getReasoningModeForWorkspace, getThinkingLevelForWorkspace] + [getModelForWorkspace, getReasoningModeForWorkspace, getThinkingLevelForWorkspace] ); const getFastModeActive = useCallback(() => { diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx index f00615ee80a..0c00cdf7074 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx @@ -3,7 +3,6 @@ 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 { @@ -28,43 +27,14 @@ const noop = () => { // intentional noop for tests }; -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; +function SyncHarness(props: { workspaceId: string; agentId: string }) { return ( agent.id === props.agentId), - agents, + currentAgent: undefined, + agents: [], loaded: true, loadFailed: false, refresh: () => Promise.resolve(), @@ -78,14 +48,8 @@ function SyncHarness(props: { ); } -function renderSync(props: { - workspaceId: string; - agentId: string; - agents?: AgentDefinitionDescriptor[]; -}) { - return render( - - ); +function renderSync(props: { workspaceId: string; agentId: string }) { + return render(); } describe("WorkspaceModeAISync", () => { @@ -131,7 +95,7 @@ describe("WorkspaceModeAISync", () => { expect(consumeWorkspaceModelChange(workspaceId, planModel)).toBe("agent"); }); - test("prefers a hydrated workspace bucket over configured agent defaults", async () => { + test("preserves unsent workspace choices over configured agent defaults", async () => { const workspaceId = nextWorkspaceId(); const configuredModel = "anthropic:claude-haiku-4-5"; @@ -152,145 +116,15 @@ describe("WorkspaceModeAISync", () => { renderSync({ workspaceId, agentId: "exec" }); await waitFor(() => { - expect(readPersistedState(getModelKey(workspaceId), "")).toBe(workspaceModel); - expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe(workspaceThinking); - }); - }); - - 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 }); - - await waitFor(() => { - expect(readPersistedState(getModelKey(workspaceId), "")).toBe(existingModel); + expect(readPersistedState(getModelKey(workspaceId), "")).toBe("some-legacy-model"); + expect(readPersistedState(getThinkingLevelKey(workspaceId), "high")).toBe("medium"); }); - - 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 () => { + test("ignores workspace-by-agent values when settings are inherit", 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 existingModel = "some-legacy-model"; const existingThinking = "off"; // Inherit in Settings removes explicit per-agent defaults from AGENT_AI_DEFAULTS_KEY. @@ -305,8 +139,8 @@ describe("WorkspaceModeAISync", () => { renderSync({ workspaceId, agentId: "exec" }); await waitFor(() => { - expect(readPersistedState(getModelKey(workspaceId), "")).toBe("openai:gpt-5.2"); - expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe("medium"); + expect(readPersistedState(getModelKey(workspaceId), "")).toBe(existingModel); + expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe(existingThinking); }); }); @@ -345,10 +179,10 @@ describe("WorkspaceModeAISync", () => { expect(consumeWorkspaceModelChange(workspaceId, execWorkspaceModel)).toBe("agent"); }); - test("restores a hydrated custom-agent bucket during background sync", async () => { + test("ignores same-agent workspace overrides when agent defaults are missing", async () => { const workspaceId = nextWorkspaceId(); - const existingModel = "anthropic:claude-sonnet-4-5"; + const existingModel = "some-legacy-model"; const existingThinking = "high"; updatePersistedState(AGENT_AI_DEFAULTS_KEY, { @@ -364,8 +198,8 @@ describe("WorkspaceModeAISync", () => { renderSync({ workspaceId, agentId: "custom" }); await waitFor(() => { - expect(readPersistedState(getModelKey(workspaceId), "")).toBe("openai:gpt-5.2-pro"); - expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe("medium"); + expect(readPersistedState(getModelKey(workspaceId), "")).toBe(existingModel); + expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe(existingThinking); }); }); diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx index 238c5bd5a9d..8fd18e89926 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx @@ -54,8 +54,9 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { prevAgentIdRef.current = normalizedAgentId; prevWorkspaceIdRef.current = workspaceId; - // 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. + // 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. const workspaceByAgent = readPersistedState( getWorkspaceAISettingsByAgentKey(workspaceId), {} @@ -66,19 +67,21 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { const reasoningKey = getReasoningModeKey(workspaceId); const existingReasoning = readPersistedState(reasoningKey, "standard"); - 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; + 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])), + }); if (existingModel !== resolvedModel) { setWorkspaceModelWithOrigin( diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index f683d26340e..03e48b84c5a 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -8,14 +8,7 @@ import { GlobalWindow } from "happy-dom"; import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; import { CUSTOM_EVENTS } from "@/common/constants/events"; -import { shouldApplyWorkspaceAgentIdFromBackend } from "@/browser/utils/workspaceAiSettingsSync"; -import { - GLOBAL_SCOPE_ID, - getAgentIdKey, - getModelKey, - getProjectScopeId, - getThinkingLevelKey, -} from "@/common/constants/storage"; +import { GLOBAL_SCOPE_ID, getAgentIdKey, getProjectScopeId } from "@/common/constants/storage"; import { requireTestModule } from "@/browser/testUtils"; import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; @@ -28,37 +21,12 @@ import type * as RouterContextModule from "./RouterContext"; import type * as WorkspaceContextModule from "./WorkspaceContext"; let mockAgentDefinitions: AgentDefinitionDescriptor[] = []; -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 mockWorkspaceMetadata = new Map(); 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; @@ -120,9 +88,8 @@ async function importIsolatedAgentModules() { ({ ProjectProvider } = requireTestModule<{ ProjectProvider: typeof ProjectContextModule.ProjectProvider; }>(isolatedProjectPath)); - ({ WorkspaceProvider, useWorkspaceMetadata } = requireTestModule<{ + ({ WorkspaceProvider } = requireTestModule<{ WorkspaceProvider: typeof WorkspaceContextModule.WorkspaceProvider; - useWorkspaceMetadata: typeof WorkspaceContextModule.useWorkspaceMetadata; }>(isolatedWorkspacePath)); ({ AgentProvider, useAgent } = requireTestModule<{ AgentProvider: typeof AgentContextModule.AgentProvider; @@ -186,28 +153,9 @@ 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; - agentType?: string; - aiSettingsByAgent?: FrontendWorkspaceMetadata["aiSettingsByAgent"]; - } = {} + overrides: { parentWorkspaceId?: string; agentId?: string } = {} ): FrontendWorkspaceMetadata { return { id: workspaceId, @@ -221,38 +169,6 @@ 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 { @@ -271,14 +187,11 @@ function createApiClient(): APIClient { return { agents: { - list: () => - rejectAgentDefinitions - ? Promise.reject(new Error("agent definitions unavailable")) - : Promise.resolve(mockAgentDefinitions), + list: () => Promise.resolve(mockAgentDefinitions), }, workspace: { list: () => Promise.resolve(workspaceMetadata), - onMetadata: () => Promise.resolve(createWorkspaceMetadataIterable()), + onMetadata: () => Promise.resolve(createEmptyAsyncIterable()), onChat: () => Promise.resolve(createEmptyAsyncIterable()), getSessionUsage: () => Promise.resolve(undefined), activity: { @@ -287,17 +200,6 @@ 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([]), @@ -319,19 +221,12 @@ function renderAgentHarness(props: { projectPath: string; workspaceId?: string; onChange: (value: AgentContextValue) => void; - onMetadataLayout?: (metadata: FrontendWorkspaceMetadata | undefined) => void; }) { return render( - {props.workspaceId && props.onMetadataLayout ? ( - - ) : null} @@ -350,12 +245,7 @@ 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; @@ -472,457 +362,6 @@ 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 5aaa8b41a7d..873ddec5693 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -13,40 +13,18 @@ import { import { useAPI } from "@/browser/contexts/API"; import { useWorkspaceMetadata } from "@/browser/contexts/WorkspaceContext"; -import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; -import { readPersistedState, usePersistedState } from "@/browser/hooks/usePersistedState"; +import { 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 { @@ -104,7 +82,6 @@ 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); @@ -143,169 +120,23 @@ 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; - previous = coerceAgentId(isProjectScope ? explicitPrevAgentId : prev); - next = coerceAgentId(typeof value === "function" ? value(previous) : value); - return next; + const previousAgentId = coerceAgentId(isProjectScope ? explicitPrevAgentId : prev); + const next = typeof value === "function" ? value(previousAgentId) : value; + return coerceAgentId(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); - }); }, - [ - agents, - api, - globalDefaultAgentId, - isCurrentAgentLocked, - isProjectScope, - setAgentIdRaw, - workspaceId, - workspaceStore, - ] + [globalDefaultAgentId, isProjectScope, setAgentIdRaw] ); + const [agents, setAgents] = useState([]); + const [loaded, setLoaded] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + const isMountedRef = useRef(true); useEffect(() => { @@ -399,8 +230,11 @@ function AgentProviderWithState(props: { } }, [fetchAgents, props.projectPath, props.workspaceId, disableWorkspaceAgents]); - // Project-scoped providers inherit the global default agent until a - // project-scoped preference is explicitly set. + // 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; + // 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.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index 914ed0df639..95edaeb1a63 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -322,6 +322,7 @@ describe("ThinkingContext", () => { test("setting thinking uses metadata model before global default", async () => { const workspaceId = "ws-set-thinking-metadata-model"; + updatePersistedState(getReasoningModeKey(workspaceId), "pro"); const updateAgentAISettings = mock< (args: WorkspaceUpdateAgentAISettingsArgs) => Promise >(() => @@ -356,24 +357,16 @@ describe("ThinkingContext", () => { button.click(); }); - // setThinkingLevel persists the full settings payload including the current - // reasoningMode (default "standard") so partial writes cannot clobber it. const expectedSettings = { model: "metadataModel:abc", thinkingLevel: "medium" as const, - reasoningMode: "standard" as const, + reasoningMode: "pro" as const, }; await waitFor(() => { expect(readWorkspaceAISettingsCache(workspaceId).exec).toEqual(expectedSettings); }, METADATA_WAIT_OPTIONS); - if (updateAgentAISettings.mock.calls.length > 0) { - expect(updateAgentAISettings).toHaveBeenCalledWith({ - workspaceId, - agentId: "exec", - aiSettings: expectedSettings, - }); - } + expect(updateAgentAISettings).not.toHaveBeenCalled(); }); test("setting thinking preserves an explicit Coder gateway model identity", async () => { @@ -426,6 +419,7 @@ describe("ThinkingContext", () => { await waitFor(() => { expect(readWorkspaceAISettingsCache(workspaceId).exec).toEqual(expectedSettings); }, METADATA_WAIT_OPTIONS); + expect(updateAgentAISettings).not.toHaveBeenCalled(); }); test("self-heals corrupt persisted reasoningMode to standard but keeps valid pro", async () => { @@ -633,13 +627,7 @@ describe("ThinkingContext", () => { expect(readWorkspaceAISettingsCache(workspaceId).exec).toEqual(expectedSettings); }, METADATA_WAIT_OPTIONS); - if (updateAgentAISettings.mock.calls.length > 0) { - expect(updateAgentAISettings).toHaveBeenCalledWith({ - workspaceId, - agentId: "exec", - aiSettings: expectedSettings, - }); - } + expect(updateAgentAISettings).not.toHaveBeenCalled(); }); test("requests a mid-turn override for the active workspace turn on slider changes", async () => { diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 5a3b44a9281..946a69141b3 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -28,12 +28,7 @@ import { useMinThinkingLevels } from "@/browser/hooks/useMinThinkingLevels"; import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import { useAPI } from "@/browser/contexts/API"; import { requestActiveTurnThinkingLevel } from "@/browser/utils/activeTurnThinking"; -import { - clearPendingWorkspaceAiSettings, - getWorkspaceAiSettingsFromMetadata, - markPendingWorkspaceAiSettings, - updateWorkspaceAgentAISettings, -} from "@/browser/utils/workspaceAiSettingsSync"; +import { getWorkspaceAiSettingsFromMetadata } from "@/browser/utils/workspaceAiSettingsSync"; import { useOptionalWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; @@ -134,17 +129,13 @@ export const ThinkingProvider: React.FC = (props) => { updatePersistedState(thinkingKey, legacy); }, [defaultModel, scopeId, thinkingKey]); - // Shared persistence for both setters: caches the full per-agent settings and - // pushes them to the backend. updateAgentAISettings replaces the agent's - // settings wholesale, so every payload must carry BOTH thinkingLevel and - // reasoningMode or the omitted one gets wiped on the next sync. + // Keep picker choices local until a user message sends the full settings. const persistAgentAiSettings = useCallback( (settings: { model: string; thinkingLevel: ThinkingLevel; reasoningMode: OpenAIReasoningMode; }) => { - // Workspace variant: persist to backend so settings follow the workspace across devices. if (!props.workspaceId) { return; } @@ -175,37 +166,12 @@ export const ThinkingProvider: React.FC = (props) => { }, {} ); - - if (!api) { - return; - } - - // Avoid stale backend metadata clobbering newer local preferences when users - // click through levels quickly (tests reproduce this by cycling to xhigh). - markPendingWorkspaceAiSettings(workspaceId, normalizedAgentId, settings); - - updateWorkspaceAgentAISettings(api, { - workspaceId, - agentId: normalizedAgentId, - aiSettings: settings, - }) - .then((result) => { - if (!result.success) { - clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); - } - }) - .catch(() => { - clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); - // Best-effort only. If offline or backend is old, the next sendMessage will persist. - }); }, - [api, props.workspaceId, scopeId] + [props.workspaceId, scopeId] ); // Read the sibling setting at call time (not from the render closure) so // rapid interleaved updates cannot persist a stale counterpart value. - // Coerced like the render path: a corrupt persisted value must not ride a - // thinking-level change into updateAgentAISettings and fail backend sync. const getCurrentReasoningMode = useCallback( (): OpenAIReasoningMode => coerceOpenAIReasoningMode( diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index d766b0e0401..a7c162a2b21 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -16,16 +16,11 @@ 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 { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getProjectRouteId } from "@/common/utils/projectRouteId"; import type { RightSidebarLayoutState } from "@/browser/utils/rightSidebarLayout"; @@ -525,165 +520,32 @@ describe("WorkspaceContext", () => { "xhigh" ); }); - test("backend agentId seeds a main workspace agent selection", async () => { + test.each(["unchanged", "mode", "model"])("hydrates saved selections: %s", async (change) => { + const changed = change !== "unchanged"; + const nextAgentId = change === "mode" ? "auto" : "plan"; 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 saved = createWorkspaceMetadata({ + id: workspaceId, + agentId: "plan", + aiSettingsByAgent: { plan: { model: "openai:gpt-5.2", thinkingLevel: "high" } }, }); - - 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; createMockAPI({ workspace: { - list: () => Promise.resolve([createWorkspaceMetadata({ id: workspaceId })]), + list: () => Promise.resolve([saved]), onMetadata: () => Promise.resolve( (async function* () { - while (true) { - const event = await new Promise<{ - workspaceId: string; - metadata: FrontendWorkspaceMetadata | null; - }>((resolve) => { - emitMetadata = resolve; - }); - emitMetadata = null; - yield event; - } + const event = await new Promise<{ + workspaceId: string; + metadata: FrontendWorkspaceMetadata | null; + }>((resolve) => { + emitMetadata = resolve; + }); + yield event; })() as unknown as Awaited> ), }, @@ -692,49 +554,39 @@ 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(readPersistedState(getAgentIdKey(workspaceId), "")).toBe("plan"); + expect(readPersistedState(getModelKey(workspaceId), "")).toBe("openai:gpt-5.2"); - // 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(() => { + updatePersistedState(getAgentIdKey(workspaceId), "exec"); + updatePersistedState(getModelKey(workspaceId), "anthropic:claude-opus-4-6"); emitMetadata?.({ workspaceId, - metadata: createWorkspaceMetadata({ id: workspaceId, agentId: "exec" }), + metadata: { + ...saved, + title: "Updated title", + ...(changed + ? { + agentId: nextAgentId, + aiSettingsByAgent: { + [nextAgentId]: { model: "openai:gpt-5.3-codex", thinkingLevel: "medium" }, + }, + } + : {}), + }, }); }); - 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" - ) + expect(ctx().workspaceMetadata.get(workspaceId)?.title).toBe("Updated title") + ); + expect(readPersistedState(getAgentIdKey(workspaceId), "")).toBe(changed ? nextAgentId : "exec"); + expect(readPersistedState(getModelKey(workspaceId), "")).toBe( + changed ? "openai:gpt-5.3-codex" : "anthropic:claude-opus-4-6" ); }); diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index b4869b99e06..5927a5dcd2a 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -65,10 +65,6 @@ import { import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults"; import { isWorkspaceArchived } from "@/common/utils/archive"; import { reassignPinnedTimestamps } from "@/common/utils/pin"; -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"; @@ -169,7 +165,20 @@ function migrateLocalGatewayPrefsToBackend( * * This keeps a workspace's model/thinking consistent across devices/browsers. */ -function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadata): void { +function seedWorkspaceLocalStorageFromBackend( + metadata: FrontendWorkspaceMetadata, + previous?: FrontendWorkspaceMetadata +): void { + // Unchanged server settings must not overwrite choices the user hasn't sent yet. + if ( + metadata.parentWorkspaceId == null && + previous && + resolvePersistedAgentId(metadata, "") === resolvePersistedAgentId(previous, "") && + JSON.stringify(metadata.aiSettingsByAgent) === JSON.stringify(previous.aiSettingsByAgent) && + JSON.stringify(metadata.aiSettings) === JSON.stringify(previous.aiSettings) + ) { + return; + } // Cache keyed by agentId (string) - includes exec, plan, and custom agents type WorkspaceAISettingsByAgentCache = Partial< Record< @@ -180,56 +189,24 @@ 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 (metadataAgentId.length > 0) { + const key = getAgentIdKey(workspaceId); const normalized = normalizeAgentId(metadataAgentId); - 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 existing = readPersistedState(key, undefined); + if (existing !== normalized) { + updatePersistedState(key, normalized); } } - // 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 + const aiByAgent = + metadata.aiSettingsByAgent ?? + (metadata.aiSettings ? { plan: metadata.aiSettings, exec: metadata.aiSettings, - [activeAgentId]: metadata.aiSettings, } - : undefined; + : undefined); if (!aiByAgent) { return; @@ -244,17 +221,6 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat if (!entry) continue; if (typeof entry.model !== "string" || entry.model.length === 0) continue; - // Protect newer local preferences from stale metadata updates (e.g., rapid thinking toggles). - if ( - !shouldApplyWorkspaceAiSettingsFromBackend(workspaceId, agentKey, { - model: entry.model, - thinkingLevel: entry.thinkingLevel, - reasoningMode: entry.reasoningMode, - }) - ) { - continue; - } - nextByAgent[agentKey] = { model: entry.model, thinkingLevel: entry.thinkingLevel, @@ -267,11 +233,11 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat } // Seed the active agent into the existing keys to avoid UI flash. - // 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]; + const activeAgentId = readPersistedState( + getAgentIdKey(workspaceId), + WORKSPACE_DEFAULTS.agentId + ); + const active = nextByAgent[activeAgentId] ?? nextByAgent.exec ?? nextByAgent.plan; if (!active) { return; } @@ -290,9 +256,7 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat // Absent reasoningMode means "standard": seed it explicitly so switching to // an agent whose settings never carried the field cannot inherit another - // agent's "pro" from the shared workspace-scoped key. Newer local choices - // are already protected by the pending-settings guard above - // (shouldApplyWorkspaceAiSettingsFromBackend). + // agent's "pro" from the shared workspace-scoped key. const reasoningKey = getReasoningModeKey(workspaceId); const nextReasoning = active.reasoningMode ?? "standard"; const existingReasoning = readPersistedState( @@ -1080,7 +1044,10 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { ensureCreatedAt(metadata); // Use stable workspace ID as key (not path, which can change) - seedWorkspaceLocalStorageFromBackend(metadata); + seedWorkspaceLocalStorageFromBackend( + metadata, + workspaceMetadataRef.current.get(metadata.id) + ); metadataMap.set(metadata.id, metadata); } @@ -1249,7 +1216,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { // 1. ALWAYS normalize incoming metadata first - this is the critical data update. if (meta !== null) { ensureCreatedAt(meta); - seedWorkspaceLocalStorageFromBackend(meta); + seedWorkspaceLocalStorageFromBackend(meta, workspaceMetadataRef.current.get(meta.id)); } const isNowArchived = @@ -1407,7 +1374,10 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { // Update metadata immediately to avoid race condition with validation effect ensureCreatedAt(result.metadata); - seedWorkspaceLocalStorageFromBackend(result.metadata); + seedWorkspaceLocalStorageFromBackend( + result.metadata, + workspaceMetadataRef.current.get(result.metadata.id) + ); setWorkspaceMetadata((prev) => { const updated = new Map(prev); updated.set(result.metadata.id, result.metadata); @@ -1771,7 +1741,10 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { const metadata = await api.workspace.getInfo({ workspaceId }); if (metadata) { ensureCreatedAt(metadata); - seedWorkspaceLocalStorageFromBackend(metadata); + seedWorkspaceLocalStorageFromBackend( + metadata, + workspaceMetadataRef.current.get(metadata.id) + ); } return metadata; }, diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 7fc6f473e42..4e974666d19 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -53,16 +53,7 @@ import { } from "@/browser/utils/additionalSystemContextStore"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; -import { - clearPendingWorkspaceAiSettings, - markPendingWorkspaceAiSettings, - sendWorkspaceMessage, - updateWorkspaceAgentAISettings, -} from "@/browser/utils/workspaceAiSettingsSync"; -import { - getCreationWorkspaceAiSyncState, - resolveWorkspaceAiSettingsForAgent, -} from "@/browser/utils/workspaceModeAi"; +import { resolveWorkspaceAiSettingsForAgent } from "@/browser/utils/workspaceModeAi"; import { getModelKey, getReasoningModeKey, @@ -945,42 +936,13 @@ const ChatInputInner: React.FC = (props) => { prev && typeof prev === "object" ? prev : {}; return { ...record, - // Include reasoningMode so a model change cannot wipe the persisted - // pro-mode choice (backend replaces the agent's settings wholesale). [normalizedAgentId]: { model: selectedModel, thinkingLevel, reasoningMode }, }; }, {} ); - - // Workspace variant: persist to backend for cross-device consistency. - if (!api) { - return; - } - - markPendingWorkspaceAiSettings(workspaceId, normalizedAgentId, { - model: selectedModel, - thinkingLevel, - reasoningMode, - }); - - updateWorkspaceAgentAISettings(api, { - workspaceId, - agentId: normalizedAgentId, - aiSettings: { model: selectedModel, thinkingLevel, reasoningMode }, - }) - .then((result) => { - if (!result.success) { - clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); - } - }) - .catch(() => { - clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); - // Best-effort only. If offline or backend is old, sendMessage will persist. - }); }, [ - api, agentId, creationParentProjectPath, ensureModelInSettings, @@ -1284,12 +1246,10 @@ const ChatInputInner: React.FC = (props) => { const normalizedAgentId = normalizeAgentId(agentId, "exec"); - const { isExplicitAgentSwitch, mode } = getCreationWorkspaceAiSyncState({ - previousAgentId: prevCreationAgentIdRef.current, - previousScopeId: prevCreationScopeIdRef.current, - agentId: normalizedAgentId, - scopeId, - }); + const isExplicitAgentSwitch = + prevCreationAgentIdRef.current !== null && + prevCreationScopeIdRef.current === scopeId && + prevCreationAgentIdRef.current !== normalizedAgentId; // Update refs for the next run (even if no model changes). prevCreationAgentIdRef.current = normalizedAgentId; @@ -1315,8 +1275,7 @@ const ChatInputInner: React.FC = (props) => { existingModel, existingThinking, existingReasoningMode: existingReasoning, - agents, - mode, + agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])), }); if (existingModel !== resolvedModel) { @@ -2178,25 +2137,6 @@ 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) => { @@ -3209,7 +3149,7 @@ const ChatInputInner: React.FC = (props) => { props.onMessageSendStarted?.(overrides?.queueDispatchMode ?? "tool-end"); - const result = await sendWorkspaceMessage(api, { + const result = await api.workspace.sendMessage({ workspaceId: props.workspaceId, message: finalMessageText, options: sendOptions, diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index a0a27f6b0df..80d9e475830 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -16,10 +16,6 @@ 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 { @@ -626,16 +622,18 @@ 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 = updateWorkspaceAgentAISettings(api, { - workspaceId: metadata.id, - agentId: settings.agentId, - aiSettings: { - model: settings.model, - thinkingLevel: settings.thinkingLevel, - reasoningMode: settings.reasoningMode, - }, - persistSelectedAgentId: true, - }).catch(() => null); + 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 isDraftScope = typeof draftId === "string" && draftId.trim().length > 0; const pendingScopeId = projectPath @@ -806,22 +804,24 @@ 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 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) }, - })); + 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) }, + })); if (!sendResult.success) { if (createdWorkspaceId) { diff --git a/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx b/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx index 4cbb8dbb4c0..e3c954092a0 100644 --- a/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx @@ -3,7 +3,6 @@ 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"; @@ -202,7 +201,7 @@ export const RetryBarrier: React.FC = (props) => { manualRetryRollbackBaselineMessageCountRef.current = workspaceState.messages.length; } - const resumeResult = await resumeWorkspaceStream(api, { + const resumeResult = await api.workspace.resumeStream({ workspaceId: props.workspaceId, options, }); diff --git a/src/browser/features/Tools/AskUserQuestionToolCall.tsx b/src/browser/features/Tools/AskUserQuestionToolCall.tsx index d5b29fa397d..d859c2a1203 100644 --- a/src/browser/features/Tools/AskUserQuestionToolCall.tsx +++ b/src/browser/features/Tools/AskUserQuestionToolCall.tsx @@ -5,7 +5,6 @@ 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"; @@ -534,7 +533,7 @@ export function AskUserQuestionToolCall(props: { } } - const resumeResult = await resumeWorkspaceStream(api, { + const resumeResult = await api.workspace.resumeStream({ workspaceId, options: sendOptions, }); diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index 1a9d130332e..ffc00bc4133 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -14,7 +14,6 @@ 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, @@ -60,33 +59,11 @@ interface MockApi { mode?: "destructive" | "append-compaction-boundary" | null; deletePlanFile?: boolean; }) => Promise; - 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 }>; + sendMessage: (args: SendMessageArgs) => Promise<{ success: true; data: undefined }>; }; } -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; @@ -140,10 +117,7 @@ async function installProposePlanModuleMocks() { await mock.module("@/browser/contexts/WorkspaceContext", () => ({ ...actualWorkspaceContextModule, useWorkspaceContext: () => ({ - workspaceMetadata: mockWorkspaceMetadataByWorkspace, - }), - useOptionalWorkspaceContext: () => ({ - workspaceMetadata: mockWorkspaceMetadataByWorkspace, + workspaceMetadata: new Map(), }), })); await mock.module("@/browser/hooks/useReviews", () => ({ @@ -229,34 +203,27 @@ 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", - agents: AgentDefinitionDescriptor[] = TEST_AGENTS, - loaded = true -) { +function renderToolCall(content: JSX.Element, agentId = "plan") { return render( entry.id === agentId), - agents, - loaded, + currentAgent: TEST_AGENTS.find((entry) => entry.id === agentId), + agents: TEST_AGENTS, + loaded: true, loadFailed: false, refresh: () => Promise.resolve(), refreshing: false, @@ -277,7 +244,6 @@ function createMockApi( getPlanContent?: MockApi["workspace"]["getPlanContent"]; replaceChatHistory?: MockApi["workspace"]["replaceChatHistory"]; sendMessage?: MockApi["workspace"]["sendMessage"]; - updateAgentAISettings?: MockApi["workspace"]["updateAgentAISettings"]; } = {} ): MockApi { return { @@ -292,13 +258,8 @@ function createMockApi( })), replaceChatHistory: overrides.replaceChatHistory ?? (() => 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 }); - }, + sendMessage: + overrides.sendMessage ?? (() => Promise.resolve({ success: true, data: undefined })), }, }; } @@ -328,7 +289,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: {} }); + return Promise.resolve({ success: true, data: undefined }); }; } @@ -352,9 +313,7 @@ describe("ProposePlanToolCall", () => { beforeEach(async () => { startHereCalls = []; selectableDiffRendererCalls = []; - updateAgentAISettingsCalls = []; mockApi = null; - mockWorkspaceMetadataByWorkspace = new Map(); cleanupDom = installDom(); await installProposePlanModuleMocks(); }); @@ -518,33 +477,6 @@ 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"; @@ -586,127 +518,6 @@ 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 () => { @@ -757,7 +568,7 @@ describe("ProposePlanToolCall", () => { sendMessage: (args) => { calls.push("sendMessage"); sendMessageCalls.push(args); - return Promise.resolve({ success: true, data: {} }); + return Promise.resolve({ success: true, data: undefined }); }, }); diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index dd7222aafcb..4cf57c0c89c 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -38,7 +38,6 @@ 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 { @@ -55,13 +54,6 @@ 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"; @@ -195,13 +187,10 @@ 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, loaded: agentsLoaded } = useAgent(); + const { agentId: currentAgentId, agents } = 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); @@ -486,12 +475,7 @@ export const ProposePlanToolCall: React.FC = (props) = const resolveAndPersistTargetAgentSettings = (args: { workspaceId: string; targetAgentId: "auto" | "exec"; - }): { - resolvedModel: string; - resolvedThinking: ThinkingLevel; - /** Undo this switch after a typed send rejection (transport failures keep it). */ - revertSelection: () => void; - } | null => { + }): { resolvedModel: string; resolvedThinking: ThinkingLevel } => { const modelKey = getModelKey(args.workspaceId); const thinkingKey = getThinkingLevelKey(args.workspaceId); const reasoningKey = getReasoningModeKey(args.workspaceId); @@ -506,26 +490,21 @@ export const ProposePlanToolCall: React.FC = (props) = {} ); - 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; + 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])), + }); - // 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) { @@ -539,32 +518,11 @@ export const ProposePlanToolCall: React.FC = (props) = updatePersistedState(reasoningKey, resolvedReasoningMode); } - 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, - }), - }; + return { resolvedModel, resolvedThinking }; }; const handleImplement = async () => { - if (!workspaceId || !api || !canResolveExec) return; + if (!workspaceId || !api) return; if (isImplementingRef.current) return; isImplementingRef.current = true; @@ -572,7 +530,6 @@ export const ProposePlanToolCall: React.FC = (props) = setIsImplementing(true); } - const targetAgentId = "exec"; try { let shouldReplaceChatHistory = false; @@ -591,20 +548,14 @@ export const ProposePlanToolCall: React.FC = (props) = }); } - const targetSettings = resolveAndPersistTargetAgentSettings({ + const targetAgentId = "exec"; + const { resolvedModel, resolvedThinking } = resolveAndPersistTargetAgentSettings({ workspaceId, targetAgentId, }); - if (!targetSettings) return; - const { resolvedModel, resolvedThinking, revertSelection } = targetSettings; const sendMessageOptions = getSendOptionsFromStorage(workspaceId); - // 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, { + await api.workspace.sendMessage({ workspaceId, message: "Implement the plan", options: { @@ -614,16 +565,9 @@ 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); @@ -631,7 +575,7 @@ export const ProposePlanToolCall: React.FC = (props) = } }; const handleContinueInAuto = async () => { - if (!workspaceId || !api || !canResolveAuto) return; + if (!workspaceId || !api) return; if (isContinuingInAutoRef.current) return; isContinuingInAutoRef.current = true; @@ -639,7 +583,6 @@ export const ProposePlanToolCall: React.FC = (props) = setIsContinuingInAuto(true); } - const targetAgentId = "auto"; try { let shouldReplaceChatHistory = false; @@ -658,17 +601,14 @@ export const ProposePlanToolCall: React.FC = (props) = }); } - const targetSettings = resolveAndPersistTargetAgentSettings({ + const targetAgentId = "auto"; + const { resolvedModel, resolvedThinking } = resolveAndPersistTargetAgentSettings({ workspaceId, targetAgentId, }); - if (!targetSettings) return; - const { resolvedModel, resolvedThinking, revertSelection } = targetSettings; const sendMessageOptions = getSendOptionsFromStorage(workspaceId); - // See handleImplement: transport failures keep the switch; typed - // rejections revert it. - const result = await sendWorkspaceMessage(api, { + await api.workspace.sendMessage({ workspaceId, message: "Implement the plan", options: { @@ -678,14 +618,9 @@ 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); @@ -763,7 +698,7 @@ export const ProposePlanToolCall: React.FC = (props) = ? { label: "Implement", onClick: () => void handleImplement(), - disabled: !api || !canResolveExec || isImplementing || isContinuingInAuto, + disabled: !api || isImplementing || isContinuingInAuto, icon: , tooltip: implementReplacesChatHistory ? "Replace chat history with this plan, switch to Exec, and start implementing" @@ -776,7 +711,7 @@ export const ProposePlanToolCall: React.FC = (props) = ? { label: "Continue in Auto", onClick: () => void handleContinueInAuto(), - disabled: !api || !canResolveAuto || isContinuingInAuto || isImplementing, + disabled: !api || 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 9e2f3721544..c2512b1b8ea 100644 --- a/src/browser/hooks/useResumeStream.ts +++ b/src/browser/hooks/useResumeStream.ts @@ -1,7 +1,6 @@ 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"; @@ -76,7 +75,7 @@ export function useResumeStream( options = applyCompactionOverrides(options, lastUserMessage.compactionRequest.parsed); } - const result = await resumeWorkspaceStream(api, { workspaceId, options }); + const result = await api.workspace.resumeStream({ 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 3b91f4c8f16..e17c07f04d0 100644 --- a/src/browser/utils/agents.ts +++ b/src/browser/utils/agents.ts @@ -4,10 +4,6 @@ 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 5ec762a3f18..152d9739f8a 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -74,7 +74,6 @@ import { getStagedAttachments, } from "@/browser/features/ChatInput/stagedAttachments"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; -import { sendWorkspaceMessage } from "@/browser/utils/workspaceAiSettingsSync"; // ============================================================================ // Workspace Creation @@ -153,13 +152,15 @@ export async function forkWorkspace(options: ForkOptions): Promise { const sendMessageOptions = options.sendMessageOptions; if (startMessage && sendMessageOptions) { requestAnimationFrame(() => { - sendWorkspaceMessage(client, { - workspaceId: result.metadata.id, - message: startMessage, - options: sendMessageOptions, - }).catch(() => { - // Best-effort: the user can send the message manually if this fails. - }); + client.workspace + .sendMessage({ + workspaceId: result.metadata.id, + message: startMessage, + options: sendMessageOptions, + }) + .catch(() => { + // Best-effort: the user can send the message manually if this fails. + }); }); } @@ -530,7 +531,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 sendWorkspaceMessage(activeClient, { + const sendResult = await activeClient.workspace.sendMessage({ workspaceId, message: workflowResultMessage, options: { @@ -1545,13 +1546,15 @@ export async function createNewWorkspace( const client = options.client; if (startMessage && sendMessageOptions) { requestAnimationFrame(() => { - sendWorkspaceMessage(client, { - workspaceId: result.metadata.id, - message: startMessage, - options: sendMessageOptions, - }).catch(() => { - // Best-effort: the user can send the message manually if this fails. - }); + client.workspace + .sendMessage({ + workspaceId: result.metadata.id, + message: startMessage, + options: sendMessageOptions, + }) + .catch(() => { + // Best-effort: the user can send the message manually if this fails. + }); }); } @@ -1681,7 +1684,7 @@ export async function executeCompaction( ): Promise { const { messageText, metadata, sendOptions } = prepareCompactionMessage(options); - const result = await sendWorkspaceMessage(options.api, { + const result = await options.api.workspace.sendMessage({ workspaceId: options.workspaceId, message: messageText, options: { diff --git a/src/browser/utils/workspaceAiSettingsSync.test.ts b/src/browser/utils/workspaceAiSettingsSync.test.ts deleted file mode 100644 index 332335a616a..00000000000 --- a/src/browser/utils/workspaceAiSettingsSync.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -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 387bc9e2be6..4a0c89ca07f 100644 --- a/src/browser/utils/workspaceAiSettingsSync.ts +++ b/src/browser/utils/workspaceAiSettingsSync.ts @@ -1,24 +1,6 @@ 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; - thinkingLevel: ThinkingLevel; - /** Optional: legacy settings (and non-OpenAI workflows) omit it. */ - reasoningMode?: OpenAIReasoningMode; -} export function getWorkspaceAiSettingsFromMetadata( metadata: FrontendWorkspaceMetadata | undefined, @@ -47,259 +29,3 @@ export function resolveEffectiveComposerModel( // Match ChatInput precedence so shortcuts and palette actions gate on the model users see. 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 { - return `${workspaceId}:${agentId}`; -} - -export function markPendingWorkspaceAiSettings( - workspaceId: string, - agentId: string, - settings: WorkspaceAiSettingsSnapshot -): void { - if (!workspaceId || !agentId) { - return; - } - pendingAiSettingsByWorkspace.set(getPendingKey(workspaceId, agentId), settings); -} - -export function clearPendingWorkspaceAiSettings(workspaceId: string, agentId: string): void { - if (!workspaceId || !agentId) { - return; - } - pendingAiSettingsByWorkspace.delete(getPendingKey(workspaceId, agentId)); -} - -export function shouldApplyWorkspaceAiSettingsFromBackend( - workspaceId: string, - agentId: string, - incoming: WorkspaceAiSettingsSnapshot -): boolean { - if (!workspaceId || !agentId) { - return true; - } - - const key = getPendingKey(workspaceId, agentId); - const pending = pendingAiSettingsByWorkspace.get(key); - if (!pending) { - return true; - } - - const matches = - pending.model === incoming.model && - pending.thinkingLevel === incoming.thinkingLevel && - // Absent reasoningMode is semantically "standard" on both sides. - (pending.reasoningMode ?? "standard") === (incoming.reasoningMode ?? "standard"); - if (matches) { - pendingAiSettingsByWorkspace.delete(key); - return true; - } - - 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 ad99a38486d..31cb9e63760 100644 --- a/src/browser/utils/workspaceModeAi.test.ts +++ b/src/browser/utils/workspaceModeAi.test.ts @@ -1,10 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; -import type { AgentAncestorDescriptor } from "@/common/utils/ai/agentAncestorLayers"; -import { - getCreationWorkspaceAiSyncState, - resolveWorkspaceAiSettingsForAgent, -} from "./workspaceModeAi"; +import { resolveWorkspaceAiSettingsForAgent } from "./workspaceModeAi"; describe("resolveWorkspaceAiSettingsForAgent", () => { test("uses global agent defaults when configured", () => { @@ -44,7 +40,7 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { test("uses workspace-by-agent fallback when explicitly enabled", () => { const result = resolveWorkspaceAiSettingsForAgent({ agentId: "exec", - agentAiDefaults: {}, + agentAiDefaults: { exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "high" } }, workspaceByAgent: { exec: { model: "openai:gpt-5.2", thinkingLevel: "medium" }, }, @@ -61,155 +57,14 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { }); }); - test("a saved workspace bucket beats configured defaults on explicit switches", () => { + test("ignores workspace-by-agent fallback when disabled", () => { 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: {}, + agentAiDefaults: { exec: { modelString: "openai:gpt-5.3-codex", thinkingLevel: "high" } }, workspaceByAgent: { exec: { model: "openai:gpt-5.2", thinkingLevel: "medium" }, }, - mode: "creation-sync", + useWorkspaceByAgentFallback: false, fallbackModel: "openai:gpt-5.2-mini", existingModel: "anthropic:claude-opus-4-6", existingThinking: "off", @@ -424,31 +279,21 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { expect(result.resolvedReasoningMode).toBe("pro"); }); - test("a hydrated bucket owns background sync over configured defaults", () => { + test("inherits the workspace's current pro mode during background sync", () => { const result = resolveWorkspaceAiSettingsForAgent({ agentId: "exec", - agentAiDefaults: { - exec: { - modelString: "anthropic:claude-haiku-4-5", - thinkingLevel: "off", - reasoningMode: "pro", - }, - }, + agentAiDefaults: {}, workspaceByAgent: { - exec: { model: "openai:gpt-5.6-sol", thinkingLevel: "medium" }, + exec: { model: "openai:gpt-5.6-sol", thinkingLevel: "medium", reasoningMode: "standard" }, }, useWorkspaceByAgentFallback: false, fallbackModel: "openai:gpt-5.2-mini", - existingModel: "anthropic:claude-haiku-4-5", + existingModel: "openai:gpt-5.6-sol", existingThinking: "off", existingReasoningMode: "pro", }); - expect(result).toEqual({ - resolvedModel: "openai:gpt-5.6-sol", - resolvedThinking: "medium", - resolvedReasoningMode: "standard", - }); + expect(result.resolvedReasoningMode).toBe("pro"); }); test("defaults legacy per-agent entries without reasoningMode to standard on explicit switches", () => { @@ -525,45 +370,6 @@ 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 f699a2687b8..e4392e3abc1 100644 --- a/src/browser/utils/workspaceModeAi.ts +++ b/src/browser/utils/workspaceModeAi.ts @@ -1,7 +1,5 @@ import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; -import { isBuiltInSelectableAgentId } from "@/browser/utils/agents"; -import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; -import { targetWorkspaceBucketToLayer, type AiSettingSource } from "@/common/types/agentAiSettings"; +import type { AiSettingSource } from "@/common/types/agentAiSettings"; import { coerceOpenAIReasoningMode, coerceThinkingLevel, @@ -9,10 +7,7 @@ import { type ThinkingLevel, } from "@/common/types/thinking"; import { normalizeAgentId as normalizeWorkspaceAgentId } from "@/common/utils/agentIds"; -import { - collectDeclaredAncestorLayers, - type AgentAncestorDescriptor, -} from "@/common/utils/ai/agentAncestorLayers"; +import { collectDeclaredAncestorLayers } from "@/common/utils/ai/agentAncestorLayers"; import { resolveAgentAiSettings } from "@/common/utils/ai/resolveAgentAiSettings"; export type WorkspaceAISettingsCache = Partial< @@ -63,156 +58,84 @@ export function resolveConfiguredAiDefaults( }; } -type WorkspaceAiResolutionMode = "explicit-switch" | "background-sync" | "creation-sync"; - -type WorkspaceAgentDescriptor = Pick< - AgentDefinitionDescriptor, - "id" | "base" | "ownAiDefaults" | "aiAncestors" ->; - -interface WorkspaceAiResolutionArgs { +// 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: { agentId: string; agentAiDefaults: AgentAiDefaults; workspaceByAgent?: WorkspaceAISettingsCache; + useWorkspaceByAgentFallback?: boolean; fallbackModel: string; existingModel: string; existingThinking: ThinkingLevel; existingReasoningMode?: OpenAIReasoningMode; - agents?: readonly WorkspaceAgentDescriptor[]; - /** Compatibility inputs for pure resolver tests and non-UI adapters. */ - useWorkspaceByAgentFallback?: boolean; + /** Agent id -> base id, for base-chain reasoning-mode inheritance (custom agents). */ 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 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; - } - 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 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, - }; + // 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 configuredModel = workspaceOverride ? undefined : 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 = + (workspaceOverride ? undefined : 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 }; } diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index 6b6bfd14c0e..290c0e67525 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -129,13 +129,6 @@ 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", /** @@ -212,10 +205,6 @@ 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 e56e01c8c7c..7c0037a42ac 100644 --- a/src/common/orpc/schemas/agentDefinition.ts +++ b/src/common/orpc/schemas/agentDefinition.ts @@ -101,20 +101,6 @@ 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 b810d1b2d2a..8a87a56e0f5 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1447,9 +1447,7 @@ export const workspace = { input: z.object({ workspaceId: z.string(), agentId: AgentIdSchema, - // Null persists only the selected agent (with persistSelectedAgentId), - // leaving the agent's stored model/thinking settings untouched. - aiSettings: WorkspaceAISettingsSchema.nullish(), + aiSettings: WorkspaceAISettingsSchema, 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 deleted file mode 100644 index 7dadc2e968e..00000000000 --- a/src/common/utils/ai/workspaceAiSettingsWrite.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 6f0c726f231..02bd98ff125 100644 --- a/src/node/acp/agent.ts +++ b/src/node/acp/agent.ts @@ -49,7 +49,7 @@ import { } from "@/common/utils/subProjects"; import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue"; import { negotiateCapabilities, type NegotiatedCapabilities } from "./capabilities"; -import { AGENT_MODE_CONFIG_ID, buildConfigOptions, handleSetConfigOption } from "./configOptions"; +import { buildConfigOptions, handleSetConfigOption } from "./configOptions"; import { forkSessionFromWorkspace } from "./experimental/sessionFork"; import { canonicalizePathForWorkspaceMatch, @@ -65,11 +65,6 @@ 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, @@ -377,7 +372,6 @@ export class MuxAgent implements Agent { const agentId = meta.agentId ?? workspace.agentId ?? DEFAULT_AGENT_ID; const aiSettings = await resolveAgentAiSettings(this.server.client, agentId, workspaceId); - await this.persistAiSettings(workspaceId, agentId, aiSettings); this.sessionStateById.set(sessionId, { workspaceId, @@ -393,6 +387,7 @@ export class MuxAgent implements Agent { sessionId, configOptions: await buildConfigOptions(this.server.client, workspaceId, { activeAgentId: agentId, + aiSettings, }), }; @@ -556,8 +551,6 @@ export class MuxAgent implements Agent { meta.forkName ); - await this.persistAiSettings(forked.workspaceId, forked.agentId, forked.aiSettings); - this.sessionStateById.set(forked.sessionId, { workspaceId: forked.workspaceId, runtimeMode: forked.runtimeMode, @@ -603,8 +596,7 @@ export class MuxAgent implements Agent { options: { model: sessionState.aiSettings.model, thinkingLevel: sessionState.aiSettings.thinkingLevel, - // Per-workspace pro mode from workspace metadata; the send path - // re-gates per model/route so this is inert for unsupported models. + // The send path re-gates pro mode for the selected model and route. reasoningMode: sessionState.aiSettings.reasoningMode, agentId: sessionState.agentId, }, @@ -660,24 +652,21 @@ export class MuxAgent implements Agent { ); } - const activeAgentId = this.sessionStateById.get(sessionId)?.agentId; + const sessionState = this.sessionStateById.get(sessionId); const configOptions = await handleSetConfigOption( this.server.client, workspaceId, params.configId, params.value, { - activeAgentId, + activeAgentId: sessionState?.agentId, + aiSettings: sessionState?.aiSettings, onAgentModeChanged: (agentId, aiSettings) => { this.updateSessionAgentState(sessionId, agentId, aiSettings); }, } ); - if (trimmedConfigId !== AGENT_MODE_CONFIG_ID) { - await this.refreshSessionState(sessionId); - } - return { configOptions }; } @@ -733,7 +722,7 @@ export class MuxAgent implements Agent { delegatedToolNames ); - const sendResult = await sendAcpWorkspaceMessage(this.server.client, { + const sendResult = await this.server.client.workspace.sendMessage({ workspaceId: args.workspaceId, message: args.message, options: { @@ -941,7 +930,7 @@ export class MuxAgent implements Agent { let response = `Created forked workspace \`${newWorkspaceId}\`.`; if (parsedCommand.startMessage != null && parsedCommand.startMessage.trim().length > 0) { - const startMessageResult = await sendAcpWorkspaceMessage(this.server.client, { + const startMessageResult = await this.server.client.workspace.sendMessage({ workspaceId: newWorkspaceId, message: parsedCommand.startMessage, options: { @@ -1005,7 +994,7 @@ export class MuxAgent implements Agent { let response = `Created workspace \`${displayName}\` (id: \`${newWorkspaceId}\`).`; if (hasStartMessage && parsedCommand.startMessage != null) { - const startMessageResult = await sendAcpWorkspaceMessage(this.server.client, { + const startMessageResult = await this.server.client.workspace.sendMessage({ workspaceId: newWorkspaceId, message: parsedCommand.startMessage, options: { @@ -2148,7 +2137,9 @@ export class MuxAgent implements Agent { // selection lives in sessionStateById and must not be reverted by a // workspace.agentId value from the backend. const agentId = existing?.agentId ?? workspace.agentId ?? DEFAULT_AGENT_ID; + // Picker choices remain session-local until the next user message sends them. const aiSettings = + existing?.aiSettings ?? workspace.aiSettingsByAgent?.[agentId] ?? workspace.aiSettings ?? (await resolveAgentAiSettings(this.server.client, agentId, workspaceId)); @@ -2164,36 +2155,6 @@ export class MuxAgent implements Agent { return nextState; } - private async persistAiSettings( - workspaceId: string, - agentId: string, - aiSettings: ResolvedAiSettings - ): Promise { - if (agentId === "plan" || agentId === "exec") { - const updateModeResult = await updateAcpWorkspaceModeAISettings(this.server.client, { - workspaceId, - mode: agentId, - aiSettings, - }); - - if (!updateModeResult.success) { - throw new Error(`workspace.updateModeAISettings failed: ${updateModeResult.error}`); - } - - return; - } - - const updateAgentResult = await updateAcpWorkspaceAgentAISettings(this.server.client, { - workspaceId, - agentId, - aiSettings, - }); - - if (!updateAgentResult.success) { - throw new Error(`workspace.updateAgentAISettings failed: ${updateAgentResult.error}`); - } - } - async waitForDisconnectCleanup(): Promise { await this.disconnectCleanupPromise; } diff --git a/src/node/acp/configOptions.ts b/src/node/acp/configOptions.ts index c57db6691a9..850d6c47136 100644 --- a/src/node/acp/configOptions.ts +++ b/src/node/acp/configOptions.ts @@ -9,10 +9,6 @@ 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"; @@ -131,29 +127,15 @@ async function resolveAvailableAgentIds( } type WorkspaceInfo = NonNullable>>; -type UpdateAgentAiSettingsResult = Awaited< - ReturnType ->; - interface BuildConfigOptionsArgs { activeAgentId?: string; + aiSettings?: ResolvedAiSettings; } -interface HandleSetConfigOptionArgs { - activeAgentId?: string; +interface HandleSetConfigOptionArgs extends BuildConfigOptionsArgs { onAgentModeChanged?: (agentId: string, aiSettings: ResolvedAiSettings) => Promise | void; } -function isModeAgentId(agentId: string): agentId is "plan" | "exec" { - return agentId === "plan" || agentId === "exec"; -} - -function ensureUpdateSucceeded(result: UpdateAgentAiSettingsResult, operation: string): void { - if (!result.success) { - throw new Error(`${operation} failed: ${result.error}`); - } -} - async function getWorkspaceInfoOrThrow( client: ORPCClient, workspaceId: string @@ -243,45 +225,6 @@ function buildThinkingLevelSelectOptions(modelString: string): SessionConfigSele })); } -async function persistAgentAiSettings( - client: ORPCClient, - workspaceId: string, - agentId: string, - 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 updateAcpWorkspaceModeAISettings(client, { - workspaceId, - mode: agentId, - aiSettings, - }); - ensureUpdateSucceeded(updateModeResult, "workspace.updateModeAISettings"); - return; - } - - const updateAgentResult = await updateAcpWorkspaceAgentAISettings(client, { - workspaceId, - agentId, - aiSettings, - }); - ensureUpdateSucceeded(updateAgentResult, "workspace.updateAgentAISettings"); -} - export async function buildConfigOptions( client: ORPCClient, workspaceId: string, @@ -301,12 +244,9 @@ export async function buildConfigOptions( : getCurrentAgentId(workspace), availableAgentIds.length > 0 ? availableAgentIds : exposedAgentModes.map((mode) => mode.value) ); - const currentAiSettings = await resolveCurrentAiSettings( - client, - workspace, - workspaceId, - currentAgentId - ); + const currentAiSettings = + args?.aiSettings ?? + (await resolveCurrentAiSettings(client, workspace, workspaceId, currentAgentId)); const agentModeOptions = buildAgentModeSelectOptions(exposedAgentModes, currentAgentId); const effectiveThinkingLevel = enforceThinkingPolicy( @@ -374,13 +314,17 @@ export async function handleSetConfigOption( knownAgentIds ); + let nextAgentId = currentAgentId; + let nextAiSettings: ResolvedAiSettings; if (trimmedConfigId === AGENT_MODE_CONFIG_ID) { - const nextAgentId = resolveCurrentAgentId(trimmedValue, knownAgentIds); + nextAgentId = resolveCurrentAgentId(trimmedValue, knownAgentIds); // Prefer workspace-specific settings already saved for the target agent // (e.g., user customized model/thinking for this mode). Only fall back // to resolved defaults when no prior settings exist for the agent. - const existingSettings = workspace.aiSettingsByAgent?.[nextAgentId]; + const existingSettings = + (nextAgentId === currentAgentId ? args?.aiSettings : undefined) ?? + workspace.aiSettingsByAgent?.[nextAgentId]; const resolvedAiSettings = existingSettings?.model != null && existingSettings?.thinkingLevel != null ? { @@ -390,7 +334,7 @@ export async function handleSetConfigOption( } : await resolveAgentAiSettings(client, nextAgentId, trimmedWorkspaceId); - const normalizedAiSettings: ResolvedAiSettings = { + nextAiSettings = { model: resolvedAiSettings.model, thinkingLevel: enforceThinkingPolicy( resolvedAiSettings.model, @@ -400,67 +344,36 @@ export async function handleSetConfigOption( ? { reasoningMode: resolvedAiSettings.reasoningMode } : {}), }; - - // 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); + } else { + const currentAiSettings = + args?.aiSettings ?? + (await resolveCurrentAiSettings(client, workspace, trimmedWorkspaceId, currentAgentId)); + + if (trimmedConfigId === MODEL_CONFIG_ID) { + // The send path re-gates pro mode for the selected model and route. + nextAiSettings = { + ...currentAiSettings, + model: trimmedValue, + thinkingLevel: enforceThinkingPolicy(trimmedValue, currentAiSettings.thinkingLevel), + }; + } else if (trimmedConfigId === THINKING_LEVEL_CONFIG_ID) { + if (!isThinkingLevel(trimmedValue)) { + throw new Error( + `handleSetConfigOption: value must be a valid ThinkingLevel, got '${trimmedValue}'` + ); + } + nextAiSettings = { + ...currentAiSettings, + thinkingLevel: enforceThinkingPolicy(currentAiSettings.model, trimmedValue), + }; + } else { + throw new Error(`Unsupported config option id '${trimmedConfigId}'`); } - - return buildConfigOptions(client, trimmedWorkspaceId, { activeAgentId: nextAgentId }); - } - - const currentAiSettings = await resolveCurrentAiSettings( - client, - workspace, - trimmedWorkspaceId, - currentAgentId - ); - - if (trimmedConfigId === MODEL_CONFIG_ID) { - const clampedThinkingLevel = enforceThinkingPolicy( - trimmedValue, - currentAiSettings.thinkingLevel - ); - - // Retain pro mode across model changes (matching the settings UI); the - // send path re-gates per model so unsupported models are unaffected. - await persistAgentAiSettings(client, trimmedWorkspaceId, currentAgentId, { - model: trimmedValue, - thinkingLevel: clampedThinkingLevel, - ...(currentAiSettings.reasoningMode != null - ? { reasoningMode: currentAiSettings.reasoningMode } - : {}), - }); - - return buildConfigOptions(client, trimmedWorkspaceId, { activeAgentId: currentAgentId }); } - if (trimmedConfigId === THINKING_LEVEL_CONFIG_ID) { - if (!isThinkingLevel(trimmedValue)) { - throw new Error( - `handleSetConfigOption: value must be a valid ThinkingLevel, got '${trimmedValue}'` - ); - } - - const clampedThinkingLevel = enforceThinkingPolicy(currentAiSettings.model, trimmedValue); - - await persistAgentAiSettings(client, trimmedWorkspaceId, currentAgentId, { - model: currentAiSettings.model, - thinkingLevel: clampedThinkingLevel, - ...(currentAiSettings.reasoningMode != null - ? { reasoningMode: currentAiSettings.reasoningMode } - : {}), - }); - - return buildConfigOptions(client, trimmedWorkspaceId, { activeAgentId: currentAgentId }); - } - - throw new Error(`Unsupported config option id '${trimmedConfigId}'`); + await args?.onAgentModeChanged?.(nextAgentId, nextAiSettings); + return buildConfigOptions(client, trimmedWorkspaceId, { + activeAgentId: nextAgentId, + aiSettings: nextAiSettings, + }); } diff --git a/src/node/acp/resolveAgentAiSettings.ts b/src/node/acp/resolveAgentAiSettings.ts index bad714aac92..2775af2f445 100644 --- a/src/node/acp/resolveAgentAiSettings.ts +++ b/src/node/acp/resolveAgentAiSettings.ts @@ -65,10 +65,7 @@ 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.ownAiDefaults }, - ]) + agents.map((agent) => [agent.id, { base: agent.base, definitionAiDefaults: agent.aiDefaults }]) ); return resolveAgentAiSettingsShared({ @@ -77,7 +74,7 @@ export async function resolveAcpAgentAiSettings( explicit: extras?.explicit, targetWorkspaceSettings: extras?.targetWorkspaceSettings, agentAiDefaults: config.agentAiDefaults, - targetDefinitionAiDefaults: agentDef?.ownAiDefaults, + targetDefinitionAiDefaults: agentDef?.aiDefaults, ancestors: collectDeclaredAncestorLayers(trimmedAgentId, agentDefsById), parentRuntime: extras?.parentRuntime, }); diff --git a/src/node/acp/workspaceAiSettingsSync.test.ts b/src/node/acp/workspaceAiSettingsSync.test.ts deleted file mode 100644 index ce9a2590411..00000000000 --- a/src/node/acp/workspaceAiSettingsSync.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 5e2483d28f2..00000000000 --- a/src/node/acp/workspaceAiSettingsSync.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 b904f6fefc7..62db8bbac95 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -57,118 +57,6 @@ 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 64ab7dbcd33..c425ea2be02 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -108,8 +108,6 @@ 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"; @@ -1828,28 +1826,14 @@ export const router = (authToken?: string) => { const resolved = await Promise.all( descriptors.map(async (descriptor) => { try { - 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({ + const resolvedFrontmatter = await resolveAgentFrontmatter( runtime, - workspacePath: discoveryPath, - agentId: descriptor.id, - agentDefinition, - workspaceId: input.workspaceId ?? discoveryPath, - includeAgentPlugins, - }); - const { targetDefinitionAiDefaults, ancestors } = collectDefinitionLayers( + discoveryPath, descriptor.id, - inheritanceChain + { + includeAgentPlugins, + skipScopesAbove: getSkipScopesAboveForKnownScope(descriptor.scope), + } ); const effectivelyDisabled = isAgentEffectivelyDisabled({ @@ -1874,8 +1858,6 @@ export const router = (authToken?: string) => { kind: "resolved" as const, descriptor, resolvedFrontmatter, - targetDefinitionAiDefaults, - ancestors, uiSelectableBase, }; } catch { @@ -1889,7 +1871,7 @@ export const router = (authToken?: string) => { return []; } if (entry.kind === "fallback") { - return [{ ...entry.descriptor, ownAiDefaults: entry.descriptor.aiDefaults }]; + return [entry.descriptor]; } return [ @@ -1902,8 +1884,6 @@ 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, }, ]; @@ -4709,7 +4689,7 @@ export const router = (authToken?: string) => { return context.workspaceService.updateAgentAISettings( input.workspaceId, input.agentId, - input.aiSettings ?? null, + input.aiSettings, { persistSelectedAgentId: input.persistSelectedAgentId === true } ); }), diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 3b3f0cc1aed..30b7a1a42a9 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -141,7 +141,6 @@ const mockInitStateManager: Partial = { clearInMemoryState: mock(() => undefined), }; const mockExtensionMetadataService: Partial = { - getSnapshot: mock(() => Promise.resolve(null)), setStreaming: mock(() => Promise.resolve({ recency: Date.now(), @@ -8390,11 +8389,7 @@ describe("WorkspaceService sendMessage status clearing", () => { ( workspaceService as unknown as { - maybePersistAISettingsFromOptions: ( - workspaceId: string, - options: unknown, - source: "send" | "resume" - ) => Promise; + maybePersistAISettingsFromOptions: (workspaceId: string, options: unknown) => Promise; } ).maybePersistAISettingsFromOptions = mock(() => Promise.resolve()); }); @@ -8403,6 +8398,27 @@ describe("WorkspaceService sendMessage status clearing", () => { await cleanupHistory(); }); + test.each(["send", "synthetic", "resume"] as const)( + "only a user message updates remembered settings (%s)", + async (kind) => { + const persist = mock(() => Promise.resolve()); + ( + workspaceService as unknown as { + maybePersistAISettingsFromOptions: typeof persist; + } + ).maybePersistAISettingsFromOptions = persist; + const options = { model: "openai:gpt-5.2", agentId: "plan", thinkingLevel: "high" as const }; + const result = + kind === "resume" + ? await workspaceService.resumeStream("test-workspace", options) + : await workspaceService.sendMessage("test-workspace", "hello", options, { + synthetic: kind === "synthetic", + }); + expect(result.success).toBe(true); + expect(persist).toHaveBeenCalledTimes(kind === "send" ? 1 : 0); + } + ); + test("delegates manual pricing rejections to AgentSession so user input is preserved", async () => { fakeSession.isBusy.mockReturnValue(false); const pricingError: SendMessageError = { type: "unknown", raw: "unpriced model" }; @@ -9408,11 +9424,7 @@ describe("WorkspaceService pending auto-title", () => { ( workspaceService as unknown as { - maybePersistAISettingsFromOptions: ( - workspaceId: string, - options: unknown, - source: "send" | "resume" - ) => Promise; + maybePersistAISettingsFromOptions: (workspaceId: string, options: unknown) => Promise; } ).maybePersistAISettingsFromOptions = mock(() => Promise.resolve()); }); @@ -11583,269 +11595,22 @@ 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 })); interface WorkspaceServiceTestAccess { - maybePersistAISettingsFromOptions: ( - workspaceId: string, - options: unknown, - context: "send" | "resume" - ) => Promise; + maybePersistAISettingsFromOptions: (workspaceId: string, options: unknown) => Promise; persistWorkspaceAISettingsForAgent: (...args: unknown[]) => unknown; } const svc = workspaceService as unknown as WorkspaceServiceTestAccess; svc.persistWorkspaceAISettingsForAgent = persistSpy; - await svc.maybePersistAISettingsFromOptions( - "ws", - { - agentId: "reviewer", - model: "openai:gpt-4o-mini", - thinkingLevel: "off", - }, - "send" - ); + await svc.maybePersistAISettingsFromOptions("ws", { + agentId: "reviewer", + model: "openai:gpt-4o-mini", + thinkingLevel: "off", + }); expect(persistSpy).toHaveBeenCalledTimes(1); }); @@ -11854,26 +11619,18 @@ describe("WorkspaceService maybePersistAISettingsFromOptions", () => { const persistSpy = mock(() => Promise.resolve({ success: true as const, data: true })); interface WorkspaceServiceTestAccess { - maybePersistAISettingsFromOptions: ( - workspaceId: string, - options: unknown, - context: "send" | "resume" - ) => Promise; + maybePersistAISettingsFromOptions: (workspaceId: string, options: unknown) => Promise; persistWorkspaceAISettingsForAgent: (...args: unknown[]) => unknown; } const svc = workspaceService as unknown as WorkspaceServiceTestAccess; svc.persistWorkspaceAISettingsForAgent = persistSpy; - await svc.maybePersistAISettingsFromOptions( - "ws", - { - agentId: "exec", - model: "openai:gpt-4o-mini", - thinkingLevel: "off", - }, - "send" - ); + await svc.maybePersistAISettingsFromOptions("ws", { + agentId: "exec", + model: "openai:gpt-4o-mini", + thinkingLevel: "off", + }); expect(persistSpy).toHaveBeenCalledTimes(1); }); @@ -11882,11 +11639,7 @@ describe("WorkspaceService maybePersistAISettingsFromOptions", () => { const persistSpy = mock(() => Promise.resolve({ success: true as const, data: true })); interface WorkspaceServiceTestAccess { - maybePersistAISettingsFromOptions: ( - workspaceId: string, - options: unknown, - context: "send" | "resume" - ) => Promise; + maybePersistAISettingsFromOptions: (workspaceId: string, options: unknown) => Promise; persistWorkspaceAISettingsForAgent: (...args: unknown[]) => unknown; config: { findWorkspace: ( @@ -11924,15 +11677,11 @@ describe("WorkspaceService maybePersistAISettingsFromOptions", () => { ]), })); - await svc.maybePersistAISettingsFromOptions( - "ws", - { - agentId: "exec", - model: "openai:gpt-4o-mini", - thinkingLevel: "off", - }, - "send" - ); + await svc.maybePersistAISettingsFromOptions("ws", { + agentId: "exec", + model: "openai:gpt-4o-mini", + thinkingLevel: "off", + }); expect(persistSpy).toHaveBeenCalledTimes(1); expect(persistSpy).toHaveBeenCalledWith( @@ -17224,7 +16973,7 @@ describe("WorkspaceService fork", () => { } }); - test("forks inherit the latest persisted agent settings after setup", async () => { + test("auto-generated fork names normalize legacy fork families before the validation fallback", async () => { const sourceWorkspaceId = "source-workspace"; const newWorkspaceId = "forked-workspace"; const sourceProjectPath = path.join(tempDir, "project"); @@ -17236,28 +16985,7 @@ 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 }); @@ -17273,9 +17001,7 @@ describe("WorkspaceService fork", () => { const mockAIService = { isStreaming: mock(() => false), - getWorkspaceMetadata: mock(() => - Promise.resolve(Ok(metadataReads++ === 0 ? sourceMetadata : latestSourceMetadata)) - ), + getWorkspaceMetadata: mock(() => Promise.resolve(Ok(sourceMetadata))), // eslint-disable-next-line @typescript-eslint/no-empty-function on: mock(() => {}), // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -17346,18 +17072,6 @@ 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 4d3532cb1c4..dbfef0473ed 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, resolvePersistedAgentId } from "@/common/utils/agentIds"; +import { normalizeAgentId } from "@/common/utils/agentIds"; import { HEARTBEAT_CONTEXT_MODE_VALUES, HEARTBEAT_DEFAULT_CONTEXT_MODE, @@ -9890,14 +9890,9 @@ export class WorkspaceService extends EventEmitter { ); } - /** - * Best-effort persist AI settings from send/resume options. - * Skips requests explicitly marked to avoid persistence. - */ private async maybePersistAISettingsFromOptions( workspaceId: string, - options: SendMessageOptions | undefined, - context: "send" | "resume" + options: SendMessageOptions | undefined ): Promise { if (options?.skipAiSettingsPersistence) { // One-shot/compaction sends shouldn't overwrite workspace defaults. @@ -9913,14 +9908,13 @@ export class WorkspaceService extends EventEmitter { agentId, extractedSettings, { - // Normal sends/resumes also persist the selected agent so future backend heartbeat - // dispatches can reuse the same workspace default after reloads and reconnects. + // Save the selected agent so heartbeats can reuse it after reloads and reconnects. persistSelectedAgentId: true, ...(options?.disableWorkspaceAgents === true ? { disableWorkspaceAgents: true } : {}), } ); if (!persistResult.success) { - log.debug(`Failed to persist workspace AI settings from ${context} options`, { + log.debug("Failed to persist workspace AI settings from user message", { workspaceId, error: persistResult.error, }); @@ -10065,19 +10059,13 @@ export class WorkspaceService extends EventEmitter { async updateAgentAISettings( workspaceId: string, agentId: string, - // Null persists only the selected agent (with persistSelectedAgentId), - // leaving the agent's stored settings untouched. - aiSettings: WorkspaceAISettings | null, + aiSettings: WorkspaceAISettings, options?: { persistSelectedAgentId?: boolean } ): Promise> { try { - let normalizedSettings: WorkspaceAISettings | null = null; - if (aiSettings != null) { - const normalized = this.normalizeWorkspaceAISettings(aiSettings); - if (!normalized.success) { - return Err(normalized.error); - } - normalizedSettings = normalized.data; + const normalized = this.normalizeWorkspaceAISettings(aiSettings); + if (!normalized.success) { + return Err(normalized.error); } if (this.workspaceGoalService) { @@ -10087,58 +10075,23 @@ 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)) { - // 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 = + if ( + hasBudgetedResumableGoal(goal) && + !modelHasPricingData( + normalized.data.model, typeof this.config.loadProvidersConfig === "function" ? this.config.loadProvidersConfig() - : null; - if (gatedModels.some((model) => !modelHasPricingData(model, providersConfig))) { - return Err(UNPRICED_TARGET_MODEL_GOAL_MESSAGE); - } + : null + ) + ) { + return Err(UNPRICED_TARGET_MODEL_GOAL_MESSAGE); } } const persistResult = await this.persistWorkspaceAISettingsForAgent( workspaceId, agentId, - normalizedSettings, + normalized.data, { emitMetadata: true, ...(options?.persistSelectedAgentId === true ? { persistSelectedAgentId: true } : {}), @@ -10156,7 +10109,7 @@ export class WorkspaceService extends EventEmitter { status: "completed", data: { agentId, - model: normalizedSettings?.model, + model: normalized.data.model, mode: parsedMode.success ? parsedMode.data : undefined, }, }); @@ -10568,15 +10521,6 @@ 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, @@ -10587,14 +10531,6 @@ 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 @@ -11296,8 +11232,10 @@ export class WorkspaceService extends EventEmitter { return Err(pricingGate.error); } - // Persist last-used model + thinking level for cross-device consistency. - await this.maybePersistAISettingsFromOptions(workspaceId, normalizedOptions, "send"); + // Synthetic turns must not replace the user's remembered model and mode. + if (internal?.synthetic !== true) { + await this.maybePersistAISettingsFromOptions(workspaceId, normalizedOptions); + } const shouldQueue = !normalizedOptions?.editMessageId && session.isBusy(); @@ -11758,9 +11696,6 @@ export class WorkspaceService extends EventEmitter { return Err(pricingGate.error); } - // Persist last-used model + thinking level for cross-device consistency. - await this.maybePersistAISettingsFromOptions(workspaceId, normalizedOptions, "resume"); - // Non-destructive interrupt cascades preserve descendant task workspaces with // taskStatus=interrupted. Transition before stream start so TaskService stream-end // handling does not early-return on interrupted status. @@ -14241,26 +14176,6 @@ 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) { @@ -14275,18 +14190,12 @@ 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( - overrideAgentId ?? workspaceEntry?.agentId, - WORKSPACE_DEFAULTS.agentId - ); + const persistedAgentId = normalizeAgentId(workspaceEntry?.agentId, WORKSPACE_DEFAULTS.agentId); const agentId = persistedAgentId === "plan" || persistedAgentId === "compact" ? WORKSPACE_DEFAULTS.agentId : persistedAgentId; - const selectedAgentSettings = - pendingBucket?.agentId === agentId - ? pendingBucket.settings - : workspaceEntry?.aiSettingsByAgent?.[agentId]; + const selectedAgentSettings = workspaceEntry?.aiSettingsByAgent?.[agentId]; // Unified interactive resolution: the workspace's own bucket, then // configured/definition defaults and the declared base chain, then the @@ -14845,23 +14754,12 @@ export class WorkspaceService extends EventEmitter { : String(error); } - /** - * 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>; + 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); @@ -14878,18 +14776,13 @@ export class WorkspaceService extends EventEmitter { const activity = await this.extensionMetadata.getSnapshot(workspaceId); - const agentId = normalizeAgentId( - overrideAgentId ?? workspaceEntry?.agentId, - WORKSPACE_DEFAULTS.agentId - ); - const agentSettings = - pendingBucket?.agentId === agentId - ? pendingBucket.settings - : workspaceEntry?.aiSettingsByAgent?.[agentId]; + const rawAgentId = workspaceEntry?.agentId; + const agentId = normalizeAgentId(rawAgentId, WORKSPACE_DEFAULTS.agentId); + const agentSettings = workspaceEntry?.aiSettingsByAgent?.[agentId]; - // 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. + // 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. const resolved = await resolveNodeAgentAiSettings({ agentId, profile: "interactive", @@ -14918,31 +14811,6 @@ 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 98a5b845b62..2681ea031b5 100644 --- a/tests/ipc/acp.configOptions.test.ts +++ b/tests/ipc/acp.configOptions.test.ts @@ -53,11 +53,11 @@ function createHarness( initial: WorkspaceState, options?: { agents?: AgentDescriptor[]; - parentWorkspaceId?: string; } ): { client: ORPCClient; getWorkspaceState: () => WorkspaceState; + onAgentModeChanged: jest.Mock; updateModeCalls: Array<{ workspaceId: string; mode: "exec" | "plan"; @@ -67,10 +67,9 @@ function createHarness( workspaceId: string; agentId: string; aiSettings: WorkspaceAiSettings; - persistSelectedAgentId?: boolean; }>; } { - let workspaceState: WorkspaceState = { + const workspaceState: WorkspaceState = { agentId: initial.agentId, aiSettings: { ...initial.aiSettings }, aiSettingsByAgent: { ...initial.aiSettingsByAgent }, @@ -85,7 +84,6 @@ function createHarness( workspaceId: string; agentId: string; aiSettings: WorkspaceAiSettings; - persistSelectedAgentId?: boolean; }> = []; const availableAgents = options?.agents ?? DEFAULT_AGENT_DESCRIPTORS; @@ -103,9 +101,6 @@ function createHarness( agentId: workspaceState.agentId, aiSettings: workspaceState.aiSettings, aiSettingsByAgent: workspaceState.aiSettingsByAgent, - ...(options?.parentWorkspaceId != null - ? { parentWorkspaceId: options.parentWorkspaceId } - : {}), }), updateModeAISettings: async (input: { workspaceId: string; @@ -114,36 +109,15 @@ function createHarness( }) => { updateModeCalls.push(input); - workspaceState = { - ...workspaceState, - agentId: input.mode, - aiSettings: { ...input.aiSettings }, - aiSettingsByAgent: { - ...workspaceState.aiSettingsByAgent, - [input.mode]: { ...input.aiSettings }, - }, - }; - return { success: true as const, data: undefined }; }, updateAgentAISettings: async (input: { workspaceId: string; agentId: string; aiSettings: WorkspaceAiSettings; - persistSelectedAgentId?: boolean; }) => { updateAgentCalls.push(input); - workspaceState = { - ...workspaceState, - agentId: input.agentId, - aiSettings: { ...input.aiSettings }, - aiSettingsByAgent: { - ...workspaceState.aiSettingsByAgent, - [input.agentId]: { ...input.aiSettings }, - }, - }; - return { success: true as const, data: undefined }; }, }, @@ -155,6 +129,7 @@ function createHarness( return { client, getWorkspaceState: () => workspaceState, + onAgentModeChanged: jest.fn(), updateModeCalls, updateAgentCalls, }; @@ -268,60 +243,11 @@ describe("ACP config options", () => { await handleSetConfigOption(harness.client, "ws-1", AGENT_MODE_CONFIG_ID, "exec", { activeAgentId: "plan", + onAgentModeChanged: harness.onAgentModeChanged, }); - // 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]?.mode).toBe("exec"); - expect(harness.updateAgentCalls).toHaveLength(0); + expect(harness.onAgentModeChanged.mock.calls[0]?.[1].reasoningMode).toBe("pro"); }); it("preserves pro reasoning mode across model and thinking level changes", async () => { @@ -335,16 +261,25 @@ describe("ACP config options", () => { await handleSetConfigOption(harness.client, "ws-1", "model", "anthropic:claude-opus-4-6", { activeAgentId: "exec", + onAgentModeChanged: harness.onAgentModeChanged, }); - expect(harness.updateModeCalls[0]?.aiSettings.reasoningMode).toBe("pro"); + expect(harness.onAgentModeChanged.mock.calls[0]?.[1].reasoningMode).toBe("pro"); await handleSetConfigOption(harness.client, "ws-1", "thinkingLevel", "medium", { activeAgentId: "exec", + aiSettings: harness.onAgentModeChanged.mock.calls[0]?.[1], + onAgentModeChanged: harness.onAgentModeChanged, }); - expect(harness.updateModeCalls[1]?.aiSettings.reasoningMode).toBe("pro"); + expect(harness.onAgentModeChanged.mock.calls[1]?.[1]).toEqual({ + model: "anthropic:claude-opus-4-6", + thinkingLevel: "medium", + reasoningMode: "pro", + }); + expect(harness.updateModeCalls).toHaveLength(0); + expect(harness.updateAgentCalls).toHaveLength(0); }); - it("clamps persisted thinking level when model changes", async () => { + it("clamps local thinking level when model changes", async () => { const harness = createHarness({ agentId: "exec", aiSettings: { @@ -364,11 +299,11 @@ describe("ACP config options", () => { "ws-1", "model", "openai:gpt-5-pro", - { activeAgentId: "exec" } + { activeAgentId: "exec", onAgentModeChanged: harness.onAgentModeChanged } ); - expect(harness.updateModeCalls).toHaveLength(1); - expect(harness.updateModeCalls[0]?.aiSettings).toEqual({ + expect(harness.updateModeCalls).toHaveLength(0); + expect(harness.onAgentModeChanged.mock.calls[0]?.[1]).toEqual({ model: "openai:gpt-5-pro", thinkingLevel: "high", }); @@ -379,8 +314,8 @@ describe("ACP config options", () => { expect(thinkingOption.currentValue).toBe("high"); expect(thinkingEntries.map((entry) => entry.value)).toEqual(["high"]); expect(harness.getWorkspaceState().aiSettingsByAgent.exec).toEqual({ - model: "openai:gpt-5-pro", - thinkingLevel: "high", + model: "anthropic:claude-opus-4-6", + thinkingLevel: "xhigh", }); }); @@ -403,10 +338,12 @@ describe("ACP config options", () => { const agentModeOption = getSelectConfigOption(options, AGENT_MODE_CONFIG_ID); expect(agentModeOption.currentValue).toBe("exec"); - const updated = await handleSetConfigOption(harness.client, "ws-1", "thinkingLevel", "off"); + const updated = await handleSetConfigOption(harness.client, "ws-1", "thinkingLevel", "off", { + onAgentModeChanged: harness.onAgentModeChanged, + }); - expect(harness.updateModeCalls).toHaveLength(1); - expect(harness.updateModeCalls[0]?.mode).toBe("exec"); + expect(harness.updateModeCalls).toHaveLength(0); + expect(harness.onAgentModeChanged.mock.calls[0]?.[0]).toBe("exec"); const updatedThinkingOption = getSelectConfigOption(updated, "thinkingLevel"); expect(updatedThinkingOption.currentValue).toBe("off"); @@ -446,10 +383,12 @@ describe("ACP config options", () => { const agentModeOption = getSelectConfigOption(options, AGENT_MODE_CONFIG_ID); expect(agentModeOption.currentValue).toBe("ask"); - const updated = await handleSetConfigOption(harness.client, "ws-1", "thinkingLevel", "off"); + const updated = await handleSetConfigOption(harness.client, "ws-1", "thinkingLevel", "off", { + onAgentModeChanged: harness.onAgentModeChanged, + }); - expect(harness.updateAgentCalls).toHaveLength(1); - expect(harness.updateAgentCalls[0]?.agentId).toBe("ask"); + expect(harness.updateAgentCalls).toHaveLength(0); + expect(harness.onAgentModeChanged.mock.calls[0]?.[0]).toBe("ask"); const updatedThinkingOption = getSelectConfigOption(updated, "thinkingLevel"); expect(updatedThinkingOption.currentValue).toBe("off"); diff --git a/tests/ipc/acp.promptCorrelation.test.ts b/tests/ipc/acp.promptCorrelation.test.ts index 9109ade9411..49b1f0ce251 100644 --- a/tests/ipc/acp.promptCorrelation.test.ts +++ b/tests/ipc/acp.promptCorrelation.test.ts @@ -565,6 +565,35 @@ function streamEnd( } describe("ACP prompt stream correlation", () => { + it("sends session-local picker settings despite unchanged workspace metadata", async () => { + const harness = createHarness(); + await initializeDefaultAgent(harness); + const { sessionId } = await createDefaultSession(harness); + for (const [configId, value] of [ + ["agentMode", "plan"], + ["model", "openai:gpt-5.2"], + ["thinkingLevel", "high"], + ["agentMode", "plan"], + ]) { + await harness.agent.setSessionConfigOption({ sessionId, configId, value }); + } + expect(harness.sendMessageCalls).toHaveLength(0); + + const { promptPromise, promptCorrelationId } = await startPromptTurn(harness, sessionId); + expect(harness.sendMessageCalls[0]?.options).toMatchObject({ + agentId: "plan", + model: "openai:gpt-5.2", + thinkingLevel: "high", + }); + harness.pushChatEvent( + streamStart(sessionId, "assistant-local", { acpPromptId: promptCorrelationId }) + ); + harness.pushChatEvent(streamEnd(sessionId, "assistant-local")); + await expect(promptPromise).resolves.toMatchObject({ stopReason: "end_turn" }); + harness.closeConnection(); + await harness.connectionClosed; + }); + it("ignores unrelated stream-start/end pairs while waiting for this prompt turn", async () => { const harness = createHarness(); const { newSessionResponse, promptPromise, promptCorrelationId } = diff --git a/tests/ipc/workspace/aiSettings.test.ts b/tests/ipc/workspace/aiSettings.test.ts index e3e06d7b899..71e1480b04e 100644 --- a/tests/ipc/workspace/aiSettings.test.ts +++ b/tests/ipc/workspace/aiSettings.test.ts @@ -56,51 +56,6 @@ 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(); From c841243f0709d1c0b5d254eaaab2f4ceceda09d7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:36:27 +0000 Subject: [PATCH 36/36] fix: restore workspace choices only on initial load --- .../contexts/WorkspaceContext.test.tsx | 8 +++---- src/browser/contexts/WorkspaceContext.tsx | 10 ++------- src/browser/utils/workspaceModeAi.test.ts | 22 +++++++++++++++++++ src/browser/utils/workspaceModeAi.ts | 16 ++++++++------ src/node/acp/configOptions.ts | 9 ++++++-- tests/ipc/acp.configOptions.test.ts | 17 ++++++++++++++ 6 files changed, 60 insertions(+), 22 deletions(-) diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index 294976e1563..ec8bdaf0bd2 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -520,7 +520,7 @@ describe("WorkspaceContext", () => { "xhigh" ); }); - test.each(["unchanged", "mode", "model"])("hydrates saved selections: %s", async (change) => { + test.each(["unchanged", "mode", "model"])("keeps local choices: %s", async (change) => { const changed = change !== "unchanged"; const nextAgentId = change === "mode" ? "auto" : "plan"; const workspaceId = "ws-agent-main"; @@ -584,10 +584,8 @@ describe("WorkspaceContext", () => { await waitFor(() => expect(ctx().workspaceMetadata.get(workspaceId)?.title).toBe("Updated title") ); - expect(readPersistedState(getAgentIdKey(workspaceId), "")).toBe(changed ? nextAgentId : "exec"); - expect(readPersistedState(getModelKey(workspaceId), "")).toBe( - changed ? "openai:gpt-5.3-codex" : "anthropic:claude-opus-4-6" - ); + expect(readPersistedState(getAgentIdKey(workspaceId), "")).toBe("exec"); + expect(readPersistedState(getModelKey(workspaceId), "")).toBe("anthropic:claude-opus-4-6"); }); 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 13264042add..2289a846457 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -169,14 +169,8 @@ function seedWorkspaceLocalStorageFromBackend( metadata: FrontendWorkspaceMetadata, previous?: FrontendWorkspaceMetadata ): void { - // Unchanged server settings must not overwrite choices the user hasn't sent yet. - if ( - metadata.parentWorkspaceId == null && - previous && - resolvePersistedAgentId(metadata, "") === resolvePersistedAgentId(previous, "") && - JSON.stringify(metadata.aiSettingsByAgent) === JSON.stringify(previous.aiSettingsByAgent) && - JSON.stringify(metadata.aiSettings) === JSON.stringify(previous.aiSettings) - ) { + // Restore on initial load only; later send echoes must not overwrite unsent choices. + if (metadata.parentWorkspaceId == null && previous != null) { return; } // Cache keyed by agentId (string) - includes exec, plan, and custom agents diff --git a/src/browser/utils/workspaceModeAi.test.ts b/src/browser/utils/workspaceModeAi.test.ts index 31cb9e63760..596c872abb2 100644 --- a/src/browser/utils/workspaceModeAi.test.ts +++ b/src/browser/utils/workspaceModeAi.test.ts @@ -370,6 +370,28 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { }); }); + test.each([undefined, "", "bogus", 42, "openai:gpt-5.2"])( + "invalid cached fields use configured defaults (%s)", + (model) => { + const result = resolveWorkspaceAiSettingsForAgent({ + agentId: "exec", + agentAiDefaults: { exec: { modelString: "openai:gpt-5.2", thinkingLevel: "high" } }, + workspaceByAgent: { + exec: { + model: model as string, + thinkingLevel: "invalid" as ThinkingLevel, + }, + }, + useWorkspaceByAgentFallback: true, + fallbackModel: "openai:gpt-5.2-mini", + existingModel: "anthropic:claude-opus-4-6", + existingThinking: "off", + }); + expect(result.resolvedModel).toBe("openai:gpt-5.2"); + expect(result.resolvedThinking).toBe("high"); + } + ); + 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 e4392e3abc1..488078230b0 100644 --- a/src/browser/utils/workspaceModeAi.ts +++ b/src/browser/utils/workspaceModeAi.ts @@ -1,4 +1,5 @@ import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; +import { isValidModelFormat } from "@/common/utils/ai/models"; import type { AiSettingSource } from "@/common/types/agentAiSettings"; import { coerceOpenAIReasoningMode, @@ -87,11 +88,11 @@ export function resolveWorkspaceAiSettingsForAgent(args: { args.agentAiDefaults, args.agentBaseById ); - const configuredModel = workspaceOverride ? undefined : configuredDefaults.modelString; - const workspaceOverrideModel = - args.useWorkspaceByAgentFallback && typeof workspaceOverride?.model === "string" - ? workspaceOverride.model - : undefined; + const cachedModel = + typeof workspaceOverride?.model === "string" ? workspaceOverride.model.trim() : ""; + const workspaceModel = isValidModelFormat(cachedModel) ? cachedModel : undefined; + const configuredModel = workspaceModel ? undefined : configuredDefaults.modelString; + const workspaceOverrideModel = args.useWorkspaceByAgentFallback ? workspaceModel : undefined; const inheritedModelCandidate = workspaceOverrideModel ?? (typeof args.existingModel === "string" ? args.existingModel : undefined) ?? @@ -106,12 +107,13 @@ export function resolveWorkspaceAiSettingsForAgent(args: { // Persisted workspace settings can be stale/corrupt; re-validate inherited values // so mode sync keeps self-healing behavior instead of propagating invalid options. + const workspaceThinking = coerceThinkingLevel(workspaceOverride?.thinkingLevel); const workspaceOverrideThinking = args.useWorkspaceByAgentFallback - ? coerceThinkingLevel(workspaceOverride?.thinkingLevel) + ? workspaceThinking : undefined; const inheritedThinking = workspaceOverrideThinking ?? coerceThinkingLevel(args.existingThinking); const resolvedThinking = - (workspaceOverride ? undefined : configuredDefaults.thinkingLevel) ?? + (workspaceThinking != null ? undefined : configuredDefaults.thinkingLevel) ?? inheritedThinking ?? "off"; diff --git a/src/node/acp/configOptions.ts b/src/node/acp/configOptions.ts index 850d6c47136..06ab72f25e5 100644 --- a/src/node/acp/configOptions.ts +++ b/src/node/acp/configOptions.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { isValidModelFormat, normalizeSelectedModel } from "@/common/utils/ai/models"; import type { SessionConfigOption, SessionConfigSelectOption } from "@agentclientprotocol/sdk"; import { KNOWN_MODELS } from "@/common/constants/knownModels"; import type { AgentDefinitionFrontmatter } from "@/common/types/agentDefinition"; @@ -350,11 +351,15 @@ export async function handleSetConfigOption( (await resolveCurrentAiSettings(client, workspace, trimmedWorkspaceId, currentAgentId)); if (trimmedConfigId === MODEL_CONFIG_ID) { + const model = normalizeSelectedModel(trimmedValue).trim(); + if (!isValidModelFormat(model)) { + throw new Error(`Invalid model format: ${trimmedValue}`); + } // The send path re-gates pro mode for the selected model and route. nextAiSettings = { ...currentAiSettings, - model: trimmedValue, - thinkingLevel: enforceThinkingPolicy(trimmedValue, currentAiSettings.thinkingLevel), + model, + thinkingLevel: enforceThinkingPolicy(model, currentAiSettings.thinkingLevel), }; } else if (trimmedConfigId === THINKING_LEVEL_CONFIG_ID) { if (!isThinkingLevel(trimmedValue)) { diff --git a/tests/ipc/acp.configOptions.test.ts b/tests/ipc/acp.configOptions.test.ts index 2681ea031b5..1b73aed0a6d 100644 --- a/tests/ipc/acp.configOptions.test.ts +++ b/tests/ipc/acp.configOptions.test.ts @@ -279,6 +279,23 @@ describe("ACP config options", () => { expect(harness.updateAgentCalls).toHaveLength(0); }); + it.each(["bogus", "openai:", ":gpt-5.2"])( + "rejects malformed local model choices (%s)", + async (model) => { + const harness = createHarness({ + agentId: "exec", + aiSettings: { model: "openai:gpt-5.2", thinkingLevel: "off" }, + aiSettingsByAgent: {}, + }); + await expect( + handleSetConfigOption(harness.client, "ws-1", "model", model, { + onAgentModeChanged: harness.onAgentModeChanged, + }) + ).rejects.toThrow(); + expect(harness.onAgentModeChanged).not.toHaveBeenCalled(); + } + ); + it("clamps local thinking level when model changes", async () => { const harness = createHarness({ agentId: "exec",