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
100 changes: 98 additions & 2 deletions vscode/src/webview/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { act, cleanup, fireEvent, render } from "@testing-library/react";

import { installDom } from "../../../tests/ui/dom";
import { updatePersistedState } from "xum/browser/hooks/usePersistedState";
import { getAgentIdKey, getThinkingLevelKey } from "xum/common/constants/storage";
import { readPersistedState, updatePersistedState } from "xum/browser/hooks/usePersistedState";
import { getAgentIdKey, getModelKey, getThinkingLevelKey } from "xum/common/constants/storage";
import { App } from "./App";
import type { UiWorkspace, WebviewToExtensionMessage } from "./protocol";
import type { VscodeBridge } from "./vscodeBridge";
Expand Down Expand Up @@ -51,6 +51,13 @@ class TestBridge implements VscodeBridge {
});
}

// Plays the host answering every call of `path` so far with `value`.
async answer(path: string, value: unknown): Promise<void> {
for (const call of this.orpcCalls(path)) {
await this.emit({ type: "orpcResponse", requestId: call.requestId, ok: true, kind: "value", value });
}
}

orpcCalls(path: string): Array<Extract<WebviewToExtensionMessage, { type: "orpcCall" }>> {
return this.sent.filter(
(message): message is Extract<WebviewToExtensionMessage, { type: "orpcCall" }> =>
Expand Down Expand Up @@ -554,3 +561,92 @@ describe("vscode webview agent lookup", () => {
expect(recoveryLookups[0].input).toMatchObject({ workspaceId: WORKSPACE.id });
});
});

// #4808: the admin policy can exclude the workspace's selected model (persisted, seeded or revoked).
describe("vscode webview policy-excluded model", () => {
let cleanupDom: (() => void) | null = null;

beforeEach(() => {
cleanupDom = installDom();
});

afterEach(() => {
cleanup();
cleanupDom?.();
cleanupDom = null;
});

function enforcedPolicy(providerAccess: Array<{ id: string; allowedModels: string[] | null }>) {
return {
source: "governor",
status: { state: "enforced" },
policy: {
policyFormatVersion: "0.1",
providerAccess,
mcp: { allowUserDefined: { stdio: true, remote: true } },
runtimes: null,
},
};
}

async function renderWithPolicy(policy: unknown) {
updatePersistedState(getModelKey(WORKSPACE.id), "anthropic:claude-opus-5-5");
const bridge = new TestBridge();
const view = render(<App bridge={bridge} />);
await selectWorkspace(bridge);
await bridge.answer("policy.get", policy);
const textarea = view.container.querySelector("textarea");
if (!textarea) throw new Error("composer textarea did not render");
await typeInto(textarea, "hello");
return { bridge, view };
}

async function clickSend(view: ReturnType<typeof render>) {
await act(async () => {
fireEvent.click(view.getByRole("button", { name: "Send message" }));
await Promise.resolve();
});
}

test("sends with the first allowed model, says so, and keeps the stored choice", async () => {
const { bridge, view } = await renderWithPolicy(
enforcedPolicy([{ id: "openai", allowedModels: ["gpt-5.6-terra"] }])
);

expect(view.getByRole("status").textContent).toContain("anthropic:claude-opus-5-5");
await clickSend(view);
const sends = bridge.orpcCalls("workspace.sendMessage");
expect(sends).toHaveLength(1);
const input = sends[0].input as { options: Record<string, unknown> };
expect(input.options.model).toBe("openai:gpt-5.6-terra");
// Local fallback only: nothing is written, locally or to the workspace.
expect(readPersistedState(getModelKey(WORKSPACE.id), "")).toBe("anthropic:claude-opus-5-5");
expect(bridge.orpcCalls("workspace.updateAgentAISettings")).toHaveLength(0);
});

test("keeps the stored model and says so when the policy allows no listed model", async () => {
const { bridge, view } = await renderWithPolicy(
enforcedPolicy([{ id: "openai", allowedModels: ["not-a-listed-model"] }])
);

expect(view.getByRole("status").textContent).toContain("anthropic:claude-opus-5-5");
await clickSend(view);
const input = bridge.orpcCalls("workspace.sendMessage")[0].input as {
options: Record<string, unknown>;
};
expect(input.options.model).toBe("anthropic:claude-opus-5-5");
});

test("keeps an allowed selection unchanged", async () => {
const { bridge, view } = await renderWithPolicy(
enforcedPolicy([{ id: "anthropic", allowedModels: null }])
);

expect(view.queryByRole("status")).toBeNull();
await clickSend(view);
const input = bridge.orpcCalls("workspace.sendMessage")[0].input as {
options: Record<string, unknown>;
};
expect(input.options.model).toBe("anthropic:claude-opus-5-5");
});
});
30 changes: 28 additions & 2 deletions vscode/src/webview/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { normalizeAgentId } from "xum/common/utils/agentIds";
import { ThinkingProvider } from "xum/browser/contexts/ThinkingContext";
import { usePersistedState, updatePersistedState } from "xum/browser/hooks/usePersistedState";
import { useModelsFromSettings } from "xum/browser/hooks/useModelsFromSettings";
import { normalizeToCanonical } from "xum/common/utils/ai/models";
import { normalizeSelectedModel, normalizeToCanonical } from "xum/common/utils/ai/models";
import { useProviderOptions } from "xum/browser/hooks/useProviderOptions";
import { useAutoCompactionSettings } from "xum/browser/hooks/useAutoCompactionSettings";

