From 2876e50ee624f2f8329af022e73cc42da054457c Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Tue, 17 Feb 2026 18:58:28 -0800 Subject: [PATCH] Restore tool call rendering with lifecycle tracking and tree display Detects 'tool_called' and 'tool_output' events from the OpenAI agents stream to show which tools the agent invoked, their arguments, and execution time. Tool calls now persist in the chat history and display as a formatted tree with running/completed status, rather than disappearing after streaming ends. The tool call tree also includes a root label and proper spacing before response text. Co-Authored-By: Claude Haiku 4.5 --- classes/logger.ts | 39 +++++++++++++++++++--- components/App.tsx | 68 ++++++++++++++++++++++++++++++-------- components/Message.tsx | 1 + components/MessageArea.tsx | 50 +++++++++++++++++++++++++--- tests/logger.test.ts | 34 +++++++++++-------- 5 files changed, 155 insertions(+), 37 deletions(-) diff --git a/classes/logger.ts b/classes/logger.ts index 3e726c6..5123310 100644 --- a/classes/logger.ts +++ b/classes/logger.ts @@ -14,6 +14,17 @@ export interface LogEvent { timestamp: string; } +export type ToolCallStatus = "running" | "completed"; + +export interface ToolCallInfo { + callId: string; + name: string; + args: string; + status: ToolCallStatus; + startedAt: number; + duration?: number; +} + export class Logger extends EventEmitter { private level: LogLevel; private color: boolean; @@ -76,8 +87,26 @@ export class Logger extends EventEmitter { this.log(message, LogLevel.DEBUG); } - incrementToolCalls() { - this.emit("toolCall"); + toolCallStarted(callId: string, name: string, args: string) { + const info: ToolCallInfo = { + callId, + name, + args, + status: "running", + startedAt: Date.now(), + }; + this.emit("toolCallUpdate", info); + } + + toolCallCompleted(callId: string, name: string) { + const info: ToolCallInfo = { + callId, + name, + args: "", + status: "completed", + startedAt: 0, + }; + this.emit("toolCallUpdate", info); } onLog(callback: (event: LogEvent) => void) { @@ -85,8 +114,8 @@ export class Logger extends EventEmitter { return () => this.off("log", callback); } - onToolCall(callback: () => void) { - this.on("toolCall", callback); - return () => this.off("toolCall", callback); + onToolCallUpdate(callback: (info: ToolCallInfo) => void) { + this.on("toolCallUpdate", callback); + return () => this.off("toolCallUpdate", callback); } } diff --git a/components/App.tsx b/components/App.tsx index 08f9048..0b11a86 100644 --- a/components/App.tsx +++ b/components/App.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { Box, useApp, useStdout } from "ink"; import type { MCPServerStreamableHttp } from "@openai/agents"; import type { GeneralAgent } from "../agents/general.js"; -import type { Logger, LogEvent } from "../classes/logger.js"; +import type { Logger, LogEvent, ToolCallInfo } from "../classes/logger.js"; import type { Config } from "../classes/config.js"; import { renderMarkdown } from "../utils/markdown.js"; import { MessageArea } from "./MessageArea.js"; @@ -44,7 +44,8 @@ export function App({ ]); const [streamingText, setStreamingText] = useState(""); const [isStreaming, setIsStreaming] = useState(false); - const [toolCallCount, setToolCallCount] = useState(0); + const [toolCalls, setToolCalls] = useState([]); + const toolCallsRef = useRef([]); const [startTime, setStartTime] = useState(null); const processingRef = useRef(false); const queueRef = useRef([]); @@ -119,8 +120,26 @@ export function App({ ]); }); - const unsubTool = logger.onToolCall(() => { - setToolCallCount((prev) => prev + 1); + const unsubTool = logger.onToolCallUpdate((info: ToolCallInfo) => { + setToolCalls((prev) => { + let next: ToolCallInfo[]; + if (info.status === "running") { + next = [...prev, info]; + } else { + // Mark existing call as completed + next = prev.map((tc) => + tc.callId === info.callId + ? { + ...tc, + status: "completed" as const, + duration: Date.now() - tc.startedAt, + } + : tc, + ); + } + toolCallsRef.current = next; + return next; + }); }); return () => { @@ -144,19 +163,40 @@ export function App({ setIsStreaming(true); setStreamingText(""); - setToolCallCount(0); + setToolCalls([]); + toolCallsRef.current = []; setStartTime(Date.now()); try { const stream = await agent.chat(input, [mcpServer]); - const textStream = stream.toTextStream({ - compatibleWithNodeStreams: true, - }); let accumulated = ""; - for await (const chunk of textStream) { - accumulated += chunk; - setStreamingText(accumulated); + for await (const event of stream) { + if ( + event.type === "raw_model_stream_event" && + event.data.type === "output_text_delta" + ) { + accumulated += (event.data as { delta: string }).delta; + setStreamingText(accumulated); + } else if (event.type === "run_item_stream_event") { + const raw = event.item?.rawItem as { + name?: string; + callId?: string; + arguments?: string; + }; + if (event.name === "tool_called") { + logger.toolCallStarted( + raw?.callId || "", + raw?.name || "unknown", + raw?.arguments || "", + ); + } else if (event.name === "tool_output") { + logger.toolCallCompleted( + raw?.callId || "", + raw?.name || "unknown", + ); + } + } } const finalOutput = await agent.finalizeStream(stream); @@ -169,6 +209,7 @@ export function App({ content, rendered: renderMarkdown(content), timestamp: logger.getTimestamp(), + toolCalls: toolCallsRef.current, }, ]); } catch (err) { @@ -186,7 +227,8 @@ export function App({ setIsStreaming(false); setStreamingText(""); setStartTime(null); - setToolCallCount(0); + setToolCalls([]); + toolCallsRef.current = []; } }, [agent, mcpServer, logger], @@ -255,7 +297,7 @@ export function App({ messages={messages} streamingText={streamingText} isStreaming={isStreaming} - toolCallCount={toolCallCount} + toolCalls={toolCalls} startTime={startTime} scrollX={scrollX} scrollY={scrollY} diff --git a/components/Message.tsx b/components/Message.tsx index 93e1160..75317a4 100644 --- a/components/Message.tsx +++ b/components/Message.tsx @@ -9,6 +9,7 @@ export interface MessageData { content: string; rendered: string; timestamp: string; + toolCalls?: import("../classes/logger.js").ToolCallInfo[]; } interface MessageProps extends MessageData { diff --git a/components/MessageArea.tsx b/components/MessageArea.tsx index cd4d2da..37de18e 100644 --- a/components/MessageArea.tsx +++ b/components/MessageArea.tsx @@ -2,6 +2,7 @@ import { Box, Static, Text } from "ink"; import type { BoxStyle } from "cli-boxes"; import Spinner from "ink-spinner"; import type { MessageData } from "./Message.js"; +import type { ToolCallInfo } from "../classes/logger.js"; import { renderMarkdown, applyHorizontalScroll } from "../utils/markdown.js"; const dashedBorder: BoxStyle = { @@ -15,11 +16,42 @@ const dashedBorder: BoxStyle = { left: "╎", }; +function ToolCallTree({ toolCalls }: { toolCalls: ToolCallInfo[] }) { + if (toolCalls.length === 0) return null; + return ( + + 🛠️ Tool calls: + + {toolCalls.map((tc, i) => { + const isLast = i === toolCalls.length - 1; + const prefix = isLast ? "└─" : "├─"; + const truncatedArgs = + tc.args.length > 80 ? tc.args.slice(0, 80) + "…" : tc.args; + return ( + + {tc.status === "running" ? ( + + {prefix} ⏳ {tc.name}({truncatedArgs}) + + ) : ( + + {prefix} ✓ {tc.name}({truncatedArgs}) ( + {(tc.duration! / 1000).toFixed(1)}s) + + )} + + ); + })} + + + ); +} + interface MessageAreaProps { messages: MessageData[]; streamingText: string; isStreaming: boolean; - toolCallCount: number; + toolCalls: ToolCallInfo[]; startTime: number | null; scrollX: number; scrollY: number; @@ -33,7 +65,7 @@ export function MessageArea({ messages, streamingText, isStreaming, - toolCallCount, + toolCalls, startTime, scrollX, scrollY, @@ -62,6 +94,11 @@ export function MessageArea({ {(msg, i) => ( {msg.timestamp} + {msg.toolCalls && msg.toolCalls.length > 0 && ( + + + + )} {msg.role === "assistant" ? ( {msg.rendered} ) : ( @@ -92,11 +129,14 @@ export function MessageArea({ Thinking... {startTime && ` 🕝 ${Math.round((Date.now() - startTime) / 1000)}s`} - {toolCallCount > 0 && - ` | 🛠️ ${toolCallCount} tool call${toolCallCount > 1 ? "s" : ""}`} - {streamingText && {renderMarkdown(streamingText)}} + + {streamingText && ( + 0 ? 1 : 0}> + {renderMarkdown(streamingText)} + + )} )} diff --git a/tests/logger.test.ts b/tests/logger.test.ts index 318abf6..93112fd 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -137,30 +137,36 @@ describe("Logger", () => { logger = new Logger(config); }); - it("should have incrementToolCalls method", () => { - expect(typeof logger.incrementToolCalls).toBe("function"); - expect(() => logger.incrementToolCalls()).not.toThrow(); + it("should have toolCallStarted and toolCallCompleted methods", () => { + expect(typeof logger.toolCallStarted).toBe("function"); + expect(typeof logger.toolCallCompleted).toBe("function"); + expect(() => logger.toolCallStarted("id1", "test", "{}")).not.toThrow(); }); - it("should emit toolCall events", () => { - let called = 0; - logger.onToolCall(() => called++); + it("should emit toolCallUpdate events", () => { + const updates: any[] = []; + logger.onToolCallUpdate((info: any) => updates.push(info)); - logger.incrementToolCalls(); - logger.incrementToolCalls(); + logger.toolCallStarted("id1", "search", '{"q":"hello"}'); + logger.toolCallCompleted("id1", "search"); - expect(called).toBe(2); + expect(updates.length).toBe(2); + expect(updates[0].status).toBe("running"); + expect(updates[0].name).toBe("search"); + expect(updates[0].args).toBe('{"q":"hello"}'); + expect(updates[1].status).toBe("completed"); + expect(updates[1].callId).toBe("id1"); }); it("should allow unsubscribing from events", () => { - let called = 0; - const unsub = logger.onToolCall(() => called++); + const updates: any[] = []; + const unsub = logger.onToolCallUpdate((info: any) => updates.push(info)); - logger.incrementToolCalls(); + logger.toolCallStarted("id1", "test", "{}"); unsub(); - logger.incrementToolCalls(); + logger.toolCallStarted("id2", "test2", "{}"); - expect(called).toBe(1); + expect(updates.length).toBe(1); }); });