diff --git a/frontend/src/api/sessions.test.ts b/frontend/src/api/sessions.test.ts index 40a0614f..12acc26e 100644 --- a/frontend/src/api/sessions.test.ts +++ b/frontend/src/api/sessions.test.ts @@ -13,6 +13,9 @@ test("createSession migrates legacy selections and POSTs browser preferences", a http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", })), + http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ + id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", + }])), http.post("http://localhost:8787/sessions", async ({ request }) => { body = await request.json(); return HttpResponse.json({ id: "s1" }); diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 98a5347d..8840c4fe 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -1,6 +1,10 @@ import { apiFetch } from "./client"; import { getSettings } from "./settings"; -import { workspacePolicyGate, workspacePreferences, type WorkspacePreference } from "../workspaces/drafts"; +import { getWorkspace, listWorkspaces } from "./workspaces"; +import { + WORKSPACE_POLICY_ERROR, WORKSPACE_SUMMARY_ERROR, WorkspaceSelectionError, workspacePolicyGate, + workspacePreferences, type WorkspacePreference, +} from "../workspaces/drafts"; import type { Principal, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse, } from "./types"; @@ -22,9 +26,83 @@ async function selectedPreferences(): Promise { }); } +function reconcileWorkspacePolicy(preferences: WorkspacePreference, allowed: readonly string[], defaultModel?: string) { + if (allowed.length === 0) throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); + const selected = preferences.provider && preferences.model + ? `${preferences.provider}/${preferences.model}` + : undefined; + if (selected && allowed.includes(selected)) return preferences; + const replacement = defaultModel && allowed.includes(defaultModel) ? defaultModel : allowed[0]; + const separator = replacement.indexOf("/"); + if (separator <= 0 || separator === replacement.length - 1) { + throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); + } + return workspacePreferences.save({ + ...preferences, + provider: replacement.slice(0, separator), + model: replacement.slice(separator + 1), + }); +} + +async function ensureWorkspaceSelectionPolicy(): Promise { + while (true) { + const preferences = workspacePreferences.load(); + const workspaceId = preferences.workspaceId; + if (!workspaceId) return preferences; + workspacePolicyGate.beginSummary(workspaceId); + let workspace; + try { + workspace = (await listWorkspaces()).find((candidate) => candidate.id === workspaceId); + } catch { + if (workspacePreferences.load().workspaceId !== workspaceId) continue; + workspacePolicyGate.rejectSummary(workspaceId); + throw new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR); + } + if (workspacePreferences.load().workspaceId !== workspaceId) continue; + if (!workspace?.revision) { + workspacePolicyGate.allowLegacy(workspaceId); + return workspacePreferences.load(); + } + workspacePolicyGate.select(workspaceId); + const outcome = await Promise.race([ + getWorkspace(workspaceId).then( + (record) => ({ kind: "record" as const, record }), + () => ({ kind: "error" as const }), + ), + workspacePolicyGate.waitForCurrent(() => workspacePreferences.load()).then( + (selection) => ({ kind: "selection" as const, selection }), + ), + ]); + if (outcome.kind === "selection") { + if (outcome.selection.workspaceId !== workspaceId) continue; + return outcome.selection; + } + if (outcome.kind === "error") { + if (workspacePreferences.load().workspaceId !== workspaceId) continue; + workspacePolicyGate.reject(workspaceId); + throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); + } + const { record } = outcome; + if (workspacePreferences.load().workspaceId !== workspaceId) continue; + let selection: WorkspacePreference; + try { + selection = reconcileWorkspacePolicy( + preferences, + record.workspace.llm_policy.allowed, + record.workspace.llm_policy.default, + ); + } catch (error) { + workspacePolicyGate.reject(workspaceId); + throw error; + } + workspacePolicyGate.resolve(workspaceId); + if (workspacePreferences.load().workspaceId === workspaceId) return selection; + } +} + export async function createSession(i: NewSessionInput) { await selectedPreferences(); - const selection = await workspacePolicyGate.waitForCurrent(() => workspacePreferences.load()); + const selection = await ensureWorkspaceSelectionPolicy(); return apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify({ ...i, ...selection }), diff --git a/frontend/src/shell/NewSessionDialog.test.tsx b/frontend/src/shell/NewSessionDialog.test.tsx index bee5edb5..bf28d88c 100644 --- a/frontend/src/shell/NewSessionDialog.test.tsx +++ b/frontend/src/shell/NewSessionDialog.test.tsx @@ -34,6 +34,9 @@ test("submitting includes browser-local migrated preferences and calls onCreated http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low", })), + http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ + id: "default", name: "default", file: "default.yaml", displayName: "Default", + }])), http.post("http://localhost:8787/sessions", async ({ request }) => { body = await request.json(); return HttpResponse.json({ id: "s1" }); @@ -51,6 +54,56 @@ test("submitting includes browser-local migrated preferences and calls onCreated await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s1")); }); +test("first-run direct dialog creation waits for registry policy without a mounted footer", async () => { + let body: unknown; + let releasePolicy!: () => void; + let summaryRequestStarted = false; + let policyRequestStarted = false; + const policyMayFinish = new Promise((resolve) => { releasePolicy = resolve; }); + const revision = { + id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const, + }; + localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", + })); + server.use( + http.get("http://localhost:8787/workspaces", () => { + summaryRequestStarted = true; + return HttpResponse.json([{ + id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", + revision, + }]); + }), + http.get("http://localhost:8787/workspaces/psd-clinical", async () => { + policyRequestStarted = true; + await policyMayFinish; + return HttpResponse.json({ + workspace: { llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] } }, revision, + }); + }), + http.post("http://localhost:8787/sessions", async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ id: "s1" }); + }), + ); + + const { onCreated } = renderDialog(); + await userEvent.click(screen.getByRole("button", { name: /new/i })); + await userEvent.type(await screen.findByLabelText(/question/i), "q"); + await userEvent.click(screen.getByRole("button", { name: /^create$/i })); + + await waitFor(() => expect(summaryRequestStarted).toBe(true)); + await waitFor(() => expect(policyRequestStarted).toBe(true)); + expect(body).toBeUndefined(); + expect(screen.getByRole("button", { name: /creating/i })).toBeDisabled(); + releasePolicy(); + + await waitFor(() => expect(body).toEqual({ + question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", + })); + await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s1")); +}); + test("empty question shows a validation error and does not submit", async () => { renderDialog(); await userEvent.click(screen.getByRole("button", { name: /new/i })); diff --git a/frontend/src/shell/SteerInput.test.tsx b/frontend/src/shell/SteerInput.test.tsx index 76d474bd..b2c10bd7 100644 --- a/frontend/src/shell/SteerInput.test.tsx +++ b/frontend/src/shell/SteerInput.test.tsx @@ -22,6 +22,9 @@ test("new sessions send the browser-selected workspace, model, provider, and thi workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high", })); server.use( + http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ + id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", + }])), http.post("http://localhost:8787/sessions", async ({ request }) => { body = await request.json(); return HttpResponse.json({ id: "s1" }); diff --git a/frontend/src/workspaces/drafts.ts b/frontend/src/workspaces/drafts.ts index 7007eb71..25b9f649 100644 --- a/frontend/src/workspaces/drafts.ts +++ b/frontend/src/workspaces/drafts.ts @@ -9,6 +9,9 @@ export interface WorkspaceDraft { updatedAt: string; } +export const WORKSPACE_SUMMARY_ERROR = "Could not load workspace registry. Please retry."; +export const WORKSPACE_POLICY_ERROR = "Could not load selected workspace policy. Please retry."; + export class WorkspaceSelectionError extends Error {} type PolicySelection = { @@ -70,7 +73,7 @@ export const workspacePolicyGate = { rejectSummary(workspaceId: string): void { if (!selection || selection.workspaceId !== workspaceId || selection.state !== "summary") return; selection.state = "error"; - selection.error = new WorkspaceSelectionError("Could not load workspace registry. Please retry."); + selection.error = new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR); selection.settle(); }, @@ -88,7 +91,7 @@ export const workspacePolicyGate = { reject(workspaceId: string): void { if (!selection || selection.workspaceId !== workspaceId || selection.state !== "pending") return; selection.state = "error"; - selection.error = new WorkspaceSelectionError("Could not load selected workspace policy. Please retry."); + selection.error = new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); selection.settle(); },