From eea1f9b8c39a50ec5f126d56b081e36964c3e180 Mon Sep 17 00:00:00 2001
From: Rhys Sullivan
Date: Thu, 11 Jun 2026 16:37:02 -0700
Subject: [PATCH 01/11] Developer-session e2e: real OpenCode chat on camera via
a replay brain
A scenario now runs the way a developer actually tests the product: chat
with the real OpenCode TUI in a recorded PTY, the agent registers an API
and drops a connect link in the chat (real MCP tool execution), a browser
opens the link and pastes a real emulator-minted key while the terminal
session stays open, and the chat resumes to verify the connection with a
live send confirmed by the provider emulator's request ledger.
- e2e/src/clients/replay-brain.ts: local OpenAI-wire chat-completions
server (SSE + tool calls) scripted per scenario by transcript
inspection, so approval pauses and OpenCode side requests don't derail
it. Tool names resolve by suffix against whatever the agent offers.
- clients/opencode.ts: makeOpenCodeHome optionally wires a 'replay'
provider (openai-compatible) at the brain and pre-allows permissions.
- scenarios/connect-handoff-session.test.ts: one continuous terminal.cast
timeline with the browser hop forked concurrently; chat-brain.json
transcript written as a run artifact even on failure.
---
e2e/scenarios/connect-handoff-session.test.ts | 371 ++++++++++++++++++
e2e/src/clients/opencode.ts | 30 +-
e2e/src/clients/replay-brain.ts | 208 ++++++++++
3 files changed, 607 insertions(+), 2 deletions(-)
create mode 100644 e2e/scenarios/connect-handoff-session.test.ts
create mode 100644 e2e/src/clients/replay-brain.ts
diff --git a/e2e/scenarios/connect-handoff-session.test.ts b/e2e/scenarios/connect-handoff-session.test.ts
new file mode 100644
index 000000000..e0e8c62ec
--- /dev/null
+++ b/e2e/scenarios/connect-handoff-session.test.ts
@@ -0,0 +1,371 @@
+// The connect handoff as a DEVELOPER SESSION — the way a human actually
+// tests this: chat with a real agent in a real terminal, the agent wires up
+// the API over MCP and drops a connect link in the chat, you open the link
+// in a browser and paste your key, then come back to the terminal and ask
+// the agent to prove the connection works.
+//
+// Replay brain, real hands: the LLM is a scripted local server
+// (clients/replay-brain.ts) so the conversation is deterministic, but
+// EVERYTHING else is real — the installed OpenCode binary renders the TUI,
+// does MCP OAuth against this deployment, executes the brain's tool calls
+// through the real /mcp endpoint, and the pasted key round-trips through the
+// real add-account UI into a connection that hits the real emulated provider
+// (resend.emulators.dev), whose request ledger is the final evidence.
+//
+// The terminal PTY stays open for the WHOLE session (one terminal.cast,
+// one timeline); the browser hop runs concurrently as the "user" and the
+// chat resumes when it's done. A Desk surface can later film both windows
+// as one screen recording without changing this scenario's shape.
+import { spawnSync } from "node:child_process";
+import { randomBytes } from "node:crypto";
+import { mkdirSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { expect } from "@effect/vitest";
+import { Effect, Exit, Fiber } from "effect";
+
+import { scenario } from "../src/scenario";
+import { Browser, Cli, Mcp, OpenCode, RunDir, Target } from "../src/services";
+import {
+ serveReplayBrain,
+ type BrainContext,
+ type BrainResponse,
+} from "../src/clients/replay-brain";
+
+const SERVER_NAME = "executor";
+const EMULATOR_BASE = "https://resend.emulators.dev";
+
+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
+
+/** Resend subset spec pointed at the emulator, with an explicit apiKey
+ * template so the add-account modal renders a paste-a-token flow. */
+const resendSpec = {
+ openapi: "3.0.3",
+ info: { title: "Resend (emulated)", version: "1.0.0" },
+ paths: {
+ "/emails": {
+ post: {
+ operationId: "sendEmail",
+ tags: ["emails"],
+ requestBody: {
+ required: true,
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ from: { type: "string" },
+ to: { type: "string" },
+ subject: { type: "string" },
+ html: { type: "string" },
+ },
+ required: ["from", "to", "subject"],
+ },
+ },
+ },
+ },
+ responses: { "200": { description: "sent" } },
+ },
+ },
+ },
+} as const;
+
+const addSpecCode = (slug: string) => `
+const added = await tools.executor.openapi.addSpec({
+ spec: { kind: "blob", value: ${JSON.stringify(JSON.stringify(resendSpec))} },
+ slug: ${JSON.stringify(slug)},
+ baseUrl: ${JSON.stringify(EMULATOR_BASE)},
+ authenticationTemplate: [{
+ type: "apiKey",
+ label: "API key",
+ headers: { Authorization: ["Bearer ", { type: "variable", name: "apiKey" }] },
+ }],
+});
+return added.ok ? { ok: true, slug: added.data.slug, toolCount: added.data.toolCount } : { ok: false, error: added.error };
+`;
+
+const createHandoffCode = (slug: string) => `
+const handoff = await tools.executor.coreTools.connections.createHandoff({
+ integration: ${JSON.stringify(slug)},
+ owner: "org",
+ label: "Resend",
+});
+return handoff.ok ? { ok: true, url: handoff.data.url } : { ok: false, error: handoff.error };
+`;
+
+const sendEmailCode = (slug: string, subject: string) => `
+const found = await tools.search({ namespace: ${JSON.stringify(slug)}, query: "send email", limit: 5 });
+const path = found.items[0]?.path;
+if (!path) return { ok: false, error: "no send tool found" };
+let t = tools;
+for (const seg of path.split(".")) t = t[seg];
+const sent = await t({
+ body: {
+ from: "onboarding@example.com",
+ to: "dev-session@example.com",
+ subject: ${JSON.stringify(subject)},
+ html: "
connect-handoff developer session
",
+ },
+});
+return { ok: sent.ok, path, result: sent.ok ? sent.data : sent.error };
+`;
+
+// Lines the terminal waits for — the brain says them, the PTY assertion
+// reads them off the rendered TUI. Kept short and ASCII so terminal line
+// wrapping and glyph rendering can't break the substring match.
+const SAY_LINK_READY = "Open this link to connect your Resend account";
+const SAY_EMAIL_SENT = "Test email sent";
+
+interface SessionState {
+ handoffUrl: string | undefined;
+ browserDone: boolean;
+ scriptNotes: string[];
+}
+
+/** The scripted side of the conversation, as inspection of the transcript —
+ * no turn counting, so approval-pause detours and OpenCode's side requests
+ * (title generation etc.) don't derail it. */
+const makeBrainScript = (input: {
+ readonly integration: string;
+ readonly emailSubject: string;
+ readonly state: SessionState;
+}) => {
+ const { integration, emailSubject, state } = input;
+ return (ctx: BrainContext): BrainResponse => {
+ // OpenCode's very first request can fire before its MCP tool registry
+ // loads (zero tools offered). Stall politely; it re-asks with tools.
+ if (ctx.toolNames.length === 0) {
+ return { text: "One moment…" };
+ }
+ // Agent returned a tool result — continue the current job.
+ if (ctx.lastRole === "tool" && ctx.lastToolResult !== undefined) {
+ const result = ctx.lastToolResult;
+
+ // Approval gate: resume whatever paused.
+ const paused = /executionId[":\s]+"?([\w-]+)/.exec(result);
+ if (result.includes("Execution paused") && paused?.[1]) {
+ return {
+ tool: { name: "resume", args: { executionId: paused[1], action: "accept" } },
+ };
+ }
+
+ const handoffUrl = /https?:\/\/[^"\s\\]+\/integrations\/[^"\s\\]+/.exec(result)?.[0];
+ if (handoffUrl) {
+ state.handoffUrl = handoffUrl;
+ return {
+ text: `${SAY_LINK_READY}:\n\n${handoffUrl}\n\nTell me once you've pasted your API key.`,
+ };
+ }
+
+ if (result.includes('"toolCount"')) {
+ return {
+ text: "The Resend API is registered. Creating your connect link…",
+ tool: { name: "execute", args: { code: createHandoffCode(integration) } },
+ };
+ }
+
+ if (result.includes(emailSubject) || /"ok":\s*true/.test(result)) {
+ return { text: `${SAY_EMAIL_SENT} - your Resend connection works.` };
+ }
+
+ state.scriptNotes.push(`unexpected tool result: ${result.slice(0, 300)}`);
+ return { text: "Something unexpected came back — stopping here." };
+ }
+
+ // Fresh human turn.
+ if (/add the resend api/i.test(ctx.lastUser)) {
+ return {
+ text: "I'll register the Resend API in your Executor now.",
+ tool: { name: "execute", args: { code: addSpecCode(integration) } },
+ };
+ }
+ if (/send a test email/i.test(ctx.lastUser)) {
+ return {
+ text: "Sending a test email through your new connection…",
+ tool: { name: "execute", args: { code: sendEmailCode(integration, emailSubject) } },
+ };
+ }
+ // OpenCode side requests (session titles, warm-up pings).
+ return { text: "Connect Resend" };
+ };
+};
+
+const mintEmulatorApiKey = Effect.promise(async () => {
+ const response = await fetch(`${EMULATOR_BASE}/_emulate/credentials`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ type: "api-key" }),
+ });
+ const body = (await response.json()) as { credential?: { token?: string } };
+ const token = body.credential?.token;
+ if (!token) throw new Error(`emulator credential mint failed: ${JSON.stringify(body)}`);
+ return token;
+});
+
+const sleep = (ms: number) => new Promise((tick) => setTimeout(tick, ms));
+
+scenario(
+ "Connect · developer session: agent chat → handoff link → paste key → verified send",
+ { timeout: 360_000 },
+ Effect.scoped(
+ Effect.gen(function* () {
+ const target = yield* Target;
+ const mcp = yield* Mcp;
+ const browser = yield* Browser;
+ const opencode = yield* OpenCode;
+ const cli = yield* Cli;
+ const runDir = yield* RunDir;
+
+ const integration = unique("resendsesh");
+ const emailSubject = unique("dev-session");
+ const apiKey = yield* mintEmulatorApiKey;
+ const identity = yield* target.newIdentity();
+ const email = identity.credentials?.email ?? identity.label;
+
+ const state: SessionState = { handoffUrl: undefined, browserDone: false, scriptNotes: [] };
+ const brain = yield* serveReplayBrain(makeBrainScript({ integration, emailSubject, state }));
+ // The conversation transcript is evidence too — written even on failure.
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() =>
+ writeFileSync(
+ join(runDir, "chat-brain.json"),
+ JSON.stringify(
+ { requests: brain.requests(), errors: brain.errors(), notes: state.scriptNotes },
+ null,
+ 2,
+ ),
+ ),
+ ),
+ );
+
+ const home = opencode.makeHome(SERVER_NAME, mcp.url, { chatBrainUrl: brain.baseUrl });
+ yield* Effect.sync(() => opencode.warmUp(home));
+ // Pre-download the openai-compatible provider package off camera, in a
+ // project with the brain but NO MCP (touching /mcp before `mcp auth`
+ // breaks the auth flow — see warmUp).
+ yield* Effect.sync(() => {
+ const warm = join(home.projectDir, "..", "chat-warmup");
+ mkdirSync(warm, { recursive: true });
+ writeFileSync(
+ join(warm, "opencode.json"),
+ JSON.stringify({
+ autoupdate: false,
+ share: "disabled",
+ model: "replay/replay-model",
+ provider: {
+ replay: {
+ name: "Replay",
+ npm: "@ai-sdk/openai-compatible",
+ options: { baseURL: brain.baseUrl, apiKey: "replay-key" },
+ models: { "replay-model": { name: "Replay Model" } },
+ },
+ },
+ }),
+ );
+ spawnSync("opencode", ["run", "-m", "replay/replay-model", "warmup ping"], {
+ cwd: warm,
+ env: home.env,
+ timeout: 120_000,
+ });
+ });
+
+ // The "user at the browser": waits for the agent to produce the link,
+ // opens it, pastes the key — while the terminal session stays open.
+ const browserWork = Effect.gen(function* () {
+ yield* Effect.promise(async () => {
+ const deadline = Date.now() + 240_000;
+ while (state.handoffUrl === undefined) {
+ if (Date.now() > deadline) throw new Error("handoff URL never appeared in chat");
+ await sleep(300);
+ }
+ });
+ yield* browser.session(identity, async ({ page, step }) => {
+ await step("Open the connect link from the chat", async () => {
+ await page.goto(state.handoffUrl ?? "", { waitUntil: "networkidle" });
+ await page
+ .getByRole("heading", { name: /Add connection/ })
+ .waitFor({ timeout: 15_000 });
+ });
+ await step("Paste the Resend API key and connect", async () => {
+ const credential = page.getByPlaceholder(/paste the value \/ token/i);
+ await credential.waitFor({ timeout: 15_000 });
+ await credential.fill(apiKey);
+ await page.getByRole("button", { name: "Add connection", exact: true }).click();
+ await page
+ .getByRole("heading", { name: /Add connection/ })
+ .waitFor({ state: "hidden", timeout: 20_000 });
+ });
+ });
+ });
+ const browserFiber = yield* Effect.forkChild(
+ Effect.gen(function* () {
+ const exit = yield* Effect.exit(browserWork);
+ state.browserDone = true;
+ return exit;
+ }),
+ );
+
+ yield* cli.session(
+ ["bash", "--norc"],
+ async (term) => {
+ await term.screen.waitForText("$", { timeoutMs: 10_000 });
+
+ // MCP OAuth on camera, consent played off camera. Wait for the
+ // flow's own completion text — the shell prompt was already on
+ // screen before the command, so it can't gate anything.
+ const consent = opencode.completeOAuthConsent(home, email, home.openedUrls().length);
+ await term.keyboard.type(`opencode mcp auth ${SERVER_NAME}`);
+ await term.keyboard.press("Enter");
+ await consent;
+ await term.screen.waitForText("Authentication successful", { timeoutMs: 60_000 });
+ await sleep(1_500); // let the auth UI finish and the prompt repaint
+
+ // Into the TUI for the conversation.
+ await term.keyboard.type("opencode");
+ await term.keyboard.press("Enter");
+ await term.screen.waitForText("Replay Model", { timeoutMs: 90_000 });
+
+ await term.keyboard.type(
+ "Add the Resend API to my executor and give me a link to connect my account",
+ );
+ await term.keyboard.press("Enter");
+ await term.screen.waitForText(SAY_LINK_READY, { timeoutMs: 180_000 });
+
+ // The human walks over to the browser; the chat waits.
+ const deadline = Date.now() + 180_000;
+ while (!state.browserDone) {
+ if (Date.now() > deadline) throw new Error("browser side never finished");
+ await sleep(300);
+ }
+
+ await sleep(750); // let the TUI settle before the next message
+ await term.keyboard.type("Connected, now send a test email to prove it works");
+ await term.keyboard.press("Enter");
+ await term.screen.waitForText(SAY_EMAIL_SENT, { timeoutMs: 180_000 });
+ },
+ {
+ cwd: home.projectDir,
+ env: { ...home.env, PS1: "$ ", BASH_SILENCE_DEPRECATION_WARNING: "1" },
+ record: join(runDir, "terminal.cast"),
+ viewport: { cols: 100, rows: 40 },
+ },
+ );
+
+ const browserExit = yield* Fiber.join(browserFiber);
+ expect(Exit.isSuccess(browserExit), `browser side succeeded: ${String(browserExit)}`).toBe(
+ true,
+ );
+ expect(brain.errors(), "replay brain script ran clean").toEqual([]);
+ expect(state.scriptNotes, "conversation followed the script").toEqual([]);
+
+ // Final evidence: the emulator's ledger saw the send from Executor.
+ const ledger = yield* Effect.promise(async () =>
+ (await fetch(`${EMULATOR_BASE}/_emulate/ledger`)).text(),
+ );
+ expect(
+ ledger.includes(emailSubject),
+ "the emulator request ledger recorded the test email",
+ ).toBe(true);
+ }),
+ ),
+);
diff --git a/e2e/src/clients/opencode.ts b/e2e/src/clients/opencode.ts
index 6d3c9ead3..f4242063e 100644
--- a/e2e/src/clients/opencode.ts
+++ b/e2e/src/clients/opencode.ts
@@ -24,8 +24,18 @@ export interface OpenCodeHome {
) => { accessToken?: string; refreshToken?: string; expiresAt?: number } | undefined;
}
-/** A throwaway OpenCode installation configured with one remote MCP server. */
-export const makeOpenCodeHome = (serverName: string, mcpUrl: string): OpenCodeHome => {
+/** A throwaway OpenCode installation configured with one remote MCP server.
+ *
+ * With `chatBrainUrl` set, the config also declares a `replay` provider
+ * pointing OpenCode's LLM traffic at a local replay brain
+ * (clients/replay-brain.ts) and selects it as the model — real agent,
+ * scripted conversation. Tool permissions are pre-allowed so the recorded
+ * TUI session flows without approval dialogs. */
+export const makeOpenCodeHome = (
+ serverName: string,
+ mcpUrl: string,
+ options?: { readonly chatBrainUrl?: string },
+): OpenCodeHome => {
const root = mkdtempSync(join(tmpdir(), "e2e-opencode-"));
const projectDir = join(root, "project");
const dataDir = join(root, "data");
@@ -38,6 +48,22 @@ export const makeOpenCodeHome = (serverName: string, mcpUrl: string): OpenCodeHo
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: { [serverName]: { type: "remote", url: mcpUrl } },
+ ...(options?.chatBrainUrl
+ ? {
+ autoupdate: false,
+ share: "disabled",
+ model: "replay/replay-model",
+ permission: { "*": "allow" },
+ provider: {
+ replay: {
+ name: "Replay",
+ npm: "@ai-sdk/openai-compatible",
+ options: { baseURL: options.chatBrainUrl, apiKey: "replay-key" },
+ models: { "replay-model": { name: "Replay Model" } },
+ },
+ },
+ }
+ : {}),
}),
);
// OpenCode launches the OAuth URL via `open`; the shim records it instead.
diff --git a/e2e/src/clients/replay-brain.ts b/e2e/src/clients/replay-brain.ts
new file mode 100644
index 000000000..2d5ac49bb
--- /dev/null
+++ b/e2e/src/clients/replay-brain.ts
@@ -0,0 +1,208 @@
+// Replay brain: a local server speaking the OpenAI chat-completions wire
+// (SSE streaming, tool calls) that a REAL agent (OpenCode) uses as its LLM.
+// The scenario scripts the brain; the agent does everything else for real —
+// TUI rendering, MCP discovery/auth, tool execution against the target,
+// result round-trips. "Replay brain, real hands": deterministic conversation,
+// zero modeled client behavior.
+//
+// The brain is a state machine, not a fixed turn list: each request gets a
+// normalized view of the conversation so far and the scenario's `respond`
+// callback decides what the "model" says next (text, a tool call, or both).
+// That lets a script absorb variable-length detours — approval pauses that
+// need a resume call, retries — without index bookkeeping.
+import { createServer, type Server } from "node:http";
+import type { AddressInfo } from "node:net";
+
+import { Effect, Scope } from "effect";
+
+/** What the scenario's script sees per request. */
+export interface BrainContext {
+ /** 0-based count of requests served before this one. */
+ readonly requestIndex: number;
+ /** Role of the final message — "user" means a fresh human turn, "tool"
+ * means the agent is returning a tool result for the brain to continue. */
+ readonly lastRole: string;
+ /** The latest user-role message content (the human's chat input). */
+ readonly lastUser: string;
+ /** The latest tool-role message content (last tool result), if any. */
+ readonly lastToolResult: string | undefined;
+ /** Tool names offered in this request (already namespaced by the agent). */
+ readonly toolNames: ReadonlyArray;
+}
+
+/** What the "model" does next. `tool.name` may be a suffix — the brain
+ * resolves it against the request's offered tool names, so scripts don't
+ * hardcode the agent's MCP namespacing. */
+export interface BrainResponse {
+ readonly text?: string;
+ readonly tool?: { readonly name: string; readonly args: unknown };
+}
+
+export interface ReplayBrain {
+ /** Base URL for the agent's provider config (…/v1). */
+ readonly baseUrl: string;
+ /** Every request body served, in order (for post-hoc assertions). */
+ readonly requests: () => ReadonlyArray;
+ /** Script errors (mismatched expectations, throws) — assert empty. */
+ readonly errors: () => ReadonlyArray;
+}
+
+export interface BrainRequest {
+ readonly messages: ReadonlyArray<{ role: string; content: string }>;
+ readonly toolNames: ReadonlyArray;
+}
+
+interface WireMessage {
+ readonly role: string;
+ readonly content?: unknown;
+ readonly tool_calls?: ReadonlyArray;
+}
+
+interface WireBody {
+ readonly messages?: ReadonlyArray;
+ readonly tools?: ReadonlyArray<{ function?: { name?: string } }>;
+}
+
+const contentText = (content: unknown): string => {
+ if (typeof content === "string") return content;
+ if (Array.isArray(content)) {
+ return content
+ .map((part) =>
+ typeof part === "object" && part !== null && "text" in part
+ ? String((part as { text: unknown }).text)
+ : "",
+ )
+ .join("");
+ }
+ return "";
+};
+
+const sseChunk = (delta: Record, finish: string | null) =>
+ `data: ${JSON.stringify({
+ id: "chatcmpl-replay",
+ object: "chat.completion.chunk",
+ created: 0,
+ model: "replay-model",
+ choices: [{ index: 0, delta, finish_reason: finish }],
+ })}\n\n`;
+
+/**
+ * Serve a scripted brain for the lifetime of the surrounding scope.
+ * `respond` is called once per chat-completions request.
+ */
+export const serveReplayBrain = (
+ respond: (ctx: BrainContext) => BrainResponse,
+): Effect.Effect =>
+ Effect.acquireRelease(
+ Effect.callback<{ server: Server; brain: ReplayBrain }>((resume) => {
+ const served: BrainRequest[] = [];
+ const errors: string[] = [];
+
+ const server = createServer((request, response) => {
+ if (!request.url?.includes("/chat/completions")) {
+ response.writeHead(404).end();
+ return;
+ }
+ let raw = "";
+ request.on("data", (piece: Buffer) => (raw += piece.toString("utf8")));
+ request.on("end", () => {
+ const body = JSON.parse(raw || "{}") as WireBody;
+ const messages = (body.messages ?? []).map((message) => ({
+ role: message.role,
+ content: contentText(message.content),
+ }));
+ const toolNames = (body.tools ?? [])
+ .map((tool) => tool.function?.name ?? "")
+ .filter(Boolean);
+ const requestIndex = served.length;
+ served.push({ messages, toolNames });
+
+ const lastOf = (role: string) =>
+ [...messages].reverse().find((message) => message.role === role)?.content;
+
+ let scripted: BrainResponse;
+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a throwing script must surface as a recorded error + a stop response, not a hung agent
+ try {
+ scripted = respond({
+ requestIndex,
+ lastRole: messages.at(-1)?.role ?? "",
+ lastUser: lastOf("user") ?? "",
+ lastToolResult: lastOf("tool"),
+ toolNames,
+ });
+ } catch (error) {
+ errors.push(`respond() threw on request ${requestIndex}: ${String(error)}`);
+ scripted = { text: "(replay brain script error)" };
+ }
+
+ // Resolve a scripted tool name against the agent's namespaced names.
+ let resolvedTool: { name: string; args: unknown } | undefined;
+ if (scripted.tool) {
+ const wanted = scripted.tool.name;
+ const match =
+ toolNames.find((name) => name === wanted) ??
+ toolNames.find((name) => name.endsWith(wanted));
+ if (match) {
+ resolvedTool = { name: match, args: scripted.tool.args };
+ } else {
+ errors.push(
+ `request ${requestIndex}: no offered tool matches "${wanted}" (offered: ${toolNames.join(", ")})`,
+ );
+ }
+ }
+
+ response.writeHead(200, {
+ "content-type": "text/event-stream",
+ "cache-control": "no-cache",
+ connection: "keep-alive",
+ });
+ response.write(sseChunk({ role: "assistant" }, null));
+ if (scripted.text) {
+ // A few chunks so the TUI streams like a real model reply.
+ for (const piece of scripted.text.match(/.{1,24}/gs) ?? []) {
+ response.write(sseChunk({ content: piece }, null));
+ }
+ }
+ if (resolvedTool) {
+ response.write(
+ sseChunk(
+ {
+ tool_calls: [
+ {
+ index: 0,
+ id: `call_${requestIndex}`,
+ type: "function",
+ function: {
+ name: resolvedTool.name,
+ arguments: JSON.stringify(resolvedTool.args ?? {}),
+ },
+ },
+ ],
+ },
+ null,
+ ),
+ );
+ }
+ response.write(sseChunk({}, resolvedTool ? "tool_calls" : "stop"));
+ response.write("data: [DONE]\n\n");
+ response.end();
+ });
+ });
+
+ server.listen(0, "127.0.0.1", () => {
+ const { port } = server.address() as AddressInfo;
+ resume(
+ Effect.succeed({
+ server,
+ brain: {
+ baseUrl: `http://127.0.0.1:${port}/v1`,
+ requests: () => served,
+ errors: () => errors,
+ },
+ }),
+ );
+ });
+ }),
+ (resource: { server: Server; brain: ReplayBrain }) =>
+ Effect.sync(() => void resource.server.close()),
+ ).pipe(Effect.map(({ brain }) => brain));
From fd98701d76ad8681c6d691a1c01b0ae33ac7e875 Mon Sep 17 00:00:00 2001
From: Rhys Sullivan
Date: Thu, 11 Jun 2026 16:45:31 -0700
Subject: [PATCH 02/11] Chat theater: agent-chat recordings over real mcporter
calls, no inference
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The developer-session recording no longer needs a third-party agent binary
or a faked LLM: the 'agent' is a chat presentation (agent-chat-tui.ts, a
renderer in the recorded PTY) driven by chat-theater.ts while the scenario
makes REAL mcporter MCP calls — OAuth, execute, approval pause/resume all
genuine, each on-screen tool spinner bracketing the actual call it
narrates. Runs anywhere bun runs (no opencode capability), ~28s on cloud.
replay-brain.ts stays for the distinct tier where the third-party client's
own protocol behavior is under test (OpenCode/Claude Code); real-inference
evals remain a separate axis (performance distributions, not pass/fail
scenarios).
---
e2e/scenarios/connect-handoff-session.test.ts | 336 +++++-------------
e2e/src/clients/agent-chat-tui.ts | 128 +++++++
e2e/src/clients/chat-theater.ts | 118 ++++++
3 files changed, 339 insertions(+), 243 deletions(-)
create mode 100644 e2e/src/clients/agent-chat-tui.ts
create mode 100644 e2e/src/clients/chat-theater.ts
diff --git a/e2e/scenarios/connect-handoff-session.test.ts b/e2e/scenarios/connect-handoff-session.test.ts
index e0e8c62ec..a1f5911e8 100644
--- a/e2e/scenarios/connect-handoff-session.test.ts
+++ b/e2e/scenarios/connect-handoff-session.test.ts
@@ -1,38 +1,25 @@
// The connect handoff as a DEVELOPER SESSION — the way a human actually
-// tests this: chat with a real agent in a real terminal, the agent wires up
-// the API over MCP and drops a connect link in the chat, you open the link
-// in a browser and paste your key, then come back to the terminal and ask
-// the agent to prove the connection works.
+// tests this: an agent chat in a real terminal where the agent wires up the
+// API over MCP and drops a connect link, a browser hop to paste the key,
+// then back to the chat to prove the connection works with a live send.
//
-// Replay brain, real hands: the LLM is a scripted local server
-// (clients/replay-brain.ts) so the conversation is deterministic, but
-// EVERYTHING else is real — the installed OpenCode binary renders the TUI,
-// does MCP OAuth against this deployment, executes the brain's tool calls
-// through the real /mcp endpoint, and the pasted key round-trips through the
-// real add-account UI into a connection that hits the real emulated provider
-// (resend.emulators.dev), whose request ledger is the final evidence.
-//
-// The terminal PTY stays open for the WHOLE session (one terminal.cast,
-// one timeline); the browser hop runs concurrently as the "user" and the
-// chat resumes when it's done. A Desk surface can later film both windows
-// as one screen recording without changing this scenario's shape.
-import { spawnSync } from "node:child_process";
+// No inference, no third-party agent binary: the "agent" is the chat
+// theater (src/clients/chat-theater.ts) presenting REAL mcporter MCP calls
+// — OAuth, execute, approval pause/resume all genuine, every tool spinner
+// on screen bracketing the actual call it narrates. The provider on the
+// other side is real too (resend.emulators.dev); its request ledger is the
+// final evidence.
import { randomBytes } from "node:crypto";
-import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { expect } from "@effect/vitest";
-import { Effect, Exit, Fiber } from "effect";
+import { Effect } from "effect";
import { scenario } from "../src/scenario";
-import { Browser, Cli, Mcp, OpenCode, RunDir, Target } from "../src/services";
-import {
- serveReplayBrain,
- type BrainContext,
- type BrainResponse,
-} from "../src/clients/replay-brain";
+import { Browser, Cli, Mcp, RunDir, Target } from "../src/services";
+import { withChatTheater } from "../src/clients/chat-theater";
+import type { McpSession } from "../src/surfaces/mcp";
-const SERVER_NAME = "executor";
const EMULATOR_BASE = "https://resend.emulators.dev";
const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
@@ -110,85 +97,17 @@ const sent = await t({
return { ok: sent.ok, path, result: sent.ok ? sent.data : sent.error };
`;
-// Lines the terminal waits for — the brain says them, the PTY assertion
-// reads them off the rendered TUI. Kept short and ASCII so terminal line
-// wrapping and glyph rendering can't break the substring match.
-const SAY_LINK_READY = "Open this link to connect your Resend account";
-const SAY_EMAIL_SENT = "Test email sent";
-
-interface SessionState {
- handoffUrl: string | undefined;
- browserDone: boolean;
- scriptNotes: string[];
-}
-
-/** The scripted side of the conversation, as inspection of the transcript —
- * no turn counting, so approval-pause detours and OpenCode's side requests
- * (title generation etc.) don't derail it. */
-const makeBrainScript = (input: {
- readonly integration: string;
- readonly emailSubject: string;
- readonly state: SessionState;
-}) => {
- const { integration, emailSubject, state } = input;
- return (ctx: BrainContext): BrainResponse => {
- // OpenCode's very first request can fire before its MCP tool registry
- // loads (zero tools offered). Stall politely; it re-asks with tools.
- if (ctx.toolNames.length === 0) {
- return { text: "One moment…" };
- }
- // Agent returned a tool result — continue the current job.
- if (ctx.lastRole === "tool" && ctx.lastToolResult !== undefined) {
- const result = ctx.lastToolResult;
-
- // Approval gate: resume whatever paused.
- const paused = /executionId[":\s]+"?([\w-]+)/.exec(result);
- if (result.includes("Execution paused") && paused?.[1]) {
- return {
- tool: { name: "resume", args: { executionId: paused[1], action: "accept" } },
- };
- }
-
- const handoffUrl = /https?:\/\/[^"\s\\]+\/integrations\/[^"\s\\]+/.exec(result)?.[0];
- if (handoffUrl) {
- state.handoffUrl = handoffUrl;
- return {
- text: `${SAY_LINK_READY}:\n\n${handoffUrl}\n\nTell me once you've pasted your API key.`,
- };
- }
-
- if (result.includes('"toolCount"')) {
- return {
- text: "The Resend API is registered. Creating your connect link…",
- tool: { name: "execute", args: { code: createHandoffCode(integration) } },
- };
- }
-
- if (result.includes(emailSubject) || /"ok":\s*true/.test(result)) {
- return { text: `${SAY_EMAIL_SENT} - your Resend connection works.` };
- }
-
- state.scriptNotes.push(`unexpected tool result: ${result.slice(0, 300)}`);
- return { text: "Something unexpected came back — stopping here." };
- }
-
- // Fresh human turn.
- if (/add the resend api/i.test(ctx.lastUser)) {
- return {
- text: "I'll register the Resend API in your Executor now.",
- tool: { name: "execute", args: { code: addSpecCode(integration) } },
- };
- }
- if (/send a test email/i.test(ctx.lastUser)) {
- return {
- text: "Sending a test email through your new connection…",
- tool: { name: "execute", args: { code: sendEmailCode(integration, emailSubject) } },
- };
+/** Run `execute`, auto-approving a paused execution (policy elicitation)
+ * once, and parse the sandbox's JSON return value. */
+const executeJson = (session: McpSession, code: string) =>
+ Effect.gen(function* () {
+ let result = yield* session.call("execute", { code });
+ if (result.text.includes("executionId:")) {
+ result = yield* session.approvePaused(result.text);
}
- // OpenCode side requests (session titles, warm-up pings).
- return { text: "Connect Resend" };
- };
-};
+ expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true);
+ return JSON.parse(result.text) as Record;
+ });
const mintEmulatorApiKey = Effect.promise(async () => {
const response = await fetch(`${EMULATOR_BASE}/_emulate/credentials`, {
@@ -202,17 +121,14 @@ const mintEmulatorApiKey = Effect.promise(async () => {
return token;
});
-const sleep = (ms: number) => new Promise((tick) => setTimeout(tick, ms));
-
scenario(
"Connect · developer session: agent chat → handoff link → paste key → verified send",
- { timeout: 360_000 },
+ { timeout: 240_000 },
Effect.scoped(
Effect.gen(function* () {
const target = yield* Target;
const mcp = yield* Mcp;
const browser = yield* Browser;
- const opencode = yield* OpenCode;
const cli = yield* Cli;
const runDir = yield* RunDir;
@@ -220,143 +136,77 @@ scenario(
const emailSubject = unique("dev-session");
const apiKey = yield* mintEmulatorApiKey;
const identity = yield* target.newIdentity();
- const email = identity.credentials?.email ?? identity.label;
-
- const state: SessionState = { handoffUrl: undefined, browserDone: false, scriptNotes: [] };
- const brain = yield* serveReplayBrain(makeBrainScript({ integration, emailSubject, state }));
- // The conversation transcript is evidence too — written even on failure.
- yield* Effect.addFinalizer(() =>
- Effect.sync(() =>
- writeFileSync(
- join(runDir, "chat-brain.json"),
- JSON.stringify(
- { requests: brain.requests(), errors: brain.errors(), notes: state.scriptNotes },
- null,
- 2,
- ),
- ),
- ),
- );
-
- const home = opencode.makeHome(SERVER_NAME, mcp.url, { chatBrainUrl: brain.baseUrl });
- yield* Effect.sync(() => opencode.warmUp(home));
- // Pre-download the openai-compatible provider package off camera, in a
- // project with the brain but NO MCP (touching /mcp before `mcp auth`
- // breaks the auth flow — see warmUp).
- yield* Effect.sync(() => {
- const warm = join(home.projectDir, "..", "chat-warmup");
- mkdirSync(warm, { recursive: true });
- writeFileSync(
- join(warm, "opencode.json"),
- JSON.stringify({
- autoupdate: false,
- share: "disabled",
- model: "replay/replay-model",
- provider: {
- replay: {
- name: "Replay",
- npm: "@ai-sdk/openai-compatible",
- options: { baseURL: brain.baseUrl, apiKey: "replay-key" },
- models: { "replay-model": { name: "Replay Model" } },
- },
- },
+ const session = mcp.session(identity);
+
+ yield* withChatTheater(
+ cli,
+ { title: "executor agent — connect Resend", record: join(runDir, "terminal.cast") },
+ (chat) =>
+ Effect.gen(function* () {
+ // Real MCP OAuth + tool discovery happens behind this spinner.
+ yield* chat.tool("mcp · authorize and list tools", session.listTools());
+
+ yield* chat.user(
+ "Add the Resend API to my executor and give me a link to connect my account",
+ );
+ yield* chat.assistant("I'll register the Resend API in your Executor now.");
+ const added = yield* chat.tool(
+ "executor.execute · openapi.addSpec(resend)",
+ executeJson(session, addSpecCode(integration)),
+ (result) => `${String(result.toolCount)} tool`,
+ );
+ expect(added.ok, `addSpec succeeded: ${JSON.stringify(added)}`).toBe(true);
+
+ yield* chat.assistant("Registered. Creating your connect link…");
+ const handoff = yield* chat.tool(
+ "executor.execute · connections.createHandoff",
+ executeJson(session, createHandoffCode(integration)),
+ );
+ expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true);
+ const handoffUrl = String(handoff.url);
+ expect(new URL(handoffUrl).origin, "handoff targets this deployment").toBe(
+ new URL(target.baseUrl).origin,
+ );
+
+ yield* chat.assistant(
+ `Open this link to connect your Resend account:\n\n${handoffUrl}\n\nTell me once you've pasted your API key.`,
+ );
+
+ // The browser hop — the terminal session stays open while the
+ // "user" pastes the key; the paste is the real add-account UI.
+ yield* chat.status("you, in the browser: opening the link and pasting the API key…");
+ yield* browser.session(identity, async ({ page, step }) => {
+ await step("Open the connect link from the chat", async () => {
+ await page.goto(handoffUrl, { waitUntil: "networkidle" });
+ await page
+ .getByRole("heading", { name: /Add connection/ })
+ .waitFor({ timeout: 15_000 });
+ });
+ await step("Paste the Resend API key and connect", async () => {
+ const credential = page.getByPlaceholder(/paste the value \/ token/i);
+ await credential.waitFor({ timeout: 15_000 });
+ await credential.fill(apiKey);
+ await page.getByRole("button", { name: "Add connection", exact: true }).click();
+ await page
+ .getByRole("heading", { name: /Add connection/ })
+ .waitFor({ state: "hidden", timeout: 20_000 });
+ });
+ });
+
+ yield* chat.user("Connected, now send a test email to prove it works");
+ yield* chat.assistant("Sending a test email through your new connection…");
+ const sent = yield* chat.tool(
+ "resend.emails.sendEmail · via the pasted connection",
+ executeJson(session, sendEmailCode(integration, emailSubject)),
+ (result) => `path ${String(result.path)}`,
+ );
+ expect(sent.ok, `email sent through the connection: ${JSON.stringify(sent)}`).toBe(
+ true,
+ );
+
+ yield* chat.assistant("Test email sent - your Resend connection works.");
}),
- );
- spawnSync("opencode", ["run", "-m", "replay/replay-model", "warmup ping"], {
- cwd: warm,
- env: home.env,
- timeout: 120_000,
- });
- });
-
- // The "user at the browser": waits for the agent to produce the link,
- // opens it, pastes the key — while the terminal session stays open.
- const browserWork = Effect.gen(function* () {
- yield* Effect.promise(async () => {
- const deadline = Date.now() + 240_000;
- while (state.handoffUrl === undefined) {
- if (Date.now() > deadline) throw new Error("handoff URL never appeared in chat");
- await sleep(300);
- }
- });
- yield* browser.session(identity, async ({ page, step }) => {
- await step("Open the connect link from the chat", async () => {
- await page.goto(state.handoffUrl ?? "", { waitUntil: "networkidle" });
- await page
- .getByRole("heading", { name: /Add connection/ })
- .waitFor({ timeout: 15_000 });
- });
- await step("Paste the Resend API key and connect", async () => {
- const credential = page.getByPlaceholder(/paste the value \/ token/i);
- await credential.waitFor({ timeout: 15_000 });
- await credential.fill(apiKey);
- await page.getByRole("button", { name: "Add connection", exact: true }).click();
- await page
- .getByRole("heading", { name: /Add connection/ })
- .waitFor({ state: "hidden", timeout: 20_000 });
- });
- });
- });
- const browserFiber = yield* Effect.forkChild(
- Effect.gen(function* () {
- const exit = yield* Effect.exit(browserWork);
- state.browserDone = true;
- return exit;
- }),
- );
-
- yield* cli.session(
- ["bash", "--norc"],
- async (term) => {
- await term.screen.waitForText("$", { timeoutMs: 10_000 });
-
- // MCP OAuth on camera, consent played off camera. Wait for the
- // flow's own completion text — the shell prompt was already on
- // screen before the command, so it can't gate anything.
- const consent = opencode.completeOAuthConsent(home, email, home.openedUrls().length);
- await term.keyboard.type(`opencode mcp auth ${SERVER_NAME}`);
- await term.keyboard.press("Enter");
- await consent;
- await term.screen.waitForText("Authentication successful", { timeoutMs: 60_000 });
- await sleep(1_500); // let the auth UI finish and the prompt repaint
-
- // Into the TUI for the conversation.
- await term.keyboard.type("opencode");
- await term.keyboard.press("Enter");
- await term.screen.waitForText("Replay Model", { timeoutMs: 90_000 });
-
- await term.keyboard.type(
- "Add the Resend API to my executor and give me a link to connect my account",
- );
- await term.keyboard.press("Enter");
- await term.screen.waitForText(SAY_LINK_READY, { timeoutMs: 180_000 });
-
- // The human walks over to the browser; the chat waits.
- const deadline = Date.now() + 180_000;
- while (!state.browserDone) {
- if (Date.now() > deadline) throw new Error("browser side never finished");
- await sleep(300);
- }
-
- await sleep(750); // let the TUI settle before the next message
- await term.keyboard.type("Connected, now send a test email to prove it works");
- await term.keyboard.press("Enter");
- await term.screen.waitForText(SAY_EMAIL_SENT, { timeoutMs: 180_000 });
- },
- {
- cwd: home.projectDir,
- env: { ...home.env, PS1: "$ ", BASH_SILENCE_DEPRECATION_WARNING: "1" },
- record: join(runDir, "terminal.cast"),
- viewport: { cols: 100, rows: 40 },
- },
- );
-
- const browserExit = yield* Fiber.join(browserFiber);
- expect(Exit.isSuccess(browserExit), `browser side succeeded: ${String(browserExit)}`).toBe(
- true,
);
- expect(brain.errors(), "replay brain script ran clean").toEqual([]);
- expect(state.scriptNotes, "conversation followed the script").toEqual([]);
// Final evidence: the emulator's ledger saw the send from Executor.
const ledger = yield* Effect.promise(async () =>
diff --git a/e2e/src/clients/agent-chat-tui.ts b/e2e/src/clients/agent-chat-tui.ts
new file mode 100644
index 000000000..893efe2ee
--- /dev/null
+++ b/e2e/src/clients/agent-chat-tui.ts
@@ -0,0 +1,128 @@
+// The chat renderer half of the chat theater (see chat-theater.ts): a tiny
+// agent-chat TUI that runs inside the recorded PTY and paints whatever the
+// scenario's REAL MCP calls are doing. It performs no logic of its own — it
+// reads base64-encoded JSON events on stdin and renders them with the
+// pacing of a chat session (typed user input, streamed agent text, live
+// tool spinners that run exactly as long as the real call did).
+//
+// Run with: bun agent-chat-tui.ts ""
+import { createInterface } from "node:readline";
+
+type TheaterEvent =
+ | { readonly type: "user"; readonly text: string }
+ | { readonly type: "assistant"; readonly text: string }
+ | { readonly type: "tool-start"; readonly label: string }
+ | { readonly type: "tool-end"; readonly ok: boolean; readonly note?: string }
+ | { readonly type: "status"; readonly text: string }
+ | { readonly type: "done" };
+
+const out = (text: string) => process.stdout.write(text);
+
+// The PTY would otherwise echo every incoming event line into the recording.
+if (process.stdin.isTTY) process.stdin.setRawMode(true);
+
+const DIM = "\x1b[2m";
+const BOLD = "\x1b[1m";
+const RESET = "\x1b[0m";
+const CYAN = "\x1b[36m";
+const GREEN = "\x1b[32m";
+const RED = "\x1b[31m";
+const MAGENTA = "\x1b[35m";
+const CLEAR_LINE = "\x1b[2K\r";
+
+const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
+
+const sleep = (ms: number) => new Promise((tick) => setTimeout(tick, ms));
+
+const title = process.argv[2] ?? "executor session";
+out(`${BOLD}${MAGENTA}●${RESET} ${BOLD}${title}${RESET}\n`);
+out(`${DIM}${"─".repeat(Math.min(96, title.length + 24))}${RESET}\n`);
+
+let spinnerTimer: ReturnType | undefined;
+let spinnerLabel = "";
+
+const startSpinner = (label: string) => {
+ spinnerLabel = label;
+ let frame = 0;
+ out("\n");
+ spinnerTimer = setInterval(() => {
+ out(`${CLEAR_LINE} ${CYAN}${SPINNER[frame % SPINNER.length]}${RESET} ${DIM}${label}${RESET}`);
+ frame += 1;
+ }, 80);
+};
+
+const stopSpinner = (ok: boolean, note?: string) => {
+ if (spinnerTimer) clearInterval(spinnerTimer);
+ spinnerTimer = undefined;
+ const mark = ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
+ out(`${CLEAR_LINE} ${mark} ${DIM}${spinnerLabel}${note ? ` — ${note}` : ""}${RESET}\n`);
+};
+
+const handle = async (event: TheaterEvent): Promise => {
+ switch (event.type) {
+ case "user": {
+ out(`\n${BOLD}${CYAN}┃ you${RESET} `);
+ for (const ch of event.text) {
+ out(ch);
+ await sleep(18);
+ }
+ out("\n");
+ return true;
+ }
+ case "assistant": {
+ out(`\n${BOLD}${MAGENTA}● agent${RESET} `);
+ for (const piece of event.text.match(/.{1,3}/gs) ?? []) {
+ out(piece);
+ await sleep(12);
+ }
+ out("\n");
+ return true;
+ }
+ case "tool-start": {
+ startSpinner(event.label);
+ return true;
+ }
+ case "tool-end": {
+ stopSpinner(event.ok, event.note);
+ return true;
+ }
+ case "status": {
+ out(`\n ${DIM}· ${event.text}${RESET}\n`);
+ return true;
+ }
+ case "done": {
+ out(`\n${DIM}${"─".repeat(40)}${RESET}\n${GREEN}✦ session complete${RESET}\n`);
+ return false;
+ }
+ }
+};
+
+// Events arrive faster than they render; process strictly in order so the
+// animations (not arrival time) set the pacing.
+const queue: TheaterEvent[] = [];
+let draining = false;
+const drain = async () => {
+ if (draining) return;
+ draining = true;
+ while (queue.length > 0) {
+ const event = queue.shift();
+ if (!event) break;
+ const keepGoing = await handle(event);
+ if (!keepGoing) {
+ process.exit(0);
+ }
+ }
+ draining = false;
+};
+
+createInterface({ input: process.stdin, terminal: false }).on("line", (line) => {
+ const trimmed = line.trim();
+ if (!trimmed) return;
+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: PTY stdin may echo stray keystrokes that aren't base64 JSON; ignore them
+ try {
+ queue.push(JSON.parse(Buffer.from(trimmed, "base64").toString("utf8")) as TheaterEvent);
+ } catch {
+ return;
+ }
+ void drain();
+});
diff --git a/e2e/src/clients/chat-theater.ts b/e2e/src/clients/chat-theater.ts
new file mode 100644
index 000000000..af07c159c
--- /dev/null
+++ b/e2e/src/clients/chat-theater.ts
@@ -0,0 +1,118 @@
+// 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 forked PTY runs the chat renderer
+// (agent-chat-tui.ts) and this driver feeds it events, so the terminal.cast
+// reads like a developer chatting with an agent while every tool spinner
+// brackets the genuine call it narrates.
+//
+// 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 scenarios
+import { fileURLToPath } from "node:url";
+
+import { Effect, Exit, Fiber } from "effect";
+
+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;
+ /** The agent's reply, streamed on screen. */
+ readonly assistant: (text: string) => Effect.Effect;
+ /** Run a REAL effect behind a live tool spinner; the spinner runs exactly
+ * as long as the call does and resolves to ✓/✗ with its outcome. */
+ readonly tool: (
+ label: string,
+ work: Effect.Effect,
+ note?: (result: A) => string | undefined,
+ ) => Effect.Effect;
+ /** A dim narrator line (e.g. "waiting for the browser hop"). */
+ readonly status: (text: string) => Effect.Effect;
+}
+
+/**
+ * Open a recorded PTY running the chat renderer, hand the scenario a
+ * ChatTheater handle, and close the session (footer + exit) when the body
+ * finishes — success or failure, so the cast always ends cleanly.
+ */
+export const withChatTheater = (
+ cli: CliSurface,
+ options: {
+ readonly title: string;
+ readonly record: string;
+ readonly viewport?: { readonly cols: number; readonly rows: number };
+ },
+ body: (theater: ChatTheater) => Effect.Effect,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const queue: TheaterEvent[] = [];
+ let closed = false;
+
+ const push = (event: TheaterEvent) =>
+ Effect.sync(() => {
+ if (!closed) queue.push(event);
+ });
+
+ // The PTY pump: forward queued events as base64 lines (keystroke-safe),
+ // and return once the renderer paints its completion footer.
+ 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 });
+ const deadline = Date.now() + 30 * 60 * 1000;
+ for (;;) {
+ const event = queue.shift();
+ if (event) {
+ const line = Buffer.from(JSON.stringify(event)).toString("base64");
+ await term.keyboard.type(`${line}\n`);
+ if (event.type === "done") break;
+ continue;
+ }
+ if (Date.now() > deadline) break;
+ await new Promise((tick) => setTimeout(tick, 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 theater: ChatTheater = {
+ user: (text) => push({ type: "user", text }),
+ assistant: (text) => push({ type: "assistant", text }),
+ status: (text) => push({ type: "status", text }),
+ tool: (label, work, note) =>
+ Effect.gen(function* () {
+ yield* push({ type: "tool-start", label });
+ const exit = yield* Effect.exit(work);
+ if (Exit.isSuccess(exit)) {
+ yield* push({ type: "tool-end", ok: true, note: note?.(exit.value) });
+ return exit.value;
+ }
+ yield* push({ type: "tool-end", ok: false, note: "failed" });
+ return yield* exit;
+ }),
+ };
+
+ const result = yield* Effect.exit(body(theater));
+ yield* push({ type: "done" });
+ closed = true;
+ yield* Fiber.join(pumpFiber);
+ return yield* result;
+ });
From 82895ca16565a6793354b1ce7b6d9a040cd8977f Mon Sep 17 00:00:00 2001
From: Rhys Sullivan
Date: Thu, 11 Jun 2026 16:56:42 -0700
Subject: [PATCH 03/11] Viewer: a run with both recordings plays as one session
in time order
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Runs from chat-theater scenarios produce a terminal.cast (the chat) AND a
session.mp4 (the browser hop). The run page previously picked one
(video ?? cast), hiding the chat that led to the browser. Now both render
under a single 'session' tab in story order — 1 · the chat, in the
terminal; 2 · the browser hop, mid-chat — with autoplay reserved for
video-only runs.
---
e2e/viewer/src/App.tsx | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/e2e/viewer/src/App.tsx b/e2e/viewer/src/App.tsx
index c36272626..024e9a17a 100644
--- a/e2e/viewer/src/App.tsx
+++ b/e2e/viewer/src/App.tsx
@@ -193,7 +193,7 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => {
className={tab === "media" ? "tab active" : "tab"}
onClick={() => setTab("media")}
>
- ▶ {video ? "video" : "terminal"}
+ ▶ {cast && video ? "session" : video ? "video" : "terminal"}
}>
+ {video &&
1 · the chat, in the terminal
}
)}
{video && tab === "media" && (
<>
- {/* muted is required for browsers to honor autoplay */}
+ {cast &&
2 · the browser hop, mid-chat
}
+ {/* muted is required for browsers to honor autoplay; don't
+ autoplay when it's the second act of a session */}