chat.tool now takes the call itself — tool name, the REAL input (the sandbox code the scenario executes, shown as the indented preview block agent TUIs draw), and a result-line renderer — instead of a prose label. The renderer paints name → input block → live spinner → ✓ with the real duration and the real result's first line. The recording reads like an actual agent session: what was called, with what, how long it took, what came back.
Unified · Split
| @@ -110,23 +110,25 @@ scenario( · 109 unmodified lines | |||
| 110 | { title: "executor agent — connect Resend", record: join(runDir, "terminal.cast") }, | 110 | { title: "executor agent — connect Resend", record: join(runDir, "terminal.cast") }, |
| 111 | (chat) => | 111 | (chat) => |
| 112 | Effect.gen(function* () { | 112 | Effect.gen(function* () { |
| 113 | // Real MCP OAuth + tool discovery happens behind this spinner. | 113 | // Real MCP OAuth + tool discovery happens behind this call. |
| 114 | yield* chat.tool("mcp · authorize and list tools", session.listTools()); | 114 | yield* chat.tool( |
| 115 | { name: "executor (mcp)", result: (tools) => `${tools.length} tools available` }, | ||
| 116 | session.listTools(), | ||
| 117 | ); | ||
| 115 | 118 | ||
| 116 | yield* chat.user( | 119 | yield* chat.user( |
| 117 | "Add the Resend API to my executor and give me a link to connect my account", | 120 | "Add the Resend API to my executor and give me a link to connect my account", |
| 118 | ); | 121 | ); |
| 119 | yield* chat.assistant("I'll register the Resend API in your Executor now."); | 122 | yield* chat.assistant("I'll register the Resend API in your Executor now."); |
| 120 | const added = yield* chat.tool( | 123 | const added = yield* chat.tool( |
| 121 | "executor.execute · openapi.addSpec(resend)", | 124 | { name: "execute", input: addSpecCode(integration) }, |
| 122 | executeJson(session, addSpecCode(integration)), | 125 | executeJson(session, addSpecCode(integration)), |
| 123 | (result) => `${String(result.toolCount)} tools`, | ||
| 124 | ); | 126 | ); |
| 125 | expect(added.ok, `addSpec succeeded: ${JSON.stringify(added)}`).toBe(true); | 127 | expect(added.ok, `addSpec succeeded: ${JSON.stringify(added)}`).toBe(true); |
| 126 | 128 | ||
| 127 | yield* chat.assistant("Registered. Creating your connect link…"); | 129 | yield* chat.assistant("Registered. Creating your connect link…"); |
| 128 | const handoff = yield* chat.tool( | 130 | const handoff = yield* chat.tool( |
| 129 | "executor.execute · connections.createHandoff", | 131 | { name: "execute", input: createHandoffCode(integration) }, |
| 130 | executeJson(session, createHandoffCode(integration)), | 132 | executeJson(session, createHandoffCode(integration)), |
| 131 | ); | 133 | ); |
| 132 | expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true); | 134 | expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true); |
| @@ -163,9 +165,8 @@ scenario( · 30 unmodified lines | |||
| 163 | yield* chat.user("Connected, now send a test email to prove it works"); | 165 | yield* chat.user("Connected, now send a test email to prove it works"); |
| 164 | yield* chat.assistant("Sending a test email through your new connection…"); | 166 | yield* chat.assistant("Sending a test email through your new connection…"); |
| 165 | const sent = yield* chat.tool( | 167 | const sent = yield* chat.tool( |
| 166 | "resend.emails.sendEmail · via the pasted connection", | 168 | { name: "execute", input: sendEmailCode(integration, emailSubject) }, |
| 167 | executeJson(session, sendEmailCode(integration, emailSubject)), | 169 | executeJson(session, sendEmailCode(integration, emailSubject)), |
| 168 | (result) => `path ${String(result.path)}`, | ||
| 169 | ); | 170 | ); |
| 170 | expect(sent.ok, `email sent through the connection: ${JSON.stringify(sent)}`).toBe( | 171 | expect(sent.ok, `email sent through the connection: ${JSON.stringify(sent)}`).toBe( |
| 171 | true, | 172 | true, |
| @@ -17,8 +17,19 @@ import { createInterface } from "node:readline"; · 16 unmodified lines | |||
| 17 | type TheaterEvent = | 17 | type TheaterEvent = |
| 18 | | { readonly type: "user"; readonly text: string } | 18 | | { readonly type: "user"; readonly text: string } |
| 19 | | { readonly type: "assistant"; readonly text: string } | 19 | | { readonly type: "assistant"; readonly text: string } |
| 20 | | { readonly type: "tool-start"; readonly label: string } | 20 | | { |
| 21 | | { readonly type: "tool-end"; readonly ok: boolean; readonly note?: string } | 21 | readonly type: "tool-start"; |
| 22 | readonly name: string; | ||
| 23 | /** The call's real input, pre-rendered as preview lines. */ | ||
| 24 | readonly input?: readonly string[]; | ||
| 25 | } | ||
| 26 | | { | ||
| 27 | readonly type: "tool-end"; | ||
| 28 | readonly ok: boolean; | ||
| 29 | /** First line of the call's real result. */ | ||
| 30 | readonly result?: string; | ||
| 31 | readonly seconds?: number; | ||
| 32 | } | ||
| 22 | | { readonly type: "status"; readonly text: string } | 33 | | { readonly type: "status"; readonly text: string } |
| 23 | | { readonly type: "done" }; | 34 | | { readonly type: "done" }; |
| 24 | 35 | ||
| @@ -46,23 +57,36 @@ out(`${BOLD}${MAGENTA}●${RESET} ${BOLD}${title}${RESET}\n`); · 21 unmodified lines | |||
| 46 | out(`${DIM}${"─".repeat(Math.min(96, title.length + 24))}${RESET}\n`); | 57 | out(`${DIM}${"─".repeat(Math.min(96, title.length + 24))}${RESET}\n`); |
| 47 | 58 | ||
| 48 | let spinnerTimer: ReturnType<typeof setInterval> | undefined; | 59 | let spinnerTimer: ReturnType<typeof setInterval> | undefined; |
| 49 | let spinnerLabel = ""; | 60 | let spinnerName = ""; |
| 50 | 61 | ||
| 51 | const startSpinner = (label: string) => { | 62 | // A tool call renders the way agent TUIs render them: the tool's name, the |
| 52 | spinnerLabel = label; | 63 | // call's real input as an indented block, a spinner while the real call |
| 64 | // runs, then the real result's first line with the real duration. | ||
| 65 | // | ||
| 66 | // ⚙ execute | ||
| 67 | // │ const added = await tools.executor.openapi.addSpec({ | ||
| 68 | // │ spec: { kind: "url", url: "https://…/openapi.json" }, | ||
| 69 | // ⠼ running… | ||
| 70 | // ✓ 2.3s {"ok":true,"slug":"resend","toolCount":9} | ||
| 71 | const startTool = (name: string, input?: readonly string[]) => { | ||
| 72 | spinnerName = name; | ||
| 73 | out(`\n ${MAGENTA}⚙${RESET} ${BOLD}${name}${RESET}\n`); | ||
| 74 | for (const line of input ?? []) { | ||
| 75 | out(` ${DIM}│ ${line.slice(0, 92)}${RESET}\n`); | ||
| 76 | } | ||
| 53 | let frame = 0; | 77 | let frame = 0; |
| 54 | out("\n"); | ||
| 55 | spinnerTimer = setInterval(() => { | 78 | spinnerTimer = setInterval(() => { |
| 56 | out(`${CLEAR_LINE} ${CYAN}${SPINNER[frame % SPINNER.length]}${RESET} ${DIM}${label}${RESET}`); | 79 | out(`${CLEAR_LINE} ${CYAN}${SPINNER[frame % SPINNER.length]}${RESET} ${DIM}running…${RESET}`); |
| 57 | frame += 1; | 80 | frame += 1; |
| 58 | }, 80); | 81 | }, 80); |
| 59 | }; | 82 | }; |
| 60 | 83 | ||
| 61 | const stopSpinner = (ok: boolean, note?: string) => { | 84 | const endTool = (ok: boolean, result?: string, seconds?: number) => { |
| 62 | if (spinnerTimer) clearInterval(spinnerTimer); | 85 | if (spinnerTimer) clearInterval(spinnerTimer); |
| 63 | spinnerTimer = undefined; | 86 | spinnerTimer = undefined; |
| 64 | const mark = ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`; | 87 | const mark = ok ? `${GREEN}✓${RESET}` : `${RED}✗ ${spinnerName}${RESET}`; |
| 65 | out(`${CLEAR_LINE} ${mark} ${DIM}${spinnerLabel}${note ? ` — ${note}` : ""}${RESET}\n`); | 88 | const took = seconds !== undefined ? `${DIM}${seconds.toFixed(1)}s${RESET} ` : ""; |
| 89 | out(`${CLEAR_LINE} ${mark} ${took}${DIM}${(result ?? "").slice(0, 88)}${RESET}\n`); | ||
| 66 | }; | 90 | }; |
| 67 | 91 | ||
| 68 | const handle = async (event: TheaterEvent): Promise<boolean> => { | 92 | const handle = async (event: TheaterEvent): Promise<boolean> => { |
| @@ -86,11 +110,11 @@ const handle = async (event: TheaterEvent): Promise<boolean> => { · 17 unmodified lines | |||
| 86 | return true; | 110 | return true; |
| 87 | } | 111 | } |
| 88 | case "tool-start": { | 112 | case "tool-start": { |
| 89 | startSpinner(event.label); | 113 | startTool(event.name, event.input); |
| 90 | return true; | 114 | return true; |
| 91 | } | 115 | } |
| 92 | case "tool-end": { | 116 | case "tool-end": { |
| 93 | stopSpinner(event.ok, event.note); | 117 | endTool(event.ok, event.result, event.seconds); |
| 94 | return true; | 118 | return true; |
| 95 | } | 119 | } |
| 96 | case "status": { | 120 | case "status": { |
| @@ -39,12 +39,19 @@ export interface ChatTheater { · 38 unmodified lines | |||
| 39 | readonly user: (text: string) => Effect.Effect<void>; | 39 | readonly user: (text: string) => Effect.Effect<void>; |
| 40 | /** The agent's reply, streamed on screen. */ | 40 | /** The agent's reply, streamed on screen. */ |
| 41 | readonly assistant: (text: string) => Effect.Effect<void>; | 41 | readonly assistant: (text: string) => Effect.Effect<void>; |
| 42 | /** Run a REAL effect behind a live tool spinner; the spinner runs exactly | 42 | /** Run a REAL effect rendered as a tool call: the tool's name, the call's |
| 43 | * as long as the call does and resolves to ✓/✗ with its outcome. */ | 43 | * real input block, a spinner for exactly as long as the call runs, then |
| 44 | * the call's real result line with the real duration. */ | ||
| 44 | readonly tool: <A, E, R>( | 45 | readonly tool: <A, E, R>( |
| 45 | label: string, | 46 | call: { |
| 47 | /** Tool name as an agent TUI would show it (e.g. "execute"). */ | ||
| 48 | readonly name: string; | ||
| 49 | /** The call's real input — rendered as the indented block. */ | ||
| 50 | readonly input?: string; | ||
| 51 | /** Render the result's display line (defaults to JSON of the value). */ | ||
| 52 | readonly result?: (value: A) => string; | ||
| 53 | }, | ||
| 46 | work: Effect.Effect<A, E, R>, | 54 | work: Effect.Effect<A, E, R>, |
| 47 | note?: (result: A) => string | undefined, | ||
| 48 | ) => Effect.Effect<A, E, R>; | 55 | ) => Effect.Effect<A, E, R>; |
| 49 | /** A dim narrator line (e.g. "waiting for the browser hop"). */ | 56 | /** A dim narrator line (e.g. "waiting for the browser hop"). */ |
| 50 | readonly status: (text: string) => Effect.Effect<void>; | 57 | readonly status: (text: string) => Effect.Effect<void>; |
| @@ -55,19 +62,32 @@ const sleep = (ms: number) => new Promise<void>((tick) => setTimeout(tick, ms)); · 4 unmodified lines | |||
| 55 | const encode = (event: TheaterEvent) => | 62 | const encode = (event: TheaterEvent) => |
| 56 | `${Buffer.from(JSON.stringify(event)).toString("base64")}\n`; | 63 | `${Buffer.from(JSON.stringify(event)).toString("base64")}\n`; |
| 57 | 64 | ||
| 65 | /** Input block, like a TUI's call preview: trimmed lines, capped count. */ | ||
| 66 | const inputPreview = (input: string | undefined): string[] | undefined => { | ||
| 67 | if (!input) return undefined; | ||
| 68 | const lines = input | ||
| 69 | .split("\n") | ||
| 70 | .map((line) => line.trimEnd()) | ||
| 71 | .filter((line) => line.trim().length > 0); | ||
| 72 | return lines.length > 7 ? [...lines.slice(0, 6), `… +${lines.length - 6} lines`] : lines; | ||
| 73 | }; | ||
| 74 | |||
| 58 | const makeTheater = (push: (event: TheaterEvent) => Effect.Effect<void>): ChatTheater => ({ | 75 | const makeTheater = (push: (event: TheaterEvent) => Effect.Effect<void>): ChatTheater => ({ |
| 59 | user: (text) => push({ type: "user", text }), | 76 | user: (text) => push({ type: "user", text }), |
| 60 | assistant: (text) => push({ type: "assistant", text }), | 77 | assistant: (text) => push({ type: "assistant", text }), |
| 61 | status: (text) => push({ type: "status", text }), | 78 | status: (text) => push({ type: "status", text }), |
| 62 | tool: (label, work, note) => | 79 | tool: (call, work) => |
| 63 | Effect.gen(function* () { | 80 | Effect.gen(function* () { |
| 64 | yield* push({ type: "tool-start", label }); | 81 | yield* push({ type: "tool-start", name: call.name, input: inputPreview(call.input) }); |
| 82 | const startedAt = Date.now(); | ||
| 65 | const exit = yield* Effect.exit(work); | 83 | const exit = yield* Effect.exit(work); |
| 84 | const seconds = (Date.now() - startedAt) / 1000; | ||
| 66 | if (Exit.isSuccess(exit)) { | 85 | if (Exit.isSuccess(exit)) { |
| 67 | yield* push({ type: "tool-end", ok: true, note: note?.(exit.value) }); | 86 | const line = call.result ? call.result(exit.value) : JSON.stringify(exit.value); |
| 87 | yield* push({ type: "tool-end", ok: true, result: line, seconds }); | ||
| 68 | return exit.value; | 88 | return exit.value; |
| 69 | } | 89 | } |
| 70 | yield* push({ type: "tool-end", ok: false, note: "failed" }); | 90 | yield* push({ type: "tool-end", ok: false, result: "failed", seconds }); |
| 71 | return yield* exit; | 91 | return yield* exit; |
| 72 | }), | 92 | }), |
| 73 | }); | 93 | }); |