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>
This commit is contained in:
Luan VuandLuvu182 authored and GitHub committed 2026-03-09 12:57:55 +07:00
1 parent 4721cf26bd
commit a321af8b04
9 files changed
+162 -132

No files matched your search

+21 -5
View File
@@ -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
}
+6 -8
View File
@@ -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
}
+19 -73
View File
@@ -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<string, string>;
}
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 (
<div className="space-y-1">
{message.tool_calls!.map((tc) => (
<ToolCallItem key={tc.id} toolCall={tc} isError={toolCallErrors?.has(tc.id)} errorContent={toolCallErrors?.get(tc.id)} />
))}
</div>
);
}
return (
<div className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border bg-background">
@@ -53,10 +44,15 @@ export function MessageBubble({ message, toolCallErrors }: MessageBubbleProps) {
: "bg-card text-card-foreground border border-border shadow-sm"
}`}
>
{hasToolCalls && (
<div className="mb-2 space-y-1">
{message.tool_calls!.map((tc) => (
<ToolCallItem key={tc.id} toolCall={tc} compact isError={toolCallErrors?.has(tc.id)} errorContent={toolCallErrors?.get(tc.id)} />
{hasThinking && (
<div className="mb-2">
<ThinkingBlock text={message.thinking!} />
</div>
)}
{hasToolDetails && (
<div className="mb-2">
{message.toolDetails!.map((entry) => (
<ToolCallCard key={entry.toolCallId} entry={entry} />
))}
</div>
)}
@@ -73,53 +69,3 @@ export function MessageBubble({ message, toolCallErrors }: MessageBubbleProps) {
</div>
);
}
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
? <AlertTriangle className={`${iconSize} shrink-0 text-red-500`} />
: isSkill
? <Zap className={`${iconSize} shrink-0 text-amber-500`} />
: <Check className={`${iconSize} shrink-0 text-green-500`} />;
return (
<div className={compact ? "" : "rounded-md border bg-muted/50 overflow-hidden"}>
<button
type="button"
onClick={() => canExpand && setExpanded(!expanded)}
className={`flex items-center gap-1.5 w-full text-left ${
compact
? "text-xs text-muted-foreground py-0.5"
: "px-3 py-1.5 text-sm hover:bg-muted/80 transition-colors"
} ${canExpand ? "cursor-pointer" : "cursor-default"}`}
>
{StatusIcon}
<span className={`font-mono truncate ${compact ? "" : "text-xs"}`}>
{isSkill
? `skill: ${(toolCall.arguments?.name as string) || "unknown"}`
: toolCall.name}
</span>
{canExpand && (
<ChevronRight className={`h-3 w-3 shrink-0 ml-auto text-muted-foreground transition-transform ${expanded ? "rotate-90" : ""}`} />
)}
</button>
{expanded && canExpand && (
<div className={`text-[11px] overflow-auto max-h-48 ${
compact ? "pl-4.5 pb-1" : "px-3 pb-2 border-t bg-muted/30"
}`}>
{isError && errorContent && (
<pre className="text-red-500 whitespace-pre-wrap">{errorContent}</pre>
)}
{hasArgs && (
<pre className="text-muted-foreground">{JSON.stringify(toolCall.arguments, null, 2)}</pre>
)}
</div>
)}
</div>
);
}
+46 -21
View File
@@ -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 (
<div className="my-1 rounded-md border bg-muted/50 overflow-hidden">
<div className="my-1 rounded-md border bg-muted/50 text-sm">
<button
type="button"
onClick={() => canExpand && setExpanded(!expanded)}
className={`flex items-start gap-2 w-full text-left px-3 py-2 text-sm ${
canExpand ? "cursor-pointer hover:bg-muted/80 transition-colors" : "cursor-default"
}`}
className="flex w-full items-center gap-2 px-3 py-2 text-left"
onClick={() => canExpand && setExpanded((v) => !v)}
disabled={!canExpand}
>
<ToolIcon phase={entry.phase} isSkill={isSkillTool(entry.name)} />
<span className="font-mono text-xs break-all">
<span className="font-medium shrink-0">
{isSkillTool(entry.name)
? `skill: ${(entry.arguments?.name as string) || "unknown"}`
: entry.name}
</span>
<PhaseLabel phase={entry.phase} isSkill={isSkillTool(entry.name)} />
{canExpand && (
<ChevronRight
className={`h-3 w-3 shrink-0 text-muted-foreground transition-transform ${
expanded ? "rotate-90" : ""
}`}
/>
{summary && (
<span className="truncate text-xs text-muted-foreground">{summary}</span>
)}
<span className="ml-auto flex items-center gap-2 shrink-0">
<PhaseLabel phase={entry.phase} isSkill={isSkillTool(entry.name)} />
{canExpand && (
expanded
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
</span>
</button>
{expanded && canExpand && (
<div className="px-3 pb-2 border-t bg-muted/30 text-[11px] overflow-auto max-h-48">
<div className="border-t border-muted px-3 py-2 space-y-2">
{hasError && (
<pre className="text-red-500 whitespace-pre-wrap">{entry.errorContent}</pre>
)}
{hasArgs && (
<pre className="text-muted-foreground whitespace-pre-wrap break-words">{JSON.stringify(entry.arguments, null, 2)}</pre>
{entry.arguments && Object.keys(entry.arguments).length > 0 && (
<div>
<div className="text-[10px] font-semibold uppercase text-muted-foreground mb-1">Arguments</div>
<pre className="whitespace-pre-wrap text-xs font-mono bg-background rounded p-2 max-h-48 overflow-y-auto">
{JSON.stringify(entry.arguments, null, 2)}
</pre>
</div>
)}
{entry.result && (
<div>
<div className="text-[10px] font-semibold uppercase text-muted-foreground mb-1">Result</div>
<pre className="whitespace-pre-wrap text-xs font-mono bg-background rounded p-2 max-h-48 overflow-y-auto">
{entry.result}
</pre>
</div>
)}
</div>
)}
@@ -87,7 +112,7 @@ function PhaseLabel({ phase, isSkill }: { phase: ToolStreamEntry["phase"]; isSki
error: "text-red-500",
};
return (
<span className={`ml-auto text-xs ${colors[phase] ?? "text-muted-foreground"}`}>
<span className={`text-xs ${colors[phase] ?? "text-muted-foreground"}`}>
{labels[phase] ?? phase}
</span>
);
+1 -15
View File
@@ -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<string, string>();
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({
>
<div className="mx-auto max-w-3xl space-y-4">
{messages.map((msg, i) => (
<MessageBubble key={`${msg.role}-${i}`} message={msg} toolCallErrors={toolCallErrors} />
<MessageBubble key={`${msg.role}-${i}`} message={msg} />
))}
{/* Tool stream during active run */}
@@ -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<string, Message>();
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,
@@ -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<string, Message>();
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);
}
+3
View File
@@ -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<string, unknown>;
result?: string;
};
}
@@ -31,6 +33,7 @@ export interface ToolStreamEntry {
name: string;
phase: "calling" | "completed" | "error";
arguments?: Record<string, unknown>;
result?: string;
errorContent?: string;
startedAt: number;
updatedAt: number;
+1
View File
@@ -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;
}