From 4626fce895dfd455991ad4f723d43c2b5adebb94 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:15:28 +0000 Subject: [PATCH] =?UTF-8?q?feat(cockpit/web):=20scaffold=20S3=20web=20cock?= =?UTF-8?q?pit=20renderer=20=E2=80=94=20Hono=20server,=20SSE,=20transcript?= =?UTF-8?q?=20reducer,=20chat=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth.ts: loopback-only random token generation + timing-safe verification - server.ts: Hono app factory with GET /events (SSE), POST /input, POST /commands/:slash - transcript-reducer.ts: renderer-agnostic pure state machine consuming CockpitEvent - sse-client.ts: fetch-based SSE consumer with custom header support - ui/index.html: chat-first layout with transcript, subagent tabs, systems drawer, skills feed - ui/app.ts: typed entry point for future Vite bundling - index.ts: re-exports all new modules - 35 tests passing (auth, server contract, transcript reducer parity) Co-Authored-By: Terrence Schonleber --- package.json | 1 + pnpm-lock.yaml | 17 +- src/cockpit/web/auth.ts | 25 ++ src/cockpit/web/index.ts | 26 ++ src/cockpit/web/server.ts | 92 ++++++ src/cockpit/web/sse-client.ts | 82 +++++ src/cockpit/web/transcript-reducer.ts | 272 ++++++++++++++++ src/cockpit/web/ui/app.ts | 41 +++ src/cockpit/web/ui/index.html | 277 +++++++++++++++++ tests/cockpit/web/auth.test.ts | 64 ++++ tests/cockpit/web/server.test.ts | 178 +++++++++++ tests/cockpit/web/transcript-reducer.test.ts | 308 +++++++++++++++++++ 12 files changed, 1376 insertions(+), 7 deletions(-) create mode 100644 src/cockpit/web/auth.ts create mode 100644 src/cockpit/web/server.ts create mode 100644 src/cockpit/web/sse-client.ts create mode 100644 src/cockpit/web/transcript-reducer.ts create mode 100644 src/cockpit/web/ui/app.ts create mode 100644 src/cockpit/web/ui/index.html create mode 100644 tests/cockpit/web/auth.test.ts create mode 100644 tests/cockpit/web/server.test.ts create mode 100644 tests/cockpit/web/transcript-reducer.test.ts diff --git a/package.json b/package.json index 71b43a0..792a521 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "better-sqlite3": "^11.5.0", "commander": "^14.0.3", "dotenv": "^16.4.5", + "hono": "^4.12.15", "ink": "^7.0.1", "ink-big-text": "^2.0.0", "ink-gradient": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92f7439..1f94976 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: dotenv: specifier: ^16.4.5 version: 16.6.1 + hono: + specifier: ^4.12.15 + version: 4.12.15 ink: specifier: ^7.0.1 version: 7.0.1(@types/react@19.2.14)(react@19.2.5) @@ -1361,8 +1364,8 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - hono@4.12.14: - resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} + hono@4.12.15: + resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==} engines: {node: '>=16.9.0'} http-errors@2.0.1: @@ -2478,9 +2481,9 @@ snapshots: - supports-color - utf-8-validate - '@hono/node-server@1.19.14(hono@4.12.14)': + '@hono/node-server@1.19.14(hono@4.12.15)': dependencies: - hono: 4.12.14 + hono: 4.12.15 '@huggingface/jinja@0.2.2': {} @@ -2515,7 +2518,7 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.14) + '@hono/node-server': 1.19.14(hono@4.12.15) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -2525,7 +2528,7 @@ snapshots: eventsource-parser: 3.0.8 express: 5.2.1 express-rate-limit: 8.3.2(express@5.2.1) - hono: 4.12.14 + hono: 4.12.15 jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -3355,7 +3358,7 @@ snapshots: help-me@5.0.0: {} - hono@4.12.14: {} + hono@4.12.15: {} http-errors@2.0.1: dependencies: diff --git a/src/cockpit/web/auth.ts b/src/cockpit/web/auth.ts new file mode 100644 index 0000000..75f1285 --- /dev/null +++ b/src/cockpit/web/auth.ts @@ -0,0 +1,25 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; + +export const COCKPIT_TOKEN_HEADER = "X-Cockpit-Token"; +export const COCKPIT_TOKEN_BYTES = 32; + +export function generateCockpitToken(): string { + return randomBytes(COCKPIT_TOKEN_BYTES).toString("hex"); +} + +export function isLoopbackAddress(addr: string | undefined): boolean { + if (!addr) return false; + return ( + addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1" || addr === "localhost" + ); +} + +export function verifyToken(provided: string | null | undefined, expected: string): boolean { + if (!provided || !expected) return false; + if (provided.length !== expected.length) return false; + try { + return timingSafeEqual(Buffer.from(provided, "utf8"), Buffer.from(expected, "utf8")); + } catch { + return false; + } +} diff --git a/src/cockpit/web/index.ts b/src/cockpit/web/index.ts index 19479f7..08c4c16 100644 --- a/src/cockpit/web/index.ts +++ b/src/cockpit/web/index.ts @@ -11,3 +11,29 @@ export const COCKPIT_COMMAND_PATH_PREFIX = "/commands"; export const COCKPIT_SSE_HEADERS = { [COCKPIT_PROTOCOL_HEADER]: String(COCKPIT_PROTOCOL_VERSION), } as const; + +export { + COCKPIT_TOKEN_HEADER, + generateCockpitToken, + isLoopbackAddress, + verifyToken, +} from "./auth"; +export { createCockpitApp } from "./server"; +export type { CockpitServerOptions } from "./server"; +export { connectSSE } from "./sse-client"; +export type { SSEClient, SSEClientOptions } from "./sse-client"; +export { + initialTranscriptState, + reduceTranscriptEvent, + replayEvents, +} from "./transcript-reducer"; +export type { + BudgetWarningEntry, + ErrorEntry, + PolicyGateEntry, + SkillProposalEntry, + SubagentEntry, + ToolCallEntry, + TranscriptMessage, + TranscriptState, +} from "./transcript-reducer"; diff --git a/src/cockpit/web/server.ts b/src/cockpit/web/server.ts new file mode 100644 index 0000000..8633108 --- /dev/null +++ b/src/cockpit/web/server.ts @@ -0,0 +1,92 @@ +import { Hono } from "hono"; +import { streamSSE } from "hono/streaming"; +import type { ChatController } from "../core/controller"; +import type { EventBus } from "../core/events"; +import { COCKPIT_PROTOCOL_HEADER, COCKPIT_PROTOCOL_VERSION } from "../core/events"; +import { COCKPIT_TOKEN_HEADER, verifyToken } from "./auth"; + +// ─── Server factory ───────────────────────────────────────────────────────── + +export interface CockpitServerOptions { + readonly eventBus: EventBus; + readonly controller: ChatController; + readonly token: string; +} + +export function createCockpitApp(opts: CockpitServerOptions): Hono { + const app = new Hono(); + + // Token auth middleware — every request must carry the cockpit token. + app.use("*", async (c, next) => { + const provided = c.req.header(COCKPIT_TOKEN_HEADER); + if (verifyToken(provided, opts.token)) { + await next(); + return; + } + return c.json({ error: "unauthorized" }, 401); + }); + + // GET /events — SSE stream of CockpitEvents. + app.get("/events", (c) => { + return streamSSE(c, async (stream) => { + c.header(COCKPIT_PROTOCOL_HEADER, String(COCKPIT_PROTOCOL_VERSION)); + + const ac = new AbortController(); + stream.onAbort(() => ac.abort()); + + const unsubscribe = opts.eventBus.subscribe((event) => { + if (!ac.signal.aborted) { + void stream.writeSSE({ data: JSON.stringify(event) }); + } + }); + + await new Promise((resolve) => { + if (ac.signal.aborted) { + resolve(); + return; + } + ac.signal.addEventListener("abort", () => resolve()); + }); + + unsubscribe(); + }); + }); + + // POST /input — submit user input to the ChatController. + app.post("/input", async (c) => { + const body = (await c.req.json()) as { + sessionId?: string; + text?: string; + metadata?: Record; + }; + if (!body.sessionId || !body.text) { + return c.json({ error: "sessionId and text required" }, 400); + } + await opts.controller.submit({ + sessionId: body.sessionId, + text: body.text, + ...(body.metadata !== undefined ? { metadata: body.metadata } : {}), + }); + return c.json({ ok: true }); + }); + + // POST /commands/:slash — dispatch a slash command. + app.post("/commands/:slash", async (c) => { + const slash = c.req.param("slash"); + const body = (await c.req.json()) as { + sessionId?: string; + args?: readonly string[]; + }; + if (!body.sessionId) { + return c.json({ error: "sessionId required" }, 400); + } + await opts.controller.slash({ + sessionId: body.sessionId, + command: slash, + args: body.args ?? [], + }); + return c.json({ ok: true }); + }); + + return app; +} diff --git a/src/cockpit/web/sse-client.ts b/src/cockpit/web/sse-client.ts new file mode 100644 index 0000000..b5cbc5d --- /dev/null +++ b/src/cockpit/web/sse-client.ts @@ -0,0 +1,82 @@ +/** + * Fetch-based SSE client for the cockpit web renderer. + * + * Uses fetch + ReadableStream instead of native EventSource so we can pass the + * cockpit token via a custom header (EventSource only supports query params). + * + * This module is browser-compatible — no Node imports. + */ + +import type { CockpitEvent } from "../core/events"; +import { COCKPIT_TOKEN_HEADER } from "./auth"; + +export interface SSEClientOptions { + readonly url: string; + readonly token: string; + readonly onEvent: (event: CockpitEvent) => void; + readonly onError?: (error: Error) => void; + readonly onOpen?: () => void; +} + +export interface SSEClient { + close(): void; +} + +export function connectSSE(opts: SSEClientOptions): SSEClient { + const ac = new AbortController(); + + const run = async (): Promise => { + const response = await fetch(opts.url, { + headers: { + [COCKPIT_TOKEN_HEADER]: opts.token, + Accept: "text/event-stream", + }, + signal: ac.signal, + }); + + if (!response.ok) { + throw new Error(`SSE connection failed: ${String(response.status)}`); + } + + opts.onOpen?.(); + + const reader = response.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + let buffer = ""; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (line.startsWith("data:")) { + const data = line.slice(5).trim(); + if (!data) continue; + try { + opts.onEvent(JSON.parse(data) as CockpitEvent); + } catch { + // skip malformed events + } + } + } + } + }; + + run().catch((err: unknown) => { + if (!ac.signal.aborted) { + opts.onError?.(err instanceof Error ? err : new Error(String(err))); + } + }); + + return { + close(): void { + ac.abort(); + }, + }; +} diff --git a/src/cockpit/web/transcript-reducer.ts b/src/cockpit/web/transcript-reducer.ts new file mode 100644 index 0000000..9bd0854 --- /dev/null +++ b/src/cockpit/web/transcript-reducer.ts @@ -0,0 +1,272 @@ +/** + * Renderer-agnostic transcript state machine. + * + * Takes a stream of CockpitEvents and produces a TranscriptState that both Ink + * and Web renderers can consume. Pure functions only — no side effects, no I/O, + * no UI imports. + */ + +import type { CockpitEvent, SkillProposal, SubagentBackend } from "../core/events"; + +// ─── State types ──────────────────────────────────────────────────────────── + +export interface TranscriptMessage { + readonly id: string; + readonly role: "system" | "user" | "assistant" | "tool"; + content: string; + readonly name: string | undefined; + readonly toolCallId: string | undefined; + readonly createdAt: string | undefined; + streaming: boolean; +} + +export interface ToolCallEntry { + readonly callId: string; + readonly name: string; + readonly args: unknown; + progress: string; + ok: boolean | null; + result: unknown | undefined; +} + +export interface SubagentEntry { + readonly id: string; + readonly backend: SubagentBackend; + readonly parentSessionId: string; + status: "running" | "completed" | "failed"; + output: string; + exit: number | undefined; +} + +export interface SkillProposalEntry { + readonly proposalId: string; + readonly kind: "draft" | "retire"; + readonly payload: SkillProposal; + decision: "accepted" | "rejected" | undefined; + decidedBy: "user" | "auto" | undefined; +} + +export interface BudgetWarningEntry { + readonly dimension: string; + readonly used: number; + readonly cap: number; +} + +export interface ErrorEntry { + readonly code: string; + readonly message: string; + readonly sessionId: string | undefined; +} + +export interface PolicyGateEntry { + readonly candidateId: string; + readonly result: "approved" | "rejected"; + readonly reason: string | undefined; +} + +export interface TranscriptState { + readonly messages: readonly TranscriptMessage[]; + readonly toolCalls: ReadonlyMap; + readonly subagents: ReadonlyMap; + readonly skillProposals: ReadonlyMap; + readonly activeProvider: string | null; + readonly budgetWarnings: readonly BudgetWarningEntry[]; + readonly errors: readonly ErrorEntry[]; + readonly policyEvents: readonly PolicyGateEntry[]; +} + +// ─── Initial state ────────────────────────────────────────────────────────── + +export function initialTranscriptState(): TranscriptState { + return { + messages: [], + toolCalls: new Map(), + subagents: new Map(), + skillProposals: new Map(), + activeProvider: null, + budgetWarnings: [], + errors: [], + policyEvents: [], + }; +} + +// ─── Reducer ──────────────────────────────────────────────────────────────── + +export function reduceTranscriptEvent( + state: TranscriptState, + event: CockpitEvent, +): TranscriptState { + switch (event.t) { + case "transcript.append": { + const m = event.message; + const entry: TranscriptMessage = { + id: m.id, + role: m.role, + content: m.content, + name: m.name ?? undefined, + toolCallId: m.toolCallId ?? undefined, + createdAt: m.createdAt ?? undefined, + streaming: false, + }; + return { ...state, messages: [...state.messages, entry] }; + } + + case "transcript.delta": { + const messages = state.messages.map((msg) => + msg.id === event.messageId + ? { ...msg, content: msg.content + event.chunk, streaming: true } + : msg, + ); + return { ...state, messages }; + } + + case "tool.start": { + const toolCalls = new Map(state.toolCalls); + toolCalls.set(event.callId, { + callId: event.callId, + name: event.name, + args: event.args, + progress: "", + ok: null, + result: undefined, + }); + return { ...state, toolCalls }; + } + + case "tool.progress": { + const toolCalls = new Map(state.toolCalls); + const prev = toolCalls.get(event.callId); + if (prev) { + toolCalls.set(event.callId, { + ...prev, + progress: prev.progress + event.chunk, + }); + } + return { ...state, toolCalls }; + } + + case "tool.end": { + const toolCalls = new Map(state.toolCalls); + const prev = toolCalls.get(event.callId); + if (prev) { + toolCalls.set(event.callId, { + ...prev, + ok: event.ok, + result: event.result, + }); + } + return { ...state, toolCalls }; + } + + case "subagent.spawn": { + const subagents = new Map(state.subagents); + subagents.set(event.subagentId, { + id: event.subagentId, + backend: event.backend, + parentSessionId: event.parentSessionId, + status: "running", + output: "", + exit: undefined, + }); + return { ...state, subagents }; + } + + case "subagent.event": { + const subagents = new Map(state.subagents); + const prev = subagents.get(event.subagentId); + if (prev) { + subagents.set(event.subagentId, { + ...prev, + output: prev.output + event.chunk, + }); + } + return { ...state, subagents }; + } + + case "subagent.end": { + const subagents = new Map(state.subagents); + const prev = subagents.get(event.subagentId); + if (prev) { + subagents.set(event.subagentId, { + ...prev, + status: event.ok ? "completed" : "failed", + exit: event.exit, + }); + } + return { ...state, subagents }; + } + + case "skill.proposal": { + const skillProposals = new Map(state.skillProposals); + skillProposals.set(event.proposalId, { + proposalId: event.proposalId, + kind: event.kind, + payload: event.payload, + decision: undefined, + decidedBy: undefined, + }); + return { ...state, skillProposals }; + } + + case "skill.decision": { + const skillProposals = new Map(state.skillProposals); + const prev = skillProposals.get(event.proposalId); + if (prev) { + skillProposals.set(event.proposalId, { + ...prev, + decision: event.decision, + decidedBy: event.by, + }); + } + return { ...state, skillProposals }; + } + + case "provider.switch": + return { ...state, activeProvider: event.to }; + + case "policy.gate": + return { + ...state, + policyEvents: [ + ...state.policyEvents, + { + candidateId: event.candidateId, + result: event.result, + reason: event.reason, + }, + ], + }; + + case "budget.warn": + return { + ...state, + budgetWarnings: [ + ...state.budgetWarnings, + { + dimension: event.dimension, + used: event.used, + cap: event.cap, + }, + ], + }; + + case "error": + return { + ...state, + errors: [ + ...state.errors, + { + code: event.code, + message: event.message, + sessionId: event.sessionId, + }, + ], + }; + } +} + +// ─── Replay helper ────────────────────────────────────────────────────────── + +export function replayEvents(events: readonly CockpitEvent[]): TranscriptState { + return events.reduce(reduceTranscriptEvent, initialTranscriptState()); +} diff --git a/src/cockpit/web/ui/app.ts b/src/cockpit/web/ui/app.ts new file mode 100644 index 0000000..379028a --- /dev/null +++ b/src/cockpit/web/ui/app.ts @@ -0,0 +1,41 @@ +/** + * Web cockpit application entry point. + * + * When bundled by Vite this replaces the inline + + diff --git a/tests/cockpit/web/auth.test.ts b/tests/cockpit/web/auth.test.ts new file mode 100644 index 0000000..1f6e8ce --- /dev/null +++ b/tests/cockpit/web/auth.test.ts @@ -0,0 +1,64 @@ +import { + COCKPIT_TOKEN_BYTES, + COCKPIT_TOKEN_HEADER, + generateCockpitToken, + isLoopbackAddress, + verifyToken, +} from "@/cockpit/web/auth"; +import { describe, expect, it } from "vitest"; + +describe("cockpit web auth", () => { + it("generates tokens of correct length", () => { + const token = generateCockpitToken(); + expect(typeof token).toBe("string"); + expect(token).toHaveLength(COCKPIT_TOKEN_BYTES * 2); // hex encoding + }); + + it("generates unique tokens each time", () => { + const a = generateCockpitToken(); + const b = generateCockpitToken(); + expect(a).not.toBe(b); + }); + + it("verifies matching tokens", () => { + const token = generateCockpitToken(); + expect(verifyToken(token, token)).toBe(true); + }); + + it("rejects mismatched tokens", () => { + const a = generateCockpitToken(); + const b = generateCockpitToken(); + expect(verifyToken(a, b)).toBe(false); + }); + + it("rejects null/undefined/empty tokens", () => { + const token = generateCockpitToken(); + expect(verifyToken(null, token)).toBe(false); + expect(verifyToken(undefined, token)).toBe(false); + expect(verifyToken("", token)).toBe(false); + }); + + it("rejects tokens of different length", () => { + const token = generateCockpitToken(); + expect(verifyToken(token.slice(0, 10), token)).toBe(false); + expect(verifyToken(`${token}extra`, token)).toBe(false); + }); + + it("identifies loopback addresses correctly", () => { + expect(isLoopbackAddress("127.0.0.1")).toBe(true); + expect(isLoopbackAddress("::1")).toBe(true); + expect(isLoopbackAddress("::ffff:127.0.0.1")).toBe(true); + expect(isLoopbackAddress("localhost")).toBe(true); + }); + + it("rejects non-loopback addresses", () => { + expect(isLoopbackAddress("192.168.1.1")).toBe(false); + expect(isLoopbackAddress("10.0.0.1")).toBe(false); + expect(isLoopbackAddress("example.com")).toBe(false); + expect(isLoopbackAddress(undefined)).toBe(false); + }); + + it("exports the correct header name", () => { + expect(COCKPIT_TOKEN_HEADER).toBe("X-Cockpit-Token"); + }); +}); diff --git a/tests/cockpit/web/server.test.ts b/tests/cockpit/web/server.test.ts new file mode 100644 index 0000000..172f0d6 --- /dev/null +++ b/tests/cockpit/web/server.test.ts @@ -0,0 +1,178 @@ +import type { ChatInput, SlashCommandInput } from "@/cockpit/core/controller"; +import { EventBus } from "@/cockpit/core/events"; +import { COCKPIT_TOKEN_HEADER, generateCockpitToken } from "@/cockpit/web/auth"; +import { createCockpitApp } from "@/cockpit/web/server"; +import { describe, expect, it } from "vitest"; + +// ── Stub controller ───────────────────────────────────────────────────────── + +function stubController() { + const calls: Array<{ method: string; input: ChatInput | SlashCommandInput }> = []; + return { + calls, + async submit(input: ChatInput): Promise { + calls.push({ method: "submit", input }); + }, + async slash(input: SlashCommandInput): Promise { + calls.push({ method: "slash", input }); + }, + async *events() { + // no-op + }, + }; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("cockpit web server contract", () => { + const token = generateCockpitToken(); + const bus = new EventBus(); + + function makeApp() { + const controller = stubController(); + const app = createCockpitApp({ eventBus: bus, controller, token }); + return { app, controller }; + } + + it("rejects requests without a token", async () => { + const { app } = makeApp(); + const res = await app.request("/events"); + expect(res.status).toBe(401); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("unauthorized"); + }); + + it("rejects requests with wrong token", async () => { + const { app } = makeApp(); + const res = await app.request("/events", { + headers: { [COCKPIT_TOKEN_HEADER]: "wrong-token" }, + }); + expect(res.status).toBe(401); + }); + + it("POST /input validates required fields", async () => { + const { app } = makeApp(); + const res = await app.request("/input", { + method: "POST", + headers: { + [COCKPIT_TOKEN_HEADER]: token, + "Content-Type": "application/json", + }, + body: JSON.stringify({ text: "hi" }), // missing sessionId + }); + expect(res.status).toBe(400); + }); + + it("POST /input dispatches to controller.submit", async () => { + const { app, controller } = makeApp(); + const res = await app.request("/input", { + method: "POST", + headers: { + [COCKPIT_TOKEN_HEADER]: token, + "Content-Type": "application/json", + }, + body: JSON.stringify({ sessionId: "s1", text: "hello strand" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + expect(controller.calls).toHaveLength(1); + expect(controller.calls[0]?.method).toBe("submit"); + const input = controller.calls[0]?.input as ChatInput; + expect(input.sessionId).toBe("s1"); + expect(input.text).toBe("hello strand"); + }); + + it("POST /commands/:slash validates required fields", async () => { + const { app } = makeApp(); + const res = await app.request("/commands/model", { + method: "POST", + headers: { + [COCKPIT_TOKEN_HEADER]: token, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), // missing sessionId + }); + expect(res.status).toBe(400); + }); + + it("POST /commands/:slash dispatches to controller.slash", async () => { + const { app, controller } = makeApp(); + const res = await app.request("/commands/spawn", { + method: "POST", + headers: { + [COCKPIT_TOKEN_HEADER]: token, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + sessionId: "s1", + args: ["claude", "review this PR"], + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + expect(controller.calls).toHaveLength(1); + expect(controller.calls[0]?.method).toBe("slash"); + const input = controller.calls[0]?.input as SlashCommandInput; + expect(input.command).toBe("spawn"); + expect(input.args).toEqual(["claude", "review this PR"]); + }); + + it("POST /commands/:slash defaults args to empty", async () => { + const { app, controller } = makeApp(); + const res = await app.request("/commands/help", { + method: "POST", + headers: { + [COCKPIT_TOKEN_HEADER]: token, + "Content-Type": "application/json", + }, + body: JSON.stringify({ sessionId: "s1" }), + }); + expect(res.status).toBe(200); + const input = controller.calls[0]?.input as SlashCommandInput; + expect(input.args).toEqual([]); + }); + + it("GET /events returns SSE stream with protocol header", async () => { + const { app } = makeApp(); + + const resPromise = app.request("/events", { + headers: { [COCKPIT_TOKEN_HEADER]: token }, + }); + + // Publish an event shortly after the connection opens. + setTimeout(() => { + bus.publish({ + t: "transcript.append", + sessionId: "s1", + message: { + id: "msg-1", + role: "assistant", + content: "hello from SSE", + }, + }); + }, 50); + + const res = await resPromise; + expect(res.status).toBe(200); + + // Read enough to see the first event in the stream. + const reader = res.body?.getReader(); + expect(reader).toBeDefined(); + if (!reader) return; + + const decoder = new TextDecoder(); + let text = ""; + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + if (text.includes("hello from SSE")) break; + } + reader.cancel(); + + expect(text).toContain("hello from SSE"); + }); +}); diff --git a/tests/cockpit/web/transcript-reducer.test.ts b/tests/cockpit/web/transcript-reducer.test.ts new file mode 100644 index 0000000..792d434 --- /dev/null +++ b/tests/cockpit/web/transcript-reducer.test.ts @@ -0,0 +1,308 @@ +import type { CockpitEvent } from "@/cockpit/core/events"; +import { + initialTranscriptState, + reduceTranscriptEvent, + replayEvents, +} from "@/cockpit/web/transcript-reducer"; +import { describe, expect, it } from "vitest"; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function msg( + id: string, + role: "user" | "assistant" | "system" | "tool", + content: string, +): CockpitEvent { + return { + t: "transcript.append", + sessionId: "s1", + message: { id, role, content }, + }; +} + +function delta(messageId: string, chunk: string): CockpitEvent { + return { t: "transcript.delta", sessionId: "s1", messageId, chunk }; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("transcript reducer — parity-oriented", () => { + it("starts with empty state", () => { + const state = initialTranscriptState(); + expect(state.messages).toEqual([]); + expect(state.toolCalls.size).toBe(0); + expect(state.subagents.size).toBe(0); + expect(state.skillProposals.size).toBe(0); + expect(state.activeProvider).toBeNull(); + expect(state.budgetWarnings).toEqual([]); + expect(state.errors).toEqual([]); + expect(state.policyEvents).toEqual([]); + }); + + it("appends transcript messages in order", () => { + const events: CockpitEvent[] = [ + msg("m1", "user", "hello"), + msg("m2", "assistant", "hi there"), + msg("m3", "system", "context loaded"), + ]; + const state = replayEvents(events); + expect(state.messages).toHaveLength(3); + expect(state.messages[0]?.id).toBe("m1"); + expect(state.messages[0]?.role).toBe("user"); + expect(state.messages[0]?.content).toBe("hello"); + expect(state.messages[1]?.role).toBe("assistant"); + expect(state.messages[2]?.role).toBe("system"); + }); + + it("applies streaming deltas to the correct message", () => { + const events: CockpitEvent[] = [ + msg("m1", "assistant", ""), + delta("m1", "Hello"), + delta("m1", " world"), + ]; + const state = replayEvents(events); + expect(state.messages[0]?.content).toBe("Hello world"); + expect(state.messages[0]?.streaming).toBe(true); + }); + + it("ignores deltas for unknown message IDs", () => { + const events: CockpitEvent[] = [msg("m1", "user", "hi"), delta("m-unknown", "ghost")]; + const state = replayEvents(events); + expect(state.messages).toHaveLength(1); + expect(state.messages[0]?.content).toBe("hi"); + }); + + it("tracks tool call lifecycle: start → progress → end", () => { + const events: CockpitEvent[] = [ + { + t: "tool.start", + sessionId: "s1", + callId: "c1", + name: "web_search", + args: { query: "test" }, + }, + { t: "tool.progress", sessionId: "s1", callId: "c1", chunk: "searching..." }, + { + t: "tool.end", + sessionId: "s1", + callId: "c1", + ok: true, + result: { items: [] }, + }, + ]; + const state = replayEvents(events); + const tc = state.toolCalls.get("c1"); + expect(tc).toBeDefined(); + expect(tc?.name).toBe("web_search"); + expect(tc?.progress).toBe("searching..."); + expect(tc?.ok).toBe(true); + }); + + it("tracks subagent lifecycle: spawn → event → end", () => { + const events: CockpitEvent[] = [ + { + t: "subagent.spawn", + subagentId: "sa1", + backend: "cli-process", + parentSessionId: "s1", + }, + { + t: "subagent.event", + subagentId: "sa1", + kind: "stdout", + chunk: "output line 1\n", + }, + { + t: "subagent.event", + subagentId: "sa1", + kind: "stdout", + chunk: "output line 2\n", + }, + { t: "subagent.end", subagentId: "sa1", ok: true, exit: 0 }, + ]; + const state = replayEvents(events); + const sa = state.subagents.get("sa1"); + expect(sa).toBeDefined(); + expect(sa?.backend).toBe("cli-process"); + expect(sa?.output).toBe("output line 1\noutput line 2\n"); + expect(sa?.status).toBe("completed"); + expect(sa?.exit).toBe(0); + }); + + it("records failed subagent with exit code", () => { + const events: CockpitEvent[] = [ + { + t: "subagent.spawn", + subagentId: "sa2", + backend: "internal", + parentSessionId: "s1", + }, + { t: "subagent.end", subagentId: "sa2", ok: false, exit: 1 }, + ]; + const state = replayEvents(events); + const sa = state.subagents.get("sa2"); + expect(sa?.status).toBe("failed"); + expect(sa?.exit).toBe(1); + }); + + it("tracks skill proposal and decision", () => { + const events: CockpitEvent[] = [ + { + t: "skill.proposal", + proposalId: "p1", + kind: "draft", + payload: { rationale: "useful pattern detected" }, + }, + { + t: "skill.decision", + proposalId: "p1", + decision: "accepted", + by: "user", + }, + ]; + const state = replayEvents(events); + const proposal = state.skillProposals.get("p1"); + expect(proposal).toBeDefined(); + expect(proposal?.kind).toBe("draft"); + expect(proposal?.decision).toBe("accepted"); + expect(proposal?.decidedBy).toBe("user"); + }); + + it("tracks provider switches", () => { + const events: CockpitEvent[] = [ + { t: "provider.switch", from: "xai", to: "anthropic" }, + { t: "provider.switch", from: "anthropic", to: "openai" }, + ]; + const state = replayEvents(events); + expect(state.activeProvider).toBe("openai"); + }); + + it("accumulates budget warnings", () => { + const events: CockpitEvent[] = [ + { + t: "budget.warn", + sessionId: "s1", + dimension: "tokens", + used: 45000, + cap: 50000, + }, + { + t: "budget.warn", + sessionId: "s1", + dimension: "usd", + used: 1800000, + cap: 2000000, + }, + ]; + const state = replayEvents(events); + expect(state.budgetWarnings).toHaveLength(2); + expect(state.budgetWarnings[0]?.dimension).toBe("tokens"); + expect(state.budgetWarnings[1]?.dimension).toBe("usd"); + }); + + it("accumulates errors", () => { + const events: CockpitEvent[] = [ + { + t: "error", + sessionId: "s1", + code: "RATE_LIMIT", + message: "429 from X API", + }, + { + t: "error", + code: "PARSE_ERROR", + message: "malformed response", + }, + ]; + const state = replayEvents(events); + expect(state.errors).toHaveLength(2); + expect(state.errors[0]?.code).toBe("RATE_LIMIT"); + expect(state.errors[1]?.sessionId).toBeUndefined(); + }); + + it("accumulates policy gate events", () => { + const events: CockpitEvent[] = [ + { + t: "policy.gate", + candidateId: "cand-1", + result: "approved", + }, + { + t: "policy.gate", + candidateId: "cand-2", + result: "rejected", + reason: "relevance below threshold", + }, + ]; + const state = replayEvents(events); + expect(state.policyEvents).toHaveLength(2); + expect(state.policyEvents[0]?.result).toBe("approved"); + expect(state.policyEvents[1]?.reason).toBe("relevance below threshold"); + }); + + it("replays a complete scripted session", () => { + const events: CockpitEvent[] = [ + msg("m1", "system", "Welcome to Strand cockpit."), + msg("m2", "user", "scout trending AI topics"), + msg("m3", "assistant", ""), + delta("m3", "Let me search"), + delta("m3", " for that..."), + { + t: "tool.start", + sessionId: "s1", + callId: "tc1", + name: "x_search", + args: { query: "AI trends" }, + }, + { + t: "tool.end", + sessionId: "s1", + callId: "tc1", + ok: true, + result: { tweets: [] }, + }, + { + t: "subagent.spawn", + subagentId: "sub1", + backend: "cli-process", + parentSessionId: "s1", + }, + { + t: "subagent.event", + subagentId: "sub1", + kind: "stdout", + chunk: "analysis complete", + }, + { t: "subagent.end", subagentId: "sub1", ok: true, exit: 0 }, + { + t: "budget.warn", + sessionId: "s1", + dimension: "tokens", + used: 40000, + cap: 50000, + }, + msg("m4", "assistant", "Found 3 trending topics."), + ]; + + const state = replayEvents(events); + + expect(state.messages).toHaveLength(4); + expect(state.messages[2]?.content).toBe("Let me search for that..."); + expect(state.messages[2]?.streaming).toBe(true); + expect(state.messages[3]?.content).toBe("Found 3 trending topics."); + + expect(state.toolCalls.get("tc1")?.ok).toBe(true); + expect(state.subagents.get("sub1")?.status).toBe("completed"); + expect(state.budgetWarnings).toHaveLength(1); + }); + + it("reducer is pure — does not mutate input state", () => { + const state = initialTranscriptState(); + const event: CockpitEvent = msg("m1", "user", "test"); + const next = reduceTranscriptEvent(state, event); + + expect(state.messages).toHaveLength(0); + expect(next.messages).toHaveLength(1); + expect(state).not.toBe(next); + }); +});