From 45f5eab9396cc0650e75076bb7889c96b532a15e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:54:51 +0000 Subject: [PATCH 1/2] refactor: resolve the workspace agent from metadata and an unsent pick The agent pick lived in a per-browser agentId key seeded from metadata, so two browsers could disagree. Workspace metadata is now the stored agent and an unsent picker change stays in memory, read through the same resolver as model. Stack-Layer: A3a --- .../WorkspaceModeAISync.test.tsx | 16 +-- .../WorkspaceModeAISync.tsx | 12 +- src/browser/contexts/AgentContext.test.tsx | 6 +- .../contexts/WorkspaceContext.test.tsx | 32 +++--- src/browser/contexts/WorkspaceContext.tsx | 108 ++---------------- .../ChatInput/useCreationWorkspace.test.tsx | 11 +- .../ChatInput/useCreationWorkspace.ts | 2 - .../ProposePlanToolCall.stories.tsx | 3 +- .../Tools/ProposePlanToolCall.test.tsx | 30 ++--- .../features/Tools/ProposePlanToolCall.tsx | 7 +- .../hooks/persistedStateBudget.test.ts | 2 - src/browser/stories/helpers/chatSetup.ts | 2 + src/browser/stories/mocks/workspaces.ts | 2 + src/browser/utils/aiSelectionIntent.test.ts | 6 +- src/browser/utils/aiSelectionIntent.ts | 62 ++++++++-- src/browser/utils/modelChange.test.ts | 5 +- src/browser/utils/modelChange.ts | 13 +-- src/browser/utils/scopedAiDefaults.ts | 48 ++++---- src/common/constants/storage.ts | 19 --- src/constants/workspaceDefaults.ts | 8 +- vscode/src/webview/App.test.tsx | 9 +- vscode/src/webview/App.tsx | 17 ++- vscode/src/webview/protocol.ts | 4 +- 23 files changed, 148 insertions(+), 276 deletions(-) diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx index 284b026e089..1153651f19a 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx @@ -12,12 +12,8 @@ import { setTestAgentAiDefaults, setTestExperiment, } from "@/browser/testUtils"; -import { - getAgentIdKey, - getAutoModelRoutingKey, - getAutoThinkingLevelKey, - getWorkspaceAISettingsByAgentKey, -} from "@/common/constants/storage"; +import { getAutoModelRoutingKey, getAutoThinkingLevelKey } from "@/common/constants/storage"; +import { setWorkspaceAgentPick, setWorkspaceAiMetadata } from "@/browser/utils/aiSelectionIntent"; import { WorkspaceModeAISync } from "../WorkspaceModeAISync/WorkspaceModeAISync"; @@ -124,12 +120,12 @@ describe("WorkspaceModeAISync", () => { }); function renderAt(workspaceId: string, agentId: string) { - updatePersistedState(getAgentIdKey(workspaceId), agentId); + setWorkspaceAgentPick(workspaceId, agentId); return renderSync({ workspaceId, agentId }); } function switchTo(rerender: (ui: ReactElement) => void, workspaceId: string, agentId: string) { - updatePersistedState(getAgentIdKey(workspaceId), agentId); + setWorkspaceAgentPick(workspaceId, agentId); rerender(); } @@ -166,8 +162,8 @@ describe("WorkspaceModeAISync", () => { cleanup(); const legacy = nextWorkspaceId(); - updatePersistedState(getWorkspaceAISettingsByAgentKey(legacy), { - exec: { model: "openai:gpt-5.2", thinkingLevel: "low" }, + setWorkspaceAiMetadata(legacy, { + aiSettingsByAgent: { exec: { model: "openai:gpt-5.2", thinkingLevel: "low" } }, }); renderAt(legacy, "exec"); expect(readAuto(legacy)).toEqual({ model: false, thinkingLevel: false }); diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx index 60b02831652..2534c8f86c0 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx @@ -2,15 +2,12 @@ import { useEffect, useRef } from "react"; import { useAgent } from "@/browser/contexts/AgentContext"; import { readPersistedState } from "@/browser/hooks/usePersistedState"; import { useAgentAiDefaults } from "@/browser/stores/AppConfigStore"; -import { - getAutoRoutingChoiceByAgentKey, - getWorkspaceAISettingsByAgentKey, -} from "@/common/constants/storage"; +import { getAutoRoutingChoiceByAgentKey } from "@/common/constants/storage"; +import { getWorkspaceAiMetadata } from "@/browser/utils/aiSelectionIntent"; import { applyAutoRoutingOutcome, recordWorkspaceModelChange } from "@/browser/utils/modelChange"; import { resolveAutoRoutingForAgent, type AutoRoutingChoiceByAgent, - type WorkspaceAISettingsCache, } from "@/browser/utils/workspaceModeAi"; import { getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync"; import { useExperimentValue } from "@/browser/hooks/useExperiments"; @@ -64,10 +61,7 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { getAutoRoutingChoiceByAgentKey(workspaceId), {} ), - workspaceByAgent: readPersistedState( - getWorkspaceAISettingsByAgentKey(workspaceId), - {} - ), + workspaceByAgent: getWorkspaceAiMetadata(workspaceId)?.aiSettingsByAgent, }); applyAutoRoutingOutcome(workspaceId, autoRoutingOutcome); }, [agentAiDefaults, agentId, agents, autoRoutingEnabled, workspaceId]); diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index 650433bb914..e2c0f25a911 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -5,7 +5,7 @@ import { GlobalWindow } from "happy-dom"; import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; import { CUSTOM_EVENTS } from "@/common/constants/events"; -import { getAgentIdKey } from "@/common/constants/storage"; +import { setWorkspaceAgentPick } from "@/browser/utils/aiSelectionIntent"; import type { UserPreferences } from "@/common/config/schemas/userPreferences"; import { getAppConfigStore, getUserPreferences } from "@/browser/stores/AppConfigStore"; import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; @@ -309,7 +309,7 @@ describe("AgentContext", () => { parentWorkspaceId: "parent-workspace", agentId: "exec", }); - window.localStorage.setItem(getAgentIdKey(lockedWorkspaceId), JSON.stringify("plan")); + setWorkspaceAgentPick(lockedWorkspaceId, "plan"); let contextValue: AgentContextValue | undefined; let openPickerEvents = 0; @@ -326,7 +326,7 @@ describe("AgentContext", () => { }); await waitFor(() => { - // Backend-assigned agent overrides stale localStorage in locked workspaces. + // Backend-assigned agent overrides a stale agent pick in locked workspaces. expect(contextValue?.agentId).toBe("exec"); }); diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index ff64f16ef3e..ff443b5bfde 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -14,7 +14,6 @@ import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/W import { LAST_VISITED_ROUTE_KEY, SELECTED_WORKSPACE_KEY, - getAgentIdKey, getDraftScopeId, getModelKey, getRightSidebarLayoutKey, @@ -24,9 +23,13 @@ import { import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import { createTestApiClient, type TestApiOverrides } from "@/browser/testUtils"; -import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { readPersistedState } from "@/browser/hooks/usePersistedState"; import { getProjectRouteId } from "@/common/utils/projectRouteId"; -import { getWorkspaceAiMetadata } from "@/browser/utils/aiSelectionIntent"; +import { + getWorkspaceAgentId, + getWorkspaceAiMetadata, + setWorkspaceAgentPick, +} from "@/browser/utils/aiSelectionIntent"; import type { RightSidebarLayoutState } from "@/browser/utils/rightSidebarLayout"; import { resetWorkspaceStorageGcForTests } from "@/browser/utils/workspaceStorageGc"; import { resetCreationDraftStorageGcForTests } from "@/browser/utils/creationDraftStorageGc"; @@ -540,10 +543,10 @@ describe("WorkspaceContext", () => { } ); - test.each(["unchanged", "mode", "model"])("keeps local choices: %s", async (change) => { + test.each(["unchanged", "mode", "model"])("keeps an unsent agent pick: %s", async (change) => { const changed = change !== "unchanged"; const nextAgentId = change === "mode" ? "auto" : "plan"; - const workspaceId = "ws-agent-main"; + const workspaceId = `ws-agent-main-${change}`; const saved = createWorkspaceMetadata({ id: workspaceId, agentId: "plan", @@ -569,19 +572,16 @@ describe("WorkspaceContext", () => { })() as unknown as Awaited> ), }, - localStorage: { - [getAgentIdKey(workspaceId)]: JSON.stringify("exec"), - }, }); const ctx = await setup(); await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); await waitFor(() => expect(emitMetadata).toBeTruthy()); - expect(readPersistedState(getAgentIdKey(workspaceId), "")).toBe("plan"); + expect(getWorkspaceAgentId(workspaceId)).toBe("plan"); act(() => { - updatePersistedState(getAgentIdKey(workspaceId), "exec"); + setWorkspaceAgentPick(workspaceId, "exec"); emitMetadata?.({ workspaceId, metadata: { @@ -602,10 +602,10 @@ describe("WorkspaceContext", () => { await waitFor(() => expect(ctx().workspaceMetadata.get(workspaceId)?.title).toBe("Updated title") ); - expect(readPersistedState(getAgentIdKey(workspaceId), "")).toBe("exec"); + expect(getWorkspaceAgentId(workspaceId)).toBe("exec"); }); - test("child workspace metadata still seeds the locked backend agent", async () => { + test("child workspace metadata resolves the locked backend agent", async () => { const workspaceId = "ws-agent-child"; createMockAPI({ @@ -615,22 +615,18 @@ describe("WorkspaceContext", () => { createWorkspaceMetadata({ id: workspaceId, parentWorkspaceId: "ws-parent", + agentId: "exec", agentType: "plan", }), ]), }, - localStorage: { - [getAgentIdKey(workspaceId)]: JSON.stringify("exec"), - }, }); const ctx = await setup(); await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); - expect(readPersistedState(getAgentIdKey(workspaceId), undefined)).toBe( - "plan" - ); + expect(getWorkspaceAgentId(workspaceId)).toBe("plan"); }); test("loads workspace metadata on mount", async () => { diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 442d8e65ea9..90caab00e62 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -13,17 +13,14 @@ import { import { useLocation } from "react-router-dom"; import type { FrontendWorkspaceMetadata, WorkspaceRemoveResult } from "@/common/types/workspace"; import type { ArchivePreflightResult, ArchiveWorkspaceResult } from "@/common/orpc/schemas/api"; -import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import type { WorkspaceSelection } from "@/browser/components/ProjectSidebar/ProjectSidebar"; import type { RuntimeConfig } from "@/common/types/runtime"; import type { DeepLinkPayload } from "@/common/types/deepLink"; import { - getAgentIdKey, getDraftScopeId, getPendingScopeId, getRightSidebarLayoutKey, getTerminalTitlesKey, - getWorkspaceAISettingsByAgentKey, getWorkspaceNameStateKey, SELECTED_WORKSPACE_KEY, } from "@/common/constants/storage"; @@ -46,7 +43,6 @@ import { appendPinnedTimestamp, reassignPinnedTimestamps } from "@/common/utils/ import { isAbortError } from "@/browser/utils/isAbortError"; import { findAdjacentWorkspaceId } from "@/browser/utils/ui/workspaceDomNav"; import { useRouter } from "@/browser/contexts/RouterContext"; -import { normalizeAgentId, resolvePersistedAgentId } from "@/common/utils/agentIds"; import { setWorkspaceAiMetadata } from "@/browser/utils/aiSelectionIntent"; import type { APIClient } from "@/browser/contexts/API"; import { getErrorMessage } from "@/common/utils/errors"; @@ -64,84 +60,6 @@ import { } from "@/browser/stores/DraftStore"; import { createDraftId } from "@/common/utils/drafts"; -/** - * Record backend workspace AI metadata for the AI selection resolver and seed the - * per-workspace agent localStorage from it. - */ -/** The metadata fields the seeding reads; the VS Code webview only receives these (#4738). */ -export type WorkspaceAiSeedSource = Pick< - FrontendWorkspaceMetadata, - "id" | "agentId" | "agentType" | "parentWorkspaceId" | "aiSettings" | "aiSettingsByAgent" -> & - Partial>; - -export function seedWorkspaceLocalStorageFromBackend( - metadata: WorkspaceAiSeedSource, - previous?: WorkspaceAiSeedSource -): void { - setWorkspaceAiMetadata(metadata.id, { - projectPath: metadata.projectPath, - aiSettings: metadata.aiSettings, - aiSettingsByAgent: metadata.aiSettingsByAgent, - }); - // Snapshot all main-workspace choices on client load, not on navigation. - // Later metadata must not overwrite unsent choices; reload to restore backend settings. - if (metadata.parentWorkspaceId == null && previous != null) { - return; - } - // Cache keyed by agentId (string) - includes exec, plan, and custom agents - type WorkspaceAISettingsByAgentCache = Partial< - Record< - string, - { model: string; thinkingLevel: ThinkingLevel; reasoningMode?: OpenAIReasoningMode } - > - >; - - const workspaceId = metadata.id; - - const metadataAgentId = resolvePersistedAgentId(metadata, ""); - if (metadataAgentId.length > 0) { - const key = getAgentIdKey(workspaceId); - const normalized = normalizeAgentId(metadataAgentId); - const existing = readPersistedState(key, undefined); - if (existing !== normalized) { - updatePersistedState(key, normalized); - } - } - - const aiByAgent = - metadata.aiSettingsByAgent ?? - (metadata.aiSettings - ? { - plan: metadata.aiSettings, - exec: metadata.aiSettings, - } - : undefined); - - if (!aiByAgent) { - return; - } - - // Merge backend values into a per-workspace per-agent cache. - const byAgentKey = getWorkspaceAISettingsByAgentKey(workspaceId); - const existingByAgent = readPersistedState(byAgentKey, {}); - const nextByAgent: WorkspaceAISettingsByAgentCache = { ...existingByAgent }; - - for (const [agentKey, entry] of Object.entries(aiByAgent)) { - if (!entry) continue; - if (typeof entry.model !== "string" || entry.model.length === 0) continue; - nextByAgent[agentKey] = { - model: entry.model, - thinkingLevel: entry.thinkingLevel, - ...(entry.reasoningMode != null ? { reasoningMode: entry.reasoningMode } : {}), - }; - } - - if (JSON.stringify(existingByAgent) !== JSON.stringify(nextByAgent)) { - updatePersistedState(byAgentKey, nextByAgent); - } -} - export function toWorkspaceSelection(metadata: FrontendWorkspaceMetadata): WorkspaceSelection { return { workspaceId: metadata.id, @@ -470,10 +388,9 @@ function getMostRecentVisibleWorkspaceScope( : null; } -// Skips archived rows and seeds renderer settings; callers decide how the map is applied. +// Skips archived rows and records AI metadata for the resolver; callers decide how the map is applied. function buildActiveWorkspaceMetadataMap( - metadataList: FrontendWorkspaceMetadata[], - previous: ReadonlyMap + metadataList: FrontendWorkspaceMetadata[] ): Map { const metadataMap = new Map(); for (const metadata of metadataList) { @@ -482,7 +399,7 @@ function buildActiveWorkspaceMetadataMap( ensureCreatedAt(metadata); // Use stable workspace ID as key (not path, which can change) - seedWorkspaceLocalStorageFromBackend(metadata, previous.get(metadata.id)); + setWorkspaceAiMetadata(metadata.id, metadata); metadataMap.set(metadata.id, metadata); } return metadataMap; @@ -1034,9 +951,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { if ("type" in event) { arrivals.lastSnapshot = arrivals.count; arrivals.lastByWorkspaceId.clear(); - setWorkspaceMetadata( - buildActiveWorkspaceMetadataMap(event.workspaces, workspaceMetadataRef.current) - ); + setWorkspaceMetadata(buildActiveWorkspaceMetadataMap(event.workspaces)); setLoaded(true); setLoadError(null); if (!snapshotApplied) { @@ -1058,7 +973,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { // Archived metadata never enters the active map or needs renderer settings. if (meta !== null && !isNowArchived) { ensureCreatedAt(meta); - seedWorkspaceLocalStorageFromBackend(meta, workspaceMetadataRef.current.get(meta.id)); + setWorkspaceAiMetadata(meta.id, meta); } // If the currently-selected workspace is being archived, navigate away *before* @@ -1210,10 +1125,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { // Update metadata immediately to avoid race condition with validation effect ensureCreatedAt(result.metadata); - seedWorkspaceLocalStorageFromBackend( - result.metadata, - workspaceMetadataRef.current.get(result.metadata.id) - ); + setWorkspaceAiMetadata(result.metadata.id, result.metadata); setWorkspaceMetadata((prev) => { const updated = new Map(prev); updated.set(result.metadata.id, result.metadata); @@ -1643,8 +1555,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { if (arrivedAt > requestedAt) touchedIds.add(workspaceId); } const listed = buildActiveWorkspaceMetadataMap( - metadataList.filter((metadata) => !touchedIds.has(metadata.id)), - workspaceMetadataRef.current + metadataList.filter((metadata) => !touchedIds.has(metadata.id)) ); setWorkspaceMetadata((prev) => { const next = new Map(listed); @@ -1669,10 +1580,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { const metadata = await api.workspace.getInfo({ workspaceId }); if (metadata) { ensureCreatedAt(metadata); - seedWorkspaceLocalStorageFromBackend( - metadata, - workspaceMetadataRef.current.get(metadata.id) - ); + setWorkspaceAiMetadata(metadata.id, metadata); } return metadata; }, diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index fd65ae3414c..d4100e439aa 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -11,7 +11,6 @@ import * as DraftWorkspaceSettingsModule from "@/browser/hooks/useDraftWorkspace import * as ChatCommandsModule from "@/browser/utils/chatCommands"; import type { ProjectConfig } from "@/common/types/project"; import { - getAgentIdKey, getAutoModelRoutingKey, getAutoRoutingChoiceByAgentKey, getAutoThinkingLevelKey, @@ -19,7 +18,6 @@ import { getPendingScopeId, getPendingWorkspaceSendErrorKey, getProjectScopeId, - getThinkingLevelKey, getWorkspaceNameStateKey, } from "@/common/constants/storage"; import type { WorkspaceChatMessage } from "@/common/orpc/types"; @@ -1969,7 +1967,6 @@ describe("useCreationWorkspace", () => { }); expect(handleSendResult).toEqual({ success: true }); - expect(updatePersistedStateCalls).toContainEqual([getAgentIdKey(TEST_WORKSPACE_ID), "ask"]); const sendCall = sendMessageMock.mock.calls[0]; if (!sendCall) { @@ -2021,7 +2018,6 @@ describe("useCreationWorkspace", () => { await getHook().handleSend("launch workspace"); }); - expect(updatePersistedStateCalls).toContainEqual([getAgentIdKey(TEST_WORKSPACE_ID), "exec"]); expect(updateAgentAISettingsMock.mock.calls[0]?.[0]?.agentId).toBe("exec"); expect(sendMessageMock.mock.calls[0]?.[0]?.options?.agentId).toBe("exec"); }); @@ -2437,12 +2433,7 @@ describe("useCreationWorkspace", () => { expect(onWorkspaceCreated.mock.calls.length).toBe(0); await waitFor(() => expect(getHook().toast?.message).toBe("backend exploded")); await waitFor(() => expect(getHook().isSending).toBe(false)); - - // Side effect: send-options reader may migrate thinking level into the project scope. - const thinkingKey = getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH)); - if (updatePersistedStateCalls.length > 0) { - expect(updatePersistedStateCalls).toEqual([[thinkingKey, "off"]]); - } + expect(updatePersistedStateCalls).toEqual([]); }); }); diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index f702f192874..7117d05f9ab 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -19,7 +19,6 @@ import { resolveConfiguredAiDefaults } from "@/browser/utils/workspaceModeAi"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { - getAgentIdKey, getPendingScopeId, getDraftScopeId, getPendingDraftSkillDiscoveryKey, @@ -143,7 +142,6 @@ function syncCreationPreferences( : normalizeAgentId(globalDefaultAgentId, WORKSPACE_DEFAULTS.agentId), agentBaseById ); - updatePersistedState(getAgentIdKey(workspaceId), effectiveAgentId); // Preserve only creation choices that differ from configured defaults; recording // defaults would prevent later Settings changes from taking effect. diff --git a/src/browser/features/Tools/ProposePlan/ProposePlanToolCall.stories.tsx b/src/browser/features/Tools/ProposePlan/ProposePlanToolCall.stories.tsx index 59f440781bb..d9987ed0ff8 100644 --- a/src/browser/features/Tools/ProposePlan/ProposePlanToolCall.stories.tsx +++ b/src/browser/features/Tools/ProposePlan/ProposePlanToolCall.stories.tsx @@ -99,10 +99,9 @@ export const ProposePlanInPlanMode: AppStory = { render: () => ( { - window.localStorage.setItem("agentId:ws-plan-mode", JSON.stringify("plan")); - return setupSimpleChatStory({ workspaceId: "ws-plan-mode", + agentId: "plan", messages: [ createUserMessage("msg-1", "Help me refactor the authentication module", { historySequence: 1, diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index 53781a7462e..112b838a94a 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -15,12 +15,9 @@ import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import { AgentProvider } from "@/browser/contexts/AgentContext"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { getAutoModelRoutingKey, getAutoThinkingLevelKey } from "@/common/constants/storage"; import { - getAgentIdKey, - getAutoModelRoutingKey, - getAutoThinkingLevelKey, -} from "@/common/constants/storage"; -import { + getWorkspaceAgentId, resetAiSelectionIntentForTests, setWorkspaceAiMetadata, } from "@/browser/utils/aiSelectionIntent"; @@ -326,10 +323,10 @@ function startInPlanMode( model?: string, thinkingLevel: ThinkingLevel = "off" ) { - window.localStorage.setItem(getAgentIdKey(workspaceId), JSON.stringify("plan")); - if (model) { - setWorkspaceAiMetadata(workspaceId, { aiSettingsByAgent: { plan: { model, thinkingLevel } } }); - } + setWorkspaceAiMetadata(workspaceId, { + agentId: "plan", + ...(model ? { aiSettingsByAgent: { plan: { model, thinkingLevel } } } : {}), + }); } function recordSendMessage(calls: SendMessageArgs[]): MockApi["workspace"]["sendMessage"] { @@ -596,18 +593,7 @@ describe("ProposePlanToolCall", () => { expect(sendMessageCalls[0]?.options.autoThinkingLevel).toBe(false); // Clicking Implement should switch the workspace agent to exec. - // - // Note: some tests in this repo mock the `usePersistedState` module globally. In that case, - // `updatePersistedState` won't actually write to localStorage here, so we assert the call. - const agentKey = getAgentIdKey(WORKSPACE_ID); - const updatePersistedStateMaybeMock = updatePersistedState as unknown as { - mock?: { calls: unknown[][] }; - }; - if (updatePersistedStateMaybeMock.mock) { - expect(updatePersistedState).toHaveBeenCalledWith(agentKey, "exec"); - } else { - expect(JSON.parse(window.localStorage.getItem(agentKey)!)).toBe("exec"); - } + expect(getWorkspaceAgentId(WORKSPACE_ID)).toBe("exec"); }); test("Implement keeps the exec model when the composer has Auto routing selected", async () => { @@ -656,9 +642,9 @@ describe("ProposePlanToolCall", () => { const execWorkspaceModel = "openai:gpt-5.2-pro"; const execWorkspaceThinking = "medium"; - window.localStorage.setItem(getAgentIdKey(WORKSPACE_ID), JSON.stringify("plan")); setTestAgentAiDefaults({}); setWorkspaceAiMetadata(WORKSPACE_ID, { + agentId: "plan", aiSettingsByAgent: { plan: { model: "anthropic:claude-sonnet-4-5", thinkingLevel: "high" }, exec: { model: execWorkspaceModel, thinkingLevel: execWorkspaceThinking }, diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index 1825598497e..acd880a8bcb 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -41,8 +41,9 @@ import { useOpenInEditor } from "@/browser/hooks/useOpenInEditor"; import { useOptionalWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { usePopoverError } from "@/browser/hooks/usePopoverError"; import { PopoverError } from "@/browser/components/PopoverError/PopoverError"; -import { getAgentIdKey, getAutoRoutingChoiceByAgentKey } from "@/common/constants/storage"; -import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { getAutoRoutingChoiceByAgentKey } from "@/common/constants/storage"; +import { readPersistedState } from "@/browser/hooks/usePersistedState"; +import { setWorkspaceAgentPick } from "@/browser/utils/aiSelectionIntent"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { applyAutoRoutingOutcome } from "@/browser/utils/modelChange"; import { useExperimentValue } from "@/browser/hooks/useExperiments"; @@ -528,7 +529,7 @@ export const ProposePlanToolCall: React.FC = (props) = targetAgentId: "auto" | "exec"; autoRouting: AutoRoutingOutcome; }): void => { - updatePersistedState(getAgentIdKey(args.workspaceId), args.targetAgentId); + setWorkspaceAgentPick(args.workspaceId, args.targetAgentId); // Persist routing for later composer sends; the immediate action below disables routing. applyAutoRoutingOutcome(args.workspaceId, args.autoRouting); }; diff --git a/src/browser/hooks/persistedStateBudget.test.ts b/src/browser/hooks/persistedStateBudget.test.ts index d4129433454..38a93d28390 100644 --- a/src/browser/hooks/persistedStateBudget.test.ts +++ b/src/browser/hooks/persistedStateBudget.test.ts @@ -15,7 +15,6 @@ import * as storageModule from "@/common/constants/storage"; import { GLOBAL_SCOPE_ID, PERSISTED_KEY_REGISTRY, - getAgentIdKey, getAutoModelRoutingKey, getAutoThinkingLevelKey, getDisableWorkspaceAgentsKey, @@ -56,7 +55,6 @@ const LOCAL_STORAGE_BUDGET_CEILING_CHARS = 3.5 * 1024 * 1024; */ const PROJECT_SCOPED_WORKSPACE_KEYS = [ getModelKey, - getAgentIdKey, getThinkingLevelKey, getReasoningModeKey, getDisableWorkspaceAgentsKey, diff --git a/src/browser/stories/helpers/chatSetup.ts b/src/browser/stories/helpers/chatSetup.ts index e5e97b1e476..ec90102edf7 100644 --- a/src/browser/stories/helpers/chatSetup.ts +++ b/src/browser/stories/helpers/chatSetup.ts @@ -108,6 +108,7 @@ export interface SimpleChatSetupOptions { /** Render the chat workspace as transcript-only (worktree gone; no composer). */ transcriptOnly?: boolean; aiSettings?: FrontendWorkspaceMetadata["aiSettings"]; + agentId?: string; } /** @@ -126,6 +127,7 @@ export function setupSimpleChatStory(opts: SimpleChatSetupOptions): APIClient { projectPath, transcriptOnly: opts.transcriptOnly, aiSettings: opts.aiSettings, + agentId: opts.agentId, }), ...(opts.additionalWorkspaces ?? []), ]; diff --git a/src/browser/stories/mocks/workspaces.ts b/src/browser/stories/mocks/workspaces.ts index 30c7f28bb1b..d2fb77a9ef7 100644 --- a/src/browser/stories/mocks/workspaces.ts +++ b/src/browser/stories/mocks/workspaces.ts @@ -30,6 +30,7 @@ export interface WorkspaceFixture { /** Recipient consent generation; present means cross-tree discovery/messaging is on. */ unrelatedWorkspaceConsent?: string; aiSettings?: FrontendWorkspaceMetadata["aiSettings"]; + agentId?: string; } /** Create a workspace with sensible defaults */ @@ -56,6 +57,7 @@ export function createWorkspace( pinnedAt: opts.pinnedAt, unrelatedWorkspaceConsent: opts.unrelatedWorkspaceConsent, aiSettings: opts.aiSettings, + agentId: opts.agentId, }; } diff --git a/src/browser/utils/aiSelectionIntent.test.ts b/src/browser/utils/aiSelectionIntent.test.ts index 8c248cbafe8..c9f6f5f5f1b 100644 --- a/src/browser/utils/aiSelectionIntent.test.ts +++ b/src/browser/utils/aiSelectionIntent.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { restoreDomGlobals, saveDomGlobals } from "../../../tests/ui/domGlobals"; import { GlobalWindow } from "happy-dom"; -import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { consumeAiSelectionIntent, getAiSelectionIntentForSend, @@ -10,8 +9,8 @@ import { getPendingAiSelection, markAiSelectionIntent, resetAiSelectionIntentForTests, + setWorkspaceAgentPick, } from "@/browser/utils/aiSelectionIntent"; -import { getAgentIdKey } from "@/common/constants/storage"; const WS = "intent-ws"; const MODEL_A = "openai:gpt-5.2"; @@ -25,7 +24,6 @@ describe("aiSelectionIntent", () => { globalThis.localStorage = globalThis.window.localStorage; globalThis.localStorage.clear(); resetAiSelectionIntentForTests(); - updatePersistedState(getAgentIdKey(WS), "exec"); }); afterEach(() => { @@ -73,7 +71,7 @@ describe("aiSelectionIntent", () => { }); test("a pick scoped to Plan does not apply to Exec", () => { - updatePersistedState(getAgentIdKey(WS), "plan"); + setWorkspaceAgentPick(WS, "plan"); markAiSelectionIntent(WS, "model", MODEL_A); expect(getAiSelectionIntentForSend(WS, "exec", { model: MODEL_A }).intent).toBeUndefined(); expect(getPendingAiSelection(WS, "exec", "model")).toBeUndefined(); diff --git a/src/browser/utils/aiSelectionIntent.ts b/src/browser/utils/aiSelectionIntent.ts index e856c7181b7..7f2396f7a0c 100644 --- a/src/browser/utils/aiSelectionIntent.ts +++ b/src/browser/utils/aiSelectionIntent.ts @@ -10,15 +10,16 @@ * pick never applies to Exec after a plan→exec handoff. Each pick gets a fresh token so * a re-pick made while an earlier send is outstanding survives that send's consume. * Pending picks are also the composer's unsent values (see resolveWorkspaceAiSelection), - * next to the latest workspace AI metadata, so a reload drops an unsent pick. + * next to the latest workspace AI metadata, so a reload drops an unsent pick. An unsent + * agent pick lasts until the metadata's agent matches it. */ import type { AiSelectionIntent } from "@/common/types/agentAiSettings"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import { getAgentIdKey } from "@/common/constants/storage"; import { normalizeSelectedModel } from "@/common/utils/ai/models"; +import { normalizeAgentId, resolvePersistedAgentId } from "@/common/utils/agentIds"; import assert from "@/common/utils/assert"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; -import { readPersistedState } from "@/browser/hooks/usePersistedState"; +import { getUserPreferences } from "@/browser/stores/AppConfigStore"; export type AiSelectionField = keyof AiSelectionIntent; export type AiSelectionTokens = Partial>; @@ -32,9 +33,12 @@ export type WorkspaceAiMetadata = Pick< FrontendWorkspaceMetadata, "aiSettings" | "aiSettingsByAgent" > & - Partial>; + Partial< + Pick + >; const pendingByScope = new Map>>(); +const pendingAgentByWorkspace = new Map(); const metadataByWorkspace = new Map(); const listeners = new Set<() => void>(); let nextToken = 1; @@ -54,10 +58,21 @@ export function getAiSelectionVersion(): number { return version; } -export function setWorkspaceAiMetadata(workspaceId: string, metadata: WorkspaceAiMetadata): void { +export function setWorkspaceAiMetadata(workspaceId: string, source: WorkspaceAiMetadata): void { + const metadata: WorkspaceAiMetadata = { + projectPath: source.projectPath, + agentId: source.agentId, + agentType: source.agentType, + parentWorkspaceId: source.parentWorkspaceId, + aiSettings: source.aiSettings, + aiSettingsByAgent: source.aiSettingsByAgent, + }; const previous = metadataByWorkspace.get(workspaceId); if (JSON.stringify(previous) === JSON.stringify(metadata)) return; metadataByWorkspace.set(workspaceId, metadata); + if (pendingAgentByWorkspace.get(workspaceId) === resolvePersistedAgentId(metadata, "")) { + pendingAgentByWorkspace.delete(workspaceId); + } notify(); } @@ -69,6 +84,36 @@ function normalizeAgent(agentId: string): string { return agentId.trim().toLowerCase() || WORKSPACE_DEFAULTS.agentId; } +/** The workspace's agent without an unsent pick: metadata, then project and global defaults. */ +function getSavedWorkspaceAgentId(workspaceId: string): string { + const metadata = metadataByWorkspace.get(workspaceId); + const ai = getUserPreferences().ai; + const projectAgentId = + metadata?.projectPath != null + ? ai?.projectDefaults?.[metadata.projectPath]?.agentId + : undefined; + return ( + resolvePersistedAgentId(metadata, "") || + normalizeAgentId(projectAgentId, "") || + normalizeAgentId(ai?.globalDefaults?.agentId, WORKSPACE_DEFAULTS.agentId) + ); +} + +export function getWorkspaceAgentId(workspaceId: string): string { + return pendingAgentByWorkspace.get(workspaceId) ?? getSavedWorkspaceAgentId(workspaceId); +} + +/** Records an unsent agent pick; picking the saved agent drops the pick. */ +export function setWorkspaceAgentPick(workspaceId: string, agentId: string): void { + const normalized = normalizeAgentId(agentId, WORKSPACE_DEFAULTS.agentId); + if (normalized === getSavedWorkspaceAgentId(workspaceId)) { + pendingAgentByWorkspace.delete(workspaceId); + } else { + pendingAgentByWorkspace.set(workspaceId, normalized); + } + notify(); +} + function scopeKey(workspaceId: string, agentId: string): string { assert(workspaceId.length > 0, "aiSelectionIntent: workspaceId must be non-empty"); return `${workspaceId}\u0000${normalizeAgent(agentId)}`; @@ -90,11 +135,7 @@ export function markAiSelectionIntent( field: AiSelectionField, value: string ): void { - const agentId = readPersistedState( - getAgentIdKey(workspaceId), - WORKSPACE_DEFAULTS.agentId - ); - const key = scopeKey(workspaceId, agentId); + const key = scopeKey(workspaceId, getWorkspaceAgentId(workspaceId)); const normalized = comparable(field, value); assert(normalized != null, "markAiSelectionIntent: value must be non-empty"); const token = nextToken++; @@ -205,6 +246,7 @@ export function dropPendingModelPicks(shouldDrop: (model: string) => boolean): v /** Test-only: forget all pending picks and metadata. */ export function resetAiSelectionIntentForTests(): void { pendingByScope.clear(); + pendingAgentByWorkspace.clear(); metadataByWorkspace.clear(); notify(); } diff --git a/src/browser/utils/modelChange.test.ts b/src/browser/utils/modelChange.test.ts index 36b65f02596..7e937822263 100644 --- a/src/browser/utils/modelChange.test.ts +++ b/src/browser/utils/modelChange.test.ts @@ -3,7 +3,7 @@ import { restoreDomGlobals, saveDomGlobals } from "../../../tests/ui/domGlobals" import { GlobalWindow } from "happy-dom"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; -import { markAiSelectionIntent } from "@/browser/utils/aiSelectionIntent"; +import { markAiSelectionIntent, setWorkspaceAgentPick } from "@/browser/utils/aiSelectionIntent"; import { consumeWorkspaceModelChange, recordAutoRoutingChoiceForAgent, @@ -12,7 +12,6 @@ import { } from "@/browser/utils/modelChange"; import { AUTO_ROUTING_CHOICE_BY_AGENT_MAX_CHARS, - getAgentIdKey, getAutoModelRoutingKey, getAutoRoutingChoiceByAgentKey, } from "@/common/constants/storage"; @@ -55,7 +54,7 @@ describe("modelChange", () => { test("records workspace routing picks per agent without a local experiment override", () => { const workspaceId = nextWorkspaceId(); - updatePersistedState(getAgentIdKey(workspaceId), "plan"); + setWorkspaceAgentPick(workspaceId, "plan"); setAutoRoutingChoice(workspaceId, "thinkingLevel", true); setWorkspaceModelWithOrigin(workspaceId, "openai:gpt-5.2-codex", "user"); diff --git a/src/browser/utils/modelChange.ts b/src/browser/utils/modelChange.ts index fee2e6b446e..4b1ddb9ff96 100644 --- a/src/browser/utils/modelChange.ts +++ b/src/browser/utils/modelChange.ts @@ -1,17 +1,14 @@ import { AUTO_ROUTING_CHOICE_BY_AGENT_MAX_CHARS, - getAgentIdKey, getAutoModelRoutingKey, getAutoRoutingChoiceByAgentKey, getAutoThinkingLevelKey, } from "@/common/constants/storage"; import { modelSelectionEqualityKey } from "@/common/utils/ai/models"; -import { normalizeAgentId } from "@/common/utils/agentIds"; import type { ThinkingLevel } from "@/common/types/thinking"; -import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; -import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { readScopedAiDefault, writeScopedAiDefault } from "@/browser/utils/scopedAiDefaults"; -import { markAiSelectionIntent } from "@/browser/utils/aiSelectionIntent"; +import { getWorkspaceAgentId, markAiSelectionIntent } from "@/browser/utils/aiSelectionIntent"; import { getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync"; import type { AutoRoutingChoiceByAgent, AutoRoutingOutcome } from "@/browser/utils/workspaceModeAi"; import { withRecordEntry } from "@/browser/utils/boundedPersistedValue"; @@ -126,11 +123,7 @@ function recordAutoRoutingChoice( auto: boolean ): void { if (scopeId.length === 0 || scopeId.startsWith("__")) return; - const agentId = normalizeAgentId( - readPersistedState(getAgentIdKey(scopeId), WORKSPACE_DEFAULTS.agentId), - WORKSPACE_DEFAULTS.agentId - ); - recordAutoRoutingChoiceForAgent(scopeId, agentId, { [dimension]: auto }); + recordAutoRoutingChoiceForAgent(scopeId, getWorkspaceAgentId(scopeId), { [dimension]: auto }); } export function recordAutoRoutingChoiceForAgent( diff --git a/src/browser/utils/scopedAiDefaults.ts b/src/browser/utils/scopedAiDefaults.ts index 40b30d93741..3e195a2e066 100644 --- a/src/browser/utils/scopedAiDefaults.ts +++ b/src/browser/utils/scopedAiDefaults.ts @@ -1,35 +1,27 @@ -import { - readPersistedState, - updatePersistedState, - usePersistedState, -} from "@/browser/hooks/usePersistedState"; +import { useSyncExternalStore } from "react"; import { getUserPreferences, updateUserPreferences, useUserPreferences, } from "@/browser/stores/AppConfigStore"; -import type { UserPreferences } from "@/common/config/schemas/userPreferences"; import { - getAgentIdKey, - getModelKey, - getProjectScopeId, - getThinkingLevelKey, - GLOBAL_SCOPE_ID, -} from "@/common/constants/storage"; + getWorkspaceAgentId, + setWorkspaceAgentPick, + subscribeAiSelection, +} from "@/browser/utils/aiSelectionIntent"; +import type { UserPreferences } from "@/common/config/schemas/userPreferences"; +import { getProjectScopeId, GLOBAL_SCOPE_ID } from "@/common/constants/storage"; // Global defaults have no model, so a global model read is always undefined. type AiDefaults = NonNullable["projectDefaults"]>[string]; type ScopedAiField = keyof AiDefaults; -const STORAGE_KEYS: Record string> = { - agentId: getAgentIdKey, - model: getModelKey, - thinkingLevel: getThinkingLevelKey, -}; - const PROJECT_SCOPE_PREFIX = getProjectScopeId(""); -/** Project and global scopes are config.json preferences; workspace and draft scopes stay local. */ +/** + * Project and global scopes are config.json preferences. A workspace scope resolves only its + * agent (unsent pick, then metadata); its model and thinking come from resolveWorkspaceAiSelection. + */ function getServerScope(scopeId: string): { projectPath?: string } | undefined { if (scopeId === GLOBAL_SCOPE_ID) return {}; return scopeId.startsWith(PROJECT_SCOPE_PREFIX) @@ -57,7 +49,12 @@ export function readScopedAiDefault( if (scope) { return selectServerDefault(getUserPreferences(), scope, field); } - return readPersistedState(STORAGE_KEYS[field](scopeId), undefined); + return readWorkspaceDefault(scopeId, field); +} + +function readWorkspaceDefault(scopeId: string, field: F): AiDefaults[F] { + const value: AiDefaults = field === "agentId" ? { agentId: getWorkspaceAgentId(scopeId) } : {}; + return value[field]; } export function writeScopedAiDefault( @@ -67,7 +64,8 @@ export function writeScopedAiDefault( ): void { const scope = getServerScope(scopeId); if (!scope) { - updatePersistedState(STORAGE_KEYS[field](scopeId), value); + // A workspace agent pick stays in memory until a send stores it in workspace metadata. + if (field === "agentId" && typeof value === "string") setWorkspaceAgentPick(scopeId, value); return; } const defaults = { [field]: value ?? null }; @@ -87,8 +85,8 @@ export function useScopedAiDefault( const serverValue = useUserPreferences((preferences) => scope ? selectServerDefault(preferences, scope, field) : undefined ); - const [localValue] = usePersistedState(STORAGE_KEYS[field](scopeId), undefined, { - listener: true, - }); - return scope ? serverValue : localValue; + const workspaceValue = useSyncExternalStore(subscribeAiSelection, () => + scope ? undefined : readWorkspaceDefault(scopeId, field) + ); + return scope ? serverValue : workspaceValue; } diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 54036d1873f..590df213341 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -171,14 +171,6 @@ export function getReasoningModeKey(scopeId: string): string { return `reasoningMode:${scopeId}`; } -/** - * Get the localStorage key for per-agent workspace AI overrides cache. - * Format: "workspaceAiSettingsByAgent:{workspaceId}" - */ -export function getWorkspaceAISettingsByAgentKey(workspaceId: string): string { - return `workspaceAiSettingsByAgent:${workspaceId}`; -} - /** * LEGACY: Get the localStorage key for thinking level preference per model (global). * Format: "thinkingLevel:model:{modelName}" @@ -315,14 +307,6 @@ export function getAutoRetryKey(workspaceId: string): string { return `${workspaceId}-autoRetry`; } -/** - * Get the localStorage key for the selected agent definition id for a scope. - * Format: "agentId:{scopeId}" - */ -export function getAgentIdKey(scopeId: string): string { - return `agentId:${scopeId}`; -} - /** * Get the localStorage key for the pinned third agent id for a scope. * Format: "pinnedAgentId:{scopeId}" @@ -914,8 +898,6 @@ function projectPrefix(getKey: (projectPath: string) => string): string { // exceed its budget is a bug in its owner. export const PERSISTED_KEY_REGISTRY: readonly PersistedKeyRegistration[] = [ // Copied on fork. - // Record, hydrated from workspace metadata. - workspaceKey(getWorkspaceAISettingsByAgentKey, "ui", true, 1024), workspaceKey(getModelKey, "ui", true, MODEL_KEY_MAX_CHARS), workspaceKey(getAutoModelRoutingKey, "ui", true, 16), workspaceKey(getAutoThinkingLevelKey, "ui", true, 16), @@ -928,7 +910,6 @@ export const PERSISTED_KEY_REGISTRY: readonly PersistedKeyRegistration[] = [ // The VS Code webview composer's unsent text. Longer drafts still work for the session (kept in // memory); the webview then persists only the last text that fit. workspaceKey(getInputKey, "draft", false, 8192, "webview"), - workspaceKey(getAgentIdKey, "ui", true, 128), workspaceKey(getPinnedAgentIdKey, "ui", true, 128), workspaceKey(getThinkingLevelKey, "ui", true, 32), workspaceKey(getReviewSelectedHunkKey, "workspace-scoped", true, 256), diff --git a/src/constants/workspaceDefaults.ts b/src/constants/workspaceDefaults.ts index 597b576c64e..d14dd9b1ce6 100644 --- a/src/constants/workspaceDefaults.ts +++ b/src/constants/workspaceDefaults.ts @@ -17,15 +17,9 @@ Object.freeze(STORAGE_KEYS); * * These defaults are IMMUTABLE and serve as the fallback when: * - A new workspace is created - * - A workspace has no stored override in localStorage + * - A workspace has no stored override in its metadata * - Settings are reset to defaults * - * Per-workspace overrides persist in localStorage using keys like: - * - `agentId:{workspaceId}` - * - `model:{workspaceId}` - * - `thinkingLevel:{workspaceId}` - * - `input:{workspaceId}` - * * The global defaults themselves CANNOT be changed by users. * Only per-workspace overrides are mutable. * diff --git a/vscode/src/webview/App.test.tsx b/vscode/src/webview/App.test.tsx index c6c63a8df69..ec458617bf6 100644 --- a/vscode/src/webview/App.test.tsx +++ b/vscode/src/webview/App.test.tsx @@ -5,11 +5,10 @@ import { act, cleanup, fireEvent, render } from "@testing-library/react"; import { Profiler } from "react"; import { installDom } from "../../../tests/ui/dom"; -import { updatePersistedState } from "xum/browser/hooks/usePersistedState"; -import { getAgentIdKey } from "xum/common/constants/storage"; import { markAiSelectionIntent, resetAiSelectionIntentForTests, + setWorkspaceAgentPick, } from "xum/browser/utils/aiSelectionIntent"; import { formatModelDisplayName } from "xum/common/utils/ai/modelDisplay"; import { getAppConfigStore, getUserPreferences } from "xum/browser/stores/AppConfigStore"; @@ -1394,7 +1393,7 @@ describe("vscode webview workspace AI settings", () => { }); // A stale local pick must not change the agent a child task runs with. await act(async () => { - updatePersistedState(getAgentIdKey(WORKSPACE.id), "plan"); + setWorkspaceAgentPick(WORKSPACE.id, "plan"); await Promise.resolve(); }); @@ -2484,7 +2483,7 @@ describe("vscode webview retry barrier (#5092)", () => { const view = render(); await selectWorkspace(bridge, failedTurn("network"), subAgent); await act(async () => { - updatePersistedState(getAgentIdKey(subAgent.id), "plan"); + setWorkspaceAgentPick(subAgent.id, "plan"); await Promise.resolve(); }); @@ -2498,7 +2497,7 @@ describe("vscode webview retry barrier (#5092)", () => { await selectWorkspace(bridge, [userRow("u1", 1)], subAgent); await stopMidStream(bridge); await act(async () => { - updatePersistedState(getAgentIdKey(subAgent.id), "plan"); + setWorkspaceAgentPick(subAgent.id, "plan"); await Promise.resolve(); }); diff --git a/vscode/src/webview/App.tsx b/vscode/src/webview/App.tsx index 36c0d3be86c..954e56e0fe8 100644 --- a/vscode/src/webview/App.tsx +++ b/vscode/src/webview/App.tsx @@ -37,7 +37,7 @@ import { } from "xum/browser/stores/BackgroundBashStore"; import { useChatViewDataReadyDeadline } from "xum/browser/components/ChatPane/useChatViewDataReady"; import { mergeConsecutiveStreamErrors } from "xum/browser/utils/messages/messageUtils"; -import { seedWorkspaceLocalStorageFromBackend } from "xum/browser/contexts/WorkspaceContext"; +import { setWorkspaceAiMetadata } from "xum/browser/utils/aiSelectionIntent"; import { WorkspaceModeAISync } from "xum/browser/components/WorkspaceModeAISync/WorkspaceModeAISync"; import { resolvePersistedAgentId } from "xum/common/utils/agentIds"; import { @@ -127,7 +127,7 @@ function WebviewChatProviders(props: { props.workspaceAi ? { parentWorkspaceId: props.workspaceAi.parentWorkspaceId, - // Same identity resolution as the seeding: a child task's creation-time agentType + // Same identity resolution as the composer: a child task's creation-time agentType // wins over an agentId restamped by a recovery send. agentId: resolvePersistedAgentId(props.workspaceAi, "") || undefined, } @@ -537,16 +537,13 @@ function WebviewApp(props: { bridge: VscodeBridge; apiClient: APIClient }): JSX. return; } case "workspaces": - // Seed each workspace's persisted agent/AI settings into the composer's storage, with the - // desktop's own rules (#4738): a main workspace is snapshotted once per webview load, a - // sub-agent workspace follows its backend settings. + // The composer resolves each workspace's agent and AI settings from them (#4738). for (const workspace of msg.workspaces) { if (!workspace.ai) continue; - const previousAi = workspacesRef.current.find((w) => w.id === workspace.id)?.ai; - seedWorkspaceLocalStorageFromBackend( - { id: workspace.id, projectPath: workspace.projectPath, ...workspace.ai }, - previousAi ? { id: workspace.id, ...previousAi } : undefined - ); + setWorkspaceAiMetadata(workspace.id, { + projectPath: workspace.projectPath, + ...workspace.ai, + }); } workspacesRef.current = msg.workspaces; setWorkspaces(msg.workspaces); diff --git a/vscode/src/webview/protocol.ts b/vscode/src/webview/protocol.ts index b14b51860b4..7592dd5ef95 100644 --- a/vscode/src/webview/protocol.ts +++ b/vscode/src/webview/protocol.ts @@ -11,8 +11,8 @@ import type { FrontendWorkspaceMetadata } from "xum/common/types/workspace"; export type UiWorkspaceRuntimeType = "local" | "worktree" | "ssh"; /** - * The workspace's persisted agent and AI settings: exactly the fields the webview needs to seed its - * composer (seedWorkspaceLocalStorageFromBackend) and apply the sub-agent agent lock (#4738). + * The workspace's persisted agent and AI settings: exactly the fields the webview needs to resolve + * its composer (setWorkspaceAiMetadata) and apply the sub-agent agent lock (#4738). * Deliberately narrow: no paths, prompts, runtime settings or other metadata. */ export type UiWorkspaceAiState = Pick< From 3b01beb42a1811578331dae7be81e9647a6a4fc4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:55:30 +0000 Subject: [PATCH 2/2] fix: keep an agent pick until a send of it is saved Picking the saved agent dropped the pick, so re-picking it while a send of another agent was in flight lost the choice once that send's metadata arrived. The agent pick now follows the field picks' token lifecycle: a send attaches it, and it ends once the saved agent holds it. A workspace without a saved agent also follows project and global default changes, and Auto routing reads a legacy workspace's single settings bucket, like the composer does. Stack-Layer: A3a --- .../WorkspaceModeAISync.test.tsx | 11 +++- .../WorkspaceModeAISync.tsx | 6 +- src/browser/contexts/AgentContext.test.tsx | 26 ++++++++ src/browser/features/ChatInput/index.tsx | 4 +- .../Tools/ProposePlanToolCall.test.tsx | 3 + src/browser/utils/aiSelectionIntent.test.ts | 35 ++++++++++- src/browser/utils/aiSelectionIntent.ts | 61 +++++++++++++------ src/browser/utils/scopedAiDefaults.ts | 2 + src/browser/utils/workspaceAiSettingsSync.ts | 3 +- 9 files changed, 124 insertions(+), 27 deletions(-) diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx index 1153651f19a..060d65578f7 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx @@ -161,9 +161,18 @@ describe("WorkspaceModeAISync", () => { }); cleanup(); + const saved = nextWorkspaceId(); + setWorkspaceAiMetadata(saved, { + aiSettingsByAgent: { exec: { model: "openai:gpt-5.2", thinkingLevel: "low" } }, + }); + renderAt(saved, "exec"); + expect(readAuto(saved)).toEqual({ model: false, thinkingLevel: false }); + cleanup(); + + // A legacy workspace's single settings bucket applies to every agent. const legacy = nextWorkspaceId(); setWorkspaceAiMetadata(legacy, { - aiSettingsByAgent: { exec: { model: "openai:gpt-5.2", thinkingLevel: "low" } }, + aiSettings: { model: "openai:gpt-5.2", thinkingLevel: "low" }, }); renderAt(legacy, "exec"); expect(readAuto(legacy)).toEqual({ model: false, thinkingLevel: false }); diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx index 2534c8f86c0..5f133dc5e7b 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx @@ -3,7 +3,7 @@ import { useAgent } from "@/browser/contexts/AgentContext"; import { readPersistedState } from "@/browser/hooks/usePersistedState"; import { useAgentAiDefaults } from "@/browser/stores/AppConfigStore"; import { getAutoRoutingChoiceByAgentKey } from "@/common/constants/storage"; -import { getWorkspaceAiMetadata } from "@/browser/utils/aiSelectionIntent"; +import { getSavedAiSettings } from "@/browser/utils/aiSelectionIntent"; import { applyAutoRoutingOutcome, recordWorkspaceModelChange } from "@/browser/utils/modelChange"; import { resolveAutoRoutingForAgent, @@ -61,7 +61,9 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { getAutoRoutingChoiceByAgentKey(workspaceId), {} ), - workspaceByAgent: getWorkspaceAiMetadata(workspaceId)?.aiSettingsByAgent, + workspaceByAgent: { + [normalizedAgentId]: getSavedAiSettings(workspaceId, normalizedAgentId), + }, }); applyAutoRoutingOutcome(workspaceId, autoRoutingOutcome); }, [agentAiDefaults, agentId, agents, autoRoutingEnabled, workspaceId]); diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index f611aa36a57..403939e9c48 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -311,6 +311,32 @@ describe("AgentContext", () => { }); }); + test("a workspace without a saved agent follows project default agent changes", async () => { + const workspaceId = "ws-project-default-agent"; + mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT, REVIEW_PROJECT_AGENT]; + mockWorkspaceMetadata = new Map([[workspaceId, {}]]); + let contextValue: AgentContextValue | undefined; + + renderAgentHarness({ + projectPath: "/tmp/project", + workspaceId, + preferences: { ai: { projectDefaults: { "/tmp/project": { agentId: "plan" } } } }, + onChange: (value) => (contextValue = value), + }); + await waitFor(() => { + expect(contextValue?.agentId).toBe("plan"); + }); + + act(() => { + getAppConfigStore().updateOptimistically({ + userPreferences: { ai: { projectDefaults: { "/tmp/project": { agentId: "review" } } } }, + }); + }); + await waitFor(() => { + expect(contextValue?.agentId).toBe("review"); + }); + }); + test("cycle shortcut advances to next agent", async () => { const projectPath = "/tmp/project"; mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT]; diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 05d9bddafed..f88ae406d1d 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -2936,9 +2936,7 @@ const ChatInputInner: React.FC = (props) => { editConflictToastRef.current = null; setToast((current) => (current === conflictToast.toast ? null : current)); } - if (aiSelection.intent) { - consumeAiSelectionIntent(props.workspaceId, intentAgentId, aiSelection.attachedTokens); - } + consumeAiSelectionIntent(props.workspaceId, intentAgentId, aiSelection.attachedTokens); // Track telemetry for successful message send telemetry.messageSent( props.workspaceId, diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index 568eccbda19..3febb898689 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -618,6 +618,9 @@ describe("ProposePlanToolCall", () => { await waitFor(() => expect(getPendingAiSelection(WORKSPACE_ID, "exec", "model")).toBeUndefined() ); + // The handoff's agent pick ended with the save too, so a later agent change applies. + setWorkspaceAiMetadata(WORKSPACE_ID, { agentId: "plan" }); + expect(getWorkspaceAgentId(WORKSPACE_ID)).toBe("plan"); }); test("Implement keeps the exec model when the composer has Auto routing selected", async () => { diff --git a/src/browser/utils/aiSelectionIntent.test.ts b/src/browser/utils/aiSelectionIntent.test.ts index 1ab169daf4c..01cd7a8aa95 100644 --- a/src/browser/utils/aiSelectionIntent.test.ts +++ b/src/browser/utils/aiSelectionIntent.test.ts @@ -55,16 +55,47 @@ describe("aiSelectionIntent", () => { expect(getWorkspaceAgentId(WS)).toBe("exec"); }); - test("an unsent agent pick lasts until the saved agent matches it", () => { + test("a sent agent pick lasts until the saved agent matches it; an unsent one outlasts it", () => { setWorkspaceAiMetadata(WS, { agentId: "plan" }); setWorkspaceAgentPick(WS, "exec"); - setWorkspaceAiMetadata(WS, { agentId: "plan", projectPath: "/repo" }); + setWorkspaceAiMetadata(WS, { agentId: "exec" }); + setWorkspaceAiMetadata(WS, { agentId: "plan" }); + expect(getWorkspaceAgentId(WS)).toBe("exec"); + + consumeAiSelectionIntent( + WS, + "exec", + getAiSelectionIntentForSend(WS, "exec", {}).attachedTokens + ); expect(getWorkspaceAgentId(WS)).toBe("exec"); setWorkspaceAiMetadata(WS, { agentId: "exec" }); setWorkspaceAiMetadata(WS, { agentId: "plan" }); expect(getWorkspaceAgentId(WS)).toBe("plan"); }); + test("re-picking the saved agent while a send is outstanding survives that send", () => { + setWorkspaceAiMetadata(WS, { agentId: "plan" }); + setWorkspaceAgentPick(WS, "exec"); + const first = getAiSelectionIntentForSend(WS, "exec", {}); + setWorkspaceAgentPick(WS, "plan"); + consumeAiSelectionIntent(WS, "exec", first.attachedTokens); + setWorkspaceAiMetadata(WS, { agentId: "exec" }); + expect(getWorkspaceAgentId(WS)).toBe("plan"); + }); + + test("a sent agent pick the saved agent already holds ends at once", () => { + setWorkspaceAiMetadata(WS, { agentId: "plan" }); + setWorkspaceAgentPick(WS, "plan"); + consumeAiSelectionIntent( + WS, + "plan", + getAiSelectionIntentForSend(WS, "plan", {}).attachedTokens + ); + // A no-op save emits no metadata; another window's later change still applies. + setWorkspaceAiMetadata(WS, { agentId: "exec" }); + expect(getWorkspaceAgentId(WS)).toBe("exec"); + }); + test("attaches only fields whose sent value still equals the pick", () => { markAiSelectionIntent(WS, "model", MODEL_A); markAiSelectionIntent(WS, "thinkingLevel", "high"); diff --git a/src/browser/utils/aiSelectionIntent.ts b/src/browser/utils/aiSelectionIntent.ts index 9cfa5f70ccf..647d7eca88f 100644 --- a/src/browser/utils/aiSelectionIntent.ts +++ b/src/browser/utils/aiSelectionIntent.ts @@ -10,8 +10,8 @@ * pick never applies to Exec after a plan→exec handoff. Each pick gets a fresh token so * a re-pick made while an earlier send is outstanding survives that send's consume. * Pending picks are also the composer's unsent values (see resolveWorkspaceAiSelection), - * next to the latest workspace AI metadata, so a reload drops an unsent pick. An unsent - * agent pick lasts until the metadata's agent matches it. + * next to the latest workspace AI metadata, so a reload drops an unsent pick. The agent pick + * follows the same token lifecycle as the field picks. */ import type { AiSelectionIntent } from "@/common/types/agentAiSettings"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; @@ -22,7 +22,7 @@ import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { getUserPreferences } from "@/browser/stores/AppConfigStore"; export type AiSelectionField = keyof AiSelectionIntent; -export type AiSelectionTokens = Partial>; +export type AiSelectionTokens = Partial>; interface PendingSelection { value: string; @@ -40,7 +40,7 @@ export type WorkspaceAiMetadata = Pick< >; const pendingByScope = new Map>>(); -const pendingAgentByWorkspace = new Map(); +const pendingAgentByWorkspace = new Map(); const metadataByWorkspace = new Map(); const agentBasesByScope = new Map>(); const listeners = new Set<() => void>(); @@ -73,7 +73,8 @@ export function setWorkspaceAiMetadata(workspaceId: string, source: WorkspaceAiM const previous = metadataByWorkspace.get(workspaceId); if (JSON.stringify(previous) === JSON.stringify(metadata)) return; metadataByWorkspace.set(workspaceId, metadata); - if (pendingAgentByWorkspace.get(workspaceId) === resolvePersistedAgentId(metadata, "")) { + const agentPick = pendingAgentByWorkspace.get(workspaceId); + if (agentPick?.sent === true && agentPick.value === resolvePersistedAgentId(metadata, "")) { pendingAgentByWorkspace.delete(workspaceId); } for (const agentId of Object.keys(metadata.aiSettingsByAgent ?? {})) { @@ -137,20 +138,28 @@ function getSavedWorkspaceAgentId(workspaceId: string): string { } export function getWorkspaceAgentId(workspaceId: string): string { - return pendingAgentByWorkspace.get(workspaceId) ?? getSavedWorkspaceAgentId(workspaceId); + return pendingAgentByWorkspace.get(workspaceId)?.value ?? getSavedWorkspaceAgentId(workspaceId); } -/** Records an unsent agent pick; picking the saved agent drops the pick. */ +/** + * Records an unsent agent pick. Picking the saved agent still records one: while a send of + * another agent is in flight, the saved agent is stale. + */ export function setWorkspaceAgentPick(workspaceId: string, agentId: string): void { - const normalized = normalizeAgentId(agentId, WORKSPACE_DEFAULTS.agentId); - if (normalized === getSavedWorkspaceAgentId(workspaceId)) { - pendingAgentByWorkspace.delete(workspaceId); - } else { - pendingAgentByWorkspace.set(workspaceId, normalized); - } + const value = normalizeAgentId(agentId, WORKSPACE_DEFAULTS.agentId); + pendingAgentByWorkspace.set(workspaceId, { value, token: nextToken++ }); notify(); } +/** The workspace's saved settings for an agent; a legacy workspace has only `aiSettings`. */ +export function getSavedAiSettings( + workspaceId: string, + agentId: string +): WorkspaceAiMetadata["aiSettings"] { + const metadata = metadataByWorkspace.get(workspaceId); + return metadata?.aiSettingsByAgent?.[normalizeAgent(agentId)] ?? metadata?.aiSettings; +} + function isSavedPick( workspaceId: string, agentId: string, @@ -210,6 +219,10 @@ export function getAiSelectionIntentForSend( const pending = pendingByScope.get(scopeKey(workspaceId, agentId)); const intent: AiSelectionIntent = {}; const attachedTokens: AiSelectionTokens = {}; + const agentPick = pendingAgentByWorkspace.get(workspaceId); + if (agentPick?.value === normalizeAgentId(agentId, WORKSPACE_DEFAULTS.agentId)) { + attachedTokens.agentId = agentPick.token; + } if (pending != null) { for (const field of ["model", "thinkingLevel", "reasoningMode"] as const) { const selection = pending[field]; @@ -246,7 +259,7 @@ export function getAiSelectionIntentForSendOptions( } const candidate = getAiSelectionIntentForSend(workspaceId, agentId, options); const intent: AiSelectionIntent = {}; - const attachedTokens: AiSelectionTokens = {}; + const attachedTokens: AiSelectionTokens = { agentId: candidate.attachedTokens.agentId }; const keep = (field: AiSelectionField, allowed: boolean) => { if (!allowed || candidate.intent?.[field] !== true) return; intent[field] = true; @@ -262,19 +275,31 @@ export function getAiSelectionIntentForSendOptions( } /** - * After a successful send: an attached pick ends once the saved bucket holds it, so a save - * still in flight or failed keeps it. A re-pick made meanwhile has a new token and survives. + * After a successful send: an attached pick ends once the saved bucket (or agent) holds it, so + * a save still in flight or failed keeps it. A re-pick made meanwhile has a new token and survives. */ export function consumeAiSelectionIntent( workspaceId: string, agentId: string, attachedTokens: AiSelectionTokens ): void { + const agentPick = pendingAgentByWorkspace.get(workspaceId); + if (agentPick != null && agentPick.token === attachedTokens.agentId) { + const savedAgentId = resolvePersistedAgentId(metadataByWorkspace.get(workspaceId), ""); + if (agentPick.value === savedAgentId) { + pendingAgentByWorkspace.delete(workspaceId); + } else { + pendingAgentByWorkspace.set(workspaceId, { ...agentPick, sent: true }); + } + } const key = scopeKey(workspaceId, agentId); const pending = pendingByScope.get(key); - if (pending == null) return; + if (pending == null) { + notify(); + return; + } const next = { ...pending }; - for (const field of Object.keys(attachedTokens) as AiSelectionField[]) { + for (const field of ["model", "thinkingLevel", "reasoningMode"] as const) { const selection = next[field]; if (selection == null || selection.token !== attachedTokens[field]) continue; if (isSavedPick(workspaceId, agentId, field, selection.value)) { diff --git a/src/browser/utils/scopedAiDefaults.ts b/src/browser/utils/scopedAiDefaults.ts index 3e195a2e066..3ad1b79a721 100644 --- a/src/browser/utils/scopedAiDefaults.ts +++ b/src/browser/utils/scopedAiDefaults.ts @@ -85,6 +85,8 @@ export function useScopedAiDefault( const serverValue = useUserPreferences((preferences) => scope ? selectServerDefault(preferences, scope, field) : undefined ); + // A workspace without a saved agent shows the project or global default agent. + useUserPreferences((preferences) => preferences.ai); const workspaceValue = useSyncExternalStore(subscribeAiSelection, () => scope ? undefined : readWorkspaceDefault(scopeId, field) ); diff --git a/src/browser/utils/workspaceAiSettingsSync.ts b/src/browser/utils/workspaceAiSettingsSync.ts index f7960509acf..63210f52ab4 100644 --- a/src/browser/utils/workspaceAiSettingsSync.ts +++ b/src/browser/utils/workspaceAiSettingsSync.ts @@ -4,6 +4,7 @@ import { getAgentBases, getAiSelectionVersion, getPendingAiSelection, + getSavedAiSettings, getWorkspaceAiMetadata, subscribeAiSelection, } from "@/browser/utils/aiSelectionIntent"; @@ -55,7 +56,7 @@ function resolveWorkspaceAiSelection(input: WorkspaceAiSelectionInput): Workspac const pick = (field: keyof WorkspaceAiSelection) => getPendingAiSelection(input.workspaceId, agentId, field); const metadata = getWorkspaceAiMetadata(input.workspaceId); - const saved = metadata?.aiSettingsByAgent?.[agentId] ?? metadata?.aiSettings; + const saved = getSavedAiSettings(input.workspaceId, agentId); const configured = resolveConfiguredAiDefaults( agentId, input.agentAiDefaults,