Skip to content
Merged
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
55 changes: 55 additions & 0 deletions vscode/src/webview/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 13 additions & 8 deletions vscode/src/webview/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -238,16 +244,15 @@ function ChatComposerInner(props: {
(prev) => ({
...(prev && typeof prev === "object" ? prev : {}),
[normalizeAgentId(agentId, "exec")]: {
model: canonicalModel,
model: selectedModel,
thinkingLevel,
reasoningMode,
},
}),
{}
);

// #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;
Expand Down
Loading