fix: harden Pi management readiness
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { execFile as nodeExecFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { secretValue } from "../config/secret-bundle.js";
|
||||
import {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
@@ -12,6 +13,11 @@ import {
|
||||
isPiManagedConfigError,
|
||||
} from "./managed-config.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 REASONING_CHOICES = ["low", "medium", "high"] as const;
|
||||
@@ -31,6 +37,7 @@ export interface PiInstallationConfig {
|
||||
export interface PiStatus {
|
||||
version?: string;
|
||||
ready: boolean;
|
||||
credentials: PiCredentialStatus;
|
||||
config: PiInstallationConfig;
|
||||
checkedAt: string;
|
||||
message?: string;
|
||||
@@ -89,6 +96,7 @@ interface PiManagementDeps {
|
||||
readSettings?: () => Settings;
|
||||
saveSettings?: (settings: Settings) => Settings;
|
||||
readLogs?: () => string | Promise<string>;
|
||||
credentialStatus?: (provider: string | undefined) => PiCredentialStatus;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
@@ -104,6 +112,18 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
|
||||
const persistSettings = deps.saveSettings ?? ((settings) => saveSettings(config, settings));
|
||||
const readLogs = deps.readLogs ?? (() => diagnostics.join("\n"));
|
||||
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">> => {
|
||||
let listed: PiModel[];
|
||||
@@ -168,14 +188,15 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
|
||||
async status(): Promise<PiStatus> {
|
||||
const checkedAt = now().toISOString();
|
||||
const current = installationConfig();
|
||||
const credentials = credentialStatus(current.provider);
|
||||
try {
|
||||
const currentVersion = await version();
|
||||
addDiagnostic("Pi version probe succeeded");
|
||||
return { version: currentVersion, ready: true, config: current, checkedAt };
|
||||
return { version: currentVersion, ready: true, credentials, config: current, checkedAt };
|
||||
} catch (error) {
|
||||
const message = stableMessage(error, "Pi runtime is unavailable");
|
||||
addDiagnostic(message);
|
||||
return { ready: false, config: current, checkedAt, message };
|
||||
return { ready: false, credentials, config: current, checkedAt, message };
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ export function canonicalPiProvider(provider: string | undefined): string | unde
|
||||
return value;
|
||||
}
|
||||
|
||||
export type PiCredentialStatus = "present" | "missing";
|
||||
|
||||
export interface CredentialFsOps {
|
||||
lstat(path: string): Stats;
|
||||
open(path: string, flags: number): number;
|
||||
@@ -172,3 +174,22 @@ export function buildPiChildEnv(opts: {
|
||||
}
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,14 @@ test("status parses only a Pi version from a fixed execFile argument array", asy
|
||||
execute: successfulExec(calls),
|
||||
listModels: async () => supportedModels,
|
||||
readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }),
|
||||
credentialStatus: () => "missing",
|
||||
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(service.status()).resolves.toEqual({
|
||||
version: "0.80.3",
|
||||
ready: true,
|
||||
credentials: "missing",
|
||||
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
||||
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);
|
||||
});
|
||||
|
||||
// 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
|
||||
// Pi did not explicitly enable for this installation.
|
||||
test("options expose only closed provider, model, and reasoning choices", async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PI_0803_CREDENTIAL_ENV_NAMES,
|
||||
buildPiChildEnv,
|
||||
canonicalPiProvider,
|
||||
piProviderCredentialStatus,
|
||||
} from "../src/pi/provider-credentials.js";
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const env = buildPiChildEnv({
|
||||
ambient: {
|
||||
|
||||
@@ -10,6 +10,7 @@ function fakeService(): PiManagementService {
|
||||
return {
|
||||
status: vi.fn(async () => ({
|
||||
version: "0.80.3", ready: true,
|
||||
credentials: "present",
|
||||
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
||||
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.json()).toEqual({
|
||||
version: "0.80.3", ready: true,
|
||||
credentials: "present",
|
||||
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { apiFetch } from "./client";
|
||||
test("body-less POST omits content-type (avoids Fastify empty-body 400)", async () => {
|
||||
let contentType: string | null = "unset";
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/s1/resume", ({ request }) => {
|
||||
http.post("/api/sessions/s1/resume", ({ request }) => {
|
||||
contentType = request.headers.get("content-type");
|
||||
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 () => {
|
||||
let contentType: string | null = null;
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/s1/steer", ({ request }) => {
|
||||
http.post("/api/sessions/s1/steer", ({ request }) => {
|
||||
contentType = request.headers.get("content-type");
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
|
||||
@@ -14,7 +14,7 @@ test("Pi management client calls only the sanctioned sanitized endpoints", async
|
||||
server.use(
|
||||
http.get("/api/pi-management/status", ({ request }) => {
|
||||
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 }) => {
|
||||
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(savePiManagementConfig({ provider: "zai", model: "glm-5.2", reasoning: "high" })).resolves.toMatchObject({ reasoning: "high" });
|
||||
await expect(runPiManagementTest()).resolves.toMatchObject({ ready: true });
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface PiInstallationConfig {
|
||||
export interface PiManagementStatus {
|
||||
version?: string;
|
||||
ready: boolean;
|
||||
credentials: "present" | "missing";
|
||||
config: Partial<PiInstallationConfig>;
|
||||
checkedAt: string;
|
||||
message?: string;
|
||||
|
||||
@@ -10,13 +10,13 @@ test("createSession migrates legacy selections and POSTs browser preferences", a
|
||||
localStorage.clear();
|
||||
let body: unknown = null;
|
||||
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",
|
||||
})),
|
||||
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",
|
||||
}])),
|
||||
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||
http.post("/api/sessions", async ({ request }) => {
|
||||
body = await request.json();
|
||||
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) => {
|
||||
let called = false;
|
||||
server.use(
|
||||
http.post("http://localhost:8787/runtime/prewarm", () => {
|
||||
http.post("/api/runtime/prewarm", () => {
|
||||
called = true;
|
||||
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 () => {
|
||||
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");
|
||||
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 () => {
|
||||
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");
|
||||
return HttpResponse.json([]);
|
||||
}));
|
||||
@@ -64,7 +64,7 @@ test("listSessions requests the selected administrator scope", 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 }),
|
||||
));
|
||||
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 () => {
|
||||
server.use(http.post("http://localhost:8787/sessions/s1/resume", () =>
|
||||
server.use(http.post("/api/sessions/s1/resume", () =>
|
||||
HttpResponse.json({ id: "s1", alreadyActive: false })));
|
||||
|
||||
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 () => {
|
||||
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();
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}));
|
||||
@@ -92,7 +92,7 @@ test("renameSession POSTs {name}", async () => {
|
||||
|
||||
test("setSessionGroup POSTs {group}", async () => {
|
||||
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();
|
||||
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 () => {
|
||||
const hits: string[] = [];
|
||||
server.use(
|
||||
http.post("http://localhost:8787/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.delete("http://localhost:8787/sessions/s1", () => { hits.push("delete"); return new HttpResponse(null, { status: 204 }); }),
|
||||
http.post("/api/sessions/s1/archive", () => { hits.push("archive"); return new HttpResponse(null, { status: 204 }); }),
|
||||
http.post("/api/sessions/s1/unarchive", () => { hits.push("unarchive"); return new HttpResponse(null, { status: 204 }); }),
|
||||
http.delete("/api/sessions/s1", () => { hits.push("delete"); return new HttpResponse(null, { status: 204 }); }),
|
||||
);
|
||||
await archiveSession("s1");
|
||||
await unarchiveSession("s1");
|
||||
@@ -114,7 +114,7 @@ test("archive / unarchive / delete hit the right verbs+paths", 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" }]),
|
||||
));
|
||||
const docs = await getSessionDocuments("s1");
|
||||
|
||||
@@ -15,7 +15,7 @@ const workspace: CanonicalWorkspace = {
|
||||
|
||||
test("uploads a workspace bundle without JSON content type", async () => {
|
||||
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");
|
||||
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 () => {
|
||||
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"],
|
||||
base: { ...workspace, dwh: { ...workspace.dwh, password: "secret" } }, local: workspace, remote: workspace,
|
||||
}, { status: 409 })));
|
||||
@@ -73,7 +73,7 @@ const diagnosticsWorkspace: CanonicalWorkspace = {
|
||||
};
|
||||
|
||||
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],
|
||||
expected: { commit: "a".repeat(40), blob: "b".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" } },
|
||||
},
|
||||
};
|
||||
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],
|
||||
expected: { commit: "a".repeat(40), blob: "b".repeat(40) },
|
||||
|
||||
@@ -18,19 +18,19 @@ beforeEach(() => {
|
||||
(globalThis as any).EventSource = FakeEventSource;
|
||||
useSessionStore.getState().resetSession();
|
||||
server.use(
|
||||
http.get("http://localhost:8787/me", () => HttpResponse.json({ issuer: "test", subject: "test", displayName: "Test", isAdmin: false })),
|
||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([])),
|
||||
http.get("http://localhost:8787/settings", () =>
|
||||
http.get("/api/me", () => HttpResponse.json({ issuer: "test", subject: "test", displayName: "Test", isAdmin: false })),
|
||||
http.get("/api/sessions", () => HttpResponse.json([])),
|
||||
http.get("/api/settings", () =>
|
||||
HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
|
||||
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([])),
|
||||
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [] })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([])),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
||||
);
|
||||
});
|
||||
|
||||
test("New session starts prewarm without delaying composer focus", async () => {
|
||||
let prewarmStarted = false;
|
||||
server.use(
|
||||
http.post("http://localhost:8787/runtime/prewarm", async () => {
|
||||
http.post("/api/runtime/prewarm", async () => {
|
||||
prewarmStarted = true;
|
||||
await delay(100);
|
||||
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;
|
||||
const createMayFinish = new Promise<void>((resolve) => { releaseCreate = resolve; });
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions", async () => {
|
||||
http.post("/api/sessions", async () => {
|
||||
await createMayFinish;
|
||||
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 () => {
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions", () =>
|
||||
http.post("/api/sessions", () =>
|
||||
new HttpResponse("unavailable", { status: 503 })),
|
||||
);
|
||||
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 () => {
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions", () =>
|
||||
http.post("/api/sessions", () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
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 () => {
|
||||
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",
|
||||
})),
|
||||
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: "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 },
|
||||
@@ -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 () => {
|
||||
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 })),
|
||||
);
|
||||
renderShell();
|
||||
|
||||
@@ -56,20 +56,20 @@ beforeEach(() => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() });
|
||||
useSessionStore.getState().resetSession();
|
||||
server.use(
|
||||
http.get("http://localhost:8787/me", () =>
|
||||
http.get("/api/me", () =>
|
||||
HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false }),
|
||||
),
|
||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)),
|
||||
http.get("http://localhost:8787/sessions/:id/documents", () => HttpResponse.json([
|
||||
http.get("/api/sessions", () => HttpResponse.json(LIST)),
|
||||
http.get("/api/sessions/:id/documents", () => HttpResponse.json([
|
||||
{ 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 () => {
|
||||
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");
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
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({
|
||||
version: "0.80.3", ready: true,
|
||||
credentials: "present",
|
||||
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
||||
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" }],
|
||||
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();
|
||||
|
||||
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" }));
|
||||
expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
|
||||
const shell = document.querySelector('[data-testid="app-shell"]');
|
||||
expect(shell).toHaveTextContent("Workspace management");
|
||||
expect(shell).toHaveTextContent("Active sessions");
|
||||
const hiddenSummary = document.querySelector<HTMLElement>('aside[aria-label="Session summary"]');
|
||||
expect(hiddenSummary).toHaveAttribute("aria-hidden", "true");
|
||||
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 () => {
|
||||
let scope = "";
|
||||
server.use(
|
||||
http.get("http://localhost:8787/me", () =>
|
||||
http.get("/api/me", () =>
|
||||
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") ?? "";
|
||||
return HttpResponse.json([
|
||||
{ ...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 () => {
|
||||
let deletes = 0;
|
||||
server.use(
|
||||
http.get("http://localhost:8787/me", () =>
|
||||
http.get("/api/me", () =>
|
||||
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[1], id: "s3", question: "Second session", archived: false, author: "Bob" },
|
||||
])),
|
||||
http.delete("http://localhost:8787/sessions/:id", () => {
|
||||
http.delete("/api/sessions/:id", () => {
|
||||
deletes += 1;
|
||||
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;
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
server.use(
|
||||
http.get("http://localhost:8787/me", () =>
|
||||
http.get("/api/me", () =>
|
||||
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" },
|
||||
])),
|
||||
http.post("http://localhost:8787/sessions/:id/archive", () => {
|
||||
http.post("/api/sessions/:id/archive", () => {
|
||||
archives += 1;
|
||||
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 () => {
|
||||
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: "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 () => {
|
||||
let resumed: string | null = null;
|
||||
server.use(
|
||||
http.get("http://localhost:8787/sessions", () =>
|
||||
http.get("/api/sessions", () =>
|
||||
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;
|
||||
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 })),
|
||||
);
|
||||
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 () => {
|
||||
server.use(http.post("http://localhost:8787/runtime/prewarm", () =>
|
||||
server.use(http.post("/api/runtime/prewarm", () =>
|
||||
HttpResponse.json({ status: "warming" }, { status: 202 })));
|
||||
wrap();
|
||||
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" }],
|
||||
});
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
|
||||
http.post("/api/sessions/:id/resume", ({ params }) => {
|
||||
resumed = params.id as string;
|
||||
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 () => {
|
||||
useSessionStore.getState().resetSession();
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")),
|
||||
http.get("http://localhost:8787/sessions/:id", () =>
|
||||
http.post("/api/sessions/:id/resume", () => resumeResult("s1")),
|
||||
http.get("/api/sessions/:id", () =>
|
||||
HttpResponse.json({ id: "s1", status: "open", phase: 4 })),
|
||||
);
|
||||
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 () => {
|
||||
let resumeCalls = 0;
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/resume", () => {
|
||||
http.post("/api/sessions/:id/resume", () => {
|
||||
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 })),
|
||||
);
|
||||
wrap();
|
||||
@@ -379,14 +391,14 @@ test("concurrent same-id Resume invocations share one cold request and replaceme
|
||||
const coldGate = deferred();
|
||||
const coldStarted = deferred();
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
||||
http.post("/api/sessions/:id/resume", async () => {
|
||||
resumeCalls += 1;
|
||||
if (resumeCalls === 1) return resumeResult("s1", false);
|
||||
if (resumeCalls === 2) coldStarted.resolve();
|
||||
await coldGate.promise;
|
||||
return resumeResult("s1", false);
|
||||
}),
|
||||
http.get("http://localhost:8787/sessions/:id", () =>
|
||||
http.get("/api/sessions/:id", () =>
|
||||
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
||||
);
|
||||
wrap();
|
||||
@@ -408,7 +420,7 @@ test("concurrent same-id Resume invocations share one cold request and replaceme
|
||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
|
||||
const replacement = FakeEventSource.instances[1];
|
||||
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([
|
||||
{ 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 firstS1ManifestStarted = deferred();
|
||||
server.use(
|
||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
|
||||
http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||
http.post("/api/sessions/:id/resume", ({ params }) => {
|
||||
const id = params.id as string;
|
||||
if (id === "s1") s1ResumeCalls += 1;
|
||||
return resumeResult(id);
|
||||
}),
|
||||
http.get("http://localhost:8787/sessions/:id", async ({ params }) => {
|
||||
http.get("/api/sessions/:id", async ({ params }) => {
|
||||
if (params.id === "s1") {
|
||||
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 () => {
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/resume", () =>
|
||||
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 })),
|
||||
);
|
||||
wrap();
|
||||
@@ -492,7 +504,7 @@ test("cold same-session Resume keeps the old stream until success then receives
|
||||
let markStarted!: () => void;
|
||||
const resumeStarted = new Promise<void>((resolve) => { markStarted = 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();
|
||||
await resumeReleased;
|
||||
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));
|
||||
expect(first.closed).toBe(true);
|
||||
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().activityLog).toEqual([
|
||||
{ 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 () => {
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/resume", () =>
|
||||
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 })),
|
||||
);
|
||||
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"));
|
||||
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 })));
|
||||
await userEvent.click(screen.getByTestId("session-item-s1"));
|
||||
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",
|
||||
};
|
||||
server.use(
|
||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) =>
|
||||
http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||
http.post("/api/sessions/:id/resume", ({ params }) =>
|
||||
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 })),
|
||||
);
|
||||
wrap();
|
||||
@@ -617,8 +629,8 @@ test("competing Resume requests for different ids commit only the latest intent"
|
||||
const s1Started = deferred();
|
||||
const s3Started = deferred();
|
||||
server.use(
|
||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||
http.post("http://localhost:8787/sessions/:id/resume", async ({ params }) => {
|
||||
http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||
http.post("/api/sessions/:id/resume", async ({ params }) => {
|
||||
const id = params.id as string;
|
||||
if (id === "s1") {
|
||||
s1Started.resolve();
|
||||
@@ -629,7 +641,7 @@ test("competing Resume requests for different ids commit only the latest intent"
|
||||
}
|
||||
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 })),
|
||||
);
|
||||
wrap();
|
||||
@@ -667,10 +679,10 @@ test("a stale Resume manifest cannot repaint the latest session phase", async ()
|
||||
const s1ManifestGate = deferred();
|
||||
const s1ManifestStarted = deferred();
|
||||
server.use(
|
||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) =>
|
||||
http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||
http.post("/api/sessions/:id/resume", ({ params }) =>
|
||||
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") {
|
||||
s1ManifestStarted.resolve();
|
||||
await s1ManifestGate.promise;
|
||||
@@ -700,12 +712,12 @@ test("starting a new question invalidates a pending Resume intent", async () =>
|
||||
const resumeGate = deferred();
|
||||
const resumeStarted = deferred();
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
||||
http.post("/api/sessions/:id/resume", async () => {
|
||||
resumeStarted.resolve();
|
||||
await resumeGate.promise;
|
||||
return resumeResult("s1");
|
||||
}),
|
||||
http.post("http://localhost:8787/runtime/prewarm", () =>
|
||||
http.post("/api/runtime/prewarm", () =>
|
||||
HttpResponse.json({ status: "warming" }, { status: 202 })),
|
||||
);
|
||||
wrap();
|
||||
@@ -728,12 +740,12 @@ test("a successful Delete invalidates an earlier pending Resume for the same tar
|
||||
const resumeStarted = deferred();
|
||||
const deleteCompleted = deferred();
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
||||
http.post("/api/sessions/:id/resume", async () => {
|
||||
resumeStarted.resolve();
|
||||
await resumeGate.promise;
|
||||
return resumeResult("s1");
|
||||
}),
|
||||
http.delete("http://localhost:8787/sessions/:id", ({ params }) => {
|
||||
http.delete("/api/sessions/:id", ({ params }) => {
|
||||
expect(params.id).toBe("s1");
|
||||
deleteCompleted.resolve();
|
||||
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 deleteStarted = deferred();
|
||||
server.use(
|
||||
http.delete("http://localhost:8787/sessions/:id", async ({ params }) => {
|
||||
http.delete("/api/sessions/:id", async ({ params }) => {
|
||||
expect(params.id).toBe("s1");
|
||||
deleteStarted.resolve();
|
||||
await deleteGate.promise;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")),
|
||||
http.get("http://localhost:8787/sessions/:id", () =>
|
||||
http.post("/api/sessions/:id/resume", () => resumeResult("s1")),
|
||||
http.get("/api/sessions/:id", () =>
|
||||
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
||||
);
|
||||
wrap();
|
||||
@@ -794,12 +806,12 @@ test("deleting another session does not invalidate a pending Resume", async () =
|
||||
const resumeStarted = deferred();
|
||||
const deleteCompleted = deferred();
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
||||
http.post("/api/sessions/:id/resume", async () => {
|
||||
resumeStarted.resolve();
|
||||
await resumeGate.promise;
|
||||
return resumeResult("s1");
|
||||
}),
|
||||
http.delete("http://localhost:8787/sessions/:id", ({ params }) => {
|
||||
http.delete("/api/sessions/:id", ({ params }) => {
|
||||
expect(params.id).toBe("s2");
|
||||
deleteCompleted.resolve();
|
||||
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 s3ResumeStarted = deferred();
|
||||
server.use(
|
||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||
http.post("http://localhost:8787/sessions/:id/resume", async ({ params }) => {
|
||||
http.get("/api/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||
http.post("/api/sessions/:id/resume", async ({ params }) => {
|
||||
const id = params.id as string;
|
||||
if (id === "s3") {
|
||||
s3ResumeStarted.resolve();
|
||||
@@ -840,9 +852,9 @@ test("deleting active A preserves a pending Resume for different session B", asy
|
||||
}
|
||||
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 })),
|
||||
http.delete("http://localhost:8787/sessions/:id", ({ params }) => {
|
||||
http.delete("/api/sessions/:id", ({ params }) => {
|
||||
expect(params.id).toBe("s1");
|
||||
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 deleteFailed = deferred();
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
||||
http.post("/api/sessions/:id/resume", async () => {
|
||||
resumeStarted.resolve();
|
||||
await resumeGate.promise;
|
||||
return resumeResult("s1");
|
||||
}),
|
||||
http.delete("http://localhost:8787/sessions/:id", ({ params }) => {
|
||||
http.delete("/api/sessions/:id", ({ params }) => {
|
||||
expect(params.id).toBe("s1");
|
||||
deleteFailed.resolve();
|
||||
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 () => {
|
||||
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({
|
||||
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 () => {
|
||||
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.findByRole("button", { name: /resume/i }));
|
||||
act(() => {
|
||||
@@ -971,8 +983,8 @@ test("session finalization shows the completion banner and returns to landing",
|
||||
useSessionStore.getState().resetSession();
|
||||
let finalized = false;
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")),
|
||||
http.get("http://localhost:8787/sessions", () =>
|
||||
http.post("/api/sessions/:id/resume", () => resumeResult("s1")),
|
||||
http.get("/api/sessions", () =>
|
||||
HttpResponse.json(finalized ? [{ ...LIST[0], status: "finalized" }] : LIST)),
|
||||
);
|
||||
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 () => {
|
||||
const groupSets: Array<{ id: string; group: string }> = [];
|
||||
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 };
|
||||
groupSets.push({ id: params.id as string, group: body.group });
|
||||
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 () => {
|
||||
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")));
|
||||
server.use(http.post("/api/sessions/:id/resume", () => resumeResult("s1")));
|
||||
wrap();
|
||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||
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 () => {
|
||||
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();
|
||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||
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 () => {
|
||||
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();
|
||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||
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 () => {
|
||||
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();
|
||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||
|
||||
@@ -31,13 +31,13 @@ test("submitting includes browser-local migrated preferences and calls onCreated
|
||||
localStorage.clear();
|
||||
let body: unknown = null;
|
||||
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",
|
||||
})),
|
||||
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{
|
||||
http.get("/api/workspaces", () => HttpResponse.json([{
|
||||
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();
|
||||
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",
|
||||
}));
|
||||
server.use(
|
||||
http.get("http://localhost:8787/workspaces", () => {
|
||||
http.get("/api/workspaces", () => {
|
||||
summaryRequestStarted = true;
|
||||
return HttpResponse.json([{
|
||||
id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical",
|
||||
revision,
|
||||
}]);
|
||||
}),
|
||||
http.get("http://localhost:8787/workspaces/psd-clinical", async () => {
|
||||
http.get("/api/workspaces/psd-clinical", async () => {
|
||||
policyRequestStarted = true;
|
||||
await policyMayFinish;
|
||||
return HttpResponse.json({
|
||||
workspace: { llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] } }, revision,
|
||||
});
|
||||
}),
|
||||
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||
http.post("/api/sessions", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id: "s1" });
|
||||
}),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PiManagement } from "./PiManagement";
|
||||
const readyStatus = {
|
||||
version: "0.80.3",
|
||||
ready: true,
|
||||
credentials: "present",
|
||||
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
||||
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.getByText("Pi 0.80.3")).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 () => {
|
||||
@@ -80,7 +81,7 @@ test("saves only a selected non-secret configuration", async () => {
|
||||
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();
|
||||
let tests = 0;
|
||||
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();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Run smoke test" }));
|
||||
expect(await screen.findByText("Credentials present")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Run smoke test" }));
|
||||
expect(await screen.findByText("Credentials missing")).toBeVisible();
|
||||
expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Pi provider smoke check failed");
|
||||
const testButton = await screen.findByRole("button", { name: "Test saved defaults" });
|
||||
expect(screen.getByText("Defaults ready")).toBeVisible();
|
||||
expect(screen.queryByText("Changes are not saved yet.")).not.toBeInTheDocument();
|
||||
expect(testButton).toBeEnabled();
|
||||
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 () => {
|
||||
@@ -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(screen.queryByLabelText("Provider")).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 () => {
|
||||
@@ -168,3 +175,101 @@ test("reloads installation defaults when the panel is reopened", async () => {
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
savePiManagementConfig,
|
||||
type PiInstallationConfig,
|
||||
type PiManagementOptions,
|
||||
type PiManagementStatus,
|
||||
} from "../api/pi-management";
|
||||
import { Button } from "../components/ui/button";
|
||||
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";
|
||||
|
||||
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)
|
||||
? status.config.provider
|
||||
: options.providers[0];
|
||||
@@ -33,27 +34,55 @@ function configFrom(status: { config: Partial<PiInstallationConfig> }, options:
|
||||
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 {
|
||||
return asPiManagementApiError(error)?.message ?? fallback;
|
||||
}
|
||||
|
||||
function ReadinessRail({ ready, configured, credentialState }: {
|
||||
function ReadinessRail({ ready, configured, credentials, smokeState }: {
|
||||
ready: boolean;
|
||||
configured: boolean;
|
||||
credentialState: CredentialState;
|
||||
credentials: "present" | "missing";
|
||||
smokeState: SmokeState;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
data-testid="pi-readiness-rail"
|
||||
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="Defaults" value={configured ? "Defaults ready" : "Defaults incomplete"} state={configured ? "ready" : "attention"} />
|
||||
<RailItem
|
||||
label="Provider smoke"
|
||||
value={credentialState === "present" ? "Credentials present" : credentialState === "missing" ? "Credentials missing" : "Run smoke test"}
|
||||
state={credentialState === "missing" ? "attention" : credentialState === "present" ? "ready" : "idle"}
|
||||
label="Credentials"
|
||||
value={`Credentials ${credentials}`}
|
||||
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>
|
||||
);
|
||||
@@ -78,7 +107,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
const queryClient = useQueryClient();
|
||||
const [draft, setDraft] = useState<PiInstallationConfig>();
|
||||
const [feedback, setFeedback] = useState<Feedback>();
|
||||
const [credentialState, setCredentialState] = useState<CredentialState>();
|
||||
const [smokeState, setSmokeState] = useState<SmokeState>();
|
||||
const [logsRequested, setLogsRequested] = useState(false);
|
||||
const statusQuery = useQuery({ queryKey: ["pi-management", "status"], queryFn: getPiManagementStatus, 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) ?? [],
|
||||
[draft?.provider, optionsQuery.data?.models],
|
||||
);
|
||||
const configured = Boolean(draft?.provider && draft.model && draft.reasoning);
|
||||
const valid = Boolean(
|
||||
draft
|
||||
&& optionsQuery.data?.providers.includes(draft.provider)
|
||||
&& optionsQuery.data.models.some((model) => model.provider === draft.provider && model.id === draft.model)
|
||||
&& optionsQuery.data.reasoning.includes(draft.reasoning),
|
||||
const initialDraft = useMemo(
|
||||
() => statusQuery.data && optionsQuery.data
|
||||
? suggestedConfig(statusQuery.data, optionsQuery.data)
|
||||
: undefined,
|
||||
[optionsQuery.data, statusQuery.data],
|
||||
);
|
||||
const persistedReady = isSupportedConfig(statusQuery.data?.config, optionsQuery.data);
|
||||
const validDraft = isSupportedConfig(draft, optionsQuery.data);
|
||||
const dirty = Boolean(draft && !sameConfig(statusQuery.data?.config, draft));
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDraft(undefined);
|
||||
setFeedback(undefined);
|
||||
setCredentialState(undefined);
|
||||
setSmokeState(undefined);
|
||||
setLogsRequested(false);
|
||||
queryClient.removeQueries({ queryKey: ["pi-management"] });
|
||||
}
|
||||
}, [open, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (draft || !statusQuery.data || !optionsQuery.data) return;
|
||||
setDraft(configFrom(statusQuery.data, optionsQuery.data));
|
||||
}, [draft, optionsQuery.data, statusQuery.data]);
|
||||
if (draft || !initialDraft) return;
|
||||
setDraft(initialDraft);
|
||||
}, [draft, initialDraft]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
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." });
|
||||
void queryClient.invalidateQueries({ queryKey: ["pi-management", "status"] });
|
||||
},
|
||||
onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not save Pi defaults.") }),
|
||||
});
|
||||
const smokeMutation = useMutation({
|
||||
mutationFn: runPiManagementTest,
|
||||
onSuccess: (result) => {
|
||||
setCredentialState(result.ready ? "present" : "missing");
|
||||
setFeedback({ tone: result.ready ? "success" : "error", message: result.message ?? (result.ready ? "Pi smoke test passed." : "Pi smoke test failed.") });
|
||||
setSmokeState(result.ready ? "passed" : "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() {
|
||||
@@ -138,15 +182,29 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
}
|
||||
|
||||
function saveDefaults() {
|
||||
if (!draft || !valid) {
|
||||
if (!draft || !validDraft) {
|
||||
setFeedback({ tone: "error", message: "Choose a supported provider, model, and reasoning level." });
|
||||
return;
|
||||
}
|
||||
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 loading = statusQuery.isLoading || optionsQuery.isLoading || !draft;
|
||||
const loading = statusQuery.isLoading || optionsQuery.isLoading || Boolean(!draft && initialDraft);
|
||||
const unavailable = statusQuery.isError || optionsQuery.isError;
|
||||
|
||||
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">
|
||||
<p className="thot-label">Installation controls</p>
|
||||
<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>
|
||||
<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">
|
||||
@@ -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>
|
||||
<Button className="mt-3" size="sm" variant="outline" onClick={() => { void statusQuery.refetch(); void optionsQuery.refetch(); }}>Retry</Button>
|
||||
</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="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
@@ -181,7 +239,12 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
</p>
|
||||
</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>}
|
||||
{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><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 className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<Field label="Provider">
|
||||
<select aria-label="Provider" className={fieldClass} value={draft.provider} onChange={(event) => {
|
||||
const provider = event.target.value;
|
||||
setDraft((current) => current ? { ...current, provider, model: optionsQuery.data.models.find((model) => model.provider === provider)?.id ?? "" } : current);
|
||||
}}>
|
||||
{optionsQuery.data.providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Model">
|
||||
<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>
|
||||
</Field>
|
||||
<Field label="Reasoning level">
|
||||
<select aria-label="Reasoning level" className={fieldClass} value={draft.reasoning} onChange={(event) => setDraft((current) => current ? { ...current, reasoning: event.target.value as PiInstallationConfig["reasoning"] } : current)}>
|
||||
{optionsQuery.data.reasoning.map((reasoning) => <option key={reasoning} value={reasoning}>{reasoning}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={saveMutation.isPending} onClick={saveDefaults}>{saveMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <ClipboardCheck />}Save defaults</Button>
|
||||
<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>
|
||||
</div>
|
||||
{draft ? (
|
||||
<>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<Field label="Provider">
|
||||
<select aria-label="Provider" className={fieldClass} value={draft.provider} onChange={(event) => {
|
||||
const provider = event.target.value;
|
||||
updateDraft((current) => ({
|
||||
...current,
|
||||
provider,
|
||||
model: optionsQuery.data.models.find((model) => model.provider === provider)?.id ?? "",
|
||||
}));
|
||||
}}>
|
||||
{optionsQuery.data.providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Model">
|
||||
<select aria-label="Model" className={fieldClass} value={draft.model} onChange={(event) => updateDraft((current) => ({ ...current, model: event.target.value }))}>
|
||||
{models.map((model) => <option key={model.id} value={model.id}>{model.id === "glm-5.2" ? "GLM 5.2" : model.id}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Reasoning level">
|
||||
<select aria-label="Reasoning level" className={fieldClass} value={draft.reasoning} onChange={(event) => updateDraft((current) => ({ ...current, reasoning: event.target.value as PiInstallationConfig["reasoning"] }))}>
|
||||
{optionsQuery.data.reasoning.map((reasoning) => <option key={reasoning} value={reasoning}>{reasoning}</option>)}
|
||||
</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 aria-label="Sanitized diagnostics" className="border-t border-border/70 pt-5">
|
||||
|
||||
@@ -23,7 +23,7 @@ const base: SessionSummary = {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(http.get("http://localhost:8787/sessions/s1/documents", () =>
|
||||
server.use(http.get("/api/sessions/s1/documents", () =>
|
||||
HttpResponse.json([
|
||||
{ phase: "—", key: "question", title: "Original question", format: "text", content: "How many ablations?" },
|
||||
{ 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.
|
||||
// resetHandlers(...) replaces the beforeEach handler so this response is used.
|
||||
server.resetHandlers(
|
||||
http.get("http://localhost:8787/sessions/s1/documents", () =>
|
||||
http.get("/api/sessions/s1/documents", () =>
|
||||
HttpResponse.json([
|
||||
{ phase: "F4", key: "schema", title: "Schema linking", format: "schema-linking", content: '{"joins":[]}' },
|
||||
{ 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 () => {
|
||||
server.resetHandlers(
|
||||
http.get("http://localhost:8787/sessions/s1/documents", () =>
|
||||
http.get("/api/sessions/s1/documents", () =>
|
||||
HttpResponse.json([
|
||||
{ phase: "—", key: "question", title: "Original question", format: "text", content: "Original **question**" },
|
||||
{ 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 () => {
|
||||
server.resetHandlers(
|
||||
http.get("http://localhost:8787/sessions/s1/documents", () =>
|
||||
http.get("/api/sessions/s1/documents", () =>
|
||||
HttpResponse.json([
|
||||
{
|
||||
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**" },
|
||||
].map((decision) => JSON.stringify(decision)).join("\n");
|
||||
server.resetHandlers(
|
||||
http.get("http://localhost:8787/sessions/s1/documents", () =>
|
||||
http.get("/api/sessions/s1/documents", () =>
|
||||
HttpResponse.json([
|
||||
{ phase: "—", key: "decisions", title: "Decisions", format: "decisions", content: lines },
|
||||
]),
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput";
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/steer", () =>
|
||||
http.post("/api/sessions/:id/steer", () =>
|
||||
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",
|
||||
}));
|
||||
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",
|
||||
}])),
|
||||
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||
http.post("/api/sessions", async ({ request }) => {
|
||||
body = await request.json();
|
||||
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 () => {
|
||||
let captured: unknown = null;
|
||||
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() };
|
||||
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 () => {
|
||||
let captured: unknown = null;
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/steer", async ({ request }) => {
|
||||
http.post("/api/sessions/:id/steer", async ({ request }) => {
|
||||
captured = await request.json();
|
||||
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 () => {
|
||||
let called = false;
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/steer", () => {
|
||||
http.post("/api/sessions/:id/steer", () => {
|
||||
called = true;
|
||||
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 () => {
|
||||
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",
|
||||
})),
|
||||
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ name: "psd" }])),
|
||||
http.get("http://localhost:8787/workspaces/psd", () => HttpResponse.json({
|
||||
http.get("/api/workspaces", () => HttpResponse.json([{ name: "psd" }])),
|
||||
http.get("/api/workspaces/psd", () => HttpResponse.json({
|
||||
workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } },
|
||||
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 }],
|
||||
})),
|
||||
);
|
||||
@@ -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 () => {
|
||||
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",
|
||||
})),
|
||||
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: { 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"] } },
|
||||
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: "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,
|
||||
});
|
||||
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",
|
||||
})),
|
||||
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: "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"] } },
|
||||
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"] } },
|
||||
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: "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();
|
||||
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,
|
||||
});
|
||||
server.use(
|
||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "research" })),
|
||||
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([
|
||||
{ id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") },
|
||||
{ id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", revision: revision("psd-clinical") },
|
||||
])),
|
||||
http.get("http://localhost:8787/workspaces/research", () => HttpResponse.json({
|
||||
http.get("/api/workspaces/research", () => HttpResponse.json({
|
||||
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;
|
||||
await policyMayFinish;
|
||||
return HttpResponse.json({
|
||||
@@ -264,11 +264,11 @@ test("immediate submit waits for a switched workspace policy before creating a s
|
||||
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: "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();
|
||||
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,
|
||||
});
|
||||
server.use(
|
||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "psd-clinical" })),
|
||||
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([
|
||||
{ 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;
|
||||
await policyMayFinish;
|
||||
return HttpResponse.json({
|
||||
@@ -316,11 +316,11 @@ test("initial restored workspace waits for its delayed policy before creating a
|
||||
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: "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();
|
||||
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",
|
||||
}));
|
||||
server.use(
|
||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "legacy-workspace" })),
|
||||
http.get("http://localhost:8787/workspaces", async () => {
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "legacy-workspace" })),
|
||||
http.get("/api/workspaces", async () => {
|
||||
summaryRequestStarted = true;
|
||||
await summariesMayFinish;
|
||||
return HttpResponse.json([{
|
||||
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 },
|
||||
] })),
|
||||
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||
http.post("/api/sessions", async ({ request }) => {
|
||||
body = await request.json();
|
||||
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",
|
||||
}));
|
||||
server.use(
|
||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "psd-clinical" })),
|
||||
http.get("http://localhost:8787/workspaces", () => {
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })),
|
||||
http.get("/api/workspaces", () => {
|
||||
summaryRequestFailed = true;
|
||||
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 },
|
||||
] })),
|
||||
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||
http.post("/api/sessions", async ({ request }) => {
|
||||
body = await request.json();
|
||||
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,
|
||||
});
|
||||
server.use(
|
||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({ workspace: "research" })),
|
||||
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([
|
||||
{ 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-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"),
|
||||
})),
|
||||
http.get("http://localhost:8787/workspaces/workspace-b", async () => {
|
||||
http.get("/api/workspaces/workspace-b", async () => {
|
||||
bPolicyRequestStarted = true;
|
||||
await new Promise(() => undefined);
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.get("http://localhost:8787/workspaces/workspace-c", async () => {
|
||||
http.get("/api/workspaces/workspace-c", async () => {
|
||||
cPolicyRequestStarted = true;
|
||||
await cPolicyMayFinish;
|
||||
return HttpResponse.json({
|
||||
@@ -455,11 +455,11 @@ test("submit follows a rapid workspace switch instead of waiting for an abandone
|
||||
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: "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();
|
||||
return HttpResponse.json({ id: "s1" });
|
||||
}),
|
||||
|
||||
@@ -24,14 +24,14 @@ function renderManager() {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
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 })),
|
||||
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",
|
||||
language: "en", file: "workspaces/psd-clinical.yaml",
|
||||
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,
|
||||
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 publishSpy = vi.fn();
|
||||
server.use(
|
||||
http.post("http://localhost:8787/workspaces/import", () => HttpResponse.json({ draft: { workspace, contract: {} } })),
|
||||
http.post("http://localhost:8787/workspaces/publish", () => {
|
||||
http.post("/api/workspaces/import", () => HttpResponse.json({ draft: { workspace, contract: {} } })),
|
||||
http.post("/api/workspaces/publish", () => {
|
||||
publishSpy();
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
@@ -87,9 +87,9 @@ test("pulls and exports only when the manager explicitly requests each action",
|
||||
vi.stubGlobal("URL", DownloadUrl);
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
|
||||
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.get("http://localhost:8787/workspaces/psd-clinical/export", () => new HttpResponse(new Blob(["bundle"], { type: "application/zip" }))),
|
||||
http.post("http://localhost:8787/workspaces/publish", () => {
|
||||
http.post("/api/workspace-registry/pull", () => HttpResponse.json({ branch: "main", head: "c".repeat(40), ahead: 0, behind: 0, degraded: false })),
|
||||
http.get("/api/workspaces/psd-clinical/export", () => new HttpResponse(new Blob(["bundle"], { type: "application/zip" }))),
|
||||
http.post("/api/workspaces/publish", () => {
|
||||
publishSpy();
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
let published = false;
|
||||
server.use(http.post("http://localhost:8787/workspaces/publish", () => {
|
||||
server.use(http.post("/api/workspaces/publish", () => {
|
||||
published = true;
|
||||
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 remote = { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "remote-model" } } };
|
||||
server.use(
|
||||
http.post("http://localhost:8787/workspaces/validate", () => HttpResponse.json({ workspace: local, contract: {} })),
|
||||
http.post("http://localhost:8787/workspaces/publish", () => {
|
||||
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: local, contract: {} })),
|
||||
http.post("/api/workspaces/publish", () => {
|
||||
publishCalls += 1;
|
||||
return HttpResponse.json({
|
||||
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 maxWorkspace = { ...workspace, workspace: { ...workspace.workspace, id: maxId } };
|
||||
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`,
|
||||
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,
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post("http://localhost:8787/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })),
|
||||
http.post("http://localhost:8787/workspaces/psd-clinical/test", () => HttpResponse.json({
|
||||
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })),
|
||||
http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({
|
||||
activatable: false,
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
let calls = 0;
|
||||
server.use(http.get("http://localhost:8787/workspace-registry/status", () => {
|
||||
server.use(http.get("/api/workspace-registry/status", () => {
|
||||
calls += 1;
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
let calls = 0;
|
||||
server.use(http.get("http://localhost:8787/workspaces", () => {
|
||||
server.use(http.get("/api/workspaces", () => {
|
||||
calls += 1;
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
let calls = 0;
|
||||
server.use(http.get("http://localhost:8787/workspaces/psd-clinical", () => {
|
||||
server.use(http.get("/api/workspaces/psd-clinical", () => {
|
||||
calls += 1;
|
||||
return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({
|
||||
workspace,
|
||||
|
||||
@@ -43,14 +43,14 @@ const diagnosticsBranchConflict: WorkspaceConflict = {
|
||||
};
|
||||
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const published = vi.fn();
|
||||
let publishCalls = 0;
|
||||
server.use(http.post("http://localhost:8787/workspaces/publish", () => {
|
||||
server.use(http.post("/api/workspaces/publish", () => {
|
||||
publishCalls += 1;
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
let published = false;
|
||||
server.use(http.post("http://localhost:8787/workspaces/publish", () => {
|
||||
server.use(http.post("/api/workspaces/publish", () => {
|
||||
published = true;
|
||||
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 saved = vi.fn();
|
||||
let publishCalls = 0;
|
||||
server.use(http.post("http://localhost:8787/workspaces/publish", () => {
|
||||
server.use(http.post("/api/workspaces/publish", () => {
|
||||
publishCalls += 1;
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
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.",
|
||||
}, { status: 409 })));
|
||||
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 () => {
|
||||
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.",
|
||||
}, { 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()} />);
|
||||
|
||||
@@ -18,16 +18,16 @@ beforeEach(() => {
|
||||
test("F1: create session -> widget via SSE -> respond -> POST /response", async () => {
|
||||
let responded: any = null;
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions", () => HttpResponse.json({ id: "s1" })),
|
||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([])),
|
||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
||||
http.post("/api/sessions", () => HttpResponse.json({ id: "s1" })),
|
||||
http.get("/api/sessions", () => HttpResponse.json([])),
|
||||
http.get("/api/settings", () => HttpResponse.json({
|
||||
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" }]),
|
||||
),
|
||||
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [] })),
|
||||
http.post("http://localhost:8787/sessions/s1/response", async ({ request }) => {
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
||||
http.post("/api/sessions/s1/response", async ({ request }) => {
|
||||
responded = await request.json();
|
||||
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", () => {
|
||||
renderHook(() => useSessionStream("s1"));
|
||||
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
|
||||
// that production relies on, not the unnamed onmessage fallback.
|
||||
act(() =>
|
||||
@@ -146,7 +146,7 @@ test("a same-session generation reconnect includes the last consumed SSE id", ()
|
||||
expect(first.closed).toBe(true);
|
||||
expect(FakeEventSource.instances).toHaveLength(2);
|
||||
expect(FakeEventSource.instances[1].url).toBe(
|
||||
"http://localhost:8787/sessions/s1/events?lastEventId=7",
|
||||
"/api/sessions/s1/events?lastEventId=7",
|
||||
);
|
||||
|
||||
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(FakeEventSource.instances).toHaveLength(2);
|
||||
expect(FakeEventSource.instances[1].url).toBe(
|
||||
"http://localhost:8787/sessions/s1/events",
|
||||
"/api/sessions/s1/events",
|
||||
);
|
||||
|
||||
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 });
|
||||
const replacement = FakeEventSource.instances[1];
|
||||
expect(replacement.url).toBe("http://localhost:8787/sessions/s1/events");
|
||||
expect(replacement.url).toBe("/api/sessions/s1/events");
|
||||
act(() =>
|
||||
replacement.emitNamed(
|
||||
"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");
|
||||
rerender({ generation: 1, resetEpoch: 1 });
|
||||
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" });
|
||||
|
||||
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", () => {
|
||||
@@ -239,7 +239,7 @@ test("a queued event from a replaced source cannot mutate the new session or its
|
||||
|
||||
rerender({ sessionId: "s2", generation: 0 });
|
||||
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();
|
||||
|
||||
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([]);
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import { setupServer } from "msw/node";
|
||||
import { http, HttpResponse } from "msw";
|
||||
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" })),
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { server } from "../test/msw";
|
||||
import { ResultsPanel } from "./ResultsPanel";
|
||||
|
||||
const BASE = "http://localhost:8787";
|
||||
const BASE = "/api";
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
|
||||
Reference in New Issue
Block a user