From 926c5f5ff005c9d1ca23b42e3dbabcef4e8c085a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 27 Sep 2026 04:31:06 +0000 Subject: [PATCH] fix: keep gateway IDs and record intent for VS Code webview model picks --- vscode/src/webview/App.test.tsx | 55 +++++++++++++++++++++++++++++ vscode/src/webview/ChatComposer.tsx | 21 ++++++----- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/vscode/src/webview/App.test.tsx b/vscode/src/webview/App.test.tsx index a3e2c3c4ea6..a63516ff72a 100644 --- a/vscode/src/webview/App.test.tsx +++ b/vscode/src/webview/App.test.tsx @@ -6,6 +6,8 @@ import { act, cleanup, fireEvent, render } from "@testing-library/react"; import { installDom } from "../../../tests/ui/dom"; import { readPersistedState, updatePersistedState } from "xum/browser/hooks/usePersistedState"; import { getAgentIdKey, getModelKey, getThinkingLevelKey } from "xum/common/constants/storage"; +import { resetAiSelectionIntentForTests } from "xum/browser/utils/aiSelectionIntent"; +import { formatModelDisplayName } from "xum/common/utils/ai/modelDisplay"; import { getAppConfigStore } from "xum/browser/stores/AppConfigStore"; import { getProvidersConfigStore } from "xum/browser/stores/ProvidersConfigStore"; import { App } from "./App"; @@ -372,6 +374,8 @@ describe("vscode webview workspace AI settings", () => { beforeEach(() => { cleanupDom = installDom(); + // Picks are recorded in a module-level map; start each test without earlier tests' picks. + resetAiSelectionIntentForTests(); }); afterEach(() => { @@ -510,6 +514,57 @@ describe("vscode webview workspace AI settings", () => { expect(bridge.orpcCalls("workspace.updateAgentAISettings")).toHaveLength(0); }); + test("sends a gateway-routed model pick with its gateway ID", async () => { + const { bridge, view } = await selectWorkspaceWith(WORKSPACE); + // A configured gateway provider lists its custom models under the gateway prefix. + await bridge.answer("providers.getConfig", { + openrouter: { apiKeySet: true, isEnabled: true, isConfigured: true, models: ["openai/gpt-5"] }, + }); + + try { + await act(async () => { + fireEvent.click(view.getByRole("combobox")); + await Promise.resolve(); + }); + await act(async () => { + fireEvent.click(view.getByText(formatModelDisplayName("openai/gpt-5"))); + await Promise.resolve(); + }); + + const options = await send(bridge, view); + expect(options.model).toBe("openrouter:openai/gpt-5"); + } finally { + await clearProvidersConfig(bridge); + } + }); + + test("keeps a sub-agent's unsent model pick across a metadata refresh", async () => { + const child: UiWorkspace = { + ...WORKSPACE, + ai: { + parentWorkspaceId: "ws-parent", + agentId: "exec", + agentType: "exec", + aiSettingsByAgent: { exec: { model: "openai:gpt-5.6-terra", thinkingLevel: "high" } }, + }, + }; + const { bridge, view } = await selectWorkspaceWith(child); + + await act(async () => { + fireEvent.click(view.getByRole("combobox")); + await Promise.resolve(); + }); + await act(async () => { + fireEvent.click(view.getByText("Sonnet 5")); + await Promise.resolve(); + }); + // A sub-agent follows its backend settings on every refresh, except a deliberate unsent pick. + await bridge.emit({ type: "workspaces", workspaces: [child] }); + + const options = await send(bridge, view); + expect(String(options.model)).toContain("sonnet"); + }); + test("shows the actual custom agent instead of mislabeling it as Exec", async () => { const { bridge, view } = await selectWorkspaceWith({ ...WORKSPACE, diff --git a/vscode/src/webview/ChatComposer.tsx b/vscode/src/webview/ChatComposer.tsx index a5216367e78..50e23a3fe21 100644 --- a/vscode/src/webview/ChatComposer.tsx +++ b/vscode/src/webview/ChatComposer.tsx @@ -16,7 +16,8 @@ import { ThinkingProvider } from "xum/browser/contexts/ThinkingContext"; import { usePersistedState, updatePersistedState } from "xum/browser/hooks/usePersistedState"; import { useModelsFromSettings } from "xum/browser/hooks/useModelsFromSettings"; import { useProvidersConfig } from "xum/browser/hooks/useProvidersConfig"; -import { normalizeSelectedModel, normalizeToCanonical } from "xum/common/utils/ai/models"; +import { normalizeSelectedModel } from "xum/common/utils/ai/models"; +import { markAiSelectionIntent } from "xum/browser/utils/aiSelectionIntent"; import { useProviderOptions } from "xum/browser/hooks/useProviderOptions"; import { useAutoCompactionSettings } from "xum/browser/hooks/useAutoCompactionSettings"; @@ -146,7 +147,9 @@ function ChatComposerInner(props: { listener: true, }); - const storedModel = normalizeToCanonical(preferredModel); + // Gateway-preserving, like the desktop composer: an explicit gateway pick (e.g. + // openrouter:openai/gpt-5) stays selected instead of showing as its direct-provider model. + const storedModel = normalizeSelectedModel(preferredModel); // #4808: the stored model can be one the admin policy excludes (persisted earlier, seeded from the // workspace, or revoked by a policy refresh), and every send with it fails with policy_denied. @@ -227,9 +230,12 @@ function ChatComposerInner(props: { Boolean(api); const onModelChange = (model: string) => { - const canonicalModel = normalizeToCanonical(model); - ensureModelInSettings(canonicalModel); - setPreferredModel(canonicalModel); + // The desktop's setPreferredModel semantics (ChatInput): keep an explicit gateway route, and + // record the deliberate pick so a sub-agent's metadata refresh keeps it until a send carries it. + const selectedModel = normalizeSelectedModel(model); + ensureModelInSettings(selectedModel); + markAiSelectionIntent(props.workspaceId, "model", selectedModel); + setPreferredModel(selectedModel); // Like the desktop composer, record the pick in the active agent's cache so // WorkspaceModeAISync restores it (not the seeded model) after switching agents and back. @@ -238,7 +244,7 @@ function ChatComposerInner(props: { (prev) => ({ ...(prev && typeof prev === "object" ? prev : {}), [normalizeAgentId(agentId, "exec")]: { - model: canonicalModel, + model: selectedModel, thinkingLevel, reasoningMode, }, @@ -246,8 +252,7 @@ function ChatComposerInner(props: { {} ); - // #4755: a model change stays local. Persisting from the webview would need the desktop's - // selection-intent, gateway-route and write-ordering handling, so it stays off (#4778 review). + // #4755: a model change still stays local; sends do not persist AI settings yet (#4781). }; const cycleModels = customModels.length > 0 ? customModels : models;