fix: harden Pi management readiness

This commit is contained in:
2026-08-05 06:20:25 +02:00
parent 7a8bcacbfe
commit 7df21b5f21
24 changed files with 577 additions and 274 deletions
+23 -2
View File
@@ -1,6 +1,7 @@
import { execFile as nodeExecFile } from "node:child_process"; import { execFile as nodeExecFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import type { AppConfig } from "../config.js"; import type { AppConfig } from "../config.js";
import { secretValue } from "../config/secret-bundle.js";
import { import {
loadSettings, loadSettings,
saveSettings, saveSettings,
@@ -12,6 +13,11 @@ import {
isPiManagedConfigError, isPiManagedConfigError,
} from "./managed-config.js"; } from "./managed-config.js";
import { createPiProviderSmoke, type PiProviderSmoke } from "./provider-smoke.js"; import { createPiProviderSmoke, type PiProviderSmoke } from "./provider-smoke.js";
import { loadPiAuthProviders } from "./auth-providers.js";
import {
piProviderCredentialStatus,
type PiCredentialStatus,
} from "./provider-credentials.js";
const execFile = promisify(nodeExecFile); const execFile = promisify(nodeExecFile);
const REASONING_CHOICES = ["low", "medium", "high"] as const; const REASONING_CHOICES = ["low", "medium", "high"] as const;
@@ -31,6 +37,7 @@ export interface PiInstallationConfig {
export interface PiStatus { export interface PiStatus {
version?: string; version?: string;
ready: boolean; ready: boolean;
credentials: PiCredentialStatus;
config: PiInstallationConfig; config: PiInstallationConfig;
checkedAt: string; checkedAt: string;
message?: string; message?: string;
@@ -89,6 +96,7 @@ interface PiManagementDeps {
readSettings?: () => Settings; readSettings?: () => Settings;
saveSettings?: (settings: Settings) => Settings; saveSettings?: (settings: Settings) => Settings;
readLogs?: () => string | Promise<string>; readLogs?: () => string | Promise<string>;
credentialStatus?: (provider: string | undefined) => PiCredentialStatus;
now?: () => Date; now?: () => Date;
} }
@@ -104,6 +112,18 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
const persistSettings = deps.saveSettings ?? ((settings) => saveSettings(config, settings)); const persistSettings = deps.saveSettings ?? ((settings) => saveSettings(config, settings));
const readLogs = deps.readLogs ?? (() => diagnostics.join("\n")); const readLogs = deps.readLogs ?? (() => diagnostics.join("\n"));
const smokeProvider = deps.smokeProvider ?? createPiProviderSmoke(config); const smokeProvider = deps.smokeProvider ?? createPiProviderSmoke(config);
const credentialStatus = deps.credentialStatus ?? ((provider: string | undefined) => {
try {
return piProviderCredentialStatus({
provider,
authProviders: loadPiAuthProviders(),
credentialValue: secretValue(config, "THT_MODEL_API_KEY"),
credentialFile: config.modelApiKeyFile,
});
} catch {
return "missing";
}
});
const closedOptions = async (): Promise<Omit<PiOptions, "checkedAt">> => { const closedOptions = async (): Promise<Omit<PiOptions, "checkedAt">> => {
let listed: PiModel[]; let listed: PiModel[];
@@ -168,14 +188,15 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
async status(): Promise<PiStatus> { async status(): Promise<PiStatus> {
const checkedAt = now().toISOString(); const checkedAt = now().toISOString();
const current = installationConfig(); const current = installationConfig();
const credentials = credentialStatus(current.provider);
try { try {
const currentVersion = await version(); const currentVersion = await version();
addDiagnostic("Pi version probe succeeded"); addDiagnostic("Pi version probe succeeded");
return { version: currentVersion, ready: true, config: current, checkedAt }; return { version: currentVersion, ready: true, credentials, config: current, checkedAt };
} catch (error) { } catch (error) {
const message = stableMessage(error, "Pi runtime is unavailable"); const message = stableMessage(error, "Pi runtime is unavailable");
addDiagnostic(message); addDiagnostic(message);
return { ready: false, config: current, checkedAt, message }; return { ready: false, credentials, config: current, checkedAt, message };
} }
}, },
+21
View File
@@ -53,6 +53,8 @@ export function canonicalPiProvider(provider: string | undefined): string | unde
return value; return value;
} }
export type PiCredentialStatus = "present" | "missing";
export interface CredentialFsOps { export interface CredentialFsOps {
lstat(path: string): Stats; lstat(path: string): Stats;
open(path: string, flags: number): number; open(path: string, flags: number): number;
@@ -172,3 +174,22 @@ export function buildPiChildEnv(opts: {
} }
return env; return env;
} }
/** Report only whether the selected hosted provider has a usable credential source. */
export function piProviderCredentialStatus(opts: {
provider?: string;
credentialFile?: string;
credentialValue?: string;
authProviders?: ReadonlySet<string>;
fsOps?: CredentialFsOps;
}): PiCredentialStatus {
const provider = canonicalPiProvider(opts.provider);
if (!provider || LOCAL_PROVIDERS.has(provider)) return "missing";
if (opts.authProviders?.has(provider)) return "present";
try {
buildPiChildEnv({ ...opts, ambient: {} });
return "present";
} catch {
return "missing";
}
}
+32
View File
@@ -38,12 +38,14 @@ test("status parses only a Pi version from a fixed execFile argument array", asy
execute: successfulExec(calls), execute: successfulExec(calls),
listModels: async () => supportedModels, listModels: async () => supportedModels,
readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }), readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }),
credentialStatus: () => "missing",
now: () => new Date("2026-08-05T10:00:00.000Z"), now: () => new Date("2026-08-05T10:00:00.000Z"),
}); });
await expect(service.status()).resolves.toEqual({ await expect(service.status()).resolves.toEqual({
version: "0.80.3", version: "0.80.3",
ready: true, ready: true,
credentials: "missing",
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
checkedAt: "2026-08-05T10:00:00.000Z", checkedAt: "2026-08-05T10:00:00.000Z",
}); });
@@ -53,6 +55,36 @@ test("status parses only a Pi version from a fixed execFile argument array", asy
expect(calls[0].timeout).toBeLessThanOrEqual(750); expect(calls[0].timeout).toBeLessThanOrEqual(750);
}); });
// Catches credential presence being inferred from smoke success/failure or exposing any
// credential material instead of the installation's explicit sanitized presence state.
test.each(["present", "missing"] as const)(
"status reports configured-provider credentials only as %s",
async (credentials) => {
const checkedProviders: Array<string | undefined> = [];
const service = createPiManagement(configFor(), {
execute: successfulExec([]),
listModels: async () => supportedModels,
readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }),
credentialStatus: (provider) => {
checkedProviders.push(provider);
return credentials;
},
now: () => new Date("2026-08-05T10:00:00.000Z"),
});
const status = await service.status();
expect(status).toEqual({
version: "0.80.3",
ready: true,
credentials,
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
checkedAt: "2026-08-05T10:00:00.000Z",
});
expect(checkedProviders).toEqual(["zai"]);
expect(JSON.stringify(status)).not.toMatch(/api.?key|token|password|secret/i);
},
);
// Catches an options response that leaks provider metadata or lets callers choose model IDs that // Catches an options response that leaks provider metadata or lets callers choose model IDs that
// Pi did not explicitly enable for this installation. // Pi did not explicitly enable for this installation.
test("options expose only closed provider, model, and reasoning choices", async () => { test("options expose only closed provider, model, and reasoning choices", async () => {
+14
View File
@@ -5,6 +5,7 @@ import {
PI_0803_CREDENTIAL_ENV_NAMES, PI_0803_CREDENTIAL_ENV_NAMES,
buildPiChildEnv, buildPiChildEnv,
canonicalPiProvider, canonicalPiProvider,
piProviderCredentialStatus,
} from "../src/pi/provider-credentials.js"; } from "../src/pi/provider-credentials.js";
test("canonical provider aliases resolve to packaged Pi 0.80.3 IDs", () => { test("canonical provider aliases resolve to packaged Pi 0.80.3 IDs", () => {
@@ -130,6 +131,19 @@ test("local-qwen is an explicit local provider and needs no generic key", () =>
expect(env).not.toHaveProperty("THT_MODEL_API_KEY_FILE"); expect(env).not.toHaveProperty("THT_MODEL_API_KEY_FILE");
}); });
test("credential status reports only present or missing without treating local providers as credentialed", () => {
expect(piProviderCredentialStatus({
provider: "deepseek",
authProviders: new Set(["deepseek"]),
credentialValue: "must-not-be-returned",
})).toBe("present");
expect(piProviderCredentialStatus({ provider: "deepseek" })).toBe("missing");
expect(piProviderCredentialStatus({
provider: "local-qwen",
credentialValue: "must-not-be-returned",
})).toBe("missing");
});
test("bundle value is injected without exposing bundle metadata to Pi", () => { test("bundle value is injected without exposing bundle metadata to Pi", () => {
const env = buildPiChildEnv({ const env = buildPiChildEnv({
ambient: { ambient: {
@@ -10,6 +10,7 @@ function fakeService(): PiManagementService {
return { return {
status: vi.fn(async () => ({ status: vi.fn(async () => ({
version: "0.80.3", ready: true, version: "0.80.3", ready: true,
credentials: "present",
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
checkedAt: "2026-08-05T10:00:00.000Z", checkedAt: "2026-08-05T10:00:00.000Z",
})), })),
@@ -77,6 +78,7 @@ test("loopback-only AUTH_MODE=none may read the sanitized Pi status", async () =
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ expect(response.json()).toEqual({
version: "0.80.3", ready: true, version: "0.80.3", ready: true,
credentials: "present",
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
checkedAt: "2026-08-05T10:00:00.000Z", checkedAt: "2026-08-05T10:00:00.000Z",
}); });
+2 -2
View File
@@ -5,7 +5,7 @@ import { apiFetch } from "./client";
test("body-less POST omits content-type (avoids Fastify empty-body 400)", async () => { test("body-less POST omits content-type (avoids Fastify empty-body 400)", async () => {
let contentType: string | null = "unset"; let contentType: string | null = "unset";
server.use( server.use(
http.post("http://localhost:8787/sessions/s1/resume", ({ request }) => { http.post("/api/sessions/s1/resume", ({ request }) => {
contentType = request.headers.get("content-type"); contentType = request.headers.get("content-type");
return HttpResponse.json({ id: "s1" }); return HttpResponse.json({ id: "s1" });
}), }),
@@ -17,7 +17,7 @@ test("body-less POST omits content-type (avoids Fastify empty-body 400)", async
test("POST with a body sends application/json content-type", async () => { test("POST with a body sends application/json content-type", async () => {
let contentType: string | null = null; let contentType: string | null = null;
server.use( server.use(
http.post("http://localhost:8787/sessions/s1/steer", ({ request }) => { http.post("/api/sessions/s1/steer", ({ request }) => {
contentType = request.headers.get("content-type"); contentType = request.headers.get("content-type");
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
}), }),
+2 -2
View File
@@ -14,7 +14,7 @@ test("Pi management client calls only the sanctioned sanitized endpoints", async
server.use( server.use(
http.get("/api/pi-management/status", ({ request }) => { http.get("/api/pi-management/status", ({ request }) => {
calls.push({ method: request.method, path: new URL(request.url).pathname }); calls.push({ method: request.method, path: new URL(request.url).pathname });
return HttpResponse.json({ ready: true, version: "0.80.3", config: {}, checkedAt: "2026-08-05T10:00:00.000Z" }); return HttpResponse.json({ ready: true, version: "0.80.3", credentials: "present", config: {}, checkedAt: "2026-08-05T10:00:00.000Z" });
}), }),
http.get("/api/pi-management/options", ({ request }) => { http.get("/api/pi-management/options", ({ request }) => {
calls.push({ method: request.method, path: new URL(request.url).pathname }); calls.push({ method: request.method, path: new URL(request.url).pathname });
@@ -34,7 +34,7 @@ test("Pi management client calls only the sanctioned sanitized endpoints", async
}), }),
); );
await expect(getPiManagementStatus()).resolves.toMatchObject({ version: "0.80.3", ready: true }); await expect(getPiManagementStatus()).resolves.toMatchObject({ version: "0.80.3", ready: true, credentials: "present" });
await expect(getPiManagementOptions()).resolves.toMatchObject({ providers: ["zai"] }); await expect(getPiManagementOptions()).resolves.toMatchObject({ providers: ["zai"] });
await expect(savePiManagementConfig({ provider: "zai", model: "glm-5.2", reasoning: "high" })).resolves.toMatchObject({ reasoning: "high" }); await expect(savePiManagementConfig({ provider: "zai", model: "glm-5.2", reasoning: "high" })).resolves.toMatchObject({ reasoning: "high" });
await expect(runPiManagementTest()).resolves.toMatchObject({ ready: true }); await expect(runPiManagementTest()).resolves.toMatchObject({ ready: true });
+1
View File
@@ -11,6 +11,7 @@ export interface PiInstallationConfig {
export interface PiManagementStatus { export interface PiManagementStatus {
version?: string; version?: string;
ready: boolean; ready: boolean;
credentials: "present" | "missing";
config: Partial<PiInstallationConfig>; config: Partial<PiInstallationConfig>;
checkedAt: string; checkedAt: string;
message?: string; message?: string;
+14 -14
View File
@@ -10,13 +10,13 @@ test("createSession migrates legacy selections and POSTs browser preferences", a
localStorage.clear(); localStorage.clear();
let body: unknown = null; let body: unknown = null;
server.use( server.use(
http.get("http://localhost:8787/settings", () => HttpResponse.json({ http.get("/api/settings", () => HttpResponse.json({
workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
})), })),
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ http.get("/api/workspaces", () => HttpResponse.json([{
id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical",
}])), }])),
http.post("http://localhost:8787/sessions", async ({ request }) => { http.post("/api/sessions", async ({ request }) => {
body = await request.json(); body = await request.json();
return HttpResponse.json({ id: "s1" }); return HttpResponse.json({ id: "s1" });
}), }),
@@ -33,7 +33,7 @@ test("createSession migrates legacy selections and POSTs browser preferences", a
test.each([202, 204])("prewarmRuntime accepts a body-less %s response", async (status) => { test.each([202, 204])("prewarmRuntime accepts a body-less %s response", async (status) => {
let called = false; let called = false;
server.use( server.use(
http.post("http://localhost:8787/runtime/prewarm", () => { http.post("/api/runtime/prewarm", () => {
called = true; called = true;
return new HttpResponse(null, { status }); return new HttpResponse(null, { status });
}), }),
@@ -44,7 +44,7 @@ test.each([202, 204])("prewarmRuntime accepts a body-less %s response", async (s
test("listSessions defaults to the current user's scope", async () => { test("listSessions defaults to the current user's scope", async () => {
let scope: string | null = null; let scope: string | null = null;
server.use(http.get("http://localhost:8787/sessions", ({ request }) => { server.use(http.get("/api/sessions", ({ request }) => {
scope = new URL(request.url).searchParams.get("scope"); scope = new URL(request.url).searchParams.get("scope");
return HttpResponse.json([{ id: "s1", status: "open", question: "q", summary: null, created_at: "t", updated_at: null, author: null }]); return HttpResponse.json([{ id: "s1", status: "open", question: "q", summary: null, created_at: "t", updated_at: null, author: null }]);
})); }));
@@ -55,7 +55,7 @@ test("listSessions defaults to the current user's scope", async () => {
test("listSessions requests the selected administrator scope", async () => { test("listSessions requests the selected administrator scope", async () => {
let scope: string | null = null; let scope: string | null = null;
server.use(http.get("http://localhost:8787/sessions", ({ request }) => { server.use(http.get("/api/sessions", ({ request }) => {
scope = new URL(request.url).searchParams.get("scope"); scope = new URL(request.url).searchParams.get("scope");
return HttpResponse.json([]); return HttpResponse.json([]);
})); }));
@@ -64,7 +64,7 @@ test("listSessions requests the selected administrator scope", async () => {
}); });
test("getMe fetches the typed authenticated principal", async () => { test("getMe fetches the typed authenticated principal", async () => {
server.use(http.get("http://localhost:8787/me", () => server.use(http.get("/api/me", () =>
HttpResponse.json({ issuer: "portal", subject: "42", displayName: "Alice", isAdmin: true }), HttpResponse.json({ issuer: "portal", subject: "42", displayName: "Alice", isAdmin: true }),
)); ));
await expect(getMe()).resolves.toEqual({ await expect(getMe()).resolves.toEqual({
@@ -73,7 +73,7 @@ test("getMe fetches the typed authenticated principal", async () => {
}); });
test("resumeSession returns the typed runtime disposition", async () => { test("resumeSession returns the typed runtime disposition", async () => {
server.use(http.post("http://localhost:8787/sessions/s1/resume", () => server.use(http.post("/api/sessions/s1/resume", () =>
HttpResponse.json({ id: "s1", alreadyActive: false }))); HttpResponse.json({ id: "s1", alreadyActive: false })));
const result: { id: string; alreadyActive: boolean } = await resumeSession("s1"); const result: { id: string; alreadyActive: boolean } = await resumeSession("s1");
@@ -82,7 +82,7 @@ test("resumeSession returns the typed runtime disposition", async () => {
test("renameSession POSTs {name}", async () => { test("renameSession POSTs {name}", async () => {
let body: unknown = null; let body: unknown = null;
server.use(http.post("http://localhost:8787/sessions/s1/rename", async ({ request }) => { server.use(http.post("/api/sessions/s1/rename", async ({ request }) => {
body = await request.json(); body = await request.json();
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
})); }));
@@ -92,7 +92,7 @@ test("renameSession POSTs {name}", async () => {
test("setSessionGroup POSTs {group}", async () => { test("setSessionGroup POSTs {group}", async () => {
let body: unknown = null; let body: unknown = null;
server.use(http.post("http://localhost:8787/sessions/s1/group", async ({ request }) => { server.use(http.post("/api/sessions/s1/group", async ({ request }) => {
body = await request.json(); body = await request.json();
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
})); }));
@@ -103,9 +103,9 @@ test("setSessionGroup POSTs {group}", async () => {
test("archive / unarchive / delete hit the right verbs+paths", async () => { test("archive / unarchive / delete hit the right verbs+paths", async () => {
const hits: string[] = []; const hits: string[] = [];
server.use( server.use(
http.post("http://localhost:8787/sessions/s1/archive", () => { hits.push("archive"); return new HttpResponse(null, { status: 204 }); }), http.post("/api/sessions/s1/archive", () => { hits.push("archive"); return new HttpResponse(null, { status: 204 }); }),
http.post("http://localhost:8787/sessions/s1/unarchive", () => { hits.push("unarchive"); return new HttpResponse(null, { status: 204 }); }), http.post("/api/sessions/s1/unarchive", () => { hits.push("unarchive"); return new HttpResponse(null, { status: 204 }); }),
http.delete("http://localhost:8787/sessions/s1", () => { hits.push("delete"); return new HttpResponse(null, { status: 204 }); }), http.delete("/api/sessions/s1", () => { hits.push("delete"); return new HttpResponse(null, { status: 204 }); }),
); );
await archiveSession("s1"); await archiveSession("s1");
await unarchiveSession("s1"); await unarchiveSession("s1");
@@ -114,7 +114,7 @@ test("archive / unarchive / delete hit the right verbs+paths", async () => {
}); });
test("getSessionDocuments GETs the array", async () => { test("getSessionDocuments GETs the array", async () => {
server.use(http.get("http://localhost:8787/sessions/s1/documents", () => server.use(http.get("/api/sessions/s1/documents", () =>
HttpResponse.json([{ phase: "—", key: "question", title: "t", format: "text", content: "q" }]), HttpResponse.json([{ phase: "—", key: "question", title: "t", format: "text", content: "q" }]),
)); ));
const docs = await getSessionDocuments("s1"); const docs = await getSessionDocuments("s1");
+4 -4
View File
@@ -15,7 +15,7 @@ const workspace: CanonicalWorkspace = {
test("uploads a workspace bundle without JSON content type", async () => { test("uploads a workspace bundle without JSON content type", async () => {
let contentType: string | null = null; let contentType: string | null = null;
server.use(http.post("http://localhost:8787/workspaces/import", ({ request }) => { server.use(http.post("/api/workspaces/import", ({ request }) => {
contentType = request.headers.get("content-type"); contentType = request.headers.get("content-type");
return HttpResponse.json({ draft: { workspace: {} } }); return HttpResponse.json({ draft: { workspace: {} } });
})); }));
@@ -29,7 +29,7 @@ test("uploads a workspace bundle without JSON content type", async () => {
}); });
test("rejects a conflict payload that attempts to surface a secret field", async () => { test("rejects a conflict payload that attempts to surface a secret field", async () => {
server.use(http.post("http://localhost:8787/workspaces/publish", () => HttpResponse.json({ server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
code: "workspace_conflict", message: "Workspace changed in the registry.", fields: ["dwh.password"], code: "workspace_conflict", message: "Workspace changed in the registry.", fields: ["dwh.password"],
base: { ...workspace, dwh: { ...workspace.dwh, password: "secret" } }, local: workspace, remote: workspace, base: { ...workspace, dwh: { ...workspace.dwh, password: "secret" } }, local: workspace, remote: workspace,
}, { status: 409 }))); }, { status: 409 })));
@@ -73,7 +73,7 @@ const diagnosticsWorkspace: CanonicalWorkspace = {
}; };
test.each(optionalDiagnosticsConflictFields)("accepts optional diagnostics conflict branch %s", async (field) => { test.each(optionalDiagnosticsConflictFields)("accepts optional diagnostics conflict branch %s", async (field) => {
server.use(http.post("http://localhost:8787/workspaces/publish", () => HttpResponse.json({ server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
code: "workspace_conflict", message: "Workspace changed in the registry.", fields: [field], code: "workspace_conflict", message: "Workspace changed in the registry.", fields: [field],
expected: { commit: "a".repeat(40), blob: "b".repeat(40) }, expected: { commit: "a".repeat(40), blob: "b".repeat(40) },
actual: { commit: "c".repeat(40), blob: "d".repeat(40) }, actual: { commit: "c".repeat(40), blob: "d".repeat(40) },
@@ -99,7 +99,7 @@ test.each(diagnosticConflictFields)("accepts canonical diagnostic conflict leaf
embedding: { method: "GET", path: "/models", auth: "none", response: { model: "model", dimensions: "dimensions" } }, embedding: { method: "GET", path: "/models", auth: "none", response: { model: "model", dimensions: "dimensions" } },
}, },
}; };
server.use(http.post("http://localhost:8787/workspaces/publish", () => HttpResponse.json({ server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
code: "workspace_conflict", message: "Workspace changed in the registry.", code: "workspace_conflict", message: "Workspace changed in the registry.",
fields: [field], fields: [field],
expected: { commit: "a".repeat(40), blob: "b".repeat(40) }, expected: { commit: "a".repeat(40), blob: "b".repeat(40) },
@@ -18,19 +18,19 @@ beforeEach(() => {
(globalThis as any).EventSource = FakeEventSource; (globalThis as any).EventSource = FakeEventSource;
useSessionStore.getState().resetSession(); useSessionStore.getState().resetSession();
server.use( server.use(
http.get("http://localhost:8787/me", () => HttpResponse.json({ issuer: "test", subject: "test", displayName: "Test", isAdmin: false })), http.get("/api/me", () => HttpResponse.json({ issuer: "test", subject: "test", displayName: "Test", isAdmin: false })),
http.get("http://localhost:8787/sessions", () => HttpResponse.json([])), http.get("/api/sessions", () => HttpResponse.json([])),
http.get("http://localhost:8787/settings", () => http.get("/api/settings", () =>
HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })), HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([])), http.get("/api/workspaces", () => HttpResponse.json([])),
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [] })), http.get("/api/models", () => HttpResponse.json({ models: [] })),
); );
}); });
test("New session starts prewarm without delaying composer focus", async () => { test("New session starts prewarm without delaying composer focus", async () => {
let prewarmStarted = false; let prewarmStarted = false;
server.use( server.use(
http.post("http://localhost:8787/runtime/prewarm", async () => { http.post("/api/runtime/prewarm", async () => {
prewarmStarted = true; prewarmStarted = true;
await delay(100); await delay(100);
return new HttpResponse(null, { status: 202 }); return new HttpResponse(null, { status: 202 });
@@ -51,7 +51,7 @@ test("records the prompt without central duplication, then opens the live log",
let releaseCreate!: () => void; let releaseCreate!: () => void;
const createMayFinish = new Promise<void>((resolve) => { releaseCreate = resolve; }); const createMayFinish = new Promise<void>((resolve) => { releaseCreate = resolve; });
server.use( server.use(
http.post("http://localhost:8787/sessions", async () => { http.post("/api/sessions", async () => {
await createMayFinish; await createMayFinish;
return HttpResponse.json({ id: "s-new" }); return HttpResponse.json({ id: "s-new" });
}), }),
@@ -81,7 +81,7 @@ test("records the prompt without central duplication, then opens the live log",
test("a failed create restores the landing view and preserves the question for retry", async () => { test("a failed create restores the landing view and preserves the question for retry", async () => {
server.use( server.use(
http.post("http://localhost:8787/sessions", () => http.post("/api/sessions", () =>
new HttpResponse("unavailable", { status: 503 })), new HttpResponse("unavailable", { status: 503 })),
); );
renderShell(); renderShell();
@@ -99,7 +99,7 @@ test("a failed create restores the landing view and preserves the question for r
test("a DWH-unreachable precheck shows a specific alert and preserves the question", async () => { test("a DWH-unreachable precheck shows a specific alert and preserves the question", async () => {
server.use( server.use(
http.post("http://localhost:8787/sessions", () => http.post("/api/sessions", () =>
HttpResponse.json( HttpResponse.json(
{ {
error: "Cannot start a session: the database is unreachable. Check the VPN connection and try again.", error: "Cannot start a session: the database is unreachable. Check the VPN connection and try again.",
@@ -125,10 +125,10 @@ test("a DWH-unreachable precheck shows a specific alert and preserves the questi
test("model selector shows the three Pi-enabled models and stores the selected provider locally", async () => { test("model selector shows the three Pi-enabled models and stores the selected provider locally", async () => {
server.use( server.use(
http.get("http://localhost:8787/settings", () => HttpResponse.json({ http.get("/api/settings", () => HttpResponse.json({
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low", workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
})), })),
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ http.get("/api/models", () => HttpResponse.json({ models: [
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
{ provider: "deepseek", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", reasoning: true }, { provider: "deepseek", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", reasoning: true },
{ provider: "local-qwen", id: "qwen3.6-35b-a3b", name: "Qwen3.6 35B A3B Local", reasoning: false }, { provider: "local-qwen", id: "qwen3.6-35b-a3b", name: "Qwen3.6 35B A3B Local", reasoning: false },
@@ -151,7 +151,7 @@ test("model selector shows the three Pi-enabled models and stores the selected p
test("opens Workspace management from the right sidebar without interrupting the shell", async () => { test("opens Workspace management from the right sidebar without interrupting the shell", async () => {
server.use( server.use(
http.get("http://localhost:8787/workspace-registry/status", () => http.get("/api/workspace-registry/status", () =>
HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false })), HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false })),
); );
renderShell(); renderShell();
@@ -56,20 +56,20 @@ beforeEach(() => {
window.matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }); window.matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() });
useSessionStore.getState().resetSession(); useSessionStore.getState().resetSession();
server.use( server.use(
http.get("http://localhost:8787/me", () => http.get("/api/me", () =>
HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false }), HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false }),
), ),
http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)), http.get("/api/sessions", () => HttpResponse.json(LIST)),
http.get("http://localhost:8787/sessions/:id/documents", () => HttpResponse.json([ http.get("/api/sessions/:id/documents", () => HttpResponse.json([
{ phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Attiva uno" }, { phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Attiva uno" },
])), ])),
http.post("http://localhost:8787/sessions/:id/archive", () => new HttpResponse(null, { status: 204 })), http.post("/api/sessions/:id/archive", () => new HttpResponse(null, { status: 204 })),
); );
}); });
test("regular users load only their sessions and never see administrator controls", async () => { test("regular users load only their sessions and never see administrator controls", async () => {
let scope: string | null = null; let scope: string | null = null;
server.use(http.get("http://localhost:8787/sessions", ({ request }) => { server.use(http.get("/api/sessions", ({ request }) => {
scope = new URL(request.url).searchParams.get("scope"); scope = new URL(request.url).searchParams.get("scope");
return HttpResponse.json(LIST); return HttpResponse.json(LIST);
})); }));
@@ -80,17 +80,12 @@ test("regular users load only their sessions and never see administrator control
expect(screen.queryByText(/administrator view/i)).not.toBeInTheDocument(); expect(screen.queryByText(/administrator view/i)).not.toBeInTheDocument();
}); });
test("opens Pi management from the session rail without replacing session controls", async () => { test("Pi management preserves the open session summary and the model activity timeline", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( server.use(
http.get("/api/me", () => HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false })),
http.get("/api/sessions", () => HttpResponse.json(LIST)),
http.get("/api/health/dwh", () => HttpResponse.json({ ok: true, detail: "ok" })),
http.get("/api/settings", () => HttpResponse.json({})),
http.get("/api/workspaces", () => HttpResponse.json([])),
http.get("/api/models", () => HttpResponse.json([])),
http.get("/api/pi-management/status", () => HttpResponse.json({ http.get("/api/pi-management/status", () => HttpResponse.json({
version: "0.80.3", ready: true, version: "0.80.3", ready: true,
credentials: "present",
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
checkedAt: "2026-08-05T10:00:00.000Z", checkedAt: "2026-08-05T10:00:00.000Z",
})), })),
@@ -98,24 +93,41 @@ test("opens Pi management from the session rail without replacing session contro
providers: ["zai"], models: [{ provider: "zai", id: "glm-5.2" }], providers: ["zai"], models: [{ provider: "zai", id: "glm-5.2" }],
reasoning: ["low", "medium", "high"], checkedAt: "2026-08-05T10:00:00.000Z", reasoning: ["low", "medium", "high"], checkedAt: "2026-08-05T10:00:00.000Z",
})), })),
http.post("/api/sessions/:id/resume", () => resumeResult("s1")),
http.get("/api/sessions/:id", () => HttpResponse.json({ phase: 4 })),
); );
wrap(); wrap();
expect(await screen.findByText("Attiva uno")).toBeVisible(); await user.click(await screen.findByText("Attiva uno"));
expect(await screen.findByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale");
await user.click(screen.getByRole("button", { name: "Pi management" })); await user.click(screen.getByRole("button", { name: "Pi management" }));
expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible(); expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
const shell = document.querySelector('[data-testid="app-shell"]'); const hiddenSummary = document.querySelector<HTMLElement>('aside[aria-label="Session summary"]');
expect(shell).toHaveTextContent("Workspace management"); expect(hiddenSummary).toHaveAttribute("aria-hidden", "true");
expect(shell).toHaveTextContent("Active sessions"); expect(hiddenSummary).toHaveTextContent("Attiva uno");
await user.click(screen.getByRole("button", { name: "Close Pi management" }));
await waitFor(() => {
expect(screen.getByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale");
});
await user.click(screen.getByRole("button", { name: "Resume" }));
await screen.findByRole("button", { name: "Show model activity" });
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "Preserved activity" }));
await user.click(screen.getByRole("button", { name: "Pi management" }));
await user.click(await screen.findByRole("button", { name: "Close Pi management" }));
await waitFor(() => expect(screen.queryByRole("heading", { name: "Pi management" })).not.toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "Show model activity" }));
expect(await screen.findByRole("heading", { name: "Model activity" })).toBeVisible();
expect(screen.getByLabelText("Model activity timeline")).toHaveTextContent("Preserved activity");
}); });
test("administrators can explicitly switch to all sessions and see owners", async () => { test("administrators can explicitly switch to all sessions and see owners", async () => {
let scope = ""; let scope = "";
server.use( server.use(
http.get("http://localhost:8787/me", () => http.get("/api/me", () =>
HttpResponse.json({ issuer: "portal", subject: "alice-id", displayName: "Alice", isAdmin: true }), HttpResponse.json({ issuer: "portal", subject: "alice-id", displayName: "Alice", isAdmin: true }),
), ),
http.get("http://localhost:8787/sessions", ({ request }) => { http.get("/api/sessions", ({ request }) => {
scope = new URL(request.url).searchParams.get("scope") ?? ""; scope = new URL(request.url).searchParams.get("scope") ?? "";
return HttpResponse.json([ return HttpResponse.json([
{ ...LIST[0], author: "Alice" }, { ...LIST[0], author: "Alice" },
@@ -138,14 +150,14 @@ test("administrators can explicitly switch to all sessions and see owners", asyn
test("administrator confirms before deleting a same-named user's session", async () => { test("administrator confirms before deleting a same-named user's session", async () => {
let deletes = 0; let deletes = 0;
server.use( server.use(
http.get("http://localhost:8787/me", () => http.get("/api/me", () =>
HttpResponse.json({ issuer: "portal", subject: "alice-id", displayName: "Alice", isAdmin: true }), HttpResponse.json({ issuer: "portal", subject: "alice-id", displayName: "Alice", isAdmin: true }),
), ),
http.get("http://localhost:8787/sessions", () => HttpResponse.json([ http.get("/api/sessions", () => HttpResponse.json([
{ ...LIST[0], author: "Alice" }, { ...LIST[0], author: "Alice" },
{ ...LIST[1], id: "s3", question: "Second session", archived: false, author: "Bob" }, { ...LIST[1], id: "s3", question: "Second session", archived: false, author: "Bob" },
])), ])),
http.delete("http://localhost:8787/sessions/:id", () => { http.delete("/api/sessions/:id", () => {
deletes += 1; deletes += 1;
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
}), }),
@@ -165,13 +177,13 @@ test("administrator confirms before archiving a same-named user's session", asyn
let archives = 0; let archives = 0;
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
server.use( server.use(
http.get("http://localhost:8787/me", () => http.get("/api/me", () =>
HttpResponse.json({ issuer: "portal", subject: "alice-id", displayName: "Alice", isAdmin: true }), HttpResponse.json({ issuer: "portal", subject: "alice-id", displayName: "Alice", isAdmin: true }),
), ),
http.get("http://localhost:8787/sessions", () => HttpResponse.json([ http.get("/api/sessions", () => HttpResponse.json([
{ ...LIST[0], author: "Alice" }, { ...LIST[0], author: "Alice" },
])), ])),
http.post("http://localhost:8787/sessions/:id/archive", () => { http.post("/api/sessions/:id/archive", () => {
archives += 1; archives += 1;
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
}), }),
@@ -195,7 +207,7 @@ test("active list shows group header and hides archived sessions", async () => {
test("ungrouped sessions render after groups with no 'No group' label", async () => { test("ungrouped sessions render after groups with no 'No group' label", async () => {
server.use( server.use(
http.get("http://localhost:8787/sessions", () => HttpResponse.json([ http.get("/api/sessions", () => HttpResponse.json([
{ id: "g1", status: "open", question: "In gruppo", summary: null, created_at: "2026-01-02T00:00:00Z", updated_at: null, author: null, name: null, group: "Aritmologia", archived: false }, { id: "g1", status: "open", question: "In gruppo", summary: null, created_at: "2026-01-02T00:00:00Z", updated_at: null, author: null, name: null, group: "Aritmologia", archived: false },
{ id: "u1", status: "open", question: "Senza gruppo", summary: null, created_at: "2026-01-03T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: false }, { id: "u1", status: "open", question: "Senza gruppo", summary: null, created_at: "2026-01-03T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: false },
])), ])),
@@ -271,13 +283,13 @@ test("supports keyboard session resizing and hides its divider when the split is
test("clicking a session with a live runtime reconnects to its gate instead of the panel", async () => { test("clicking a session with a live runtime reconnects to its gate instead of the panel", async () => {
let resumed: string | null = null; let resumed: string | null = null;
server.use( server.use(
http.get("http://localhost:8787/sessions", () => http.get("/api/sessions", () =>
HttpResponse.json([{ ...LIST[0], active: true }])), HttpResponse.json([{ ...LIST[0], active: true }])),
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => { http.post("/api/sessions/:id/resume", ({ params }) => {
resumed = params.id as string; resumed = params.id as string;
return resumeResult(resumed, true); // warm runtime → alreadyActive return resumeResult(resumed, true); // warm runtime → alreadyActive
}), }),
http.get("http://localhost:8787/sessions/:id", () => http.get("/api/sessions/:id", () =>
HttpResponse.json({ id: "s1", status: "open", phase: 1 })), HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
); );
wrap(); wrap();
@@ -289,7 +301,7 @@ test("clicking a session with a live runtime reconnects to its gate instead of t
}); });
test("New session closes an open session detail panel", async () => { test("New session closes an open session detail panel", async () => {
server.use(http.post("http://localhost:8787/runtime/prewarm", () => server.use(http.post("/api/runtime/prewarm", () =>
HttpResponse.json({ status: "warming" }, { status: 202 }))); HttpResponse.json({ status: "warming" }, { status: 202 })));
wrap(); wrap();
await userEvent.click(await screen.findByText("Attiva uno")); // cold session → panel opens await userEvent.click(await screen.findByText("Attiva uno")); // cold session → panel opens
@@ -316,7 +328,7 @@ test("Resume from the panel activates the session and closes the panel", async (
activityLog: [{ kind: "status", phase: "F7", text: "Stale prior activity", level: "info" }], activityLog: [{ kind: "status", phase: "F7", text: "Stale prior activity", level: "info" }],
}); });
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => { http.post("/api/sessions/:id/resume", ({ params }) => {
resumed = params.id as string; resumed = params.id as string;
return resumeResult(resumed); return resumeResult(resumed);
}), }),
@@ -335,8 +347,8 @@ test("Resume from the panel activates the session and closes the panel", async (
test("Resume paints the re-entry phase from the manifest after the cold Resume succeeds", async () => { test("Resume paints the re-entry phase from the manifest after the cold Resume succeeds", async () => {
useSessionStore.getState().resetSession(); useSessionStore.getState().resetSession();
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")), http.post("/api/sessions/:id/resume", () => resumeResult("s1")),
http.get("http://localhost:8787/sessions/:id", () => http.get("/api/sessions/:id", () =>
HttpResponse.json({ id: "s1", status: "open", phase: 4 })), HttpResponse.json({ id: "s1", status: "open", phase: 4 })),
); );
wrap(); wrap();
@@ -349,11 +361,11 @@ test("Resume paints the re-entry phase from the manifest after the cold Resume s
test("an already-active same-session Resume preserves its EventSource and store", async () => { test("an already-active same-session Resume preserves its EventSource and store", async () => {
let resumeCalls = 0; let resumeCalls = 0;
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/resume", () => { http.post("/api/sessions/:id/resume", () => {
resumeCalls += 1; resumeCalls += 1;
return resumeResult("s1", resumeCalls > 1); return resumeResult("s1", resumeCalls > 1);
}), }),
http.get("http://localhost:8787/sessions/:id", () => http.get("/api/sessions/:id", () =>
HttpResponse.json({ id: "s1", status: "open", phase: 1 })), HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
); );
wrap(); wrap();
@@ -379,14 +391,14 @@ test("concurrent same-id Resume invocations share one cold request and replaceme
const coldGate = deferred(); const coldGate = deferred();
const coldStarted = deferred(); const coldStarted = deferred();
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/resume", async () => { http.post("/api/sessions/:id/resume", async () => {
resumeCalls += 1; resumeCalls += 1;
if (resumeCalls === 1) return resumeResult("s1", false); if (resumeCalls === 1) return resumeResult("s1", false);
if (resumeCalls === 2) coldStarted.resolve(); if (resumeCalls === 2) coldStarted.resolve();
await coldGate.promise; await coldGate.promise;
return resumeResult("s1", false); return resumeResult("s1", false);
}), }),
http.get("http://localhost:8787/sessions/:id", () => http.get("/api/sessions/:id", () =>
HttpResponse.json({ id: "s1", status: "open", phase: 1 })), HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
); );
wrap(); wrap();
@@ -408,7 +420,7 @@ test("concurrent same-id Resume invocations share one cold request and replaceme
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2)); await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
const replacement = FakeEventSource.instances[1]; const replacement = FakeEventSource.instances[1];
expect(oldSource.closed).toBe(true); expect(oldSource.closed).toBe(true);
expect(replacement.url).toBe("http://localhost:8787/sessions/s1/events"); expect(replacement.url).toBe("/api/sessions/s1/events");
expect(useSessionStore.getState().activityLog).toEqual([ expect(useSessionStore.getState().activityLog).toEqual([
{ kind: "lifecycle", phase: null, text: "Resuming session" }, { kind: "lifecycle", phase: null, text: "Resuming session" },
]); ]);
@@ -431,13 +443,13 @@ test("a committed Resume releases same-id single-flight before its manifest sett
const firstS1ManifestGate = deferred(); const firstS1ManifestGate = deferred();
const firstS1ManifestStarted = deferred(); const firstS1ManifestStarted = deferred();
server.use( server.use(
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])), http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])),
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => { http.post("/api/sessions/:id/resume", ({ params }) => {
const id = params.id as string; const id = params.id as string;
if (id === "s1") s1ResumeCalls += 1; if (id === "s1") s1ResumeCalls += 1;
return resumeResult(id); return resumeResult(id);
}), }),
http.get("http://localhost:8787/sessions/:id", async ({ params }) => { http.get("/api/sessions/:id", async ({ params }) => {
if (params.id === "s1") { if (params.id === "s1") {
s1ManifestCalls += 1; s1ManifestCalls += 1;
if (s1ManifestCalls === 1) { if (s1ManifestCalls === 1) {
@@ -476,9 +488,9 @@ test("a committed Resume releases same-id single-flight before its manifest sett
test("cold same-session Resume keeps the old stream until success then receives post-clear events once", async () => { test("cold same-session Resume keeps the old stream until success then receives post-clear events once", async () => {
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/resume", () => http.post("/api/sessions/:id/resume", () =>
resumeResult("s1")), resumeResult("s1")),
http.get("http://localhost:8787/sessions/:id", () => http.get("/api/sessions/:id", () =>
HttpResponse.json({ id: "s1", status: "open", phase: 1 })), HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
); );
wrap(); wrap();
@@ -492,7 +504,7 @@ test("cold same-session Resume keeps the old stream until success then receives
let markStarted!: () => void; let markStarted!: () => void;
const resumeStarted = new Promise<void>((resolve) => { markStarted = resolve; }); const resumeStarted = new Promise<void>((resolve) => { markStarted = resolve; });
const resumeReleased = new Promise<void>((resolve) => { releaseResume = resolve; }); const resumeReleased = new Promise<void>((resolve) => { releaseResume = resolve; });
server.use(http.post("http://localhost:8787/sessions/:id/resume", async () => { server.use(http.post("/api/sessions/:id/resume", async () => {
markStarted(); markStarted();
await resumeReleased; await resumeReleased;
return resumeResult("s1"); return resumeResult("s1");
@@ -517,7 +529,7 @@ test("cold same-session Resume keeps the old stream until success then receives
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2)); await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
expect(first.closed).toBe(true); expect(first.closed).toBe(true);
const replacement = FakeEventSource.instances[1]; const replacement = FakeEventSource.instances[1];
expect(replacement.url).toBe("http://localhost:8787/sessions/s1/events"); expect(replacement.url).toBe("/api/sessions/s1/events");
expect(useSessionStore.getState().transcript).toEqual([]); expect(useSessionStore.getState().transcript).toEqual([]);
expect(useSessionStore.getState().activityLog).toEqual([ expect(useSessionStore.getState().activityLog).toEqual([
{ kind: "lifecycle", phase: null, text: "Resuming session" }, { kind: "lifecycle", phase: null, text: "Resuming session" },
@@ -549,9 +561,9 @@ test("cold same-session Resume keeps the old stream until success then receives
test("a failed same-session Resume preserves its source, activity, and document panel", async () => { test("a failed same-session Resume preserves its source, activity, and document panel", async () => {
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/resume", () => http.post("/api/sessions/:id/resume", () =>
resumeResult("s1")), resumeResult("s1")),
http.get("http://localhost:8787/sessions/:id", () => http.get("/api/sessions/:id", () =>
HttpResponse.json({ id: "s1", status: "open", phase: 1 })), HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
); );
wrap(); wrap();
@@ -562,7 +574,7 @@ test("a failed same-session Resume preserves its source, activity, and document
act(() => first.emitNamed("info", { type: "info", text: "Keep me" }, "4")); act(() => first.emitNamed("info", { type: "info", text: "Keep me" }, "4"));
const before = useSessionStore.getState().activityLog.map((entry) => ({ ...entry })); const before = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => server.use(http.post("/api/sessions/:id/resume", () =>
new HttpResponse(null, { status: 409 }))); new HttpResponse(null, { status: 409 })));
await userEvent.click(screen.getByTestId("session-item-s1")); await userEvent.click(screen.getByTestId("session-item-s1"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i })); await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
@@ -581,10 +593,10 @@ test("resuming a different already-active session binds it only after success",
created_at: "2026-01-03T00:00:00Z", created_at: "2026-01-03T00:00:00Z",
}; };
server.use( server.use(
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])), http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])),
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => http.post("/api/sessions/:id/resume", ({ params }) =>
resumeResult(params.id as string, params.id === "s3")), resumeResult(params.id as string, params.id === "s3")),
http.get("http://localhost:8787/sessions/:id", ({ params }) => http.get("/api/sessions/:id", ({ params }) =>
HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })), HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })),
); );
wrap(); wrap();
@@ -617,8 +629,8 @@ test("competing Resume requests for different ids commit only the latest intent"
const s1Started = deferred(); const s1Started = deferred();
const s3Started = deferred(); const s3Started = deferred();
server.use( server.use(
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])), http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])),
http.post("http://localhost:8787/sessions/:id/resume", async ({ params }) => { http.post("/api/sessions/:id/resume", async ({ params }) => {
const id = params.id as string; const id = params.id as string;
if (id === "s1") { if (id === "s1") {
s1Started.resolve(); s1Started.resolve();
@@ -629,7 +641,7 @@ test("competing Resume requests for different ids commit only the latest intent"
} }
return resumeResult(id); return resumeResult(id);
}), }),
http.get("http://localhost:8787/sessions/:id", ({ params }) => http.get("/api/sessions/:id", ({ params }) =>
HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })), HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })),
); );
wrap(); wrap();
@@ -667,10 +679,10 @@ test("a stale Resume manifest cannot repaint the latest session phase", async ()
const s1ManifestGate = deferred(); const s1ManifestGate = deferred();
const s1ManifestStarted = deferred(); const s1ManifestStarted = deferred();
server.use( server.use(
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])), http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])),
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => http.post("/api/sessions/:id/resume", ({ params }) =>
resumeResult(params.id as string)), resumeResult(params.id as string)),
http.get("http://localhost:8787/sessions/:id", async ({ params }) => { http.get("/api/sessions/:id", async ({ params }) => {
if (params.id === "s1") { if (params.id === "s1") {
s1ManifestStarted.resolve(); s1ManifestStarted.resolve();
await s1ManifestGate.promise; await s1ManifestGate.promise;
@@ -700,12 +712,12 @@ test("starting a new question invalidates a pending Resume intent", async () =>
const resumeGate = deferred(); const resumeGate = deferred();
const resumeStarted = deferred(); const resumeStarted = deferred();
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/resume", async () => { http.post("/api/sessions/:id/resume", async () => {
resumeStarted.resolve(); resumeStarted.resolve();
await resumeGate.promise; await resumeGate.promise;
return resumeResult("s1"); return resumeResult("s1");
}), }),
http.post("http://localhost:8787/runtime/prewarm", () => http.post("/api/runtime/prewarm", () =>
HttpResponse.json({ status: "warming" }, { status: 202 })), HttpResponse.json({ status: "warming" }, { status: 202 })),
); );
wrap(); wrap();
@@ -728,12 +740,12 @@ test("a successful Delete invalidates an earlier pending Resume for the same tar
const resumeStarted = deferred(); const resumeStarted = deferred();
const deleteCompleted = deferred(); const deleteCompleted = deferred();
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/resume", async () => { http.post("/api/sessions/:id/resume", async () => {
resumeStarted.resolve(); resumeStarted.resolve();
await resumeGate.promise; await resumeGate.promise;
return resumeResult("s1"); return resumeResult("s1");
}), }),
http.delete("http://localhost:8787/sessions/:id", ({ params }) => { http.delete("/api/sessions/:id", ({ params }) => {
expect(params.id).toBe("s1"); expect(params.id).toBe("s1");
deleteCompleted.resolve(); deleteCompleted.resolve();
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
@@ -760,14 +772,14 @@ test("a successful Delete detaches a Resume that commits while Delete is pending
const deleteGate = deferred(); const deleteGate = deferred();
const deleteStarted = deferred(); const deleteStarted = deferred();
server.use( server.use(
http.delete("http://localhost:8787/sessions/:id", async ({ params }) => { http.delete("/api/sessions/:id", async ({ params }) => {
expect(params.id).toBe("s1"); expect(params.id).toBe("s1");
deleteStarted.resolve(); deleteStarted.resolve();
await deleteGate.promise; await deleteGate.promise;
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
}), }),
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")), http.post("/api/sessions/:id/resume", () => resumeResult("s1")),
http.get("http://localhost:8787/sessions/:id", () => http.get("/api/sessions/:id", () =>
HttpResponse.json({ id: "s1", status: "open", phase: 1 })), HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
); );
wrap(); wrap();
@@ -794,12 +806,12 @@ test("deleting another session does not invalidate a pending Resume", async () =
const resumeStarted = deferred(); const resumeStarted = deferred();
const deleteCompleted = deferred(); const deleteCompleted = deferred();
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/resume", async () => { http.post("/api/sessions/:id/resume", async () => {
resumeStarted.resolve(); resumeStarted.resolve();
await resumeGate.promise; await resumeGate.promise;
return resumeResult("s1"); return resumeResult("s1");
}), }),
http.delete("http://localhost:8787/sessions/:id", ({ params }) => { http.delete("/api/sessions/:id", ({ params }) => {
expect(params.id).toBe("s2"); expect(params.id).toBe("s2");
deleteCompleted.resolve(); deleteCompleted.resolve();
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
@@ -831,8 +843,8 @@ test("deleting active A preserves a pending Resume for different session B", asy
const s3ResumeGate = deferred(); const s3ResumeGate = deferred();
const s3ResumeStarted = deferred(); const s3ResumeStarted = deferred();
server.use( server.use(
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])), http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])),
http.post("http://localhost:8787/sessions/:id/resume", async ({ params }) => { http.post("/api/sessions/:id/resume", async ({ params }) => {
const id = params.id as string; const id = params.id as string;
if (id === "s3") { if (id === "s3") {
s3ResumeStarted.resolve(); s3ResumeStarted.resolve();
@@ -840,9 +852,9 @@ test("deleting active A preserves a pending Resume for different session B", asy
} }
return resumeResult(id); return resumeResult(id);
}), }),
http.get("http://localhost:8787/sessions/:id", ({ params }) => http.get("/api/sessions/:id", ({ params }) =>
HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })), HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })),
http.delete("http://localhost:8787/sessions/:id", ({ params }) => { http.delete("/api/sessions/:id", ({ params }) => {
expect(params.id).toBe("s1"); expect(params.id).toBe("s1");
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
}), }),
@@ -876,12 +888,12 @@ test("a failed Delete does not invalidate a pending Resume for its target", asyn
const resumeStarted = deferred(); const resumeStarted = deferred();
const deleteFailed = deferred(); const deleteFailed = deferred();
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/resume", async () => { http.post("/api/sessions/:id/resume", async () => {
resumeStarted.resolve(); resumeStarted.resolve();
await resumeGate.promise; await resumeGate.promise;
return resumeResult("s1"); return resumeResult("s1");
}), }),
http.delete("http://localhost:8787/sessions/:id", ({ params }) => { http.delete("/api/sessions/:id", ({ params }) => {
expect(params.id).toBe("s1"); expect(params.id).toBe("s1");
deleteFailed.resolve(); deleteFailed.resolve();
return new HttpResponse(null, { status: 500 }); return new HttpResponse(null, { status: 500 });
@@ -905,7 +917,7 @@ test("a failed Delete does not invalidate a pending Resume for its target", asyn
}); });
test("a failed Resume with no active session preserves the panel and existing activity", async () => { test("a failed Resume with no active session preserves the panel and existing activity", async () => {
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 409 }))); server.use(http.post("/api/sessions/:id/resume", () => new HttpResponse(null, { status: 409 })));
useSessionStore.setState({ useSessionStore.setState({
activityLog: [{ kind: "status", phase: "F7", text: "Preserve activity", level: "info" }], activityLog: [{ kind: "status", phase: "F7", text: "Preserve activity", level: "info" }],
}); });
@@ -923,7 +935,7 @@ test("a failed Resume with no active session preserves the panel and existing ac
test("closing and reopening Model activity preserves the complete activity log", async () => { test("closing and reopening Model activity preserves the complete activity log", async () => {
wrap(); wrap();
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); server.use(http.post("/api/sessions/:id/resume", () => resumeResult("s1")));
await userEvent.click(await screen.findByText("Attiva uno")); await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i })); await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
act(() => { act(() => {
@@ -971,8 +983,8 @@ test("session finalization shows the completion banner and returns to landing",
useSessionStore.getState().resetSession(); useSessionStore.getState().resetSession();
let finalized = false; let finalized = false;
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")), http.post("/api/sessions/:id/resume", () => resumeResult("s1")),
http.get("http://localhost:8787/sessions", () => http.get("/api/sessions", () =>
HttpResponse.json(finalized ? [{ ...LIST[0], status: "finalized" }] : LIST)), HttpResponse.json(finalized ? [{ ...LIST[0], status: "finalized" }] : LIST)),
); );
wrap(); wrap();
@@ -995,7 +1007,7 @@ test("session finalization shows the completion banner and returns to landing",
test("renaming a group reassigns its members via setSessionGroup", async () => { test("renaming a group reassigns its members via setSessionGroup", async () => {
const groupSets: Array<{ id: string; group: string }> = []; const groupSets: Array<{ id: string; group: string }> = [];
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/group", async ({ params, request }) => { http.post("/api/sessions/:id/group", async ({ params, request }) => {
const body = (await request.json()) as { group: string }; const body = (await request.json()) as { group: string };
groupSets.push({ id: params.id as string, group: body.group }); groupSets.push({ id: params.id as string, group: body.group });
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
@@ -1013,7 +1025,7 @@ test("renaming a group reassigns its members via setSessionGroup", async () => {
test("opens an accessible resizable activity split and persists pointer width", async () => { test("opens an accessible resizable activity split and persists pointer width", async () => {
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); server.use(http.post("/api/sessions/:id/resume", () => resumeResult("s1")));
wrap(); wrap();
await userEvent.click(await screen.findByText("Attiva uno")); await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i })); await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
@@ -1043,7 +1055,7 @@ test("opens an accessible resizable activity split and persists pointer width",
test("resizes the activity split with keyboard and exposes responsive drawer classes", async () => { test("resizes the activity split with keyboard and exposes responsive drawer classes", async () => {
localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "448"); localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "448");
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); server.use(http.post("/api/sessions/:id/resume", () => resumeResult("s1")));
wrap(); wrap();
await userEvent.click(await screen.findByText("Attiva uno")); await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i })); await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
@@ -1070,7 +1082,7 @@ test("resizes the activity split with keyboard and exposes responsive drawer cla
test("uses the measured app shell for the desktop activity split", async () => { test("uses the measured app shell for the desktop activity split", async () => {
localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "576"); localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "576");
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); server.use(http.post("/api/sessions/:id/resume", () => resumeResult("s1")));
wrap(); wrap();
await userEvent.click(await screen.findByText("Attiva uno")); await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i })); await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
@@ -1092,7 +1104,7 @@ test("uses the measured app shell for the desktop activity split", async () => {
test("cancels an active resize when the measured shell becomes too narrow", async () => { test("cancels an active resize when the measured shell becomes too narrow", async () => {
localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "576"); localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "576");
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); server.use(http.post("/api/sessions/:id/resume", () => resumeResult("s1")));
wrap(); wrap();
await userEvent.click(await screen.findByText("Attiva uno")); await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i })); await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
+6 -6
View File
@@ -31,13 +31,13 @@ test("submitting includes browser-local migrated preferences and calls onCreated
localStorage.clear(); localStorage.clear();
let body: unknown = null; let body: unknown = null;
server.use( server.use(
http.get("http://localhost:8787/settings", () => HttpResponse.json({ http.get("/api/settings", () => HttpResponse.json({
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low", workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
})), })),
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ http.get("/api/workspaces", () => HttpResponse.json([{
id: "default", name: "default", file: "default.yaml", displayName: "Default", id: "default", name: "default", file: "default.yaml", displayName: "Default",
}])), }])),
http.post("http://localhost:8787/sessions", async ({ request }) => { http.post("/api/sessions", async ({ request }) => {
body = await request.json(); body = await request.json();
return HttpResponse.json({ id: "s1" }); return HttpResponse.json({ id: "s1" });
}), }),
@@ -67,21 +67,21 @@ test("first-run direct dialog creation waits for registry policy without a mount
workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
})); }));
server.use( server.use(
http.get("http://localhost:8787/workspaces", () => { http.get("/api/workspaces", () => {
summaryRequestStarted = true; summaryRequestStarted = true;
return HttpResponse.json([{ return HttpResponse.json([{
id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical",
revision, revision,
}]); }]);
}), }),
http.get("http://localhost:8787/workspaces/psd-clinical", async () => { http.get("/api/workspaces/psd-clinical", async () => {
policyRequestStarted = true; policyRequestStarted = true;
await policyMayFinish; await policyMayFinish;
return HttpResponse.json({ return HttpResponse.json({
workspace: { llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] } }, revision, workspace: { llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] } }, revision,
}); });
}), }),
http.post("http://localhost:8787/sessions", async ({ request }) => { http.post("/api/sessions", async ({ request }) => {
body = await request.json(); body = await request.json();
return HttpResponse.json({ id: "s1" }); return HttpResponse.json({ id: "s1" });
}), }),
+113 -8
View File
@@ -8,6 +8,7 @@ import { PiManagement } from "./PiManagement";
const readyStatus = { const readyStatus = {
version: "0.80.3", version: "0.80.3",
ready: true, ready: true,
credentials: "present",
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
checkedAt: "2026-08-05T10:00:00.000Z", checkedAt: "2026-08-05T10:00:00.000Z",
}; };
@@ -45,7 +46,7 @@ test("loads Pi version and readiness as an accessible operational rail", async (
expect(screen.getByRole("status", { name: "Pi readiness" })).toHaveTextContent("Ready"); expect(screen.getByRole("status", { name: "Pi readiness" })).toHaveTextContent("Ready");
expect(screen.getByText("Pi 0.80.3")).toBeVisible(); expect(screen.getByText("Pi 0.80.3")).toBeVisible();
expect(screen.getByText("Defaults ready")).toBeVisible(); expect(screen.getByText("Defaults ready")).toBeVisible();
expect(screen.getByRole("button", { name: "Run smoke test" })).toBeVisible(); expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeVisible();
}); });
test("uses closed provider, model, and reasoning choices without a secret field or terminal", async () => { test("uses closed provider, model, and reasoning choices without a secret field or terminal", async () => {
@@ -80,7 +81,7 @@ test("saves only a selected non-secret configuration", async () => {
expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Defaults saved"); expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Defaults saved");
}); });
test("runs a provider smoke test and reports credentials only as present or missing", async () => { test("runs the saved-configuration test without changing credential presence", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let tests = 0; let tests = 0;
server.use(http.post("/api/pi-management/test", () => { server.use(http.post("/api/pi-management/test", () => {
@@ -91,11 +92,17 @@ test("runs a provider smoke test and reports credentials only as present or miss
})); }));
renderManagement(); renderManagement();
await user.click(await screen.findByRole("button", { name: "Run smoke test" })); const testButton = await screen.findByRole("button", { name: "Test saved defaults" });
expect(await screen.findByText("Credentials present")).toBeVisible(); expect(screen.getByText("Defaults ready")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Run smoke test" })); expect(screen.queryByText("Changes are not saved yet.")).not.toBeInTheDocument();
expect(await screen.findByText("Credentials missing")).toBeVisible(); expect(testButton).toBeEnabled();
expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Pi provider smoke check failed"); await user.click(testButton);
await waitFor(() => expect(tests).toBe(1));
expect(await screen.findByText("Saved configuration test passed")).toBeVisible();
expect(screen.getByText("Credentials present")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Test saved defaults" }));
expect(await screen.findByText("Saved configuration test failed")).toBeVisible();
expect(screen.getByText("Credentials present")).toBeVisible();
}); });
test("fetches and displays bounded sanitized diagnostic logs only on request", async () => { test("fetches and displays bounded sanitized diagnostic logs only on request", async () => {
@@ -125,7 +132,7 @@ test("explains forbidden management access without offering mutation controls",
expect(await screen.findByRole("alert", { name: "Pi management unavailable" })).toHaveTextContent("Pi management is not permitted"); expect(await screen.findByRole("alert", { name: "Pi management unavailable" })).toHaveTextContent("Pi management is not permitted");
expect(screen.queryByLabelText("Provider")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Provider")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save defaults" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Save defaults" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Run smoke test" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Test saved defaults" })).not.toBeInTheDocument();
}); });
test("offers a copyable host-side Pi update instruction without an update action", async () => { test("offers a copyable host-side Pi update instruction without an update action", async () => {
@@ -168,3 +175,101 @@ test("reloads installation defaults when the panel is reopened", async () => {
await waitFor(() => expect(screen.getByLabelText("Provider")).toHaveValue("deepseek")); await waitFor(() => expect(screen.getByLabelText("Provider")).toHaveValue("deepseek"));
}); });
test("shows an explicit recoverable incomplete state when no provider model is available", async () => {
server.use(
http.get("/api/pi-management/status", () => HttpResponse.json({ ...readyStatus, credentials: "present" })),
http.get("/api/pi-management/options", () => HttpResponse.json({
...options,
providers: ["zai"],
models: [],
})),
);
renderManagement();
const incomplete = await screen.findByRole("alert", { name: "Pi configuration incomplete" });
expect(incomplete).toHaveTextContent("No enabled provider and model choices are available");
expect(incomplete).toHaveTextContent("Check the host-managed Pi model configuration");
expect(screen.queryByText("Loading Pi management…")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save defaults" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeDisabled();
});
test("keeps suggested draft choices distinct from invalid persisted defaults until save succeeds", async () => {
const persisted = {
...readyStatus,
credentials: "missing",
config: { provider: "retired", model: "old-model", reasoning: "medium" },
};
server.use(
http.get("/api/pi-management/status", () => HttpResponse.json(persisted)),
http.put("/api/pi-management/config", async ({ request }) => HttpResponse.json({
...await request.json() as object,
updatedAt: "2026-08-05T10:05:00.000Z",
})),
);
const user = userEvent.setup();
renderManagement();
expect(await screen.findByLabelText("Provider")).toHaveValue("zai");
expect(screen.getByText("Defaults incomplete")).toBeVisible();
expect(screen.getByText("Suggested choices are not saved yet.")).toBeVisible();
expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Save defaults" }));
expect(await screen.findByText("Defaults ready")).toBeVisible();
expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeEnabled();
});
test.each(["present", "missing"] as const)(
"shows credentials %s from status without inferring them from smoke",
async (credentials) => {
server.use(http.get("/api/pi-management/status", () => HttpResponse.json({
...readyStatus,
credentials,
})));
renderManagement();
expect(await screen.findByText(`Credentials ${credentials}`)).toBeVisible();
},
);
test("labels smoke only as a saved-configuration test and resets it when the draft changes", async () => {
server.use(
http.get("/api/pi-management/status", () => HttpResponse.json({ ...readyStatus, credentials: "present" })),
http.post("/api/pi-management/test", () => HttpResponse.json({
ready: true,
checkedAt: "2026-08-05T10:06:00.000Z",
})),
);
const user = userEvent.setup();
renderManagement();
await user.click(await screen.findByRole("button", { name: "Test saved defaults" }));
expect(await screen.findByText("Saved configuration test passed")).toBeVisible();
expect(screen.getByText("Credentials present")).toBeVisible();
await user.selectOptions(screen.getByLabelText("Provider"), "deepseek");
expect(screen.getByText("Saved configuration test not run")).toBeVisible();
expect(screen.getByText("Save these changes before testing. The test always uses saved defaults.")).toBeVisible();
expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeDisabled();
expect(screen.getByText("Credentials present")).toBeVisible();
});
test("a failed saved-configuration test does not change backend credential presence", async () => {
server.use(
http.get("/api/pi-management/status", () => HttpResponse.json({ ...readyStatus, credentials: "present" })),
http.post("/api/pi-management/test", () => HttpResponse.json({
ready: false,
message: "Pi runtime is unavailable",
checkedAt: "2026-08-05T10:07:00.000Z",
})),
);
const user = userEvent.setup();
renderManagement();
await user.click(await screen.findByRole("button", { name: "Test saved defaults" }));
expect(await screen.findByText("Saved configuration test failed")).toBeVisible();
expect(screen.getByText("Credentials present")).toBeVisible();
expect(screen.queryByText("Credentials missing")).not.toBeInTheDocument();
});
+141 -53
View File
@@ -10,6 +10,7 @@ import {
savePiManagementConfig, savePiManagementConfig,
type PiInstallationConfig, type PiInstallationConfig,
type PiManagementOptions, type PiManagementOptions,
type PiManagementStatus,
} from "../api/pi-management"; } from "../api/pi-management";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
@@ -18,9 +19,9 @@ const UPDATE_COMMAND = "thothctl pi update";
const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-60"; const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-60";
type Feedback = { tone: "success" | "error"; message: string } | undefined; type Feedback = { tone: "success" | "error"; message: string } | undefined;
type CredentialState = "present" | "missing" | undefined; type SmokeState = "passed" | "failed" | undefined;
function configFrom(status: { config: Partial<PiInstallationConfig> }, options: PiManagementOptions): PiInstallationConfig | undefined { function suggestedConfig(status: { config: Partial<PiInstallationConfig> }, options: PiManagementOptions): PiInstallationConfig | undefined {
const provider = status.config.provider && options.providers.includes(status.config.provider) const provider = status.config.provider && options.providers.includes(status.config.provider)
? status.config.provider ? status.config.provider
: options.providers[0]; : options.providers[0];
@@ -33,27 +34,55 @@ function configFrom(status: { config: Partial<PiInstallationConfig> }, options:
return provider && model && reasoning ? { provider, model, reasoning } : undefined; return provider && model && reasoning ? { provider, model, reasoning } : undefined;
} }
function isSupportedConfig(
config: Partial<PiInstallationConfig> | undefined,
options: PiManagementOptions | undefined,
): config is PiInstallationConfig {
return Boolean(
config?.provider
&& config.model
&& config.reasoning
&& options?.providers.includes(config.provider)
&& options.models.some((model) => model.provider === config.provider && model.id === config.model)
&& options.reasoning.includes(config.reasoning),
);
}
function sameConfig(a: Partial<PiInstallationConfig> | undefined, b: PiInstallationConfig | undefined): boolean {
return Boolean(
a?.provider === b?.provider
&& a?.model === b?.model
&& a?.reasoning === b?.reasoning,
);
}
function errorMessage(error: unknown, fallback: string): string { function errorMessage(error: unknown, fallback: string): string {
return asPiManagementApiError(error)?.message ?? fallback; return asPiManagementApiError(error)?.message ?? fallback;
} }
function ReadinessRail({ ready, configured, credentialState }: { function ReadinessRail({ ready, configured, credentials, smokeState }: {
ready: boolean; ready: boolean;
configured: boolean; configured: boolean;
credentialState: CredentialState; credentials: "present" | "missing";
smokeState: SmokeState;
}) { }) {
return ( return (
<section <section
data-testid="pi-readiness-rail" data-testid="pi-readiness-rail"
aria-label="Pi readiness sequence" aria-label="Pi readiness sequence"
className="grid gap-3 border-l-2 border-border pl-4 sm:grid-cols-3" className="grid gap-3 border-l-2 border-border pl-4 sm:grid-cols-4"
> >
<RailItem label="Runtime" value={ready ? "Runtime ready" : "Runtime needs attention"} state={ready ? "ready" : "attention"} /> <RailItem label="Runtime" value={ready ? "Runtime ready" : "Runtime needs attention"} state={ready ? "ready" : "attention"} />
<RailItem label="Defaults" value={configured ? "Defaults ready" : "Defaults incomplete"} state={configured ? "ready" : "attention"} /> <RailItem label="Defaults" value={configured ? "Defaults ready" : "Defaults incomplete"} state={configured ? "ready" : "attention"} />
<RailItem <RailItem
label="Provider smoke" label="Credentials"
value={credentialState === "present" ? "Credentials present" : credentialState === "missing" ? "Credentials missing" : "Run smoke test"} value={`Credentials ${credentials}`}
state={credentialState === "missing" ? "attention" : credentialState === "present" ? "ready" : "idle"} state={credentials === "present" ? "ready" : "attention"}
/>
<RailItem
label="Saved-config test"
value={smokeState === "passed" ? "Saved configuration test passed" : smokeState === "failed" ? "Saved configuration test failed" : "Saved configuration test not run"}
state={smokeState === "passed" ? "ready" : smokeState === "failed" ? "attention" : "idle"}
/> />
</section> </section>
); );
@@ -78,7 +107,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [draft, setDraft] = useState<PiInstallationConfig>(); const [draft, setDraft] = useState<PiInstallationConfig>();
const [feedback, setFeedback] = useState<Feedback>(); const [feedback, setFeedback] = useState<Feedback>();
const [credentialState, setCredentialState] = useState<CredentialState>(); const [smokeState, setSmokeState] = useState<SmokeState>();
const [logsRequested, setLogsRequested] = useState(false); const [logsRequested, setLogsRequested] = useState(false);
const statusQuery = useQuery({ queryKey: ["pi-management", "status"], queryFn: getPiManagementStatus, enabled: open }); const statusQuery = useQuery({ queryKey: ["pi-management", "status"], queryFn: getPiManagementStatus, enabled: open });
const optionsQuery = useQuery({ queryKey: ["pi-management", "options"], queryFn: getPiManagementOptions, enabled: open }); const optionsQuery = useQuery({ queryKey: ["pi-management", "options"], queryFn: getPiManagementOptions, enabled: open });
@@ -87,44 +116,59 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
() => optionsQuery.data?.models.filter((model) => model.provider === draft?.provider) ?? [], () => optionsQuery.data?.models.filter((model) => model.provider === draft?.provider) ?? [],
[draft?.provider, optionsQuery.data?.models], [draft?.provider, optionsQuery.data?.models],
); );
const configured = Boolean(draft?.provider && draft.model && draft.reasoning); const initialDraft = useMemo(
const valid = Boolean( () => statusQuery.data && optionsQuery.data
draft ? suggestedConfig(statusQuery.data, optionsQuery.data)
&& optionsQuery.data?.providers.includes(draft.provider) : undefined,
&& optionsQuery.data.models.some((model) => model.provider === draft.provider && model.id === draft.model) [optionsQuery.data, statusQuery.data],
&& optionsQuery.data.reasoning.includes(draft.reasoning),
); );
const persistedReady = isSupportedConfig(statusQuery.data?.config, optionsQuery.data);
const validDraft = isSupportedConfig(draft, optionsQuery.data);
const dirty = Boolean(draft && !sameConfig(statusQuery.data?.config, draft));
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setDraft(undefined); setDraft(undefined);
setFeedback(undefined); setFeedback(undefined);
setCredentialState(undefined); setSmokeState(undefined);
setLogsRequested(false); setLogsRequested(false);
queryClient.removeQueries({ queryKey: ["pi-management"] }); queryClient.removeQueries({ queryKey: ["pi-management"] });
} }
}, [open, queryClient]); }, [open, queryClient]);
useEffect(() => { useEffect(() => {
if (draft || !statusQuery.data || !optionsQuery.data) return; if (draft || !initialDraft) return;
setDraft(configFrom(statusQuery.data, optionsQuery.data)); setDraft(initialDraft);
}, [draft, optionsQuery.data, statusQuery.data]); }, [draft, initialDraft]);
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: savePiManagementConfig, mutationFn: savePiManagementConfig,
onSuccess: () => { onSuccess: (saved) => {
const config = { provider: saved.provider, model: saved.model, reasoning: saved.reasoning };
queryClient.setQueryData<PiManagementStatus>(["pi-management", "status"], (current) => (
current ? { ...current, config } : current
));
setDraft(config);
setSmokeState(undefined);
setFeedback({ tone: "success", message: "Defaults saved." }); setFeedback({ tone: "success", message: "Defaults saved." });
void queryClient.invalidateQueries({ queryKey: ["pi-management", "status"] });
}, },
onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not save Pi defaults.") }), onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not save Pi defaults.") }),
}); });
const smokeMutation = useMutation({ const smokeMutation = useMutation({
mutationFn: runPiManagementTest, mutationFn: runPiManagementTest,
onSuccess: (result) => { onSuccess: (result) => {
setCredentialState(result.ready ? "present" : "missing"); setSmokeState(result.ready ? "passed" : "failed");
setFeedback({ tone: result.ready ? "success" : "error", message: result.message ?? (result.ready ? "Pi smoke test passed." : "Pi smoke test failed.") }); setFeedback({
tone: result.ready ? "success" : "error",
message: result.ready
? "Saved configuration test passed."
: `Saved configuration test failed.${result.message ? ` ${result.message}` : ""}`,
});
},
onError: (error) => {
setSmokeState("failed");
setFeedback({ tone: "error", message: errorMessage(error, "Could not test the saved Pi defaults.") });
}, },
onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not run the Pi smoke test.") }),
}); });
async function copyUpdateCommand() { async function copyUpdateCommand() {
@@ -138,15 +182,29 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
} }
function saveDefaults() { function saveDefaults() {
if (!draft || !valid) { if (!draft || !validDraft) {
setFeedback({ tone: "error", message: "Choose a supported provider, model, and reasoning level." }); setFeedback({ tone: "error", message: "Choose a supported provider, model, and reasoning level." });
return; return;
} }
saveMutation.mutate(draft); saveMutation.mutate(draft);
} }
function updateDraft(update: (current: PiInstallationConfig) => PiInstallationConfig) {
setDraft((current) => current ? update(current) : current);
setSmokeState(undefined);
setFeedback(undefined);
}
function testSavedDefaults() {
if (!persistedReady || dirty) {
setFeedback({ tone: "error", message: "Save supported defaults before running the test." });
return;
}
smokeMutation.mutate();
}
const forbidden = asPiManagementApiError(statusQuery.error)?.code === "pi_management_forbidden"; const forbidden = asPiManagementApiError(statusQuery.error)?.code === "pi_management_forbidden";
const loading = statusQuery.isLoading || optionsQuery.isLoading || !draft; const loading = statusQuery.isLoading || optionsQuery.isLoading || Boolean(!draft && initialDraft);
const unavailable = statusQuery.isError || optionsQuery.isError; const unavailable = statusQuery.isError || optionsQuery.isError;
return ( return (
@@ -155,7 +213,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
<DialogHeader className="border-b border-border/70 px-5 pb-4 pt-5 pr-12"> <DialogHeader className="border-b border-border/70 px-5 pb-4 pt-5 pr-12">
<p className="thot-label">Installation controls</p> <p className="thot-label">Installation controls</p>
<DialogTitle>Pi management</DialogTitle> <DialogTitle>Pi management</DialogTitle>
<DialogDescription>Review the bundled runtime, set safe defaults, and run a sanitized provider check.</DialogDescription> <DialogDescription>Review the bundled runtime, set safe defaults, and test the saved provider configuration.</DialogDescription>
</DialogHeader> </DialogHeader>
<Button aria-label="Close Pi management" className="absolute right-2 top-2" size="icon-sm" variant="ghost" onClick={onClose}><X /></Button> <Button aria-label="Close Pi management" className="absolute right-2 top-2" size="icon-sm" variant="ghost" onClick={onClose}><X /></Button>
<div className="max-h-[calc(100vh-11rem)] overflow-y-auto px-5 py-5"> <div className="max-h-[calc(100vh-11rem)] overflow-y-auto px-5 py-5">
@@ -169,7 +227,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
<p className="font-semibold">{errorMessage(statusQuery.error ?? optionsQuery.error, "Pi management is unavailable")}</p> <p className="font-semibold">{errorMessage(statusQuery.error ?? optionsQuery.error, "Pi management is unavailable")}</p>
<Button className="mt-3" size="sm" variant="outline" onClick={() => { void statusQuery.refetch(); void optionsQuery.refetch(); }}>Retry</Button> <Button className="mt-3" size="sm" variant="outline" onClick={() => { void statusQuery.refetch(); void optionsQuery.refetch(); }}>Retry</Button>
</div> </div>
) : loading ? <p className="text-sm text-muted-foreground">Loading Pi management…</p> : statusQuery.data && optionsQuery.data && draft && ( ) : loading ? <p className="text-sm text-muted-foreground">Loading Pi management…</p> : statusQuery.data && optionsQuery.data && (
<div className="grid gap-6"> <div className="grid gap-6">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
@@ -181,7 +239,12 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
</p> </p>
</div> </div>
<ReadinessRail ready={statusQuery.data.ready} configured={configured} credentialState={credentialState} /> <ReadinessRail
ready={statusQuery.data.ready}
configured={persistedReady}
credentials={statusQuery.data.credentials ?? "missing"}
smokeState={smokeState}
/>
{statusQuery.data.message && <p role="status" aria-live="polite" className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">{statusQuery.data.message}</p>} {statusQuery.data.message && <p role="status" aria-live="polite" className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">{statusQuery.data.message}</p>}
{feedback && <p role="status" aria-label="Pi management feedback" aria-live="polite" className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm ${feedback.tone === "error" ? "border-destructive/30 bg-destructive/5" : "border-border bg-muted/50"}`}> {feedback && <p role="status" aria-label="Pi management feedback" aria-live="polite" className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm ${feedback.tone === "error" ? "border-destructive/30 bg-destructive/5" : "border-border bg-muted/50"}`}>
@@ -193,30 +256,55 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
<div className="flex flex-wrap items-baseline justify-between gap-2"> <div className="flex flex-wrap items-baseline justify-between gap-2">
<div><h3 className="font-heading text-sm font-semibold">Installation defaults</h3><p className="mt-1 text-sm text-muted-foreground">These choices apply to new Pi work. Credentials remain outside this browser.</p></div> <div><h3 className="font-heading text-sm font-semibold">Installation defaults</h3><p className="mt-1 text-sm text-muted-foreground">These choices apply to new Pi work. Credentials remain outside this browser.</p></div>
</div> </div>
<div className="mt-4 grid gap-3 sm:grid-cols-3"> {draft ? (
<Field label="Provider"> <>
<select aria-label="Provider" className={fieldClass} value={draft.provider} onChange={(event) => { <div className="mt-4 grid gap-3 sm:grid-cols-3">
const provider = event.target.value; <Field label="Provider">
setDraft((current) => current ? { ...current, provider, model: optionsQuery.data.models.find((model) => model.provider === provider)?.id ?? "" } : current); <select aria-label="Provider" className={fieldClass} value={draft.provider} onChange={(event) => {
}}> const provider = event.target.value;
{optionsQuery.data.providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)} updateDraft((current) => ({
</select> ...current,
</Field> provider,
<Field label="Model"> model: optionsQuery.data.models.find((model) => model.provider === provider)?.id ?? "",
<select aria-label="Model" className={fieldClass} value={draft.model} onChange={(event) => setDraft((current) => current ? { ...current, model: event.target.value } : current)}> }));
{models.map((model) => <option key={model.id} value={model.id}>{model.id === "glm-5.2" ? "GLM 5.2" : model.id}</option>)} }}>
</select> {optionsQuery.data.providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
</Field> </select>
<Field label="Reasoning level"> </Field>
<select aria-label="Reasoning level" className={fieldClass} value={draft.reasoning} onChange={(event) => setDraft((current) => current ? { ...current, reasoning: event.target.value as PiInstallationConfig["reasoning"] } : current)}> <Field label="Model">
{optionsQuery.data.reasoning.map((reasoning) => <option key={reasoning} value={reasoning}>{reasoning}</option>)} <select aria-label="Model" className={fieldClass} value={draft.model} onChange={(event) => updateDraft((current) => ({ ...current, model: event.target.value }))}>
</select> {models.map((model) => <option key={model.id} value={model.id}>{model.id === "glm-5.2" ? "GLM 5.2" : model.id}</option>)}
</Field> </select>
</div> </Field>
<div className="mt-4 flex flex-wrap gap-2"> <Field label="Reasoning level">
<Button size="sm" variant="outline" disabled={saveMutation.isPending} onClick={saveDefaults}>{saveMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <ClipboardCheck />}Save defaults</Button> <select aria-label="Reasoning level" className={fieldClass} value={draft.reasoning} onChange={(event) => updateDraft((current) => ({ ...current, reasoning: event.target.value as PiInstallationConfig["reasoning"] }))}>
<Button size="sm" variant="outline" disabled={smokeMutation.isPending} onClick={() => smokeMutation.mutate()}>{smokeMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <FlaskConical />}Run smoke test</Button> {optionsQuery.data.reasoning.map((reasoning) => <option key={reasoning} value={reasoning}>{reasoning}</option>)}
</div> </select>
</Field>
</div>
{dirty && <p className="mt-3 text-sm text-amber-800 dark:text-amber-300">
{persistedReady ? "Changes are not saved yet." : "Suggested choices are not saved yet."}
</p>}
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={saveMutation.isPending || !validDraft || !dirty} onClick={saveDefaults}>{saveMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <ClipboardCheck />}Save defaults</Button>
<Button size="sm" variant="outline" disabled={smokeMutation.isPending || dirty || !persistedReady} onClick={testSavedDefaults}>{smokeMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <FlaskConical />}Test saved defaults</Button>
</div>
{dirty && <p className="mt-2 text-xs text-muted-foreground">Save these changes before testing. The test always uses saved defaults.</p>}
{!dirty && !persistedReady && <p className="mt-2 text-xs text-muted-foreground">Save supported defaults before testing. The test always uses saved defaults.</p>}
</>
) : (
<div className="mt-4">
<div role="alert" aria-label="Pi configuration incomplete" className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
<p className="font-semibold">No enabled provider and model choices are available.</p>
<p className="mt-1">Check the host-managed Pi model configuration, then retry this panel.</p>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled><ClipboardCheck />Save defaults</Button>
<Button size="sm" variant="outline" disabled><FlaskConical />Test saved defaults</Button>
<Button size="sm" variant="ghost" onClick={() => { void statusQuery.refetch(); void optionsQuery.refetch(); }}>Retry choices</Button>
</div>
</div>
)}
</section> </section>
<section aria-label="Sanitized diagnostics" className="border-t border-border/70 pt-5"> <section aria-label="Sanitized diagnostics" className="border-t border-border/70 pt-5">
@@ -23,7 +23,7 @@ const base: SessionSummary = {
}; };
beforeEach(() => { beforeEach(() => {
server.use(http.get("http://localhost:8787/sessions/s1/documents", () => server.use(http.get("/api/sessions/s1/documents", () =>
HttpResponse.json([ HttpResponse.json([
{ phase: "—", key: "question", title: "Original question", format: "text", content: "How many ablations?" }, { phase: "—", key: "question", title: "Original question", format: "text", content: "How many ablations?" },
{ phase: "F7", key: "sql", title: "Final SQL", format: "sql", content: "SELECT 1" }, { phase: "F7", key: "sql", title: "Final SQL", format: "sql", content: "SELECT 1" },
@@ -77,7 +77,7 @@ test("a malformed document renders a fallback without taking down its siblings",
// unmounts the tree); the good sibling document must still render. // unmounts the tree); the good sibling document must still render.
// resetHandlers(...) replaces the beforeEach handler so this response is used. // resetHandlers(...) replaces the beforeEach handler so this response is used.
server.resetHandlers( server.resetHandlers(
http.get("http://localhost:8787/sessions/s1/documents", () => http.get("/api/sessions/s1/documents", () =>
HttpResponse.json([ HttpResponse.json([
{ phase: "F4", key: "schema", title: "Schema linking", format: "schema-linking", content: '{"joins":[]}' }, { phase: "F4", key: "schema", title: "Schema linking", format: "schema-linking", content: '{"joins":[]}' },
{ phase: "—", key: "question", title: "Original question", format: "text", content: "SIBLING-SURVIVES" }, { phase: "—", key: "question", title: "Original question", format: "text", content: "SIBLING-SURVIVES" },
@@ -95,7 +95,7 @@ test("a malformed document renders a fallback without taking down its siblings",
test("renders the canonical summary order and formats human text as Markdown", async () => { test("renders the canonical summary order and formats human text as Markdown", async () => {
server.resetHandlers( server.resetHandlers(
http.get("http://localhost:8787/sessions/s1/documents", () => http.get("/api/sessions/s1/documents", () =>
HttpResponse.json([ HttpResponse.json([
{ phase: "—", key: "question", title: "Original question", format: "text", content: "Original **question**" }, { phase: "—", key: "question", title: "Original question", format: "text", content: "Original **question**" },
{ phase: "F7", key: "sql", title: "Final SQL", format: "sql", content: "SELECT 1" }, { phase: "F7", key: "sql", title: "Final SQL", format: "sql", content: "SELECT 1" },
@@ -129,7 +129,7 @@ test("renders the canonical summary order and formats human text as Markdown", a
test("renders one approved-then-declined memory list with Markdown details", async () => { test("renders one approved-then-declined memory list with Markdown details", async () => {
server.resetHandlers( server.resetHandlers(
http.get("http://localhost:8787/sessions/s1/documents", () => http.get("/api/sessions/s1/documents", () =>
HttpResponse.json([ HttpResponse.json([
{ {
phase: "F8", phase: "F8",
@@ -182,7 +182,7 @@ test("never renders technical approval, promotion, or memory decisions", async (
{ type: "column_excluded", subject: "fact_a.note", detail: "Non **pertinente**" }, { type: "column_excluded", subject: "fact_a.note", detail: "Non **pertinente**" },
].map((decision) => JSON.stringify(decision)).join("\n"); ].map((decision) => JSON.stringify(decision)).join("\n");
server.resetHandlers( server.resetHandlers(
http.get("http://localhost:8787/sessions/s1/documents", () => http.get("/api/sessions/s1/documents", () =>
HttpResponse.json([ HttpResponse.json([
{ phase: "—", key: "decisions", title: "Decisions", format: "decisions", content: lines }, { phase: "—", key: "decisions", title: "Decisions", format: "decisions", content: lines },
]), ]),
+46 -46
View File
@@ -10,7 +10,7 @@ import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput";
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/steer", () => http.post("/api/sessions/:id/steer", () =>
new HttpResponse(null, { status: 204 }), new HttpResponse(null, { status: 204 }),
), ),
); );
@@ -22,10 +22,10 @@ test("new sessions send the browser-selected workspace, model, provider, and thi
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high",
})); }));
server.use( server.use(
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ http.get("/api/workspaces", () => HttpResponse.json([{
id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical",
}])), }])),
http.post("http://localhost:8787/sessions", async ({ request }) => { http.post("/api/sessions", async ({ request }) => {
body = await request.json(); body = await request.json();
return HttpResponse.json({ id: "s1" }); return HttpResponse.json({ id: "s1" });
}), }),
@@ -49,7 +49,7 @@ test("renders a text input and submit button", () => {
test("submitting typed text POSTs to /steer and clears the input", async () => { test("submitting typed text POSTs to /steer and clears the input", async () => {
let captured: unknown = null; let captured: unknown = null;
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/steer", async ({ request, params }) => { http.post("/api/sessions/:id/steer", async ({ request, params }) => {
captured = { id: params.id, body: await request.json() }; captured = { id: params.id, body: await request.json() };
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
}), }),
@@ -67,7 +67,7 @@ test("submitting typed text POSTs to /steer and clears the input", async () => {
test("pressing Enter in the input submits the steer", async () => { test("pressing Enter in the input submits the steer", async () => {
let captured: unknown = null; let captured: unknown = null;
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/steer", async ({ request }) => { http.post("/api/sessions/:id/steer", async ({ request }) => {
captured = await request.json(); captured = await request.json();
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
}), }),
@@ -83,7 +83,7 @@ test("pressing Enter in the input submits the steer", async () => {
test("does not POST when input is empty", async () => { test("does not POST when input is empty", async () => {
let called = false; let called = false;
server.use( server.use(
http.post("http://localhost:8787/sessions/:id/steer", () => { http.post("/api/sessions/:id/steer", () => {
called = true; called = true;
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
}), }),
@@ -126,15 +126,15 @@ test("the context gauge uses green, yellow, and red at the requested thresholds"
test("footer shows cumulative k-token counters after workspace and context gauge after thinking", async () => { test("footer shows cumulative k-token counters after workspace and context gauge after thinking", async () => {
server.use( server.use(
http.get("http://localhost:8787/settings", () => HttpResponse.json({ http.get("/api/settings", () => HttpResponse.json({
workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium", workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium",
})), })),
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ name: "psd" }])), http.get("/api/workspaces", () => HttpResponse.json([{ name: "psd" }])),
http.get("http://localhost:8787/workspaces/psd", () => HttpResponse.json({ http.get("/api/workspaces/psd", () => HttpResponse.json({
workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } }, workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } },
revision: { id: "psd", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" }, revision: { id: "psd", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" },
})), })),
http.get("http://localhost:8787/models", () => HttpResponse.json({ http.get("/api/models", () => HttpResponse.json({
models: [{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }], models: [{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }],
})), })),
); );
@@ -162,18 +162,18 @@ test("footer shows cumulative k-token counters after workspace and context gauge
test("footer limits model choices to the selected workspace policy", async () => { test("footer limits model choices to the selected workspace policy", async () => {
server.use( server.use(
http.get("http://localhost:8787/settings", () => HttpResponse.json({ http.get("/api/settings", () => HttpResponse.json({
workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
})), })),
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ http.get("/api/workspaces", () => HttpResponse.json([{
id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical",
revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" }, revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" },
}])), }])),
http.get("http://localhost:8787/workspaces/psd-clinical", () => HttpResponse.json({ http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } }, workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } },
revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" }, revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" },
})), })),
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ http.get("/api/models", () => HttpResponse.json({ models: [
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true },
] })), ] })),
@@ -195,26 +195,26 @@ test("switching workspaces replaces an out-of-policy model before session creati
id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const, id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const,
}); });
server.use( server.use(
http.get("http://localhost:8787/settings", () => HttpResponse.json({ http.get("/api/settings", () => HttpResponse.json({
workspace: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", workspace: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
})), })),
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([ http.get("/api/workspaces", () => HttpResponse.json([
{ id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") }, { 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") }, { 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({ http.get("/api/workspaces/research", () => HttpResponse.json({
workspace: { workspace: { id: "research" }, llm_policy: { allowed: ["deepseek/deepseek-v4-pro"] } }, workspace: { workspace: { id: "research" }, llm_policy: { allowed: ["deepseek/deepseek-v4-pro"] } },
revision: revision("research"), revision: revision("research"),
})), })),
http.get("http://localhost:8787/workspaces/psd-clinical", () => HttpResponse.json({ http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
workspace: { workspace: { id: "psd-clinical" }, llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] } }, workspace: { workspace: { id: "psd-clinical" }, llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] } },
revision: revision("psd-clinical"), revision: revision("psd-clinical"),
})), })),
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ http.get("/api/models", () => HttpResponse.json({ models: [
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true },
] })), ] })),
http.post("http://localhost:8787/sessions", async ({ request }) => { http.post("/api/sessions", async ({ request }) => {
body = await request.json(); body = await request.json();
return HttpResponse.json({ id: "s1" }); return HttpResponse.json({ id: "s1" });
}), }),
@@ -248,15 +248,15 @@ test("immediate submit waits for a switched workspace policy before creating a s
id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const, id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const,
}); });
server.use( server.use(
http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "research" })), http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })),
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([ http.get("/api/workspaces", () => HttpResponse.json([
{ id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") }, { 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") }, { 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({ http.get("/api/workspaces/research", () => HttpResponse.json({
workspace: { llm_policy: { allowed: ["deepseek/deepseek-v4-pro"] } }, revision: revision("research"), workspace: { llm_policy: { allowed: ["deepseek/deepseek-v4-pro"] } }, revision: revision("research"),
})), })),
http.get("http://localhost:8787/workspaces/psd-clinical", async () => { http.get("/api/workspaces/psd-clinical", async () => {
policyRequestStarted = true; policyRequestStarted = true;
await policyMayFinish; await policyMayFinish;
return HttpResponse.json({ return HttpResponse.json({
@@ -264,11 +264,11 @@ test("immediate submit waits for a switched workspace policy before creating a s
revision: revision("psd-clinical"), revision: revision("psd-clinical"),
}); });
}), }),
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ http.get("/api/models", () => HttpResponse.json({ models: [
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true },
] })), ] })),
http.post("http://localhost:8787/sessions", async ({ request }) => { http.post("/api/sessions", async ({ request }) => {
body = await request.json(); body = await request.json();
return HttpResponse.json({ id: "s1" }); return HttpResponse.json({ id: "s1" });
}), }),
@@ -304,11 +304,11 @@ test("initial restored workspace waits for its delayed policy before creating a
id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const, id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const,
}); });
server.use( server.use(
http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "psd-clinical" })), http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })),
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([ http.get("/api/workspaces", () => HttpResponse.json([
{ id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", revision: revision("psd-clinical") }, { id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", revision: revision("psd-clinical") },
])), ])),
http.get("http://localhost:8787/workspaces/psd-clinical", async () => { http.get("/api/workspaces/psd-clinical", async () => {
policyRequestStarted = true; policyRequestStarted = true;
await policyMayFinish; await policyMayFinish;
return HttpResponse.json({ return HttpResponse.json({
@@ -316,11 +316,11 @@ test("initial restored workspace waits for its delayed policy before creating a
revision: revision("psd-clinical"), revision: revision("psd-clinical"),
}); });
}), }),
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ http.get("/api/models", () => HttpResponse.json({ models: [
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true },
] })), ] })),
http.post("http://localhost:8787/sessions", async ({ request }) => { http.post("/api/sessions", async ({ request }) => {
body = await request.json(); body = await request.json();
return HttpResponse.json({ id: "s1" }); return HttpResponse.json({ id: "s1" });
}), }),
@@ -350,18 +350,18 @@ test("initial submit waits for delayed workspace summaries before allowing a con
workspaceId: "legacy-workspace", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", workspaceId: "legacy-workspace", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
})); }));
server.use( server.use(
http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "legacy-workspace" })), http.get("/api/settings", () => HttpResponse.json({ workspace: "legacy-workspace" })),
http.get("http://localhost:8787/workspaces", async () => { http.get("/api/workspaces", async () => {
summaryRequestStarted = true; summaryRequestStarted = true;
await summariesMayFinish; await summariesMayFinish;
return HttpResponse.json([{ return HttpResponse.json([{
id: "legacy-workspace", name: "legacy-workspace", file: "legacy-workspace.yaml", displayName: "Legacy workspace", id: "legacy-workspace", name: "legacy-workspace", file: "legacy-workspace.yaml", displayName: "Legacy workspace",
}]); }]);
}), }),
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ http.get("/api/models", () => HttpResponse.json({ models: [
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true },
] })), ] })),
http.post("http://localhost:8787/sessions", async ({ request }) => { http.post("/api/sessions", async ({ request }) => {
body = await request.json(); body = await request.json();
return HttpResponse.json({ id: "s1" }); return HttpResponse.json({ id: "s1" });
}), }),
@@ -390,15 +390,15 @@ test("failed workspace summaries block creation and report a safe error", async
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
})); }));
server.use( server.use(
http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "psd-clinical" })), http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })),
http.get("http://localhost:8787/workspaces", () => { http.get("/api/workspaces", () => {
summaryRequestFailed = true; summaryRequestFailed = true;
return new HttpResponse(null, { status: 503 }); return new HttpResponse(null, { status: 503 });
}), }),
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ http.get("/api/models", () => HttpResponse.json({ models: [
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
] })), ] })),
http.post("http://localhost:8787/sessions", async ({ request }) => { http.post("/api/sessions", async ({ request }) => {
body = await request.json(); body = await request.json();
return HttpResponse.json({ id: "s1" }); return HttpResponse.json({ id: "s1" });
}), }),
@@ -433,21 +433,21 @@ test("submit follows a rapid workspace switch instead of waiting for an abandone
id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const, id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const,
}); });
server.use( server.use(
http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "research" })), http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })),
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([ http.get("/api/workspaces", () => HttpResponse.json([
{ id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") }, { id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") },
{ id: "workspace-b", name: "workspace-b", file: "workspace-b.yaml", displayName: "Workspace B", revision: revision("workspace-b") }, { id: "workspace-b", name: "workspace-b", file: "workspace-b.yaml", displayName: "Workspace B", revision: revision("workspace-b") },
{ id: "workspace-c", name: "workspace-c", file: "workspace-c.yaml", displayName: "Workspace C", revision: revision("workspace-c") }, { id: "workspace-c", name: "workspace-c", file: "workspace-c.yaml", displayName: "Workspace C", revision: revision("workspace-c") },
])), ])),
http.get("http://localhost:8787/workspaces/research", () => HttpResponse.json({ http.get("/api/workspaces/research", () => HttpResponse.json({
workspace: { llm_policy: { allowed: ["deepseek/deepseek-v4-pro"] } }, revision: revision("research"), workspace: { llm_policy: { allowed: ["deepseek/deepseek-v4-pro"] } }, revision: revision("research"),
})), })),
http.get("http://localhost:8787/workspaces/workspace-b", async () => { http.get("/api/workspaces/workspace-b", async () => {
bPolicyRequestStarted = true; bPolicyRequestStarted = true;
await new Promise(() => undefined); await new Promise(() => undefined);
return HttpResponse.json({}); return HttpResponse.json({});
}), }),
http.get("http://localhost:8787/workspaces/workspace-c", async () => { http.get("/api/workspaces/workspace-c", async () => {
cPolicyRequestStarted = true; cPolicyRequestStarted = true;
await cPolicyMayFinish; await cPolicyMayFinish;
return HttpResponse.json({ return HttpResponse.json({
@@ -455,11 +455,11 @@ test("submit follows a rapid workspace switch instead of waiting for an abandone
revision: revision("workspace-c"), revision: revision("workspace-c"),
}); });
}), }),
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [ http.get("/api/models", () => HttpResponse.json({ models: [
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true },
] })), ] })),
http.post("http://localhost:8787/sessions", async ({ request }) => { http.post("/api/sessions", async ({ request }) => {
body = await request.json(); body = await request.json();
return HttpResponse.json({ id: "s1" }); return HttpResponse.json({ id: "s1" });
}), }),
+18 -18
View File
@@ -24,14 +24,14 @@ function renderManager() {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
server.use( server.use(
http.get("http://localhost:8787/workspace-registry/status", () => http.get("/api/workspace-registry/status", () =>
HttpResponse.json({ branch: "main", head: "a".repeat(40), ahead: 0, behind: 0, degraded: false })), HttpResponse.json({ branch: "main", head: "a".repeat(40), ahead: 0, behind: 0, degraded: false })),
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ http.get("/api/workspaces", () => HttpResponse.json([{
id: "psd-clinical", name: "PSD Clinical", displayName: "PSD Clinical", description: "Clinical data", id: "psd-clinical", name: "PSD Clinical", displayName: "PSD Clinical", description: "Clinical data",
language: "en", file: "workspaces/psd-clinical.yaml", language: "en", file: "workspaces/psd-clinical.yaml",
revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "operational" }, revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "operational" },
}])), }])),
http.get("http://localhost:8787/workspaces/psd-clinical", () => HttpResponse.json({ http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
workspace, workspace,
revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "operational" }, revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "operational" },
})), })),
@@ -60,8 +60,8 @@ test("imports a bundle as a local draft and never publishes it automatically", a
const user = userEvent.setup(); const user = userEvent.setup();
const publishSpy = vi.fn(); const publishSpy = vi.fn();
server.use( server.use(
http.post("http://localhost:8787/workspaces/import", () => HttpResponse.json({ draft: { workspace, contract: {} } })), http.post("/api/workspaces/import", () => HttpResponse.json({ draft: { workspace, contract: {} } })),
http.post("http://localhost:8787/workspaces/publish", () => { http.post("/api/workspaces/publish", () => {
publishSpy(); publishSpy();
return HttpResponse.json({}); return HttpResponse.json({});
}), }),
@@ -87,9 +87,9 @@ test("pulls and exports only when the manager explicitly requests each action",
vi.stubGlobal("URL", DownloadUrl); vi.stubGlobal("URL", DownloadUrl);
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
server.use( server.use(
http.post("http://localhost:8787/workspace-registry/pull", () => HttpResponse.json({ branch: "main", head: "c".repeat(40), ahead: 0, behind: 0, degraded: false })), http.post("/api/workspace-registry/pull", () => HttpResponse.json({ branch: "main", head: "c".repeat(40), ahead: 0, behind: 0, degraded: false })),
http.get("http://localhost:8787/workspaces/psd-clinical/export", () => new HttpResponse(new Blob(["bundle"], { type: "application/zip" }))), http.get("/api/workspaces/psd-clinical/export", () => new HttpResponse(new Blob(["bundle"], { type: "application/zip" }))),
http.post("http://localhost:8787/workspaces/publish", () => { http.post("/api/workspaces/publish", () => {
publishSpy(); publishSpy();
return HttpResponse.json({}); return HttpResponse.json({});
}), }),
@@ -109,7 +109,7 @@ test("pulls and exports only when the manager explicitly requests each action",
test("stages duplicate and delete operations without publishing", async () => { test("stages duplicate and delete operations without publishing", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let published = false; let published = false;
server.use(http.post("http://localhost:8787/workspaces/publish", () => { server.use(http.post("/api/workspaces/publish", () => {
published = true; published = true;
return HttpResponse.json({}); return HttpResponse.json({});
})); }));
@@ -131,8 +131,8 @@ test("saves resolved conflict choices as a rebased browser draft without publish
const local = { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "local-model" } } }; const local = { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "local-model" } } };
const remote = { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "remote-model" } } }; const remote = { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "remote-model" } } };
server.use( server.use(
http.post("http://localhost:8787/workspaces/validate", () => HttpResponse.json({ workspace: local, contract: {} })), http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: local, contract: {} })),
http.post("http://localhost:8787/workspaces/publish", () => { http.post("/api/workspaces/publish", () => {
publishCalls += 1; publishCalls += 1;
return HttpResponse.json({ return HttpResponse.json({
code: "workspace_conflict", message: "Workspace changed in the registry.", fields: ["semantic_index.embedding.model"], code: "workspace_conflict", message: "Workspace changed in the registry.", fields: ["semantic_index.embedding.model"],
@@ -162,11 +162,11 @@ test("proposes a different valid ID when duplicating a 63-character workspace ID
const maxId = `w${"a".repeat(62)}`; const maxId = `w${"a".repeat(62)}`;
const maxWorkspace = { ...workspace, workspace: { ...workspace.workspace, id: maxId } }; const maxWorkspace = { ...workspace, workspace: { ...workspace.workspace, id: maxId } };
server.use( server.use(
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ http.get("/api/workspaces", () => HttpResponse.json([{
id: maxId, name: "Maximum", displayName: "Maximum", language: "en", file: `workspaces/${maxId}.yaml`, id: maxId, name: "Maximum", displayName: "Maximum", language: "en", file: `workspaces/${maxId}.yaml`,
revision: { id: maxId, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/maximum", state: "operational" }, revision: { id: maxId, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/maximum", state: "operational" },
}])), }])),
http.get(`http://localhost:8787/workspaces/${maxId}`, () => HttpResponse.json({ http.get(`/api/workspaces/${maxId}`, () => HttpResponse.json({
workspace: maxWorkspace, workspace: maxWorkspace,
revision: { id: maxId, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/maximum", state: "operational" }, revision: { id: maxId, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/maximum", state: "operational" },
})), })),
@@ -197,8 +197,8 @@ test("does not show a saved-draft toast when manager validation rejects a DWH ti
test("runs validation and installation test with only sanitized messages", async () => { test("runs validation and installation test with only sanitized messages", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( server.use(
http.post("http://localhost:8787/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })), http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })),
http.post("http://localhost:8787/workspaces/psd-clinical/test", () => HttpResponse.json({ http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({
activatable: false, activatable: false,
diagnostics: [{ level: "warning", code: "binding_missing", field: "dwh", message: "DWH binding is not configured" }], diagnostics: [{ level: "warning", code: "binding_missing", field: "dwh", message: "DWH binding is not configured" }],
})), })),
@@ -216,7 +216,7 @@ test("runs validation and installation test with only sanitized messages", async
test("shows an accessible retry instead of a loading status when the registry status query fails", async () => { test("shows an accessible retry instead of a loading status when the registry status query fails", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let calls = 0; let calls = 0;
server.use(http.get("http://localhost:8787/workspace-registry/status", () => { server.use(http.get("/api/workspace-registry/status", () => {
calls += 1; calls += 1;
return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false }); return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false });
})); }));
@@ -231,7 +231,7 @@ test("shows an accessible retry instead of a loading status when the registry st
test("shows an accessible retry instead of an empty list when the workspace list query fails", async () => { test("shows an accessible retry instead of an empty list when the workspace list query fails", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let calls = 0; let calls = 0;
server.use(http.get("http://localhost:8787/workspaces", () => { server.use(http.get("/api/workspaces", () => {
calls += 1; calls += 1;
return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json([]); return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json([]);
})); }));
@@ -247,7 +247,7 @@ test("shows an accessible retry instead of an empty list when the workspace list
test("shows an accessible retry when the selected workspace detail query fails", async () => { test("shows an accessible retry when the selected workspace detail query fails", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let calls = 0; let calls = 0;
server.use(http.get("http://localhost:8787/workspaces/psd-clinical", () => { server.use(http.get("/api/workspaces/psd-clinical", () => {
calls += 1; calls += 1;
return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({ return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({
workspace, workspace,
@@ -43,14 +43,14 @@ const diagnosticsBranchConflict: WorkspaceConflict = {
}; };
beforeEach(() => { beforeEach(() => {
server.use(http.post("http://localhost:8787/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} }))); server.use(http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })));
}); });
test("validates a draft and requires a separate confirmation before publishing", async () => { test("validates a draft and requires a separate confirmation before publishing", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const published = vi.fn(); const published = vi.fn();
let publishCalls = 0; let publishCalls = 0;
server.use(http.post("http://localhost:8787/workspaces/publish", () => { server.use(http.post("/api/workspaces/publish", () => {
publishCalls += 1; publishCalls += 1;
return HttpResponse.json({ revision: { id: "psd-clinical", commit: "c".repeat(40), blob: "d".repeat(40), snapshotPath: "/safe", state: "operational" } }); return HttpResponse.json({ revision: { id: "psd-clinical", commit: "c".repeat(40), blob: "d".repeat(40), snapshotPath: "/safe", state: "operational" } });
})); }));
@@ -71,7 +71,7 @@ test("validates a draft and requires a separate confirmation before publishing",
test("shows a field-level conflict and never overwrites the remote workspace", async () => { test("shows a field-level conflict and never overwrites the remote workspace", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let published = false; let published = false;
server.use(http.post("http://localhost:8787/workspaces/publish", () => { server.use(http.post("/api/workspaces/publish", () => {
published = true; published = true;
return HttpResponse.json({ ...conflict, message: "Workspace changed in the registry." }, { status: 409 }); return HttpResponse.json({ ...conflict, message: "Workspace changed in the registry." }, { status: 409 });
})); }));
@@ -94,7 +94,7 @@ test("saves explicit local choices as a rebased draft and does not republish it"
const user = userEvent.setup(); const user = userEvent.setup();
const saved = vi.fn(); const saved = vi.fn();
let publishCalls = 0; let publishCalls = 0;
server.use(http.post("http://localhost:8787/workspaces/publish", () => { server.use(http.post("/api/workspaces/publish", () => {
publishCalls += 1; publishCalls += 1;
return HttpResponse.json({ ...conflict, message: "Workspace changed in the registry." }, { status: 409 }); return HttpResponse.json({ ...conflict, message: "Workspace changed in the registry." }, { status: 409 });
})); }));
@@ -116,7 +116,7 @@ test("saves explicit local choices as a rebased draft and does not republish it"
test("rebases a selected optional diagnostics branch into the revised draft", async () => { test("rebases a selected optional diagnostics branch into the revised draft", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const saved = vi.fn(); const saved = vi.fn();
server.use(http.post("http://localhost:8787/workspaces/publish", () => HttpResponse.json({ server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
...diagnosticsBranchConflict, message: "Workspace changed in the registry.", ...diagnosticsBranchConflict, message: "Workspace changed in the registry.",
}, { status: 409 }))); }, { status: 409 })));
render(<WorkspacePublishDialog open request={request} onOpenChange={vi.fn()} onPublished={vi.fn()} onResolved={saved} onPull={vi.fn()} onReload={vi.fn()} />); render(<WorkspacePublishDialog open request={request} onOpenChange={vi.fn()} onPublished={vi.fn()} onResolved={saved} onPull={vi.fn()} onReload={vi.fn()} />);
@@ -135,7 +135,7 @@ test("rebases a selected optional diagnostics branch into the revised draft", as
test("keeps a conflict open and redacts a failed registry pull", async () => { test("keeps a conflict open and redacts a failed registry pull", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use(http.post("http://localhost:8787/workspaces/publish", () => HttpResponse.json({ server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
...conflict, message: "Workspace changed in the registry.", ...conflict, message: "Workspace changed in the registry.",
}, { status: 409 }))); }, { status: 409 })));
render(<WorkspacePublishDialog open request={request} onOpenChange={vi.fn()} onPublished={vi.fn()} onResolved={vi.fn()} onPull={vi.fn().mockRejectedValue(new Error("token=secret"))} onReload={vi.fn()} />); render(<WorkspacePublishDialog open request={request} onOpenChange={vi.fn()} onPublished={vi.fn()} onResolved={vi.fn()} onPull={vi.fn().mockRejectedValue(new Error("token=secret"))} onReload={vi.fn()} />);
+6 -6
View File
@@ -18,16 +18,16 @@ beforeEach(() => {
test("F1: create session -> widget via SSE -> respond -> POST /response", async () => { test("F1: create session -> widget via SSE -> respond -> POST /response", async () => {
let responded: any = null; let responded: any = null;
server.use( server.use(
http.post("http://localhost:8787/sessions", () => HttpResponse.json({ id: "s1" })), http.post("/api/sessions", () => HttpResponse.json({ id: "s1" })),
http.get("http://localhost:8787/sessions", () => HttpResponse.json([])), http.get("/api/sessions", () => HttpResponse.json([])),
http.get("http://localhost:8787/settings", () => HttpResponse.json({ http.get("/api/settings", () => HttpResponse.json({
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low", workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
})), })),
http.get("http://localhost:8787/workspaces", () => http.get("/api/workspaces", () =>
HttpResponse.json([{ name: "default", file: "default.db" }]), HttpResponse.json([{ name: "default", file: "default.db" }]),
), ),
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [] })), http.get("/api/models", () => HttpResponse.json({ models: [] })),
http.post("http://localhost:8787/sessions/s1/response", async ({ request }) => { http.post("/api/sessions/s1/response", async ({ request }) => {
responded = await request.json(); responded = await request.json();
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
}), }),
@@ -56,7 +56,7 @@ test("flushes pending stream text before a structural event", () => {
test("opens an EventSource and feeds NAMED events to the store", () => { test("opens an EventSource and feeds NAMED events to the store", () => {
renderHook(() => useSessionStream("s1")); renderHook(() => useSessionStream("s1"));
const es = FakeEventSource.instances[0]; const es = FakeEventSource.instances[0];
expect(es.url).toBe("http://localhost:8787/sessions/s1/events"); expect(es.url).toBe("/api/sessions/s1/events");
// Backend sends `event: ui_request` (named) — drive the addEventListener path // Backend sends `event: ui_request` (named) — drive the addEventListener path
// that production relies on, not the unnamed onmessage fallback. // that production relies on, not the unnamed onmessage fallback.
act(() => act(() =>
@@ -146,7 +146,7 @@ test("a same-session generation reconnect includes the last consumed SSE id", ()
expect(first.closed).toBe(true); expect(first.closed).toBe(true);
expect(FakeEventSource.instances).toHaveLength(2); expect(FakeEventSource.instances).toHaveLength(2);
expect(FakeEventSource.instances[1].url).toBe( expect(FakeEventSource.instances[1].url).toBe(
"http://localhost:8787/sessions/s1/events?lastEventId=7", "/api/sessions/s1/events?lastEventId=7",
); );
act(() => act(() =>
@@ -176,7 +176,7 @@ test("a same-session reset epoch drops a high cursor and accepts fresh low-id ev
expect(first.closed).toBe(true); expect(first.closed).toBe(true);
expect(FakeEventSource.instances).toHaveLength(2); expect(FakeEventSource.instances).toHaveLength(2);
expect(FakeEventSource.instances[1].url).toBe( expect(FakeEventSource.instances[1].url).toBe(
"http://localhost:8787/sessions/s1/events", "/api/sessions/s1/events",
); );
act(() => act(() =>
@@ -199,7 +199,7 @@ test("a reset epoch also accepts an in-process preserved high id and resumes fro
rerender({ generation: 0, resetEpoch: 1 }); rerender({ generation: 0, resetEpoch: 1 });
const replacement = FakeEventSource.instances[1]; const replacement = FakeEventSource.instances[1];
expect(replacement.url).toBe("http://localhost:8787/sessions/s1/events"); expect(replacement.url).toBe("/api/sessions/s1/events");
act(() => act(() =>
replacement.emitNamed( replacement.emitNamed(
"ui_request", "ui_request",
@@ -211,7 +211,7 @@ test("a reset epoch also accepts an in-process preserved high id and resumes fro
expect(useSessionStore.getState().pendingWidget?.id).toBe("preserved-high-gate"); expect(useSessionStore.getState().pendingWidget?.id).toBe("preserved-high-gate");
rerender({ generation: 1, resetEpoch: 1 }); rerender({ generation: 1, resetEpoch: 1 });
expect(FakeEventSource.instances[2].url).toBe( expect(FakeEventSource.instances[2].url).toBe(
"http://localhost:8787/sessions/s1/events?lastEventId=902", "/api/sessions/s1/events?lastEventId=902",
); );
}); });
@@ -226,7 +226,7 @@ test("changing the session resets the manual reconnect cursor", () => {
rerender({ sessionId: "s2" }); rerender({ sessionId: "s2" });
expect(first.closed).toBe(true); expect(first.closed).toBe(true);
expect(FakeEventSource.instances[1].url).toBe("http://localhost:8787/sessions/s2/events"); expect(FakeEventSource.instances[1].url).toBe("/api/sessions/s2/events");
}); });
test("a queued event from a replaced source cannot mutate the new session or its cursor", () => { test("a queued event from a replaced source cannot mutate the new session or its cursor", () => {
@@ -239,7 +239,7 @@ test("a queued event from a replaced source cannot mutate the new session or its
rerender({ sessionId: "s2", generation: 0 }); rerender({ sessionId: "s2", generation: 0 });
expect(first.closed).toBe(true); expect(first.closed).toBe(true);
expect(FakeEventSource.instances[1].url).toBe("http://localhost:8787/sessions/s2/events"); expect(FakeEventSource.instances[1].url).toBe("/api/sessions/s2/events");
useSessionStore.getState().resetSession(); useSessionStore.getState().resetSession();
act(() => act(() =>
@@ -252,7 +252,7 @@ test("a queued event from a replaced source cannot mutate the new session or its
expect(useSessionStore.getState().transcript).toEqual([]); expect(useSessionStore.getState().transcript).toEqual([]);
rerender({ sessionId: "s2", generation: 1 }); rerender({ sessionId: "s2", generation: 1 });
expect(FakeEventSource.instances[2].url).toBe("http://localhost:8787/sessions/s2/events"); expect(FakeEventSource.instances[2].url).toBe("/api/sessions/s2/events");
}); });
test("the old source is invalid before later layout effects can deliver a queued event", () => { test("the old source is invalid before later layout effects can deliver a queued event", () => {
+7
View File
@@ -0,0 +1,7 @@
import { checkDwhHealth } from "../api/sessions";
// Catches shared browser test fixtures drifting from the same-origin /api contract and
// attempting real localhost network requests instead of using MSW.
test("the shared MSW health fixture follows the browser same-origin API contract", async () => {
await expect(checkDwhHealth()).resolves.toEqual({ ok: true, detail: "ok" });
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { setupServer } from "msw/node"; import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
export const server = setupServer( export const server = setupServer(
http.get("http://localhost:8787/health/dwh", () => HttpResponse.json({ ok: true, detail: "ok" })), http.get("/api/health/dwh", () => HttpResponse.json({ ok: true, detail: "ok" })),
); );
+1 -1
View File
@@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "../test/msw"; import { server } from "../test/msw";
import { ResultsPanel } from "./ResultsPanel"; import { ResultsPanel } from "./ResultsPanel";
const BASE = "http://localhost:8787"; const BASE = "/api";
function makeClient() { function makeClient() {
return new QueryClient({ return new QueryClient({