Update files
This commit is contained in:
@@ -47,7 +47,6 @@ import type { Agent } from "@/contexts/session-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import { QuestionFormCard } from "./question-form-card";
|
||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||
@@ -363,11 +362,11 @@ export function AgentStreamView({
|
||||
return (
|
||||
<ToolCall
|
||||
toolName={data.name}
|
||||
provider={data.provider}
|
||||
args={data.input}
|
||||
result={data.result}
|
||||
error={data.error}
|
||||
status={data.status as "executing" | "completed" | "failed"}
|
||||
status={data.status}
|
||||
detail={data.detail}
|
||||
cwd={agent.cwd}
|
||||
metadata={data.metadata}
|
||||
isLastInSequence={isLastInSequence}
|
||||
@@ -904,17 +903,6 @@ function PermissionRequestCard({
|
||||
return undefined;
|
||||
}, [request]);
|
||||
|
||||
const toolCallDisplay = useMemo(() => {
|
||||
if (isPlanRequest) {
|
||||
return null;
|
||||
}
|
||||
return parseToolCallDisplay({
|
||||
name: request.name ?? "unknown",
|
||||
provider: request.provider,
|
||||
input: request.input,
|
||||
});
|
||||
}, [isPlanRequest, request.name, request.provider, request.input]);
|
||||
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
|
||||
const markdownRules = useMemo(() => {
|
||||
@@ -1133,8 +1121,12 @@ function PermissionRequestCard({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!isPlanRequest && toolCallDisplay ? (
|
||||
<ToolCallDetailsContent detail={toolCallDisplay.detail} maxHeight={200} />
|
||||
{!isPlanRequest ? (
|
||||
<ToolCallDetailsContent
|
||||
input={request.input ?? null}
|
||||
output={null}
|
||||
maxHeight={200}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Text
|
||||
|
||||
@@ -62,18 +62,22 @@ import {
|
||||
useUnistyles,
|
||||
UnistylesRuntime,
|
||||
} from "react-native-unistyles";
|
||||
import { baseColors, theme } from "@/styles/theme";
|
||||
import { theme } from "@/styles/theme";
|
||||
import {
|
||||
createMarkdownStyles,
|
||||
} from "@/styles/markdown-styles";
|
||||
import { Colors, Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import type { TodoEntry } from "@/types/stream";
|
||||
import { parseToolCallDisplay, type ToolCallKind } from "@/utils/tool-call-parsers";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
buildToolCallDisplayModel,
|
||||
formatToolCallError,
|
||||
type ToolCallKind,
|
||||
} from "@/utils/tool-call-display";
|
||||
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 } from "./tool-call-details";
|
||||
|
||||
@@ -106,6 +110,29 @@ function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
|
||||
return disableOuterSpacing ?? contextValue;
|
||||
}
|
||||
|
||||
function hexToRgba(hexColor: string, alpha: number) {
|
||||
const clampedAlpha = Math.max(0, Math.min(1, alpha));
|
||||
const hex = hexColor.replace("#", "");
|
||||
const normalized =
|
||||
hex.length === 3
|
||||
? hex
|
||||
.split("")
|
||||
.map((char) => `${char}${char}`)
|
||||
.join("")
|
||||
: hex;
|
||||
|
||||
if (!/^[\da-fA-F]{6}$/.test(normalized)) {
|
||||
return `rgba(255, 255, 255, ${clampedAlpha})`;
|
||||
}
|
||||
|
||||
const intValue = Number.parseInt(normalized, 16);
|
||||
const red = (intValue >> 16) & 255;
|
||||
const green = (intValue >> 8) & 255;
|
||||
const blue = intValue & 255;
|
||||
|
||||
return `rgba(${red}, ${green}, ${blue}, ${clampedAlpha})`;
|
||||
}
|
||||
|
||||
const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
@@ -418,8 +445,9 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
shimmerOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
top: 3,
|
||||
bottom: 3,
|
||||
borderRadius: 999,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1026,6 +1054,14 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
}, [isLoading]);
|
||||
|
||||
const shimmerBandWidth = 14;
|
||||
const shimmerGradientColors = useMemo(
|
||||
() => ({
|
||||
edge: hexToRgba(theme.colors.foreground, 0),
|
||||
soft: hexToRgba(theme.colors.foreground, 0.08),
|
||||
core: hexToRgba(theme.colors.foreground, 0.24),
|
||||
}),
|
||||
[theme.colors.foreground]
|
||||
);
|
||||
const shimmerStyle = useAnimatedStyle(() => {
|
||||
const travel = badgeWidth + shimmerBandWidth;
|
||||
return {
|
||||
@@ -1112,13 +1148,13 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
x2="1"
|
||||
y2="0"
|
||||
>
|
||||
<Stop offset="0" stopColor={baseColors.white} stopOpacity="0" />
|
||||
<Stop offset="0.34" stopColor={baseColors.white} stopOpacity="0" />
|
||||
<Stop offset="0.46" stopColor={baseColors.white} stopOpacity="0.12" />
|
||||
<Stop offset="0.5" stopColor={baseColors.white} stopOpacity="0.34" />
|
||||
<Stop offset="0.54" stopColor={baseColors.white} stopOpacity="0.12" />
|
||||
<Stop offset="0.66" stopColor={baseColors.white} stopOpacity="0" />
|
||||
<Stop offset="1" stopColor={baseColors.white} stopOpacity="0" />
|
||||
<Stop offset="0" stopColor={shimmerGradientColors.edge} />
|
||||
<Stop offset="0.38" stopColor={shimmerGradientColors.edge} />
|
||||
<Stop offset="0.48" stopColor={shimmerGradientColors.soft} />
|
||||
<Stop offset="0.5" stopColor={shimmerGradientColors.core} />
|
||||
<Stop offset="0.52" stopColor={shimmerGradientColors.soft} />
|
||||
<Stop offset="0.62" stopColor={shimmerGradientColors.edge} />
|
||||
<Stop offset="1" stopColor={shimmerGradientColors.edge} />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
<Rect width="100%" height="100%" fill="url(#shimmerGrad)" />
|
||||
@@ -1155,11 +1191,11 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
|
||||
interface ToolCallProps {
|
||||
toolName: string;
|
||||
provider?: string;
|
||||
args: any;
|
||||
result?: any;
|
||||
error?: any;
|
||||
status: "executing" | "completed" | "failed";
|
||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||
detail?: ToolCallDetail;
|
||||
cwd?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
isLastInSequence?: boolean;
|
||||
@@ -1183,11 +1219,11 @@ const TOOL_CALL_COMMIT_THRESHOLD_MS = 16;
|
||||
|
||||
export const ToolCall = memo(function ToolCall({
|
||||
toolName,
|
||||
provider,
|
||||
args,
|
||||
result,
|
||||
error,
|
||||
status,
|
||||
detail,
|
||||
cwd,
|
||||
metadata,
|
||||
isLastInSequence = false,
|
||||
@@ -1204,20 +1240,14 @@ export const ToolCall = memo(function ToolCall({
|
||||
UnistylesRuntime.breakpoint === "xs" ||
|
||||
UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
const displayInfo = useMemo(
|
||||
() =>
|
||||
parseToolCallDisplay({
|
||||
name: toolName,
|
||||
provider,
|
||||
input: args,
|
||||
output: result,
|
||||
error,
|
||||
metadata,
|
||||
cwd,
|
||||
}),
|
||||
[toolName, provider, args, result, error, metadata, cwd]
|
||||
const displayModel = useMemo(
|
||||
() => buildToolCallDisplayModel({ name: toolName, detail, metadata, cwd }),
|
||||
[toolName, detail, metadata, cwd]
|
||||
);
|
||||
const { kind, displayName, summary, detail, errorText } = displayInfo;
|
||||
const displayName = displayModel.displayName;
|
||||
const kind: ToolCallKind = displayModel.kind;
|
||||
const summary = displayModel.summary;
|
||||
const errorText = useMemo(() => formatToolCallError(error), [error]);
|
||||
const IconComponent = toolKindIcons[kind] || Wrench;
|
||||
|
||||
// Check if there's any content to display
|
||||
@@ -1229,11 +1259,19 @@ export const ToolCall = memo(function ToolCall({
|
||||
toggleStartRef.current = getNowMs();
|
||||
}
|
||||
if (isMobile) {
|
||||
openToolCall(displayInfo);
|
||||
openToolCall({
|
||||
kind,
|
||||
displayName,
|
||||
summary,
|
||||
detail,
|
||||
input: args,
|
||||
output: result,
|
||||
errorText,
|
||||
});
|
||||
} else {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}
|
||||
}, [isMobile, openToolCall, displayInfo]);
|
||||
}, [isMobile, openToolCall, kind, displayName, summary, detail, args, result, errorText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isPerfLoggingEnabled()) {
|
||||
@@ -1292,8 +1330,16 @@ export const ToolCall = memo(function ToolCall({
|
||||
// Render inline details for desktop
|
||||
const renderDetails = useCallback(() => {
|
||||
if (isMobile) return null;
|
||||
return <ToolCallDetailsContent detail={detail} errorText={errorText} maxHeight={400} />;
|
||||
}, [isMobile, detail, errorText]);
|
||||
return (
|
||||
<ToolCallDetailsContent
|
||||
detail={detail}
|
||||
input={args}
|
||||
output={result}
|
||||
errorText={errorText}
|
||||
maxHeight={400}
|
||||
/>
|
||||
);
|
||||
}, [isMobile, detail, args, result, errorText]);
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
@@ -1310,7 +1356,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
? () => null
|
||||
: undefined
|
||||
}
|
||||
isLoading={status === "executing"}
|
||||
isLoading={status === "executing" || status === "running"}
|
||||
isError={status === "failed"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
disableOuterSpacing={disableOuterSpacing}
|
||||
|
||||
@@ -3,10 +3,10 @@ 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 type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
buildLineDiff,
|
||||
parseUnifiedDiff,
|
||||
type ToolCallDetail,
|
||||
} from "@/utils/tool-call-parsers";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
@@ -16,34 +16,39 @@ const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
|
||||
// ---- Content Component ----
|
||||
|
||||
interface ToolCallDetailsContentProps {
|
||||
detail: ToolCallDetail;
|
||||
detail?: ToolCallDetail;
|
||||
input?: unknown | null;
|
||||
output?: unknown | null;
|
||||
errorText?: string;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export function ToolCallDetailsContent({
|
||||
detail,
|
||||
input,
|
||||
output,
|
||||
errorText,
|
||||
maxHeight = 300,
|
||||
}: ToolCallDetailsContentProps) {
|
||||
// Compute diff lines for edit type
|
||||
const diffLines = useMemo(() => {
|
||||
if (detail.type !== "edit") return undefined;
|
||||
if (!detail || detail.type !== "edit") return undefined;
|
||||
// Use pre-computed unified diff if available (e.g., from apply_patch)
|
||||
if (detail.unifiedDiff) {
|
||||
return parseUnifiedDiff(detail.unifiedDiff);
|
||||
}
|
||||
return buildLineDiff(detail.oldString, detail.newString);
|
||||
return buildLineDiff(detail.oldString ?? "", detail.newString ?? "");
|
||||
}, [detail]);
|
||||
|
||||
const sections: ReactNode[] = [];
|
||||
const isFullBleed = detail.type === "edit" || detail.type === "shell";
|
||||
const isFullBleed =
|
||||
detail?.type === "edit" || detail?.type === "shell" || detail?.type === "write";
|
||||
const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer;
|
||||
|
||||
if (detail.type === "shell") {
|
||||
if (detail?.type === "shell") {
|
||||
const command = detail.command.replace(/\n+$/, "");
|
||||
const output = detail.output.replace(/^\n+/, "");
|
||||
const hasOutput = output.length > 0;
|
||||
const commandOutput = (detail.output ?? "").replace(/^\n+/, "");
|
||||
const hasOutput = commandOutput.length > 0;
|
||||
sections.push(
|
||||
<View key="shell" style={styles.section}>
|
||||
<View style={codeBlockStyle}>
|
||||
@@ -63,7 +68,7 @@ export function ToolCallDetailsContent({
|
||||
<Text selectable style={styles.scrollText}>
|
||||
<Text style={styles.shellPrompt}>$ </Text>
|
||||
{command}
|
||||
{hasOutput ? `\n\n${output}` : ""}
|
||||
{hasOutput ? `\n\n${commandOutput}` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
@@ -71,7 +76,7 @@ export function ToolCallDetailsContent({
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
} else if (detail.type === "edit") {
|
||||
} else if (detail?.type === "edit") {
|
||||
sections.push(
|
||||
<View key="edit" style={styles.section}>
|
||||
{diffLines ? (
|
||||
@@ -81,7 +86,28 @@ export function ToolCallDetailsContent({
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
} else if (detail.type === "read") {
|
||||
} else if (detail?.type === "write") {
|
||||
sections.push(
|
||||
<View key="write" style={styles.section}>
|
||||
{detail.content ? (
|
||||
<ScrollView
|
||||
style={[styles.scrollArea, { maxHeight }]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={true}
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator={true}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>{detail.content}</Text>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
} else if (detail?.type === "read") {
|
||||
sections.push(
|
||||
<View key="read" style={styles.section}>
|
||||
{(detail.offset !== undefined || detail.limit !== undefined) ? (
|
||||
@@ -109,68 +135,49 @@ export function ToolCallDetailsContent({
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
} else if (detail.type === "thinking") {
|
||||
// Thinking: display the content as plain text
|
||||
} else if (detail?.type === "search") {
|
||||
sections.push(
|
||||
<View key="thinking" style={styles.section}>
|
||||
<ScrollView
|
||||
style={[styles.scrollArea, { maxHeight }]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={true}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>{detail.content}</Text>
|
||||
</ScrollView>
|
||||
<View key="search" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>{detail.query}</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
// Generic tool: show input/output as key-value pairs
|
||||
if (detail.input.length > 0) {
|
||||
sections.push(
|
||||
<View key="input-header" style={styles.groupHeader}>
|
||||
<Text style={styles.groupHeaderText}>Input</Text>
|
||||
</View>
|
||||
);
|
||||
detail.input.forEach((pair, index) => {
|
||||
sections.push(
|
||||
<View key={`input-${index}-${pair.key}`} style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{pair.key}</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
style={styles.jsonScroll}
|
||||
contentContainerStyle={styles.jsonContent}
|
||||
showsHorizontalScrollIndicator={true}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>{pair.value}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
}
|
||||
const sectionsFromTopLevel = [
|
||||
{ title: "Input", value: input },
|
||||
{ title: "Output", value: output },
|
||||
].filter((entry) => entry.value !== null && entry.value !== undefined);
|
||||
|
||||
if (detail.output.length > 0) {
|
||||
for (const section of sectionsFromTopLevel) {
|
||||
let value = "";
|
||||
try {
|
||||
value =
|
||||
typeof section.value === "string"
|
||||
? section.value
|
||||
: JSON.stringify(section.value, null, 2);
|
||||
} catch {
|
||||
value = String(section.value);
|
||||
}
|
||||
if (!value.length) {
|
||||
continue;
|
||||
}
|
||||
sections.push(
|
||||
<View key="output-header" style={styles.groupHeader}>
|
||||
<Text style={styles.groupHeaderText}>Output</Text>
|
||||
<View key={`${section.title}-header`} style={styles.groupHeader}>
|
||||
<Text style={styles.groupHeaderText}>{section.title}</Text>
|
||||
</View>
|
||||
);
|
||||
sections.push(
|
||||
<View key={`${section.title}-value`} style={styles.section}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
style={styles.jsonScroll}
|
||||
contentContainerStyle={styles.jsonContent}
|
||||
showsHorizontalScrollIndicator={true}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>{value}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
detail.output.forEach((pair, index) => {
|
||||
sections.push(
|
||||
<View key={`output-${index}-${pair.key}`} style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{pair.key}</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
style={styles.jsonScroll}
|
||||
contentContainerStyle={styles.jsonContent}
|
||||
showsHorizontalScrollIndicator={true}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>{pair.value}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveToolCallPreview } from "./tool-call-preview";
|
||||
import type { CommandDetails, EditEntry, ReadEntry } from "@/utils/tool-call-parsers";
|
||||
|
||||
describe("resolveToolCallPreview", () => {
|
||||
it("prefers parsed hydration payloads when provided", () => {
|
||||
const parsedEdits: EditEntry[] = [
|
||||
{
|
||||
filePath: "README.md",
|
||||
diffLines: [
|
||||
{ type: "header", content: "@@ -1 +1 @@" },
|
||||
{ type: "remove", content: "-Old" },
|
||||
{ type: "add", content: "+New" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const parsedReads: ReadEntry[] = [
|
||||
{
|
||||
filePath: "README.md",
|
||||
content: "# Hydrated\nFinal text\n",
|
||||
},
|
||||
];
|
||||
const parsedCommand: CommandDetails = {
|
||||
command: "ls",
|
||||
cwd: "/tmp/hydrated",
|
||||
output: "README.md\npackages\n",
|
||||
exitCode: 0,
|
||||
};
|
||||
|
||||
const preview = resolveToolCallPreview({
|
||||
parsedEditEntries: parsedEdits,
|
||||
parsedReadEntries: parsedReads,
|
||||
parsedCommandDetails: parsedCommand,
|
||||
});
|
||||
|
||||
expect(preview.editEntries).toBe(parsedEdits);
|
||||
expect(preview.readEntries).toBe(parsedReads);
|
||||
expect(preview.commandDetails).toBe(parsedCommand);
|
||||
});
|
||||
|
||||
it("falls back to derived parser output when hydration metadata is missing", () => {
|
||||
const args = {
|
||||
type: "mcp_tool_use",
|
||||
id: "call_fallback",
|
||||
name: "apply_patch",
|
||||
server: "editor",
|
||||
input: {
|
||||
file_path: "README.md",
|
||||
patch: "*** Begin Patch\n*** Update File: README.md\n@@\n-Old\n+New\n*** End Patch",
|
||||
},
|
||||
};
|
||||
const result = {
|
||||
changes: [
|
||||
{
|
||||
file_path: "README.md",
|
||||
previous_content: "Old\n",
|
||||
content: "New\n",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const preview = resolveToolCallPreview({
|
||||
args,
|
||||
result,
|
||||
});
|
||||
|
||||
expect(preview.editEntries[0]?.diffLines.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
preview.editEntries[0]?.diffLines.some((line) => line.content.includes("+New"))
|
||||
).toBe(true);
|
||||
expect(preview.commandDetails).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import {
|
||||
extractCommandDetails,
|
||||
extractEditEntries,
|
||||
extractReadEntries,
|
||||
type CommandDetails,
|
||||
type EditEntry,
|
||||
type ReadEntry,
|
||||
} from "@/utils/tool-call-parsers";
|
||||
|
||||
export type ToolCallPreviewSource = {
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
parsedEditEntries?: EditEntry[] | undefined;
|
||||
parsedReadEntries?: ReadEntry[] | undefined;
|
||||
parsedCommandDetails?: CommandDetails | null | undefined;
|
||||
};
|
||||
|
||||
export type ToolCallPreview = {
|
||||
editEntries: EditEntry[];
|
||||
readEntries: ReadEntry[];
|
||||
commandDetails: CommandDetails | null | undefined;
|
||||
};
|
||||
|
||||
export function resolveToolCallPreview({
|
||||
args,
|
||||
result,
|
||||
parsedEditEntries,
|
||||
parsedReadEntries,
|
||||
parsedCommandDetails,
|
||||
}: ToolCallPreviewSource): ToolCallPreview {
|
||||
const fallbackEditEntries = extractEditEntries(args, result);
|
||||
const fallbackReadEntries = extractReadEntries(result, args);
|
||||
const fallbackCommandDetails = extractCommandDetails(args, result);
|
||||
|
||||
return {
|
||||
editEntries: parsedEditEntries ?? fallbackEditEntries,
|
||||
readEntries: parsedReadEntries ?? fallbackReadEntries,
|
||||
commandDetails: parsedCommandDetails ?? fallbackCommandDetails,
|
||||
};
|
||||
}
|
||||
@@ -16,12 +16,21 @@ import {
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Pencil, Eye, SquareTerminal, Search, Bot, Wrench, X } from "lucide-react-native";
|
||||
import type { ToolCallDisplayInfo } from "@/utils/tool-call-parsers";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import type { ToolCallKind } from "@/utils/tool-call-display";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
|
||||
// ----- Types -----
|
||||
|
||||
export type ToolCallSheetData = ToolCallDisplayInfo;
|
||||
export type ToolCallSheetData = {
|
||||
kind: ToolCallKind;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
detail?: ToolCallDetail;
|
||||
input?: unknown | null;
|
||||
output?: unknown | null;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
interface ToolCallSheetContextValue {
|
||||
openToolCall: (data: ToolCallSheetData) => void;
|
||||
@@ -133,7 +142,7 @@ interface ToolCallSheetContentProps {
|
||||
}
|
||||
|
||||
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
const { kind, displayName, detail, errorText } = data;
|
||||
const { kind, displayName, detail, input, output, errorText } = data;
|
||||
|
||||
const IconComponent = toolKindIcons[kind] || Wrench;
|
||||
|
||||
@@ -157,7 +166,12 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
style={styles.content}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
<ToolCallDetailsContent detail={detail} errorText={errorText} />
|
||||
<ToolCallDetailsContent
|
||||
detail={detail}
|
||||
input={input}
|
||||
output={output}
|
||||
errorText={errorText}
|
||||
/>
|
||||
</BottomSheetScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -23,8 +23,12 @@ const toolCallEvent = (): AgentStreamEventPayload => ({
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "buffer-tool-call",
|
||||
name: "run",
|
||||
status: "executing",
|
||||
status: "running",
|
||||
input: { command: "echo hi" },
|
||||
output: null,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@ import {
|
||||
hydrateStreamState,
|
||||
type StreamItem,
|
||||
type AgentToolCallItem,
|
||||
type ToolCallStatus,
|
||||
isAgentToolCallItem,
|
||||
} from "./stream";
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
type HarnessUpdate = { event: AgentStreamEventPayload; timestamp: Date };
|
||||
type ToolStatus = "running" | "completed" | "failed" | "canceled";
|
||||
|
||||
const HARNESS_CALL_IDS = {
|
||||
command: "harness-command",
|
||||
@@ -31,60 +32,80 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
|
||||
timestamp: new Date("2025-02-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolStartEvent({
|
||||
event: buildToolEvent({
|
||||
callId: HARNESS_CALL_IDS.edit,
|
||||
name: "apply_patch",
|
||||
status: "running",
|
||||
input: {
|
||||
file_path: "README.md",
|
||||
patch: "*** Begin Patch\n*** Update File: README.md\n@@\n-Old line\n+New line\n*** End Patch",
|
||||
},
|
||||
detail: {
|
||||
type: "edit",
|
||||
filePath: "README.md",
|
||||
unifiedDiff: "@@\n-Old line\n+New line",
|
||||
},
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:00:01Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolResultEvent({
|
||||
event: buildToolEvent({
|
||||
callId: HARNESS_CALL_IDS.edit,
|
||||
name: "apply_patch",
|
||||
status: "completed",
|
||||
output: {
|
||||
changes: [
|
||||
files: [
|
||||
{
|
||||
file_path: "README.md",
|
||||
previous_content: "Old line\n",
|
||||
content: "New line\n",
|
||||
path: "README.md",
|
||||
patch: "@@\n-Old line\n+New line",
|
||||
},
|
||||
],
|
||||
},
|
||||
input: null,
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:00:02Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolStartEvent({
|
||||
event: buildToolEvent({
|
||||
callId: HARNESS_CALL_IDS.read,
|
||||
name: "read_file",
|
||||
status: "running",
|
||||
input: { file_path: "README.md" },
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "README.md",
|
||||
},
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:00:03Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolResultEvent({
|
||||
event: buildToolEvent({
|
||||
callId: HARNESS_CALL_IDS.read,
|
||||
name: "read_file",
|
||||
status: "completed",
|
||||
output: { content: "# README\nNew line\n" },
|
||||
input: null,
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:00:04Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolStartEvent({
|
||||
event: buildToolEvent({
|
||||
callId: HARNESS_CALL_IDS.command,
|
||||
name: "shell",
|
||||
status: "running",
|
||||
input: { command: "ls" },
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "ls",
|
||||
},
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:00:05Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolResultEvent({
|
||||
event: buildToolEvent({
|
||||
callId: HARNESS_CALL_IDS.command,
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
output: {
|
||||
result: {
|
||||
command: "ls",
|
||||
@@ -92,12 +113,12 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
|
||||
},
|
||||
metadata: { exit_code: 0, cwd: "/tmp/harness" },
|
||||
},
|
||||
input: null,
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:00:06Z"),
|
||||
},
|
||||
];
|
||||
|
||||
// Hydration snapshot recorded after refreshing the chat – this is the broken state we need to codify.
|
||||
const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [
|
||||
{
|
||||
event: {
|
||||
@@ -112,96 +133,112 @@ const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [
|
||||
timestamp: new Date("2025-02-01T10:05:00Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolStartEvent({
|
||||
event: buildToolEvent({
|
||||
callId: HARNESS_CALL_IDS.edit,
|
||||
name: "apply_patch",
|
||||
status: "completed",
|
||||
input: {
|
||||
file_path: "README.md",
|
||||
},
|
||||
output: null,
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:05:01Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolStartEvent({
|
||||
event: buildToolEvent({
|
||||
callId: HARNESS_CALL_IDS.read,
|
||||
name: "read_file",
|
||||
status: "completed",
|
||||
input: { file_path: "README.md" },
|
||||
output: null,
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:05:02Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolStartEvent({
|
||||
event: buildToolEvent({
|
||||
callId: HARNESS_CALL_IDS.command,
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
input: { command: "ls" },
|
||||
output: null,
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:05:03Z"),
|
||||
},
|
||||
];
|
||||
|
||||
describe("stream harness captures hydrated regression", () => {
|
||||
it("records tool payloads during the live run", () => {
|
||||
describe("stream harness canonical payloads", () => {
|
||||
it("keeps provider detail payloads during live run", () => {
|
||||
const liveState = hydrateStreamState(STREAM_HARNESS_LIVE);
|
||||
const snapshots = extractHarnessSnapshots(liveState);
|
||||
|
||||
expect(snapshots.edit?.payload.data.parsedEdits?.[0]?.diffLines.length).toBeGreaterThan(0);
|
||||
expect(snapshots.read?.payload.data.parsedReads?.[0]?.content).toContain("New line");
|
||||
expect(snapshots.command?.payload.data.parsedCommand?.output).toContain("README.md");
|
||||
expect(snapshots.edit?.payload.data.detail).toEqual({
|
||||
type: "edit",
|
||||
filePath: "README.md",
|
||||
unifiedDiff: "@@\n-Old line\n+New line",
|
||||
});
|
||||
expect(snapshots.read?.payload.data.detail).toEqual({
|
||||
type: "read",
|
||||
filePath: "README.md",
|
||||
});
|
||||
expect(snapshots.command?.payload.data.detail).toEqual({
|
||||
type: "shell",
|
||||
command: "ls",
|
||||
});
|
||||
});
|
||||
|
||||
it("documents that hydrated events without output lose parsed payloads", () => {
|
||||
// After a refresh, hydrated events only contain status but no input/output data.
|
||||
// Without full data, parsed payloads cannot be reconstructed.
|
||||
it("keeps tool records hydrated even when output is missing", () => {
|
||||
const hydratedState = hydrateStreamState(STREAM_HARNESS_HYDRATED);
|
||||
const snapshots = extractHarnessSnapshots(hydratedState);
|
||||
|
||||
// Hydrated events exist but lack parsed content since input/output were not provided
|
||||
expect(snapshots.edit?.payload.data.parsedEdits).toBeUndefined();
|
||||
expect(snapshots.read?.payload.data.parsedReads).toBeUndefined();
|
||||
expect(snapshots.command?.payload.data.parsedCommand).toBeUndefined();
|
||||
expect(snapshots.edit?.payload.data.status).toBe("completed");
|
||||
expect(snapshots.read?.payload.data.status).toBe("completed");
|
||||
expect(snapshots.command?.payload.data.status).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
function buildToolStartEvent({
|
||||
function buildToolEvent({
|
||||
callId,
|
||||
name,
|
||||
input,
|
||||
status = "executing",
|
||||
status,
|
||||
input = null,
|
||||
output = null,
|
||||
error,
|
||||
detail,
|
||||
}: {
|
||||
callId: string;
|
||||
name: string;
|
||||
input?: Record<string, unknown>;
|
||||
status?: ToolCallStatus;
|
||||
status: ToolStatus;
|
||||
input?: Record<string, unknown> | null;
|
||||
output?: Record<string, unknown> | null;
|
||||
error?: unknown;
|
||||
detail?: ToolCallDetail;
|
||||
}): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
name,
|
||||
status,
|
||||
callId,
|
||||
input,
|
||||
},
|
||||
const baseItem = {
|
||||
type: "tool_call" as const,
|
||||
name,
|
||||
status,
|
||||
callId,
|
||||
input,
|
||||
output,
|
||||
...(detail ? { detail } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildToolResultEvent({
|
||||
callId,
|
||||
name,
|
||||
output,
|
||||
}: {
|
||||
callId: string;
|
||||
name: string;
|
||||
output?: Record<string, unknown>;
|
||||
}): AgentStreamEventPayload {
|
||||
const item =
|
||||
status === "failed"
|
||||
? {
|
||||
...baseItem,
|
||||
status: "failed" as const,
|
||||
error: error ?? { message: "failed" },
|
||||
}
|
||||
: {
|
||||
...baseItem,
|
||||
error: null,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
name,
|
||||
callId,
|
||||
output,
|
||||
},
|
||||
item,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+277
-1105
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,10 @@
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import type {
|
||||
AgentProvider,
|
||||
ToolCallDetail,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import {
|
||||
extractCommandDetails,
|
||||
extractEditEntries,
|
||||
extractReadEntries,
|
||||
extractTaskEntriesFromToolCall,
|
||||
type CommandDetails,
|
||||
type EditEntry,
|
||||
type ReadEntry,
|
||||
} from "../utils/tool-call-parsers";
|
||||
|
||||
/**
|
||||
@@ -86,7 +83,8 @@ export interface ThoughtItem {
|
||||
status: ThoughtStatus;
|
||||
}
|
||||
|
||||
export type ToolCallStatus = "executing" | "completed" | "failed";
|
||||
export type OrchestratorToolCallStatus = "executing" | "completed" | "failed";
|
||||
export type AgentToolCallStatus = "running" | "completed" | "failed" | "canceled";
|
||||
|
||||
interface OrchestratorToolCallData {
|
||||
toolCallId: string;
|
||||
@@ -94,20 +92,18 @@ interface OrchestratorToolCallData {
|
||||
arguments: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
status: ToolCallStatus;
|
||||
status: OrchestratorToolCallStatus;
|
||||
}
|
||||
|
||||
export interface AgentToolCallData {
|
||||
provider: AgentProvider;
|
||||
callId: string;
|
||||
name: string;
|
||||
status?: ToolCallStatus;
|
||||
callId?: string;
|
||||
input?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
parsedEdits?: EditEntry[];
|
||||
parsedReads?: ReadEntry[];
|
||||
parsedCommand?: CommandDetails | null;
|
||||
status: AgentToolCallStatus;
|
||||
input: unknown | null;
|
||||
result: unknown | null;
|
||||
error: unknown | null;
|
||||
detail?: ToolCallDetail;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -173,14 +169,6 @@ function normalizeChunk(text: string): { chunk: string; hasContent: boolean } {
|
||||
return { chunk, hasContent: /\S/.test(chunk) };
|
||||
}
|
||||
|
||||
function coerceString(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
function appendUserMessage(
|
||||
state: StreamItem[],
|
||||
text: string,
|
||||
@@ -310,97 +298,59 @@ function finalizeActiveThoughts(state: StreamItem[]): StreamItem[] {
|
||||
return mutated ? nextState : state;
|
||||
}
|
||||
|
||||
function mergeToolCallRaw(existingRaw: unknown, nextRaw: unknown): unknown {
|
||||
if (existingRaw === undefined || existingRaw === null) {
|
||||
return nextRaw;
|
||||
}
|
||||
if (nextRaw === undefined || nextRaw === null) {
|
||||
return existingRaw;
|
||||
}
|
||||
if (Array.isArray(existingRaw)) {
|
||||
return [...existingRaw, nextRaw];
|
||||
}
|
||||
return [existingRaw, nextRaw];
|
||||
}
|
||||
|
||||
function computeParsedToolPayload(result: unknown): {
|
||||
parsedEdits?: EditEntry[];
|
||||
parsedReads?: ReadEntry[];
|
||||
parsedCommand?: CommandDetails | null;
|
||||
} {
|
||||
const edits = extractEditEntries(result);
|
||||
const reads = extractReadEntries(result);
|
||||
const command = extractCommandDetails(result);
|
||||
|
||||
return {
|
||||
parsedEdits: edits.length > 0 ? edits : undefined,
|
||||
parsedReads: reads.length > 0 ? reads : undefined,
|
||||
parsedCommand: command ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeComparableString(value?: string | null): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
function findExistingAgentToolCallIndex(
|
||||
state: StreamItem[],
|
||||
callId: string | null,
|
||||
data: AgentToolCallData
|
||||
callId: string
|
||||
): number {
|
||||
const normalizedCallId = normalizeComparableString(callId);
|
||||
if (normalizedCallId) {
|
||||
const existingIndex = state.findIndex(
|
||||
(entry) =>
|
||||
entry.kind === "tool_call" &&
|
||||
entry.payload.source === "agent" &&
|
||||
normalizeComparableString(entry.payload.data.callId) ===
|
||||
normalizedCallId
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
return existingIndex;
|
||||
}
|
||||
return state.findIndex(
|
||||
(entry) =>
|
||||
entry.kind === "tool_call" &&
|
||||
entry.payload.source === "agent" &&
|
||||
entry.payload.data.callId === callId
|
||||
);
|
||||
}
|
||||
|
||||
function hasNonEmptyObject(value: unknown): boolean {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value as Record<string, unknown>).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function mergeCanonicalValue(
|
||||
existing: unknown | null,
|
||||
incoming: unknown | null
|
||||
): unknown | null {
|
||||
if (incoming === null) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const fallbackCandidates: Array<{ index: number; item: AgentToolCallItem }> =
|
||||
[];
|
||||
const metadataMatches: Array<{ index: number; item: AgentToolCallItem }> = [];
|
||||
for (let i = 0; i < state.length; i += 1) {
|
||||
const entry = state[i];
|
||||
if (entry.kind !== "tool_call" || entry.payload.source !== "agent") {
|
||||
continue;
|
||||
}
|
||||
const payload = entry.payload.data;
|
||||
const providerMatches =
|
||||
payload.provider === data.provider && payload.name === data.name;
|
||||
if (providerMatches) {
|
||||
metadataMatches.push({ index: i, item: entry as AgentToolCallItem });
|
||||
}
|
||||
if (payload.callId) {
|
||||
continue;
|
||||
}
|
||||
if (payload.status !== "executing") {
|
||||
continue;
|
||||
}
|
||||
if (providerMatches) {
|
||||
fallbackCandidates.push({ index: i, item: entry as AgentToolCallItem });
|
||||
}
|
||||
if (!hasNonEmptyObject(incoming) && hasNonEmptyObject(existing)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (fallbackCandidates.length) {
|
||||
return fallbackCandidates[0]?.index ?? -1;
|
||||
}
|
||||
return incoming;
|
||||
}
|
||||
|
||||
// If this update still lacks a call id, fall back to metadata matches (e.g. replayed hydration events)
|
||||
if (!normalizedCallId && metadataMatches.length) {
|
||||
return metadataMatches[0]?.index ?? -1;
|
||||
function mergeAgentToolCallStatus(
|
||||
existing: AgentToolCallStatus,
|
||||
incoming: AgentToolCallStatus
|
||||
): AgentToolCallStatus {
|
||||
if (existing === "failed" || incoming === "failed") {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
return -1;
|
||||
if (existing === "canceled") {
|
||||
return "canceled";
|
||||
}
|
||||
if (incoming === "canceled") {
|
||||
return existing === "completed" ? "completed" : "canceled";
|
||||
}
|
||||
if (existing === "completed" || incoming === "completed") {
|
||||
return "completed";
|
||||
}
|
||||
return "running";
|
||||
}
|
||||
|
||||
function appendAgentToolCall(
|
||||
@@ -408,49 +358,26 @@ function appendAgentToolCall(
|
||||
data: AgentToolCallData,
|
||||
timestamp: Date
|
||||
): StreamItem[] {
|
||||
const normalizedStatus = normalizeToolCallStatus(
|
||||
data.status,
|
||||
data.result,
|
||||
data.error
|
||||
);
|
||||
const callId = data.callId;
|
||||
|
||||
const payloadData: AgentToolCallData = {
|
||||
...data,
|
||||
status: normalizedStatus,
|
||||
callId: callId ?? data.callId,
|
||||
};
|
||||
|
||||
const existingIndex = findExistingAgentToolCallIndex(
|
||||
state,
|
||||
callId ?? null,
|
||||
payloadData
|
||||
);
|
||||
const existingIndex = findExistingAgentToolCallIndex(state, data.callId);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const next = [...state];
|
||||
const existing = next[existingIndex] as AgentToolCallItem;
|
||||
const mergedInput =
|
||||
hasValue(payloadData.input)
|
||||
? payloadData.input
|
||||
: existing.payload.data.input;
|
||||
const mergedResult =
|
||||
hasValue(payloadData.result)
|
||||
? payloadData.result
|
||||
: existing.payload.data.result;
|
||||
const mergedError =
|
||||
hasValue(payloadData.error)
|
||||
? payloadData.error
|
||||
: existing.payload.data.error;
|
||||
const mergedStatus = mergeToolCallStatus(
|
||||
const mergedInput = mergeCanonicalValue(existing.payload.data.input, data.input);
|
||||
const mergedResult = mergeCanonicalValue(existing.payload.data.result, data.result);
|
||||
const mergedStatus = mergeAgentToolCallStatus(
|
||||
existing.payload.data.status,
|
||||
payloadData.status ?? existing.payload.data.status ?? "executing"
|
||||
data.status
|
||||
);
|
||||
const mergedError =
|
||||
mergedStatus === "failed"
|
||||
? data.error ?? existing.payload.data.error ?? { message: "Tool call failed" }
|
||||
: null;
|
||||
const mergedMetadata =
|
||||
payloadData.metadata || existing.payload.data.metadata
|
||||
? { ...existing.payload.data.metadata, ...payloadData.metadata }
|
||||
data.metadata || existing.payload.data.metadata
|
||||
? { ...existing.payload.data.metadata, ...data.metadata }
|
||||
: undefined;
|
||||
const parsed = computeParsedToolPayload(mergedResult);
|
||||
|
||||
next[existingIndex] = {
|
||||
...existing,
|
||||
timestamp,
|
||||
@@ -458,41 +385,28 @@ function appendAgentToolCall(
|
||||
source: "agent",
|
||||
data: {
|
||||
...existing.payload.data,
|
||||
...payloadData,
|
||||
...data,
|
||||
status: mergedStatus,
|
||||
input: mergedInput,
|
||||
result: mergedResult,
|
||||
error: mergedError,
|
||||
detail: data.detail ?? existing.payload.data.detail,
|
||||
metadata: mergedMetadata,
|
||||
callId: payloadData.callId ?? existing.payload.data.callId,
|
||||
parsedEdits: parsed.parsedEdits ?? existing.payload.data.parsedEdits,
|
||||
parsedReads: parsed.parsedReads ?? existing.payload.data.parsedReads,
|
||||
parsedCommand:
|
||||
parsed.parsedCommand ?? existing.payload.data.parsedCommand,
|
||||
},
|
||||
},
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
const id = callId
|
||||
? `agent_tool_${callId}`
|
||||
: createUniqueTimelineId(
|
||||
state,
|
||||
"tool",
|
||||
`${data.provider}:${data.name}`,
|
||||
timestamp
|
||||
);
|
||||
|
||||
const item: ToolCallItem = {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
id: `agent_tool_${data.callId}`,
|
||||
timestamp,
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
...payloadData,
|
||||
...computeParsedToolPayload(payloadData.result),
|
||||
...data,
|
||||
error: data.status === "failed" ? data.error : null,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -500,195 +414,6 @@ function appendAgentToolCall(
|
||||
return [...state, item];
|
||||
}
|
||||
|
||||
const FAILED_STATUS_PATTERN =
|
||||
/fail|error|deny|reject|cancel|abort|exception|refus/;
|
||||
const COMPLETED_STATUS_PATTERN =
|
||||
/complete|success|granted|applied|done|resolved|finish|succeed|ok/;
|
||||
|
||||
function normalizeStatusString(
|
||||
status?: string | null
|
||||
): "executing" | "completed" | "failed" | null {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
const normalized = status.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (FAILED_STATUS_PATTERN.test(normalized)) {
|
||||
return "failed";
|
||||
}
|
||||
if (COMPLETED_STATUS_PATTERN.test(normalized)) {
|
||||
return "completed";
|
||||
}
|
||||
return "executing";
|
||||
}
|
||||
|
||||
function hasValue(value: unknown): boolean {
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
function inferStatusFromRaw(raw: unknown): "completed" | "failed" | null {
|
||||
if (!hasValue(raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queue: unknown[] = Array.isArray(raw) ? [...raw] : [raw];
|
||||
const visited = new Set<object>();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const candidate = queue.shift();
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (visited.has(candidate as object)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(candidate as object);
|
||||
const record = candidate as Record<string, unknown>;
|
||||
|
||||
if (record.is_error === true) {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const statusValue = normalizeStatusString(
|
||||
typeof record.status === "string" ? record.status : undefined
|
||||
);
|
||||
if (statusValue === "failed") {
|
||||
return "failed";
|
||||
}
|
||||
if (statusValue === "completed") {
|
||||
return "completed";
|
||||
}
|
||||
|
||||
if ("error" in record && hasValue(record.error)) {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
if (typeof record.stderr === "string" && record.stderr.length > 0) {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const typeValue =
|
||||
typeof record.type === "string" ? record.type.toLowerCase() : "";
|
||||
if (typeValue) {
|
||||
if (FAILED_STATUS_PATTERN.test(typeValue)) {
|
||||
return "failed";
|
||||
}
|
||||
if (/result|response|output|success/.test(typeValue)) {
|
||||
return "completed";
|
||||
}
|
||||
}
|
||||
|
||||
const exitCode =
|
||||
typeof record.exitCode === "number"
|
||||
? record.exitCode
|
||||
: typeof record.exit_code === "number"
|
||||
? record.exit_code
|
||||
: null;
|
||||
if (exitCode !== null) {
|
||||
return exitCode === 0 ? "completed" : "failed";
|
||||
}
|
||||
|
||||
const successValue =
|
||||
typeof record.success === "boolean" ? record.success : null;
|
||||
if (successValue !== null) {
|
||||
return successValue ? "completed" : "failed";
|
||||
}
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
queue.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeToolCallStatus(
|
||||
status?: string,
|
||||
result?: unknown,
|
||||
error?: unknown
|
||||
): ToolCallStatus {
|
||||
const normalizedFromStatus = normalizeStatusString(status);
|
||||
if (normalizedFromStatus === "failed") {
|
||||
return "failed";
|
||||
}
|
||||
if (normalizedFromStatus === "completed") {
|
||||
return "completed";
|
||||
}
|
||||
|
||||
if (hasValue(error)) {
|
||||
return "failed";
|
||||
}
|
||||
if (hasValue(result)) {
|
||||
return "completed";
|
||||
}
|
||||
|
||||
return normalizedFromStatus ?? "executing";
|
||||
}
|
||||
|
||||
function mergeToolCallStatus(
|
||||
existing: ToolCallStatus | undefined,
|
||||
incoming: ToolCallStatus
|
||||
): ToolCallStatus {
|
||||
if (existing === "failed" || incoming === "failed") {
|
||||
return "failed";
|
||||
}
|
||||
if (existing === "completed" || incoming === "completed") {
|
||||
return "completed";
|
||||
}
|
||||
return incoming ?? existing ?? "executing";
|
||||
}
|
||||
|
||||
const TOOL_CALL_ID_KEYS = [
|
||||
"toolCallId",
|
||||
"tool_call_id",
|
||||
"callId",
|
||||
"call_id",
|
||||
"tool_use_id",
|
||||
"toolUseId",
|
||||
];
|
||||
|
||||
function extractToolCallId(raw: unknown, depth = 0): string | null {
|
||||
if (!raw || depth > 4) {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw === "string" || typeof raw === "number") {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
for (const entry of raw) {
|
||||
const nested = extractToolCallId(entry, depth + 1);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof raw === "object") {
|
||||
const record = raw as Record<string, unknown>;
|
||||
for (const key of TOOL_CALL_ID_KEYS) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
const idValue = record.id;
|
||||
if (typeof idValue === "string" && /tool|call/i.test(idValue)) {
|
||||
return idValue;
|
||||
}
|
||||
for (const value of Object.values(record)) {
|
||||
const nested = extractToolCallId(value, depth + 1);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendActivityLog(
|
||||
state: StreamItem[],
|
||||
entry: ActivityLogItem
|
||||
@@ -826,12 +551,13 @@ export function reduceStreamUpdate(
|
||||
state,
|
||||
{
|
||||
provider: event.provider,
|
||||
name: item.name,
|
||||
status: normalizeStatusString(item.status) ?? "executing",
|
||||
callId: item.callId,
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
input: item.input,
|
||||
result: item.output,
|
||||
error: item.error,
|
||||
detail: item.detail,
|
||||
metadata: item.metadata,
|
||||
},
|
||||
timestamp
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildToolCallDisplayModel, formatToolCallError } from "./tool-call-display";
|
||||
|
||||
describe("tool-call-display", () => {
|
||||
it("builds display model from canonical shell detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "shell",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm test",
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
kind: "execute",
|
||||
displayName: "Shell",
|
||||
summary: "npm test",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds display model from canonical read detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "read_file",
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "/tmp/repo/src/index.ts",
|
||||
},
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
kind: "read",
|
||||
displayName: "Read",
|
||||
summary: "src/index.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses metadata summary for task tool calls", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "task",
|
||||
metadata: {
|
||||
subAgentActivity: "Running tests",
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
kind: "agent",
|
||||
displayName: "Task",
|
||||
summary: "Running tests",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to humanized tool name for unknown tools", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "custom_tool_name",
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
kind: "tool",
|
||||
displayName: "Custom Tool Name",
|
||||
});
|
||||
});
|
||||
|
||||
it("formats non-string errors", () => {
|
||||
expect(formatToolCallError({ message: "boom" })).toBe('{\n "message": "boom"\n}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export type ToolCallKind =
|
||||
| "read"
|
||||
| "edit"
|
||||
| "write"
|
||||
| "execute"
|
||||
| "search"
|
||||
| "agent"
|
||||
| "tool"
|
||||
| "thinking";
|
||||
|
||||
type SummaryParams = {
|
||||
name: string;
|
||||
detail?: ToolCallDetail;
|
||||
metadata?: Record<string, unknown>;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export type ToolCallDisplayModel = {
|
||||
kind: ToolCallKind;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
const TOOL_CALL_DETAIL_SCHEMA: z.ZodType<ToolCallDetail> = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("shell"),
|
||||
command: z.string(),
|
||||
cwd: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
exitCode: z.number().nullable().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("read"),
|
||||
filePath: z.string(),
|
||||
content: z.string().optional(),
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("edit"),
|
||||
filePath: z.string(),
|
||||
oldString: z.string().optional(),
|
||||
newString: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("write"),
|
||||
filePath: z.string(),
|
||||
content: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("search"),
|
||||
query: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const TOOL_CALL_DISPLAY_INPUT_SCHEMA = z.object({
|
||||
name: z.string().min(1),
|
||||
detail: TOOL_CALL_DETAIL_SCHEMA.optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
cwd: z.string().optional(),
|
||||
});
|
||||
|
||||
function toTitleCase(words: string): string {
|
||||
return words
|
||||
.split(" ")
|
||||
.filter((segment) => segment.length > 0)
|
||||
.map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function humanizeToolName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return name;
|
||||
}
|
||||
return toTitleCase(trimmed.replace(/[._-]+/g, " "));
|
||||
}
|
||||
|
||||
export function stripCwdPrefix(filePath: string, cwd?: string): string {
|
||||
if (!cwd || !filePath) return filePath;
|
||||
|
||||
const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = filePath.replace(/\\/g, "/");
|
||||
|
||||
const prefix = `${normalizedCwd}/`;
|
||||
if (normalizedPath.startsWith(prefix)) {
|
||||
return normalizedPath.slice(prefix.length);
|
||||
}
|
||||
if (normalizedPath === normalizedCwd) {
|
||||
return ".";
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function buildDisplayFromDetail(detail: ToolCallDetail, cwd?: string): ToolCallDisplayModel {
|
||||
switch (detail.type) {
|
||||
case "shell":
|
||||
return {
|
||||
kind: "execute",
|
||||
displayName: "Shell",
|
||||
summary: detail.command,
|
||||
};
|
||||
case "read":
|
||||
return {
|
||||
kind: "read",
|
||||
displayName: "Read",
|
||||
summary: stripCwdPrefix(detail.filePath, cwd),
|
||||
};
|
||||
case "edit":
|
||||
return {
|
||||
kind: "edit",
|
||||
displayName: "Edit",
|
||||
summary: stripCwdPrefix(detail.filePath, cwd),
|
||||
};
|
||||
case "write":
|
||||
return {
|
||||
kind: "write",
|
||||
displayName: "Write",
|
||||
summary: stripCwdPrefix(detail.filePath, cwd),
|
||||
};
|
||||
case "search":
|
||||
return {
|
||||
kind: "search",
|
||||
displayName: "Search",
|
||||
summary: detail.query,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
kind: "tool",
|
||||
displayName: "Tool",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildDisplayWithoutDetail(params: {
|
||||
toolNameLower: string;
|
||||
rawName: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): ToolCallDisplayModel {
|
||||
if (params.toolNameLower === "task") {
|
||||
const summary = params.metadata?.subAgentActivity;
|
||||
return {
|
||||
kind: "agent",
|
||||
displayName: "Task",
|
||||
summary: typeof summary === "string" && summary.trim().length > 0 ? summary : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.toolNameLower === "thinking") {
|
||||
return {
|
||||
kind: "thinking",
|
||||
displayName: "Thinking",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "tool",
|
||||
displayName: humanizeToolName(params.rawName),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildToolCallDisplayModel(params: SummaryParams): ToolCallDisplayModel {
|
||||
const parsed = TOOL_CALL_DISPLAY_INPUT_SCHEMA.parse(params);
|
||||
if (parsed.detail) {
|
||||
return buildDisplayFromDetail(parsed.detail, parsed.cwd);
|
||||
}
|
||||
|
||||
return buildDisplayWithoutDetail({
|
||||
toolNameLower: parsed.name.trim().toLowerCase(),
|
||||
rawName: parsed.name,
|
||||
metadata: parsed.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveToolCallDisplayName(name: string, detail?: ToolCallDetail): string {
|
||||
return buildToolCallDisplayModel({ name, detail }).displayName;
|
||||
}
|
||||
|
||||
export function resolveToolCallKind(name: string, detail?: ToolCallDetail): ToolCallKind {
|
||||
return buildToolCallDisplayModel({ name, detail }).kind;
|
||||
}
|
||||
|
||||
export function resolveToolCallSummary(params: SummaryParams): string | undefined {
|
||||
return buildToolCallDisplayModel(params).summary;
|
||||
}
|
||||
|
||||
export function formatToolCallError(error: unknown): string | undefined {
|
||||
if (error === null || error === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
"content" in (error as Record<string, unknown>) &&
|
||||
typeof (error as Record<string, unknown>).content === "string"
|
||||
) {
|
||||
return (error as Record<string, unknown>).content as string;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error, null, 2);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
@@ -1,428 +1,35 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
extractKeyValuePairs,
|
||||
parseToolCallDisplay,
|
||||
type ToolCallDisplayInfo,
|
||||
buildLineDiff,
|
||||
parseUnifiedDiff,
|
||||
extractTaskEntriesFromToolCall,
|
||||
} from "./tool-call-parsers";
|
||||
|
||||
describe("tool-call-parsers - real runtime shapes", () => {
|
||||
// Real data captured from Claude agent test: "shows the command inside pending tool calls"
|
||||
// Run: npx vitest run claude-agent.test.ts -t "shows the command"
|
||||
describe("tool-call-parsers", () => {
|
||||
it("builds line diff for text changes", () => {
|
||||
const diff = buildLineDiff("old\nline\n", "new\nline\n");
|
||||
|
||||
test("bash tool call - input shape", () => {
|
||||
// REAL shape from Claude SDK timeline event (status: pending/completed)
|
||||
const bashInput = {
|
||||
command: "pwd",
|
||||
description: "Print working directory",
|
||||
};
|
||||
|
||||
const pairs = extractKeyValuePairs(bashInput);
|
||||
expect(pairs).toContainEqual({ key: "command", value: "pwd" });
|
||||
expect(pairs).toContainEqual({ key: "description", value: "Print working directory" });
|
||||
expect(diff.some((entry) => entry.type === "remove")).toBe(true);
|
||||
expect(diff.some((entry) => entry.type === "add")).toBe(true);
|
||||
});
|
||||
|
||||
test("bash tool call - output shape (completed)", () => {
|
||||
// REAL shape from Claude SDK timeline event (status: completed)
|
||||
// NOTE: output already has type: "command" discriminator!
|
||||
const bashOutput = {
|
||||
type: "command",
|
||||
command: "pwd",
|
||||
output: "/private/var/folders/xl/kkk9drfd3ms_t8x7rmy4z6900000gn/T/claude-agent-e2e-9tnmUm",
|
||||
};
|
||||
it("parses unified diff", () => {
|
||||
const parsed = parseUnifiedDiff("@@\n-old\n+new\n");
|
||||
|
||||
const pairs = extractKeyValuePairs(bashOutput);
|
||||
expect(pairs).toContainEqual({ key: "type", value: "command" });
|
||||
expect(pairs).toContainEqual({ key: "command", value: "pwd" });
|
||||
expect(pairs).toContainEqual({ key: "output", value: expect.stringContaining("claude-agent") });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseToolCallDisplay", () => {
|
||||
test("parses completed bash tool call into shell detail", () => {
|
||||
const input = { command: "pwd", description: "Print working directory" };
|
||||
const output = { type: "command", command: "pwd", output: "/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");
|
||||
}
|
||||
expect(parsed.find((entry) => entry.type === "remove")?.content).toBe("-old");
|
||||
expect(parsed.find((entry) => entry.type === "add")?.content).toBe("+new");
|
||||
});
|
||||
|
||||
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 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("");
|
||||
}
|
||||
});
|
||||
|
||||
test("falls back to output command when shell input is missing", () => {
|
||||
const output = { type: "command", command: "pwd", output: "/some/path" };
|
||||
|
||||
const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Bash", output });
|
||||
expect(info.summary).toBe("pwd");
|
||||
expect(info.detail.type).toBe("shell");
|
||||
if (info.detail.type === "shell") {
|
||||
expect(info.detail.command).toBe("pwd");
|
||||
expect(info.detail.output).toBe("/some/path");
|
||||
}
|
||||
});
|
||||
|
||||
test("falls back to read output path when input is missing", () => {
|
||||
const info: ToolCallDisplayInfo = parseToolCallDisplay({
|
||||
name: "Read",
|
||||
output: {
|
||||
type: "file_read",
|
||||
filePath: "/some/file.txt",
|
||||
content: "hello",
|
||||
},
|
||||
it("extracts TodoWrite task entries", () => {
|
||||
const tasks = extractTaskEntriesFromToolCall("TodoWrite", {
|
||||
todos: [
|
||||
{ content: "Task 1", status: "pending" },
|
||||
{ content: "Task 2", status: "completed" },
|
||||
],
|
||||
});
|
||||
expect(info.summary).toBe("/some/file.txt");
|
||||
expect(info.detail.type).toBe("read");
|
||||
if (info.detail.type === "read") {
|
||||
expect(info.detail.filePath).toBe("/some/file.txt");
|
||||
expect(info.detail.content).toBe("hello");
|
||||
}
|
||||
});
|
||||
|
||||
test("strips shell + cd wrapper from command (Codex exec_command style)", () => {
|
||||
const input = {
|
||||
command:
|
||||
'/bin/zsh -lc "cd /Users/me/dev/paseo && nl -ba packages/app/src/utils/tool-call-parsers.test.ts | sed -n \'150,260p\'"',
|
||||
};
|
||||
|
||||
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'"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("handles command as array", () => {
|
||||
const input = { command: ["git", "status"] };
|
||||
const output = { type: "command", output: "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 info = parseToolCallDisplay({ name: "shell", input });
|
||||
expect(info.displayName).toBe("Shell");
|
||||
});
|
||||
|
||||
test("normalizes tool names - Bash to Shell", () => {
|
||||
const input = { command: "pwd" };
|
||||
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 info = parseToolCallDisplay({ name: "read_file", input });
|
||||
expect(info.displayName).toBe("Read");
|
||||
});
|
||||
|
||||
test("uses stable label for Edit in frontend", () => {
|
||||
const input = { file_path: "/some/file.txt", old_string: "a", new_string: "b" };
|
||||
const first = parseToolCallDisplay({ name: "Edit", input });
|
||||
const second = parseToolCallDisplay({ name: "Edit", input });
|
||||
expect(first.displayName).toBe("Edit");
|
||||
expect(second.displayName).toBe("Edit");
|
||||
});
|
||||
|
||||
test("normalizes tool names - paseo_voice.speak to Speak", () => {
|
||||
const input = { text: "hello from namespaced 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 info = parseToolCallDisplay({ name: "mcp__paseo_voice__speak", input });
|
||||
expect(info.displayName).toBe("Speak");
|
||||
});
|
||||
|
||||
test("preserves unknown tool names", () => {
|
||||
const input = { some_arg: "value" };
|
||||
const info = parseToolCallDisplay({ name: "MyCustomTool", input });
|
||||
expect(info.displayName).toBe("MyCustomTool");
|
||||
});
|
||||
|
||||
test("does not let Task metadata override non-Task summary", () => {
|
||||
const info = parseToolCallDisplay({
|
||||
name: "shell",
|
||||
input: { command: "pwd" },
|
||||
metadata: { subAgentActivity: "Read" },
|
||||
});
|
||||
expect(info.summary).toBe("pwd");
|
||||
});
|
||||
|
||||
test("parses non-command tool call into generic detail", () => {
|
||||
const input = { file_path: "/some/file.txt" };
|
||||
const output = { content: "file contents here", lineCount: 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 edit", () => {
|
||||
const input = { file_path: "/some/file.txt", content: "new content" };
|
||||
const output = { type: "file_write", filePath: "/some/file.txt" };
|
||||
|
||||
const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Write", input, output });
|
||||
expect(info.detail.type).toBe("edit");
|
||||
expect(info.summary).toBe("/some/file.txt");
|
||||
if (info.detail.type === "edit") {
|
||||
expect(info.detail.filePath).toBe("/some/file.txt");
|
||||
}
|
||||
});
|
||||
|
||||
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 detail with old_string/new_string", () => {
|
||||
const input = {
|
||||
file_path: "/some/file.txt",
|
||||
old_string: "const foo = 1;",
|
||||
new_string: "const foo = 2;",
|
||||
};
|
||||
const output = {
|
||||
type: "file_edit",
|
||||
filePath: "/some/file.txt",
|
||||
};
|
||||
|
||||
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;");
|
||||
}
|
||||
});
|
||||
|
||||
test("parses edit tool call with old_str/new_str variants", () => {
|
||||
const input = {
|
||||
file_path: "/some/file.txt",
|
||||
old_str: "line 1",
|
||||
new_str: "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");
|
||||
}
|
||||
});
|
||||
|
||||
test("parses pending edit tool call (no result yet)", () => {
|
||||
const input = {
|
||||
file_path: "/some/file.txt",
|
||||
old_string: "old content",
|
||||
new_string: "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 detail with unified diff", () => {
|
||||
const input = {
|
||||
files: [
|
||||
{
|
||||
path: "/Users/me/dev/blankpage/editor/.tasks/578561c8.md",
|
||||
kind: "update",
|
||||
},
|
||||
],
|
||||
};
|
||||
const output = {
|
||||
files: [
|
||||
{
|
||||
path: "/Users/me/dev/blankpage/editor/.tasks/578561c8.md",
|
||||
patch: "@@ -15,3 +15,2 @@\n-This task defines the **design philosophy**\n+This task defines the **updated philosophy**",
|
||||
kind: "update",
|
||||
},
|
||||
],
|
||||
message: "Success. Updated the following files:\nM .tasks/578561c8.md",
|
||||
success: true,
|
||||
};
|
||||
|
||||
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 detail", () => {
|
||||
const input = {
|
||||
files: [
|
||||
{
|
||||
path: "/Users/me/.paseo/worktrees/paseo/naive-zebra/packages/server/src/server/daemon-keypair.ts",
|
||||
kind: { type: "update", move_path: null },
|
||||
},
|
||||
],
|
||||
};
|
||||
const output = {
|
||||
files: [
|
||||
{
|
||||
path: "/Users/me/.paseo/worktrees/paseo/naive-zebra/packages/server/src/server/daemon-keypair.ts",
|
||||
patch: "@@ -1,1 +1,1 @@\n-foo\n+bar",
|
||||
kind: { type: "update", move_path: null },
|
||||
},
|
||||
],
|
||||
success: true,
|
||||
};
|
||||
|
||||
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(info.detail.unifiedDiff).toBe("@@ -1,1 +1,1 @@\n-foo\n+bar");
|
||||
}
|
||||
});
|
||||
|
||||
test("prefers move_path for display but still finds patch by original path", () => {
|
||||
const input = {
|
||||
files: [
|
||||
{
|
||||
path: "/some/old-path.txt",
|
||||
kind: { type: "update", move_path: "/some/new-path.txt" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const output = {
|
||||
files: [
|
||||
{
|
||||
path: "/some/old-path.txt",
|
||||
patch: "@@ -1,1 +1,1 @@\n-old\n+new",
|
||||
kind: { type: "update", move_path: "/some/new-path.txt" },
|
||||
},
|
||||
],
|
||||
success: true,
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
test("parses pending apply_patch (no result yet)", () => {
|
||||
const input = {
|
||||
files: [
|
||||
{
|
||||
path: "/some/file.txt",
|
||||
kind: "create",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
test("handles apply_patch with multiple files (uses first file)", () => {
|
||||
const input = {
|
||||
files: [
|
||||
{ path: "/first/file.txt", kind: "update" },
|
||||
{ path: "/second/file.txt", kind: "create" },
|
||||
],
|
||||
};
|
||||
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" },
|
||||
],
|
||||
success: true,
|
||||
};
|
||||
|
||||
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 detail", () => {
|
||||
const input = {
|
||||
path: "/Users/me/dev/blankpage/editor/.tasks/578561c8.md",
|
||||
};
|
||||
const output = {
|
||||
type: "read_file",
|
||||
path: "/Users/me/dev/blankpage/editor/.tasks/578561c8.md",
|
||||
content: "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`");
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex read_file stays read when result is missing", () => {
|
||||
const input = {
|
||||
path: "/some/file.txt",
|
||||
};
|
||||
|
||||
const info = parseToolCallDisplay({ name: "read_file", input });
|
||||
expect(info.detail.type).toBe("read");
|
||||
expect(info.displayName).toBe("Read");
|
||||
expect(tasks?.map((task) => task.text)).toEqual(["Task 1", "Task 2"]);
|
||||
expect(tasks?.map((task) => task.completed)).toEqual([false, true]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,31 +18,6 @@ export type DiffLine = {
|
||||
segments?: DiffSegment[];
|
||||
};
|
||||
|
||||
export type EditEntry = {
|
||||
filePath?: string;
|
||||
diffLines: DiffLine[];
|
||||
};
|
||||
|
||||
export type ReadEntry = {
|
||||
filePath?: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type CommandDetails = {
|
||||
command?: string;
|
||||
cwd?: string;
|
||||
output?: string;
|
||||
exitCode?: number | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function splitIntoLines(text: string): string[] {
|
||||
if (!text) {
|
||||
return [];
|
||||
@@ -323,612 +298,6 @@ export function parseUnifiedDiff(diffText?: string): DiffLine[] {
|
||||
return diff;
|
||||
}
|
||||
|
||||
function deriveDiffLines({
|
||||
unifiedDiff,
|
||||
original,
|
||||
updated,
|
||||
}: {
|
||||
unifiedDiff?: string;
|
||||
original?: string;
|
||||
updated?: string;
|
||||
}): DiffLine[] {
|
||||
if (unifiedDiff) {
|
||||
const parsed = parseUnifiedDiff(unifiedDiff);
|
||||
if (parsed.length > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (original !== undefined || updated !== undefined) {
|
||||
return buildLineDiff(original ?? "", updated ?? "");
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function looksLikePatch(text: string): boolean {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return /(\*\*\* Begin Patch|@@|diff --git|\+\+\+|--- )/.test(text);
|
||||
}
|
||||
|
||||
function parsePatchText(text: string): DiffLine[] {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
return parseUnifiedDiff(text);
|
||||
}
|
||||
|
||||
function getFilePathFromRecord(record: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
getString(record["file_path"]) ??
|
||||
getString(record["filePath"]) ??
|
||||
getString(record["path"]) ??
|
||||
getString(record["target_path"]) ??
|
||||
getString(record["targetPath"]) ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
const ChangeBlockSchema = z
|
||||
.object({
|
||||
unified_diff: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
patch: z.string().optional(),
|
||||
old_content: z.string().optional(),
|
||||
oldContent: z.string().optional(),
|
||||
previous_content: z.string().optional(),
|
||||
previousContent: z.string().optional(),
|
||||
base_content: z.string().optional(),
|
||||
baseContent: z.string().optional(),
|
||||
old_string: z.string().optional(),
|
||||
new_string: z.string().optional(),
|
||||
new_content: z.string().optional(),
|
||||
newContent: z.string().optional(),
|
||||
replace_with: z.string().optional(),
|
||||
replaceWith: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function buildEditEntryFromBlock(
|
||||
filePath: string | undefined,
|
||||
blockValue: Record<string, unknown>
|
||||
): EditEntry | null {
|
||||
const parsed = ChangeBlockSchema.safeParse(blockValue);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const diffLines = deriveDiffLines({
|
||||
unifiedDiff:
|
||||
getString(
|
||||
data.unified_diff ??
|
||||
data.unifiedDiff ??
|
||||
data.patch ??
|
||||
data.diff
|
||||
) ?? undefined,
|
||||
original:
|
||||
getString(
|
||||
data.old_string ??
|
||||
data.old_content ??
|
||||
data.oldContent ??
|
||||
data.previous_content ??
|
||||
data.previousContent ??
|
||||
data.base_content ??
|
||||
data.baseContent
|
||||
) ?? undefined,
|
||||
updated:
|
||||
getString(
|
||||
data.new_string ??
|
||||
data.new_content ??
|
||||
data.newContent ??
|
||||
data.replace_with ??
|
||||
data.replaceWith ??
|
||||
data.content
|
||||
) ?? undefined,
|
||||
});
|
||||
|
||||
if (diffLines.length > 0) {
|
||||
return {
|
||||
filePath: filePath ?? getFilePathFromRecord(blockValue),
|
||||
diffLines,
|
||||
};
|
||||
}
|
||||
|
||||
const patchCandidate =
|
||||
getString(data.unified_diff ?? data.unifiedDiff ?? data.patch ?? data.diff) ??
|
||||
undefined;
|
||||
if (patchCandidate && looksLikePatch(patchCandidate)) {
|
||||
const parsedLines = parsePatchText(patchCandidate);
|
||||
if (parsedLines.length > 0) {
|
||||
return {
|
||||
filePath: filePath ?? getFilePathFromRecord(blockValue),
|
||||
diffLines: parsedLines,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeEditEntries(entries: EditEntry[]): EditEntry[] {
|
||||
if (entries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Map<string, EditEntry>();
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.diffLines.length) {
|
||||
return;
|
||||
}
|
||||
const hash = `${entry.filePath ?? "unknown"}::${entry.diffLines
|
||||
.map((line) => `${line.type}:${line.content}`)
|
||||
.join("|")}`;
|
||||
if (!seen.has(hash)) {
|
||||
seen.set(hash, entry);
|
||||
}
|
||||
});
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
function parseEditArguments(value: unknown, depth = 0): EditEntry[] {
|
||||
if (!value || depth > 5) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (looksLikePatch(value)) {
|
||||
const diffLines = parsePatchText(value);
|
||||
return diffLines.length ? [{ diffLines }] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((entry) => parseEditArguments(entry, depth + 1));
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filePathHint = getFilePathFromRecord(value) ?? getString(value["name"]);
|
||||
|
||||
if (value["patch"] || value["diff"] || value["unified_diff"] || value["unifiedDiff"]) {
|
||||
const entry = buildEditEntryFromBlock(filePathHint, value);
|
||||
return entry ? [entry] : [];
|
||||
}
|
||||
|
||||
const entries: EditEntry[] = [];
|
||||
const changeKeys = [
|
||||
"changes",
|
||||
"files",
|
||||
"fileChanges",
|
||||
"file_changes",
|
||||
"edits",
|
||||
"diffs",
|
||||
"patches",
|
||||
"fileDiffs",
|
||||
"file_diffs",
|
||||
] as const;
|
||||
|
||||
for (const key of changeKeys) {
|
||||
const block = value[key];
|
||||
if (!block) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(block)) {
|
||||
for (const item of block) {
|
||||
if (isRecord(item)) {
|
||||
const entry = buildEditEntryFromBlock(filePathHint, item);
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isRecord(block)) {
|
||||
if (block["patch"] || block["diff"]) {
|
||||
const entry = buildEditEntryFromBlock(filePathHint, block);
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const [path, nested] of Object.entries(block)) {
|
||||
if (isRecord(nested)) {
|
||||
const entry = buildEditEntryFromBlock(path, nested);
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
} else if (typeof nested === "string" && looksLikePatch(nested)) {
|
||||
const diffLines = parsePatchText(nested);
|
||||
if (diffLines.length) {
|
||||
entries.push({ filePath: path, diffLines });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const changeEntry = buildEditEntryFromBlock(filePathHint, value);
|
||||
if (changeEntry) {
|
||||
entries.push(changeEntry);
|
||||
}
|
||||
|
||||
const nestedKeys = [
|
||||
"create",
|
||||
"delete",
|
||||
"raw",
|
||||
"data",
|
||||
"payload",
|
||||
"arguments",
|
||||
"result",
|
||||
] as const;
|
||||
for (const key of nestedKeys) {
|
||||
if (value[key] !== undefined) {
|
||||
const nestedEntries = parseEditArguments(value[key], depth + 1);
|
||||
entries.push(
|
||||
...nestedEntries.map((entry) => ({
|
||||
...entry,
|
||||
filePath: entry.filePath ?? filePathHint,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
const ReadContainerSchema = z
|
||||
.object({
|
||||
filePath: z.string().optional(),
|
||||
file_path: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
blob: z.string().optional(),
|
||||
data: z
|
||||
.object({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
structuredContent: z
|
||||
.object({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
data: z
|
||||
.object({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
structured_content: z
|
||||
.object({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
output: z
|
||||
.object({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function parseReadEntriesInternal(value: unknown, depth = 0): ReadEntry[] {
|
||||
if (!value || depth > 4) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? [{ content: value }] : [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((entry) => parseReadEntriesInternal(entry, depth + 1));
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = ReadContainerSchema.safeParse(value);
|
||||
if (parsed.success) {
|
||||
const data = parsed.data;
|
||||
const content =
|
||||
getString(data.content) ??
|
||||
getString(data.text) ??
|
||||
getString(data.blob) ??
|
||||
getString(data.data?.content) ??
|
||||
getString(data.data?.text) ??
|
||||
getString(data.structuredContent?.content) ??
|
||||
getString(data.structuredContent?.text) ??
|
||||
getString(data.structuredContent?.data?.content) ??
|
||||
getString(data.structuredContent?.data?.text) ??
|
||||
getString(data.structured_content?.content) ??
|
||||
getString(data.structured_content?.text) ??
|
||||
getString(data.output?.content) ??
|
||||
getString(data.output?.text);
|
||||
if (content) {
|
||||
return [
|
||||
{
|
||||
filePath: data.filePath ?? data.file_path ?? data.path,
|
||||
content,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const nestedKeys = [
|
||||
"output",
|
||||
"result",
|
||||
"structuredContent",
|
||||
"structured_content",
|
||||
"data",
|
||||
"raw",
|
||||
"value",
|
||||
"content",
|
||||
] as const;
|
||||
const entries: ReadEntry[] = [];
|
||||
for (const key of nestedKeys) {
|
||||
if (value[key] !== undefined) {
|
||||
entries.push(...parseReadEntriesInternal(value[key], depth + 1));
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function mergeReadEntries(entries: ReadEntry[]): ReadEntry[] {
|
||||
if (!entries.length) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Map<string, ReadEntry>();
|
||||
entries.forEach((entry) => {
|
||||
const hash = `${entry.filePath ?? "content"}::${entry.content}`;
|
||||
if (!seen.has(hash)) {
|
||||
seen.set(hash, entry);
|
||||
}
|
||||
});
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
const CommandRawSchema = z
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
command: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
aggregated_output: z.string().optional(),
|
||||
exit_code: z.number().optional(),
|
||||
cwd: z.string().optional(),
|
||||
directory: z.string().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
exit_code: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
input: z.unknown().optional(),
|
||||
output: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CommandResultSchema = z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
exitCode: z.number().nullable().optional(),
|
||||
structuredContent: z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
structured_content: z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
exit_code: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
result: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function coerceCommandValue(value: unknown): string | undefined {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const tokens = value.filter((entry): entry is string => typeof entry === "string");
|
||||
if (tokens.length) {
|
||||
return tokens.join(" ");
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectCommandDetails(
|
||||
target: CommandDetails,
|
||||
value: unknown,
|
||||
depth = 0
|
||||
): void {
|
||||
if (!value || depth > 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (!target.output) {
|
||||
target.output = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawParsed = CommandRawSchema.safeParse(value);
|
||||
if (rawParsed.success) {
|
||||
const data = rawParsed.data;
|
||||
const commandCandidate =
|
||||
coerceCommandValue(data.command) ??
|
||||
(isRecord(data.input) ? coerceCommandValue(data.input["command"]) : undefined);
|
||||
if (!target.command && commandCandidate) {
|
||||
target.command = commandCandidate;
|
||||
}
|
||||
const cwdCandidate =
|
||||
getString(data.cwd ?? data.directory) ??
|
||||
(isRecord(data.input)
|
||||
? getString(data.input["cwd"] ?? data.input["directory"])
|
||||
: undefined);
|
||||
if (!target.cwd && cwdCandidate) {
|
||||
target.cwd = cwdCandidate;
|
||||
}
|
||||
const aggregatedOutput =
|
||||
getString(data.aggregated_output) ??
|
||||
(isRecord(data.output)
|
||||
? getString(
|
||||
(data.output as Record<string, unknown>)["aggregated_output"] ??
|
||||
(data.output as Record<string, unknown>)["output"] ??
|
||||
(data.output as Record<string, unknown>)["text"]
|
||||
)
|
||||
: undefined);
|
||||
if (!target.output && aggregatedOutput) {
|
||||
target.output = aggregatedOutput;
|
||||
}
|
||||
const exitCandidate =
|
||||
data.exit_code ??
|
||||
(data.metadata ? data.metadata.exit_code : undefined) ??
|
||||
(isRecord(data.output)
|
||||
? ((data.output as Record<string, unknown>)["exit_code"] as number | undefined) ??
|
||||
((data.output as Record<string, unknown>)["exitCode"] as number | undefined)
|
||||
: undefined);
|
||||
if (target.exitCode === undefined && exitCandidate !== undefined) {
|
||||
target.exitCode = exitCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
const resultParsed = CommandResultSchema.safeParse(value);
|
||||
if (resultParsed.success) {
|
||||
const data = resultParsed.data;
|
||||
if (!target.output) {
|
||||
target.output =
|
||||
getString(data.output) ??
|
||||
getString(data.structuredContent?.output) ??
|
||||
getString(data.structuredContent?.text) ??
|
||||
getString(data.structured_content?.output) ??
|
||||
getString(data.structured_content?.text) ??
|
||||
(typeof data.result === "string" ? data.result : undefined);
|
||||
}
|
||||
if (target.exitCode === undefined) {
|
||||
target.exitCode = data.exitCode ?? data.metadata?.exit_code;
|
||||
}
|
||||
if (!target.command && isRecord(data.result)) {
|
||||
const nestedCommand =
|
||||
coerceCommandValue(data.result["command"]) ??
|
||||
coerceCommandValue((data.result as Record<string, unknown>)["args"]);
|
||||
if (nestedCommand) {
|
||||
target.command = nestedCommand;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nestedKeys = [
|
||||
"input",
|
||||
"output",
|
||||
"result",
|
||||
"response",
|
||||
"data",
|
||||
"raw",
|
||||
"payload",
|
||||
] as const;
|
||||
for (const key of nestedKeys) {
|
||||
if (value[key] !== undefined) {
|
||||
collectCommandDetails(target, value[key], depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function extractEditEntries(...sources: unknown[]): EditEntry[] {
|
||||
const entries = sources.flatMap((value) => parseEditArguments(value));
|
||||
return mergeEditEntries(entries);
|
||||
}
|
||||
|
||||
export function extractReadEntries(...sources: unknown[]): ReadEntry[] {
|
||||
return mergeReadEntries(sources.flatMap((value) => parseReadEntriesInternal(value)));
|
||||
}
|
||||
|
||||
export function extractCommandDetails(...sources: unknown[]): CommandDetails | null {
|
||||
const details: CommandDetails = {};
|
||||
sources.forEach((value) => collectCommandDetails(details, value));
|
||||
if (details.command || details.output || details.cwd) {
|
||||
return details;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- Key-Value Extraction for Generic Tool Results ----
|
||||
|
||||
export interface KeyValuePair {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function stringifyValue(value: unknown): string {
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
if (value === undefined) {
|
||||
return "undefined";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const record = parsed.data;
|
||||
return Object.entries(record).map(([key, value]) => ({
|
||||
key,
|
||||
value: stringifyValue(value),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- Task Extraction (cross-provider) ----
|
||||
|
||||
export type TaskStatus = "pending" | "in_progress" | "completed";
|
||||
@@ -1007,19 +376,3 @@ export function extractTaskEntriesFromToolCall(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- Unified Tool Call Display ----
|
||||
// Re-export from server — single source of truth
|
||||
export {
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user