diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index c891e0c13..db7f26562 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -15,8 +15,11 @@ import { formatToolTitle, getBuzzToolInfo, getToolStatusDisplay, - normalizeToolNameText, } from "./agentSessionToolCatalog"; +import { + buildCompactToolSummary, + isCompactDeveloperTool, +} from "./agentSessionToolSummary"; import { asRecord, formatCodeValue, @@ -46,7 +49,10 @@ export function ToolItem({ const ToolIcon = buzzTool?.icon ?? Wrench; const showStatus = status.state !== "output-available"; const toolTitle = formatToolTitle(canonicalToolName, item.title); - const isCommandTool = isShellCommandTool(item); + const useCompactSummary = isCompactDeveloperTool(item); + const compactSummary = useCompactSummary + ? buildCompactToolSummary(item) + : null; const duration = getToolDuration(item); const handleToggle = React.useCallback( (event: React.SyntheticEvent) => { @@ -73,11 +79,15 @@ export function ToolItem({ - {isCommandTool ? ( - + {compactSummary ? ( + ) : ( <> {ToolIcon ? ( @@ -135,30 +145,24 @@ export function ToolItem({ ); } -function CommandToolSummary({ +function CompactToolSummaryRow({ duration, - item, + label, + preview, }: { duration: string | null; - item: Extract; + label: string; + preview: string | null; }) { - const command = getToolString(item.args, ["command"]); - const label = - item.status === "failed" || item.isError - ? "Command failed" - : item.status === "executing" || item.status === "pending" - ? "Running command" - : "Ran command"; - return ( <> {label} - {command ? ( + {preview ? ( - {command} + {preview} ) : null} {duration ? ( @@ -171,14 +175,6 @@ function CommandToolSummary({ ); } -function isShellCommandTool(item: Extract) { - return [item.buzzToolName, item.toolName, item.title].some((value) => { - if (!value) return false; - const normalized = normalizeToolNameText(value); - return normalized === "shell" || normalized.endsWith("_shell"); - }); -} - function getToolDuration(item: Extract) { if (item.startedAt && item.completedAt) { return formatDuration(item.startedAt, item.completedAt); diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs new file mode 100644 index 000000000..4e6360ab3 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildCompactToolSummary, + isCompactDeveloperTool, +} from "./agentSessionToolSummary.ts"; + +const baseTimestamp = "2026-06-14T19:00:00.000Z"; + +function makeTool(overrides = {}) { + return { + id: "tool:1", + type: "tool", + title: "Tool call", + toolName: "shell", + buzzToolName: null, + status: "completed", + args: {}, + result: "", + isError: false, + timestamp: baseTimestamp, + startedAt: baseTimestamp, + completedAt: "2026-06-14T19:00:01.000Z", + ...overrides, + }; +} + +test("isCompactDeveloperTool returns false for Buzz relay tools", () => { + assert.equal( + isCompactDeveloperTool( + makeTool({ + toolName: "send_message", + buzzToolName: "send_message", + title: "Send Message", + }), + ), + false, + ); +}); + +test("isCompactDeveloperTool detects buzz-dev-mcp shell tools", () => { + assert.equal( + isCompactDeveloperTool( + makeTool({ + toolName: "buzz-dev-mcp__shell", + title: "buzz-dev-mcp__shell", + }), + ), + true, + ); +}); + +test("buildCompactToolSummary formats shell command preview", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "buzz-dev-mcp__shell", + args: { command: "git status" }, + }), + ); + + assert.equal(summary.label, "Ran command"); + assert.equal(summary.preview, "git status"); +}); + +test("buildCompactToolSummary formats view_image source preview", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "buzz-dev-mcp__view_image", + args: { + source: + "https://sprout-oss.stage.blox.sqprod.co/media/ffd1b2721f2d52e19f0ca2be9aa7842cdec5b4e0215aaab2a67c26a2a76a6a83.png", + }, + }), + ); + + assert.equal(summary.label, "Viewed image"); + assert.ok(summary.preview?.startsWith("https://sprout-oss")); +}); + +test("buildCompactToolSummary formats read_file path preview", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "read_file", + args: { path: "desktop/src/app/App.tsx" }, + }), + ); + + assert.equal(summary.label, "Read file"); + assert.equal(summary.preview, "desktop/src/app/App.tsx"); +}); + +test("buildCompactToolSummary formats todo list preview", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "todo", + args: { + todos: [ + { text: "Ship compact summaries", done: false }, + { text: "Verify UI", done: false }, + ], + }, + }), + ); + + assert.equal(summary.label, "Updated todos"); + assert.equal(summary.preview, "Ship compact summaries (+1)"); +}); + +test("buildCompactToolSummary uses running and failed labels", () => { + assert.equal( + buildCompactToolSummary( + makeTool({ toolName: "str_replace", status: "executing" }), + ).label, + "Editing file", + ); + assert.equal( + buildCompactToolSummary( + makeTool({ toolName: "str_replace", status: "failed", isError: true }), + ).label, + "Edit failed", + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts new file mode 100644 index 000000000..34ec5957c --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -0,0 +1,203 @@ +import type { ToolStatus, TranscriptItem } from "./agentSessionTypes"; +import { + getBuzzToolInfo, + normalizeToolNameText, +} from "./agentSessionToolCatalog"; +import { asRecord, getToolString } from "./agentSessionUtils"; + +export type CompactToolKind = + | "shell" + | "read_file" + | "view_image" + | "str_replace" + | "todo" + | "stop_hook" + | "post_compact_hook" + | "dev_mcp"; + +export type CompactToolSummary = { + kind: CompactToolKind; + label: string; + preview: string | null; +}; + +const DEVELOPER_TOOL_BASES = new Set([ + "shell", + "read_file", + "view_image", + "str_replace", + "todo", + "stop", + "postcompact", +]); + +type ToolItem = Extract; + +/** Whether this tool row should use the muted compact developer summary. */ +export function isCompactDeveloperTool(item: ToolItem): boolean { + if (item.buzzToolName && getBuzzToolInfo(item.buzzToolName)) { + return false; + } + return resolveDeveloperToolKind(item) !== null; +} + +/** Build the compact summary label and preview for developer MCP tool rows. */ +export function buildCompactToolSummary(item: ToolItem): CompactToolSummary { + const kind = resolveDeveloperToolKind(item) ?? "dev_mcp"; + const preview = extractCompactToolPreview(item, kind); + return { + kind, + label: compactToolLabel(kind, item.status, item.isError), + preview, + }; +} + +function resolveDeveloperToolKind(item: ToolItem): CompactToolKind | null { + for (const value of [item.toolName, item.title, item.buzzToolName]) { + const kind = classifyDeveloperToolName(value); + if (kind) return kind; + } + return null; +} + +function classifyDeveloperToolName( + value: string | null | undefined, +): CompactToolKind | null { + if (!value) return null; + + const normalized = normalizeToolNameText(value); + const base = stripMcpServerPrefix(normalized); + + if (base === "shell" || normalized.endsWith("_shell")) { + return "shell"; + } + if (base === "read_file") return "read_file"; + if (base === "view_image") return "view_image"; + if (base === "str_replace") return "str_replace"; + if (base === "todo") return "todo"; + if (base === "stop") return "stop_hook"; + if (base === "postcompact") return "post_compact_hook"; + + if (DEVELOPER_TOOL_BASES.has(base)) { + return base === "shell" ? "shell" : "dev_mcp"; + } + + if (normalized.includes("buzz_dev_mcp")) { + return "dev_mcp"; + } + + return null; +} + +function stripMcpServerPrefix(normalized: string): string { + return normalized.replace(/^buzz_dev_mcp_/, ""); +} + +function compactToolLabel( + kind: CompactToolKind, + status: ToolStatus, + isError: boolean, +): string { + const failed = isError || status === "failed"; + const running = status === "executing" || status === "pending"; + + const labels: Record< + CompactToolKind, + { completed: string; running: string; failed: string } + > = { + shell: { + completed: "Ran command", + running: "Running command", + failed: "Command failed", + }, + read_file: { + completed: "Read file", + running: "Reading file", + failed: "Read failed", + }, + view_image: { + completed: "Viewed image", + running: "Viewing image", + failed: "View failed", + }, + str_replace: { + completed: "Edited file", + running: "Editing file", + failed: "Edit failed", + }, + todo: { + completed: "Updated todos", + running: "Updating todos", + failed: "Todo update failed", + }, + stop_hook: { + completed: "Checked todos", + running: "Checking todos", + failed: "Todo check failed", + }, + post_compact_hook: { + completed: "Synced todos", + running: "Syncing todos", + failed: "Todo sync failed", + }, + dev_mcp: { + completed: "Ran tool", + running: "Running tool", + failed: "Tool failed", + }, + }; + + const set = labels[kind]; + if (failed) return set.failed; + if (running) return set.running; + return set.completed; +} + +function extractCompactToolPreview( + item: ToolItem, + kind: CompactToolKind, +): string | null { + const args = item.args; + + switch (kind) { + case "shell": + return getToolString(args, ["command"]); + case "read_file": + case "str_replace": + return getToolString(args, ["path"]); + case "view_image": + return getToolString(args, ["source"]); + case "todo": + return getTodoPreview(args); + case "stop_hook": + case "post_compact_hook": + return null; + case "dev_mcp": + return ( + getToolString(args, ["command", "path", "source", "query", "name"]) ?? + null + ); + } +} + +function getTodoPreview(args: Record): string | null { + const todos = args.todos; + if (!Array.isArray(todos)) { + return "todo list"; + } + if (todos.length === 0) { + return "empty list"; + } + + const first = todos[0]; + const firstText = + first && typeof first === "object" + ? getToolString(asRecord(first), ["text"]) + : null; + + if (firstText) { + return todos.length > 1 ? `${firstText} (+${todos.length - 1})` : firstText; + } + + return `${todos.length} item${todos.length === 1 ? "" : "s"}`; +}