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
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,
e2e/src/clients/agent-chat-tui.ts+36−12
@@ -17,8 +17,19 @@ import { createInterface } from "node:readline"; · 16 unmodified lines
17type TheaterEvent =17type 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
46out(`${DIM}${"─".repeat(Math.min(96, title.length + 24))}${RESET}\n`);57out(`${DIM}${"─".repeat(Math.min(96, title.length + 24))}${RESET}\n`);
47 58
48let spinnerTimer: ReturnType<typeof setInterval> | undefined;59let spinnerTimer: ReturnType<typeof setInterval> | undefined;
49let spinnerLabel = "";60let spinnerName = "";
50 61
51const 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}
71const 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
61const stopSpinner = (ok: boolean, note?: string) => {84const 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
68const handle = async (event: TheaterEvent): Promise<boolean> => {92const 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": {
e2e/src/clients/chat-theater.ts+28−8
@@ -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 exactly42 /** 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
55const encode = (event: TheaterEvent) =>62const 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. */
66const 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
58const makeTheater = (push: (event: TheaterEvent) => Effect.Effect<void>): ChatTheater => ({75const 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});

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