Unified tool call display schema, compaction timeline, and auth fixes

- Replace extractPrincipalParam, parseToolCallDisplay, getToolKindFromName
  with a single unified parseToolCallDisplay on the server, consumed by
  both server (activity-curator) and app
- Parse tool calls exactly once and pass ToolCallDisplayInfo down to all
  consumers (message, sheet, details, stream view)
- Add compaction timeline item: detect SDKStatusMessage (compacting) and
  SDKCompactBoundaryMessage, show loading/completed marker in timeline
- Suppress compact summary user message in live stream (flag) and JSONL
  resume (isCompactSummary check)
- Add CompactionMarker component with scissors icon and token count
- Hide sub-agent tool calls from timeline, collapse into Task metadata
- Add Bot icon for Task tool calls
- Fix stripShellWrapperPrefix to strip /bin/zsh -lc wrappers without cd
- Replace CLAUDE_SESSION_TOKEN with CLAUDE_CODE_OAUTH_TOKEN
This commit is contained in:
Mohamed Boudra
2026-02-07 21:13:24 +07:00
parent 598a1823ae
commit e3fad77d5b
18 changed files with 1470 additions and 867 deletions
@@ -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 (
<CompactionMarker
status={item.status}
preTokens={item.preTokens}
/>
);
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 ? (
<ToolCallDetailsContent display={toolCallDisplay} maxHeight={200} />
<ToolCallDetailsContent detail={toolCallDisplay.detail} maxHeight={200} />
) : null}
<Text
+76 -44
View File
@@ -3,6 +3,7 @@ import {
Text,
Pressable,
Animated,
ActivityIndicator,
StyleProp,
ViewStyle,
Platform,
@@ -38,8 +39,10 @@ import {
SquareTerminal,
Search,
Brain,
Bot,
Copy,
TriangleAlertIcon,
Scissors,
} from "lucide-react-native";
import {
StyleSheet,
@@ -53,16 +56,13 @@ import {
import { Colors, Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry } from "@/types/stream";
import { extractPrincipalParam } from "@/utils/tool-call-parsers";
import { parseToolCallDisplay, type ToolCallKind } from "@/utils/tool-call-parsers";
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
export type { InlinePathTarget } from "@/utils/inline-path";
import { resolveToolCallPreview } from "./tool-call-preview";
import { useToolCallSheet } from "./tool-call-sheet";
import {
ToolCallDetailsContent,
useToolCallDetails,
} from "./tool-call-details";
import { ToolCallDetailsContent } from "./tool-call-details";
interface UserMessageProps {
message: string;
@@ -767,6 +767,63 @@ export const ActivityLog = memo(function ActivityLog({
);
});
interface CompactionMarkerProps {
status: "loading" | "completed";
preTokens?: number;
}
const compactionStylesheet = StyleSheet.create((theme) => ({
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 (
<View style={compactionStylesheet.container}>
<View style={compactionStylesheet.line} />
<View style={compactionStylesheet.label}>
{status === "loading" ? (
<ActivityIndicator size="small" color="#a1a1aa" />
) : (
<Scissors size={12} color="#a1a1aa" />
)}
<Text style={compactionStylesheet.text}>{label}</Text>
</View>
<View style={compactionStylesheet.line} />
</View>
);
});
interface TodoListCardProps {
items: TodoEntry[];
disableOuterSpacing?: boolean;
@@ -1038,6 +1095,7 @@ interface ToolCallProps {
error?: any;
status: "executing" | "completed" | "failed";
cwd?: string;
metadata?: Record<string, unknown>;
isLastInSequence?: boolean;
disableOuterSpacing?: boolean;
onInlineDetailsHoverChange?: (hovered: boolean) => void;
@@ -1051,23 +1109,11 @@ const toolKindIcons: Record<string, any> = {
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 <ToolCallDetailsContent display={display} errorText={errorText} maxHeight={400} />;
}, [isMobile, display, errorText]);
return <ToolCallDetailsContent detail={detail} errorText={errorText} maxHeight={400} />;
}, [isMobile, detail, errorText]);
return (
<ExpandableBadge
testID="tool-call-badge"
label={display.toolName}
secondaryLabel={principalParam}
label={displayName}
secondaryLabel={summary}
icon={IconComponent}
isExpanded={!isMobile && isExpanded}
onToggle={hasDetails ? handleToggle : undefined}
+26 -180
View File
@@ -3,169 +3,46 @@ import { View, Text, Platform, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
import {
parseToolCallDisplay,
buildLineDiff,
parseUnifiedDiff,
type ToolCallDisplay,
type ToolCallDetail,
} from "@/utils/tool-call-parsers";
import { DiffViewer } from "./diff-viewer";
import { getCodeInsets } from "./code-insets";
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
// ---- Types ----
export interface ToolCallDetailsData {
toolName: string;
args?: unknown;
result?: unknown;
error?: unknown;
}
const TOOL_CALL_DETAILS_LOG_TAG = "[ToolCallDetails]";
const TOOL_CALL_DETAILS_DURATION_THRESHOLD_MS = 8;
const TOOL_CALL_DETAILS_SIZE_THRESHOLD = 20000;
type ToolCallDisplaySummary = {
displayType: ToolCallDisplay["type"];
totalChars: number;
detail: Record<string, unknown>;
};
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(
<View key="shell" style={styles.section}>
@@ -194,7 +71,7 @@ export function ToolCallDetailsContent({
</View>
</View>
);
} else if (display.type === "edit") {
} else if (detail.type === "edit") {
sections.push(
<View key="edit" style={styles.section}>
{diffLines ? (
@@ -204,17 +81,17 @@ export function ToolCallDetailsContent({
) : null}
</View>
);
} else if (display.type === "read") {
} else if (detail.type === "read") {
sections.push(
<View key="read" style={styles.section}>
{(display.offset !== undefined || display.limit !== undefined) ? (
{(detail.offset !== undefined || detail.limit !== undefined) ? (
<Text style={styles.rangeText}>
{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}` : ""}
</Text>
) : null}
{display.content ? (
{detail.content ? (
<ScrollView
style={[styles.scrollArea, { maxHeight }]}
contentContainerStyle={styles.scrollContent}
@@ -226,13 +103,13 @@ export function ToolCallDetailsContent({
nestedScrollEnabled
showsHorizontalScrollIndicator={true}
>
<Text selectable style={styles.scrollText}>{display.content}</Text>
<Text selectable style={styles.scrollText}>{detail.content}</Text>
</ScrollView>
</ScrollView>
) : null}
</View>
);
} else if (display.type === "thinking") {
} else if (detail.type === "thinking") {
// Thinking: display the content as plain text
sections.push(
<View key="thinking" style={styles.section}>
@@ -242,19 +119,19 @@ export function ToolCallDetailsContent({
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<Text selectable style={styles.scrollText}>{display.content}</Text>
<Text selectable style={styles.scrollText}>{detail.content}</Text>
</ScrollView>
</View>
);
} else {
// Generic tool: show input/output as key-value pairs
if (display.input.length > 0) {
if (detail.input.length > 0) {
sections.push(
<View key="input-header" style={styles.groupHeader}>
<Text style={styles.groupHeaderText}>Input</Text>
</View>
);
display.input.forEach((pair, index) => {
detail.input.forEach((pair, index) => {
sections.push(
<View key={`input-${index}-${pair.key}`} style={styles.section}>
<Text style={styles.sectionTitle}>{pair.key}</Text>
@@ -272,13 +149,13 @@ export function ToolCallDetailsContent({
});
}
if (display.output.length > 0) {
if (detail.output.length > 0) {
sections.push(
<View key="output-header" style={styles.groupHeader}>
<Text style={styles.groupHeaderText}>Output</Text>
</View>
);
display.output.forEach((pair, index) => {
detail.output.forEach((pair, index) => {
sections.push(
<View key={`output-${index}-${pair.key}`} style={styles.section}>
<Text style={styles.sectionTitle}>{pair.key}</Text>
@@ -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) => {
+11 -39
View File
@@ -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<string, React.ComponentType<{ size?: number; color?:
read: Eye,
execute: SquareTerminal,
search: Search,
agent: Bot,
};
// ----- Custom Background Component -----
@@ -140,14 +133,9 @@ interface ToolCallSheetContentProps {
}
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
const { toolName, kind, cwd, args, result, error } = data;
const { kind, displayName, detail, errorText } = data;
const IconComponent = kind
? toolKindIcons[kind.toLowerCase()] || Wrench
: Wrench;
const { display, errorText } = useToolCallDetails({ toolName, args, result, error });
const principalParam = useMemo(() => extractPrincipalParam(args, cwd), [args, cwd]);
const IconComponent = toolKindIcons[kind] || Wrench;
return (
<View style={styles.container}>
@@ -155,16 +143,9 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
<View style={styles.header}>
<View style={styles.headerLeft}>
<IconComponent size={20} color={styles.headerIcon.color} />
<View style={styles.headerTextColumn}>
<Text style={styles.headerTitle} numberOfLines={1}>
{display.toolName}
</Text>
{principalParam ? (
<Text style={styles.headerSubtitle} numberOfLines={1}>
{principalParam}
</Text>
) : null}
</View>
<Text style={styles.headerTitle} numberOfLines={1}>
{displayName}
</Text>
</View>
<Pressable onPress={onClose} style={styles.closeButton}>
<X size={20} color={styles.closeIcon.color} />
@@ -176,7 +157,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
style={styles.content}
contentContainerStyle={styles.contentContainer}
>
<ToolCallDetailsContent display={display} errorText={errorText} />
<ToolCallDetailsContent detail={detail} errorText={errorText} />
</BottomSheetScrollView>
</View>
);
@@ -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],
},
+51 -1
View File
@@ -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<string, unknown>;
}
export type ToolCallPayload =
@@ -141,6 +143,15 @@ export interface ActivityLogItem {
metadata?: Record<string, unknown>;
}
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;
}
+123 -125
View File
@@ -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");
});
});
+18 -341
View File
@@ -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<string, unknown>;
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<string>([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<string, string> = {
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<typeof ToolCallDisplaySchema>;
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";