From 959c6871eec287c70f96dfc84e96cca2f26af532 Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 4 Aug 2026 05:38:43 +0200 Subject: [PATCH] fix: validate workspace drafts and reconcile models --- frontend/src/shell/SteerInput.test.tsx | 50 ++++++ frontend/src/shell/SteerInput.tsx | 24 ++- frontend/src/workspaces/drafts.test.ts | 54 +++++- frontend/src/workspaces/drafts.ts | 231 +++++++++++++++++-------- 4 files changed, 279 insertions(+), 80 deletions(-) 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) => (