fix: properly persist and render tool calls in agent chat

- 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 <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2026-01-08 04:32:25 +10:00
parent d6de3e025a
commit 6f8617fead
2 changed files with 120 additions and 29 deletions

View file

@ -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 (
<div className="px-4 py-2">
<div
className={`flex ${message.role === 'USER' ? 'justify-end' : 'justify-start'}`}
>
{message.role === 'TOOL' ? (
{/* TOOL message - shows the result of a tool call */}
{message.role === 'TOOL' && (
<div className="flex justify-start">
<div className="max-w-[80%]">
<ToolCallCard
toolCall={{
toolCallId: message.toolCallId || message.id,
toolName: message.toolName || 'Unknown Tool',
input: getToolCallInput(message.toolCallId || ''),
output: getToolOutput(),
status: 'success',
}}
@ -772,18 +796,64 @@ export default function PublicAgentChatPage(): React.ReactElement {
onToggle={() => toggleToolCall(message.toolCallId || message.id)}
/>
</div>
) : (
<div
className={`max-w-[80%] rounded-lg p-4 ${
message.role === 'USER'
? 'bg-primary text-primary-foreground'
: 'bg-surface-secondary'
}`}
>
</div>
)}
{/* USER message */}
{message.role === 'USER' && (
<div className="flex justify-end">
<div className="max-w-[80%] rounded-lg p-4 bg-primary text-primary-foreground">
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
</div>
)}
</div>
</div>
)}
{/* ASSISTANT message - may contain text and/or tool calls */}
{message.role === 'ASSISTANT' && (
<div className="space-y-2">
{/* Assistant text content */}
{message.content && (
<div className="flex justify-start">
<div className="max-w-[80%] rounded-lg p-4 bg-surface-secondary">
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
{/* Token usage for debugging */}
{(message.inputTokens || message.outputTokens) && (
<div className="mt-2 pt-2 border-t border-border/50 text-[10px] text-foreground-tertiary font-mono">
{message.inputTokens && <span>In: {message.inputTokens}</span>}
{message.inputTokens && message.outputTokens && (
<span> </span>
)}
{message.outputTokens && (
<span>Out: {message.outputTokens}</span>
)}
</div>
)}
</div>
</div>
)}
{/* Show tool calls from this assistant message that don't have results yet */}
{message.toolCalls &&
message.toolCalls
.filter((tc) => !hasToolResult(tc.toolCallId))
.map((tc) => (
<div key={tc.toolCallId} className="flex justify-start">
<div className="max-w-[80%]">
<ToolCallCard
toolCall={{
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: tc.args,
status: 'pending',
}}
isExpanded={expandedToolCalls.has(tc.toolCallId)}
onToggle={() => toggleToolCall(tc.toolCallId)}
/>
</div>
</div>
))}
</div>
)}
</div>
);
}}

View file

@ -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<string, { toolCallId: string; toolName: string; args: unknown }> =
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,
},