tool call observability

This commit is contained in:
Rob Colbert 2026-05-15 18:24:00 -04:00
parent d1e77f52f1
commit 69f45867b2
4 changed files with 259 additions and 19 deletions

View File

@ -1,6 +1,7 @@
import { memo, useState, useCallback } from "react";
import { marked } from "marked";
import type { ChatTurn as ChatTurnType, ChatTurnBlockTool } from "../lib/api";
import ToolCallDisplay from "./ToolCallDisplay";
interface ChatTurnProps {
turn: ChatTurnType;
@ -151,16 +152,13 @@ const ChatTurn = memo(function ChatTurn({ turn }: ChatTurnProps) {
const subagent = toolCall.subagent;
return (
<div key={idx} className="tool-call">
<div className="flex items-center gap-2 text-xs font-mono text-text-secondary">
<span className="text-brand"></span>
<span>{toolCall.name}</span>
{toolCall.response && !subagent && (
<span className="text-green-500"></span>
)}
{subagent && subagent.stats && (
<span className="text-green-500"></span>
)}
</div>
<ToolCallDisplay
callId={toolCall.callId}
name={toolCall.name}
parameters={toolCall.parameters}
response={toolCall.response}
createdAt={block.createdAt}
/>
{subagent && <SubagentDisplay subagent={subagent} />}
</div>
);
@ -238,14 +236,14 @@ function SubagentDisplay({
</div>
<div className="space-y-0.5">
{subagent.toolCalls.map((tc, i) => (
<div
<ToolCallDisplay
key={tc.callId || i}
className="flex items-center gap-2 text-xs font-mono text-text-secondary"
>
<span className="text-yellow-500"></span>
<span>{tc.name}</span>
{tc.response && <span className="text-green-500"></span>}
</div>
callId={tc.callId}
name={tc.name}
parameters={tc.parameters}
response={tc.response}
defaultExpanded={false}
/>
))}
</div>
</div>

View File

@ -0,0 +1,130 @@
import { useState, useCallback } from "react";
import { marked } from "marked";
import { formatToolCallSignature } from "../lib/toolCallFormat";
// Configure marked with breaks enabled (consistent with ChatTurn)
marked.use({ breaks: true });
interface ToolCallDisplayProps {
callId: string;
name: string;
parameters?: string;
response?: string;
createdAt?: string;
defaultExpanded?: boolean;
}
/**
* Renders a single tool call with expand/collapse behavior.
*
* Collapsed: toolName(param: value, ...)
* Expanded: shows response (raw/markdown toggle) + metadata
*/
export default function ToolCallDisplay({
callId,
name,
parameters,
response,
createdAt,
defaultExpanded = false,
}: ToolCallDisplayProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const [renderMarkdown, setRenderMarkdown] = useState(false);
const toggle = useCallback(() => setExpanded((p) => !p), []);
const signature = formatToolCallSignature(name, parameters);
const hasResponse = !!response;
return (
<div className="tool-call">
{/* Header row — always visible, clickable to toggle */}
<button
onClick={toggle}
className="tool-call-header flex items-start gap-1.5 text-xs font-mono text-text-secondary hover:text-text-primary transition-colors w-full text-left"
>
<span className="text-brand shrink-0">{expanded ? "▾" : "▸"}</span>
<span className="break-all">{signature}</span>
{hasResponse && !expanded && (
<span className="text-green-500 shrink-0 ml-1"></span>
)}
{expanded && createdAt && (
<span className="text-text-muted shrink-0 ml-auto text-[10px]">
{new Date(createdAt).toLocaleTimeString()}
</span>
)}
</button>
{/* Expanded detail panel */}
{expanded && (
<div className="tool-call-detail ml-4 mt-1 space-y-1">
{/* Metadata row */}
<div className="flex items-center gap-3 text-[10px] font-mono text-text-muted">
<span>id: {callId}</span>
{createdAt && (
<span>{new Date(createdAt).toLocaleTimeString()}</span>
)}
</div>
{/* Response block */}
{hasResponse && (
<div className="tool-call-response-container">
{/* Response header with RAW/MD toggle */}
<div className="flex items-center justify-between text-[10px] font-mono text-text-muted mb-0.5">
<span>Response</span>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
setRenderMarkdown(false);
}}
className={`px-1.5 py-0.5 rounded text-[10px] transition-colors ${
!renderMarkdown
? "bg-bg-elevated text-text-primary border border-border-default"
: "text-text-muted hover:text-text-secondary"
}`}
>
RAW
</button>
<button
onClick={(e) => {
e.stopPropagation();
setRenderMarkdown(true);
}}
className={`px-1.5 py-0.5 rounded text-[10px] transition-colors ${
renderMarkdown
? "bg-bg-elevated text-text-primary border border-border-default"
: "text-text-muted hover:text-text-secondary"
}`}
>
MD
</button>
</div>
</div>
{/* Response content */}
{renderMarkdown ? (
<div
className="tool-call-response-md gadget-markdown text-xs"
dangerouslySetInnerHTML={{
__html: marked.parse(response!) as string,
}}
/>
) : (
<div className="tool-call-response-raw text-xs">
{response}
</div>
)}
</div>
)}
{/* No response indicator */}
{!hasResponse && (
<div className="text-[10px] font-mono text-text-muted italic">
No response
</div>
)}
</div>
)}
</div>
);
}

View File

@ -56,14 +56,44 @@ button {
}
.tool-call {
margin-top: theme("spacing.4");
margin-bottom: theme("spacing.4");
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.tool-call + .tool-call {
margin-top: 0;
}
/* Tool call detail panel (expanded) */
.tool-call-detail {
border-left: 2px solid var(--color-border-default);
padding-left: 0.5rem;
}
/* Raw response display — fixed-width, scrollable */
.tool-call-response-raw {
font-family: var(--font-mono);
font-size: 0.8em;
white-space: pre-wrap;
word-break: break-word;
max-height: 400px;
overflow-y: auto;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-default);
border-radius: 4px;
padding: 0.5rem 0.7rem;
}
/* Markdown-rendered response display — scrollable */
.tool-call-response-md {
max-height: 400px;
overflow-y: auto;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-default);
border-radius: 4px;
padding: 0.5rem 0.7rem;
}
input,
textarea {
font-family: inherit;

View File

@ -0,0 +1,82 @@
/**
* Tool call formatting utilities.
*
* Formats tool call parameters as function-call signature syntax:
* toolName(param1: "value", param2: 42, param3: {...})
*/
/**
* Truncate a string to maxLength, appending "..." if truncated.
*/
export function truncate(value: string, maxLength: number): string {
if (value.length <= maxLength) return value;
return value.slice(0, maxLength - 3) + "...";
}
/**
* Format a single parameter value for display in a function-call signature.
*
* - strings: quoted with truncation at 120 chars
* - numbers/booleans/null: bare
* - objects: "{...}" truncated
* - arrays: "[...]" truncated
*/
export function formatParamValue(value: unknown): string {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (typeof value === "boolean") return String(value);
if (typeof value === "number") return String(value);
if (typeof value === "string") {
const truncated = truncate(value, 120);
// Only quote if it contains spaces or special chars, or if truncated
// Always quote for consistency — function-call syntax expects it
return `"${truncated}"`;
}
if (Array.isArray(value)) {
return "[...]";
}
if (typeof value === "object") {
return "{...}";
}
return String(value);
}
/**
* Parse a JSON parameters string and format it as a function-call signature.
*
* @example
* formatToolCallSignature("file_read", '{"path": "src/foo.ts", "recursive": true}')
* // → 'file_read(path: "src/foo.ts", recursive: true)'
*
* @example
* formatToolCallSignature("list_files", undefined)
* // → 'list_files()'
*/
export function formatToolCallSignature(
name: string,
parameters?: string
): string {
if (!parameters) return `${name}()`;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(parameters);
} catch {
// If it's not valid JSON, show it as a raw string argument
return `${name}(${truncate(parameters, 200)})`;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
// Non-object JSON — show as single argument
return `${name}(${formatParamValue(parsed)})`;
}
const entries = Object.entries(parsed);
if (entries.length === 0) return `${name}()`;
const params = entries
.map(([key, val]) => `${key}: ${formatParamValue(val)}`)
.join(", ");
return `${name}(${params})`;
}