From ad0484b7fb43b62789e989559cee15e305174c51 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Tue, 4 Aug 2026 21:25:35 -0400 Subject: [PATCH 1/2] feat: display tool call count and turn duration in MessageBubble --- coverage.txt | 56 ++--- src/tui/app.js | 22 +- src/tui/messageBubble.js | 20 +- src/tui/messageList.js | 11 +- tests/unit/messageListToolStats.test.js | 299 ++++++++++++++++++++++++ 5 files changed, 377 insertions(+), 31 deletions(-) create mode 100644 tests/unit/messageListToolStats.test.js diff --git a/coverage.txt b/coverage.txt index 534ec3f1..44e25b8a 100644 --- a/coverage.txt +++ b/coverage.txt @@ -1,46 +1,46 @@ ℹ start of coverage report -ℹ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +ℹ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ file | line % | branch % | funcs % | uncovered lines -ℹ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +ℹ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ src | | | | ℹ agent | | | | ℹ agentRegistry.js | 92.23 | 84.00 | 100.00 | 72-73 78-79 82-83 86-87 ℹ agents | | | | -ℹ code-review.js | 93.55 | 66.67 | 100.00 | 18-19 -ℹ coding.js | 93.33 | 66.67 | 100.00 | 18-19 -ℹ debug.js | 93.55 | 66.67 | 100.00 | 18-19 -ℹ documentation.js | 93.55 | 66.67 | 100.00 | 18-19 +ℹ code-review.js | 91.89 | 75.00 | 100.00 | 19-21 +ℹ coding.js | 91.89 | 75.00 | 100.00 | 19-21 +ℹ debug.js | 91.89 | 75.00 | 100.00 | 19-21 +ℹ documentation.js | 91.89 | 75.00 | 100.00 | 19-21 ℹ index.js | 100.00 | 100.00 | 100.00 | -ℹ performance.js | 93.55 | 66.67 | 100.00 | 18-19 -ℹ research.js | 93.55 | 66.67 | 100.00 | 18-19 -ℹ search.js | 93.55 | 66.67 | 100.00 | 18-19 -ℹ security-audit.js | 93.55 | 66.67 | 100.00 | 18-19 -ℹ testing.js | 93.33 | 66.67 | 100.00 | 18-19 +ℹ performance.js | 91.89 | 75.00 | 100.00 | 19-21 +ℹ research.js | 91.89 | 75.00 | 100.00 | 19-21 +ℹ search.js | 91.89 | 75.00 | 100.00 | 19-21 +ℹ security-audit.js | 91.89 | 75.00 | 100.00 | 19-21 +ℹ testing.js | 91.67 | 75.00 | 100.00 | 19-21 ℹ config | | | | ℹ loader.js | 89.62 | 83.33 | 72.73 | 66-69 87-89 96 114 116 166-170 180-183 ℹ mutate.js | 54.72 | 100.00 | 0.00 | 11-15 25-37 48-53 ℹ schemas.js | 100.00 | 100.00 | 100.00 | -ℹ logger.js | 75.23 | 40.91 | 72.73 | 26-34 39 41-43 64-65 73-77 100-106 112-116 131 163-164 166-167 184-185 191-192 198-199 202-206 209-213 216 +ℹ logger.js | 76.61 | 41.67 | 81.82 | 26-34 39 41-43 64-65 73-77 100-106 112-116 131 163-164 166-167 184-185 191-192 198-199 205-206 209-213 216 ℹ memory | | | | -ℹ context.js | 98.21 | 78.79 | 100.00 | 110-111 -ℹ expireEphemeral.js | 94.29 | 73.68 | 100.00 | 24-25 65-66 +ℹ context.js | 97.44 | 78.79 | 100.00 | 114-116 +ℹ expireEphemeral.js | 93.15 | 73.68 | 100.00 | 25-27 68-69 ℹ gc.js | 99.30 | 96.00 | 100.00 | 53 -ℹ profile.js | 98.87 | 96.23 | 100.00 | 76-77 +ℹ profile.js | 98.48 | 96.43 | 100.00 | 82-84 ℹ prompts.js | 100.00 | 100.00 | 100.00 | -ℹ reader.js | 96.36 | 81.25 | 100.00 | 22-23 +ℹ reader.js | 95.16 | 82.35 | 100.00 | 23-25 ℹ provider | | | | ℹ openai.js | 100.00 | 100.00 | 100.00 | ℹ sandbox | | | | ℹ capability.js | 100.00 | 100.00 | 100.00 | ℹ envInjector.js | 100.00 | 100.00 | 100.00 | ℹ pathResolver.js | 100.00 | 100.00 | 100.00 | -ℹ runner.js | 92.23 | 63.46 | 88.89 | 30 63 65 69 77 79 84 86 88 90 94-95 132-133 181 +ℹ runner.js | 91.58 | 62.96 | 88.89 | 31 70 72 76 84 86 91 93 95 97 101-102 139-140 142-143 190 ℹ timeoutHandler.js | 100.00 | 100.00 | 100.00 | ℹ urlFilter.js | 100.00 | 93.75 | 100.00 | ℹ scheduler | | | | -ℹ cron.js | 30.19 | 100.00 | 5.88 | 29-30 52-53 65 73-79 87-96 103-105 114-134 145-185 196-230 240-283 291-326 334-370 379-398 408-432 441-464 480-571 +ℹ cron.js | 30.41 | 100.00 | 5.26 | 29-53 62-73 83-84 107-109 121 129-135 143-151 158-160 169-189 200-240 251-285 295-338 346-381 389-425 434-453 463-487 496-519 535-626 ℹ index.js | 100.00 | 100.00 | 100.00 | -ℹ scheduler.js | 99.09 | 95.00 | 85.00 | 217-218 +ℹ scheduler.js | 93.64 | 93.65 | 85.00 | 192-204 234-235 ℹ session | | | | ℹ checkpointer.js | 82.22 | 87.50 | 50.00 | 22 24 39-43 45 ℹ factory.js | 100.00 | 100.00 | 100.00 | @@ -52,8 +52,8 @@ ℹ stateManager.js | 100.00 | 100.00 | 100.00 | ℹ window.js | 100.00 | 91.67 | 100.00 | ℹ skills | | | | -ℹ discoverer.js | 96.35 | 87.93 | 100.00 | 61-66 173-174 -ℹ registry.js | 76.69 | 46.15 | 47.37 | 46-49 52-54 106-107 128-129 146-147 155-164 175-177 180-182 208-214 222-226 234-238 245-246 260-267 276-283 291-295 +ℹ discoverer.js | 96.52 | 88.33 | 100.00 | 62-67 177-178 +ℹ registry.js | 76.61 | 46.15 | 47.37 | 45-48 51-53 105-106 127-128 145-146 154-163 174-176 179-181 207-213 221-225 233-237 244-245 259-266 275-282 290-294 ℹ types.js | 100.00 | 100.00 | 100.00 | ℹ validator.js | 89.78 | 76.32 | 80.00 | 19-20 27-28 68 70 72-73 105-107 119-121 ℹ tools | | | | @@ -64,13 +64,13 @@ ℹ cron.js | 94.41 | 88.46 | 73.68 | 93-94 106-107 228-229 231-242 246-252 325-326 ℹ date.js | 100.00 | 100.00 | 100.00 | ℹ image.js | 97.50 | 91.67 | 50.00 | 95-97 -ℹ index.js | 100.00 | 94.29 | 100.00 | +ℹ index.js | 100.00 | 94.44 | 100.00 | ℹ memory.js | 96.52 | 83.56 | 93.33 | 55 98-99 194-198 298-300 ℹ moa.js | 100.00 | 94.44 | 84.62 | ℹ sampling.js | 94.97 | 81.82 | 80.00 | 27 180-188 ℹ scanAgents.js | 100.00 | 80.00 | 100.00 | ℹ session_search.js | 97.06 | 71.19 | 94.12 | 71-72 118-119 128 181-182 -ℹ shell.js | 92.58 | 76.47 | 86.67 | 41-44 81 110-111 197-198 204-206 212-213 220-221 228-229 231 +ℹ shell.js | 92.61 | 76.47 | 86.67 | 42-45 82 111-112 198-199 205-207 213-214 221-222 229-230 232 ℹ skills.js | 77.47 | 85.48 | 60.00 | 40-60 81-114 168-169 196-197 224-232 243-250 270-277 293-295 310-311 ℹ tts.js | 100.00 | 88.00 | 50.00 | ℹ vision.js | 100.00 | 84.21 | 80.00 | @@ -82,14 +82,14 @@ ℹ conversationPanel.js | 100.00 | 100.00 | 100.00 | ℹ inputPanel.js | 100.00 | 100.00 | 100.00 | ℹ markdownText.js | 94.74 | 90.91 | 72.73 | 75 90-91 99-100 123-126 -ℹ messageBubble.js | 85.20 | 59.26 | 69.23 | 138-143 163-172 177-184 189-196 236-240 -ℹ messageList.js | 83.17 | 59.46 | 57.69 | 74 91-94 119-136 146-162 171 178-182 221 229 237 246-252 260-265 284-286 306 354-355 +ℹ messageBubble.js | 83.21 | 53.33 | 69.23 | 142-147 167-176 181-188 193-200 206-213 253-257 +ℹ messageList.js | 82.81 | 59.46 | 57.69 | 74 91-94 120-140 150-166 175 182-186 228 236 244 253-259 267-272 291-293 313 361-362 ℹ messages.js | 100.00 | 94.44 | 100.00 | ℹ panels.js | 100.00 | 100.00 | 100.00 | ℹ statusBar.js | 91.89 | 84.21 | 100.00 | 36-37 48-54 ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | -ℹ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 86.32 | 83.32 | 79.44 | -ℹ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +ℹ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +ℹ all files | 85.90 | 83.25 | 79.71 | +ℹ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/src/tui/app.js b/src/tui/app.js index 400db616..b64b20e8 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -208,14 +208,17 @@ export default function App({ } const assistantTime = getTimestamp(); + const assistantStartTime = Date.now(); streamingMsgIdRef.current = messageListRef.current.addMessage("assistant", "", { time: assistantTime, + startTime: assistantStartTime, streaming: true, }); let committedContentRef = { current: "" }; let committedReasoning = ""; let lastToolCallDisplay = ""; + let toolCallCount = 0; let todoStatusLines = ""; // Set up abort controller for this stream @@ -296,6 +299,7 @@ export default function App({ committedReasoning, lastToolCallDisplay, todoStatusLines, + toolCallCount, ); // Persist assistant response to session state @@ -378,14 +382,17 @@ export default function App({ } const assistantTime = getTimestamp(); + const assistantStartTime = Date.now(); streamingMsgIdRef.current = messageListRef.current.addMessage("assistant", "", { time: assistantTime, + startTime: assistantStartTime, streaming: true, }); let committedContentRef = { current: "" }; let committedReasoning = ""; let lastToolCallDisplay = ""; + let toolCallCount = 0; let todoStatusLines = ""; // Set up abort controller for this stream @@ -470,7 +477,7 @@ export default function App({ sessionState.addExchange({ role: "user", content: text }); } - finalizeStreaming(responseContent, committedReasoning, lastToolCallDisplay, todoStatusLines); + finalizeStreaming(responseContent, committedReasoning, lastToolCallDisplay, todoStatusLines, toolCallCount); // Persist assistant message and recalculate context if (sessionState) { @@ -728,6 +735,7 @@ export default function App({ // Handle on_tool_start — set activeToolCall if (event.type === "on_tool_start") { + toolCallCount++; messageListRef.current?.updateMessage(streamingMsgIdRef.current, { activeToolCall: { name: event.name, @@ -773,13 +781,19 @@ export default function App({ * @param {string} committedReasoning - Accumulated reasoning content * @param {string} lastToolCallDisplay - Tool call display text * @param {string} todoStatusLines - Todo status lines + * @param {number} [toolCallCount] - Number of tool calls made during the turn */ const finalizeStreaming = ( responseContent, committedReasoning, lastToolCallDisplay, todoStatusLines, + toolCallCount, ) => { + const msgData = messageListRef.current?.getMessageData(streamingMsgIdRef.current); + const startTime = msgData?.startTime; + const durationMs = startTime ? Date.now() - startTime : 0; + const updates = { content: responseContent, reasoningContent: committedReasoning || undefined, @@ -799,6 +813,12 @@ export default function App({ updates.toolCallDisplay = todoStatusLines; } } + if (toolCallCount > 0) { + updates.toolCallCount = toolCallCount; + } + if (durationMs > 0) { + updates.turnDurationMs = durationMs; + } messageListRef.current?.updateMessage(streamingMsgIdRef.current, updates); }; diff --git a/src/tui/messageBubble.js b/src/tui/messageBubble.js index bf9f7a0a..da6691a6 100644 --- a/src/tui/messageBubble.js +++ b/src/tui/messageBubble.js @@ -113,7 +113,9 @@ export const PubSubContext = React.createContext({ subscribe: () => {}, unsubscr * @param {string} [props.reasoningContent] - Thinking/thought content * @param {Object} [props.activeToolCall] - {name: string} for running tool * @param {string} [props.toolCallDisplay] - Tool call result display text - + * @param {number} [props.toolCallCount] - Number of tools called during this turn + * @param {number} [props.turnDurationMs] - Duration of the turn in milliseconds + * * @returns {React.ReactElement} */ export function MessageBubble({ @@ -125,6 +127,8 @@ export function MessageBubble({ reasoningContent, activeToolCall, toolCallDisplay, + toolCallCount, + turnDurationMs, }) { const [chunks, setChunks] = useState([]); const { subscribe, unsubscribe } = useContext(PubSubContext); @@ -196,6 +200,19 @@ export function MessageBubble({ ) : null; + const hasTurnStats = role === "assistant" && toolCallCount > 0 && turnDurationMs > 0; + const turnStatsEl = hasTurnStats + ? React.createElement( + Box, + { flexDirection: "row", marginTop: 1, marginLeft: 2 }, + React.createElement( + Text, + { dimColor: true, color: "gray" }, + `🔧 ${toolCallCount} tool${toolCallCount > 1 ? "s" : ""} · ⏱ ${Math.round(turnDurationMs / 100) / 10}s`, + ), + ) + : null; + const pendingState = role === "assistant" && chunks.length === 0 && !content; return React.createElement( @@ -243,6 +260,7 @@ export function MessageBubble({ reasoningEl, toolCallEl, toolDisplayEl, + turnStatsEl, ), ); } diff --git a/src/tui/messageList.js b/src/tui/messageList.js index d51a103d..2b174e38 100644 --- a/src/tui/messageList.js +++ b/src/tui/messageList.js @@ -113,6 +113,7 @@ export const MessageList = forwardRef(function MessageList( * @param {string} [options.toolCallDisplay] - Tool call display text * @param {Array} [options.events] - Raw stream events * @param {boolean} [options.streaming] - Streaming flag + * @param {number} [options.startTime] - Epoch ms start time for duration calc * @returns {string} The assigned message ID */ addMessage(role, content, options = {}) { @@ -128,6 +129,9 @@ export const MessageList = forwardRef(function MessageList( toolCallDisplay: options.toolCallDisplay, events: options.events, streaming: options.streaming || false, + startTime: options.startTime, + toolCallCount: options.toolCallCount, + turnDurationMs: options.turnDurationMs, }); idsRef.current.push(id); @@ -184,7 +188,7 @@ export const MessageList = forwardRef(function MessageList( /** * Initialize the list from a messages data array. - * @param {Array<{role: string, content: string, time?: string, reasoningContent?: string, activeToolCall?: Object, toolCallDisplay?: string, events?: Array}>} msgs + * @param {Array<{role: string, content: string, time?: string, reasoningContent?: string, activeToolCall?: Object, toolCallDisplay?: string, events?: Array, startTime?: number, toolCallCount?: number, turnDurationMs?: number}>} msgs */ setMessages(msgs) { idsRef.current = []; @@ -204,6 +208,9 @@ export const MessageList = forwardRef(function MessageList( toolCallDisplay: m.toolCallDisplay, events: m.events, streaming: m.streaming || false, + startTime: m.startTime, + toolCallCount: m.toolCallCount, + turnDurationMs: m.turnDurationMs, }); idsRef.current.push(id); @@ -369,6 +376,8 @@ export const MessageList = forwardRef(function MessageList( streaming: data.streaming, assistantName, topic: `msg-${id}`, + toolCallCount: data.toolCallCount, + turnDurationMs: data.turnDurationMs, }); }); diff --git a/tests/unit/messageListToolStats.test.js b/tests/unit/messageListToolStats.test.js new file mode 100644 index 00000000..739f8825 --- /dev/null +++ b/tests/unit/messageListToolStats.test.js @@ -0,0 +1,299 @@ +import { createPubSub } from "../../src/tui/messageBubble.js"; +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert"; + +/** + * Simulates the imperative API used by MessageList without React. + * Tests the new startTime, toolCallCount, and turnDurationMs fields. + */ +describe("messageList - tool call count and turn duration", () => { + let pubsub; + let idsRef; + let idToIdxRef; + let dataRef; + + function reset() { + pubsub = createPubSub(); + idsRef = []; + idToIdxRef = new Map(); + dataRef = new Map(); + } + + beforeEach(reset); + + function addMessage(role, content, options = {}) { + const id = (++global._msgIdCounter || (global._msgIdCounter = 0)).toString(); + dataRef.set(id, { + id, + role, + content: content || "", + time: options.time, + reasoningContent: options.reasoningContent, + activeToolCall: options.activeToolCall, + toolCallDisplay: options.toolCallDisplay, + streaming: options.streaming || false, + startTime: options.startTime, + toolCallCount: options.toolCallCount, + turnDurationMs: options.turnDurationMs, + }); + idsRef.push(id); + idToIdxRef.set(id, idsRef.length - 1); + return id; + } + + function updateMessage(id, updates) { + const idx = idToIdxRef.get(id); + if (idx === undefined) return; + const existing = dataRef.get(id); + if (existing) { + dataRef.set(id, { ...existing, ...updates }); + } + idsRef[idx] = id; + pubsub.publish(`msg-${id}`, dataRef.get(id)); + } + + function setMessages(msgs) { + idsRef = []; + idToIdxRef = new Map(); + dataRef = new Map(); + + for (const m of msgs) { + const id = (++global._msgIdCounter || (global._msgIdCounter = 0)).toString(); + dataRef.set(id, { + id, + role: m.role, + content: m.content || "", + time: m.time, + reasoningContent: m.reasoningContent, + activeToolCall: m.activeToolCall, + toolCallDisplay: m.toolCallDisplay, + events: m.events, + streaming: m.streaming || false, + startTime: m.startTime, + toolCallCount: m.toolCallCount, + turnDurationMs: m.turnDurationMs, + }); + idsRef.push(id); + idToIdxRef.set(id, idsRef.length - 1); + } + } + + /** + * Simulates the turn stats rendering logic from MessageBubble. + * Returns the formatted string if stats should be shown, null otherwise. + */ + function simulateTurnStats(role, toolCallCount, turnDurationMs) { + if (role !== "assistant" || !toolCallCount || toolCallCount <= 0 || !turnDurationMs || turnDurationMs <= 0) { + return null; + } + return `🔧 ${toolCallCount} tool${toolCallCount > 1 ? "s" : ""} · ⏱ ${Math.round(turnDurationMs / 100) / 10}s`; + } + + describe("addMessage with new fields", () => { + it("stores startTime when provided", () => { + const now = Date.now(); + const id = addMessage("assistant", "hello", { startTime: now }); + assert.strictEqual(dataRef.get(id).startTime, now); + }); + + it("stores toolCallCount when provided", () => { + const id = addMessage("assistant", "hello", { toolCallCount: 3 }); + assert.strictEqual(dataRef.get(id).toolCallCount, 3); + }); + + it("stores turnDurationMs when provided", () => { + const id = addMessage("assistant", "hello", { turnDurationMs: 2400 }); + assert.strictEqual(dataRef.get(id).turnDurationMs, 2400); + }); + + it("stores all three fields together", () => { + const now = Date.now(); + const id = addMessage("assistant", "hello", { + startTime: now, + toolCallCount: 2, + turnDurationMs: 1500, + }); + const msg = dataRef.get(id); + assert.strictEqual(msg.startTime, now); + assert.strictEqual(msg.toolCallCount, 2); + assert.strictEqual(msg.turnDurationMs, 1500); + }); + + it("does not store fields when not provided", () => { + const id = addMessage("assistant", "hello"); + const msg = dataRef.get(id); + assert.strictEqual(msg.startTime, undefined); + assert.strictEqual(msg.toolCallCount, undefined); + assert.strictEqual(msg.turnDurationMs, undefined); + }); + + it("user messages can also carry the fields (for completeness)", () => { + const id = addMessage("user", "hello", { toolCallCount: 0, turnDurationMs: 0 }); + const msg = dataRef.get(id); + assert.strictEqual(msg.toolCallCount, 0); + assert.strictEqual(msg.turnDurationMs, 0); + }); + }); + + describe("updateMessage with new fields", () => { + it("updates toolCallCount on existing message", () => { + const id = addMessage("assistant", "hello"); + updateMessage(id, { toolCallCount: 5 }); + assert.strictEqual(dataRef.get(id).toolCallCount, 5); + }); + + it("updates turnDurationMs on existing message", () => { + const id = addMessage("assistant", "hello", { startTime: Date.now() - 3000 }); + updateMessage(id, { turnDurationMs: 3000 }); + assert.strictEqual(dataRef.get(id).turnDurationMs, 3000); + }); + + it("merges new fields with existing data", () => { + const id = addMessage("assistant", "hello", { toolCallCount: 2 }); + updateMessage(id, { turnDurationMs: 1200 }); + const msg = dataRef.get(id); + assert.strictEqual(msg.toolCallCount, 2); + assert.strictEqual(msg.turnDurationMs, 1200); + }); + }); + + describe("setMessages with new fields", () => { + it("restores startTime from messages array", () => { + const now = Date.now(); + setMessages([{ role: "assistant", content: "hi", startTime: now }]); + assert.strictEqual(dataRef.get(idsRef[0]).startTime, now); + }); + + it("restores toolCallCount from messages array", () => { + setMessages([{ role: "assistant", content: "hi", toolCallCount: 4 }]); + assert.strictEqual(dataRef.get(idsRef[0]).toolCallCount, 4); + }); + + it("restores turnDurationMs from messages array", () => { + setMessages([{ role: "assistant", content: "hi", turnDurationMs: 5000 }]); + assert.strictEqual(dataRef.get(idsRef[0]).turnDurationMs, 5000); + }); + + it("restores all three fields from messages array", () => { + const now = Date.now(); + setMessages([ + { + role: "assistant", + content: "hi", + startTime: now, + toolCallCount: 3, + turnDurationMs: 2500, + }, + ]); + const msg = dataRef.get(idsRef[0]); + assert.strictEqual(msg.startTime, now); + assert.strictEqual(msg.toolCallCount, 3); + assert.strictEqual(msg.turnDurationMs, 2500); + }); + }); + + describe("MessageBubble turn stats rendering", () => { + it("renders stats for assistant with tools and duration", () => { + const result = simulateTurnStats("assistant", 3, 2400); + assert.strictEqual(result, "🔧 3 tools · ⏱ 2.4s"); + }); + + it("renders stats for assistant with single tool", () => { + const result = simulateTurnStats("assistant", 1, 500); + assert.strictEqual(result, "🔧 1 tool · ⏱ 0.5s"); + }); + + it("renders stats for assistant with zero duration", () => { + const result = simulateTurnStats("assistant", 2, 0); + assert.strictEqual(result, null); + }); + + it("renders stats for assistant with zero tool count", () => { + const result = simulateTurnStats("assistant", 0, 1000); + assert.strictEqual(result, null); + }); + + it("does not render stats for user messages", () => { + const result = simulateTurnStats("user", 3, 2400); + assert.strictEqual(result, null); + }); + + it("does not render stats for system messages", () => { + const result = simulateTurnStats("system", 3, 2400); + assert.strictEqual(result, null); + }); + + it("handles undefined toolCallCount", () => { + const result = simulateTurnStats("assistant", undefined, 1000); + assert.strictEqual(result, null); + }); + + it("handles undefined turnDurationMs", () => { + const result = simulateTurnStats("assistant", 2, undefined); + assert.strictEqual(result, null); + }); + + it("formats duration with one decimal place", () => { + const result = simulateTurnStats("assistant", 1, 1234); + assert.strictEqual(result, "🔧 1 tool · ⏱ 1.2s"); + }); + + it("formats duration rounded to nearest tenth", () => { + const result = simulateTurnStats("assistant", 1, 1250); + assert.strictEqual(result, "🔧 1 tool · ⏱ 1.3s"); + }); + + it("handles large tool counts", () => { + const result = simulateTurnStats("assistant", 15, 10000); + assert.strictEqual(result, "🔧 15 tools · ⏱ 10s"); + }); + + it("handles sub-second duration", () => { + const result = simulateTurnStats("assistant", 1, 50); + assert.strictEqual(result, "🔧 1 tool · ⏱ 0.1s"); + }); + }); + + describe("integration: full flow simulation", () => { + it("simulates the full app.js flow: add -> update with stats -> render", () => { + const startTime = Date.now() - 2400; + const id = addMessage("assistant", "", { + startTime, + streaming: true, + }); + + // Simulate finalizeStreaming updating the message + updateMessage(id, { + content: "Here is the result.", + streaming: false, + toolCallCount: 3, + turnDurationMs: 2400, + }); + + const msg = dataRef.get(id); + assert.strictEqual(msg.content, "Here is the result."); + assert.strictEqual(msg.toolCallCount, 3); + assert.strictEqual(msg.turnDurationMs, 2400); + + // Simulate what MessageBubble would render + const stats = simulateTurnStats(msg.role, msg.toolCallCount, msg.turnDurationMs); + assert.strictEqual(stats, "🔧 3 tools · ⏱ 2.4s"); + }); + + it("no stats rendered when no tools were called", () => { + const startTime = Date.now() - 500; + const id = addMessage("assistant", "simple answer", { + startTime, + }); + + updateMessage(id, { + toolCallCount: 0, + turnDurationMs: 500, + }); + + const msg = dataRef.get(id); + const stats = simulateTurnStats(msg.role, msg.toolCallCount, msg.turnDurationMs); + assert.strictEqual(stats, null); + }); + }); +}); From d32da2ce93b314c69882e91ebee622b75ef69791 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Wed, 5 Aug 2026 07:53:41 -0400 Subject: [PATCH 2/2] fix: use streamEvents v2 for deepagents agent streaming - Switch from agent.stream() (returns Promise, not async iterable) to agent.streamEvents() which is the correct async iterable API - Fix content extraction: streamEvents v2 chunks are AIMessageChunk objects with content at chunk.content, not chunk.kwargs.content - Count tool calls from tool_call_chunks and tool_calls on message chunks since deepagents doesn't emit on_tool_start/end events - Render tool call display from on_chat_model_end output - Add events rendering layer to MessageBubble with normalized event type labels and deduplicated counts --- index.js | 54 ++++++++++++--- src/tui/app.js | 92 +++++++++++++------------ src/tui/messageBubble.js | 56 +++++++++++++++ src/tui/messageList.js | 6 +- tests/unit/messageListToolStats.test.js | 8 ++- 5 files changed, 162 insertions(+), 54 deletions(-) diff --git a/index.js b/index.js index 7e39b50c..5ad51c48 100644 --- a/index.js +++ b/index.js @@ -181,28 +181,64 @@ async function callProvider(_name, _providerConfig, message, streamingCallback, }; let collectedContent = ""; + let toolCallCount = 0; const input = { messages: [{ role: "user", content: message }], }; - for await (const [_namespace, chunk] of await agent.stream(input, { + for await (const event of agent.streamEvents(input, { ...config, ...options, - streamMode: "messages", + version: "v2", subgraphs: true, })) { - const [message] = chunk; - const text = message?.text ?? ""; + // Transform LangChain streamEvents format to match streamingCallback expectations + const transformedEvent = { + type: event.event, + name: event.name, + data: event.data, + metadata: event.metadata, + }; + + // Forward the transformed event to the streaming callback + if (streamingCallback) { + streamingCallback(transformedEvent); + } + + // Count tool calls + if (event.event === "on_tool_start") { + toolCallCount++; + } - if (text) { - collectedContent += text; - if (streamingCallback) { - streamingCallback({ type: "message", text }); + // Accumulate text content from message events + if (event.event === "on_chat_model_stream" && event.data?.chunk) { + const chunk = event.data.chunk; + // streamEvents v2 chunks are AIMessageChunk objects — content is direct + const content = chunk.content ?? ""; + if (content) { + collectedContent += content; + } + } + if (event.event === "message" && event.data?.content) { + const text = + typeof event.data.content === "string" + ? event.data.content + : (event.data.content?.text ?? ""); + if (text) { + collectedContent += text; } } + if (event.event === "on_chat_model_end" && event.data?.output?.kwargs?.content) { + collectedContent += event.data.output.kwargs.content; + } } - return { provider: providerName, content: collectedContent, tokens: { input: 0, output: 0 } }; + return { + provider: providerName, + content: collectedContent, + tokens: { input: 0, output: 0 }, + toolCallCount, + }; } // Conversation handler diff --git a/src/tui/app.js b/src/tui/app.js index b64b20e8..6426bd1c 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -477,7 +477,13 @@ export default function App({ sessionState.addExchange({ role: "user", content: text }); } - finalizeStreaming(responseContent, committedReasoning, lastToolCallDisplay, todoStatusLines, toolCallCount); + finalizeStreaming( + responseContent, + committedReasoning, + lastToolCallDisplay, + todoStatusLines, + toolCallCount, + ); // Persist assistant message and recalculate context if (sessionState) { @@ -709,66 +715,66 @@ export default function App({ }); if (event.type === "message") { - committedContentRef.current = (committedContentRef.current || "") + event.text; - messageListRef.current?.updateMessage(streamingMsgIdRef.current, { - content: committedContentRef.current + (config?.tui?.cursorChar || "\u2588"), - streaming: true, - }); - if (onTextReceived) onTextReceived(); + // streamMode "events" carries content in data.content, not data.text + const msgText = + event.data?.content && typeof event.data.content === "string" + ? event.data.content + : (event.data?.content?.text ?? event.text ?? ""); + if (msgText) { + committedContentRef.current = (committedContentRef.current || "") + msgText; + messageListRef.current?.updateMessage(streamingMsgIdRef.current, { + content: committedContentRef.current + (config?.tui?.cursorChar || "\u2588"), + streaming: true, + }); + if (onTextReceived) onTextReceived(); + } } // Handle on_chat_model_stream — accumulate content and reasoning if (event.type === "on_chat_model_stream") { - if (event.data?.chunk?.content) { - committedContentRef.current = - (committedContentRef.current || "") + event.data.chunk.content; + // deepagents streamEvents v2 chunks are AIMessageChunk objects + const chunk = event.data?.chunk; + const content = chunk?.content ?? ""; + if (content) { + committedContentRef.current = (committedContentRef.current || "") + content; messageListRef.current?.updateMessage(streamingMsgIdRef.current, { content: committedContentRef.current + (config?.tui?.cursorChar || "\u2588"), streaming: true, }); } - if (event.data?.chunk?.reasoning) { - committedReasoningRef.current = - (committedReasoningRef.current || "") + event.data.chunk.reasoning; + // Count tool calls from tool_call_chunks (streaming) and tool_calls (final) + if (chunk?.tool_call_chunks?.length) { + toolCallCount += chunk.tool_call_chunks.length; + for (const tc of chunk.tool_call_chunks) { + messageListRef.current?.updateMessage(streamingMsgIdRef.current, { + activeToolCall: { + name: tc.name, + status: "running", + }, + }); + } + } + if (chunk?.tool_calls?.length) { + toolCallCount += chunk.tool_calls.length; + } + if (chunk?.reasoning) { + committedReasoningRef.current = (committedReasoningRef.current || "") + chunk.reasoning; } } - // Handle on_tool_start — set activeToolCall - if (event.type === "on_tool_start") { - toolCallCount++; - messageListRef.current?.updateMessage(streamingMsgIdRef.current, { - activeToolCall: { - name: event.name, - input: event.data?.input, - status: "running", - }, - }); - } - - // Handle on_tool_end — clear activeToolCall, set toolCallDisplay - if (event.type === "on_tool_end") { - messageListRef.current?.updateMessage(streamingMsgIdRef.current, { - activeToolCall: null, - }); - if (event.data?.output) { + // Handle on_chat_model_end — finalize tool call display + if (event.type === "on_chat_model_end") { + const output = event.data?.output; + if (output?.kwargs?.tool_calls?.length) { lastToolCallDisplayRef.current = - (lastToolCallDisplayRef.current || "") + event.data.output; + (lastToolCallDisplayRef.current || "") + + output.kwargs.tool_calls.map((tc) => `[${tc.name}]`).join(" "); messageListRef.current?.updateMessage(streamingMsgIdRef.current, { toolCallDisplay: lastToolCallDisplayRef.current, + activeToolCall: null, }); } } - - // Handle on_tool_error — set activeToolCall with error - if (event.type === "on_tool_error") { - messageListRef.current?.updateMessage(streamingMsgIdRef.current, { - activeToolCall: { - name: event.name, - error: event.data?.error, - status: "error", - }, - }); - } } catch (cbErr) { logger.debug(`[streaming] callback error: ${cbErr.message}`); } diff --git a/src/tui/messageBubble.js b/src/tui/messageBubble.js index da6691a6..616868cf 100644 --- a/src/tui/messageBubble.js +++ b/src/tui/messageBubble.js @@ -113,6 +113,7 @@ export const PubSubContext = React.createContext({ subscribe: () => {}, unsubscr * @param {string} [props.reasoningContent] - Thinking/thought content * @param {Object} [props.activeToolCall] - {name: string} for running tool * @param {string} [props.toolCallDisplay] - Tool call result display text + * @param {Array} [props.events] - Raw stream events { type, name, data, tags, metadata } * @param {number} [props.toolCallCount] - Number of tools called during this turn * @param {number} [props.turnDurationMs] - Duration of the turn in milliseconds * @@ -127,6 +128,7 @@ export function MessageBubble({ reasoningContent, activeToolCall, toolCallDisplay, + events, toolCallCount, turnDurationMs, }) { @@ -200,6 +202,59 @@ export function MessageBubble({ ) : null; + /** + * Normalize LangChain stream event types to human-readable labels. + * @param {string} rawType - Raw event type (e.g., "on_tool_start") + * @returns {string} Human-readable label (e.g., "tool") + */ + function normalizeEventType(rawType) { + const mapping = { + on_chat_model_start: "model", + on_chat_model_end: "model", + on_chat_model_stream: "model", + on_tool_start: "tool", + on_tool_end: "tool", + on_agent_action: "agent", + on_chain_start: "chain", + on_chain_end: "chain", + on_retriever_start: "retriever", + on_retriever_end: "retriever", + on_custom_event: "custom", + }; + return mapping[rawType] || rawType.replace(/^on_/, "").replace(/_/g, " "); + } + + const hasEvents = events && events.length > 0; + + const eventsEl = hasEvents + ? React.createElement( + Box, + { flexDirection: "row", marginTop: 1, marginLeft: 2 }, + React.createElement( + Text, + { dimColor: true, color: "gray" }, + ` Events (${events.length}):`, + ), + React.createElement( + Box, + { flexDirection: "column" }, + ...Object.entries( + events.reduce((acc, evt) => { + const label = normalizeEventType(evt.type); + acc[label] = (acc[label] || 0) + 1; + return acc; + }, {}), + ).map(([type, count]) => + React.createElement( + Text, + { key: `evt-${type}`, color: "gray" }, + ` - ${type} [${count}]`, + ), + ), + ), + ) + : null; + const hasTurnStats = role === "assistant" && toolCallCount > 0 && turnDurationMs > 0; const turnStatsEl = hasTurnStats ? React.createElement( @@ -260,6 +315,7 @@ export function MessageBubble({ reasoningEl, toolCallEl, toolDisplayEl, + eventsEl, turnStatsEl, ), ); diff --git a/src/tui/messageList.js b/src/tui/messageList.js index 2b174e38..ae8d5cbf 100644 --- a/src/tui/messageList.js +++ b/src/tui/messageList.js @@ -161,7 +161,11 @@ export const MessageList = forwardRef(function MessageList( // Also trigger a parent re-render when content changed (streaming), so the // scroll effect can detect the change and scroll to bottom. publish(`msg-${id}`, dataRef.current.get(id)); - if (updates.content !== undefined) { + if ( + updates.content !== undefined || + updates.toolCallCount !== undefined || + updates.turnDurationMs !== undefined + ) { triggerRender(); } }, diff --git a/tests/unit/messageListToolStats.test.js b/tests/unit/messageListToolStats.test.js index 739f8825..a5e207e1 100644 --- a/tests/unit/messageListToolStats.test.js +++ b/tests/unit/messageListToolStats.test.js @@ -83,7 +83,13 @@ describe("messageList - tool call count and turn duration", () => { * Returns the formatted string if stats should be shown, null otherwise. */ function simulateTurnStats(role, toolCallCount, turnDurationMs) { - if (role !== "assistant" || !toolCallCount || toolCallCount <= 0 || !turnDurationMs || turnDurationMs <= 0) { + if ( + role !== "assistant" || + !toolCallCount || + toolCallCount <= 0 || + !turnDurationMs || + turnDurationMs <= 0 + ) { return null; } return `🔧 ${toolCallCount} tool${toolCallCount > 1 ? "s" : ""} · ⏱ ${Math.round(turnDurationMs / 100) / 10}s`;