From a321af8b04dde87cbddbbe1b2d46e18066a0eccb Mon Sep 17 00:00:00 2001 From: Luan Vu Date: Mon, 9 Mar 2026 12:57:55 +0700 Subject: [PATCH] feat(ui): add tool call details with arguments, results, and thinking display (#92) - Enhance tool call cards with expandable arguments/result sections and inline summary - Display thinking/reasoning blocks in chat messages (uses existing ThinkingBlock component) - Reconstruct tool details from session history for persistent display - Emit tool result content and truncated arguments in agent events - Fix sanitize to always return cleaned content instead of empty string Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com> --- internal/agent/loop.go | 26 +++++- internal/agent/sanitize.go | 14 ++- ui/web/src/components/chat/message-bubble.tsx | 92 ++++--------------- ui/web/src/components/chat/tool-call-card.tsx | 67 +++++++++----- ui/web/src/pages/chat/chat-thread.tsx | 16 +--- .../src/pages/chat/hooks/use-chat-messages.ts | 36 +++++++- .../pages/sessions/session-detail-page.tsx | 39 ++++++-- ui/web/src/types/chat.ts | 3 + ui/web/src/types/session.ts | 1 + 9 files changed, 162 insertions(+), 132 deletions(-) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 53cb238b..d989af5c 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -333,6 +333,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) iteration := 0 totalToolCalls := 0 var finalContent string + var finalThinking string var asyncToolCalls []string // track async spawn tool names for fallback var mediaResults []MediaResult // media files from tool MEDIA: results var deliverables []string // actual content from tool outputs (for team task results) @@ -532,6 +533,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } } finalContent = resp.Content + finalThinking = resp.Thinking break } @@ -594,7 +596,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) Type: protocol.AgentEventToolCall, AgentID: l.id, RunID: req.RunID, - Payload: map[string]interface{}{"name": tc.Name, "id": tc.ID, "arguments": tc.Arguments}, + Payload: map[string]interface{}{"name": tc.Name, "id": tc.ID, "arguments": truncateToolArgs(tc.Arguments, 500)}, }) argsJSON, _ := json.Marshal(tc.Arguments) @@ -640,6 +642,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) "id": tc.ID, "is_error": result.IsError, "arguments": tc.Arguments, + "result": truncateStr(result.ForLLM, 1000), } if result.IsError && result.ForLLM != "" { toolResultPayload["content"] = result.ForLLM @@ -707,7 +710,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) Type: protocol.AgentEventToolCall, AgentID: l.id, RunID: req.RunID, - Payload: map[string]interface{}{"name": tc.Name, "id": tc.ID, "arguments": tc.Arguments}, + Payload: map[string]interface{}{"name": tc.Name, "id": tc.ID, "arguments": truncateToolArgs(tc.Arguments, 500)}, }) } @@ -780,6 +783,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) "id": r.tc.ID, "is_error": r.result.IsError, "arguments": r.tc.Arguments, + "result": truncateStr(r.result.ForLLM, 1000), } if r.result.IsError && r.result.ForLLM != "" { parToolResultPayload["content"] = r.result.ForLLM @@ -862,8 +866,9 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } pendingMsgs = append(pendingMsgs, providers.Message{ - Role: "assistant", - Content: finalContent, + Role: "assistant", + Content: finalContent, + Thinking: finalThinking, }) // Flush all buffered messages to session atomically. @@ -937,4 +942,15 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) }, nil } - +// truncateToolArgs returns a copy of arguments with string values truncated to maxLen. +func truncateToolArgs(args map[string]interface{}, maxLen int) map[string]interface{} { + out := make(map[string]interface{}, len(args)) + for k, v := range args { + if s, ok := v.(string); ok && len(s) > maxLen { + out[k] = truncateStr(s, maxLen) + } else { + out[k] = v + } + } + return out +} diff --git a/internal/agent/sanitize.go b/internal/agent/sanitize.go index 25c86a7a..7a3ba59d 100644 --- a/internal/agent/sanitize.go +++ b/internal/agent/sanitize.go @@ -106,17 +106,15 @@ func stripGarbledToolXML(content string) string { cleaned := garbledToolXMLPattern.ReplaceAllString(content, "") cleaned = strings.TrimSpace(cleaned) - if cleaned != "" && hasIndicator { - slog.Warn("stripped garbled tool call XML, preserving remaining content", - "original_len", len(content), - "remaining_len", len(cleaned), - ) - return cleaned - } - if cleaned == "" { slog.Warn("stripped entire response as garbled tool XML", "original_len", len(content)) + return "" } + + slog.Warn("stripped garbled tool call XML from response", + "original_len", len(content), + "remaining_len", len(cleaned), + ) return cleaned } diff --git a/ui/web/src/components/chat/message-bubble.tsx b/ui/web/src/components/chat/message-bubble.tsx index a20c700c..c22a3d98 100644 --- a/ui/web/src/components/chat/message-bubble.tsx +++ b/ui/web/src/components/chat/message-bubble.tsx @@ -1,15 +1,14 @@ -import { useState } from "react"; -import { Bot, User, ChevronRight, Check, AlertTriangle, Zap } from "lucide-react"; +import { Bot, User } from "lucide-react"; import { MessageContent } from "./message-content"; +import { ThinkingBlock } from "./thinking-block"; +import { ToolCallCard } from "./tool-call-card"; import type { ChatMessage } from "@/types/chat"; -import type { ToolCall } from "@/types/session"; interface MessageBubbleProps { message: ChatMessage; - toolCallErrors?: Map; } -export function MessageBubble({ message, toolCallErrors }: MessageBubbleProps) { +export function MessageBubble({ message }: MessageBubbleProps) { const isUser = message.role === "user"; const isTool = message.role === "tool"; @@ -17,25 +16,17 @@ export function MessageBubble({ message, toolCallErrors }: MessageBubbleProps) { return null; // Tool messages are shown inline with assistant messages } + const isAssistant = message.role === "assistant"; + const hasThinking = isAssistant && !!message.thinking; + const hasToolDetails = isAssistant && message.toolDetails && message.toolDetails.length > 0; + const hasToolCalls = isAssistant && message.tool_calls && message.tool_calls.length > 0; const hasContent = !!message.content?.trim(); - const hasToolCalls = message.tool_calls && message.tool_calls.length > 0; - // Skip assistant messages with neither text nor tool calls - if (!isUser && !hasContent && !hasToolCalls) { + // Skip assistant messages with no content at all + if (isAssistant && !hasContent && !hasToolCalls && !hasToolDetails) { return null; } - // Tool-call-only assistant message: render compact tool call list - if (!isUser && !hasContent && hasToolCalls) { - return ( -
- {message.tool_calls!.map((tc) => ( - - ))} -
- ); - } - return (
@@ -53,10 +44,15 @@ export function MessageBubble({ message, toolCallErrors }: MessageBubbleProps) { : "bg-card text-card-foreground border border-border shadow-sm" }`} > - {hasToolCalls && ( -
- {message.tool_calls!.map((tc) => ( - + {hasThinking && ( +
+ +
+ )} + {hasToolDetails && ( +
+ {message.toolDetails!.map((entry) => ( + ))}
)} @@ -73,53 +69,3 @@ export function MessageBubble({ message, toolCallErrors }: MessageBubbleProps) {
); } - -function ToolCallItem({ toolCall, compact, isError, errorContent }: { toolCall: ToolCall; compact?: boolean; isError?: boolean; errorContent?: string }) { - const [expanded, setExpanded] = useState(false); - const hasArgs = toolCall.arguments && Object.keys(toolCall.arguments).length > 0; - const canExpand = hasArgs || (isError && !!errorContent); - const iconSize = compact ? "h-3 w-3" : "h-3.5 w-3.5"; - const isSkill = toolCall.name === "use_skill"; - - const StatusIcon = isError - ? - : isSkill - ? - : ; - - return ( -
- - {expanded && canExpand && ( -
- {isError && errorContent && ( -
{errorContent}
- )} - {hasArgs && ( -
{JSON.stringify(toolCall.arguments, null, 2)}
- )} -
- )} -
- ); -} diff --git a/ui/web/src/components/chat/tool-call-card.tsx b/ui/web/src/components/chat/tool-call-card.tsx index 17ddc9e8..d6b847db 100644 --- a/ui/web/src/components/chat/tool-call-card.tsx +++ b/ui/web/src/components/chat/tool-call-card.tsx @@ -1,50 +1,75 @@ import { useState } from "react"; -import { Wrench, Check, AlertTriangle, Loader2, ChevronRight, Zap } from "lucide-react"; +import { Wrench, Check, AlertTriangle, Loader2, ChevronDown, ChevronRight, Zap } from "lucide-react"; import type { ToolStreamEntry } from "@/types/chat"; const isSkillTool = (name: string) => name === "use_skill"; +/** Build a short summary string from tool arguments for inline display. */ +function buildToolSummary(entry: ToolStreamEntry): string | null { + if (!entry.arguments) return null; + const args = entry.arguments; + const key = args.path ?? args.command ?? args.query ?? args.url ?? args.name; + if (typeof key === "string") return key.length > 60 ? key.slice(0, 57) + "..." : key; + return null; +} + interface ToolCallCardProps { entry: ToolStreamEntry; } export function ToolCallCard({ entry }: ToolCallCardProps) { - const [expanded, setExpanded] = useState(false); - const hasArgs = entry.arguments && Object.keys(entry.arguments).length > 0; + const hasDetails = entry.arguments || entry.result; const hasError = entry.phase === "error" && !!entry.errorContent; - const canExpand = hasArgs || hasError; + const canExpand = hasDetails || hasError; + const [expanded, setExpanded] = useState(false); + const summary = buildToolSummary(entry); return ( -
+
{expanded && canExpand && ( -
+
{hasError && (
{entry.errorContent}
)} - {hasArgs && ( -
{JSON.stringify(entry.arguments, null, 2)}
+ {entry.arguments && Object.keys(entry.arguments).length > 0 && ( +
+
Arguments
+
+                {JSON.stringify(entry.arguments, null, 2)}
+              
+
+ )} + {entry.result && ( +
+
Result
+
+                {entry.result}
+              
+
)}
)} @@ -87,7 +112,7 @@ function PhaseLabel({ phase, isSkill }: { phase: ToolStreamEntry["phase"]; isSki error: "text-red-500", }; return ( - + {labels[phase] ?? phase} ); diff --git a/ui/web/src/pages/chat/chat-thread.tsx b/ui/web/src/pages/chat/chat-thread.tsx index 0f149e6b..74f01ac4 100644 --- a/ui/web/src/pages/chat/chat-thread.tsx +++ b/ui/web/src/pages/chat/chat-thread.tsx @@ -1,4 +1,3 @@ -import { useMemo } from "react"; import { Circle } from "lucide-react"; import { MessageBubble } from "@/components/chat/message-bubble"; import { StreamingText } from "@/components/chat/streaming-text"; @@ -33,19 +32,6 @@ export function ChatThread({ scrollTrigger, ); - // Build map of tool_call_id → error content for tool results that indicate errors - const toolCallErrors = useMemo(() => { - const errors = new Map(); - for (const msg of messages) { - if (msg.role !== "tool" || !msg.tool_call_id || !msg.content) continue; - const c = msg.content.trimStart(); - if (c.startsWith("Error") || c.startsWith("error:") || c.includes("failed:") || c.includes("Failed:")) { - errors.set(msg.tool_call_id, msg.content); - } - } - return errors; - }, [messages]); - // Show spinner while loading history for a different session if (loading) { return ( @@ -72,7 +58,7 @@ export function ChatThread({ >
{messages.map((msg, i) => ( - + ))} {/* Tool stream during active run */} diff --git a/ui/web/src/pages/chat/hooks/use-chat-messages.ts b/ui/web/src/pages/chat/hooks/use-chat-messages.ts index c3d76f67..0a114ce6 100644 --- a/ui/web/src/pages/chat/hooks/use-chat-messages.ts +++ b/ui/web/src/pages/chat/hooks/use-chat-messages.ts @@ -59,10 +59,37 @@ export function useChatMessages(sessionKey: string, agentId: string) { agentId, sessionKey, }); - const msgs: ChatMessage[] = (res.messages ?? []).map((m: Message, i: number) => ({ - ...m, - timestamp: Date.now() - (res.messages!.length - i) * 1000, - })); + const allMsgs = res.messages ?? []; + // Build a map of tool_call_id -> tool message for result lookup + const toolResultMap = new Map(); + for (const m of allMsgs) { + if (m.role === "tool" && m.tool_call_id) { + toolResultMap.set(m.tool_call_id, m); + } + } + const msgs: ChatMessage[] = allMsgs.map((m: Message, i: number) => { + const chatMsg: ChatMessage = { + ...m, + timestamp: Date.now() - (allMsgs.length - i) * 1000, + }; + // Reconstruct toolDetails for assistant messages with tool_calls + if (m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0) { + chatMsg.toolDetails = m.tool_calls.map((tc) => { + const toolMsg = toolResultMap.get(tc.id); + return { + toolCallId: tc.id, + runId: "", + name: tc.name, + phase: (toolMsg ? "completed" : "calling") as ToolStreamEntry["phase"], + startedAt: 0, + updatedAt: 0, + arguments: tc.arguments, + result: toolMsg?.content, + }; + }); + } + return chatMsg; + }); setMessages(msgs); } catch { // will retry @@ -153,6 +180,7 @@ export function useChatMessages(sessionKey: string, agentId: string) { ...t, phase: isError ? ("error" as const) : ("completed" as const), errorContent: isError ? event.payload?.content : undefined, + result: event.payload?.result, updatedAt: now, } : t, diff --git a/ui/web/src/pages/sessions/session-detail-page.tsx b/ui/web/src/pages/sessions/session-detail-page.tsx index 595eb2f4..a3768570 100644 --- a/ui/web/src/pages/sessions/session-detail-page.tsx +++ b/ui/web/src/pages/sessions/session-detail-page.tsx @@ -10,8 +10,8 @@ import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { Events } from "@/api/protocol"; import { parseSessionKey } from "@/lib/session-key"; import { formatDate, formatTokens } from "@/lib/format"; -import type { SessionInfo, SessionPreview } from "@/types/session"; -import type { ChatMessage, AgentEventPayload } from "@/types/chat"; +import type { SessionInfo, SessionPreview, Message } from "@/types/session"; +import type { ChatMessage, AgentEventPayload, ToolStreamEntry } from "@/types/chat"; /** Check if a message is an internal system message (subagent results, cron, etc.) */ function isSystemMessage(msg: ChatMessage): boolean { @@ -58,11 +58,38 @@ export function SessionDetailPage({ onPreview(session.key) .then((preview) => { if (preview) { + const allMsgs = preview.messages; + // Build a map of tool_call_id -> tool message for result lookup + const toolResultMap = new Map(); + for (const m of allMsgs) { + if (m.role === "tool" && m.tool_call_id) { + toolResultMap.set(m.tool_call_id, m); + } + } setMessages( - preview.messages.map((m, i) => ({ - ...m, - timestamp: Date.now() - (preview.messages.length - i) * 1000, - })), + allMsgs.map((m, i) => { + const chatMsg: ChatMessage = { + ...m, + timestamp: Date.now() - (allMsgs.length - i) * 1000, + }; + // Reconstruct toolDetails for assistant messages with tool_calls + if (m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0) { + chatMsg.toolDetails = m.tool_calls.map((tc) => { + const toolMsg = toolResultMap.get(tc.id); + return { + toolCallId: tc.id, + runId: "", + name: tc.name, + phase: (toolMsg ? "completed" : "calling") as ToolStreamEntry["phase"], + startedAt: 0, + updatedAt: 0, + arguments: tc.arguments, + result: toolMsg?.content, + }; + }); + } + return chatMsg; + }), ); setSummary(preview.summary ?? null); } diff --git a/ui/web/src/types/chat.ts b/ui/web/src/types/chat.ts index 28684fbb..5a2863fa 100644 --- a/ui/web/src/types/chat.ts +++ b/ui/web/src/types/chat.ts @@ -6,6 +6,7 @@ import type { Message } from "./session"; export interface ChatMessage extends Message { timestamp?: number; isStreaming?: boolean; + toolDetails?: ToolStreamEntry[]; } /** Agent event payload from WS event "agent" */ @@ -21,6 +22,7 @@ export interface AgentEventPayload { is_error?: boolean; error?: string; arguments?: Record; + result?: string; }; } @@ -31,6 +33,7 @@ export interface ToolStreamEntry { name: string; phase: "calling" | "completed" | "error"; arguments?: Record; + result?: string; errorContent?: string; startedAt: number; updatedAt: number; diff --git a/ui/web/src/types/session.ts b/ui/web/src/types/session.ts index f777df38..52554670 100644 --- a/ui/web/src/types/session.ts +++ b/ui/web/src/types/session.ts @@ -26,6 +26,7 @@ export interface SessionPreview { export interface Message { role: "user" | "assistant" | "tool"; content: string; + thinking?: string; tool_calls?: ToolCall[]; tool_call_id?: string; }