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 | 110 | { title: "executor agent — connect Resend", record: join(runDir, "terminal.cast") }, |
| 111 | 111 | (chat) => |
| 112 | 112 | Effect.gen(function* () { |
| 113 | − // Real MCP OAuth + tool discovery happens behind this spinner. | |
| 114 | − yield* chat.tool("mcp · authorize and list tools", session.listTools()); | |
| 113 | + // Real MCP OAuth + tool discovery happens behind this call. | |
| 114 | + yield* chat.tool( | |
| 115 | + { name: "executor (mcp)", result: (tools) => `${tools.length} tools available` }, | |
| 116 | + session.listTools(), | |
| 117 | + ); | |
| 115 | 118 | |
| 116 | 119 | yield* chat.user( |
| 117 | 120 | "Add the Resend API to my executor and give me a link to connect my account", |
| 118 | 121 | ); |
| 119 | 122 | yield* chat.assistant("I'll register the Resend API in your Executor now."); |
| 120 | 123 | const added = yield* chat.tool( |
| 121 | − "executor.execute · openapi.addSpec(resend)", | |
| 124 | + { name: "execute", input: addSpecCode(integration) }, | |
| 122 | 125 | executeJson(session, addSpecCode(integration)), |
| 123 | − (result) => `${String(result.toolCount)} tools`, | |
| 124 | 126 | ); |
| 125 | 127 | expect(added.ok, `addSpec succeeded: ${JSON.stringify(added)}`).toBe(true); |
| 126 | 128 | |
| 127 | 129 | yield* chat.assistant("Registered. Creating your connect link…"); |
| 128 | 130 | const handoff = yield* chat.tool( |
| 129 | − "executor.execute · connections.createHandoff", | |
| 131 | + { name: "execute", input: createHandoffCode(integration) }, | |
| 130 | 132 | executeJson(session, createHandoffCode(integration)), |
| 131 | 133 | ); |
| 132 | 134 | expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true); |
| @@ -163,9 +165,8 @@ scenario( · 30 unmodified lines | ||
| 163 | 165 | yield* chat.user("Connected, now send a test email to prove it works"); |
| 164 | 166 | yield* chat.assistant("Sending a test email through your new connection…"); |
| 165 | 167 | const sent = yield* chat.tool( |
| 166 | − "resend.emails.sendEmail · via the pasted connection", | |
| 168 | + { name: "execute", input: sendEmailCode(integration, emailSubject) }, | |
| 167 | 169 | executeJson(session, sendEmailCode(integration, emailSubject)), |
| 168 | − (result) => `path ${String(result.path)}`, | |
| 169 | 170 | ); |
| 170 | 171 | expect(sent.ok, `email sent through the connection: ${JSON.stringify(sent)}`).toBe( |
| 171 | 172 | true, |
| @@ -17,8 +17,19 @@ import { createInterface } from "node:readline"; · 16 unmodified lines | ||
| 17 | 17 | type TheaterEvent = |
| 18 | 18 | | { readonly type: "user"; readonly text: string } |
| 19 | 19 | | { readonly type: "assistant"; readonly text: string } |
| 20 | − | { readonly type: "tool-start"; readonly label: string } | |
| 21 | − | { readonly type: "tool-end"; readonly ok: boolean; readonly note?: string } | |
| 20 | + | { | |
| 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 | 33 | | { readonly type: "status"; readonly text: string } |
| 23 | 34 | | { readonly type: "done" }; |
| 24 | 35 | |
| @@ -46,23 +57,36 @@ out(`${BOLD}${MAGENTA}●${RESET} ${BOLD}${title}${RESET}\n`); · 21 unmodified lines | ||
| 46 | 57 | out(`${DIM}${"─".repeat(Math.min(96, title.length + 24))}${RESET}\n`); |
| 47 | 58 | |
| 48 | 59 | let spinnerTimer: ReturnType<typeof setInterval> | undefined; |
| 49 | −let spinnerLabel = ""; | |
| 60 | +let spinnerName = ""; | |
| 50 | 61 | |
| 51 | −const startSpinner = (label: string) => { | |
| 52 | − spinnerLabel = label; | |
| 62 | +// A tool call renders the way agent TUIs render them: the tool's name, the | |
| 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 | 77 | let frame = 0; |
| 54 | − out("\n"); | |
| 55 | 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 | 80 | frame += 1; |
| 58 | 81 | }, 80); |
| 59 | 82 | }; |
| 60 | 83 | |
| 61 | −const stopSpinner = (ok: boolean, note?: string) => { | |
| 84 | +const endTool = (ok: boolean, result?: string, seconds?: number) => { | |
| 62 | 85 | if (spinnerTimer) clearInterval(spinnerTimer); |
| 63 | 86 | spinnerTimer = undefined; |
| 64 | − const mark = ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`; | |
| 65 | − out(`${CLEAR_LINE} ${mark} ${DIM}${spinnerLabel}${note ? ` — ${note}` : ""}${RESET}\n`); | |
| 87 | + const mark = ok ? `${GREEN}✓${RESET}` : `${RED}✗ ${spinnerName}${RESET}`; | |
| 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 | 92 | const handle = async (event: TheaterEvent): Promise<boolean> => { |
| @@ -86,11 +110,11 @@ const handle = async (event: TheaterEvent): Promise<boolean> => { · 17 unmodified lines | ||
| 86 | 110 | return true; |
| 87 | 111 | } |
| 88 | 112 | case "tool-start": { |
| 89 | − startSpinner(event.label); | |
| 113 | + startTool(event.name, event.input); | |
| 90 | 114 | return true; |
| 91 | 115 | } |
| 92 | 116 | case "tool-end": { |
| 93 | − stopSpinner(event.ok, event.note); | |
| 117 | + endTool(event.ok, event.result, event.seconds); | |
| 94 | 118 | return true; |
| 95 | 119 | } |
| 96 | 120 | case "status": { |
| @@ -39,12 +39,19 @@ export interface ChatTheater { · 38 unmodified lines | ||
| 39 | 39 | readonly user: (text: string) => Effect.Effect<void>; |
| 40 | 40 | /** The agent's reply, streamed on screen. */ |
| 41 | 41 | readonly assistant: (text: string) => Effect.Effect<void>; |
| 42 | − /** Run a REAL effect behind a live tool spinner; the spinner runs exactly | |
| 43 | − * as long as the call does and resolves to ✓/✗ with its outcome. */ | |
| 42 | + /** Run a REAL effect rendered as a tool call: the tool's name, the call's | |
| 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 | 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 | 54 | work: Effect.Effect<A, E, R>, |
| 47 | − note?: (result: A) => string | undefined, | |
| 48 | 55 | ) => Effect.Effect<A, E, R>; |
| 49 | 56 | /** A dim narrator line (e.g. "waiting for the browser hop"). */ |
| 50 | 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 | 62 | const encode = (event: TheaterEvent) => |
| 56 | 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 | 75 | const makeTheater = (push: (event: TheaterEvent) => Effect.Effect<void>): ChatTheater => ({ |
| 59 | 76 | user: (text) => push({ type: "user", text }), |
| 60 | 77 | assistant: (text) => push({ type: "assistant", text }), |
| 61 | 78 | status: (text) => push({ type: "status", text }), |
| 62 | − tool: (label, work, note) => | |
| 79 | + tool: (call, work) => | |
| 63 | 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 | 83 | const exit = yield* Effect.exit(work); |
| 84 | + const seconds = (Date.now() - startedAt) / 1000; | |
| 66 | 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 | 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 | 91 | return yield* exit; |
| 72 | 92 | }), |
| 73 | 93 | }); |