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;
}