217 lines · 8.7 KB · TypeScript
RawDownload
// Chat theater: agent-chat presentation over REAL mcporter MCP calls — no// inference, no third-party agent binary. The scenario stays in Effect land// making real calls; a chat renderer (agent-chat-tui.ts) paints them, so// the recording reads like a developer chatting with an agent while every// tool spinner brackets the genuine call it narrates.//// Two stages for the same play://   - PTY mode (default): the renderer runs in a recorded PTY → terminal.cast//   - Desk mode (E2E_DESK=1): the renderer runs in a visible xterm on the//     virtual desktop (events over a FIFO) and the desk's single x11grab//     films it together with the headed browser — one screen, one mp4//// Division of labor in the e2e stack://   - chat theater (this): deterministic PRODUCT-flow recordings//   - replay brain + real client (replay-brain.ts): CLIENT-behavior tests//     (OpenCode/Claude Code protocol handling), still no inference//   - real-inference evals: a separate axis entirely — performance//     distributions, not pass/fail scenariosimport { spawn, spawnSync } from "node:child_process";import { createWriteStream, existsSync, mkdtempSync } from "node:fs";import { tmpdir } from "node:os";import { dirname, join } from "node:path";import { fileURLToPath } from "node:url"; import { Effect, Exit, Fiber } from "effect"; import { markFocus, markRecordingStart } from "../timeline";import type { CliSurface } from "../surfaces/cli"; const RENDERER = fileURLToPath(new URL("./agent-chat-tui.ts", import.meta.url)); interface TheaterEvent {  readonly type: "user" | "assistant" | "tool-start" | "tool-end" | "status" | "done";  readonly [key: string]: unknown;} export interface ChatTheater {  /** The human's chat line, typed out on screen. */  readonly user: (text: string) => Effect.Effect<void>;  /** The agent's reply, streamed on screen. */  readonly assistant: (text: string) => Effect.Effect<void>;  /** Run a REAL effect rendered as a tool call: the tool's name, the call's   *  real input block, a spinner for exactly as long as the call runs, then   *  the call's real result line with the real duration. */  readonly tool: <A, E, R>(    call: {      /** Tool name as an agent TUI would show it (e.g. "execute"). */      readonly name: string;      /** The call's real input — rendered as the indented block. */      readonly input?: string;      /** Render the result's display line (defaults to JSON of the value). */      readonly result?: (value: A) => string;    },    work: Effect.Effect<A, E, R>,  ) => Effect.Effect<A, E, R>;  /** A dim narrator line (e.g. "waiting for the browser hop"). */  readonly status: (text: string) => Effect.Effect<void>;} const sleep = (ms: number) => new Promise<void>((tick) => setTimeout(tick, ms)); const encode = (event: TheaterEvent) =>  `${Buffer.from(JSON.stringify(event)).toString("base64")}\n`; /** Input block, like a TUI's call preview: trimmed lines, capped count. */const inputPreview = (input: string | undefined): string[] | undefined => {  if (!input) return undefined;  const lines = input    .split("\n")    .map((line) => line.trimEnd())    .filter((line) => line.trim().length > 0);  return lines.length > 7 ? [...lines.slice(0, 6), `… +${lines.length - 6} lines`] : lines;}; const makeTheater = (push: (event: TheaterEvent) => Effect.Effect<void>): ChatTheater => ({  user: (text) => push({ type: "user", text }),  assistant: (text) => push({ type: "assistant", text }),  status: (text) => push({ type: "status", text }),  tool: (call, work) =>    Effect.gen(function* () {      yield* push({ type: "tool-start", name: call.name, input: inputPreview(call.input) });      const startedAt = Date.now();      const exit = yield* Effect.exit(work);      const seconds = (Date.now() - startedAt) / 1000;      if (Exit.isSuccess(exit)) {        const line = call.result ? call.result(exit.value) : JSON.stringify(exit.value);        yield* push({ type: "tool-end", ok: true, result: line, seconds });        return exit.value;      }      yield* push({ type: "tool-end", ok: false, result: "failed", seconds });      return yield* exit;    }),}); export interface TheaterOptions {  readonly title: string;  readonly record: string;  readonly viewport?: { readonly cols: number; readonly rows: number };} /** * Open the chat renderer, hand the scenario a ChatTheater handle, and close * the session (footer + exit) when the body finishes — success or failure, * so the recording always ends cleanly. */export const withChatTheater = <A, E, R>(  cli: CliSurface,  options: TheaterOptions,  body: (theater: ChatTheater) => Effect.Effect<A, E, R>,): Effect.Effect<A, E, R> =>  process.env.E2E_DESK === "1" ? deskTheater(options, body) : ptyTheater(cli, options, body); // ---------------------------------------------------------------------------// PTY mode: renderer in a recorded PTY; events typed in as base64 lines.// --------------------------------------------------------------------------- const ptyTheater = <A, E, R>(  cli: CliSurface,  options: TheaterOptions,  body: (theater: ChatTheater) => Effect.Effect<A, E, R>,): Effect.Effect<A, E, R> =>  Effect.gen(function* () {    const runDir = dirname(options.record);    const queue: TheaterEvent[] = [];    let closed = false;    const push = (event: TheaterEvent) =>      Effect.sync(() => {        if (!closed) {          // Chatting IS focusing the terminal window.          if (event.type !== "done") markFocus(runDir, "terminal");          queue.push(event);        }      });     const pump = cli.session(      ["bun", RENDERER, options.title],      async (term) => {        // Don't type until the renderer has painted its header — before that        // it hasn't set raw mode yet, and the PTY would echo the event line        // into the recording.        await term.screen.waitForText(options.title, { timeoutMs: 30_000 });        // The cast's clock started with the PTY moments ago; anchor it for        // the run's focus timeline (scripts/film.ts cuts on these).        markRecordingStart(runDir, "terminal");        const deadline = Date.now() + 30 * 60 * 1000;        for (;;) {          const event = queue.shift();          if (event) {            await term.keyboard.type(encode(event));            if (event.type === "done") break;            continue;          }          if (Date.now() > deadline) break;          await sleep(40);        }        await term.screen.waitForText("session complete", { timeoutMs: 60_000 });      },      {        record: options.record,        viewport: options.viewport ?? { cols: 100, rows: 32 },      },    );    const pumpFiber = yield* Effect.forkChild(Effect.exit(pump));     const result = yield* Effect.exit(body(makeTheater(push)));    yield* push({ type: "done" });    closed = true;    yield* Fiber.join(pumpFiber);    return yield* result;  }); // ---------------------------------------------------------------------------// Desk mode: renderer in a visible xterm on the virtual desktop; events over// a FIFO. No cast — the desk's x11grab films the screen.// --------------------------------------------------------------------------- const deskTheater = <A, E, R>(  options: TheaterOptions,  body: (theater: ChatTheater) => Effect.Effect<A, E, R>,): Effect.Effect<A, E, R> =>  Effect.gen(function* () {    const root = mkdtempSync(join(tmpdir(), "desk-theater-"));    const fifo = join(root, "events.fifo");    spawnSync("mkfifo", [fifo]);     const xterm = spawn(      "xterm",      [        // -u8 + a UTF-8 locale: the renderer speaks box-drawing and bullets.        ...["-u8", "-fa", "DejaVu Sans Mono", "-fs", "13"],        ...["-bg", "#0b0b10", "-fg", "#e8e8ea"],        ...["-geometry", "112x34+48+40", "-T", options.title],        ...["-e", "bun", RENDERER, options.title, fifo],      ],      { stdio: "ignore", env: { ...process.env, LANG: "C.UTF-8", LC_ALL: "C.UTF-8" } },    );    // Opening the write end parks until the renderer opens the read end.    const writer = createWriteStream(fifo);    const push = (event: TheaterEvent) =>      Effect.sync(() => {        writer.write(encode(event));      });     const result = yield* Effect.exit(body(makeTheater(push)));    yield* push({ type: "done" });    yield* Effect.promise(async () => {      // Renderer acks the footer, lingers for the camera, then exits with      // its xterm. Don't hang the run on a wedged terminal.      const ackDeadline = Date.now() + 30_000;      while (!existsSync(`${fifo}.done`) && Date.now() < ackDeadline) await sleep(200);      const exitDeadline = Date.now() + 10_000;      while (xterm.exitCode === null && Date.now() < exitDeadline) await sleep(200);      if (xterm.exitCode === null) xterm.kill();      writer.end();    });    return yield* result;  });