diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index b8987334273..ff64f16ef3e 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -19,7 +19,6 @@ import { getModelKey, getRightSidebarLayoutKey, getTerminalTitlesKey, - getThinkingLevelKey, type LaunchBehavior, } from "@/common/constants/storage"; import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; @@ -27,11 +26,7 @@ import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; 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"; +import { getWorkspaceAiMetadata } from "@/browser/utils/aiSelectionIntent"; import type { RightSidebarLayoutState } from "@/browser/utils/rightSidebarLayout"; import { resetWorkspaceStorageGcForTests } from "@/browser/utils/workspaceStorageGc"; import { resetCreationDraftStorageGcForTests } from "@/browser/utils/creationDraftStorageGc"; @@ -542,45 +537,9 @@ describe("WorkspaceContext", () => { expect(getWorkspaceAiMetadata(workspaceId)?.aiSettings?.model).toBe( archived ? undefined : "openai:gpt-5.2" ); - expect(readPersistedState(getModelKey(workspaceId), null)).toBe( - archived ? null : "openai:gpt-5.2" - ); - expect(readPersistedState(getThinkingLevelKey(workspaceId), null)).toBe( - archived ? null : "xhigh" - ); } ); - test("seeds model + thinking localStorage from backend metadata", async () => { - const initialWorkspaces: FrontendWorkspaceMetadata[] = [ - createWorkspaceMetadata({ - id: "ws-ai", - aiSettings: { model: "openai:gpt-5.2", thinkingLevel: "xhigh" }, - }), - ]; - - createMockAPI({ - workspace: { - list: () => Promise.resolve(initialWorkspaces), - }, - localStorage: { - // Seed with different values; backend should win. - [getModelKey("ws-ai")]: JSON.stringify("anthropic:claude-3.5"), - [getThinkingLevelKey("ws-ai")]: JSON.stringify("low"), - }, - }); - - const ctx = await setup(); - - await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); - - expect(JSON.parse(globalThis.localStorage.getItem(getModelKey("ws-ai"))!)).toBe( - "openai:gpt-5.2" - ); - expect(JSON.parse(globalThis.localStorage.getItem(getThinkingLevelKey("ws-ai"))!)).toBe( - "xhigh" - ); - }); test.each(["unchanged", "mode", "model"])("keeps local choices: %s", async (change) => { const changed = change !== "unchanged"; const nextAgentId = change === "mode" ? "auto" : "plan"; @@ -620,11 +579,9 @@ describe("WorkspaceContext", () => { await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); await waitFor(() => expect(emitMetadata).toBeTruthy()); expect(readPersistedState(getAgentIdKey(workspaceId), "")).toBe("plan"); - expect(readPersistedState(getModelKey(workspaceId), "")).toBe("openai:gpt-5.2"); act(() => { updatePersistedState(getAgentIdKey(workspaceId), "exec"); - updatePersistedState(getModelKey(workspaceId), "anthropic:claude-opus-4-6"); emitMetadata?.({ workspaceId, metadata: { @@ -646,7 +603,6 @@ describe("WorkspaceContext", () => { expect(ctx().workspaceMetadata.get(workspaceId)?.title).toBe("Updated title") ); expect(readPersistedState(getAgentIdKey(workspaceId), "")).toBe("exec"); - expect(readPersistedState(getModelKey(workspaceId), "")).toBe("anthropic:claude-opus-4-6"); }); test("child workspace metadata still seeds the locked backend agent", async () => { @@ -677,48 +633,6 @@ describe("WorkspaceContext", () => { ); }); - test.each([ - { name: "keeps a pending Exec pick", pickAgent: "exec", expectedModel: "openai:gpt-5.2" }, - { - name: "reseeds over a Plan-scoped pick", - pickAgent: "plan", - expectedModel: "anthropic:claude-opus-4-6", - }, - ])("child metadata reseed $name", async (row) => { - const workspaceId = "ws-pick-child"; - createMockAPI({ - workspace: { - list: () => - Promise.resolve([ - createWorkspaceMetadata({ - id: workspaceId, - parentWorkspaceId: "ws-parent", - agentId: "exec", - aiSettingsByAgent: { - exec: { model: "anthropic:claude-opus-4-6", thinkingLevel: "high" }, - }, - }), - ]), - }, - localStorage: { - [getAgentIdKey(workspaceId)]: JSON.stringify(row.pickAgent), - [getModelKey(workspaceId)]: JSON.stringify("openai:gpt-5.2"), - [getThinkingLevelKey(workspaceId)]: JSON.stringify("low"), - }, - }); - // A deliberate, not yet sent pick made while the child's active agent was row.pickAgent. - resetAiSelectionIntentForTests(); - markAiSelectionIntent(workspaceId, "model", "openai:gpt-5.2"); - - const ctx = await setup(); - await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1)); - - expect(readPersistedState(getModelKey(workspaceId), "")).toBe(row.expectedModel); - // Fields without a pending pick always follow the backend. - expect(readPersistedState(getThinkingLevelKey(workspaceId), "")).toBe("high"); - resetAiSelectionIntentForTests(); - }); - test("loads workspace metadata on mount", async () => { const initialWorkspaces: FrontendWorkspaceMetadata[] = [ createProjectWorkspaceMetadata("ws-1", "/alpha"), diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 45eff3c941f..442d8e65ea9 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -20,12 +20,9 @@ import type { DeepLinkPayload } from "@/common/types/deepLink"; import { getAgentIdKey, getDraftScopeId, - getModelKey, getPendingScopeId, getRightSidebarLayoutKey, getTerminalTitlesKey, - getReasoningModeKey, - getThinkingLevelKey, getWorkspaceAISettingsByAgentKey, getWorkspaceNameStateKey, SELECTED_WORKSPACE_KEY, @@ -50,12 +47,7 @@ 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 { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; -import { - hasPendingAiSelectionIntent, - setWorkspaceAiMetadata, - type AiSelectionField, -} from "@/browser/utils/aiSelectionIntent"; +import { setWorkspaceAiMetadata } from "@/browser/utils/aiSelectionIntent"; import type { APIClient } from "@/browser/contexts/API"; import { getErrorMessage } from "@/common/utils/errors"; import { collectOrphanedWorkspaceStorage } from "@/browser/utils/workspaceStorageGc"; @@ -73,8 +65,8 @@ import { import { createDraftId } from "@/common/utils/drafts"; /** - * Record backend workspace AI metadata for the AI selection resolver and seed - * per-workspace localStorage from it. + * 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< @@ -130,17 +122,6 @@ export function seedWorkspaceLocalStorageFromBackend( return; } - const activeAgentId = readPersistedState( - getAgentIdKey(workspaceId), - WORKSPACE_DEFAULTS.agentId - ); - // Sub-agent metadata can arrive (e.g. after a reawakening) between a deliberate pick and - // the send that pins it; keep the unsent pick instead of reseeding over it. Picks are - // scoped to the active agent, so a pending Plan pick never blocks Exec reseeding. - const keepsUnsentPick = (field: AiSelectionField, localValue: string | undefined) => - metadata.parentWorkspaceId != null && - hasPendingAiSelectionIntent(workspaceId, activeAgentId, field, localValue); - // Merge backend values into a per-workspace per-agent cache. const byAgentKey = getWorkspaceAISettingsByAgentKey(workspaceId); const existingByAgent = readPersistedState(byAgentKey, {}); @@ -149,60 +130,16 @@ export function seedWorkspaceLocalStorageFromBackend( for (const [agentKey, entry] of Object.entries(aiByAgent)) { if (!entry) continue; if (typeof entry.model !== "string" || entry.model.length === 0) continue; - - const existing = agentKey === activeAgentId ? existingByAgent[agentKey] : undefined; - const reasoningMode = - existing != null && keepsUnsentPick("reasoningMode", existing.reasoningMode) - ? existing.reasoningMode - : entry.reasoningMode; nextByAgent[agentKey] = { - model: - existing != null && keepsUnsentPick("model", existing.model) ? existing.model : entry.model, - thinkingLevel: - existing != null && keepsUnsentPick("thinkingLevel", existing.thinkingLevel) - ? existing.thinkingLevel - : entry.thinkingLevel, - ...(reasoningMode != null ? { reasoningMode } : {}), + model: entry.model, + thinkingLevel: entry.thinkingLevel, + ...(entry.reasoningMode != null ? { reasoningMode: entry.reasoningMode } : {}), }; } if (JSON.stringify(existingByAgent) !== JSON.stringify(nextByAgent)) { updatePersistedState(byAgentKey, nextByAgent); } - - // Seed the active agent into the existing keys to avoid UI flash. - const active = nextByAgent[activeAgentId] ?? nextByAgent.exec ?? nextByAgent.plan; - if (!active) { - return; - } - - const modelKey = getModelKey(workspaceId); - const existingModel = readPersistedState(modelKey, undefined); - if (existingModel !== active.model && !keepsUnsentPick("model", existingModel)) { - updatePersistedState(modelKey, active.model); - } - - const thinkingKey = getThinkingLevelKey(workspaceId); - const existingThinking = readPersistedState(thinkingKey, undefined); - if ( - existingThinking !== active.thinkingLevel && - !keepsUnsentPick("thinkingLevel", existingThinking) - ) { - updatePersistedState(thinkingKey, active.thinkingLevel); - } - - // Absent reasoningMode means "standard": seed it explicitly so switching to - // an agent whose settings never carried the field cannot inherit another - // agent's "pro" from the shared workspace-scoped key. - const reasoningKey = getReasoningModeKey(workspaceId); - const nextReasoning = active.reasoningMode ?? "standard"; - const existingReasoning = readPersistedState( - reasoningKey, - undefined - ); - if (existingReasoning !== nextReasoning && !keepsUnsentPick("reasoningMode", existingReasoning)) { - updatePersistedState(reasoningKey, nextReasoning); - } } export function toWorkspaceSelection(metadata: FrontendWorkspaceMetadata): WorkspaceSelection { diff --git a/src/browser/features/Settings/Sections/ProvidersSection.test.tsx b/src/browser/features/Settings/Sections/ProvidersSection.test.tsx index 655880c804b..184a52d0bf9 100644 --- a/src/browser/features/Settings/Sections/ProvidersSection.test.tsx +++ b/src/browser/features/Settings/Sections/ProvidersSection.test.tsx @@ -19,9 +19,7 @@ import type { ProvidersConfigMap, } from "@/common/orpc/types"; -let repairRemovedProviderMock = mock( - (_provider: string, _workspaceIds: Iterable) => undefined -); +let repairRemovedProviderMock = mock((_provider: string) => undefined); // Snapshot the real exports before any mock below replaces them: the namespace import is a live // binding, so restoring from it would republish the mock into later test files. @@ -39,10 +37,8 @@ void mock.module("@/browser/components/SelectPrimitive/SelectPrimitive", () => ); void mock.module("@/browser/utils/modelPreferenceRepair", () => ({ - repairLocalModelPreferencesForRemovedProvider: ( - provider: string, - workspaceIds: Iterable - ) => repairRemovedProviderMock(provider, workspaceIds), + repairLocalModelPreferencesForRemovedProvider: (provider: string) => + repairRemovedProviderMock(provider), })); let providersConfigMock: ProvidersConfigMap | null = null; @@ -225,9 +221,7 @@ describe("ProvidersSection", () => { void mock.module("@/browser/components/SelectPrimitive/SelectPrimitive", () => createSelectPrimitiveDouble() ); - repairRemovedProviderMock = mock( - (_provider: string, _workspaceIds: Iterable) => undefined - ); + repairRemovedProviderMock = mock((_provider: string) => undefined); providersConfigMock = null; providersRefreshMock.mockClear(); updateOptimisticallyMock.mockClear(); @@ -645,7 +639,7 @@ describe("ProvidersSection", () => { expect(view.removeCustomProvider).toHaveBeenCalledWith({ provider: CUSTOM_PROVIDER_ID }); }); expect(confirmMock).toHaveBeenCalledTimes(1); - expect(repairRemovedProviderMock).toHaveBeenCalledWith(CUSTOM_PROVIDER_ID, expect.any(Set)); + expect(repairRemovedProviderMock).toHaveBeenCalledWith(CUSTOM_PROVIDER_ID); }); test("invalidates queued format writes when the provider is removed", async () => { diff --git a/src/browser/features/Settings/Sections/ProvidersSection.tsx b/src/browser/features/Settings/Sections/ProvidersSection.tsx index 30abb673efc..9193b2faf03 100644 --- a/src/browser/features/Settings/Sections/ProvidersSection.tsx +++ b/src/browser/features/Settings/Sections/ProvidersSection.tsx @@ -479,7 +479,7 @@ export function ProvidersSection() { const { api } = useAPI(); const { config, loading: configLoading, refresh, updateOptimistically } = useProvidersConfig(); - const { workspaceMetadata, selectedWorkspace, refreshWorkspaceMetadata } = useWorkspaceContext(); + const { refreshWorkspaceMetadata } = useWorkspaceContext(); const visibleProviders = useMemo(() => getProviderIdsForUi(config), [config]); const { data: muxGatewayAccountStatus, @@ -1848,11 +1848,6 @@ export function ProvidersSection() { // would recreate the entry (setConfig creates absent sections). providerTypeWritesRef.current.delete(provider); - const workspaceIds = new Set(workspaceMetadata.keys()); - if (selectedWorkspace) { - workspaceIds.add(selectedWorkspace.workspaceId); - } - clearCustomProviderRemoveError(provider); setCustomProviderNotice(null); setCustomProviderRemoving(provider); @@ -1887,7 +1882,7 @@ export function ProvidersSection() { // Browser cache repair still needs to run after backend removal succeeds. } } finally { - repairLocalModelPreferencesForRemovedProvider(provider, workspaceIds); + repairLocalModelPreferencesForRemovedProvider(provider); } setExpandedProvider((prev) => (prev === provider ? null : prev)); @@ -1909,9 +1904,7 @@ export function ProvidersSection() { clearCustomProviderRemoveError, refresh, refreshWorkspaceMetadata, - selectedWorkspace, setProvidersExpandedProvider, - workspaceMetadata, ] ); diff --git a/src/browser/utils/aiSelectionIntent.test.ts b/src/browser/utils/aiSelectionIntent.test.ts index b43219e8d0b..4aeba6502b9 100644 --- a/src/browser/utils/aiSelectionIntent.test.ts +++ b/src/browser/utils/aiSelectionIntent.test.ts @@ -8,7 +8,6 @@ import { getAiSelectionIntentForSend, getAiSelectionIntentForSendOptions, getPendingAiSelection, - hasPendingAiSelectionIntent, markAiSelectionIntent, resetAiSelectionIntentForTests, setWorkspaceAiMetadata, @@ -101,9 +100,8 @@ describe("aiSelectionIntent", () => { updatePersistedState(getAgentIdKey(WS), "plan"); markAiSelectionIntent(WS, "model", MODEL_A); expect(getAiSelectionIntentForSend(WS, "exec", { model: MODEL_A }).intent).toBeUndefined(); - expect(hasPendingAiSelectionIntent(WS, "exec", "model", MODEL_A)).toBe(false); - expect(hasPendingAiSelectionIntent(WS, "plan", "model", MODEL_A)).toBe(true); - expect(hasPendingAiSelectionIntent(WS, "plan", "model", MODEL_B)).toBe(false); + expect(getPendingAiSelection(WS, "exec", "model")).toBeUndefined(); + expect(getPendingAiSelection(WS, "plan", "model")).toBe(MODEL_A); }); test("send options: one-shot sends pin nothing and Auto drops the routed dimension", () => { diff --git a/src/browser/utils/aiSelectionIntent.ts b/src/browser/utils/aiSelectionIntent.ts index 780acc17876..19f5cfd6414 100644 --- a/src/browser/utils/aiSelectionIntent.ts +++ b/src/browser/utils/aiSelectionIntent.ts @@ -9,6 +9,8 @@ * Intentionally in memory only (per window) and scoped by workspace + agent: a Plan * 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. */ import type { AiSelectionIntent } from "@/common/types/agentAiSettings"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; @@ -248,17 +250,6 @@ export function consumeAiSelectionIntent( notify(); } -/** Whether a local field value still reflects an unsent deliberate pick (reseed guard). */ -export function hasPendingAiSelectionIntent( - workspaceId: string, - agentId: string, - field: AiSelectionField, - localValue: string | undefined -): boolean { - const selection = pendingByScope.get(scopeKey(workspaceId, agentId))?.[field]; - return selection != null && comparable(field, localValue) === selection.value; -} - /** 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()) { diff --git a/src/browser/utils/modelPreferenceRepair.test.ts b/src/browser/utils/modelPreferenceRepair.test.ts index 50591581ccc..488d33b74c5 100644 --- a/src/browser/utils/modelPreferenceRepair.test.ts +++ b/src/browser/utils/modelPreferenceRepair.test.ts @@ -1,190 +1,54 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { installDom } from "../../../tests/ui/dom"; -import { - readPersistedState, - readPersistedString, - updatePersistedState, -} from "@/browser/hooks/usePersistedState"; -import { - LAST_CUSTOM_MODEL_PROVIDER_KEY, - getModelKey, - getWorkspaceAISettingsByAgentKey, -} from "@/common/constants/storage"; -import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; +import { readPersistedString, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getPendingAiSelection, markAiSelectionIntent, resetAiSelectionIntentForTests, } from "@/browser/utils/aiSelectionIntent"; +import { LAST_CUSTOM_MODEL_PROVIDER_KEY } from "@/common/constants/storage"; import { repairLocalModelPreferencesForRemovedProvider } from "./modelPreferenceRepair"; const REMOVED_PROVIDER = "removed-provider"; const OTHER_PROVIDER = "other-provider"; let cleanupDom: (() => void) | null = null; -let workspaceCounter = 0; -const touchedKeys = new Set(); - -function nextWorkspaceId(): string { - workspaceCounter += 1; - return `model-preference-repair-test-${workspaceCounter}`; -} - -function touchKey(key: string): string { - touchedKeys.add(key); - return key; -} - -function writeState(key: string, value: T): void { - touchedKeys.add(key); - updatePersistedState(key, value); -} - -function readString(key: string): string | undefined { - touchedKeys.add(key); - return readPersistedString(key); -} - -function readState(key: string, defaultValue: T): T { - touchedKeys.add(key); - return readPersistedState(key, defaultValue); -} describe("repairLocalModelPreferencesForRemovedProvider", () => { beforeEach(() => { cleanupDom = installDom(); - touchedKeys.clear(); }); afterEach(() => { - for (const key of touchedKeys) { - updatePersistedState(key, undefined); - } - touchedKeys.clear(); + updatePersistedState(LAST_CUSTOM_MODEL_PROVIDER_KEY, undefined); resetAiSelectionIntentForTests(); cleanupDom?.(); cleanupDom = null; }); - test("resets per-workspace model only when it belongs to the removed provider", () => { - const affectedWorkspaceId = nextWorkspaceId(); - const unaffectedWorkspaceId = nextWorkspaceId(); - const affectedKey = getModelKey(affectedWorkspaceId); - const unaffectedKey = getModelKey(unaffectedWorkspaceId); - const unaffectedModel = `${OTHER_PROVIDER}:workspace-model`; - - writeState(affectedKey, `${REMOVED_PROVIDER}:workspace-model`); - writeState(unaffectedKey, unaffectedModel); - - repairLocalModelPreferencesForRemovedProvider(REMOVED_PROVIDER, [ - affectedWorkspaceId, - unaffectedWorkspaceId, - affectedWorkspaceId, - ]); - - expect(readString(affectedKey)).toBe(WORKSPACE_DEFAULTS.model); - expect(readString(unaffectedKey)).toBe(unaffectedModel); - }); - test("drops unsent model picks only when they belong to the removed provider", () => { const unaffectedModel = `${OTHER_PROVIDER}:workspace-model`; markAiSelectionIntent("affected", "model", `${REMOVED_PROVIDER}:workspace-model`); markAiSelectionIntent("unaffected", "model", unaffectedModel); - repairLocalModelPreferencesForRemovedProvider(REMOVED_PROVIDER, []); + repairLocalModelPreferencesForRemovedProvider(REMOVED_PROVIDER); expect(getPendingAiSelection("affected", "exec", "model")).toBeUndefined(); expect(getPendingAiSelection("unaffected", "exec", "model")).toBe(unaffectedModel); }); test("clears last custom model provider only when it matches the removed provider", () => { - writeState(LAST_CUSTOM_MODEL_PROVIDER_KEY, REMOVED_PROVIDER); - - repairLocalModelPreferencesForRemovedProvider(REMOVED_PROVIDER, []); - - expect(readString(LAST_CUSTOM_MODEL_PROVIDER_KEY)).toBe(""); - - writeState(LAST_CUSTOM_MODEL_PROVIDER_KEY, OTHER_PROVIDER); - - repairLocalModelPreferencesForRemovedProvider(REMOVED_PROVIDER, []); - - expect(readString(LAST_CUSTOM_MODEL_PROVIDER_KEY)).toBe(OTHER_PROVIDER); - }); - - test("resets affected per-agent workspace models while preserving entries and fields", () => { - const workspaceId = nextWorkspaceId(); - const key = getWorkspaceAISettingsByAgentKey(workspaceId); - const originalSettings = { - exec: { - model: `${REMOVED_PROVIDER}:agent-model`, - thinkingLevel: "high", - extraField: "preserve-me", - }, - plan: { - model: `${OTHER_PROVIDER}:agent-model`, - thinkingLevel: "medium", - extraField: 42, - }, - custom: { - thinkingLevel: "off", - extraField: true, - }, - }; - - writeState(key, originalSettings); - - repairLocalModelPreferencesForRemovedProvider(REMOVED_PROVIDER, [workspaceId]); - - expect(readState(key, {})).toEqual({ - exec: { - model: WORKSPACE_DEFAULTS.model, - thinkingLevel: "high", - extraField: "preserve-me", - }, - plan: originalSettings.plan, - custom: originalSettings.custom, - }); - }); + updatePersistedState(LAST_CUSTOM_MODEL_PROVIDER_KEY, REMOVED_PROVIDER); - test("does not change persisted state when no values match", () => { - const workspaceId = nextWorkspaceId(); - const missingWorkspaceId = nextWorkspaceId(); - const workspaceModelKey = getModelKey(workspaceId); - const missingWorkspaceModelKey = touchKey(getModelKey(missingWorkspaceId)); - const workspaceSettingsKey = getWorkspaceAISettingsByAgentKey(workspaceId); - const missingWorkspaceSettingsKey = touchKey( - getWorkspaceAISettingsByAgentKey(missingWorkspaceId) - ); - const unchangedSettings = { - exec: { - model: `${OTHER_PROVIDER}:agent-model`, - thinkingLevel: "off", - extraField: { nested: true }, - }, - }; + repairLocalModelPreferencesForRemovedProvider(REMOVED_PROVIDER); - writeState(LAST_CUSTOM_MODEL_PROVIDER_KEY, OTHER_PROVIDER); - writeState(workspaceModelKey, `${OTHER_PROVIDER}:workspace-model`); - writeState(workspaceSettingsKey, unchangedSettings); + expect(readPersistedString(LAST_CUSTOM_MODEL_PROVIDER_KEY)).toBe(""); - const before = { - lastProvider: readString(LAST_CUSTOM_MODEL_PROVIDER_KEY), - workspaceModel: readString(workspaceModelKey), - workspaceSettings: readState(workspaceSettingsKey, {}), - }; + updatePersistedState(LAST_CUSTOM_MODEL_PROVIDER_KEY, OTHER_PROVIDER); - repairLocalModelPreferencesForRemovedProvider(REMOVED_PROVIDER, [ - workspaceId, - missingWorkspaceId, - ]); + repairLocalModelPreferencesForRemovedProvider(REMOVED_PROVIDER); - expect({ - lastProvider: readString(LAST_CUSTOM_MODEL_PROVIDER_KEY), - workspaceModel: readString(workspaceModelKey), - workspaceSettings: readState(workspaceSettingsKey, {}), - }).toEqual(before); - expect(readString(missingWorkspaceModelKey)).toBeUndefined(); - expect(readState(missingWorkspaceSettingsKey, undefined)).toBeUndefined(); + expect(readPersistedString(LAST_CUSTOM_MODEL_PROVIDER_KEY)).toBe(OTHER_PROVIDER); }); }); diff --git a/src/browser/utils/modelPreferenceRepair.ts b/src/browser/utils/modelPreferenceRepair.ts index a68878d6779..f65d05088de 100644 --- a/src/browser/utils/modelPreferenceRepair.ts +++ b/src/browser/utils/modelPreferenceRepair.ts @@ -1,88 +1,14 @@ -import type { WorkspaceAISettingsCache } from "@/browser/utils/workspaceModeAi"; -import { - readPersistedState, - readPersistedString, - updatePersistedState, -} from "@/browser/hooks/usePersistedState"; -import { - LAST_CUSTOM_MODEL_PROVIDER_KEY, - getModelKey, - getWorkspaceAISettingsByAgentKey, -} from "@/common/constants/storage"; -import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; +import { readPersistedString, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { LAST_CUSTOM_MODEL_PROVIDER_KEY } from "@/common/constants/storage"; import { modelStringStartsWithProvider } from "@/common/utils/providers/modelString"; import { dropPendingModelPicks } from "@/browser/utils/aiSelectionIntent"; // Browser repair only: removing a custom provider updates config on the backend, -// but per-origin persisted browser preferences can still reference provider-owned models. -type UnknownRecord = Record; - -type WorkspaceAISettingsRepairEntry = Partial> & - UnknownRecord; -type WorkspaceAISettingsRepairCache = Record; - -function isRecord(value: unknown): value is UnknownRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function repairPersistedModelString(key: string, provider: string, replacement: string): void { - const model = readPersistedString(key); - if (model !== undefined && modelStringStartsWithProvider(model, provider)) { - updatePersistedState(key, replacement); - } -} - -function repairLastCustomModelProvider(provider: string): void { +// but browser state can still reference provider-owned models. +export function repairLocalModelPreferencesForRemovedProvider(provider: string): void { const lastProvider = readPersistedString(LAST_CUSTOM_MODEL_PROVIDER_KEY); if (lastProvider === provider && lastProvider !== "") { updatePersistedState(LAST_CUSTOM_MODEL_PROVIDER_KEY, ""); } -} - -function repairWorkspaceAISettingsByAgent(workspaceId: string, provider: string): void { - const key = getWorkspaceAISettingsByAgentKey(workspaceId); - const settingsByAgent = readPersistedState( - key, - undefined - ); - if (!isRecord(settingsByAgent)) { - return; - } - - let changed = false; - const nextSettingsByAgent: WorkspaceAISettingsRepairCache = { ...settingsByAgent }; - - for (const [agentName, settings] of Object.entries(settingsByAgent)) { - if (!isRecord(settings)) { - continue; - } - - const model = settings.model; - if (typeof model !== "string" || !modelStringStartsWithProvider(model, provider)) { - continue; - } - - nextSettingsByAgent[agentName] = { - ...settings, - model: WORKSPACE_DEFAULTS.model, - }; - changed = true; - } - - if (changed) { - updatePersistedState(key, nextSettingsByAgent); - } -} - -export function repairLocalModelPreferencesForRemovedProvider( - provider: string, - workspaceIds: Iterable -): void { - repairLastCustomModelProvider(provider); - - for (const workspaceId of new Set(workspaceIds)) { - repairPersistedModelString(getModelKey(workspaceId), provider, WORKSPACE_DEFAULTS.model); - repairWorkspaceAISettingsByAgent(workspaceId, provider); - } dropPendingModelPicks((model) => modelStringStartsWithProvider(model, provider)); }