Expand Down Expand Up @@ -137,14 +137,31 @@ function ChatComposerInner(props: {
ensureModelInSettings,
defaultModel,
setDefaultModel,
isAllowedByPolicyOnActiveRoute,
} = useModelsFromSettings();

const modelKey = getModelKey(props.workspaceId);
const [preferredModel, setPreferredModel] = usePersistedState<string>(modelKey, defaultModel, {
listener: true,
});

const baseModel = normalizeToCanonical(preferredModel);
const storedModel = normalizeToCanonical(preferredModel);
Comment thread
ThomasK33 marked this conversation as resolved.

// #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.
// Fall back to the first allowed model for display and send, without writing it anywhere: the
// webview does not persist AI settings, and the stored choice comes back if the policy allows it
// again. With no allowed model in the list, nothing changes and the backend decides. Either way,
// a status line says so. The check is route-aware, like the model list, because the backend
// enforces policy after routing; it uses the gateway-preserving identity so an explicitly pinned
// gateway model is checked on that gateway.
// The status line names this identity too, so a denied gateway pin is not shown as its canonical ID.
const storedSelection = normalizeSelectedModel(preferredModel);
const storedModelAllowed = isAllowedByPolicyOnActiveRoute(storedSelection);
const policyFallbackModel = storedModelAllowed
? null
: (models.find((model) => isAllowedByPolicyOnActiveRoute(model)) ?? null);
Comment thread
ThomasK33 marked this conversation as resolved.
const baseModel = storedModelAllowed ? storedModel : (policyFallbackModel ?? storedModel);

const inputKey = getInputKey(props.workspaceId);
const [input, setInput] = usePersistedState<string>(inputKey, "", { listener: true });
Expand Down Expand Up @@ -290,6 +307,8 @@ function ChatComposerInner(props: {
// The thinking level is sent as selected: the webview does not load the user's configured
// per-model minimums, so only the backend can apply the authoritative floor.
skipAiSettingsPersistence: true,
// Only when the stored model is policy-excluded; otherwise keep the stored model string.
...(policyFallbackModel ? { model: policyFallbackModel } : {}),
};

const result = await api.workspace.sendMessage(
Expand Down Expand Up @@ -383,6 +402,13 @@ function ChatComposerInner(props: {
/>

<div className="flex flex-col gap-2">
{storedModelAllowed ? null : (
<div role="status" className="text-content-secondary text-[11px]">
{policyFallbackModel
? `Admin policy does not allow ${storedSelection}; using ${policyFallbackModel}.`
: `Admin policy does not allow ${storedSelection}. Choose an allowed model.`}
</div>
)}
<div className="w-full min-w-0" data-component="ModelSelectorGroup">
<ModelSelector
value={baseModel}
Expand Down
Loading