28c6a21d27
Co-Authored-By: Codex <noreply@openai.com>
245 lines
12 KiB
TypeScript
245 lines
12 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
|
|
import type { WorkbenchApplication, WorkbenchTemporalGateway } from "./contracts.ts";
|
|
import { createWorkbenchDispatcher } from "./dispatcher.ts";
|
|
import { createCloudWorkbenchApplication } from "./cloud-application.ts";
|
|
import { createWorkbenchHttpApp } from "./http.ts";
|
|
import { runWorkbenchCli } from "../../tools/src/workbench-cli.ts";
|
|
|
|
describe("Workbench dispatcher", () => {
|
|
test("submits through Temporal without synthesizing terminal state", async () => {
|
|
const calls: string[] = [];
|
|
const application: WorkbenchApplication = {
|
|
async health() { return { status: "ready" }; },
|
|
async createSession() { return { session: { sessionId: "ses_test" } }; },
|
|
async admitTurn(input) { calls.push("admit"); return input; },
|
|
async dispatchTurn() { throw new Error("API dispatcher must not dispatch directly"); },
|
|
async cancelTurn() { throw new Error("API dispatcher must not cancel directly"); }
|
|
};
|
|
const temporal: WorkbenchTemporalGateway = {
|
|
async submit(input) { calls.push("temporal"); return { workflowId: `workbench-turn-${input.traceId}`, workflowRunId: "run-1", reused: false }; },
|
|
async cancel() { return { workflowId: "cancel", workflowRunId: "run-2", reused: false }; },
|
|
async close() {}
|
|
};
|
|
const dispatch = createWorkbenchDispatcher({ application, temporal, mode: "temporal" });
|
|
const result = await dispatch({ operation: "turn.submit", actor: { id: "usr_test" }, traceId: "trc_test", params: { sessionId: "ses_test", message: "hi" } });
|
|
expect(calls).toEqual(["admit", "temporal"]);
|
|
expect(result).toMatchObject({ ok: true, mode: "temporal", data: { status: "running", traceId: "trc_test", terminalAuthority: "hwlab.event.v1", resultSynthesized: false } });
|
|
});
|
|
|
|
test("cancel is a durable Temporal command", async () => {
|
|
const application = stubApplication();
|
|
const temporal: WorkbenchTemporalGateway = {
|
|
async submit() { throw new Error("not used"); },
|
|
async cancel(input) { return { workflowId: `workbench-cancel-${input.traceId}`, workflowRunId: "run-cancel", reused: false }; },
|
|
async close() {}
|
|
};
|
|
const result = await createWorkbenchDispatcher({ application, temporal, mode: "temporal" })({ operation: "turn.cancel", actor: { id: "usr_test" }, traceId: "trc_test" });
|
|
expect(result).toMatchObject({ ok: true, data: { status: "cancel_requested", resultSynthesized: false } });
|
|
});
|
|
});
|
|
|
|
describe("Workbench Cloud application adapter", () => {
|
|
test("persists admission before dispatch through distinct internal routes", async () => {
|
|
const calls: Array<{ path: string; authorization: string | null }> = [];
|
|
const application = createCloudWorkbenchApplication({
|
|
baseUrl: "http://cloud.internal",
|
|
authorization: "unified-internal-key",
|
|
fetchImpl: (async (url: string | URL | Request, init?: RequestInit) => {
|
|
calls.push({ path: new URL(String(url)).pathname, authorization: new Headers(init?.headers).get("authorization") });
|
|
return Response.json({ ok: true, accepted: true, status: "admitted" });
|
|
}) as typeof fetch
|
|
});
|
|
const input = { actor: { id: "usr_test" }, traceId: "trc_test", sessionId: "ses_test", params: { sessionId: "ses_test", message: "hi" } };
|
|
await application.admitTurn(input);
|
|
await application.dispatchTurn(input);
|
|
expect(calls).toEqual([
|
|
{ path: "/v1/internal/workbench/admit", authorization: "unified-internal-key" },
|
|
{ path: "/v1/agent/chat", authorization: "unified-internal-key" }
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("Workbench native HTTP adapter", () => {
|
|
test("authorizes only the Workbench navigation entry", async () => {
|
|
const app = createWorkbenchHttpApp({
|
|
async dispatch() { return { ok: true }; },
|
|
async snapshot() { return { sessions: {}, turns: {} }; }
|
|
});
|
|
|
|
for (const path of ["/auth/session", "/auth/bootstrap"]) {
|
|
const response = await app.fetch(new Request(`http://native.test${path}`));
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toMatchObject({
|
|
authenticated: true,
|
|
access: { nav: { profileId: "workbench-native", allowedIds: ["workbench.code"] } }
|
|
});
|
|
}
|
|
});
|
|
|
|
test("serves read-only empty states for Workbench auxiliary panels", async () => {
|
|
const app = createWorkbenchHttpApp({
|
|
async dispatch() { return { ok: true }; },
|
|
async snapshot() { return { sessions: {}, turns: {} }; }
|
|
});
|
|
|
|
for (const path of ["/v1/caserun/cases", "/v1/hwpod/specs?probe=1", "/v1/hwpod-node-ops"]) {
|
|
const response = await app.fetch(new Request(`http://native.test${path}`));
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toMatchObject({ ok: true, status: "unavailable", mode: "native-test" });
|
|
}
|
|
const performance = await app.fetch(new Request("http://native.test/v1/web-performance", { method: "POST", body: "{}" }));
|
|
expect(performance.status).toBe(202);
|
|
expect(await performance.json()).toMatchObject({ ok: true, status: "accepted", persisted: false, mode: "native-test" });
|
|
});
|
|
|
|
test("L0 CLI observes AgentRun-derived events only through the Kafka SSE route", async () => {
|
|
const subscribers = new Set<(envelope: any) => void>();
|
|
const bridge = {
|
|
capabilities: { liveKafkaSse: true },
|
|
ready: Promise.resolve(),
|
|
subscribeLiveHwlabEvents(listener: (envelope: any) => void) { subscribers.add(listener); return () => subscribers.delete(listener); }
|
|
};
|
|
const app = createWorkbenchHttpApp({
|
|
mode: "agentrun-native",
|
|
kafkaEventBridge: bridge,
|
|
async dispatch() { return { ok: true }; },
|
|
async snapshot() { return { sessions: {}, turns: {} }; }
|
|
});
|
|
const server = Bun.serve({ port: 0, fetch: (request) => app.fetch(request) });
|
|
try {
|
|
const pending = runWorkbenchCli([
|
|
"events", "inspect",
|
|
"--session-id", "ses_l0_sse",
|
|
"--trace-id", "trc_l0_sse",
|
|
"--over-api",
|
|
"--api-url", `http://127.0.0.1:${server.port}`,
|
|
"--timeout-ms", "1000"
|
|
], {});
|
|
for (let index = 0; index < 20 && subscribers.size === 0; index += 1) await Bun.sleep(5);
|
|
for (const listener of subscribers) listener({ schema: "hwlab.event.v1", sessionId: "ses_l0_sse", traceId: "trc_l0_sse", event: { type: "backend", eventType: "backend", label: "agentrun:backend:command-created" } });
|
|
expect(await pending).toMatchObject({ ok: true, operation: "events.inspect", connected: true, eventCount: 1, eventTypes: ["backend"] });
|
|
} finally {
|
|
server.stop(true);
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("L0 CLI waits for explicit Kafka event semantics in one SSE window", async () => {
|
|
const subscribers = new Set<(envelope: any) => void>();
|
|
const bridge = {
|
|
capabilities: { liveKafkaSse: true },
|
|
ready: Promise.resolve(),
|
|
subscribeLiveHwlabEvents(listener: (envelope: any) => void) { subscribers.add(listener); return () => subscribers.delete(listener); }
|
|
};
|
|
const app = createWorkbenchHttpApp({
|
|
mode: "agentrun-native",
|
|
kafkaEventBridge: bridge,
|
|
async dispatch() { return { ok: true }; },
|
|
async snapshot() { return { sessions: {}, turns: {} }; }
|
|
});
|
|
const server = Bun.serve({ port: 0, fetch: (request) => app.fetch(request) });
|
|
try {
|
|
const pending = runWorkbenchCli([
|
|
"events", "inspect",
|
|
"--session-id", "ses_l0_semantics",
|
|
"--trace-id", "trc_l0_semantics",
|
|
"--over-api",
|
|
"--api-url", `http://127.0.0.1:${server.port}`,
|
|
"--timeout-ms", "1000",
|
|
"--wait-for", "user,backend,assistant,terminal,final"
|
|
], {});
|
|
for (let index = 0; index < 20 && subscribers.size === 0; index += 1) await Bun.sleep(5);
|
|
const events = [
|
|
{ type: "user", eventType: "user" },
|
|
{ type: "backend", eventType: "backend" },
|
|
{ type: "assistant", eventType: "assistant", final: true, replyAuthority: true, finalResponse: { text: "done" } },
|
|
{ type: "result", eventType: "terminal", terminal: true, terminalStatus: "completed", status: "completed" }
|
|
];
|
|
for (const event of events) {
|
|
for (const listener of subscribers) listener({ schema: "hwlab.event.v1", sessionId: "ses_l0_semantics", traceId: "trc_l0_semantics", event });
|
|
}
|
|
expect(await pending).toMatchObject({
|
|
ok: true,
|
|
eventCount: 4,
|
|
eventTypes: ["user", "backend", "assistant", "terminal"],
|
|
waitFor: ["user", "backend", "assistant", "terminal", "final"],
|
|
observedSemantics: ["user", "backend", "assistant", "terminal", "final"],
|
|
missingSemantics: [],
|
|
semanticCounts: { user: 1, backend: 1, assistant: 1, terminal: 1, final: 1 },
|
|
terminalStatuses: ["completed"]
|
|
});
|
|
} finally {
|
|
server.stop(true);
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("L0 CLI reports missing Kafka event semantics on timeout", async () => {
|
|
const subscribers = new Set<(envelope: any) => void>();
|
|
const app = createWorkbenchHttpApp({
|
|
mode: "agentrun-native",
|
|
kafkaEventBridge: {
|
|
capabilities: { liveKafkaSse: true },
|
|
ready: Promise.resolve(),
|
|
subscribeLiveHwlabEvents(listener: (envelope: any) => void) { subscribers.add(listener); return () => subscribers.delete(listener); }
|
|
},
|
|
async dispatch() { return { ok: true }; },
|
|
async snapshot() { return { sessions: {}, turns: {} }; }
|
|
});
|
|
const server = Bun.serve({ port: 0, fetch: (request) => app.fetch(request) });
|
|
try {
|
|
const pending = runWorkbenchCli([
|
|
"events", "inspect",
|
|
"--trace-id", "trc_l0_missing_terminal",
|
|
"--over-api",
|
|
"--api-url", `http://127.0.0.1:${server.port}`,
|
|
"--timeout-ms", "50",
|
|
"--wait-for", "assistant,terminal"
|
|
], {});
|
|
for (let index = 0; index < 20 && subscribers.size === 0; index += 1) await Bun.sleep(2);
|
|
for (const listener of subscribers) listener({ schema: "hwlab.event.v1", traceId: "trc_l0_missing_terminal", event: { type: "assistant", eventType: "assistant" } });
|
|
expect(await pending).toMatchObject({
|
|
ok: false,
|
|
status: "event-timeout",
|
|
observedSemantics: ["assistant"],
|
|
missingSemantics: ["terminal"],
|
|
terminalStatuses: [],
|
|
error: { code: "workbench_kafka_sse_event_missing" }
|
|
});
|
|
} finally {
|
|
server.stop(true);
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("L0 SSE exposes Kafka startup failure without waiting for a read model", async () => {
|
|
const startupError = Object.assign(new Error("Kafka broker unavailable"), { code: "kafka_broker_unavailable" });
|
|
const response = await createWorkbenchHttpApp({
|
|
mode: "agentrun-native",
|
|
kafkaEventBridge: {
|
|
capabilities: { liveKafkaSse: true },
|
|
liveReady: Promise.reject(startupError),
|
|
subscribeLiveHwlabEvents() { return () => {}; }
|
|
},
|
|
async dispatch() { return { ok: true }; },
|
|
async snapshot() { return { sessions: {}, turns: {} }; }
|
|
}).fetch(new Request("http://localhost/v1/workbench/events?traceId=trc_l0_kafka_error"));
|
|
expect(response.status).toBe(200);
|
|
const body = await response.text();
|
|
expect(body).toContain("event: workbench.connected");
|
|
expect(body).toContain("event: workbench.error");
|
|
expect(body).toContain("kafka_broker_unavailable");
|
|
});
|
|
});
|
|
|
|
function stubApplication(): WorkbenchApplication {
|
|
return {
|
|
async health() { return {}; },
|
|
async createSession() { return {}; },
|
|
async admitTurn(input) { return input; },
|
|
async dispatchTurn() { return {}; },
|
|
async cancelTurn() { return {}; }
|
|
};
|
|
}
|