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 ); }