Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 1 addition & 87 deletions src/browser/contexts/WorkspaceContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,14 @@ import {
getModelKey,
getRightSidebarLayoutKey,
getTerminalTitlesKey,
getThinkingLevelKey,
type LaunchBehavior,
} from "@/common/constants/storage";
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 { 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";
Expand Down Expand Up @@ -542,45 +537,9 @@ describe("WorkspaceContext", () => {
expect(getWorkspaceAiMetadata(workspaceId)?.aiSettings?.model).toBe(
archived ? undefined : "openai:gpt-5.2"
);
expect(readPersistedState<string | null>(getModelKey(workspaceId), null)).toBe(
archived ? null : "openai:gpt-5.2"
);
expect(readPersistedState<string | null>(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";
Expand Down Expand Up @@ -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: {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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"),
Expand Down
75 changes: 6 additions & 69 deletions src/browser/contexts/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand All @@ -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<
Expand Down Expand Up @@ -130,17 +122,6 @@ export function seedWorkspaceLocalStorageFromBackend(
return;
}

const activeAgentId = readPersistedState<string>(
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<WorkspaceAISettingsByAgentCache>(byAgentKey, {});
Expand All @@ -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<string | undefined>(modelKey, undefined);
if (existingModel !== active.model && !keepsUnsentPick("model", existingModel)) {
updatePersistedState(modelKey, active.model);
}

const thinkingKey = getThinkingLevelKey(workspaceId);
const existingThinking = readPersistedState<ThinkingLevel | undefined>(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<OpenAIReasoningMode | undefined>(
reasoningKey,
undefined
);
if (existingReasoning !== nextReasoning && !keepsUnsentPick("reasoningMode", existingReasoning)) {
updatePersistedState(reasoningKey, nextReasoning);
}
}

export function toWorkspaceSelection(metadata: FrontendWorkspaceMetadata): WorkspaceSelection {
Expand Down
16 changes: 5 additions & 11 deletions src/browser/features/Settings/Sections/ProvidersSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ import type {
ProvidersConfigMap,
} from "@/common/orpc/types";

let repairRemovedProviderMock = mock(
(_provider: string, _workspaceIds: Iterable<string>) => 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.
Expand All @@ -39,10 +37,8 @@ void mock.module("@/browser/components/SelectPrimitive/SelectPrimitive", () =>
);

void mock.module("@/browser/utils/modelPreferenceRepair", () => ({
repairLocalModelPreferencesForRemovedProvider: (
provider: string,
workspaceIds: Iterable<string>
) => repairRemovedProviderMock(provider, workspaceIds),
repairLocalModelPreferencesForRemovedProvider: (provider: string) =>
repairRemovedProviderMock(provider),
}));

let providersConfigMock: ProvidersConfigMap | null = null;
Expand Down Expand Up @@ -225,9 +221,7 @@ describe("ProvidersSection", () => {
void mock.module("@/browser/components/SelectPrimitive/SelectPrimitive", () =>
createSelectPrimitiveDouble()
);
repairRemovedProviderMock = mock(
(_provider: string, _workspaceIds: Iterable<string>) => undefined
);
repairRemovedProviderMock = mock((_provider: string) => undefined);
providersConfigMock = null;
providersRefreshMock.mockClear();
updateOptimisticallyMock.mockClear();
Expand Down Expand Up @@ -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 () => {
Expand Down
11 changes: 2 additions & 9 deletions src/browser/features/Settings/Sections/ProvidersSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand All @@ -1909,9 +1904,7 @@ export function ProvidersSection() {
clearCustomProviderRemoveError,
refresh,
refreshWorkspaceMetadata,
selectedWorkspace,
setProvidersExpandedProvider,
workspaceMetadata,
]
);

Expand Down
6 changes: 2 additions & 4 deletions src/browser/utils/aiSelectionIntent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
getAiSelectionIntentForSend,
getAiSelectionIntentForSendOptions,
getPendingAiSelection,
hasPendingAiSelectionIntent,
markAiSelectionIntent,
resetAiSelectionIntentForTests,
setWorkspaceAiMetadata,
Expand Down Expand Up @@ -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", () => {
Expand Down
13 changes: 2 additions & 11 deletions src/browser/utils/aiSelectionIntent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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鈫抏xec 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";
Expand Down Expand Up @@ -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()) {
Expand Down
Loading
Loading