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/34] =?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/34] =?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/34] =?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/34] =?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/34] =?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/34] =?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/34] =?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/34] =?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/34] =?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/34] =?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/34] =?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/34] =?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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] =?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/34] =?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/34] =?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/34] =?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/34] =?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/34] =?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/34] =?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/34] =?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/34] =?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/34] =?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/34] =?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,