feat: sync tool result contentful display

This commit is contained in:
Timothy
2026-05-01 17:44:19 -07:00
parent 522e0f511e
commit 3a94f52009
8 changed files with 2012 additions and 55 deletions
+1264 -2
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -11,7 +11,9 @@
},
"dependencies": {
"clsx": "^2.1.1",
"echarts": "^5.6.0",
"lucide-react": "^0.575.0",
"mermaid": "^11.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
+61 -45
View File
@@ -13,6 +13,9 @@ import {
} from "lucide-react";
import WorkerRunBubble from "@/components/WorkerRunBubble";
import type { WorkerRunGroup } from "@/components/WorkerRunBubble";
import ChartToolDetail, {
type ChartToolEntry,
} from "@/components/charts/ChartToolDetail";
export interface ImageContent {
type: "image_url";
@@ -205,7 +208,7 @@ export function toolHex(name: string): string {
}
export function ToolActivityRow({ content }: { content: string }) {
let tools: { name: string; done: boolean }[] = [];
let tools: ChartToolEntry[] = [];
try {
const parsed = JSON.parse(content);
tools = parsed.tools || [];
@@ -239,52 +242,65 @@ export function ToolActivityRow({ content }: { content: string }) {
if (counts.done > 0) donePills.push({ name, count: counts.done });
}
// Per-call chart embeds: chart_render's result envelope carries the
// spec back, so the chat renders the same chart the server
// rasterized to PNG. Other tools stay pill-only by design.
const chartDetails = tools.filter((t) => t.name.startsWith("chart_"));
return (
<div className="flex gap-3 pl-10">
<div className="flex flex-wrap items-center gap-1.5">
{runningPills.map((p) => {
const hex = toolHex(p.name);
return (
<span
key={`run-${p.name}`}
className="inline-flex items-center gap-1 text-[11px] px-2.5 py-0.5 rounded-full"
style={{
color: hex,
backgroundColor: `${hex}18`,
border: `1px solid ${hex}35`,
}}
>
<Loader2 className="w-2.5 h-2.5 animate-spin" />
{p.name}
{p.count > 1 && (
<span className="text-[10px] font-medium opacity-70">
×{p.count}
</span>
)}
</span>
);
})}
{donePills.map((p) => {
const hex = toolHex(p.name);
return (
<span
key={`done-${p.name}`}
className="inline-flex items-center gap-1 text-[11px] px-2.5 py-0.5 rounded-full"
style={{
color: hex,
backgroundColor: `${hex}18`,
border: `1px solid ${hex}35`,
}}
>
<Check className="w-2.5 h-2.5" />
{p.name}
{p.count > 1 && (
<span className="text-[10px] opacity-80">×{p.count}</span>
)}
</span>
);
})}
<div className="flex flex-col gap-0.5">
<div className="flex gap-3 pl-10">
<div className="flex flex-wrap items-center gap-1.5">
{runningPills.map((p) => {
const hex = toolHex(p.name);
return (
<span
key={`run-${p.name}`}
className="inline-flex items-center gap-1 text-[11px] px-2.5 py-0.5 rounded-full"
style={{
color: hex,
backgroundColor: `${hex}18`,
border: `1px solid ${hex}35`,
}}
>
<Loader2 className="w-2.5 h-2.5 animate-spin" />
{p.name}
{p.count > 1 && (
<span className="text-[10px] font-medium opacity-70">
×{p.count}
</span>
)}
</span>
);
})}
{donePills.map((p) => {
const hex = toolHex(p.name);
return (
<span
key={`done-${p.name}`}
className="inline-flex items-center gap-1 text-[11px] px-2.5 py-0.5 rounded-full"
style={{
color: hex,
backgroundColor: `${hex}18`,
border: `1px solid ${hex}35`,
}}
>
<Check className="w-2.5 h-2.5" />
{p.name}
{p.count > 1 && (
<span className="text-[10px] opacity-80">×{p.count}</span>
)}
</span>
);
})}
</div>
</div>
{chartDetails.map((t, idx) => (
<ChartToolDetail
key={t.callKey ?? `${t.name}-${idx}`}
entry={t}
/>
))}
</div>
);
}
@@ -0,0 +1,158 @@
/**
* Per-call detail row for ``chart_*`` tool calls.
*
* The canonical embedding mechanism: when the agent invokes
* ``chart_render``, the runtime stores the result envelope in
* ``events.jsonl``; ``chat-helpers.replayEvent`` retains it and the
* chat panel dispatches it here. We read ``result.spec`` and mount
* the live renderer; ``result.file_url`` becomes the download link.
*
* Rules baked in:
* - The chart is reconstructed FROM THE TOOL RESULT, not from any
* markdown fence the agent might have written. Calling the tool
* IS the embedding — there's nothing else to remember.
* - The chart survives session reload because the spec lives in
* events.jsonl alongside the tool_call_completed event.
* - The downloadable PNG lives at ``result.file_url`` (a ``file://``
* URI on the runtime host). The web frontend can't open file://
* directly; we surface ``file_path`` as text and give a Copy
* button so the user can paste it into a file manager. (The
* desktop renderer has an Electron IPC bridge — not available
* in OSS.)
*/
import { lazy, Suspense, useState } from "react";
import { Copy, Loader2, Check } from "lucide-react";
// Lazy chunks so non-chart messages don't drag in echarts/mermaid.
const EChartsBlock = lazy(() => import("./EChartsBlock"));
const MermaidBlock = lazy(() => import("./MermaidBlock"));
export interface ChartToolEntry {
name: string;
done: boolean;
args?: unknown;
result?: unknown;
isError?: boolean;
callKey?: string;
}
interface ChartResult {
kind?: "echarts" | "mermaid";
spec?: unknown;
file_path?: string;
file_url?: string;
title?: string;
error?: string;
// Width/height come back from the server tool but are NOT displayed
// in the footer. Kept here so the live in-chat render can match the
// spec's native aspect ratio instead of forcing a 16:9 box that
// clips wide dashboards.
width?: number;
height?: number;
}
function asResult(v: unknown): ChartResult {
if (v && typeof v === "object") return v as ChartResult;
return {};
}
export default function ChartToolDetail({ entry }: { entry: ChartToolEntry }) {
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
// Still running: show a tiny inline spinner. Charts render fast (a
// few hundred ms), so a full skeleton would flash and feel janky.
if (!entry.done) {
return (
<div className="pl-10 mt-1.5">
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin shrink-0" />
<span>rendering chart</span>
</div>
</div>
);
}
const result = asResult(entry.result);
if (result.error) {
// Errors are intentionally NOT shown to the user — the agent sees
// them in the tool result envelope and is expected to retry with a
// fixed spec.
return null;
}
const kind = result.kind;
const spec = result.spec;
if (!kind || spec === undefined) {
return null;
}
const handleCopyPath = async () => {
if (!result.file_path) return;
try {
await navigator.clipboard.writeText(result.file_path);
setCopyState("copied");
window.setTimeout(() => setCopyState("idle"), 2000);
} catch {
// Clipboard API unavailable (insecure context); silently no-op.
}
};
// Honor the spec's native aspect ratio when both dimensions are
// known (the server tool always returns them).
const aspectRatio =
result.width && result.height ? result.width / result.height : undefined;
return (
<div className="pl-10 mt-1.5 max-w-5xl">
<Suspense
fallback={
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
<span>loading chart engine</span>
</div>
}
>
{kind === "echarts" ? (
<EChartsBlock spec={spec} aspectRatio={aspectRatio} />
) : kind === "mermaid" ? (
<MermaidBlock source={typeof spec === "string" ? spec : ""} />
) : (
<div className="text-[11px] text-muted-foreground">
unknown chart kind: {String(kind)}
</div>
)}
</Suspense>
{/* Footer: title + path-copy. The PNG lives on the runtime host;
web browsers can't open file:// URIs from a hosted page, so
we surface the path as a copyable string instead of a fake
Download button. */}
<div className="flex items-center justify-between mt-2 px-1 text-[10.5px] text-muted-foreground/80">
<span className="truncate min-w-0 flex-1">
{result.title || kind}
</span>
{result.file_path && (
<button
type="button"
onClick={handleCopyPath}
className="inline-flex items-center gap-1 hover:text-foreground transition shrink-0 cursor-pointer"
title={
copyState === "copied"
? "Copied to clipboard"
: `Copy path: ${result.file_path}`
}
>
{copyState === "copied" ? (
<Check className="w-3 h-3 text-primary" />
) : (
<Copy className="w-3 h-3" />
)}
{copyState === "copied" ? "Copied" : "Copy path"}
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,232 @@
/**
* Live ECharts renderer for the chat bubble.
*
* Mounts an ECharts instance into a sized div and feeds it the spec
* the agent passed to `chart_render`. The same spec is rendered
* server-side to a PNG; the live render is the in-chat experience,
* the PNG is the downloadable.
*
* - Lazy-loaded via dynamic import so non-chart messages don't pay
* the ~1 MB bundle cost.
* - SVG renderer (`{ renderer: 'svg' }`) for crisp scaling and lower
* memory than canvas. Looks identical at chat-bubble sizes.
* - Resize handled via ResizeObserver; charts adapt to the bubble's
* width while keeping a fixed aspect ratio.
* - Error boundary inside the component itself: invalid specs render
* a tiny "spec invalid" pill with a copy-spec button so the agent
* can self-correct on the next turn.
*/
import { useEffect, useRef, useState } from "react";
import { AlertCircle } from "lucide-react";
import { buildOpenHiveTheme } from "./openhiveTheme";
interface Props {
spec: unknown;
/** Aspect ratio kept while the width adapts to the bubble. Defaults
* to 16:9 — the standard chart shape that fits in slide decks. */
aspectRatio?: number;
/** Hard cap on rendered height (px). Prevents very-tall charts from
* dominating the chat scroll. */
maxHeight?: number;
}
const _themeRegistered: Record<"light" | "dark", boolean> = {
light: false,
dark: false,
};
/**
* Detect the user's current UI theme from the DOM. The OpenHive
* desktop app applies a `dark` class to <html> in dark mode (see
* index.css). We use the same signal here so the live chart matches
* the surrounding chat — neither the agent nor the caller picks the
* theme, and the PNG download is rendered server-side from the same
* source of truth (HIVE_DESKTOP_THEME env, set by Electron from
* nativeTheme.shouldUseDarkColors).
*/
function useDocumentTheme(): "light" | "dark" {
const [theme, setTheme] = useState<"light" | "dark">(() =>
document.documentElement.classList.contains("dark") ? "dark" : "light",
);
useEffect(() => {
const obs = new MutationObserver(() => {
setTheme(
document.documentElement.classList.contains("dark") ? "dark" : "light",
);
});
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => obs.disconnect();
}, []);
return theme;
}
export default function EChartsBlock({
spec,
aspectRatio = 16 / 9,
maxHeight = 480,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<unknown>(null); // echarts.ECharts instance, kept untyped to avoid coupling the type import
const [error, setError] = useState<string | null>(null);
// Theme follows the user's OpenHive UI mode automatically. Same
// signal feeds the server-side PNG render via HIVE_DESKTOP_THEME, so
// live chart and downloaded file always match.
const theme = useDocumentTheme();
useEffect(() => {
if (!containerRef.current) return;
let disposed = false;
let resizeObserver: ResizeObserver | null = null;
(async () => {
try {
const echarts = await import("echarts");
if (disposed || !containerRef.current) return;
// Register the OpenHive brand theme once per (theme, mode) so
// bar/line/etc. inherit our palette + cozy spacing instead of
// ECharts' generic-web-2010 defaults. Theme matches the
// server-side render via tools/src/chart_tools/theme.py.
const themeName = theme === "dark" ? "openhive-dark" : "openhive-light";
if (!_themeRegistered[theme]) {
echarts.registerTheme(themeName, buildOpenHiveTheme(theme));
_themeRegistered[theme] = true;
}
// Coerce string specs to objects (defensive — the agent should
// pass dicts but LLMs sometimes serialize before sending).
let parsedSpec: Record<string, unknown>;
if (typeof spec === "string") {
try {
parsedSpec = JSON.parse(spec);
} catch {
throw new Error("spec is a string and not valid JSON");
}
} else {
parsedSpec = spec as Record<string, unknown>;
}
// Disjoint-region layout policy. ECharts has no auto-layout
// for component overlap (verified against the option ref):
// title/legend/grid are absolutely positioned and ignore each
// other. We enforce three non-overlapping regions:
// - Title: anchored to TOP (top:16, no bottom)
// - Legend: anchored to BOTTOM (bottom:16, no top) except
// when orient:'vertical' (side legend stays where placed)
// - Grid: middle, with containLabel for axis labels
// Strips user-supplied vertical positions so an agent spec
// like `legend.top:"8%"` (which lands inside the title at
// chat-bubble dimensions — the 2026-05-01 bug) can't collide.
// Horizontal anchoring is preserved so left-aligned legends
// still work. Must mirror chart_tools/renderer.py exactly so
// the live chart and downloaded PNG look the same.
const userTitle = (parsedSpec.title as Record<string, unknown> | undefined) ?? {};
const userLegend = parsedSpec.legend as Record<string, unknown> | undefined;
const userGrid = (parsedSpec.grid as Record<string, unknown> | undefined) ?? {};
const legendVertical = userLegend?.orient === "vertical";
const stripV = (o: Record<string, unknown>) => {
const c = { ...o };
delete c.top;
delete c.bottom;
return c;
};
const normalizedSpec: Record<string, unknown> = {
...parsedSpec,
title: { left: "center", ...stripV(userTitle), top: 16 },
grid: {
left: 56,
right: 56,
...stripV(userGrid),
// Force vertical bounds — user-supplied grid.top/bottom
// (often percentage strings like "8%" the agent picks at
// default dims) don't generalize across chat-bubble sizes.
// 96 covers: bottom legend (~36) + xAxis name (containLabel
// handles tick labels but NOT axis name; outerBoundsMode is
// v6+ and we're on v5). 40 when no legend.
top: 64,
bottom: userLegend && !legendVertical ? 96 : 40,
containLabel: true,
},
};
if (userLegend) {
const legendDefaults = {
icon: "roundRect",
itemWidth: 12,
itemHeight: 12,
itemGap: 16,
};
normalizedSpec.legend = legendVertical
? { ...legendDefaults, ...userLegend }
: { ...legendDefaults, ...stripV(userLegend), bottom: 16 };
}
// Fresh chart instance per spec; cheaper than reuse + setOption
// for our sizes and avoids stale state between specs.
const chart = echarts.init(containerRef.current, themeName, {
renderer: "svg",
});
chartRef.current = chart;
chart.setOption(normalizedSpec, {
notMerge: true,
lazyUpdate: false,
});
// Resize on container size change.
resizeObserver = new ResizeObserver(() => {
if (chartRef.current && containerRef.current) {
(chartRef.current as { resize: () => void }).resize();
}
});
resizeObserver.observe(containerRef.current);
} catch (e) {
if (!disposed) {
setError(e instanceof Error ? e.message : String(e));
}
}
})();
return () => {
disposed = true;
if (resizeObserver) resizeObserver.disconnect();
if (chartRef.current) {
try {
(chartRef.current as { dispose: () => void }).dispose();
} catch {
// best-effort cleanup
}
chartRef.current = null;
}
};
}, [spec, theme]);
if (error) {
return (
<div
className="flex items-center gap-2 text-[11px] text-muted-foreground px-2.5 py-1.5 rounded-md border border-border/40 bg-muted/30"
role="alert"
>
<AlertCircle className="w-3 h-3 shrink-0" />
<span>chart spec invalid: {error}</span>
</div>
);
}
return (
<div
ref={containerRef}
// Transparent background so the chart blends with the chat bubble
// instead of sitting in an obtrusive white card. The OpenHive
// ECharts theme also sets backgroundColor: 'transparent' so the
// chart itself is see-through. Subtle rounded corners only.
className="w-full rounded-lg bg-transparent"
style={{
// Reserve aspect-ratio space so the chart doesn't pop in.
// ECharts will overwrite the inline style as it lays out.
aspectRatio,
maxHeight,
}}
/>
);
}
@@ -0,0 +1,81 @@
/**
* Live Mermaid renderer for the chat bubble.
*
* Renders a mermaid source string to an inline SVG. Mermaid is
* lazy-loaded so non-diagram messages don't pay the ~600 KB cost.
*
* Theme follows the OpenHive light/dark setting. Errors render a
* tiny pill so the agent gets feedback for the next turn.
*/
import { useEffect, useRef, useState } from "react";
import { AlertCircle } from "lucide-react";
interface Props {
source: string;
theme?: "light" | "dark";
}
let _mermaidInitialized = false;
export default function MermaidBlock({ source, theme = "light" }: Props) {
const ref = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let disposed = false;
(async () => {
try {
const mermaid = (await import("mermaid")).default;
if (!_mermaidInitialized) {
mermaid.initialize({
startOnLoad: false,
theme: theme === "dark" ? "dark" : "default",
securityLevel: "loose",
fontFamily:
"'Inter Tight', -apple-system, BlinkMacSystemFont, system-ui, sans-serif",
});
_mermaidInitialized = true;
}
if (disposed || !ref.current) return;
// Unique id per render to avoid conflicting injected styles.
const id = `mmd-${Math.random().toString(36).slice(2, 10)}`;
const { svg } = await mermaid.render(id, source);
if (disposed || !ref.current) return;
ref.current.innerHTML = svg;
} catch (e) {
if (!disposed) {
setError(e instanceof Error ? e.message : String(e));
}
}
})();
return () => {
disposed = true;
};
}, [source, theme]);
if (error) {
return (
<div
className="flex items-center gap-2 text-[11px] text-muted-foreground px-2.5 py-1.5 rounded-md border border-border/40 bg-muted/30"
role="alert"
>
<AlertCircle className="w-3 h-3 shrink-0" />
<span>diagram syntax invalid: {error}</span>
</div>
);
}
return (
<div
ref={ref}
// Match EChartsBlock: transparent so the diagram blends with the
// chat bubble; rounded corners and inner padding give breathing
// room without adding a visible card.
className="w-full overflow-x-auto rounded-lg bg-transparent p-4 [&_svg]:max-w-full [&_svg]:h-auto [&_svg]:mx-auto"
/>
);
}
@@ -0,0 +1,131 @@
/**
* OpenHive ECharts theme — must stay in sync with
* tools/src/chart_tools/theme.py on the runtime side.
*
* Same palette + spacing both for the live in-chat ECharts mount
* (see EChartsBlock.tsx) and the headless server-side render that
* produces the downloadable PNG. Without this both diverge: the chat
* shows ECharts default colors and the PNG shows OpenHive colors,
* confusing the user.
*/
const PALETTE_LIGHT = [
"#db6f02", // honey orange (primary)
"#456a8d", // slate blue
"#3d7a4a", // sage green
"#a8453d", // terracotta brick
"#c48820", // warm bronze
"#5d5b88", // indigo
"#7d6b51", // olive
"#8e4200", // rust
];
const PALETTE_DARK = [
"#ffb825",
"#7ba2c4",
"#7bb285",
"#d97470",
"#e0a83a",
"#9892c4",
"#b8a685",
"#d97e3a",
];
export function buildOpenHiveTheme(theme: "light" | "dark" = "light") {
const isDark = theme === "dark";
const fg = isDark ? "#e8e6e0" : "#1a1a1a";
const fgMuted = isDark ? "#8a8a8a" : "#6b6b6b";
const gridLine = isDark ? "#2a2724" : "#ebe9e2";
const axisLine = isDark ? "#3a3733" : "#d0cfca";
const tooltipBg = isDark ? "#181715" : "#ffffff";
const tooltipBorder = isDark ? "#2a2724" : "#d0cfca";
const palette = isDark ? PALETTE_DARK : PALETTE_LIGHT;
return {
color: palette,
backgroundColor: "transparent",
textStyle: {
fontFamily:
'"Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
color: fg,
fontSize: 12,
},
title: {
left: "center",
top: 28,
textStyle: { color: fg, fontSize: 16, fontWeight: 600 },
subtextStyle: { color: fgMuted, fontSize: 12 },
},
legend: {
top: 64,
icon: "roundRect",
itemWidth: 12,
itemHeight: 12,
itemGap: 20,
textStyle: { color: fgMuted, fontSize: 12 },
},
grid: {
top: 116,
left: 48,
right: 48,
bottom: 72,
containLabel: true,
},
categoryAxis: {
axisLine: { show: true, lineStyle: { color: axisLine } },
axisTick: { show: false },
axisLabel: { color: fgMuted, fontSize: 11, margin: 14 },
splitLine: { show: false },
nameLocation: "middle",
nameGap: 36,
nameTextStyle: { color: fgMuted, fontSize: 12 },
},
valueAxis: {
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: fgMuted, fontSize: 11, margin: 14 },
splitLine: { lineStyle: { color: gridLine, type: "dashed" } },
nameLocation: "middle",
nameGap: 42,
nameTextStyle: { color: fgMuted, fontSize: 12, fontWeight: 500 },
// Don't auto-rotate value-axis names — the theme can't tell xAxis
// (horizontal bar) from yAxis (vertical bar), so rotating both at
// 90° vertical-mounts the xAxis name on horizontal-bar charts and
// it collides with the legend (peer_val regression). Let specs
// set nameRotate explicitly when they want a vertical y-name.
},
timeAxis: {
axisLine: { show: true, lineStyle: { color: axisLine } },
axisLabel: { color: fgMuted, fontSize: 11, margin: 14 },
splitLine: { show: false },
nameLocation: "middle",
nameGap: 36,
nameTextStyle: { color: fgMuted, fontSize: 12 },
},
tooltip: {
backgroundColor: tooltipBg,
borderColor: tooltipBorder,
borderWidth: 1,
padding: [8, 12],
textStyle: { color: fg, fontSize: 12 },
axisPointer: {
lineStyle: { color: axisLine, type: "dashed" },
crossStyle: { color: axisLine },
},
},
bar: { itemStyle: { borderRadius: [3, 3, 0, 0] } },
line: {
lineStyle: { width: 2.5 },
symbol: "circle",
symbolSize: 6,
},
candlestick: {
itemStyle: {
color: "#3d7a4a",
color0: "#a8453d",
borderColor: "#3d7a4a",
borderColor0: "#a8453d",
},
},
};
}
+83 -8
View File
@@ -295,12 +295,40 @@ export function sseEventToChatMessage(
* deferred `tool_call_completed` events can find the exact pill they belong
* to after the turn counter moves on.
*/
/**
* For chart_* tools we retain the args (from tool_call_started) and
* result envelope (from tool_call_completed) so the chat panel can
* render the live chart inline from the same spec the runtime
* rasterized to PNG. Other tools omit these fields to keep the
* tool_status content payload small (catalogs are pill-only).
*/
type ToolEntry = {
name: string;
done: boolean;
/** opaque per-call id surfaced to the UI; used to key React rows */
callKey?: string;
/** present only for tools whose name matches shouldRetainDetail */
args?: unknown;
result?: unknown;
isError?: boolean;
};
type ToolRowState = {
streamId: string;
executionId: string;
tools: Record<string, { name: string; done: boolean }>;
tools: Record<string, ToolEntry>;
};
/**
* Names whose detail (args + result envelope) we surface in the chat.
* Other tools stay pill-only — keeping their args/results out of the
* message content avoids ballooning the chat history with tool
* catalogs, file blobs, etc.
*/
function shouldRetainDetail(toolName: string): boolean {
return toolName.startsWith("chart_");
}
export interface ReplayState {
turnCounters: Record<string, number>;
toolRows: Record<string, ToolRowState>;
@@ -349,10 +377,20 @@ function toolLookupKey(
}
function toolRowContent(row: ToolRowState): string {
const tools = Object.values(row.tools).map((t) => ({
name: t.name,
done: t.done,
}));
const tools = Object.values(row.tools).map((t) => {
const out: ToolEntry = { name: t.name, done: t.done };
// Carry callKey + retained fields only for tools whose detail the
// UI mounts (chart_*). Pill-only tools stay terse so the
// tool_status payload doesn't grow with every catalog/file_ops
// call and existing snapshot tests stay valid.
if (shouldRetainDetail(t.name)) {
if (t.callKey !== undefined) out.callKey = t.callKey;
if (t.args !== undefined) out.args = t.args;
if (t.result !== undefined) out.result = t.result;
if (t.isError !== undefined) out.isError = t.isError;
}
return out;
});
const allDone = tools.length > 0 && tools.every((t) => t.done);
return JSON.stringify({ tools, allDone });
}
@@ -417,10 +455,19 @@ export function replayEvent(
tools: {},
});
const toolKey = toolUseId || `anonymous-${Object.keys(row.tools).length}`;
row.tools[toolKey] = {
const entry: ToolEntry = {
name: toolName,
done: false,
callKey: toolKey,
};
// Capture args at start for retained-detail tools so the chat
// can show what the agent rendered. Other tools' arguments are
// intentionally dropped to keep the tool_status JSON small.
if (shouldRetainDetail(toolName)) {
const toolInput = event.data?.tool_input;
if (toolInput !== undefined) entry.args = toolInput;
}
row.tools[toolKey] = entry;
if (toolUseId) {
state.toolUseToPill[toolLookupKey(streamId, event.execution_id, toolUseId)] = {
msgId: pillId,
@@ -453,10 +500,38 @@ export function replayEvent(
if (!tracked) break;
const row = state.toolRows[tracked.msgId];
if (!row) break;
row.tools[tracked.toolKey] = {
name: row.tools[tracked.toolKey]?.name || tracked.name,
const prior = row.tools[tracked.toolKey];
const completedName = prior?.name || tracked.name;
const completed: ToolEntry = {
name: completedName,
done: true,
callKey: tracked.toolKey,
};
// Preserve any args captured at start; capture the result
// envelope for retained-detail tools (chart_* needs spec/file_url
// to mount the live chart).
if (shouldRetainDetail(completedName)) {
if (prior?.args !== undefined) completed.args = prior.args;
const rawResult = event.data?.result;
if (rawResult !== undefined) {
// The framework serializes envelopes as JSON strings. Try to
// parse so the renderer can pick fields cheaply; fall back to
// the raw value when parsing fails (already-an-object or
// non-JSON string).
if (typeof rawResult === "string") {
try {
completed.result = JSON.parse(rawResult);
} catch {
completed.result = rawResult;
}
} else {
completed.result = rawResult;
}
}
const isErr = event.data?.is_error;
if (typeof isErr === "boolean") completed.isError = isErr;
}
row.tools[tracked.toolKey] = completed;
out.push({
id: tracked.msgId,
agent: effectiveName || event.node_id || "Agent",