diff --git a/frontend/src/shell/SteerInput.test.tsx b/frontend/src/shell/SteerInput.test.tsx
index 86faebbd..e8974f0c 100644
--- a/frontend/src/shell/SteerInput.test.tsx
+++ b/frontend/src/shell/SteerInput.test.tsx
@@ -181,6 +181,56 @@ test("footer limits model choices to the selected workspace policy", async () =>
await waitFor(() => expect(selector).not.toHaveTextContent("DeepSeek V4 Pro"));
});
+test("switching workspaces replaces an out-of-policy model before session creation", async () => {
+ let body: unknown;
+ localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
+ workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
+ }));
+ const revision = (id: string) => ({
+ id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const,
+ });
+ server.use(
+ http.get("http://localhost:8787/settings", () => HttpResponse.json({
+ workspace: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
+ })),
+ http.get("http://localhost:8787/workspaces", () => HttpResponse.json([
+ { id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") },
+ { id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", revision: revision("psd-clinical") },
+ ])),
+ http.get("http://localhost:8787/workspaces/research", () => HttpResponse.json({
+ workspace: { workspace: { id: "research" }, llm_policy: { allowed: ["deepseek/deepseek-v4-pro"] } },
+ revision: revision("research"),
+ })),
+ http.get("http://localhost:8787/workspaces/psd-clinical", () => HttpResponse.json({
+ workspace: { workspace: { id: "psd-clinical" }, llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] } },
+ revision: revision("psd-clinical"),
+ })),
+ http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [
+ { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
+ { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true },
+ ] })),
+ http.post("http://localhost:8787/sessions", async ({ request }) => {
+ body = await request.json();
+ return HttpResponse.json({ id: "s1" });
+ }),
+ );
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render();
+
+ const workspaceSelector = await screen.findByRole("combobox", { name: "Workspace" });
+ await waitFor(() => expect(workspaceSelector).toHaveTextContent("psd-clinical"));
+ await userEvent.selectOptions(workspaceSelector, "psd-clinical");
+ await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toHaveValue("glm-5.2"));
+ expect(screen.getByRole("combobox", { name: "Model" })).not.toHaveTextContent("DeepSeek V4 Pro");
+
+ await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q");
+ await userEvent.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => expect(body).toEqual({
+ question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
+ }));
+});
+
test("pulses the stop dot only while the harness is working", () => {
const { rerender } = render();
const dot = () =>
diff --git a/frontend/src/shell/SteerInput.tsx b/frontend/src/shell/SteerInput.tsx
index a503b7b8..74c95e9d 100644
--- a/frontend/src/shell/SteerInput.tsx
+++ b/frontend/src/shell/SteerInput.tsx
@@ -189,6 +189,27 @@ export function ComposerFooter() {
? models.filter((candidate) => allowedModels.includes(`${candidate.provider}/${candidate.id}`))
: models;
+ useEffect(() => {
+ if (!allowedModels?.length) return;
+ const selected = preferences.provider && preferences.model
+ ? `${preferences.provider}/${preferences.model}`
+ : undefined;
+ if (selected && allowedModels.some((allowed) => allowed === selected)) return;
+ const replacement = workspaceRecord?.workspace.llm_policy.default
+ && allowedModels.includes(workspaceRecord.workspace.llm_policy.default)
+ ? workspaceRecord.workspace.llm_policy.default
+ : allowedModels[0];
+ const separator = replacement.indexOf("/");
+ if (separator <= 0 || separator === replacement.length - 1) return;
+ const next = {
+ ...preferences,
+ provider: replacement.slice(0, separator),
+ model: replacement.slice(separator + 1),
+ };
+ workspacePreferences.save(next);
+ setPreferences(next);
+ }, [allowedModels, preferences, workspaceRecord]);
+
function update(patch: WorkspacePreference) {
const next = workspacePreferences.save({ ...preferences, ...patch });
setPreferences(next);
@@ -200,6 +221,7 @@ export function ComposerFooter() {
}
const knownModel = policyModels.some((m) => m.id === model);
+ const showModelFallback = !allowedModels && !knownModel;
const contextPct = tokenUsage && tokenUsage.contextWindow > 0
? tokenUsage.totalTokens / tokenUsage.contextWindow
: 0;
@@ -232,7 +254,7 @@ export function ComposerFooter() {
) : (
<>
- {!knownModel && }
+ {showModelFallback && }
{policyModels.map((m) => (