From 6f8617fead25eb594dafd0fbff43bfa2df8844b7 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Thu, 8 Jan 2026 04:32:25 +1000 Subject: [PATCH] fix: properly persist and render tool calls in agent chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Capture tool call inputs from onChunk and onStepFinish callbacks - Store toolCalls array in ASSISTANT messages with proper format - Update chat page to combine ASSISTANT toolCalls (input) with TOOL messages (output) - Show complete tool call cards with both input args and output results - Display token usage in assistant messages for debugging - Handle pending tool calls from ASSISTANT messages without results 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../app/agents/[id]/chat/[chatId]/page.tsx | 98 ++++++++++++++++--- .../conversation/[conversationId]/route.ts | 51 +++++++--- 2 files changed, 120 insertions(+), 29 deletions(-) diff --git a/apps/web/src/app/agents/[id]/chat/[chatId]/page.tsx b/apps/web/src/app/agents/[id]/chat/[chatId]/page.tsx index 33f1d4b..5c290bc 100644 --- a/apps/web/src/app/agents/[id]/chat/[chatId]/page.tsx +++ b/apps/web/src/app/agents/[id]/chat/[chatId]/page.tsx @@ -69,6 +69,13 @@ interface Message { toolName?: string; toolCallId?: string; toolResult?: unknown; + toolCalls?: Array<{ + toolCallId: string; + toolName: string; + args: unknown; + }>; + inputTokens?: number; + outputTokens?: number; createdAt: string; } @@ -754,17 +761,34 @@ export default function PublicAgentChatPage(): React.ReactElement { } }; + // Build a lookup map for tool call inputs from ASSISTANT messages + // This allows us to show the input args when rendering TOOL messages + const getToolCallInput = (toolCallId: string): unknown => { + for (const msg of messages) { + if (msg.role === 'ASSISTANT' && msg.toolCalls) { + const tc = msg.toolCalls.find((t) => t.toolCallId === toolCallId); + if (tc) return tc.args; + } + } + return undefined; + }; + + // Check if a tool call has a corresponding TOOL message (meaning it completed) + const hasToolResult = (toolCallId: string): boolean => { + return messages.some((m) => m.role === 'TOOL' && m.toolCallId === toolCallId); + }; + return (
-
- {message.role === 'TOOL' ? ( + {/* TOOL message - shows the result of a tool call */} + {message.role === 'TOOL' && ( +
toggleToolCall(message.toolCallId || message.id)} />
- ) : ( -
+
+ )} + + {/* USER message */} + {message.role === 'USER' && ( +
+

{message.content}

- )} -
+
+ )} + + {/* ASSISTANT message - may contain text and/or tool calls */} + {message.role === 'ASSISTANT' && ( +
+ {/* Assistant text content */} + {message.content && ( +
+
+

{message.content}

+ {/* Token usage for debugging */} + {(message.inputTokens || message.outputTokens) && ( +
+ {message.inputTokens && In: {message.inputTokens}} + {message.inputTokens && message.outputTokens && ( + • + )} + {message.outputTokens && ( + Out: {message.outputTokens} + )} +
+ )} +
+
+ )} + + {/* Show tool calls from this assistant message that don't have results yet */} + {message.toolCalls && + message.toolCalls + .filter((tc) => !hasToolResult(tc.toolCallId)) + .map((tc) => ( +
+
+ toggleToolCall(tc.toolCallId)} + /> +
+
+ ))} +
+ )}
); }} diff --git a/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts b/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts index 3529b13..b14b313 100644 --- a/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts +++ b/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts @@ -266,8 +266,9 @@ export async function POST(request: NextRequest, context: RouteContext): Promise try { const startTime = Date.now(); let fullContent = ''; - // biome-ignore lint/suspicious/noExplicitAny: Dynamic tool call structure - let allToolCalls: any[] = []; + // Accumulate tool calls with their input args + const toolCallsMap: Map = + new Map(); let inputTokens = 0; let outputTokens = 0; @@ -278,16 +279,39 @@ export async function POST(request: NextRequest, context: RouteContext): Promise tools, stopWhen: stepCountIs(agent.maxToolCallsPerTurn), onChunk: async ({ chunk }) => { - // Stream tool calls as they come in + // Stream tool calls as they come in and capture their inputs if (chunk.type === 'tool-call') { + const input = 'args' in chunk ? chunk.args : chunk.input; + // Store tool call with input for later persistence + toolCallsMap.set(chunk.toolCallId, { + toolCallId: chunk.toolCallId, + toolName: chunk.toolName, + args: input, + }); sendEvent('tool_call', { toolCallId: chunk.toolCallId, toolName: chunk.toolName, - input: 'args' in chunk ? chunk.args : chunk.input, + input, }); } }, - onStepFinish: async ({ toolResults, usage }) => { + onStepFinish: async ({ toolCalls, toolResults, usage }) => { + // Capture tool calls from step finish (backup in case onChunk missed any) + if (toolCalls && Array.isArray(toolCalls)) { + for (const tc of toolCalls) { + if (!toolCallsMap.has(tc.toolCallId)) { + // Use 'input' from DynamicToolCall or fall back to type assertion for typed calls + const args = + 'input' in tc ? tc.input : 'args' in tc ? (tc as { args: unknown }).args : {}; + toolCallsMap.set(tc.toolCallId, { + toolCallId: tc.toolCallId, + toolName: tc.toolName, + args, + }); + } + } + } + // Send tool results if (toolResults && toolResults.length > 0) { for (const tr of toolResults) { @@ -325,17 +349,10 @@ export async function POST(request: NextRequest, context: RouteContext): Promise } // Get final response data - const finalResponse = await result.response; const finalUsage = await result.usage; - // Extract tool calls from final response - if (finalResponse.messages) { - for (const msg of finalResponse.messages) { - if ('toolCalls' in msg && msg.toolCalls && Array.isArray(msg.toolCalls)) { - allToolCalls = [...allToolCalls, ...(msg.toolCalls as unknown[])]; - } - } - } + // Convert tool calls map to array for storage + const allToolCalls = Array.from(toolCallsMap.values()); // Update token counts from final usage if (finalUsage) { @@ -349,7 +366,11 @@ export async function POST(request: NextRequest, context: RouteContext): Promise conversationId: conversation.id, role: 'ASSISTANT', content: fullContent, - toolCalls: allToolCalls.length > 0 ? allToolCalls : Prisma.JsonNull, + // Cast to Prisma-compatible JSON type + toolCalls: + allToolCalls.length > 0 + ? (allToolCalls as unknown as Prisma.InputJsonValue) + : Prisma.JsonNull, inputTokens, outputTokens, },