From e3fad77d5bcebf130b23f6c4379499c629cd3d8b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 7 Feb 2026 21:13:24 +0700 Subject: [PATCH] Unified tool call display schema, compaction timeline, and auth fixes - Replace extractPrincipalParam, parseToolCallDisplay, getToolKindFromName with a single unified parseToolCallDisplay on the server, consumed by both server (activity-curator) and app - Parse tool calls exactly once and pass ToolCallDisplayInfo down to all consumers (message, sheet, details, stream view) - Add compaction timeline item: detect SDKStatusMessage (compacting) and SDKCompactBoundaryMessage, show loading/completed marker in timeline - Suppress compact summary user message in live stream (flag) and JSONL resume (isCompactSummary check) - Add CompactionMarker component with scissors icon and token count - Hide sub-agent tool calls from timeline, collapse into Task metadata - Add Bot icon for Task tool calls - Fix stripShellWrapperPrefix to strip /bin/zsh -lc wrappers without cd - Replace CLAUDE_SESSION_TOKEN with CLAUDE_CODE_OAUTH_TOKEN --- .../app/src/components/agent-stream-view.tsx | 14 +- packages/app/src/components/message.tsx | 120 ++-- .../app/src/components/tool-call-details.tsx | 206 +----- .../app/src/components/tool-call-sheet.tsx | 50 +- packages/app/src/types/stream.ts | 52 +- .../app/src/utils/tool-call-parsers.test.ts | 248 ++++---- packages/app/src/utils/tool-call-parsers.ts | 359 +---------- .../src/server/agent/activity-curator.test.ts | 182 +++++- .../src/server/agent/activity-curator.ts | 26 +- .../agent/agent-response-loop.e2e.test.ts | 2 +- .../src/server/agent/agent-sdk-types.ts | 11 +- .../providers/claude-agent-commands.test.ts | 2 +- .../agent/providers/claude-agent.test.ts | 101 ++- .../server/agent/providers/claude-agent.ts | 102 +++ .../src/server/test-utils/claude-auth.ts | 14 +- packages/server/src/shared/messages.ts | 7 + .../src/utils/tool-call-parsers.test.ts | 243 +++++-- .../server/src/utils/tool-call-parsers.ts | 598 +++++++++++++++--- 18 files changed, 1470 insertions(+), 867 deletions(-) diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 458f48b22..f3a8da81f 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -35,6 +35,7 @@ import { ActivityLog, ToolCall, TodoListCard, + CompactionMarker, TurnCopyButton, MessageOuterSpacingProvider, type InlinePathTarget, @@ -366,6 +367,7 @@ export function AgentStreamView({ error={data.error} status={data.status as "executing" | "completed" | "failed"} cwd={agent.cwd} + metadata={data.metadata} isLastInSequence={isLastInSequence} onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange} /> @@ -402,6 +404,14 @@ export function AgentStreamView({ /> ); + case "compaction": + return ( + + ); + default: return null; } @@ -897,7 +907,7 @@ function PermissionRequestCard({ if (isPlanRequest) { return null; } - return parseToolCallDisplay(request.name ?? "unknown", request.input, null); + return parseToolCallDisplay({ name: request.name ?? "unknown", input: request.input }); }, [isPlanRequest, request.name, request.input]); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); @@ -1116,7 +1126,7 @@ function PermissionRequestCard({ ) : null} {!isPlanRequest && toolCallDisplay ? ( - + ) : null} ({ + container: { + flexDirection: "row", + alignItems: "center", + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + gap: theme.spacing[2], + }, + line: { + flex: 1, + height: 1, + backgroundColor: theme.colors.border, + }, + label: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + text: { + fontFamily: Fonts.mono, + fontSize: 11, + color: theme.colors.foregroundMuted, + }, +})); + +export const CompactionMarker = memo(function CompactionMarker({ + status, + preTokens, +}: CompactionMarkerProps) { + const label = + status === "loading" + ? "Compacting..." + : preTokens + ? `Context compacted (${Math.round(preTokens / 1000)}K tokens)` + : "Context compacted"; + + return ( + + + + {status === "loading" ? ( + + ) : ( + + )} + {label} + + + + ); +}); + interface TodoListCardProps { items: TodoEntry[]; disableOuterSpacing?: boolean; @@ -1038,6 +1095,7 @@ interface ToolCallProps { error?: any; status: "executing" | "completed" | "failed"; cwd?: string; + metadata?: Record; isLastInSequence?: boolean; disableOuterSpacing?: boolean; onInlineDetailsHoverChange?: (hovered: boolean) => void; @@ -1051,23 +1109,11 @@ const toolKindIcons: Record = { execute: SquareTerminal, search: Search, thinking: Brain, + agent: Bot, }; const TOOL_CALL_LOG_TAG = "[ToolCall]"; const TOOL_CALL_COMMIT_THRESHOLD_MS = 16; -// Derive tool kind from tool name for icon selection -function getToolKindFromName(toolName: string): string { - const lower = toolName.toLowerCase(); - if (lower === "thinking") return "thinking"; - if (lower === "read" || lower === "read_file" || lower.startsWith("read")) - return "read"; - if (lower === "edit" || lower === "write" || lower === "apply_patch") - return "edit"; - if (lower === "bash" || lower === "shell") return "execute"; - if (lower === "grep" || lower === "glob" || lower === "web_search") - return "search"; - return "tool"; -} export const ToolCall = memo(function ToolCall({ toolName, @@ -1076,6 +1122,7 @@ export const ToolCall = memo(function ToolCall({ error, status, cwd, + metadata, isLastInSequence = false, disableOuterSpacing, onInlineDetailsHoverChange, @@ -1090,42 +1137,27 @@ export const ToolCall = memo(function ToolCall({ UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const kind = getToolKindFromName(toolName); - const IconComponent = toolKindIcons[kind] || Wrench; - - // Extract principal param for secondary label (memoized) - const principalParam = useMemo( - () => extractPrincipalParam(args, cwd), - [args, cwd] + const displayInfo = useMemo( + () => parseToolCallDisplay({ name: toolName, input: args, output: result, error, metadata, cwd }), + [toolName, args, result, error, metadata, cwd] ); + const { kind, displayName, summary, detail, errorText } = displayInfo; + const IconComponent = toolKindIcons[kind] || Wrench; // Check if there's any content to display const hasDetails = args !== undefined || result !== undefined || error !== undefined; - // Parse tool call details for inline rendering - const { display, errorText } = useToolCallDetails({ toolName, args, result, error }); - const handleToggle = useCallback(() => { if (!isMobile && isPerfLoggingEnabled()) { toggleStartRef.current = getNowMs(); } if (isMobile) { - // Mobile: open bottom sheet - openToolCall({ - toolName, - kind, - status, - args, - result, - error, - cwd, - }); + openToolCall(displayInfo); } else { - // Desktop: toggle inline expansion setIsExpanded((prev) => !prev); } - }, [isMobile, openToolCall, toolName, kind, status, args, result, error, cwd]); + }, [isMobile, openToolCall, displayInfo]); useEffect(() => { if (isMobile || !isPerfLoggingEnabled()) { @@ -1184,14 +1216,14 @@ export const ToolCall = memo(function ToolCall({ // Render inline details for desktop const renderDetails = useCallback(() => { if (isMobile) return null; - return ; - }, [isMobile, display, errorText]); + return ; + }, [isMobile, detail, errorText]); return ( ; -}; - -function summarizeToolCallDisplay(display: ToolCallDisplay): ToolCallDisplaySummary { - switch (display.type) { - case "shell": { - const commandLength = display.command.length; - const outputLength = display.output.length; - return { - displayType: display.type, - totalChars: commandLength + outputLength, - detail: { - commandLength, - outputLength, - }, - }; - } - case "edit": { - const oldLength = display.oldString.length; - const newLength = display.newString.length; - return { - displayType: display.type, - totalChars: oldLength + newLength, - detail: { - filePath: display.filePath, - oldLength, - newLength, - }, - }; - } - case "read": { - const contentLength = display.content.length; - return { - displayType: display.type, - totalChars: contentLength, - detail: { - filePath: display.filePath, - contentLength, - offset: display.offset, - limit: display.limit, - }, - }; - } - case "generic": { - const inputPairs = display.input.length; - const outputPairs = display.output.length; - const inputChars = display.input.reduce((sum, pair) => sum + pair.value.length, 0); - const outputChars = display.output.reduce((sum, pair) => sum + pair.value.length, 0); - return { - displayType: display.type, - totalChars: inputChars + outputChars, - detail: { - inputPairs, - outputPairs, - inputChars, - outputChars, - }, - }; - } - case "thinking": { - const contentLength = display.content.length; - return { - displayType: display.type, - totalChars: contentLength, - detail: { contentLength }, - }; - } - default: - return assertNever(display); - } -} - -// ---- Helper ---- - -function assertNever(value: never): never { - throw new Error(`Unhandled tool call display: ${JSON.stringify(value)}`); -} - -function formatValue(value: unknown): string { - if (value === undefined) { - return ""; - } - if (typeof value === "string") { - return value; - } - // Extract content from tool_result objects - if ( - typeof value === "object" && - value !== null && - "type" in value && - (value as { type: string }).type === "tool_result" && - "content" in value - ) { - const content = (value as { content: unknown }).content; - if (typeof content === "string") { - return content; - } - } - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - // ---- Content Component ---- interface ToolCallDetailsContentProps { - display: ToolCallDisplay; + detail: ToolCallDetail; errorText?: string; maxHeight?: number; } export function ToolCallDetailsContent({ - display, + detail, errorText, maxHeight = 300, }: ToolCallDetailsContentProps) { // Compute diff lines for edit type const diffLines = useMemo(() => { - if (display.type !== "edit") return undefined; + if (detail.type !== "edit") return undefined; // Use pre-computed unified diff if available (e.g., from apply_patch) - if (display.unifiedDiff) { - return parseUnifiedDiff(display.unifiedDiff); + if (detail.unifiedDiff) { + return parseUnifiedDiff(detail.unifiedDiff); } - return buildLineDiff(display.oldString, display.newString); - }, [display]); + return buildLineDiff(detail.oldString, detail.newString); + }, [detail]); const sections: ReactNode[] = []; - const isFullBleed = display.type === "edit" || display.type === "shell"; + const isFullBleed = detail.type === "edit" || detail.type === "shell"; const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer; - if (display.type === "shell") { - const command = display.command.replace(/\n+$/, ""); - const output = display.output.replace(/^\n+/, ""); + if (detail.type === "shell") { + const command = detail.command.replace(/\n+$/, ""); + const output = detail.output.replace(/^\n+/, ""); const hasOutput = output.length > 0; sections.push( @@ -194,7 +71,7 @@ export function ToolCallDetailsContent({ ); - } else if (display.type === "edit") { + } else if (detail.type === "edit") { sections.push( {diffLines ? ( @@ -204,17 +81,17 @@ export function ToolCallDetailsContent({ ) : null} ); - } else if (display.type === "read") { + } else if (detail.type === "read") { sections.push( - {(display.offset !== undefined || display.limit !== undefined) ? ( + {(detail.offset !== undefined || detail.limit !== undefined) ? ( - {display.offset !== undefined ? `Offset: ${display.offset}` : ""} - {display.offset !== undefined && display.limit !== undefined ? " • " : ""} - {display.limit !== undefined ? `Limit: ${display.limit}` : ""} + {detail.offset !== undefined ? `Offset: ${detail.offset}` : ""} + {detail.offset !== undefined && detail.limit !== undefined ? " • " : ""} + {detail.limit !== undefined ? `Limit: ${detail.limit}` : ""} ) : null} - {display.content ? ( + {detail.content ? ( - {display.content} + {detail.content} ) : null} ); - } else if (display.type === "thinking") { + } else if (detail.type === "thinking") { // Thinking: display the content as plain text sections.push( @@ -242,19 +119,19 @@ export function ToolCallDetailsContent({ nestedScrollEnabled showsVerticalScrollIndicator={true} > - {display.content} + {detail.content} ); } else { // Generic tool: show input/output as key-value pairs - if (display.input.length > 0) { + if (detail.input.length > 0) { sections.push( Input ); - display.input.forEach((pair, index) => { + detail.input.forEach((pair, index) => { sections.push( {pair.key} @@ -272,13 +149,13 @@ export function ToolCallDetailsContent({ }); } - if (display.output.length > 0) { + if (detail.output.length > 0) { sections.push( Output ); - display.output.forEach((pair, index) => { + detail.output.forEach((pair, index) => { sections.push( {pair.key} @@ -330,37 +207,6 @@ export function ToolCallDetailsContent({ ); } -// ---- Hook for parsing tool call data ---- - -export function useToolCallDetails(data: ToolCallDetailsData) { - const { toolName, args, result, error } = data; - - return useMemo(() => { - const shouldLog = isPerfLoggingEnabled(); - const startMs = shouldLog ? getNowMs() : 0; - const display = parseToolCallDisplay(toolName, args, result); - const errorText = error !== undefined ? formatValue(error) : undefined; - if (shouldLog) { - const durationMs = getNowMs() - startMs; - const summary = summarizeToolCallDisplay(display); - if ( - durationMs >= TOOL_CALL_DETAILS_DURATION_THRESHOLD_MS || - summary.totalChars >= TOOL_CALL_DETAILS_SIZE_THRESHOLD - ) { - perfLog(TOOL_CALL_DETAILS_LOG_TAG, { - event: "parse", - durationMs: Math.round(durationMs), - displayType: summary.displayType, - totalChars: summary.totalChars, - errorLength: errorText ? errorText.length : 0, - ...summary.detail, - }); - } - } - return { display, errorText }; - }, [toolName, args, result, error]); -} - // ---- Styles ---- const styles = StyleSheet.create((theme) => { diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 699f95422..4b60b78bf 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -15,21 +15,13 @@ import { BottomSheetBackdrop, BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; -import { Pencil, Eye, SquareTerminal, Search, Wrench, X } from "lucide-react-native"; -import { extractPrincipalParam } from "@/utils/tool-call-parsers"; -import { ToolCallDetailsContent, useToolCallDetails } from "./tool-call-details"; +import { Pencil, Eye, SquareTerminal, Search, Bot, Wrench, X } from "lucide-react-native"; +import type { ToolCallDisplayInfo } from "@/utils/tool-call-parsers"; +import { ToolCallDetailsContent } from "./tool-call-details"; // ----- Types ----- -export interface ToolCallSheetData { - toolName: string; - kind?: string; - status?: "executing" | "completed" | "failed"; - cwd?: string; - args?: unknown; - result?: unknown; - error?: unknown; -} +export type ToolCallSheetData = ToolCallDisplayInfo; interface ToolCallSheetContextValue { openToolCall: (data: ToolCallSheetData) => void; @@ -55,6 +47,7 @@ const toolKindIcons: Record extractPrincipalParam(args, cwd), [args, cwd]); + const IconComponent = toolKindIcons[kind] || Wrench; return ( @@ -155,16 +143,9 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { - - - {display.toolName} - - {principalParam ? ( - - {principalParam} - - ) : null} - + + {displayName} + @@ -176,7 +157,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { style={styles.content} contentContainerStyle={styles.contentContainer} > - + ); @@ -210,10 +191,6 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], flex: 1, }, - headerTextColumn: { - flex: 1, - minWidth: 0, - }, headerIcon: { color: theme.colors.foreground, }, @@ -223,11 +200,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, flex: 1, }, - headerSubtitle: { - marginTop: theme.spacing[1], - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, closeButton: { padding: theme.spacing[2], }, diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index e204fd181..6e53cc1cd 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -59,7 +59,8 @@ export type StreamItem = | ThoughtItem | ToolCallItem | TodoListItem - | ActivityLogItem; + | ActivityLogItem + | CompactionItem; export interface UserMessageItem { kind: "user_message"; @@ -107,6 +108,7 @@ export interface AgentToolCallData { parsedEdits?: EditEntry[]; parsedReads?: ReadEntry[]; parsedCommand?: CommandDetails | null; + metadata?: Record; } export type ToolCallPayload = @@ -141,6 +143,15 @@ export interface ActivityLogItem { metadata?: Record; } +export interface CompactionItem { + kind: "compaction"; + id: string; + timestamp: Date; + status: "loading" | "completed"; + trigger?: "auto" | "manual"; + preTokens?: number; +} + export type TodoEntry = { text: string; completed: boolean }; export interface TodoListItem { @@ -407,6 +418,10 @@ function appendAgentToolCall( if (existingIndex >= 0) { const next = [...state]; const existing = next[existingIndex] as AgentToolCallItem; + const mergedInput = + payloadData.input !== undefined + ? payloadData.input + : existing.payload.data.input; const mergedResult = payloadData.result !== undefined ? payloadData.result @@ -419,6 +434,10 @@ function appendAgentToolCall( existing.payload.data.status, payloadData.status ?? existing.payload.data.status ?? "executing" ); + const mergedMetadata = + payloadData.metadata || existing.payload.data.metadata + ? { ...existing.payload.data.metadata, ...payloadData.metadata } + : undefined; const parsed = computeParsedToolPayload(mergedResult); next[existingIndex] = { ...existing, @@ -429,8 +448,10 @@ function appendAgentToolCall( ...existing.payload.data, ...payloadData, status: mergedStatus, + input: mergedInput, result: mergedResult, error: mergedError, + metadata: mergedMetadata, callId: payloadData.callId ?? existing.payload.data.callId, parsedEdits: parsed.parsedEdits ?? existing.payload.data.parsedEdits, parsedReads: parsed.parsedReads ?? existing.payload.data.parsedReads, @@ -799,6 +820,7 @@ export function reduceStreamUpdate( input: item.input, result: item.output, error: item.error, + metadata: item.metadata, }, timestamp ); @@ -825,6 +847,34 @@ export function reduceStreamUpdate( nextState = appendActivityLog(state, activity); break; } + case "compaction": { + if (item.status === "completed") { + const loadingIdx = state.findIndex( + (s) => s.kind === "compaction" && s.status === "loading" + ); + if (loadingIdx >= 0) { + const existing = state[loadingIdx] as CompactionItem; + const updated: CompactionItem = { + ...existing, + status: "completed", + trigger: item.trigger, + preTokens: item.preTokens, + }; + nextState = [...state.slice(0, loadingIdx), updated, ...state.slice(loadingIdx + 1)]; + break; + } + } + const compaction: CompactionItem = { + kind: "compaction", + id: createTimelineId("compaction", item.status, timestamp), + timestamp, + status: item.status, + trigger: item.trigger, + preTokens: item.preTokens, + }; + nextState = [...state, compaction]; + break; + } default: return state; } diff --git a/packages/app/src/utils/tool-call-parsers.test.ts b/packages/app/src/utils/tool-call-parsers.test.ts index b0bbe1c9b..cbe6dbe9e 100644 --- a/packages/app/src/utils/tool-call-parsers.test.ts +++ b/packages/app/src/utils/tool-call-parsers.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect } from "vitest"; import { extractKeyValuePairs, parseToolCallDisplay, - type ToolCallDisplay, + type ToolCallDisplayInfo, } from "./tool-call-parsers"; describe("tool-call-parsers - real runtime shapes", () => { @@ -38,30 +38,29 @@ describe("tool-call-parsers - real runtime shapes", () => { }); describe("parseToolCallDisplay", () => { - test("parses completed bash tool call into shell type", () => { + test("parses completed bash tool call into shell detail", () => { const input = { command: "pwd", description: "Print working directory" }; - const result = { type: "command", command: "pwd", output: "/some/path" }; + const output = { type: "command", command: "pwd", output: "/some/path" }; - const display: ToolCallDisplay = parseToolCallDisplay("Bash", input, result); - expect(display.type).toBe("shell"); - expect(display.toolName).toBe("Shell"); - if (display.type === "shell") { - expect(display.command).toBe("pwd"); - expect(display.output).toBe("/some/path"); + const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Bash", input, output }); + expect(info.detail.type).toBe("shell"); + expect(info.displayName).toBe("Shell"); + if (info.detail.type === "shell") { + expect(info.detail.command).toBe("pwd"); + expect(info.detail.output).toBe("/some/path"); } }); - test("parses pending bash tool call into shell type with empty output", () => { + test("parses pending bash tool call into shell detail with empty output", () => { // When tool is pending, we have input but no result yet const input = { command: "pwd", description: "Print working directory" }; - const result = undefined; - const display: ToolCallDisplay = parseToolCallDisplay("Bash", input, result); - expect(display.type).toBe("shell"); - expect(display.toolName).toBe("Shell"); - if (display.type === "shell") { - expect(display.command).toBe("pwd"); - expect(display.output).toBe(""); + const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Bash", input }); + expect(info.detail.type).toBe("shell"); + expect(info.displayName).toBe("Shell"); + if (info.detail.type === "shell") { + expect(info.detail.command).toBe("pwd"); + expect(info.detail.output).toBe(""); } }); @@ -71,10 +70,10 @@ describe("parseToolCallDisplay", () => { '/bin/zsh -lc "cd /Users/me/dev/paseo && nl -ba packages/app/src/utils/tool-call-parsers.test.ts | sed -n \'150,260p\'"', }; - const display: ToolCallDisplay = parseToolCallDisplay("shell", input, undefined); - expect(display.type).toBe("shell"); - if (display.type === "shell") { - expect(display.command).toBe( + const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "shell", input }); + expect(info.detail.type).toBe("shell"); + if (info.detail.type === "shell") { + expect(info.detail.command).toBe( "nl -ba packages/app/src/utils/tool-call-parsers.test.ts | sed -n '150,260p'" ); } @@ -82,100 +81,100 @@ describe("parseToolCallDisplay", () => { test("handles command as array", () => { const input = { command: ["git", "status"] }; - const result = { type: "command", output: "On branch main" }; + const output = { type: "command", output: "On branch main" }; - const display: ToolCallDisplay = parseToolCallDisplay("shell", input, result); - expect(display.type).toBe("shell"); - expect(display.toolName).toBe("Shell"); - if (display.type === "shell") { - expect(display.command).toBe("git status"); - expect(display.output).toBe("On branch main"); + const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "shell", input, output }); + expect(info.detail.type).toBe("shell"); + expect(info.displayName).toBe("Shell"); + if (info.detail.type === "shell") { + expect(info.detail.command).toBe("git status"); + expect(info.detail.output).toBe("On branch main"); } }); test("normalizes tool names - shell to Shell", () => { const input = { command: "pwd" }; - const display = parseToolCallDisplay("shell", input, undefined); - expect(display.toolName).toBe("Shell"); + const info = parseToolCallDisplay({ name: "shell", input }); + expect(info.displayName).toBe("Shell"); }); test("normalizes tool names - Bash to Shell", () => { const input = { command: "pwd" }; - const display = parseToolCallDisplay("Bash", input, undefined); - expect(display.toolName).toBe("Shell"); + const info = parseToolCallDisplay({ name: "Bash", input }); + expect(info.displayName).toBe("Shell"); }); test("normalizes tool names - read_file to Read", () => { const input = { file_path: "/some/file.txt" }; - const display = parseToolCallDisplay("read_file", input, undefined); - expect(display.toolName).toBe("Read"); + const info = parseToolCallDisplay({ name: "read_file", input }); + expect(info.displayName).toBe("Read"); }); test("normalizes tool names - paseo_voice.speak to Speak", () => { const input = { text: "hello from namespaced speak" }; - const display = parseToolCallDisplay("paseo_voice.speak", input, undefined); - expect(display.toolName).toBe("Speak"); + const info = parseToolCallDisplay({ name: "paseo_voice.speak", input }); + expect(info.displayName).toBe("Speak"); }); test("normalizes tool names - mcp__paseo_voice__speak to Speak", () => { const input = { text: "hello from claude mcp speak" }; - const display = parseToolCallDisplay("mcp__paseo_voice__speak", input, undefined); - expect(display.toolName).toBe("Speak"); + const info = parseToolCallDisplay({ name: "mcp__paseo_voice__speak", input }); + expect(info.displayName).toBe("Speak"); }); test("preserves unknown tool names", () => { const input = { some_arg: "value" }; - const display = parseToolCallDisplay("MyCustomTool", input, undefined); - expect(display.toolName).toBe("MyCustomTool"); + const info = parseToolCallDisplay({ name: "MyCustomTool", input }); + expect(info.displayName).toBe("MyCustomTool"); }); - test("parses non-command tool call into generic type", () => { + test("parses non-command tool call into generic detail", () => { const input = { file_path: "/some/file.txt" }; - const result = { content: "file contents here", lineCount: 42 }; + const output = { content: "file contents here", lineCount: 42 }; - const display: ToolCallDisplay = parseToolCallDisplay("SomeTool", input, result); - expect(display.type).toBe("generic"); - if (display.type === "generic") { - expect(display.input).toContainEqual({ key: "file_path", value: "/some/file.txt" }); - expect(display.output).toContainEqual({ key: "content", value: "file contents here" }); - expect(display.output).toContainEqual({ key: "lineCount", value: "42" }); + const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "SomeTool", input, output }); + expect(info.detail.type).toBe("generic"); + if (info.detail.type === "generic") { + expect(info.detail.input).toContainEqual({ key: "file_path", value: "/some/file.txt" }); + expect(info.detail.output).toContainEqual({ key: "content", value: "file contents here" }); + expect(info.detail.output).toContainEqual({ key: "lineCount", value: "42" }); } }); test("handles file_write output as generic", () => { const input = { file_path: "/some/file.txt", content: "new content" }; - const result = { type: "file_write", filePath: "/some/file.txt" }; + const output = { type: "file_write", filePath: "/some/file.txt" }; - const display: ToolCallDisplay = parseToolCallDisplay("Write", input, result); - expect(display.type).toBe("generic"); + const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Write", input, output }); + expect(info.detail.type).toBe("generic"); }); - test("handles undefined input and result gracefully", () => { - const display: ToolCallDisplay = parseToolCallDisplay("unknown", undefined, undefined); - expect(display.type).toBe("generic"); - if (display.type === "generic") { - expect(display.input).toEqual([]); - expect(display.output).toEqual([]); + test("handles undefined input and output gracefully", () => { + const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "unknown" }); + expect(info.detail.type).toBe("generic"); + if (info.detail.type === "generic") { + expect(info.detail.input).toEqual([]); + expect(info.detail.output).toEqual([]); } }); - test("parses edit tool call into edit type with old_string/new_string", () => { + test("parses edit tool call into edit detail with old_string/new_string", () => { const input = { file_path: "/some/file.txt", old_string: "const foo = 1;", new_string: "const foo = 2;", }; - const result = { + const output = { type: "file_edit", filePath: "/some/file.txt", }; - const display: ToolCallDisplay = parseToolCallDisplay("Edit", input, result); - expect(display.type).toBe("edit"); - if (display.type === "edit") { - expect(display.filePath).toBe("/some/file.txt"); - expect(display.oldString).toBe("const foo = 1;"); - expect(display.newString).toBe("const foo = 2;"); + const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Edit", input, output }); + expect(info.detail.type).toBe("edit"); + if (info.detail.type === "edit") { + expect(info.detail.filePath).toBe("/some/file.txt"); + expect(info.detail.oldString).toBe("const foo = 1;"); + expect(info.detail.newString).toBe("const foo = 2;"); } }); @@ -185,14 +184,13 @@ describe("parseToolCallDisplay", () => { old_str: "line 1", new_str: "line 2", }; - const result = undefined; - const display: ToolCallDisplay = parseToolCallDisplay("Edit", input, result); - expect(display.type).toBe("edit"); - if (display.type === "edit") { - expect(display.filePath).toBe("/some/file.txt"); - expect(display.oldString).toBe("line 1"); - expect(display.newString).toBe("line 2"); + const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Edit", input }); + expect(info.detail.type).toBe("edit"); + if (info.detail.type === "edit") { + expect(info.detail.filePath).toBe("/some/file.txt"); + expect(info.detail.oldString).toBe("line 1"); + expect(info.detail.newString).toBe("line 2"); } }); @@ -203,18 +201,18 @@ describe("parseToolCallDisplay", () => { new_string: "new content", }; - const display: ToolCallDisplay = parseToolCallDisplay("Edit", input, undefined); - expect(display.type).toBe("edit"); - if (display.type === "edit") { - expect(display.filePath).toBe("/some/file.txt"); - expect(display.oldString).toBe("old content"); - expect(display.newString).toBe("new content"); + const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Edit", input }); + expect(info.detail.type).toBe("edit"); + if (info.detail.type === "edit") { + expect(info.detail.filePath).toBe("/some/file.txt"); + expect(info.detail.oldString).toBe("old content"); + expect(info.detail.newString).toBe("new content"); } }); }); describe("parseToolCallDisplay - apply_patch (Codex)", () => { - test("parses apply_patch into edit type with unified diff", () => { + test("parses apply_patch into edit detail with unified diff", () => { const input = { files: [ { @@ -223,7 +221,7 @@ describe("parseToolCallDisplay - apply_patch (Codex)", () => { }, ], }; - const result = { + const output = { files: [ { path: "/Users/me/dev/blankpage/editor/.tasks/578561c8.md", @@ -235,18 +233,18 @@ describe("parseToolCallDisplay - apply_patch (Codex)", () => { success: true, }; - const display = parseToolCallDisplay("apply_patch", input, result); - expect(display.type).toBe("edit"); - expect(display.toolName).toBe("Edit"); - if (display.type === "edit") { - expect(display.filePath).toBe("/Users/me/dev/blankpage/editor/.tasks/578561c8.md"); - expect(display.unifiedDiff).toBe("@@ -15,3 +15,2 @@\n-This task defines the **design philosophy**\n+This task defines the **updated philosophy**"); - expect(display.oldString).toBe(""); - expect(display.newString).toBe(""); + const info = parseToolCallDisplay({ name: "apply_patch", input, output }); + expect(info.detail.type).toBe("edit"); + expect(info.displayName).toBe("Edit"); + if (info.detail.type === "edit") { + expect(info.detail.filePath).toBe("/Users/me/dev/blankpage/editor/.tasks/578561c8.md"); + expect(info.detail.unifiedDiff).toBe("@@ -15,3 +15,2 @@\n-This task defines the **design philosophy**\n+This task defines the **updated philosophy**"); + expect(info.detail.oldString).toBe(""); + expect(info.detail.newString).toBe(""); } }); - test("parses apply_patch with kind object (type/move_path) into edit type", () => { + test("parses apply_patch with kind object (type/move_path) into edit detail", () => { const input = { files: [ { @@ -255,7 +253,7 @@ describe("parseToolCallDisplay - apply_patch (Codex)", () => { }, ], }; - const result = { + const output = { files: [ { path: "/Users/me/.paseo/worktrees/paseo/naive-zebra/packages/server/src/server/daemon-keypair.ts", @@ -266,14 +264,14 @@ describe("parseToolCallDisplay - apply_patch (Codex)", () => { success: true, }; - const display = parseToolCallDisplay("apply_patch", input, result); - expect(display.type).toBe("edit"); - expect(display.toolName).toBe("Edit"); - if (display.type === "edit") { - expect(display.filePath).toBe( + const info = parseToolCallDisplay({ name: "apply_patch", input, output }); + expect(info.detail.type).toBe("edit"); + expect(info.displayName).toBe("Edit"); + if (info.detail.type === "edit") { + expect(info.detail.filePath).toBe( "/Users/me/.paseo/worktrees/paseo/naive-zebra/packages/server/src/server/daemon-keypair.ts" ); - expect(display.unifiedDiff).toBe("@@ -1,1 +1,1 @@\n-foo\n+bar"); + expect(info.detail.unifiedDiff).toBe("@@ -1,1 +1,1 @@\n-foo\n+bar"); } }); @@ -286,7 +284,7 @@ describe("parseToolCallDisplay - apply_patch (Codex)", () => { }, ], }; - const result = { + const output = { files: [ { path: "/some/old-path.txt", @@ -297,11 +295,11 @@ describe("parseToolCallDisplay - apply_patch (Codex)", () => { success: true, }; - const display = parseToolCallDisplay("apply_patch", input, result); - expect(display.type).toBe("edit"); - if (display.type === "edit") { - expect(display.filePath).toBe("/some/new-path.txt"); - expect(display.unifiedDiff).toBe("@@ -1,1 +1,1 @@\n-old\n+new"); + const info = parseToolCallDisplay({ name: "apply_patch", input, output }); + expect(info.detail.type).toBe("edit"); + if (info.detail.type === "edit") { + expect(info.detail.filePath).toBe("/some/new-path.txt"); + expect(info.detail.unifiedDiff).toBe("@@ -1,1 +1,1 @@\n-old\n+new"); } }); @@ -315,12 +313,12 @@ describe("parseToolCallDisplay - apply_patch (Codex)", () => { ], }; - const display = parseToolCallDisplay("apply_patch", input, undefined); - expect(display.type).toBe("edit"); - expect(display.toolName).toBe("Edit"); - if (display.type === "edit") { - expect(display.filePath).toBe("/some/file.txt"); - expect(display.unifiedDiff).toBeUndefined(); + const info = parseToolCallDisplay({ name: "apply_patch", input }); + expect(info.detail.type).toBe("edit"); + expect(info.displayName).toBe("Edit"); + if (info.detail.type === "edit") { + expect(info.detail.filePath).toBe("/some/file.txt"); + expect(info.detail.unifiedDiff).toBeUndefined(); } }); @@ -331,7 +329,7 @@ describe("parseToolCallDisplay - apply_patch (Codex)", () => { { path: "/second/file.txt", kind: "create" }, ], }; - const result = { + const output = { files: [ { path: "/first/file.txt", patch: "@@ -1 +1 @@\n-old\n+new", kind: "update" }, { path: "/second/file.txt", patch: "@@ -0,0 +1 @@\n+content", kind: "create" }, @@ -339,32 +337,32 @@ describe("parseToolCallDisplay - apply_patch (Codex)", () => { success: true, }; - const display = parseToolCallDisplay("apply_patch", input, result); - expect(display.type).toBe("edit"); - if (display.type === "edit") { - expect(display.filePath).toBe("/first/file.txt"); - expect(display.unifiedDiff).toBe("@@ -1 +1 @@\n-old\n+new"); + const info = parseToolCallDisplay({ name: "apply_patch", input, output }); + expect(info.detail.type).toBe("edit"); + if (info.detail.type === "edit") { + expect(info.detail.filePath).toBe("/first/file.txt"); + expect(info.detail.unifiedDiff).toBe("@@ -1 +1 @@\n-old\n+new"); } }); }); describe("parseToolCallDisplay - read_file (Codex)", () => { - test("parses Codex read_file into read type", () => { + test("parses Codex read_file into read detail", () => { const input = { path: "/Users/me/dev/blankpage/editor/.tasks/578561c8.md", }; - const result = { + const output = { type: "read_file", path: "/Users/me/dev/blankpage/editor/.tasks/578561c8.md", content: "260 - Source: `**bold**|`\n261 - Action: `Shift+ArrowLeft`", }; - const display = parseToolCallDisplay("read_file", input, result); - expect(display.type).toBe("read"); - expect(display.toolName).toBe("Read"); - if (display.type === "read") { - expect(display.filePath).toBe("/Users/me/dev/blankpage/editor/.tasks/578561c8.md"); - expect(display.content).toBe("260 - Source: `**bold**|`\n261 - Action: `Shift+ArrowLeft`"); + const info = parseToolCallDisplay({ name: "read_file", input, output }); + expect(info.detail.type).toBe("read"); + expect(info.displayName).toBe("Read"); + if (info.detail.type === "read") { + expect(info.detail.filePath).toBe("/Users/me/dev/blankpage/editor/.tasks/578561c8.md"); + expect(info.detail.content).toBe("260 - Source: `**bold**|`\n261 - Action: `Shift+ArrowLeft`"); } }); @@ -373,9 +371,9 @@ describe("parseToolCallDisplay - read_file (Codex)", () => { path: "/some/file.txt", }; - const display = parseToolCallDisplay("read_file", input, undefined); + const info = parseToolCallDisplay({ name: "read_file", input }); // Without result, it can't match the schema so falls through to generic - expect(display.type).toBe("generic"); - expect(display.toolName).toBe("Read"); + expect(info.detail.type).toBe("generic"); + expect(info.displayName).toBe("Read"); }); }); diff --git a/packages/app/src/utils/tool-call-parsers.ts b/packages/app/src/utils/tool-call-parsers.ts index 5c6481f15..1535db17a 100644 --- a/packages/app/src/utils/tool-call-parsers.ts +++ b/packages/app/src/utils/tool-call-parsers.ts @@ -1,9 +1,4 @@ -import stripAnsi from "strip-ansi"; import { z } from "zod"; -import { - normalizeToolDisplayName, - stripShellWrapperPrefix, -} from "@getpaseo/server/utils/tool-call-parsers"; import { getNowMs, isPerfLoggingEnabled, perfLog } from "./perf"; const TOOL_CALL_DIFF_LOG_TAG = "[ToolCallDiff]"; @@ -893,14 +888,6 @@ export interface KeyValuePair { value: string; } -const WrappedOutputSchema = z - .object({ output: z.record(z.unknown()) }) - .transform((data) => data.output); - -const DirectRecordSchema = z.record(z.unknown()); - -const ToolResultRecordSchema = z.union([WrappedOutputSchema, DirectRecordSchema]); - function stringifyValue(value: unknown): string { if (value === null) { return "null"; @@ -921,6 +908,14 @@ function stringifyValue(value: unknown): string { } } +const WrappedOutputSchema = z + .object({ output: z.record(z.unknown()) }) + .transform((data) => data.output); + +const DirectRecordSchema = z.record(z.unknown()); + +const ToolResultRecordSchema = z.union([WrappedOutputSchema, DirectRecordSchema]); + export function extractKeyValuePairs(result: unknown): KeyValuePair[] { const parsed = ToolResultRecordSchema.safeParse(result); if (!parsed.success) { @@ -934,331 +929,6 @@ export function extractKeyValuePairs(result: unknown): KeyValuePair[] { })); } -// ---- Tool Call Display Discriminated Union ---- - -const KeyValuePairsSchema = z.record(z.unknown()).transform((data) => - Object.entries(data).map(([key, value]) => ({ - key, - value: stringifyValue(value), - })) -); - -// Shell input: { command: "pwd", description?: "..." } -const ShellInputSchema = z.object({ - command: z.union([z.string(), z.array(z.string())]), -}).passthrough(); - -// Shell result (when completed): { type: "command", command: "pwd", output: "..." } -const ShellResultSchema = z.object({ - type: z.literal("command"), - output: z.string(), -}).passthrough(); - -// Shell error result: { type: "tool_result", content: "Exit code 128\n...", is_error: true } -const ShellErrorResultSchema = z.object({ - type: z.literal("tool_result"), - content: z.string(), - is_error: z.literal(true), -}).passthrough(); - -// Shell tool call display schema -const ShellToolCallSchema = z - .object({ - input: ShellInputSchema, - result: z.unknown(), - }) - .transform((data) => { - const commandRaw = Array.isArray(data.input.command) - ? data.input.command.join(" ") - : data.input.command; - const command = stripShellWrapperPrefix(commandRaw); - - // Try parsing as success result first - const resultParsed = ShellResultSchema.safeParse(data.result); - if (resultParsed.success) { - return { - type: "shell" as const, - command, - output: stripAnsi(resultParsed.data.output), - }; - } - - // Try parsing as error result - const errorParsed = ShellErrorResultSchema.safeParse(data.result); - if (errorParsed.success) { - return { - type: "shell" as const, - command, - output: stripAnsi(errorParsed.data.content), - }; - } - - return { - type: "shell" as const, - command, - output: "", - }; - }); - -// Edit input: { file_path: string, old_string: string, new_string: string } -// Also supports old_str/new_str variants -const EditInputSchema = z.union([ - z.object({ - file_path: z.string(), - old_string: z.string(), - new_string: z.string(), - }).passthrough(), - z.object({ - file_path: z.string(), - old_str: z.string(), - new_str: z.string(), - }).passthrough(), -]); - -// Edit result: { type: "file_edit", filePath: string, oldContent?: string, newContent?: string } -const EditResultSchema = z.object({ - type: z.literal("file_edit"), - filePath: z.string(), - oldContent: z.string().optional(), - newContent: z.string().optional(), -}).passthrough(); - -// Edit tool call display schema -const EditToolCallSchema = z - .object({ - input: EditInputSchema, - result: z.unknown(), - }) - .transform((data): { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string } => { - const filePath = data.input.file_path; - const oldString = "old_string" in data.input - ? (data.input as { old_string: string }).old_string - : (data.input as { old_str: string }).old_str; - const newString = "new_string" in data.input - ? (data.input as { new_string: string }).new_string - : (data.input as { new_str: string }).new_str; - - return { - type: "edit", - filePath, - oldString, - newString, - }; - }); - -const ApplyPatchFileKindSchema = z - .union([ - z.string(), - z - .object({ - type: z.string().optional(), - move_path: z.string().nullable().optional(), - movePath: z.string().nullable().optional(), - }) - .passthrough(), - ]) - .optional(); - -function getApplyPatchMovePath(kind: unknown): string | undefined { - if (!kind || typeof kind !== "object") return undefined; - const record = kind as Record; - const movePath = typeof record.movePath === "string" ? record.movePath : undefined; - const movePathSnake = - typeof record.move_path === "string" ? record.move_path : undefined; - return movePath ?? movePathSnake ?? undefined; -} - -// Codex apply_patch input: { files: [{ path: string, kind: string | object }] } -const ApplyPatchInputSchema = z.object({ - files: z.array(z.object({ - path: z.string(), - kind: ApplyPatchFileKindSchema, - })).min(1), -}).passthrough(); - -// Codex apply_patch result: { files: [{ path: string, patch: string, kind: string | object }], message: string, success: boolean } -const ApplyPatchResultSchema = z.object({ - files: z.array(z.object({ - path: z.string(), - patch: z.string().optional(), - kind: ApplyPatchFileKindSchema, - })).optional(), - message: z.string().optional(), - success: z.boolean().optional(), -}).passthrough(); - -// Apply patch tool call display schema - transforms to edit type with unified diff -const ApplyPatchToolCallSchema = z - .object({ - input: ApplyPatchInputSchema, - result: z.unknown(), - }) - .transform((data): { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string } => { - const firstFile = data.input.files[0]; - const movePath = getApplyPatchMovePath(firstFile.kind); - const filePath = movePath ?? firstFile.path; - - // Try to get the patch from the result - const resultParsed = ApplyPatchResultSchema.safeParse(data.result); - let unifiedDiff: string | undefined; - if (resultParsed.success && resultParsed.data.files) { - const matchPaths = new Set([firstFile.path]); - if (movePath) matchPaths.add(movePath); - - const resultFile = - resultParsed.data.files.find((f) => matchPaths.has(f.path)) ?? - resultParsed.data.files[0]; - unifiedDiff = resultFile?.patch; - } - - return { - type: "edit", - filePath, - oldString: "", - newString: "", - unifiedDiff, - }; - }); - -// Read input: { file_path: string, offset?: number, limit?: number } -const ReadInputSchema = z.object({ - file_path: z.string(), - offset: z.number().optional(), - limit: z.number().optional(), -}).passthrough(); - -// Read result: { type: "file_read", filePath: string, content: string } -const ReadResultSchema = z.object({ - type: z.literal("file_read"), - filePath: z.string(), - content: z.string(), -}).passthrough(); - -// Read tool call display schema (Claude) -const ReadToolCallSchema = z - .object({ - input: ReadInputSchema, - result: ReadResultSchema, - }) - .transform((data): { type: "read"; filePath: string; content: string; offset?: number; limit?: number } => ({ - type: "read", - filePath: data.input.file_path, - content: data.result.content, - offset: data.input.offset, - limit: data.input.limit, - })); - -// Codex read_file input: { path: string } -const CodexReadInputSchema = z.object({ - path: z.string(), -}).passthrough(); - -// Codex read_file result: { type: "read_file", path: string, content: string } -const CodexReadResultSchema = z.object({ - type: z.literal("read_file"), - path: z.string(), - content: z.string(), -}).passthrough(); - -// Codex read_file tool call display schema -const CodexReadToolCallSchema = z - .object({ - input: CodexReadInputSchema, - result: CodexReadResultSchema, - }) - .transform((data): { type: "read"; filePath: string; content: string; offset?: number; limit?: number } => ({ - type: "read", - filePath: data.input.path, - content: data.result.content, - })); - -// Generic tool call display schema (fallback) -const GenericToolCallSchema = z - .object({ - input: z.unknown(), - result: z.unknown(), - }) - .transform((data) => { - const inputPairs = KeyValuePairsSchema.safeParse(data.input); - const resultPairs = KeyValuePairsSchema.safeParse(data.result); - - return { - type: "generic" as const, - input: inputPairs.success ? inputPairs.data : [], - output: resultPairs.success ? resultPairs.data : [], - }; - }); - -// Normalizes tool names for consistent display across agents -const TOOL_NAME_MAP: Record = { - shell: "Shell", - Bash: "Shell", - read_file: "Read", - apply_patch: "Edit", - paseo_worktree_setup: "Setup", - thinking: "Thinking", -}; - -const ToolCallDisplaySchema = z - .object({ - toolName: z.string(), - input: z.unknown(), - result: z.unknown(), - }) - .transform((data) => { - const normalizedToolName = normalizeToolDisplayName( - TOOL_NAME_MAP[data.toolName] ?? data.toolName - ); - - // Handle thinking - input is the thinking text content - if (data.toolName === "thinking") { - const content = typeof data.input === "string" ? data.input : ""; - return { - type: "thinking" as const, - content, - toolName: normalizedToolName, - }; - } - - // Try each schema in order - const shellParsed = ShellToolCallSchema.safeParse({ input: data.input, result: data.result }); - if (shellParsed.success) { - return { ...shellParsed.data, toolName: normalizedToolName }; - } - - const editParsed = EditToolCallSchema.safeParse({ input: data.input, result: data.result }); - if (editParsed.success) { - return { ...editParsed.data, toolName: normalizedToolName }; - } - - // Codex apply_patch - try before read since it also uses files array - const applyPatchParsed = ApplyPatchToolCallSchema.safeParse({ input: data.input, result: data.result }); - if (applyPatchParsed.success) { - return { ...applyPatchParsed.data, toolName: normalizedToolName }; - } - - const readParsed = ReadToolCallSchema.safeParse({ input: data.input, result: data.result }); - if (readParsed.success) { - return { ...readParsed.data, toolName: normalizedToolName }; - } - - // Codex read_file - const codexReadParsed = CodexReadToolCallSchema.safeParse({ input: data.input, result: data.result }); - if (codexReadParsed.success) { - return { ...codexReadParsed.data, toolName: normalizedToolName }; - } - - // Fallback to generic - const genericParsed = GenericToolCallSchema.parse({ input: data.input, result: data.result }); - return { ...genericParsed, toolName: normalizedToolName }; - }); - -export type ToolCallDisplay = z.infer; - -export function parseToolCallDisplay(toolName: string, input: unknown, result: unknown): ToolCallDisplay { - return ToolCallDisplaySchema.parse({ toolName, input, result }); -} - // ---- Task Extraction (cross-provider) ---- export type TaskStatus = "pending" | "in_progress" | "completed"; @@ -1338,11 +1008,18 @@ export function extractTaskEntriesFromToolCall( return null; } -// ---- Principal Parameter Extraction ---- -// Re-export from server to avoid drift +// ---- Unified Tool Call Display ---- +// Re-export from server — single source of truth export { - extractPrincipalParam, + parseToolCallDisplay, stripCwdPrefix, extractTodos, + normalizeToolDisplayName, + stripShellWrapperPrefix, + type ToolCallInput, + type ToolCallDisplayInfo, + type ToolCallDetail, + type ToolCallKind, type TodoItem, + type KeyValuePair as ServerKeyValuePair, } from "@getpaseo/server/utils/tool-call-parsers"; diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 2dea62247..1010fbda9 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -310,7 +310,7 @@ describe("curateAgentActivity", () => { const result = curateAgentActivity(timeline); - expect(result).toBe("[Bash] npm test"); + expect(result).toBe("[Shell] npm test"); }); test("extracts pattern from Glob tool", () => { @@ -406,5 +406,185 @@ describe("curateAgentActivity", () => { const result = curateAgentActivity(timeline); expect(result).toBe('[Speak] {"text":"hello from claude mcp"}'); }); + + test("extracts description from Task tool", () => { + const timeline: AgentTimelineItem[] = [ + { + type: "tool_call", + callId: "task-1", + name: "Task", + input: { description: "Explore the codebase" }, + status: "completed", + }, + ]; + + const result = curateAgentActivity(timeline); + expect(result).toBe("[Task] Explore the codebase"); + }); + }); + + describe("Task tool collapse with sub-agent activity", () => { + test("collapses Task metadata updates into single entry showing latest activity", () => { + const timeline: AgentTimelineItem[] = [ + { type: "user_message", text: "Investigate the bug" }, + { + type: "tool_call", + callId: "task-1", + name: "Task", + input: { description: "Explore the codebase" }, + status: "pending", + }, + // Sub-agent activity updates (same callId, metadata-only) + { + type: "tool_call", + callId: "task-1", + name: "Task", + metadata: { subAgentActivity: "Read" }, + }, + { + type: "tool_call", + callId: "task-1", + name: "Task", + metadata: { subAgentActivity: "Grep" }, + }, + { + type: "tool_call", + callId: "task-1", + name: "Task", + metadata: { subAgentActivity: "Edit" }, + }, + ]; + + const result = curateAgentActivity(timeline); + const lines = result.split("\n"); + + // Task should appear only once (collapsed by callId) + const taskLines = lines.filter((l) => l.includes("[Task]")); + expect(taskLines).toHaveLength(1); + + // The last metadata update wins — subAgentActivity "Edit" takes priority + expect(taskLines[0]).toBe("[Task] Edit"); + }); + + test("sub-agent tool calls do NOT appear as separate timeline entries", () => { + // This simulates the full flow: handleSidechainMessage only emits + // Task metadata updates, never individual sub-agent tool calls. + // So the timeline should only contain the Task call, not Read/Bash/Edit. + const timeline: AgentTimelineItem[] = [ + { + type: "tool_call", + callId: "task-1", + name: "Task", + input: { description: "Fix the bug" }, + status: "pending", + }, + // These are the metadata-only updates from handleSidechainMessage + { + type: "tool_call", + callId: "task-1", + name: "Task", + metadata: { subAgentActivity: "Read" }, + }, + { + type: "tool_call", + callId: "task-1", + name: "Task", + metadata: { subAgentActivity: "Bash" }, + }, + // Task completes + { + type: "tool_call", + callId: "task-1", + name: "Task", + input: { description: "Fix the bug" }, + output: { result: "Bug fixed successfully" }, + status: "completed", + }, + ]; + + const result = curateAgentActivity(timeline); + const lines = result.split("\n"); + + // No individual Read/Bash lines — only the Task + expect(lines.filter((l) => l.includes("[Read]"))).toHaveLength(0); + expect(lines.filter((l) => l.includes("[Shell]"))).toHaveLength(0); + + // One Task entry with the final completed state + const taskLines = lines.filter((l) => l.includes("[Task]")); + expect(taskLines).toHaveLength(1); + expect(taskLines[0]).toBe("[Task] Fix the bug"); + }); + + test("multiple concurrent Task calls are tracked independently", () => { + const timeline: AgentTimelineItem[] = [ + { + type: "tool_call", + callId: "task-a", + name: "Task", + input: { description: "Research API docs" }, + status: "pending", + }, + { + type: "tool_call", + callId: "task-b", + name: "Task", + input: { description: "Run tests" }, + status: "pending", + }, + // Activity updates for each + { + type: "tool_call", + callId: "task-a", + name: "Task", + metadata: { subAgentActivity: "WebFetch" }, + }, + { + type: "tool_call", + callId: "task-b", + name: "Task", + metadata: { subAgentActivity: "Bash" }, + }, + ]; + + const result = curateAgentActivity(timeline); + const lines = result.split("\n"); + + const taskLines = lines.filter((l) => l.includes("[Task]")); + expect(taskLines).toHaveLength(2); + // Last update for each callId wins + expect(taskLines[0]).toBe("[Task] WebFetch"); + expect(taskLines[1]).toBe("[Task] Bash"); + }); + }); + + describe("compaction", () => { + test("renders compaction as [Compacted]", () => { + const timeline: AgentTimelineItem[] = [ + { type: "assistant_message", text: "Working on it..." }, + { type: "compaction", status: "completed", trigger: "auto", preTokens: 168000 }, + { type: "assistant_message", text: "Continuing after compaction" }, + ]; + + const result = curateAgentActivity(timeline); + const lines = result.split("\n"); + expect(lines).toContain("[Compacted]"); + expect(lines.indexOf("[Compacted]")).toBeGreaterThan(0); + }); + + test("compaction flushes preceding buffers", () => { + const timeline: AgentTimelineItem[] = [ + { type: "assistant_message", text: "Before" }, + { type: "reasoning", text: "Thinking..." }, + { type: "compaction", status: "completed", trigger: "auto" }, + { type: "assistant_message", text: "After" }, + ]; + + const result = curateAgentActivity(timeline); + const lines = result.split("\n"); + const compactIdx = lines.indexOf("[Compacted]"); + expect(compactIdx).toBeGreaterThan(-1); + expect(lines.slice(0, compactIdx).some((l) => l.includes("Before"))).toBe(true); + expect(lines.slice(0, compactIdx).some((l) => l.includes("Thinking"))).toBe(true); + }); }); }); diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index f6c8d2613..13730d5ef 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -1,5 +1,5 @@ import type { AgentTimelineItem } from "./agent-sdk-types.js"; -import { extractPrincipalParam, normalizeToolDisplayName } from "../../utils/tool-call-parsers.js"; +import { parseToolCallDisplay } from "../../utils/tool-call-parsers.js"; import { isLikelyExternalToolName } from "./tool-name-normalization.js"; const DEFAULT_MAX_ITEMS = 40; @@ -89,7 +89,18 @@ function collapseTimeline(items: AgentTimelineItem[]): AgentTimelineItem[] { } else if (item.type === "tool_call" && item.callId) { flushAssistant(); flushReasoning(); - toolCallMap.set(item.callId, item); + const existing = toolCallMap.get(item.callId); + if (existing && existing.type === "tool_call") { + toolCallMap.set(item.callId, { + ...existing, + ...item, + input: item.input ?? existing.input, + output: item.output ?? existing.output, + metadata: item.metadata, + }); + } else { + toolCallMap.set(item.callId, item); + } } else if (item.type === "tool_call") { flushAssistant(); flushReasoning(); @@ -147,15 +158,14 @@ export function curateAgentActivity( break; case "tool_call": { flushBuffers(lines, buffers); - const displayName = normalizeToolDisplayName(item.name); const inputJson = formatToolInputJson(item.input); + const { displayName, summary } = parseToolCallDisplay({ name: item.name, input: item.input, metadata: item.metadata }); if (isLikelyExternalToolName(item.name) && inputJson) { lines.push(`[${displayName}] ${inputJson}`); break; } - const principal = extractPrincipalParam(item.input); - if (principal) { - lines.push(`[${displayName}] ${principal}`); + if (summary) { + lines.push(`[${displayName}] ${summary}`); } else { lines.push(`[${displayName}]`); } @@ -173,6 +183,10 @@ export function curateAgentActivity( flushBuffers(lines, buffers); lines.push(`[Error] ${item.message}`); break; + case "compaction": + flushBuffers(lines, buffers); + lines.push("[Compacted]"); + break; } } diff --git a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts index cb38afb11..3d9ed553c 100644 --- a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts +++ b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts @@ -20,7 +20,7 @@ const CODEX_TEST_THINKING_OPTION_ID = "low"; const hasOpenAICredentials = !!process.env.OPENAI_API_KEY; const hasClaudeCredentials = - !!process.env.CLAUDE_SESSION_TOKEN || !!process.env.ANTHROPIC_API_KEY; + !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; const shouldRun = !process.env.CI && (hasOpenAICredentials || hasClaudeCredentials); type AgentMcpServerHandle = { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index afeeed9f0..e36e155ce 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -124,15 +124,24 @@ export interface ToolCallTimelineItem { input?: unknown; output?: unknown; error?: unknown; + metadata?: Record; } +export type CompactionTimelineItem = { + type: "compaction"; + status: "loading" | "completed"; + trigger?: "auto" | "manual"; + preTokens?: number; +}; + export type AgentTimelineItem = | { type: "user_message"; text: string; messageId?: string } | { type: "assistant_message"; text: string } | { type: "reasoning"; text: string } | ToolCallTimelineItem | { type: "todo"; items: { text: string; completed: boolean }[] } - | { type: "error"; message: string }; + | { type: "error"; message: string } + | CompactionTimelineItem; export type AgentStreamEvent = | { type: "thread_started"; sessionId: string; provider: AgentProvider } diff --git a/packages/server/src/server/agent/providers/claude-agent-commands.test.ts b/packages/server/src/server/agent/providers/claude-agent-commands.test.ts index c32433df2..321e7c7c4 100644 --- a/packages/server/src/server/agent/providers/claude-agent-commands.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent-commands.test.ts @@ -15,7 +15,7 @@ import type { AgentSession, AgentSessionConfig, AgentSlashCommand } from "../age import { createTestLogger } from "../../../test-utils/test-logger.js"; const hasClaudeCredentials = - !!process.env.CLAUDE_SESSION_TOKEN || !!process.env.ANTHROPIC_API_KEY; + !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; (hasClaudeCredentials ? describe : describe.skip)("ClaudeAgentSession Commands", () => { let client: ClaudeAgentClient; diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index c66cb172c..7168c9751 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -16,6 +16,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { curateAgentActivity } from "../activity-curator.js"; import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js"; import { useTempClaudeConfigDir } from "../../test-utils/claude-config.js"; import type { AgentStreamEventPayload } from "../../messages.js"; @@ -33,7 +34,7 @@ import { createAgentMcpServer } from "../mcp-server.js"; const createHTTPServer = createServer; const hasClaudeCredentials = - !!process.env.CLAUDE_SESSION_TOKEN || !!process.env.ANTHROPIC_API_KEY; + !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; type StreamItem = any; type AgentToolCallData = any; @@ -1093,6 +1094,68 @@ async function startAgentMcpServer(): Promise { }, 240_000 ); + + test( + "collapses sub-agent tool calls into Task metadata updates", + async () => { + const cwd = tmpCwd(); + const client = new ClaudeAgentClient({ logger }); + const config = buildConfig(cwd, { maxThinkingTokens: 2048 }); + const session = await client.createSession(config); + + const events = session.stream( + "Use the Task tool to launch a sub-agent that reads the current directory listing. " + + "The sub-agent should run 'ls' in the shell and report the result. " + + "Do NOT do the work yourself — delegate it to a sub-agent via the Task tool." + ); + + const timeline: AgentTimelineItem[] = []; + + for await (const event of events) { + await autoApprove(session, event); + if (event.type === "timeline") { + timeline.push(event.item); + } + if (event.type === "turn_completed" || event.type === "turn_failed") { + break; + } + } + + const toolCalls = timeline.filter( + (item): item is ToolCallItem => item.type === "tool_call" + ); + + // There should be at least one Task tool call + const taskCalls = toolCalls.filter((item) => item.name === "Task"); + expect(taskCalls.length).toBeGreaterThanOrEqual(1); + + // Sub-agent tool calls (Read, Bash, shell, etc.) should NOT appear as + // separate timeline items — they should only appear as Task metadata updates + const subAgentLeaks = toolCalls.filter( + (item) => item.name !== "Task" && item.metadata?.subAgentActivity === undefined + ); + // If there are non-Task tool calls, they must be from the main agent, not sub-agent + // We can't 100% guarantee Claude won't also use tools directly, but Task metadata + // updates should exist for the sub-agent activity + const taskWithMetadata = taskCalls.filter( + (item) => item.metadata?.subAgentActivity + ); + if (taskCalls.length > 0) { + expect(taskWithMetadata.length).toBeGreaterThanOrEqual(1); + } + + // Verify the curator produces clean output with collapsed Task entries + const curated = curateAgentActivity(timeline); + const lines = curated.split("\n"); + const taskLines = lines.filter((l) => l.includes("[Task]")); + // Each Task callId should appear at most once in curated output + expect(taskLines.length).toBeLessThanOrEqual(taskCalls.length); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, + 180_000 + ); }); describe("convertClaudeHistoryEntry", () => { @@ -1148,6 +1211,42 @@ describe("convertClaudeHistoryEntry", () => { }, ]); }); + + test("converts compact_boundary entry to compaction timeline item", () => { + const entry = { + type: "system", + subtype: "compact_boundary", + content: "Conversation compacted", + compactMetadata: { trigger: "auto", preTokens: 168428 }, + }; + + const result = convertClaudeHistoryEntry(entry, () => []); + + expect(result).toEqual([ + { + type: "compaction", + status: "completed", + trigger: "auto", + preTokens: 168428, + }, + ]); + }); + + test("skips isCompactSummary user entries", () => { + const entry = { + type: "user", + isCompactSummary: true, + isVisibleInTranscriptOnly: true, + message: { + role: "user", + content: "This session is being continued from a previous conversation...", + }, + }; + + const result = convertClaudeHistoryEntry(entry, () => []); + + expect(result).toEqual([]); + }); }); type StreamHydrationUpdate = { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 7e8608f2e..ac6f62eda 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -481,6 +481,8 @@ class ClaudeAgentSession implements AgentSession { private activeTurnPromise: Promise | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null; private lastOptionsModel: string | null = null; + private activeSidechains = new Map(); + private compacting = false; constructor( config: ClaudeAgentConfig, @@ -1120,16 +1122,103 @@ class ClaudeAgentSession implements AgentSession { } } + private handleSidechainMessage( + message: SDKMessage, + parentToolUseId: string + ): AgentStreamEvent[] { + let toolName: string | undefined; + + if (message.type === "assistant") { + const content = message.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (isClaudeContentChunk(block) && + (block.type === "tool_use" || block.type === "mcp_tool_use" || block.type === "server_tool_use") && + typeof block.name === "string" + ) { + toolName = block.name; + break; + } + } + } + } else if (message.type === "stream_event") { + const event = message.event; + if (event.type === "content_block_start") { + const cb = isClaudeContentChunk(event.content_block) ? event.content_block : null; + if (cb?.type === "tool_use" && typeof cb.name === "string") { + toolName = cb.name; + } + } + } else if (message.type === "tool_progress") { + toolName = message.tool_name; + } + + if (!toolName) { + return []; + } + + const prev = this.activeSidechains.get(parentToolUseId); + if (prev === toolName) { + return []; + } + this.activeSidechains.set(parentToolUseId, toolName); + + return [{ + type: "timeline", + item: { + type: "tool_call", + name: "Task", + callId: parentToolUseId, + metadata: { subAgentActivity: toolName }, + }, + provider: "claude", + }]; + } + private translateMessageToEvents(message: SDKMessage, turnContext: TurnContext): AgentStreamEvent[] { + const parentToolUseId = "parent_tool_use_id" in message + ? (message as { parent_tool_use_id: string | null }).parent_tool_use_id + : null; + if (parentToolUseId) { + return this.handleSidechainMessage(message, parentToolUseId); + } + const events: AgentStreamEvent[] = []; switch (message.type) { case "system": if (message.subtype === "init") { this.handleSystemMessage(message); + } else if (message.subtype === "status") { + const status = (message as { status?: string }).status; + if (status === "compacting") { + this.compacting = true; + events.push({ + type: "timeline", + item: { type: "compaction", status: "loading" }, + provider: "claude", + }); + } + } else if (message.subtype === "compact_boundary") { + const meta = (message as Record).compact_metadata as + { trigger?: string; pre_tokens?: number } | undefined; + events.push({ + type: "timeline", + item: { + type: "compaction", + status: "completed", + trigger: meta?.trigger === "manual" ? "manual" : "auto", + preTokens: meta?.pre_tokens, + }, + provider: "claude", + }); } break; case "user": { + if (this.compacting) { + this.compacting = false; + break; + } const content = message.message?.content; if (typeof content === "string" && content.length > 0) { // String content from user messages (e.g., local command output) @@ -1916,6 +2005,19 @@ export function convertClaudeHistoryEntry( entry: any, mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[] ): AgentTimelineItem[] { + if (entry.type === "system" && entry.subtype === "compact_boundary") { + return [{ + type: "compaction", + status: "completed", + trigger: entry.compactMetadata?.trigger === "manual" ? "manual" : "auto", + preTokens: entry.compactMetadata?.preTokens, + }]; + } + + if (entry.isCompactSummary) { + return []; + } + const message = entry?.message; if (!message || !("content" in message)) { return []; diff --git a/packages/server/src/server/test-utils/claude-auth.ts b/packages/server/src/server/test-utils/claude-auth.ts index 3b2e630f3..544620576 100644 --- a/packages/server/src/server/test-utils/claude-auth.ts +++ b/packages/server/src/server/test-utils/claude-auth.ts @@ -11,25 +11,23 @@ import path from "path"; * @throws Error with actionable message if Claude credentials are unavailable via environment */ export function seedClaudeAuth(targetDir: string): void { - // Only use credentials from environment variables - const sessionTokenEnv = process.env.CLAUDE_SESSION_TOKEN; + const oauthTokenEnv = process.env.CLAUDE_CODE_OAUTH_TOKEN; const apiKeyEnv = process.env.ANTHROPIC_API_KEY; - if (!sessionTokenEnv && !apiKeyEnv) { + if (!oauthTokenEnv && !apiKeyEnv) { throw new Error( "Claude credentials not found in environment. Please provide credentials via:\n" + - " Environment variables: CLAUDE_SESSION_TOKEN or ANTHROPIC_API_KEY\n" + + " Environment variables: CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY\n" + "\n" + - "For CI: Set CLAUDE_SESSION_TOKEN or ANTHROPIC_API_KEY in GitHub Actions secrets\n" + + "For CI: Set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in GitHub Actions secrets\n" + "For local development: Set these environment variables before running tests" ); } - // Create credentials from environment variables const credentials: Record = {}; - if (sessionTokenEnv) { - credentials.sessionToken = sessionTokenEnv; + if (oauthTokenEnv) { + credentials.oauthToken = oauthTokenEnv; } if (apiKeyEnv) { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index d056503d8..02394498d 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -170,6 +170,7 @@ export const AgentTimelineItemPayloadSchema: z.ZodType = input: z.unknown().optional(), output: z.unknown().optional(), error: z.unknown().optional(), + metadata: z.record(z.unknown()).optional(), }), z.object({ type: z.literal("todo"), @@ -184,6 +185,12 @@ export const AgentTimelineItemPayloadSchema: z.ZodType = type: z.literal("error"), message: z.string(), }), + z.object({ + type: z.literal("compaction"), + status: z.enum(["loading", "completed"]), + trigger: z.enum(["auto", "manual"]).optional(), + preTokens: z.number().optional(), + }), ]); export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [ diff --git a/packages/server/src/utils/tool-call-parsers.test.ts b/packages/server/src/utils/tool-call-parsers.test.ts index 983c9add5..e82a98068 100644 --- a/packages/server/src/utils/tool-call-parsers.test.ts +++ b/packages/server/src/utils/tool-call-parsers.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "vitest"; import { stripShellWrapperPrefix, - extractPrincipalParam, normalizeToolDisplayName, stripCwdPrefix, + parseToolCallDisplay, } from "./tool-call-parsers.js"; describe("stripShellWrapperPrefix", () => { @@ -37,57 +37,22 @@ describe("stripShellWrapperPrefix", () => { expect(stripShellWrapperPrefix(command)).toBe("npm run build"); }); - test("returns command unchanged for partial match", () => { + test("strips shell prefix even without cd", () => { const command = "/bin/zsh -lc npm run build"; - expect(stripShellWrapperPrefix(command)).toBe("/bin/zsh -lc npm run build"); + expect(stripShellWrapperPrefix(command)).toBe("npm run build"); }); test("strips when cd path includes spaces in quotes", () => { const command = '/bin/zsh -lc cd "/path with spaces" && npm test'; expect(stripShellWrapperPrefix(command)).toBe("npm test"); }); -}); -describe("extractPrincipalParam", () => { - test("extracts and strips shell wrapper from command string", () => { - const args = { command: "/bin/zsh -lc cd /Users/dev/project && npm run format" }; - expect(extractPrincipalParam(args)).toBe("npm run format"); - }); - - test("extracts and strips shell wrapper from command array", () => { - const args = { command: ["/bin/bash", "-lc", "cd /path && git status"] }; - // Array is joined with spaces first: "/bin/bash -lc cd /path && git status" - // Then shell wrapper is stripped - expect(extractPrincipalParam(args)).toBe("git status"); - }); - - test("extracts plain command without shell wrapper", () => { - const args = { command: "npm run build" }; - expect(extractPrincipalParam(args)).toBe("npm run build"); - }); - - test("extracts file_path and strips cwd", () => { - const args = { file_path: "/Users/dev/project/src/file.ts" }; - expect(extractPrincipalParam(args, "/Users/dev/project")).toBe("src/file.ts"); - }); - - test("extracts pattern without modification", () => { - const args = { pattern: "*.ts" }; - expect(extractPrincipalParam(args)).toBe("*.ts"); - }); - - test("extracts query without modification", () => { - const args = { query: "search term" }; - expect(extractPrincipalParam(args)).toBe("search term"); - }); - - test("extracts url without modification", () => { - const args = { url: "https://example.com" }; - expect(extractPrincipalParam(args)).toBe("https://example.com"); - }); - - test("returns undefined for empty object", () => { - expect(extractPrincipalParam({})).toBeUndefined(); + test("strips /bin/zsh -lc with quoted complex command", () => { + const command = + '/bin/zsh -lc "adb shell pm list packages | rg \'sh\\.paseo(\\.dev)?\' && adb shell cmd package resolve-activity --brief sh.paseo.dev | tail -n 1"'; + expect(stripShellWrapperPrefix(command)).toBe( + "adb shell pm list packages | rg 'sh\\.paseo(\\.dev)?' && adb shell cmd package resolve-activity --brief sh.paseo.dev | tail -n 1" + ); }); }); @@ -128,3 +93,193 @@ describe("stripCwdPrefix", () => { expect(stripCwdPrefix("/Users/dev/project/file.ts")).toBe("/Users/dev/project/file.ts"); }); }); + +describe("parseToolCallDisplay", () => { + describe("summary (was extractPrincipalParam)", () => { + test("extracts and strips shell wrapper from command string", () => { + const result = parseToolCallDisplay({ + name: "Bash", + input: { command: "/bin/zsh -lc cd /Users/dev/project && npm run format" }, + }); + expect(result.summary).toBe("npm run format"); + }); + + test("extracts and strips shell wrapper from command array", () => { + const result = parseToolCallDisplay({ + name: "shell", + input: { command: ["/bin/bash", "-lc", "cd /path && git status"] }, + }); + expect(result.summary).toBe("git status"); + }); + + test("extracts plain command without shell wrapper", () => { + const result = parseToolCallDisplay({ + name: "Bash", + input: { command: "npm run build" }, + }); + expect(result.summary).toBe("npm run build"); + }); + + test("extracts file_path and strips cwd", () => { + const result = parseToolCallDisplay({ + name: "Read", + input: { file_path: "/Users/dev/project/src/file.ts" }, + cwd: "/Users/dev/project", + }); + expect(result.summary).toBe("src/file.ts"); + }); + + test("extracts pattern without modification", () => { + const result = parseToolCallDisplay({ + name: "Grep", + input: { pattern: "*.ts" }, + }); + expect(result.summary).toBe("*.ts"); + }); + + test("extracts query without modification", () => { + const result = parseToolCallDisplay({ + name: "WebSearch", + input: { query: "search term" }, + }); + expect(result.summary).toBe("search term"); + }); + + test("extracts url without modification", () => { + const result = parseToolCallDisplay({ + name: "WebFetch", + input: { url: "https://example.com" }, + }); + expect(result.summary).toBe("https://example.com"); + }); + + test("returns undefined summary for empty input", () => { + const result = parseToolCallDisplay({ name: "Unknown", input: {} }); + expect(result.summary).toBeUndefined(); + }); + + test("extracts description from Task tool", () => { + const result = parseToolCallDisplay({ + name: "Task", + input: { description: "Explore the codebase" }, + }); + expect(result.summary).toBe("Explore the codebase"); + }); + }); + + describe("summary from metadata", () => { + test("subAgentActivity in metadata takes priority over input", () => { + const result = parseToolCallDisplay({ + name: "Task", + input: { description: "Explore codebase" }, + metadata: { subAgentActivity: "Read" }, + }); + expect(result.summary).toBe("Read"); + }); + + test("metadata merges with input for summary parsing", () => { + const result = parseToolCallDisplay({ + name: "Task", + input: {}, + metadata: { subAgentActivity: "Bash" }, + }); + expect(result.summary).toBe("Bash"); + }); + }); + + describe("kind", () => { + test("Read -> read", () => { + expect(parseToolCallDisplay({ name: "Read" }).kind).toBe("read"); + }); + + test("read_file -> read", () => { + expect(parseToolCallDisplay({ name: "read_file" }).kind).toBe("read"); + }); + + test("Edit -> edit", () => { + expect(parseToolCallDisplay({ name: "Edit" }).kind).toBe("edit"); + }); + + test("Write -> edit", () => { + expect(parseToolCallDisplay({ name: "Write" }).kind).toBe("edit"); + }); + + test("apply_patch -> edit", () => { + expect(parseToolCallDisplay({ name: "apply_patch" }).kind).toBe("edit"); + }); + + test("Bash -> execute", () => { + expect(parseToolCallDisplay({ name: "Bash" }).kind).toBe("execute"); + }); + + test("shell -> execute", () => { + expect(parseToolCallDisplay({ name: "shell" }).kind).toBe("execute"); + }); + + test("Grep -> search", () => { + expect(parseToolCallDisplay({ name: "Grep" }).kind).toBe("search"); + }); + + test("Glob -> search", () => { + expect(parseToolCallDisplay({ name: "Glob" }).kind).toBe("search"); + }); + + test("thinking -> thinking", () => { + expect(parseToolCallDisplay({ name: "thinking" }).kind).toBe("thinking"); + }); + + test("unknown tool -> tool", () => { + expect(parseToolCallDisplay({ name: "MyCustomTool" }).kind).toBe("tool"); + }); + }); + + describe("displayName", () => { + test("shell -> Shell", () => { + expect(parseToolCallDisplay({ name: "shell" }).displayName).toBe("Shell"); + }); + + test("Bash -> Shell", () => { + expect(parseToolCallDisplay({ name: "Bash" }).displayName).toBe("Shell"); + }); + + test("read_file -> Read", () => { + expect(parseToolCallDisplay({ name: "read_file" }).displayName).toBe("Read"); + }); + + test("apply_patch -> Edit", () => { + expect(parseToolCallDisplay({ name: "apply_patch" }).displayName).toBe("Edit"); + }); + + test("preserves unknown tool names", () => { + expect(parseToolCallDisplay({ name: "MyCustomTool" }).displayName).toBe("MyCustomTool"); + }); + + test("normalizes speak variants", () => { + expect(parseToolCallDisplay({ name: "paseo_voice.speak" }).displayName).toBe("Speak"); + expect(parseToolCallDisplay({ name: "mcp__paseo_voice__speak" }).displayName).toBe("Speak"); + }); + }); + + describe("errorText", () => { + test("formats string error", () => { + const result = parseToolCallDisplay({ + name: "Bash", + error: "something broke", + }); + expect(result.errorText).toBe("something broke"); + }); + + test("extracts content from tool_result error", () => { + const result = parseToolCallDisplay({ + name: "Bash", + error: { type: "tool_result", content: "Exit code 1", is_error: true }, + }); + expect(result.errorText).toBe("Exit code 1"); + }); + + test("returns undefined for no error", () => { + const result = parseToolCallDisplay({ name: "Read" }); + expect(result.errorText).toBeUndefined(); + }); + }); +}); diff --git a/packages/server/src/utils/tool-call-parsers.ts b/packages/server/src/utils/tool-call-parsers.ts index e90a2f218..791438822 100644 --- a/packages/server/src/utils/tool-call-parsers.ts +++ b/packages/server/src/utils/tool-call-parsers.ts @@ -1,45 +1,69 @@ +import stripAnsi from "strip-ansi"; import { z } from "zod"; -// ---- Principal Parameter Extraction ---- +// ---- Tool Call Kind (icon category) ---- -// Schema for file entries in arrays (e.g., apply_patch files) -const FileEntrySchema = z.object({ path: z.string() }); +export type ToolCallKind = "read" | "edit" | "execute" | "search" | "thinking" | "agent" | "tool"; -// Schema for TodoWrite todos array -const TodoEntrySchema = z.object({ - content: z.string(), - status: z.enum(["pending", "in_progress", "completed"]), - activeForm: z.string().optional(), -}); +const TOOL_KIND_MAP: Record = { + read: "read", + read_file: "read", + edit: "edit", + write: "edit", + apply_patch: "edit", + bash: "execute", + shell: "execute", + grep: "search", + glob: "search", + web_search: "search", + thinking: "thinking", + task: "agent", +}; -const PrincipalParamSchema = z.union([ - // Direct path keys - z.object({ file_path: z.string() }).transform((d) => ({ type: "path" as const, value: d.file_path })), - z.object({ filePath: z.string() }).transform((d) => ({ type: "path" as const, value: d.filePath })), - z.object({ path: z.string() }).transform((d) => ({ type: "path" as const, value: d.path })), - // Command as string - z.object({ command: z.string() }).transform((d) => ({ type: "command" as const, value: d.command })), - // Command as array (Codex sends this) - z.object({ command: z.array(z.string()).nonempty() }).transform((d) => ({ type: "command" as const, value: d.command.join(" ") })), - // Other text params - z.object({ title: z.string() }).transform((d) => ({ type: "text" as const, value: d.title })), - z.object({ name: z.string() }).transform((d) => ({ type: "text" as const, value: d.name })), - z.object({ branch: z.string() }).transform((d) => ({ type: "text" as const, value: d.branch })), - z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })), - z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })), - z.object({ url: z.string() }).transform((d) => ({ type: "text" as const, value: d.url })), - z.object({ text: z.string() }).transform((d) => ({ type: "text" as const, value: d.text })), - // Files array (Codex apply_patch) - z.object({ files: z.array(FileEntrySchema).nonempty() }).transform((d) => ({ type: "path" as const, value: d.files[0].path })), - // TodoWrite - show in_progress item or count - z.object({ todos: z.array(TodoEntrySchema).nonempty() }).transform((d) => { - const inProgress = d.todos.find((t) => t.status === "in_progress"); - if (inProgress) { - return { type: "text" as const, value: inProgress.activeForm ?? inProgress.content }; - } - return { type: "text" as const, value: `${d.todos.length} tasks` }; - }), -]); +// ---- Tool Name Normalization ---- + +const TOOL_NAME_MAP: Record = { + shell: "Shell", + Bash: "Shell", + read_file: "Read", + apply_patch: "Edit", + paseo_worktree_setup: "Setup", + thinking: "Thinking", +}; + +const TOOL_TOKEN_REGEX = /[a-z0-9]+/g; + +export function normalizeToolDisplayName(toolName: string): string { + const normalized = toolName.trim().toLowerCase(); + if (!normalized) { + return toolName; + } + + const tokens = normalized.match(TOOL_TOKEN_REGEX) ?? []; + const leaf = tokens[tokens.length - 1]; + if (leaf === "speak") { + return "Speak"; + } + return toolName; +} + +function resolveDisplayName(rawName: string): string { + return normalizeToolDisplayName(TOOL_NAME_MAP[rawName] ?? rawName); +} + +function resolveKind(rawName: string): ToolCallKind { + const lower = rawName.trim().toLowerCase(); + if (TOOL_KIND_MAP[lower]) { + return TOOL_KIND_MAP[lower]; + } + // Check prefix for read variants (e.g. "read_pdf") + if (lower.startsWith("read")) { + return "read"; + } + return "tool"; +} + +// ---- Path/Command Utilities ---- export function stripCwdPrefix(filePath: string, cwd?: string): string { if (!cwd || !filePath) return filePath; @@ -63,21 +87,6 @@ export function stripCwdPrefix(filePath: string, cwd?: string): string { // This is used for display purposes to show the actual command being run. const SHELL_WRAPPER_PREFIX_PATTERN = /^\/bin\/(?:zsh|bash|sh)\s+(?:-[a-zA-Z]+\s+)?/; const CD_AND_PATTERN = /^cd\s+(?:"[^"]+"|'[^']+'|\S+)\s+&&\s+/; -const TOOL_TOKEN_REGEX = /[a-z0-9]+/g; - -export function normalizeToolDisplayName(toolName: string): string { - const normalized = toolName.trim().toLowerCase(); - if (!normalized) { - return toolName; - } - - const tokens = normalized.match(TOOL_TOKEN_REGEX) ?? []; - const leaf = tokens[tokens.length - 1]; - if (leaf === "speak") { - return "Speak"; - } - return toolName; -} export function stripShellWrapperPrefix(command: string): string { const prefixMatch = command.match(SHELL_WRAPPER_PREFIX_PATTERN); @@ -94,16 +103,67 @@ export function stripShellWrapperPrefix(command: string): string { } } - const stripped = rest.replace(CD_AND_PATTERN, ""); - if (stripped !== rest) { - return stripped; - } - - return command; + return rest.replace(CD_AND_PATTERN, ""); } -export function extractPrincipalParam(args: unknown, cwd?: string): string | undefined { - const parsed = PrincipalParamSchema.safeParse(args); +// ---- Summary Schema (was PrincipalParamSchema) ---- + +const FileEntrySchema = z.object({ path: z.string() }); + +const TodoEntrySchema = z.object({ + content: z.string(), + status: z.enum(["pending", "in_progress", "completed"]), + activeForm: z.string().optional(), +}); + +const SummarySchema = z.union([ + // Sub-agent activity (from tool call metadata, highest priority) + z.object({ subAgentActivity: z.string() }).transform((d) => ({ type: "text" as const, value: d.subAgentActivity })), + // Direct path keys + z.object({ file_path: z.string() }).transform((d) => ({ type: "path" as const, value: d.file_path })), + z.object({ filePath: z.string() }).transform((d) => ({ type: "path" as const, value: d.filePath })), + z.object({ path: z.string() }).transform((d) => ({ type: "path" as const, value: d.path })), + // Command as string + z.object({ command: z.string() }).transform((d) => ({ type: "command" as const, value: d.command })), + // Command as array (Codex sends this) + z.object({ command: z.array(z.string()).nonempty() }).transform((d) => ({ type: "command" as const, value: d.command.join(" ") })), + // Task tool description (short summary) + z.object({ description: z.string() }).transform((d) => ({ type: "text" as const, value: d.description })), + // Other text params + z.object({ title: z.string() }).transform((d) => ({ type: "text" as const, value: d.title })), + z.object({ name: z.string() }).transform((d) => ({ type: "text" as const, value: d.name })), + z.object({ branch: z.string() }).transform((d) => ({ type: "text" as const, value: d.branch })), + z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })), + z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })), + z.object({ url: z.string() }).transform((d) => ({ type: "text" as const, value: d.url })), + z.object({ text: z.string() }).transform((d) => ({ type: "text" as const, value: d.text })), + // Files array (Codex apply_patch) + z.object({ files: z.array(FileEntrySchema).nonempty() }).transform((d) => ({ type: "path" as const, value: d.files[0].path })), + // TodoWrite - show in_progress item or count + z.object({ todos: z.array(TodoEntrySchema).nonempty() }).transform((d) => { + const inProgress = d.todos.find((t) => t.status === "in_progress"); + if (inProgress) { + return { type: "text" as const, value: inProgress.activeForm ?? inProgress.content }; + } + return { type: "text" as const, value: `${d.todos.length} tasks` }; + }), +]); + +const RecordSchema = z.record(z.unknown()); + +function extractSummary(input: unknown, metadata: Record | undefined, cwd: string | undefined): string | undefined { + // Merge input + metadata into one object for the summary schema to match against. + // metadata fields take priority (e.g. subAgentActivity overrides input fields). + const inputRecord = RecordSchema.safeParse(input); + const merged = metadata + ? { ...(inputRecord.success ? inputRecord.data : {}), ...metadata } + : inputRecord.success ? inputRecord.data : undefined; + + if (!merged) { + return undefined; + } + + const parsed = SummarySchema.safeParse(merged); if (!parsed.success) { return undefined; } @@ -118,6 +178,401 @@ export function extractPrincipalParam(args: unknown, cwd?: string): string | und return value; } +// ---- Detail Schemas ---- + +export interface KeyValuePair { + key: string; + value: string; +} + +function stringifyValue(value: unknown): string { + if (value === null) { + return "null"; + } + if (value === undefined) { + return "undefined"; + } + if (value === "") { + return ""; + } + const str = z.string().safeParse(value); + if (str.success) { + return str.data; + } + const num = z.number().safeParse(value); + if (num.success) { + return String(num.data); + } + const bool = z.boolean().safeParse(value); + if (bool.success) { + return String(bool.data); + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +const KeyValuePairsSchema = z.record(z.unknown()).transform((data) => + Object.entries(data).map(([key, value]) => ({ + key, + value: stringifyValue(value), + })) +); + +// Shell input: { command: "pwd", description?: "..." } +const ShellInputSchema = z.object({ + command: z.union([z.string(), z.array(z.string())]), +}).passthrough(); + +// Shell result (when completed): { type: "command", command: "pwd", output: "..." } +const ShellResultSchema = z.object({ + type: z.literal("command"), + output: z.string(), +}).passthrough(); + +// Shell error result: { type: "tool_result", content: "Exit code 128\n...", is_error: true } +const ShellErrorResultSchema = z.object({ + type: z.literal("tool_result"), + content: z.string(), + is_error: z.literal(true), +}).passthrough(); + +const ShellToolCallSchema = z + .object({ + input: ShellInputSchema, + output: z.unknown(), + }) + .transform((data) => { + const commandRaw = Array.isArray(data.input.command) + ? data.input.command.join(" ") + : data.input.command; + const command = stripShellWrapperPrefix(commandRaw); + + const resultParsed = ShellResultSchema.safeParse(data.output); + if (resultParsed.success) { + return { + type: "shell" as const, + command, + output: stripAnsi(resultParsed.data.output), + }; + } + + const errorParsed = ShellErrorResultSchema.safeParse(data.output); + if (errorParsed.success) { + return { + type: "shell" as const, + command, + output: stripAnsi(errorParsed.data.content), + }; + } + + return { + type: "shell" as const, + command, + output: "", + }; + }); + +// Edit input: { file_path: string, old_string: string, new_string: string } +const EditInputSchema = z.union([ + z.object({ + file_path: z.string(), + old_string: z.string(), + new_string: z.string(), + }).passthrough(), + z.object({ + file_path: z.string(), + old_str: z.string(), + new_str: z.string(), + }).passthrough(), +]); + +const EditToolCallSchema = z + .object({ + input: EditInputSchema, + output: z.unknown(), + }) + .transform((data): { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string } => { + const filePath = data.input.file_path; + const oldString = "old_string" in data.input + ? (data.input as { old_string: string }).old_string + : (data.input as { old_str: string }).old_str; + const newString = "new_string" in data.input + ? (data.input as { new_string: string }).new_string + : (data.input as { new_str: string }).new_str; + + return { + type: "edit", + filePath, + oldString, + newString, + }; + }); + +// Codex apply_patch +const ApplyPatchFileKindSchema = z + .union([ + z.string(), + z.object({ + type: z.string().optional(), + move_path: z.string().nullable().optional(), + movePath: z.string().nullable().optional(), + }).passthrough(), + ]) + .optional(); + +const ApplyPatchMovePathSchema = z.object({ + movePath: z.string().optional(), + move_path: z.string().optional(), +}).passthrough(); + +function getApplyPatchMovePath(kind: unknown): string | undefined { + const parsed = ApplyPatchMovePathSchema.safeParse(kind); + if (!parsed.success) { + return undefined; + } + return parsed.data.movePath ?? parsed.data.move_path ?? undefined; +} + +const ApplyPatchInputSchema = z.object({ + files: z.array(z.object({ + path: z.string(), + kind: ApplyPatchFileKindSchema, + })).min(1), +}).passthrough(); + +const ApplyPatchResultSchema = z.object({ + files: z.array(z.object({ + path: z.string(), + patch: z.string().optional(), + kind: ApplyPatchFileKindSchema, + })).optional(), + message: z.string().optional(), + success: z.boolean().optional(), +}).passthrough(); + +const ApplyPatchToolCallSchema = z + .object({ + input: ApplyPatchInputSchema, + output: z.unknown(), + }) + .transform((data): { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string } => { + const firstFile = data.input.files[0]; + const movePath = getApplyPatchMovePath(firstFile.kind); + const filePath = movePath ?? firstFile.path; + + const resultParsed = ApplyPatchResultSchema.safeParse(data.output); + let unifiedDiff: string | undefined; + if (resultParsed.success && resultParsed.data.files) { + const matchPaths = new Set([firstFile.path]); + if (movePath) matchPaths.add(movePath); + + const resultFile = + resultParsed.data.files.find((f) => matchPaths.has(f.path)) ?? + resultParsed.data.files[0]; + unifiedDiff = resultFile?.patch; + } + + return { + type: "edit", + filePath, + oldString: "", + newString: "", + unifiedDiff, + }; + }); + +// Read (Claude): { file_path: string, offset?: number, limit?: number } +const ReadInputSchema = z.object({ + file_path: z.string(), + offset: z.number().optional(), + limit: z.number().optional(), +}).passthrough(); + +const ReadResultSchema = z.object({ + type: z.literal("file_read"), + filePath: z.string(), + content: z.string(), +}).passthrough(); + +const ReadToolCallSchema = z + .object({ + input: ReadInputSchema, + output: ReadResultSchema, + }) + .transform((data): { type: "read"; filePath: string; content: string; offset?: number; limit?: number } => ({ + type: "read", + filePath: data.input.file_path, + content: data.output.content, + offset: data.input.offset, + limit: data.input.limit, + })); + +// Codex read_file: { path: string } +const CodexReadInputSchema = z.object({ + path: z.string(), +}).passthrough(); + +const CodexReadResultSchema = z.object({ + type: z.literal("read_file"), + path: z.string(), + content: z.string(), +}).passthrough(); + +const CodexReadToolCallSchema = z + .object({ + input: CodexReadInputSchema, + output: CodexReadResultSchema, + }) + .transform((data): { type: "read"; filePath: string; content: string; offset?: number; limit?: number } => ({ + type: "read", + filePath: data.input.path, + content: data.output.content, + })); + +// Thinking: input is the thinking text content +const ThinkingInputSchema = z.string(); + +const ThinkingToolCallSchema = z + .object({ + input: ThinkingInputSchema, + }) + .transform((data) => ({ + type: "thinking" as const, + content: data.input, + })); + +// Generic tool call (fallback) +const GenericToolCallSchema = z + .object({ + input: z.unknown(), + output: z.unknown(), + }) + .transform((data) => { + const inputPairs = KeyValuePairsSchema.safeParse(data.input); + const outputPairs = KeyValuePairsSchema.safeParse(data.output); + + return { + type: "generic" as const, + input: inputPairs.success ? inputPairs.data : [], + output: outputPairs.success ? outputPairs.data : [], + }; + }); + +// ---- Detail type ---- + +export type ToolCallDetail = + | { type: "shell"; command: string; output: string } + | { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string } + | { type: "read"; filePath: string; content: string; offset?: number; limit?: number } + | { type: "thinking"; content: string } + | { type: "generic"; input: KeyValuePair[]; output: KeyValuePair[] }; + +function parseDetail(toolName: string, input: unknown, output: unknown): ToolCallDetail { + // Thinking is matched by tool name since input is a string, not an object + if (toolName === "thinking") { + const thinkingParsed = ThinkingToolCallSchema.safeParse({ input }); + if (thinkingParsed.success) { + return thinkingParsed.data; + } + return { type: "thinking", content: "" }; + } + + const shellParsed = ShellToolCallSchema.safeParse({ input, output }); + if (shellParsed.success) { + return shellParsed.data; + } + + const editParsed = EditToolCallSchema.safeParse({ input, output }); + if (editParsed.success) { + return editParsed.data; + } + + const applyPatchParsed = ApplyPatchToolCallSchema.safeParse({ input, output }); + if (applyPatchParsed.success) { + return applyPatchParsed.data; + } + + const readParsed = ReadToolCallSchema.safeParse({ input, output }); + if (readParsed.success) { + return readParsed.data; + } + + const codexReadParsed = CodexReadToolCallSchema.safeParse({ input, output }); + if (codexReadParsed.success) { + return codexReadParsed.data; + } + + const genericParsed = GenericToolCallSchema.parse({ input, output }); + return genericParsed; +} + +// ---- Error formatting ---- + +const ToolResultErrorSchema = z.object({ + type: z.literal("tool_result"), + content: z.string(), +}).passthrough(); + +function formatError(error: unknown): string | undefined { + if (error === undefined || error === null) { + return undefined; + } + + const str = z.string().safeParse(error); + if (str.success) { + return str.data; + } + + const toolResult = ToolResultErrorSchema.safeParse(error); + if (toolResult.success) { + return toolResult.data.content; + } + + try { + return JSON.stringify(error, null, 2); + } catch { + return String(error); + } +} + +// ---- Unified ToolCallDisplayInfo ---- + +export interface ToolCallInput { + name: string; + input?: unknown; + output?: unknown; + error?: unknown; + metadata?: Record; + cwd?: string; +} + +export interface ToolCallDisplayInfo { + displayName: string; + kind: ToolCallKind; + summary?: string; + detail: ToolCallDetail; + errorText?: string; +} + +export function parseToolCallDisplay(toolCall: ToolCallInput): ToolCallDisplayInfo { + const displayName = resolveDisplayName(toolCall.name); + const kind = resolveKind(toolCall.name); + const summary = extractSummary(toolCall.input, toolCall.metadata, toolCall.cwd); + const detail = parseDetail(toolCall.name, toolCall.input, toolCall.output); + const errorText = formatError(toolCall.error); + + return { + displayName, + kind, + summary, + detail, + errorText, + }; +} + // ---- TodoWrite Extraction ---- export interface TodoItem { @@ -127,18 +582,17 @@ export interface TodoItem { } export function extractTodos(value: unknown): TodoItem[] { - if (typeof value !== "object" || value === null) { + const parsed = z.object({ + todos: z.array(z.object({ + content: z.string(), + status: z.enum(["pending", "in_progress", "completed"]), + activeForm: z.string().optional(), + })), + }).safeParse(value); + + if (!parsed.success) { return []; } - const obj = value as Record; - if (!Array.isArray(obj.todos)) { - return []; - } - return obj.todos.filter( - (t): t is TodoItem => - typeof t === "object" && - t !== null && - typeof (t as Record).content === "string" && - typeof (t as Record).status === "string" - ); + + return parsed.data.todos; }