diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx
index 2e1a2659fea..12e42bf457f 100644
--- a/src/browser/contexts/AgentContext.tsx
+++ b/src/browser/contexts/AgentContext.tsx
@@ -367,6 +367,10 @@ function AgentProviderWithState(props: {
return {props.children};
}
+export function useOptionalAgent(): AgentContextValue | undefined {
+ return useContext(AgentContext);
+}
+
export function useAgent(): AgentContextValue {
const ctx = useContext(AgentContext);
if (!ctx) {
diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx
index c4cbd8d501a..b8987334273 100644
--- a/src/browser/contexts/WorkspaceContext.test.tsx
+++ b/src/browser/contexts/WorkspaceContext.test.tsx
@@ -28,6 +28,7 @@ import { createTestApiClient, type TestApiOverrides } from "@/browser/testUtils"
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import { getProjectRouteId } from "@/common/utils/projectRouteId";
import {
+ getWorkspaceAiMetadata,
markAiSelectionIntent,
resetAiSelectionIntentForTests,
} from "@/browser/utils/aiSelectionIntent";
@@ -538,6 +539,9 @@ describe("WorkspaceContext", () => {
});
await setup();
await waitFor(() => expect(processed).toBe(true));
+ expect(getWorkspaceAiMetadata(workspaceId)?.aiSettings?.model).toBe(
+ archived ? undefined : "openai:gpt-5.2"
+ );
expect(readPersistedState(getModelKey(workspaceId), null)).toBe(
archived ? null : "openai:gpt-5.2"
);
diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx
index e82edb73431..287d8cfec65 100644
--- a/src/browser/contexts/WorkspaceContext.tsx
+++ b/src/browser/contexts/WorkspaceContext.tsx
@@ -54,6 +54,7 @@ import { normalizeAgentId, resolvePersistedAgentId } from "@/common/utils/agentI
import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults";
import {
hasPendingAiSelectionIntent,
+ setWorkspaceAiMetadata,
type AiSelectionField,
} from "@/browser/utils/aiSelectionIntent";
import type { APIClient } from "@/browser/contexts/API";
@@ -73,20 +74,25 @@ import {
import { createDraftId } from "@/common/utils/drafts";
/**
- * Seed per-workspace localStorage from backend workspace metadata.
- *
- * This keeps a workspace's model/thinking consistent across devices/browsers.
+ * Record backend workspace AI metadata for the AI selection resolver and seed
+ * per-workspace 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) {
diff --git a/src/browser/utils/aiSelectionIntent.ts b/src/browser/utils/aiSelectionIntent.ts
index 0c798d09617..6c3672bb5c2 100644
--- a/src/browser/utils/aiSelectionIntent.ts
+++ b/src/browser/utils/aiSelectionIntent.ts
@@ -11,6 +11,7 @@
* a re-pick made while an earlier send is outstanding survives that send's consume.
*/
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 assert from "@/common/utils/assert";
@@ -25,8 +26,42 @@ interface PendingSelection {
token: number;
}
+export type WorkspaceAiMetadata = Pick<
+ FrontendWorkspaceMetadata,
+ "aiSettings" | "aiSettingsByAgent"
+> &
+ Partial>;
+
const pendingByScope = new Map>>();
+const metadataByWorkspace = new Map();
+const listeners = new Set<() => void>();
let nextToken = 1;
+let version = 0;
+
+function notify(): void {
+ version++;
+ for (const listener of listeners) listener();
+}
+
+export function subscribeAiSelection(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+export function getAiSelectionVersion(): number {
+ return version;
+}
+
+export function setWorkspaceAiMetadata(workspaceId: string, metadata: WorkspaceAiMetadata): void {
+ const previous = metadataByWorkspace.get(workspaceId);
+ if (JSON.stringify(previous) === JSON.stringify(metadata)) return;
+ metadataByWorkspace.set(workspaceId, metadata);
+ notify();
+}
+
+export function getWorkspaceAiMetadata(workspaceId: string): WorkspaceAiMetadata | undefined {
+ return metadataByWorkspace.get(workspaceId);
+}
function normalizeAgent(agentId: string): string {
return agentId.trim().toLowerCase() || WORKSPACE_DEFAULTS.agentId;
@@ -62,6 +97,15 @@ export function markAiSelectionIntent(
assert(normalized != null, "markAiSelectionIntent: value must be non-empty");
const token = nextToken++;
pendingByScope.set(key, { ...pendingByScope.get(key), [field]: { value: normalized, token } });
+ notify();
+}
+
+export function getPendingAiSelection(
+ workspaceId: string,
+ agentId: string,
+ field: AiSelectionField
+): string | undefined {
+ return pendingByScope.get(`${workspaceId}\u0000${normalizeAgent(agentId)}`)?.[field]?.value;
}
/**
@@ -145,6 +189,7 @@ export function consumeAiSelectionIntent(
} else {
pendingByScope.set(key, next);
}
+ notify();
}
/** Whether a local field value still reflects an unsent deliberate pick (reseed guard). */
@@ -158,7 +203,17 @@ export function hasPendingAiSelectionIntent(
return selection != null && comparable(field, localValue) === selection.value;
}
-/** Test-only: forget all pending picks. */
+/** A removed provider's pending model picks would otherwise outrank the repaired settings. */
+export function dropPendingModelPicks(shouldDrop: (model: string) => boolean): void {
+ for (const pending of pendingByScope.values()) {
+ if (pending.model != null && shouldDrop(pending.model.value)) delete pending.model;
+ }
+ notify();
+}
+
+/** Test-only: forget all pending picks and metadata. */
export function resetAiSelectionIntentForTests(): void {
pendingByScope.clear();
+ metadataByWorkspace.clear();
+ notify();
}
diff --git a/src/browser/utils/workspaceAiSettingsSync.test.ts b/src/browser/utils/workspaceAiSettingsSync.test.ts
new file mode 100644
index 00000000000..4ced15fcd8d
--- /dev/null
+++ b/src/browser/utils/workspaceAiSettingsSync.test.ts
@@ -0,0 +1,178 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { act, cleanup, renderHook } from "@testing-library/react";
+import { GlobalWindow } from "happy-dom";
+import { restoreDomGlobals, saveDomGlobals } from "../../../tests/ui/domGlobals";
+
+import { updatePersistedState } from "@/browser/hooks/usePersistedState";
+import { getAppConfigStore } from "@/browser/stores/AppConfigStore";
+import {
+ markAiSelectionIntent,
+ resetAiSelectionIntentForTests,
+ setWorkspaceAiMetadata,
+} from "@/browser/utils/aiSelectionIntent";
+import {
+ getWorkspaceAiSelection,
+ useWorkspaceAiSelection,
+ type WorkspaceAiSelection,
+} from "@/browser/utils/workspaceAiSettingsSync";
+import { getProjectScopeId, getReasoningModeKey } from "@/common/constants/storage";
+import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking";
+
+const WS = "resolver-ws";
+const PROJECT = "/repo";
+
+type Tier = "pick" | "saved" | "legacy" | "configured" | "project";
+
+function seed(tiers: readonly Tier[]): void {
+ const has = (tier: Tier) => tiers.includes(tier);
+ getAppConfigStore().updateOptimistically({
+ defaultModel: "openai:global",
+ agentAiDefaults: has("configured")
+ ? { exec: { modelString: "openai:configured", thinkingLevel: "high", reasoningMode: "pro" } }
+ : {},
+ userPreferences: {
+ ai: {
+ globalDefaults: { thinkingLevel: "low" },
+ projectDefaults: has("project")
+ ? { [PROJECT]: { model: "openai:project", thinkingLevel: "medium" } }
+ : undefined,
+ },
+ },
+ });
+ setWorkspaceAiMetadata(WS, {
+ projectPath: PROJECT,
+ aiSettings: has("legacy") ? { model: "openai:legacy", thinkingLevel: "xhigh" } : undefined,
+ aiSettingsByAgent: has("saved")
+ ? { exec: { model: "openai:saved", thinkingLevel: "xhigh" } }
+ : undefined,
+ });
+ if (has("pick")) {
+ markAiSelectionIntent(WS, "model", "openai:picked");
+ markAiSelectionIntent(WS, "thinkingLevel", "max");
+ markAiSelectionIntent(WS, "reasoningMode", "pro");
+ }
+}
+
+describe("getWorkspaceAiSelection", () => {
+ beforeEach(() => {
+ saveDomGlobals();
+ globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
+ globalThis.document = globalThis.window.document;
+ globalThis.localStorage = globalThis.window.localStorage;
+ resetAiSelectionIntentForTests();
+ });
+
+ afterEach(() => {
+ cleanup();
+ resetAiSelectionIntentForTests();
+ getAppConfigStore().updateOptimistically({
+ defaultModel: undefined,
+ agentAiDefaults: undefined,
+ userPreferences: undefined,
+ });
+ restoreDomGlobals();
+ });
+
+ test("each field falls through the unsent pick, saved settings, agent and project defaults", () => {
+ const cases: Array<{
+ tiers: Tier[];
+ model: string;
+ thinkingLevel: ThinkingLevel;
+ reasoning: OpenAIReasoningMode;
+ }> = [
+ {
+ tiers: ["pick", "saved", "legacy", "configured", "project"],
+ model: "openai:picked",
+ thinkingLevel: "max",
+ reasoning: "pro",
+ },
+ {
+ tiers: ["saved", "legacy", "configured", "project"],
+ model: "openai:saved",
+ thinkingLevel: "xhigh",
+ // A saved bucket owns reasoning: its absent mode means standard.
+ reasoning: "standard",
+ },
+ {
+ tiers: ["legacy", "configured", "project"],
+ model: "openai:legacy",
+ thinkingLevel: "xhigh",
+ reasoning: "standard",
+ },
+ {
+ tiers: ["configured", "project"],
+ model: "openai:configured",
+ thinkingLevel: "high",
+ reasoning: "pro",
+ },
+ {
+ tiers: ["project"],
+ model: "openai:project",
+ thinkingLevel: "medium",
+ reasoning: "standard",
+ },
+ { tiers: [], model: "openai:global", thinkingLevel: "low", reasoning: "standard" },
+ ];
+
+ for (const testCase of cases) {
+ resetAiSelectionIntentForTests();
+ seed(testCase.tiers);
+ expect({ tiers: testCase.tiers, ...getWorkspaceAiSelection(WS, "exec") }).toEqual({
+ tiers: testCase.tiers,
+ model: testCase.model,
+ thinkingLevel: testCase.thinkingLevel,
+ reasoningMode: testCase.reasoning,
+ });
+ }
+ });
+
+ test("the hook follows a default model that loads after it rendered", () => {
+ setWorkspaceAiMetadata(WS, {
+ projectPath: PROJECT,
+ aiSettings: undefined,
+ aiSettingsByAgent: undefined,
+ });
+ const { result } = renderHook(() => useWorkspaceAiSelection(WS, "exec"));
+
+ act(() => getAppConfigStore().updateOptimistically({ defaultModel: "openai:loaded-later" }));
+
+ expect(result.current.model).toBe("openai:loaded-later");
+ });
+
+ test("a legacy workspace id that starts with __ resolves its saved settings", () => {
+ const legacyId = "__proj-main";
+ setWorkspaceAiMetadata(legacyId, {
+ projectPath: PROJECT,
+ aiSettings: undefined,
+ aiSettingsByAgent: { exec: { model: "openai:saved", thinkingLevel: "high" } },
+ });
+
+ const { result } = renderHook(() => useWorkspaceAiSelection(legacyId, "exec"));
+ expect(result.current.model).toBe("openai:saved");
+ expect(getWorkspaceAiSelection(legacyId, "exec").model).toBe("openai:saved");
+ });
+
+ test("a creation scope resolves its own defaults in the hook and the plain reader", () => {
+ const scopeId = getProjectScopeId(PROJECT);
+ // Workspace resolution would apply the configured Exec model here instead.
+ getAppConfigStore().updateOptimistically({
+ defaultModel: "openai:global",
+ agentAiDefaults: { exec: { modelString: "openai:configured" } },
+ userPreferences: {
+ ai: {
+ projectDefaults: { [PROJECT]: { model: "openai:project", thinkingLevel: "medium" } },
+ },
+ },
+ });
+ updatePersistedState(getReasoningModeKey(scopeId), "pro");
+
+ const expected: WorkspaceAiSelection = {
+ model: "openai:project",
+ thinkingLevel: "medium",
+ reasoningMode: "pro",
+ };
+ const { result } = renderHook(() => useWorkspaceAiSelection(scopeId));
+ expect(result.current).toEqual(expected);
+ expect(getWorkspaceAiSelection(scopeId)).toEqual(expected);
+ });
+});
diff --git a/src/browser/utils/workspaceAiSettingsSync.ts b/src/browser/utils/workspaceAiSettingsSync.ts
index 4a0c89ca07f..a454b9331c6 100644
--- a/src/browser/utils/workspaceAiSettingsSync.ts
+++ b/src/browser/utils/workspaceAiSettingsSync.ts
@@ -1,6 +1,178 @@
+import { useSyncExternalStore } from "react";
import { normalizeModelPreference } from "@/browser/utils/messages/buildSendMessageOptions";
-import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking";
+import {
+ getAiSelectionVersion,
+ getPendingAiSelection,
+ getWorkspaceAiMetadata,
+ subscribeAiSelection,
+} from "@/browser/utils/aiSelectionIntent";
+import { resolveConfiguredAiDefaults } from "@/browser/utils/workspaceModeAi";
+import { readScopedAiDefault, useScopedAiDefault } from "@/browser/utils/scopedAiDefaults";
+import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
+import { readPersistedState, usePersistedState } from "@/browser/hooks/usePersistedState";
+import { getReasoningModeKey, isNonWorkspaceScopeId } from "@/common/constants/storage";
+import {
+ getAppConfigStore,
+ getUserPreferences,
+ useAgentAiDefaults,
+ useAppConfig,
+ useUserPreferences,
+} from "@/browser/stores/AppConfigStore";
+import type { UserPreferences } from "@/common/config/schemas/userPreferences";
+import type { AgentAiDefaults } from "@/common/types/agentAiDefaults";
+import {
+ coerceOpenAIReasoningMode,
+ coerceThinkingLevel,
+ type OpenAIReasoningMode,
+ type ThinkingLevel,
+} from "@/common/types/thinking";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
+import { isValidModelFormat } from "@/common/utils/ai/models";
+import { normalizeAgentId } from "@/common/utils/agentIds";
+import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults";
+
+export interface WorkspaceAiSelection {
+ model: string;
+ thinkingLevel: ThinkingLevel;
+ reasoningMode: OpenAIReasoningMode;
+}
+
+interface WorkspaceAiSelectionInput {
+ workspaceId: string;
+ agentId: string;
+ ai: UserPreferences["ai"];
+ defaultModel: string;
+ agentAiDefaults: AgentAiDefaults;
+ agentBaseById?: ReadonlyMap;
+}
+
+/**
+ * Per field: the unsent pick, the workspace's saved settings for the agent, the agent's
+ * configured defaults, then project and global defaults.
+ */
+function resolveWorkspaceAiSelection(input: WorkspaceAiSelectionInput): WorkspaceAiSelection {
+ const agentId = normalizeAgentId(input.agentId, WORKSPACE_DEFAULTS.agentId);
+ const pick = (field: keyof WorkspaceAiSelection) =>
+ getPendingAiSelection(input.workspaceId, agentId, field);
+ const metadata = getWorkspaceAiMetadata(input.workspaceId);
+ const saved = metadata?.aiSettingsByAgent?.[agentId] ?? metadata?.aiSettings;
+ const configured = resolveConfiguredAiDefaults(
+ agentId,
+ input.agentAiDefaults,
+ input.agentBaseById
+ );
+ const project =
+ metadata?.projectPath != null ? input.ai?.projectDefaults?.[metadata.projectPath] : undefined;
+
+ const model = [pick("model"), saved?.model, configured.modelString, project?.model].find(
+ (candidate): candidate is string => candidate != null && isValidModelFormat(candidate.trim())
+ );
+ const thinkingLevel =
+ [pick("thinkingLevel"), saved?.thinkingLevel, configured.thinkingLevel, project?.thinkingLevel]
+ .map(coerceThinkingLevel)
+ .find((level) => level != null) ??
+ coerceThinkingLevel(input.ai?.globalDefaults?.thinkingLevel) ??
+ WORKSPACE_DEFAULTS.thinkingLevel;
+ // A saved per-agent bucket owns the reasoning choice: its absent mode means standard.
+ const reasoningMode =
+ coerceOpenAIReasoningMode(pick("reasoningMode")) ??
+ (saved != null ? coerceOpenAIReasoningMode(saved.reasoningMode) : configured.reasoningMode) ??
+ "standard";
+ return {
+ model: normalizeModelPreference(model, input.defaultModel),
+ thinkingLevel,
+ reasoningMode,
+ };
+}
+
+function resolveCreationScopeSelection(
+ scoped: {
+ model: string | undefined;
+ thinkingLevel: ThinkingLevel | undefined;
+ reasoningMode: OpenAIReasoningMode | null;
+ },
+ defaultModel: string
+): WorkspaceAiSelection {
+ return {
+ model: normalizeModelPreference(scoped.model, defaultModel),
+ thinkingLevel: scoped.thinkingLevel ?? WORKSPACE_DEFAULTS.thinkingLevel,
+ // Coerce untrusted persisted values so corrupt entries self-heal to "standard".
+ reasoningMode: coerceOpenAIReasoningMode(scoped.reasoningMode) ?? "standard",
+ };
+}
+
+/**
+ * Non-React reader; agentId defaults to the workspace's selected agent. Creation composers
+ * pass their project, global or draft scope.
+ */
+export function getWorkspaceAiSelection(
+ workspaceId: string,
+ agentId = readScopedAiDefault(workspaceId, "agentId") ?? WORKSPACE_DEFAULTS.agentId,
+ agentBaseById?: ReadonlyMap
+): WorkspaceAiSelection {
+ if (isNonWorkspaceScopeId(workspaceId)) {
+ return resolveCreationScopeSelection(
+ {
+ model: readScopedAiDefault(workspaceId, "model"),
+ thinkingLevel: readScopedAiDefault(workspaceId, "thinkingLevel"),
+ reasoningMode: readPersistedState(
+ getReasoningModeKey(workspaceId),
+ null
+ ),
+ },
+ getDefaultModel()
+ );
+ }
+ return resolveWorkspaceAiSelection({
+ workspaceId,
+ agentId,
+ ai: getUserPreferences().ai,
+ defaultModel: getDefaultModel(),
+ agentAiDefaults: getAppConfigStore().getSnapshot()?.agentAiDefaults ?? {},
+ agentBaseById,
+ });
+}
+
+export function useWorkspaceAiSelection(
+ workspaceId: string,
+ agentId?: string,
+ agentBaseById?: ReadonlyMap
+): WorkspaceAiSelection {
+ useSyncExternalStore(subscribeAiSelection, getAiSelectionVersion);
+ const selectedAgentId = useScopedAiDefault(workspaceId, "agentId");
+ // Subscribed for creation scopes, which resolve like getWorkspaceAiSelection.
+ const scopedModel = useScopedAiDefault(workspaceId, "model");
+ const scopedThinkingLevel = useScopedAiDefault(workspaceId, "thinkingLevel");
+ const [scopedReasoningMode] = usePersistedState(
+ getReasoningModeKey(workspaceId),
+ null,
+ { listener: true }
+ );
+ const ai = useUserPreferences((preferences) => preferences.ai);
+ const agentAiDefaults = useAgentAiDefaults();
+ const defaultModel = normalizeModelPreference(
+ useAppConfig((config) => config.defaultModel),
+ WORKSPACE_DEFAULTS.model
+ );
+ if (isNonWorkspaceScopeId(workspaceId)) {
+ return resolveCreationScopeSelection(
+ {
+ model: scopedModel,
+ thinkingLevel: scopedThinkingLevel,
+ reasoningMode: scopedReasoningMode,
+ },
+ defaultModel
+ );
+ }
+ return resolveWorkspaceAiSelection({
+ workspaceId,
+ agentId: agentId ?? selectedAgentId ?? WORKSPACE_DEFAULTS.agentId,
+ ai,
+ defaultModel,
+ agentAiDefaults,
+ agentBaseById,
+ });
+}
export function getWorkspaceAiSettingsFromMetadata(
metadata: FrontendWorkspaceMetadata | undefined,
diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts
index 54036d1873f..b3d33aea7a0 100644
--- a/src/common/constants/storage.ts
+++ b/src/common/constants/storage.ts
@@ -46,6 +46,19 @@ const DRAFT_SCOPE_ID_PREFIX = "__draft__/";
*/
export const GLOBAL_SCOPE_ID = "__global__";
+/**
+ * Global, project, pending and draft composer scopes. Every other id is a workspace id,
+ * including legacy `-` ids that start with "__".
+ */
+export function isNonWorkspaceScopeId(scopeId: string): boolean {
+ return (
+ scopeId === GLOBAL_SCOPE_ID ||
+ scopeId.startsWith(getProjectScopeId("")) ||
+ scopeId.startsWith(getPendingScopeId("")) ||
+ scopeId.startsWith(DRAFT_SCOPE_ID_PREFIX)
+ );
+}
+
/**
* Get the localStorage key for the UI theme preference (global)
* Format: "uiTheme"
diff --git a/vscode/src/webview/App.tsx b/vscode/src/webview/App.tsx
index 6c9ecf60d3a..36c0d3be86c 100644
--- a/vscode/src/webview/App.tsx
+++ b/vscode/src/webview/App.tsx
@@ -544,7 +544,7 @@ function WebviewApp(props: { bridge: VscodeBridge; apiClient: APIClient }): JSX.
if (!workspace.ai) continue;
const previousAi = workspacesRef.current.find((w) => w.id === workspace.id)?.ai;
seedWorkspaceLocalStorageFromBackend(
- { id: workspace.id, ...workspace.ai },
+ { id: workspace.id, projectPath: workspace.projectPath, ...workspace.ai },
previousAi ? { id: workspace.id, ...previousAi } : undefined
);
}