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"}