mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -124,15 +124,24 @@ export interface ToolCallTimelineItem {
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
error?: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AgentMcpServerHandle> {
|
||||
},
|
||||
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 = {
|
||||
|
||||
@@ -481,6 +481,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private activeTurnPromise: Promise<void> | null = null;
|
||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||
private lastOptionsModel: string | null = null;
|
||||
private activeSidechains = new Map<string, string>();
|
||||
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<string, unknown>).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 [];
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
|
||||
if (sessionTokenEnv) {
|
||||
credentials.sessionToken = sessionTokenEnv;
|
||||
if (oauthTokenEnv) {
|
||||
credentials.oauthToken = oauthTokenEnv;
|
||||
}
|
||||
|
||||
if (apiKeyEnv) {
|
||||
|
||||
@@ -170,6 +170,7 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
|
||||
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<AgentTimelineItem> =
|
||||
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", [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, ToolCallKind> = {
|
||||
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<string, string> = {
|
||||
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<string, unknown> | 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<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 (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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
if (!Array.isArray(obj.todos)) {
|
||||
return [];
|
||||
}
|
||||
return obj.todos.filter(
|
||||
(t): t is TodoItem =>
|
||||
typeof t === "object" &&
|
||||
t !== null &&
|
||||
typeof (t as Record<string, unknown>).content === "string" &&
|
||||
typeof (t as Record<string, unknown>).status === "string"
|
||||
);
|
||||
|
||||
return parsed.data.todos;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user