History

Chat theater tool calls render like a real agent TUI

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.

Rhys Sullivan committed 5 months ago

Commit 30e118d1 parent 0b5522d3 files · +72 −27Browse files

Unified · Split

3 files
  1. e2e/scenarios/connect-handoff-session.test.ts +8 −7
  2. e2e/src/clients/agent-chat-tui.ts +36 −12
  3. e2e/src/clients/chat-theater.ts +28 −8
e2e/scenarios/connect-handoff-session.test.ts+8−7
@@ -110,23 +110,25 @@ scenario( · 109 unmodified lines
110110 { title: "executor agent — connect Resend", record: join(runDir, "terminal.cast") },
111111 (chat) =>
112112 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+ );
115118
116119 yield* chat.user(
117120 "Add the Resend API to my executor and give me a link to connect my account",
118121 );
119122 yield* chat.assistant("I'll register the Resend API in your Executor now.");
120123 const added = yield* chat.tool(
121− "executor.execute · openapi.addSpec(resend)",
124+ { name: "execute", input: addSpecCode(integration) },
122125 executeJson(session, addSpecCode(integration)),
123− (result) => `${String(result.toolCount)} tools`,
124126 );
125127 expect(added.ok, `addSpec succeeded: ${JSON.stringify(added)}`).toBe(true);
126128
127129 yield* chat.assistant("Registered. Creating your connect link…");
128130 const handoff = yield* chat.tool(
129− "executor.execute · connections.createHandoff",
131+ { name: "execute", input: createHandoffCode(integration) },
130132 executeJson(session, createHandoffCode(integration)),
131133 );
132134 expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true);
@@ -163,9 +165,8 @@ scenario( · 30 unmodified lines
163165 yield* chat.user("Connected, now send a test email to prove it works");
164166 yield* chat.assistant("Sending a test email through your new connection…");
165167 const sent = yield* chat.tool(
166− "resend.emails.sendEmail · via the pasted connection",
168+ { name: "execute", input: sendEmailCode(integration, emailSubject) },
167169 executeJson(session, sendEmailCode(integration, emailSubject)),
168− (result) => `path ${String(result.path)}`,
169170 );
170171 expect(sent.ok, `email sent through the connection: ${JSON.stringify(sent)}`).toBe(
171172 true,
e2e/src/clients/agent-chat-tui.ts+36−12
@@ -17,8 +17,19 @@ import { createInterface } from "node:readline"; · 16 unmodified lines
1717 type TheaterEvent =
1818 | { readonly type: "user"; readonly text: string }
1919 | { 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+ }
2233 | { readonly type: "status"; readonly text: string }
2334 | { readonly type: "done" };
2435
@@ -46,23 +57,36 @@ out(`${BOLD}${MAGENTA}●${RESET} ${BOLD}${title}${RESET}\n`); · 21 unmodified lines
4657 out(`${DIM}${"─".repeat(Math.min(96, title.length + 24))}${RESET}\n`);
4758
4859 let spinnerTimer: ReturnType<typeof setInterval> | undefined;
49−let spinnerLabel = "";
60+let spinnerName = "";
5061
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+ }
5377 let frame = 0;
54− out("\n");
5578 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}`);
5780 frame += 1;
5881 }, 80);
5982 };
6083
61−const stopSpinner = (ok: boolean, note?: string) => {
84+const endTool = (ok: boolean, result?: string, seconds?: number) => {
6285 if (spinnerTimer) clearInterval(spinnerTimer);
6386 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`);
6690 };
6791
6892 const handle = async (event: TheaterEvent): Promise<boolean> => {
@@ -86,11 +110,11 @@ const handle = async (event: TheaterEvent): Promise<boolean> => { · 17 unmodified lines
86110 return true;
87111 }
88112 case "tool-start": {
89− startSpinner(event.label);
113+ startTool(event.name, event.input);
90114 return true;
91115 }
92116 case "tool-end": {
93− stopSpinner(event.ok, event.note);
117+ endTool(event.ok, event.result, event.seconds);
94118 return true;
95119 }
96120 case "status": {
e2e/src/clients/chat-theater.ts+28−8
@@ -39,12 +39,19 @@ export interface ChatTheater { · 38 unmodified lines
3939 readonly user: (text: string) => Effect.Effect<void>;
4040 /** The agent's reply, streamed on screen. */
4141 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. */
4445 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+ },
4654 work: Effect.Effect<A, E, R>,
47− note?: (result: A) => string | undefined,
4855 ) => Effect.Effect<A, E, R>;
4956 /** A dim narrator line (e.g. "waiting for the browser hop"). */
5057 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
5562 const encode = (event: TheaterEvent) =>
5663 `${Buffer.from(JSON.stringify(event)).toString("base64")}\n`;
5764
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+
5875 const makeTheater = (push: (event: TheaterEvent) => Effect.Effect<void>): ChatTheater => ({
5976 user: (text) => push({ type: "user", text }),
6077 assistant: (text) => push({ type: "assistant", text }),
6178 status: (text) => push({ type: "status", text }),
62− tool: (label, work, note) =>
79+ tool: (call, work) =>
6380 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();
6583 const exit = yield* Effect.exit(work);
84+ const seconds = (Date.now() - startedAt) / 1000;
6685 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 });
6888 return exit.value;
6989 }
70− yield* push({ type: "tool-end", ok: false, note: "failed" });
90+ yield* push({ type: "tool-end", ok: false, result: "failed", seconds });
7191 return yield* exit;
7292 }),
7393 });

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t