import { describe, expect, test } from "bun:test"; import type { WorkbenchApplication, WorkbenchTemporalGateway } from "./contracts.ts"; import { createWorkbenchActivities } from "./activities.ts"; import { createWorkbenchDispatcher } from "./dispatcher.ts"; import { createCloudWorkbenchApplication } from "./cloud-application.ts"; import { createWorkbenchHttpApp } from "./http.ts"; import { workbenchRuntime } from "./runtime.ts"; import { runWorkbenchCli } from "../../tools/src/workbench-cli.ts"; test("temporal API runtime does not require worker Kafka publisher configuration", async () => { const runtime = workbenchRuntime({ WORKBENCH_MODE: "temporal", WORKBENCH_CLOUD_API_URL: "http://cloud.internal", WORKBENCH_CLOUD_API_AUTHORIZATION: "internal-key", WORKBENCH_TEMPORAL_ADDRESS: "127.0.0.1:7233", WORKBENCH_TEMPORAL_NAMESPACE: "unidesk", WORKBENCH_TEMPORAL_TASK_QUEUE: "hwlab-v03-workbench", WORKBENCH_ACTIVITY_START_TO_CLOSE_TIMEOUT_MS: "30000", WORKBENCH_ACTIVITY_RETRY_INITIAL_INTERVAL_MS: "1000", WORKBENCH_ACTIVITY_RETRY_MAXIMUM_INTERVAL_MS: "30000", WORKBENCH_ACTIVITY_RETRY_MAXIMUM_ATTEMPTS: "5" }, { eventPublisher: null }); expect(runtime.eventPublisher).toBeNull(); await runtime.close(); }); 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: "Bearer unified-internal-key" }, { path: "/v1/agent/chat", authorization: "Bearer unified-internal-key" } ]); }); test("preserves an existing Bearer authorization scheme", async () => { let authorization: string | null = null; const application = createCloudWorkbenchApplication({ baseUrl: "http://cloud.internal", authorization: "Bearer unified-internal-key", fetchImpl: (async (_url: string | URL | Request, init?: RequestInit) => { authorization = new Headers(init?.headers).get("authorization"); return Response.json({ ok: true, session: { sessionId: "ses_test" } }); }) as typeof fetch }); await application.createSession({ actor: { id: "usr_test" }, params: {} }); expect(authorization).toBe("Bearer unified-internal-key"); }); test("preserves Cloud API retryability and billing failure semantics", async () => { const application = createCloudWorkbenchApplication({ baseUrl: "http://cloud.internal", authorization: "unified-internal-key", fetchImpl: (async () => Response.json({ ok: false, error: { code: "insufficient_credits", layer: "billing", retryable: false, message: "credits exhausted", userMessage: "余额不足" } }, { status: 402 })) as typeof fetch }); const input = { actor: { id: "usr_test" }, traceId: "trc_billing", sessionId: "ses_billing", params: { message: "hi" } }; await expect(application.dispatchTurn(input)).rejects.toMatchObject({ code: "insufficient_credits", layer: "billing", retryable: false, status: 402, userMessage: "余额不足" }); }); }); describe("Workbench Temporal activities", () => { const activity = { startToCloseTimeoutMs: 1000, retryInitialIntervalMs: 100, retryMaximumIntervalMs: 500, retryMaximumAttempts: 3 }; const input = { actor: { id: "usr_test" }, traceId: "trc_activity", sessionId: "ses_activity", params: { message: "hi" } }; test("publishes semantic Kafka failure and stops retrying non-retryable billing errors", async () => { const published: any[] = []; const application = stubApplication(); application.dispatchTurn = async () => { throw Object.assign(new Error("credits exhausted"), { code: "insufficient_credits", layer: "billing", retryable: false, userMessage: "余额不足" }); }; const activities = createWorkbenchActivities(application, { activity, attempt: () => 1, eventPublisher: { async publish(event) { published.push(event); return { published: true }; } } }); await expect(activities.dispatchWorkbenchTurn(input)).rejects.toMatchObject({ type: "insufficient_credits", nonRetryable: true }); expect(published).toHaveLength(2); expect(published[0]).toMatchObject({ event: { type: "error", failureDomain: "billing", code: "insufficient_credits", retryable: false, willRetry: false, attempt: 1, maxAttempts: 3, terminal: false } }); expect(published[1]).toMatchObject({ event: { type: "terminal_status", terminalStatus: "failed", errorCode: "insufficient_credits", message: "余额不足", terminal: true } }); }); test("publishes the YAML-controlled exponential retry without sealing the turn early", async () => { const published: any[] = []; const application = stubApplication(); application.dispatchTurn = async () => { throw Object.assign(new Error("billing unavailable"), { code: "user_billing_preflight_failed", layer: "billing", retryable: true }); }; const activities = createWorkbenchActivities(application, { activity, attempt: () => 2, eventPublisher: { async publish(event) { published.push(event); return { published: true }; } } }); await expect(activities.dispatchWorkbenchTurn(input)).rejects.toMatchObject({ type: "user_billing_preflight_failed", nonRetryable: false }); expect(published).toHaveLength(1); expect(published[0]).toMatchObject({ event: { type: "backend_status", retryPhase: "retryScheduled", attempt: 2, maxAttempts: 3, backoffMs: 200, terminal: false } }); }); }); describe("Workbench native HTTP adapter", () => { test("native session list exposes the first user preview for summary-only rail loading", async () => { const app = createWorkbenchHttpApp({ async dispatch() { return { ok: true }; }, async snapshot() { return { sessions: { ses_title: { sessionId: "ses_title", updatedAt: "2026-07-20T01:00:00.000Z", messages: [{ role: "user", content: "persisted native title", createdAt: "2026-07-20T00:59:00.000Z" }] }, ses_empty: { sessionId: "ses_empty", updatedAt: "2026-07-20T00:00:00.000Z", messages: [] } }, turns: {} }; } }); const response = await app.fetch(new Request("http://native.test/v1/workbench/sessions")); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ sessions: [ { sessionId: "ses_title", firstUserMessagePreview: "persisted native title" }, { sessionId: "ses_empty", firstUserMessagePreview: null } ] }); }); test("AgentRun native session list uses Kafka index summaries instead of admission messages", async () => { const app = createWorkbenchHttpApp({ mode: "agentrun-native", kafkaEventBridge: { workbenchSessionSummaries() { return { ok: true, sessions: [{ sessionId: "ses_title", firstUserMessagePreview: "Kafka title", lastUserMessageAt: "2026-07-20T01:00:00.000Z" }] }; } }, async dispatch() { return { ok: true }; }, async snapshot() { return { sessions: { ses_title: { sessionId: "ses_title", updatedAt: "2026-07-20T00:00:00.000Z", messages: [{ role: "user", content: "admission snapshot title" }] } }, turns: {} }; } }); const response = await app.fetch(new Request("http://native.test/v1/workbench/sessions")); expect(await response.json()).toMatchObject({ sessions: [{ sessionId: "ses_title", firstUserMessagePreview: "Kafka title", lastUserMessageAt: "2026-07-20T01:00:00.000Z" }] }); }); 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: {} }; } }); let authorization: string | null = null; const server = Bun.serve({ port: 0, fetch(request) { authorization = request.headers.get("authorization"); return 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" ], { HWLAB_API_KEY: "l0-sse-api-key" }); 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, connectedContract: { deliverySemantics: "live-only", realtimeSource: "hwlab.event.v1", replay: false, liveOnly: true, refreshReplay: null }, eventCount: 1, eventTypes: ["backend"] }); expect(authorization).toBe("Bearer l0-sse-api-key"); } finally { server.stop(true); await app.close(); } }); test("L0 CLI reports the Workbench SSE HTTP status when authentication fails", async () => { const server = Bun.serve({ port: 0, fetch: () => new Response("unauthorized", { status: 401 }) }); try { await expect(runWorkbenchCli([ "events", "inspect", "--trace-id", "trc_l0_auth_failure", "--over-api", "--api-url", `http://127.0.0.1:${server.port}`, "--timeout-ms", "1000" ], {})).rejects.toMatchObject({ code: "workbench_sse_read_failed", message: expect.stringContaining("Workbench SSE returned HTTP 401") }); } finally { server.stop(true); } }); test("L0 CLI replays retained Kafka events before the same live SSE stream", async () => { const sessionId = "ses_l0_refresh"; const traceId = "trc_l0_refresh"; const bridge = { capabilities: { liveKafkaSse: true, kafkaRefreshReplay: true }, refreshReplay: { groupIdPrefix: "hwlab-l0-refresh", timeoutMs: 1000, scanLimit: 100, matchedEventLimit: 20, liveBufferLimit: 20 }, ready: Promise.resolve(), subscribeLiveHwlabEvents() { return () => {}; }, async queryHwlabEventRetention(options: any) { expect(options).toMatchObject({ sessionId, traceId, partitionKey: sessionId, fromBeginning: true }); return { topic: "hwlab.event.v1", events: Array.from({ length: 100 }, (_, index) => ({ topic: "hwlab.event.v1", partition: 0, offset: String(index), value: { schema: "hwlab.event.v1", eventType: "hwlab.trace.event.projected", eventId: `hwlab:evt_l0_refresh_${index}`, sourceEventId: `evt_l0_refresh_${index}`, sessionId, hwlabSessionId: sessionId, traceId, event: { type: "backend", eventType: "backend", sessionId, traceId, sourceEventId: `evt_l0_refresh_${index}` } } })), completionReason: "end-offset", completion: { reason: "end-offset", complete: true, barrierReached: true, retentionStartVerified: true }, reachedEndOffsets: true, endOffsetsAvailable: true, endOffsets: [{ partition: 0, startOffset: "0", endOffset: "100" }], scannedCount: 100, parsedCount: 100, matchedCount: 100, filterRejectedCount: 0, keyRejectedCount: 0, partitionKeyScoped: true, targetPartition: 0, timing: { endOffsetSnapshotMs: 0, groupOffsetsMs: 0, consumerConnectMs: 0, consumerSubscribeMs: 0, scanMs: 0, cleanupMs: 0, indexLookupMs: 1, totalMs: 1 }, index: { hit: true, source: "process-session-index", indexedEventCount: 300, indexedSessionCount: 3, incompleteSessionCount: 0, valuesRedacted: true } }; } }; 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 result = await runWorkbenchCli([ "events", "inspect", "--session-id", sessionId, "--trace-id", traceId, "--over-api", "--api-url", `http://127.0.0.1:${server.port}`, "--timeout-ms", "1000" ], {}); expect(result).toMatchObject({ ok: true, connected: true, connectedContract: { deliverySemantics: "kafka-retention-then-live", realtimeSource: "hwlab.event.v1", replay: true, liveOnly: false, refreshReplay: { phase: "live", counts: { replayed: 100 }, query: { complete: true, scannedCount: 100, parsedCount: 100, matchedCount: 100, filterRejectedCount: 0, keyRejectedCount: 0, partitionKeyScoped: true, targetPartition: 0, index: { hit: true, source: "process-session-index", indexedEventCount: 300, indexedSessionCount: 3, incompleteSessionCount: 0, blocking: false }, timing: { scanMs: 0, indexLookupMs: 1, totalMs: 1 } } } }, eventCount: 100, eventTypes: ["backend"], eventTypeCounts: { backend: 100 } }); expect(JSON.stringify(result).length).toBeLessThan(10_000); } 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", createdAt: "2026-07-19T16:00:00.000Z" }, { type: "backend", eventType: "backend", createdAt: "2026-07-19T16:00:02.000Z" }, { type: "assistant", eventType: "assistant", createdAt: "2026-07-19T16:01:02.000Z", final: true, replyAuthority: true, finalResponse: { text: "done" } }, { type: "result", eventType: "terminal", createdAt: "2026-07-19T16:01:03.000Z", 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"], timing: { clock: "agentrun-source-event-created-at", phasesMs: { firstEventToBackend: 2000, admissionAndDispatch: null, runnerProvisioning: null, runtimePreparation: null, providerExecution: null, backendToAssistant: 60000, assistantToTerminal: 1000, firstEventToTerminal: 63000, firstEventToFinal: 62000 }, slowestGap: { durationMs: 60000, toIndex: 2, toEventType: "assistant" }, valuesPrinted: false } }); } 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", "250", "--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 {}; } }; }