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 { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
|
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
import { QuestionFormCard } from "./question-form-card";
|
import { QuestionFormCard } from "./question-form-card";
|
||||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||||
@@ -363,11 +362,11 @@ export function AgentStreamView({
|
|||||||
return (
|
return (
|
||||||
<ToolCall
|
<ToolCall
|
||||||
toolName={data.name}
|
toolName={data.name}
|
||||||
provider={data.provider}
|
|
||||||
args={data.input}
|
args={data.input}
|
||||||
result={data.result}
|
result={data.result}
|
||||||
error={data.error}
|
error={data.error}
|
||||||
status={data.status as "executing" | "completed" | "failed"}
|
status={data.status}
|
||||||
|
detail={data.detail}
|
||||||
cwd={agent.cwd}
|
cwd={agent.cwd}
|
||||||
metadata={data.metadata}
|
metadata={data.metadata}
|
||||||
isLastInSequence={isLastInSequence}
|
isLastInSequence={isLastInSequence}
|
||||||
@@ -904,17 +903,6 @@ function PermissionRequestCard({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [request]);
|
}, [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 markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||||
|
|
||||||
const markdownRules = useMemo(() => {
|
const markdownRules = useMemo(() => {
|
||||||
@@ -1133,8 +1121,12 @@ function PermissionRequestCard({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isPlanRequest && toolCallDisplay ? (
|
{!isPlanRequest ? (
|
||||||
<ToolCallDetailsContent detail={toolCallDisplay.detail} maxHeight={200} />
|
<ToolCallDetailsContent
|
||||||
|
input={request.input ?? null}
|
||||||
|
output={null}
|
||||||
|
maxHeight={200}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -62,18 +62,22 @@ import {
|
|||||||
useUnistyles,
|
useUnistyles,
|
||||||
UnistylesRuntime,
|
UnistylesRuntime,
|
||||||
} from "react-native-unistyles";
|
} from "react-native-unistyles";
|
||||||
import { baseColors, theme } from "@/styles/theme";
|
import { theme } from "@/styles/theme";
|
||||||
import {
|
import {
|
||||||
createMarkdownStyles,
|
createMarkdownStyles,
|
||||||
} from "@/styles/markdown-styles";
|
} from "@/styles/markdown-styles";
|
||||||
import { Colors, Fonts } from "@/constants/theme";
|
import { Colors, Fonts } from "@/constants/theme";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import type { TodoEntry } from "@/types/stream";
|
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 { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||||
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
||||||
export 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 { useToolCallSheet } from "./tool-call-sheet";
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
|
|
||||||
@@ -106,6 +110,29 @@ function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
|
|||||||
return disableOuterSpacing ?? contextValue;
|
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) => ({
|
const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -418,8 +445,9 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
shimmerOverlay: {
|
shimmerOverlay: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 3,
|
||||||
bottom: 0,
|
bottom: 3,
|
||||||
|
borderRadius: 999,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1026,6 +1054,14 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
}, [isLoading]);
|
}, [isLoading]);
|
||||||
|
|
||||||
const shimmerBandWidth = 14;
|
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 shimmerStyle = useAnimatedStyle(() => {
|
||||||
const travel = badgeWidth + shimmerBandWidth;
|
const travel = badgeWidth + shimmerBandWidth;
|
||||||
return {
|
return {
|
||||||
@@ -1112,13 +1148,13 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
x2="1"
|
x2="1"
|
||||||
y2="0"
|
y2="0"
|
||||||
>
|
>
|
||||||
<Stop offset="0" stopColor={baseColors.white} stopOpacity="0" />
|
<Stop offset="0" stopColor={shimmerGradientColors.edge} />
|
||||||
<Stop offset="0.34" stopColor={baseColors.white} stopOpacity="0" />
|
<Stop offset="0.38" stopColor={shimmerGradientColors.edge} />
|
||||||
<Stop offset="0.46" stopColor={baseColors.white} stopOpacity="0.12" />
|
<Stop offset="0.48" stopColor={shimmerGradientColors.soft} />
|
||||||
<Stop offset="0.5" stopColor={baseColors.white} stopOpacity="0.34" />
|
<Stop offset="0.5" stopColor={shimmerGradientColors.core} />
|
||||||
<Stop offset="0.54" stopColor={baseColors.white} stopOpacity="0.12" />
|
<Stop offset="0.52" stopColor={shimmerGradientColors.soft} />
|
||||||
<Stop offset="0.66" stopColor={baseColors.white} stopOpacity="0" />
|
<Stop offset="0.62" stopColor={shimmerGradientColors.edge} />
|
||||||
<Stop offset="1" stopColor={baseColors.white} stopOpacity="0" />
|
<Stop offset="1" stopColor={shimmerGradientColors.edge} />
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</Defs>
|
</Defs>
|
||||||
<Rect width="100%" height="100%" fill="url(#shimmerGrad)" />
|
<Rect width="100%" height="100%" fill="url(#shimmerGrad)" />
|
||||||
@@ -1155,11 +1191,11 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
|
|
||||||
interface ToolCallProps {
|
interface ToolCallProps {
|
||||||
toolName: string;
|
toolName: string;
|
||||||
provider?: string;
|
|
||||||
args: any;
|
args: any;
|
||||||
result?: any;
|
result?: any;
|
||||||
error?: any;
|
error?: any;
|
||||||
status: "executing" | "completed" | "failed";
|
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||||
|
detail?: ToolCallDetail;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
isLastInSequence?: boolean;
|
isLastInSequence?: boolean;
|
||||||
@@ -1183,11 +1219,11 @@ const TOOL_CALL_COMMIT_THRESHOLD_MS = 16;
|
|||||||
|
|
||||||
export const ToolCall = memo(function ToolCall({
|
export const ToolCall = memo(function ToolCall({
|
||||||
toolName,
|
toolName,
|
||||||
provider,
|
|
||||||
args,
|
args,
|
||||||
result,
|
result,
|
||||||
error,
|
error,
|
||||||
status,
|
status,
|
||||||
|
detail,
|
||||||
cwd,
|
cwd,
|
||||||
metadata,
|
metadata,
|
||||||
isLastInSequence = false,
|
isLastInSequence = false,
|
||||||
@@ -1204,20 +1240,14 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
UnistylesRuntime.breakpoint === "xs" ||
|
UnistylesRuntime.breakpoint === "xs" ||
|
||||||
UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
const displayInfo = useMemo(
|
const displayModel = useMemo(
|
||||||
() =>
|
() => buildToolCallDisplayModel({ name: toolName, detail, metadata, cwd }),
|
||||||
parseToolCallDisplay({
|
[toolName, detail, metadata, cwd]
|
||||||
name: toolName,
|
|
||||||
provider,
|
|
||||||
input: args,
|
|
||||||
output: result,
|
|
||||||
error,
|
|
||||||
metadata,
|
|
||||||
cwd,
|
|
||||||
}),
|
|
||||||
[toolName, provider, args, result, error, 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;
|
const IconComponent = toolKindIcons[kind] || Wrench;
|
||||||
|
|
||||||
// Check if there's any content to display
|
// Check if there's any content to display
|
||||||
@@ -1229,11 +1259,19 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
toggleStartRef.current = getNowMs();
|
toggleStartRef.current = getNowMs();
|
||||||
}
|
}
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
openToolCall(displayInfo);
|
openToolCall({
|
||||||
|
kind,
|
||||||
|
displayName,
|
||||||
|
summary,
|
||||||
|
detail,
|
||||||
|
input: args,
|
||||||
|
output: result,
|
||||||
|
errorText,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setIsExpanded((prev) => !prev);
|
setIsExpanded((prev) => !prev);
|
||||||
}
|
}
|
||||||
}, [isMobile, openToolCall, displayInfo]);
|
}, [isMobile, openToolCall, kind, displayName, summary, detail, args, result, errorText]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile || !isPerfLoggingEnabled()) {
|
if (isMobile || !isPerfLoggingEnabled()) {
|
||||||
@@ -1292,8 +1330,16 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
// Render inline details for desktop
|
// Render inline details for desktop
|
||||||
const renderDetails = useCallback(() => {
|
const renderDetails = useCallback(() => {
|
||||||
if (isMobile) return null;
|
if (isMobile) return null;
|
||||||
return <ToolCallDetailsContent detail={detail} errorText={errorText} maxHeight={400} />;
|
return (
|
||||||
}, [isMobile, detail, errorText]);
|
<ToolCallDetailsContent
|
||||||
|
detail={detail}
|
||||||
|
input={args}
|
||||||
|
output={result}
|
||||||
|
errorText={errorText}
|
||||||
|
maxHeight={400}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [isMobile, detail, args, result, errorText]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExpandableBadge
|
<ExpandableBadge
|
||||||
@@ -1310,7 +1356,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
? () => null
|
? () => null
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
isLoading={status === "executing"}
|
isLoading={status === "executing" || status === "running"}
|
||||||
isError={status === "failed"}
|
isError={status === "failed"}
|
||||||
isLastInSequence={isLastInSequence}
|
isLastInSequence={isLastInSequence}
|
||||||
disableOuterSpacing={disableOuterSpacing}
|
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 { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
|
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||||
import {
|
import {
|
||||||
buildLineDiff,
|
buildLineDiff,
|
||||||
parseUnifiedDiff,
|
parseUnifiedDiff,
|
||||||
type ToolCallDetail,
|
|
||||||
} from "@/utils/tool-call-parsers";
|
} from "@/utils/tool-call-parsers";
|
||||||
import { DiffViewer } from "./diff-viewer";
|
import { DiffViewer } from "./diff-viewer";
|
||||||
import { getCodeInsets } from "./code-insets";
|
import { getCodeInsets } from "./code-insets";
|
||||||
@@ -16,34 +16,39 @@ const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
|
|||||||
// ---- Content Component ----
|
// ---- Content Component ----
|
||||||
|
|
||||||
interface ToolCallDetailsContentProps {
|
interface ToolCallDetailsContentProps {
|
||||||
detail: ToolCallDetail;
|
detail?: ToolCallDetail;
|
||||||
|
input?: unknown | null;
|
||||||
|
output?: unknown | null;
|
||||||
errorText?: string;
|
errorText?: string;
|
||||||
maxHeight?: number;
|
maxHeight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolCallDetailsContent({
|
export function ToolCallDetailsContent({
|
||||||
detail,
|
detail,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
errorText,
|
errorText,
|
||||||
maxHeight = 300,
|
maxHeight = 300,
|
||||||
}: ToolCallDetailsContentProps) {
|
}: ToolCallDetailsContentProps) {
|
||||||
// Compute diff lines for edit type
|
// Compute diff lines for edit type
|
||||||
const diffLines = useMemo(() => {
|
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)
|
// Use pre-computed unified diff if available (e.g., from apply_patch)
|
||||||
if (detail.unifiedDiff) {
|
if (detail.unifiedDiff) {
|
||||||
return parseUnifiedDiff(detail.unifiedDiff);
|
return parseUnifiedDiff(detail.unifiedDiff);
|
||||||
}
|
}
|
||||||
return buildLineDiff(detail.oldString, detail.newString);
|
return buildLineDiff(detail.oldString ?? "", detail.newString ?? "");
|
||||||
}, [detail]);
|
}, [detail]);
|
||||||
|
|
||||||
const sections: ReactNode[] = [];
|
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;
|
const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer;
|
||||||
|
|
||||||
if (detail.type === "shell") {
|
if (detail?.type === "shell") {
|
||||||
const command = detail.command.replace(/\n+$/, "");
|
const command = detail.command.replace(/\n+$/, "");
|
||||||
const output = detail.output.replace(/^\n+/, "");
|
const commandOutput = (detail.output ?? "").replace(/^\n+/, "");
|
||||||
const hasOutput = output.length > 0;
|
const hasOutput = commandOutput.length > 0;
|
||||||
sections.push(
|
sections.push(
|
||||||
<View key="shell" style={styles.section}>
|
<View key="shell" style={styles.section}>
|
||||||
<View style={codeBlockStyle}>
|
<View style={codeBlockStyle}>
|
||||||
@@ -63,7 +68,7 @@ export function ToolCallDetailsContent({
|
|||||||
<Text selectable style={styles.scrollText}>
|
<Text selectable style={styles.scrollText}>
|
||||||
<Text style={styles.shellPrompt}>$ </Text>
|
<Text style={styles.shellPrompt}>$ </Text>
|
||||||
{command}
|
{command}
|
||||||
{hasOutput ? `\n\n${output}` : ""}
|
{hasOutput ? `\n\n${commandOutput}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -71,7 +76,7 @@ export function ToolCallDetailsContent({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
} else if (detail.type === "edit") {
|
} else if (detail?.type === "edit") {
|
||||||
sections.push(
|
sections.push(
|
||||||
<View key="edit" style={styles.section}>
|
<View key="edit" style={styles.section}>
|
||||||
{diffLines ? (
|
{diffLines ? (
|
||||||
@@ -81,7 +86,28 @@ export function ToolCallDetailsContent({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</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(
|
sections.push(
|
||||||
<View key="read" style={styles.section}>
|
<View key="read" style={styles.section}>
|
||||||
{(detail.offset !== undefined || detail.limit !== undefined) ? (
|
{(detail.offset !== undefined || detail.limit !== undefined) ? (
|
||||||
@@ -109,68 +135,49 @@ export function ToolCallDetailsContent({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
} else if (detail.type === "thinking") {
|
} else if (detail?.type === "search") {
|
||||||
// Thinking: display the content as plain text
|
|
||||||
sections.push(
|
sections.push(
|
||||||
<View key="thinking" style={styles.section}>
|
<View key="search" style={styles.section}>
|
||||||
<ScrollView
|
<Text selectable style={styles.scrollText}>{detail.query}</Text>
|
||||||
style={[styles.scrollArea, { maxHeight }]}
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
nestedScrollEnabled
|
|
||||||
showsVerticalScrollIndicator={true}
|
|
||||||
>
|
|
||||||
<Text selectable style={styles.scrollText}>{detail.content}</Text>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Generic tool: show input/output as key-value pairs
|
const sectionsFromTopLevel = [
|
||||||
if (detail.input.length > 0) {
|
{ title: "Input", value: input },
|
||||||
sections.push(
|
{ title: "Output", value: output },
|
||||||
<View key="input-header" style={styles.groupHeader}>
|
].filter((entry) => entry.value !== null && entry.value !== undefined);
|
||||||
<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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
sections.push(
|
||||||
<View key="output-header" style={styles.groupHeader}>
|
<View key={`${section.title}-header`} style={styles.groupHeader}>
|
||||||
<Text style={styles.groupHeaderText}>Output</Text>
|
<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>
|
</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,
|
BottomSheetBackgroundProps,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { Pencil, Eye, SquareTerminal, Search, Bot, Wrench, X } from "lucide-react-native";
|
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";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
|
|
||||||
// ----- Types -----
|
// ----- 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 {
|
interface ToolCallSheetContextValue {
|
||||||
openToolCall: (data: ToolCallSheetData) => void;
|
openToolCall: (data: ToolCallSheetData) => void;
|
||||||
@@ -133,7 +142,7 @@ interface ToolCallSheetContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ToolCallSheetContent({ data, onClose }: 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;
|
const IconComponent = toolKindIcons[kind] || Wrench;
|
||||||
|
|
||||||
@@ -157,7 +166,12 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
|||||||
style={styles.content}
|
style={styles.content}
|
||||||
contentContainerStyle={styles.contentContainer}
|
contentContainerStyle={styles.contentContainer}
|
||||||
>
|
>
|
||||||
<ToolCallDetailsContent detail={detail} errorText={errorText} />
|
<ToolCallDetailsContent
|
||||||
|
detail={detail}
|
||||||
|
input={input}
|
||||||
|
output={output}
|
||||||
|
errorText={errorText}
|
||||||
|
/>
|
||||||
</BottomSheetScrollView>
|
</BottomSheetScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ const toolCallEvent = (): AgentStreamEventPayload => ({
|
|||||||
provider: "codex",
|
provider: "codex",
|
||||||
item: {
|
item: {
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
|
callId: "buffer-tool-call",
|
||||||
name: "run",
|
name: "run",
|
||||||
status: "executing",
|
status: "running",
|
||||||
|
input: { command: "echo hi" },
|
||||||
|
output: null,
|
||||||
|
error: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import {
|
|||||||
hydrateStreamState,
|
hydrateStreamState,
|
||||||
type StreamItem,
|
type StreamItem,
|
||||||
type AgentToolCallItem,
|
type AgentToolCallItem,
|
||||||
type ToolCallStatus,
|
|
||||||
isAgentToolCallItem,
|
isAgentToolCallItem,
|
||||||
} from "./stream";
|
} from "./stream";
|
||||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||||
|
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
type HarnessUpdate = { event: AgentStreamEventPayload; timestamp: Date };
|
type HarnessUpdate = { event: AgentStreamEventPayload; timestamp: Date };
|
||||||
|
type ToolStatus = "running" | "completed" | "failed" | "canceled";
|
||||||
|
|
||||||
const HARNESS_CALL_IDS = {
|
const HARNESS_CALL_IDS = {
|
||||||
command: "harness-command",
|
command: "harness-command",
|
||||||
@@ -31,60 +32,80 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
|
|||||||
timestamp: new Date("2025-02-01T10:00:00Z"),
|
timestamp: new Date("2025-02-01T10:00:00Z"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
event: buildToolStartEvent({
|
event: buildToolEvent({
|
||||||
callId: HARNESS_CALL_IDS.edit,
|
callId: HARNESS_CALL_IDS.edit,
|
||||||
name: "apply_patch",
|
name: "apply_patch",
|
||||||
|
status: "running",
|
||||||
input: {
|
input: {
|
||||||
file_path: "README.md",
|
file_path: "README.md",
|
||||||
patch: "*** Begin Patch\n*** Update File: README.md\n@@\n-Old line\n+New line\n*** End Patch",
|
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"),
|
timestamp: new Date("2025-02-01T10:00:01Z"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
event: buildToolResultEvent({
|
event: buildToolEvent({
|
||||||
callId: HARNESS_CALL_IDS.edit,
|
callId: HARNESS_CALL_IDS.edit,
|
||||||
name: "apply_patch",
|
name: "apply_patch",
|
||||||
|
status: "completed",
|
||||||
output: {
|
output: {
|
||||||
changes: [
|
files: [
|
||||||
{
|
{
|
||||||
file_path: "README.md",
|
path: "README.md",
|
||||||
previous_content: "Old line\n",
|
patch: "@@\n-Old line\n+New line",
|
||||||
content: "New line\n",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
input: null,
|
||||||
}),
|
}),
|
||||||
timestamp: new Date("2025-02-01T10:00:02Z"),
|
timestamp: new Date("2025-02-01T10:00:02Z"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
event: buildToolStartEvent({
|
event: buildToolEvent({
|
||||||
callId: HARNESS_CALL_IDS.read,
|
callId: HARNESS_CALL_IDS.read,
|
||||||
name: "read_file",
|
name: "read_file",
|
||||||
|
status: "running",
|
||||||
input: { file_path: "README.md" },
|
input: { file_path: "README.md" },
|
||||||
|
detail: {
|
||||||
|
type: "read",
|
||||||
|
filePath: "README.md",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
timestamp: new Date("2025-02-01T10:00:03Z"),
|
timestamp: new Date("2025-02-01T10:00:03Z"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
event: buildToolResultEvent({
|
event: buildToolEvent({
|
||||||
callId: HARNESS_CALL_IDS.read,
|
callId: HARNESS_CALL_IDS.read,
|
||||||
name: "read_file",
|
name: "read_file",
|
||||||
|
status: "completed",
|
||||||
output: { content: "# README\nNew line\n" },
|
output: { content: "# README\nNew line\n" },
|
||||||
|
input: null,
|
||||||
}),
|
}),
|
||||||
timestamp: new Date("2025-02-01T10:00:04Z"),
|
timestamp: new Date("2025-02-01T10:00:04Z"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
event: buildToolStartEvent({
|
event: buildToolEvent({
|
||||||
callId: HARNESS_CALL_IDS.command,
|
callId: HARNESS_CALL_IDS.command,
|
||||||
name: "shell",
|
name: "shell",
|
||||||
|
status: "running",
|
||||||
input: { command: "ls" },
|
input: { command: "ls" },
|
||||||
|
detail: {
|
||||||
|
type: "shell",
|
||||||
|
command: "ls",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
timestamp: new Date("2025-02-01T10:00:05Z"),
|
timestamp: new Date("2025-02-01T10:00:05Z"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
event: buildToolResultEvent({
|
event: buildToolEvent({
|
||||||
callId: HARNESS_CALL_IDS.command,
|
callId: HARNESS_CALL_IDS.command,
|
||||||
name: "shell",
|
name: "shell",
|
||||||
|
status: "completed",
|
||||||
output: {
|
output: {
|
||||||
result: {
|
result: {
|
||||||
command: "ls",
|
command: "ls",
|
||||||
@@ -92,12 +113,12 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
|
|||||||
},
|
},
|
||||||
metadata: { exit_code: 0, cwd: "/tmp/harness" },
|
metadata: { exit_code: 0, cwd: "/tmp/harness" },
|
||||||
},
|
},
|
||||||
|
input: null,
|
||||||
}),
|
}),
|
||||||
timestamp: new Date("2025-02-01T10:00:06Z"),
|
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[] = [
|
const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [
|
||||||
{
|
{
|
||||||
event: {
|
event: {
|
||||||
@@ -112,96 +133,112 @@ const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [
|
|||||||
timestamp: new Date("2025-02-01T10:05:00Z"),
|
timestamp: new Date("2025-02-01T10:05:00Z"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
event: buildToolStartEvent({
|
event: buildToolEvent({
|
||||||
callId: HARNESS_CALL_IDS.edit,
|
callId: HARNESS_CALL_IDS.edit,
|
||||||
name: "apply_patch",
|
name: "apply_patch",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
|
input: {
|
||||||
|
file_path: "README.md",
|
||||||
|
},
|
||||||
|
output: null,
|
||||||
}),
|
}),
|
||||||
timestamp: new Date("2025-02-01T10:05:01Z"),
|
timestamp: new Date("2025-02-01T10:05:01Z"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
event: buildToolStartEvent({
|
event: buildToolEvent({
|
||||||
callId: HARNESS_CALL_IDS.read,
|
callId: HARNESS_CALL_IDS.read,
|
||||||
name: "read_file",
|
name: "read_file",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
|
input: { file_path: "README.md" },
|
||||||
|
output: null,
|
||||||
}),
|
}),
|
||||||
timestamp: new Date("2025-02-01T10:05:02Z"),
|
timestamp: new Date("2025-02-01T10:05:02Z"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
event: buildToolStartEvent({
|
event: buildToolEvent({
|
||||||
callId: HARNESS_CALL_IDS.command,
|
callId: HARNESS_CALL_IDS.command,
|
||||||
name: "shell",
|
name: "shell",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
|
input: { command: "ls" },
|
||||||
|
output: null,
|
||||||
}),
|
}),
|
||||||
timestamp: new Date("2025-02-01T10:05:03Z"),
|
timestamp: new Date("2025-02-01T10:05:03Z"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("stream harness captures hydrated regression", () => {
|
describe("stream harness canonical payloads", () => {
|
||||||
it("records tool payloads during the live run", () => {
|
it("keeps provider detail payloads during live run", () => {
|
||||||
const liveState = hydrateStreamState(STREAM_HARNESS_LIVE);
|
const liveState = hydrateStreamState(STREAM_HARNESS_LIVE);
|
||||||
const snapshots = extractHarnessSnapshots(liveState);
|
const snapshots = extractHarnessSnapshots(liveState);
|
||||||
|
|
||||||
expect(snapshots.edit?.payload.data.parsedEdits?.[0]?.diffLines.length).toBeGreaterThan(0);
|
expect(snapshots.edit?.payload.data.detail).toEqual({
|
||||||
expect(snapshots.read?.payload.data.parsedReads?.[0]?.content).toContain("New line");
|
type: "edit",
|
||||||
expect(snapshots.command?.payload.data.parsedCommand?.output).toContain("README.md");
|
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", () => {
|
it("keeps tool records hydrated even when output is missing", () => {
|
||||||
// After a refresh, hydrated events only contain status but no input/output data.
|
|
||||||
// Without full data, parsed payloads cannot be reconstructed.
|
|
||||||
const hydratedState = hydrateStreamState(STREAM_HARNESS_HYDRATED);
|
const hydratedState = hydrateStreamState(STREAM_HARNESS_HYDRATED);
|
||||||
const snapshots = extractHarnessSnapshots(hydratedState);
|
const snapshots = extractHarnessSnapshots(hydratedState);
|
||||||
|
|
||||||
// Hydrated events exist but lack parsed content since input/output were not provided
|
expect(snapshots.edit?.payload.data.status).toBe("completed");
|
||||||
expect(snapshots.edit?.payload.data.parsedEdits).toBeUndefined();
|
expect(snapshots.read?.payload.data.status).toBe("completed");
|
||||||
expect(snapshots.read?.payload.data.parsedReads).toBeUndefined();
|
expect(snapshots.command?.payload.data.status).toBe("completed");
|
||||||
expect(snapshots.command?.payload.data.parsedCommand).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildToolStartEvent({
|
function buildToolEvent({
|
||||||
callId,
|
callId,
|
||||||
name,
|
name,
|
||||||
input,
|
status,
|
||||||
status = "executing",
|
input = null,
|
||||||
|
output = null,
|
||||||
|
error,
|
||||||
|
detail,
|
||||||
}: {
|
}: {
|
||||||
callId: string;
|
callId: string;
|
||||||
name: string;
|
name: string;
|
||||||
input?: Record<string, unknown>;
|
status: ToolStatus;
|
||||||
status?: ToolCallStatus;
|
input?: Record<string, unknown> | null;
|
||||||
|
output?: Record<string, unknown> | null;
|
||||||
|
error?: unknown;
|
||||||
|
detail?: ToolCallDetail;
|
||||||
}): AgentStreamEventPayload {
|
}): AgentStreamEventPayload {
|
||||||
return {
|
const baseItem = {
|
||||||
type: "timeline",
|
type: "tool_call" as const,
|
||||||
provider: "claude",
|
name,
|
||||||
item: {
|
status,
|
||||||
type: "tool_call",
|
callId,
|
||||||
name,
|
input,
|
||||||
status,
|
output,
|
||||||
callId,
|
...(detail ? { detail } : {}),
|
||||||
input,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function buildToolResultEvent({
|
const item =
|
||||||
callId,
|
status === "failed"
|
||||||
name,
|
? {
|
||||||
output,
|
...baseItem,
|
||||||
}: {
|
status: "failed" as const,
|
||||||
callId: string;
|
error: error ?? { message: "failed" },
|
||||||
name: string;
|
}
|
||||||
output?: Record<string, unknown>;
|
: {
|
||||||
}): AgentStreamEventPayload {
|
...baseItem,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
item: {
|
item,
|
||||||
type: "tool_call",
|
|
||||||
name,
|
|
||||||
callId,
|
|
||||||
output,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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 type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||||
import {
|
import {
|
||||||
extractCommandDetails,
|
|
||||||
extractEditEntries,
|
|
||||||
extractReadEntries,
|
|
||||||
extractTaskEntriesFromToolCall,
|
extractTaskEntriesFromToolCall,
|
||||||
type CommandDetails,
|
|
||||||
type EditEntry,
|
|
||||||
type ReadEntry,
|
|
||||||
} from "../utils/tool-call-parsers";
|
} from "../utils/tool-call-parsers";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,7 +83,8 @@ export interface ThoughtItem {
|
|||||||
status: ThoughtStatus;
|
status: ThoughtStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToolCallStatus = "executing" | "completed" | "failed";
|
export type OrchestratorToolCallStatus = "executing" | "completed" | "failed";
|
||||||
|
export type AgentToolCallStatus = "running" | "completed" | "failed" | "canceled";
|
||||||
|
|
||||||
interface OrchestratorToolCallData {
|
interface OrchestratorToolCallData {
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
@@ -94,20 +92,18 @@ interface OrchestratorToolCallData {
|
|||||||
arguments: unknown;
|
arguments: unknown;
|
||||||
result?: unknown;
|
result?: unknown;
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
status: ToolCallStatus;
|
status: OrchestratorToolCallStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentToolCallData {
|
export interface AgentToolCallData {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
|
callId: string;
|
||||||
name: string;
|
name: string;
|
||||||
status?: ToolCallStatus;
|
status: AgentToolCallStatus;
|
||||||
callId?: string;
|
input: unknown | null;
|
||||||
input?: unknown;
|
result: unknown | null;
|
||||||
result?: unknown;
|
error: unknown | null;
|
||||||
error?: unknown;
|
detail?: ToolCallDetail;
|
||||||
parsedEdits?: EditEntry[];
|
|
||||||
parsedReads?: ReadEntry[];
|
|
||||||
parsedCommand?: CommandDetails | null;
|
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,14 +169,6 @@ function normalizeChunk(text: string): { chunk: string; hasContent: boolean } {
|
|||||||
return { chunk, hasContent: /\S/.test(chunk) };
|
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(
|
function appendUserMessage(
|
||||||
state: StreamItem[],
|
state: StreamItem[],
|
||||||
text: string,
|
text: string,
|
||||||
@@ -310,97 +298,59 @@ function finalizeActiveThoughts(state: StreamItem[]): StreamItem[] {
|
|||||||
return mutated ? nextState : state;
|
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(
|
function findExistingAgentToolCallIndex(
|
||||||
state: StreamItem[],
|
state: StreamItem[],
|
||||||
callId: string | null,
|
callId: string
|
||||||
data: AgentToolCallData
|
|
||||||
): number {
|
): number {
|
||||||
const normalizedCallId = normalizeComparableString(callId);
|
return state.findIndex(
|
||||||
if (normalizedCallId) {
|
(entry) =>
|
||||||
const existingIndex = state.findIndex(
|
entry.kind === "tool_call" &&
|
||||||
(entry) =>
|
entry.payload.source === "agent" &&
|
||||||
entry.kind === "tool_call" &&
|
entry.payload.data.callId === callId
|
||||||
entry.payload.source === "agent" &&
|
);
|
||||||
normalizeComparableString(entry.payload.data.callId) ===
|
}
|
||||||
normalizedCallId
|
|
||||||
);
|
function hasNonEmptyObject(value: unknown): boolean {
|
||||||
if (existingIndex >= 0) {
|
return Boolean(
|
||||||
return existingIndex;
|
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 }> =
|
if (!hasNonEmptyObject(incoming) && hasNonEmptyObject(existing)) {
|
||||||
[];
|
return existing;
|
||||||
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 (fallbackCandidates.length) {
|
return incoming;
|
||||||
return fallbackCandidates[0]?.index ?? -1;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If this update still lacks a call id, fall back to metadata matches (e.g. replayed hydration events)
|
function mergeAgentToolCallStatus(
|
||||||
if (!normalizedCallId && metadataMatches.length) {
|
existing: AgentToolCallStatus,
|
||||||
return metadataMatches[0]?.index ?? -1;
|
incoming: AgentToolCallStatus
|
||||||
|
): AgentToolCallStatus {
|
||||||
|
if (existing === "failed" || incoming === "failed") {
|
||||||
|
return "failed";
|
||||||
}
|
}
|
||||||
|
if (existing === "canceled") {
|
||||||
return -1;
|
return "canceled";
|
||||||
|
}
|
||||||
|
if (incoming === "canceled") {
|
||||||
|
return existing === "completed" ? "completed" : "canceled";
|
||||||
|
}
|
||||||
|
if (existing === "completed" || incoming === "completed") {
|
||||||
|
return "completed";
|
||||||
|
}
|
||||||
|
return "running";
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendAgentToolCall(
|
function appendAgentToolCall(
|
||||||
@@ -408,49 +358,26 @@ function appendAgentToolCall(
|
|||||||
data: AgentToolCallData,
|
data: AgentToolCallData,
|
||||||
timestamp: Date
|
timestamp: Date
|
||||||
): StreamItem[] {
|
): StreamItem[] {
|
||||||
const normalizedStatus = normalizeToolCallStatus(
|
const existingIndex = findExistingAgentToolCallIndex(state, data.callId);
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
const next = [...state];
|
const next = [...state];
|
||||||
const existing = next[existingIndex] as AgentToolCallItem;
|
const existing = next[existingIndex] as AgentToolCallItem;
|
||||||
const mergedInput =
|
const mergedInput = mergeCanonicalValue(existing.payload.data.input, data.input);
|
||||||
hasValue(payloadData.input)
|
const mergedResult = mergeCanonicalValue(existing.payload.data.result, data.result);
|
||||||
? payloadData.input
|
const mergedStatus = mergeAgentToolCallStatus(
|
||||||
: 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(
|
|
||||||
existing.payload.data.status,
|
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 =
|
const mergedMetadata =
|
||||||
payloadData.metadata || existing.payload.data.metadata
|
data.metadata || existing.payload.data.metadata
|
||||||
? { ...existing.payload.data.metadata, ...payloadData.metadata }
|
? { ...existing.payload.data.metadata, ...data.metadata }
|
||||||
: undefined;
|
: undefined;
|
||||||
const parsed = computeParsedToolPayload(mergedResult);
|
|
||||||
next[existingIndex] = {
|
next[existingIndex] = {
|
||||||
...existing,
|
...existing,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -458,41 +385,28 @@ function appendAgentToolCall(
|
|||||||
source: "agent",
|
source: "agent",
|
||||||
data: {
|
data: {
|
||||||
...existing.payload.data,
|
...existing.payload.data,
|
||||||
...payloadData,
|
...data,
|
||||||
status: mergedStatus,
|
status: mergedStatus,
|
||||||
input: mergedInput,
|
input: mergedInput,
|
||||||
result: mergedResult,
|
result: mergedResult,
|
||||||
error: mergedError,
|
error: mergedError,
|
||||||
|
detail: data.detail ?? existing.payload.data.detail,
|
||||||
metadata: mergedMetadata,
|
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;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = callId
|
|
||||||
? `agent_tool_${callId}`
|
|
||||||
: createUniqueTimelineId(
|
|
||||||
state,
|
|
||||||
"tool",
|
|
||||||
`${data.provider}:${data.name}`,
|
|
||||||
timestamp
|
|
||||||
);
|
|
||||||
|
|
||||||
const item: ToolCallItem = {
|
const item: ToolCallItem = {
|
||||||
kind: "tool_call",
|
kind: "tool_call",
|
||||||
id,
|
id: `agent_tool_${data.callId}`,
|
||||||
timestamp,
|
timestamp,
|
||||||
payload: {
|
payload: {
|
||||||
source: "agent",
|
source: "agent",
|
||||||
data: {
|
data: {
|
||||||
...payloadData,
|
...data,
|
||||||
...computeParsedToolPayload(payloadData.result),
|
error: data.status === "failed" ? data.error : null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -500,195 +414,6 @@ function appendAgentToolCall(
|
|||||||
return [...state, item];
|
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(
|
function appendActivityLog(
|
||||||
state: StreamItem[],
|
state: StreamItem[],
|
||||||
entry: ActivityLogItem
|
entry: ActivityLogItem
|
||||||
@@ -826,12 +551,13 @@ export function reduceStreamUpdate(
|
|||||||
state,
|
state,
|
||||||
{
|
{
|
||||||
provider: event.provider,
|
provider: event.provider,
|
||||||
name: item.name,
|
|
||||||
status: normalizeStatusString(item.status) ?? "executing",
|
|
||||||
callId: item.callId,
|
callId: item.callId,
|
||||||
|
name: item.name,
|
||||||
|
status: item.status,
|
||||||
input: item.input,
|
input: item.input,
|
||||||
result: item.output,
|
result: item.output,
|
||||||
error: item.error,
|
error: item.error,
|
||||||
|
detail: item.detail,
|
||||||
metadata: item.metadata,
|
metadata: item.metadata,
|
||||||
},
|
},
|
||||||
timestamp
|
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 {
|
import {
|
||||||
extractKeyValuePairs,
|
buildLineDiff,
|
||||||
parseToolCallDisplay,
|
parseUnifiedDiff,
|
||||||
type ToolCallDisplayInfo,
|
extractTaskEntriesFromToolCall,
|
||||||
} from "./tool-call-parsers";
|
} from "./tool-call-parsers";
|
||||||
|
|
||||||
describe("tool-call-parsers - real runtime shapes", () => {
|
describe("tool-call-parsers", () => {
|
||||||
// Real data captured from Claude agent test: "shows the command inside pending tool calls"
|
it("builds line diff for text changes", () => {
|
||||||
// Run: npx vitest run claude-agent.test.ts -t "shows the command"
|
const diff = buildLineDiff("old\nline\n", "new\nline\n");
|
||||||
|
|
||||||
test("bash tool call - input shape", () => {
|
expect(diff.some((entry) => entry.type === "remove")).toBe(true);
|
||||||
// REAL shape from Claude SDK timeline event (status: pending/completed)
|
expect(diff.some((entry) => entry.type === "add")).toBe(true);
|
||||||
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" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("bash tool call - output shape (completed)", () => {
|
it("parses unified diff", () => {
|
||||||
// REAL shape from Claude SDK timeline event (status: completed)
|
const parsed = parseUnifiedDiff("@@\n-old\n+new\n");
|
||||||
// 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",
|
|
||||||
};
|
|
||||||
|
|
||||||
const pairs = extractKeyValuePairs(bashOutput);
|
expect(parsed.find((entry) => entry.type === "remove")?.content).toBe("-old");
|
||||||
expect(pairs).toContainEqual({ key: "type", value: "command" });
|
expect(parsed.find((entry) => entry.type === "add")?.content).toBe("+new");
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses pending bash tool call into shell detail with empty output", () => {
|
it("extracts TodoWrite task entries", () => {
|
||||||
// When tool is pending, we have input but no result yet
|
const tasks = extractTaskEntriesFromToolCall("TodoWrite", {
|
||||||
const input = { command: "pwd", description: "Print working directory" };
|
todos: [
|
||||||
|
{ content: "Task 1", status: "pending" },
|
||||||
const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Bash", input });
|
{ content: "Task 2", status: "completed" },
|
||||||
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",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
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)", () => {
|
expect(tasks?.map((task) => task.text)).toEqual(["Task 1", "Task 2"]);
|
||||||
const input = {
|
expect(tasks?.map((task) => task.completed)).toEqual([false, true]);
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,31 +18,6 @@ export type DiffLine = {
|
|||||||
segments?: DiffSegment[];
|
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[] {
|
function splitIntoLines(text: string): string[] {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return [];
|
return [];
|
||||||
@@ -323,612 +298,6 @@ export function parseUnifiedDiff(diffText?: string): DiffLine[] {
|
|||||||
return diff;
|
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) ----
|
// ---- Task Extraction (cross-provider) ----
|
||||||
|
|
||||||
export type TaskStatus = "pending" | "in_progress" | "completed";
|
export type TaskStatus = "pending" | "in_progress" | "completed";
|
||||||
@@ -1007,19 +376,3 @@ export function extractTaskEntriesFromToolCall(
|
|||||||
|
|
||||||
return null;
|
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";
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { DaemonClient, type DaemonTransport } from "./daemon-client";
|
import { DaemonClient, type DaemonTransport } from "./daemon-client";
|
||||||
|
|
||||||
@@ -49,6 +50,18 @@ function createMockTransport() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadLegacySnapshotFixture(): unknown {
|
||||||
|
const url = new URL("../shared/__fixtures__/legacy-agent-stream-snapshot-inProgress.json", import.meta.url);
|
||||||
|
return JSON.parse(readFileSync(url, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapSessionMessage(message: unknown): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("DaemonClient", () => {
|
describe("DaemonClient", () => {
|
||||||
const clients: DaemonClient[] = [];
|
const clients: DaemonClient[] = [];
|
||||||
|
|
||||||
@@ -171,4 +184,122 @@ describe("DaemonClient", () => {
|
|||||||
vi.runOnlyPendingTimers();
|
vi.runOnlyPendingTimers();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parses agent_stream tool_call payloads (including legacy inProgress) without crashing", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const received: unknown[] = [];
|
||||||
|
const unsubscribe = client.on("agent_stream", (msg) => {
|
||||||
|
received.push(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "agent_stream",
|
||||||
|
payload: {
|
||||||
|
agentId: "agent_cli",
|
||||||
|
timestamp: "2026-02-08T20:20:00.000Z",
|
||||||
|
event: {
|
||||||
|
type: "timeline",
|
||||||
|
provider: "codex",
|
||||||
|
item: {
|
||||||
|
type: "tool_call",
|
||||||
|
callId: "call_cli_stream",
|
||||||
|
name: "shell",
|
||||||
|
status: "inProgress",
|
||||||
|
input: { command: "pwd" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
const streamMsg = received[0] as {
|
||||||
|
payload: {
|
||||||
|
event: {
|
||||||
|
type: "timeline";
|
||||||
|
item: {
|
||||||
|
type: "tool_call";
|
||||||
|
status: string;
|
||||||
|
error: unknown;
|
||||||
|
output: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(streamMsg.payload.event.item.status).toBe("running");
|
||||||
|
expect(streamMsg.payload.event.item.error).toBeNull();
|
||||||
|
expect(streamMsg.payload.event.item.output).toBeNull();
|
||||||
|
expect(logger.warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses agent_stream_snapshot tool_call payloads without crashing", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const received: unknown[] = [];
|
||||||
|
const unsubscribe = client.on("agent_stream_snapshot", (msg) => {
|
||||||
|
received.push(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = loadLegacySnapshotFixture();
|
||||||
|
mock.triggerMessage(wrapSessionMessage(snapshot));
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
const snapshotMsg = received[0] as {
|
||||||
|
payload: {
|
||||||
|
events: Array<{
|
||||||
|
event: {
|
||||||
|
type: "timeline";
|
||||||
|
item: {
|
||||||
|
type: "tool_call";
|
||||||
|
status: string;
|
||||||
|
error: unknown;
|
||||||
|
output: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstTimeline = snapshotMsg.payload.events[0]?.event;
|
||||||
|
expect(firstTimeline?.type).toBe("timeline");
|
||||||
|
if (firstTimeline?.type === "timeline" && firstTimeline.item.type === "tool_call") {
|
||||||
|
expect(firstTimeline.item.status).toBe("running");
|
||||||
|
expect(firstTimeline.item.error).toBeNull();
|
||||||
|
expect(firstTimeline.item.output).toBeNull();
|
||||||
|
}
|
||||||
|
expect(logger.warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,590 +1,158 @@
|
|||||||
import { describe, test, expect } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { curateAgentActivity } from "./activity-curator.js";
|
import { curateAgentActivity } from "./activity-curator.js";
|
||||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||||
|
|
||||||
|
function toolCallItem(params: {
|
||||||
|
callId: string;
|
||||||
|
name: string;
|
||||||
|
status?: "running" | "completed" | "failed" | "canceled";
|
||||||
|
input?: unknown | null;
|
||||||
|
output?: unknown | null;
|
||||||
|
error?: unknown;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
detail?: Extract<AgentTimelineItem, { type: "tool_call" }>['detail'];
|
||||||
|
}): Extract<AgentTimelineItem, { type: "tool_call" }> {
|
||||||
|
const status = params.status ?? "completed";
|
||||||
|
return {
|
||||||
|
type: "tool_call",
|
||||||
|
callId: params.callId,
|
||||||
|
name: params.name,
|
||||||
|
status,
|
||||||
|
input: params.input ?? null,
|
||||||
|
output: params.output ?? null,
|
||||||
|
error: status === "failed" ? params.error ?? { message: "failed" } : null,
|
||||||
|
metadata: params.metadata,
|
||||||
|
detail: params.detail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("curateAgentActivity", () => {
|
describe("curateAgentActivity", () => {
|
||||||
describe("serializes all timeline item types", () => {
|
it("renders user/assistant/reasoning entries", () => {
|
||||||
test("serializes user_message", () => {
|
const timeline: AgentTimelineItem[] = [
|
||||||
const timeline: AgentTimelineItem[] = [
|
{ type: "user_message", text: "Hello" },
|
||||||
{ type: "user_message", text: "Hello, can you help me?" },
|
{ type: "assistant_message", text: "Hi" },
|
||||||
];
|
{ type: "reasoning", text: "Thinking" },
|
||||||
|
];
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
const result = curateAgentActivity(timeline);
|
||||||
|
|
||||||
expect(result).toBe("[User] Hello, can you help me?");
|
expect(result).toContain("[User] Hello");
|
||||||
});
|
expect(result).toContain("Hi");
|
||||||
|
expect(result).toContain("[Thought] Thinking");
|
||||||
test("serializes assistant_message", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{ type: "assistant_message", text: "I can help you with that." },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
|
|
||||||
expect(result).toBe("I can help you with that.");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("serializes reasoning as [Thought]", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{ type: "reasoning", text: "The user wants to understand X." },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
|
|
||||||
expect(result).toBe("[Thought] The user wants to understand X.");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("serializes tool_call with name", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "call-1",
|
|
||||||
name: "Read",
|
|
||||||
input: { file_path: "/src/index.ts" },
|
|
||||||
status: "completed",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
|
|
||||||
expect(result).toBe("[Read] /src/index.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("serializes tool_call without principal param", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "call-1",
|
|
||||||
name: "ListFiles",
|
|
||||||
input: {},
|
|
||||||
status: "completed",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
|
|
||||||
expect(result).toBe("[ListFiles]");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not treat generic double-underscore tool names as MCP calls", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "call-1",
|
|
||||||
name: "custom__tool",
|
|
||||||
input: {},
|
|
||||||
status: "completed",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
|
|
||||||
expect(result).toBe("[custom__tool]");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("serializes todo items as [Tasks]", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{
|
|
||||||
type: "todo",
|
|
||||||
items: [
|
|
||||||
{ text: "Read the file", completed: true },
|
|
||||||
{ text: "Fix the bug", completed: false },
|
|
||||||
{ text: "Run tests", completed: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
|
|
||||||
expect(result).toContain("[Tasks]");
|
|
||||||
expect(result).toContain("- [x] Read the file");
|
|
||||||
expect(result).toContain("- [ ] Fix the bug");
|
|
||||||
expect(result).toContain("- [ ] Run tests");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("serializes error items", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{ type: "error", message: "File not found: /missing.ts" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
|
|
||||||
expect(result).toBe("[Error] File not found: /missing.ts");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("handles complex conversations", () => {
|
it("uses detail enrichment for tool summaries", () => {
|
||||||
test("serializes full conversation with multiple item types", () => {
|
const timeline: AgentTimelineItem[] = [
|
||||||
const timeline: AgentTimelineItem[] = [
|
toolCallItem({
|
||||||
{ type: "user_message", text: "Fix the bug in auth.ts" },
|
callId: "read-1",
|
||||||
{ type: "reasoning", text: "I need to read the file first." },
|
name: "read_file",
|
||||||
{
|
detail: {
|
||||||
type: "tool_call",
|
type: "read",
|
||||||
callId: "call-1",
|
filePath: "src/index.ts",
|
||||||
name: "Read",
|
content: "console.log('hi')",
|
||||||
input: { file_path: "/src/auth.ts" },
|
|
||||||
status: "completed",
|
|
||||||
},
|
},
|
||||||
{ type: "assistant_message", text: "I found the issue." },
|
}),
|
||||||
{
|
toolCallItem({
|
||||||
type: "tool_call",
|
callId: "shell-1",
|
||||||
callId: "call-2",
|
name: "shell",
|
||||||
name: "Edit",
|
detail: {
|
||||||
input: { file_path: "/src/auth.ts", old_string: "bug", new_string: "fix" },
|
type: "shell",
|
||||||
status: "completed",
|
command: "npm test",
|
||||||
|
output: "ok",
|
||||||
|
exitCode: 0,
|
||||||
},
|
},
|
||||||
{ type: "assistant_message", text: "The bug has been fixed." },
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
const result = curateAgentActivity(timeline);
|
||||||
|
|
||||||
expect(result).toContain("[User] Fix the bug in auth.ts");
|
expect(result).toContain("[Read] src/index.ts");
|
||||||
expect(result).toContain("[Thought] I need to read the file first.");
|
expect(result).toContain("[Shell] npm test");
|
||||||
expect(result).toContain("[Read] /src/auth.ts");
|
|
||||||
expect(result).toContain("I found the issue.");
|
|
||||||
expect(result).toContain("[Edit] /src/auth.ts");
|
|
||||||
expect(result).toContain("The bug has been fixed.");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("preserves order of items", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{ type: "user_message", text: "Step 1" },
|
|
||||||
{ type: "assistant_message", text: "Step 2" },
|
|
||||||
{ type: "user_message", text: "Step 3" },
|
|
||||||
{ type: "assistant_message", text: "Step 4" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
const lines = result.split("\n");
|
|
||||||
|
|
||||||
expect(lines[0]).toContain("Step 1");
|
|
||||||
expect(lines[1]).toContain("Step 2");
|
|
||||||
expect(lines[2]).toContain("Step 3");
|
|
||||||
expect(lines[3]).toContain("Step 4");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("handles edge cases", () => {
|
it("falls back to input json for likely external tools", () => {
|
||||||
test("returns default message for empty timeline", () => {
|
const timeline: AgentTimelineItem[] = [
|
||||||
const result = curateAgentActivity([]);
|
toolCallItem({
|
||||||
|
callId: "mcp-1",
|
||||||
|
name: "paseo__create_agent",
|
||||||
|
input: { cwd: "/tmp/repo", initialPrompt: "do the thing" },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
expect(result).toBe("No activity to display.");
|
const result = curateAgentActivity(timeline);
|
||||||
});
|
|
||||||
|
|
||||||
test("handles whitespace-only messages", () => {
|
expect(result).toBe(
|
||||||
const timeline: AgentTimelineItem[] = [
|
'[paseo__create_agent] {"cwd":"/tmp/repo","initialPrompt":"do the thing"}'
|
||||||
{ type: "user_message", text: " \n " },
|
);
|
||||||
{ type: "assistant_message", text: "Real message" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
|
|
||||||
expect(result).toContain("Real message");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("trims whitespace from messages", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{ type: "user_message", text: " Hello \n" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
|
|
||||||
expect(result).toBe("[User] Hello");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("collapsing behavior", () => {
|
it("collapses repeated tool updates by callId", () => {
|
||||||
test("merges consecutive assistant_message items", () => {
|
const timeline: AgentTimelineItem[] = [
|
||||||
const timeline: AgentTimelineItem[] = [
|
toolCallItem({
|
||||||
{ type: "assistant_message", text: "Part 1. " },
|
callId: "task-1",
|
||||||
{ type: "assistant_message", text: "Part 2. " },
|
name: "Task",
|
||||||
{ type: "assistant_message", text: "Part 3." },
|
status: "running",
|
||||||
];
|
input: { description: "Investigate" },
|
||||||
|
}),
|
||||||
|
toolCallItem({
|
||||||
|
callId: "task-1",
|
||||||
|
name: "Task",
|
||||||
|
status: "running",
|
||||||
|
metadata: { subAgentActivity: "Read" },
|
||||||
|
}),
|
||||||
|
toolCallItem({
|
||||||
|
callId: "task-1",
|
||||||
|
name: "Task",
|
||||||
|
status: "running",
|
||||||
|
metadata: { subAgentActivity: "Edit" },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
const result = curateAgentActivity(timeline);
|
||||||
|
const lines = result.split("\n");
|
||||||
|
|
||||||
expect(result).toBe("Part 1. Part 2. Part 3.");
|
expect(lines.filter((line) => line.startsWith("[Task]"))).toEqual(["[Task] Edit"]);
|
||||||
});
|
|
||||||
|
|
||||||
test("merges consecutive reasoning items", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{ type: "reasoning", text: "First thought. " },
|
|
||||||
{ type: "reasoning", text: "Second thought." },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
|
|
||||||
expect(result).toBe("[Thought] First thought. Second thought.");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("deduplicates tool calls by callId", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "call-1",
|
|
||||||
name: "Read",
|
|
||||||
input: { file_path: "/src/a.ts" },
|
|
||||||
status: "pending",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "call-1",
|
|
||||||
name: "Read",
|
|
||||||
input: { file_path: "/src/a.ts" },
|
|
||||||
status: "completed",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
|
|
||||||
// Should only appear once
|
|
||||||
const matches = result.match(/\[Read\]/g);
|
|
||||||
expect(matches?.length).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("maxItems limit", () => {
|
it("renders todo/error/compaction entries", () => {
|
||||||
test("respects maxItems option", () => {
|
const timeline: AgentTimelineItem[] = [
|
||||||
const timeline: AgentTimelineItem[] = [
|
{
|
||||||
{ type: "user_message", text: "Message 1" },
|
type: "todo",
|
||||||
{ type: "user_message", text: "Message 2" },
|
items: [
|
||||||
{ type: "user_message", text: "Message 3" },
|
{ text: "One", completed: false },
|
||||||
{ type: "user_message", text: "Message 4" },
|
{ text: "Two", completed: true },
|
||||||
{ type: "user_message", text: "Message 5" },
|
],
|
||||||
];
|
},
|
||||||
|
{ type: "error", message: "boom" },
|
||||||
|
{ type: "compaction", status: "completed", trigger: "auto" },
|
||||||
|
];
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline, { maxItems: 3 });
|
const result = curateAgentActivity(timeline);
|
||||||
|
|
||||||
// Should only have the last 3 messages
|
expect(result).toContain("[Tasks]");
|
||||||
expect(result).not.toContain("Message 1");
|
expect(result).toContain("- [ ] One");
|
||||||
expect(result).not.toContain("Message 2");
|
expect(result).toContain("- [x] Two");
|
||||||
expect(result).toContain("Message 3");
|
expect(result).toContain("[Error] boom");
|
||||||
expect(result).toContain("Message 4");
|
expect(result).toContain("[Compacted]");
|
||||||
expect(result).toContain("Message 5");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses default maxItems of 40", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [];
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
timeline.push({ type: "user_message", text: `Message ${i}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
|
|
||||||
// First 10 should be truncated
|
|
||||||
expect(result).not.toContain("Message 0");
|
|
||||||
expect(result).not.toContain("Message 9");
|
|
||||||
// Last 40 should be present
|
|
||||||
expect(result).toContain("Message 10");
|
|
||||||
expect(result).toContain("Message 49");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("tool call principal extraction", () => {
|
it("truncates to maxItems", () => {
|
||||||
test("extracts file_path from Read tool", () => {
|
const timeline: AgentTimelineItem[] = [
|
||||||
const timeline: AgentTimelineItem[] = [
|
{ type: "user_message", text: "Message 1" },
|
||||||
{
|
{ type: "user_message", text: "Message 2" },
|
||||||
type: "tool_call",
|
{ type: "user_message", text: "Message 3" },
|
||||||
callId: "1",
|
{ type: "user_message", text: "Message 4" },
|
||||||
name: "Read",
|
];
|
||||||
input: { file_path: "/src/index.ts" },
|
|
||||||
status: "completed",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
const result = curateAgentActivity(timeline, { maxItems: 2 });
|
||||||
|
|
||||||
expect(result).toBe("[Read] /src/index.ts");
|
expect(result).not.toContain("Message 1");
|
||||||
});
|
expect(result).not.toContain("Message 2");
|
||||||
|
expect(result).toContain("Message 3");
|
||||||
test("extracts command from Bash tool", () => {
|
expect(result).toContain("Message 4");
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "1",
|
|
||||||
name: "Bash",
|
|
||||||
input: { command: "npm test" },
|
|
||||||
status: "completed",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
|
|
||||||
expect(result).toBe("[Shell] npm test");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts pattern from Glob tool", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "1",
|
|
||||||
name: "Glob",
|
|
||||||
input: { pattern: "**/*.ts" },
|
|
||||||
status: "completed",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
|
|
||||||
expect(result).toBe("[Glob] **/*.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts pattern from Grep tool", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "1",
|
|
||||||
name: "Grep",
|
|
||||||
input: { pattern: "TODO" },
|
|
||||||
status: "completed",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
|
|
||||||
expect(result).toBe("[Grep] TODO");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows speak tool text input", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "s1",
|
|
||||||
name: "speak",
|
|
||||||
input: { text: "hello from voice" },
|
|
||||||
status: "completed",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
expect(result).toBe('[Speak] {"text":"hello from voice"}');
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows MCP tool input JSON", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "m1",
|
|
||||||
name: "paseo__create_agent",
|
|
||||||
input: { cwd: "/tmp/repo", initialPrompt: "do the thing" },
|
|
||||||
status: "completed",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
expect(result).toBe(
|
|
||||||
'[paseo__create_agent] {"cwd":"/tmp/repo","initialPrompt":"do the thing"}'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows namespaced tool input JSON regardless of prefix format", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "m2",
|
|
||||||
name: "paseo_voice.speak",
|
|
||||||
input: { text: "hello from namespaced tool" },
|
|
||||||
status: "completed",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
expect(result).toBe('[Speak] {"text":"hello from namespaced tool"}');
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows claude mcp speak tool input as Speak", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "m3",
|
|
||||||
name: "mcp__paseo_voice__speak",
|
|
||||||
input: { text: "hello from claude mcp" },
|
|
||||||
status: "completed",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
expect(result).toBe('[Speak] {"text":"hello from claude mcp"}');
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts description from Task tool", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "task-1",
|
|
||||||
name: "Task",
|
|
||||||
input: { description: "Explore the codebase" },
|
|
||||||
status: "completed",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
expect(result).toBe("[Task] Explore the codebase");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Task tool collapse with sub-agent activity", () => {
|
it("returns a default message when timeline is empty", () => {
|
||||||
test("collapses Task metadata updates into single entry showing latest activity", () => {
|
expect(curateAgentActivity([])).toBe("No activity to display.");
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{ type: "user_message", text: "Investigate the bug" },
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "task-1",
|
|
||||||
name: "Task",
|
|
||||||
input: { description: "Explore the codebase" },
|
|
||||||
status: "pending",
|
|
||||||
},
|
|
||||||
// Sub-agent activity updates (same callId, metadata-only)
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "task-1",
|
|
||||||
name: "Task",
|
|
||||||
metadata: { subAgentActivity: "Read" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "task-1",
|
|
||||||
name: "Task",
|
|
||||||
metadata: { subAgentActivity: "Grep" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "task-1",
|
|
||||||
name: "Task",
|
|
||||||
metadata: { subAgentActivity: "Edit" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
const lines = result.split("\n");
|
|
||||||
|
|
||||||
// Task should appear only once (collapsed by callId)
|
|
||||||
const taskLines = lines.filter((l) => l.includes("[Task]"));
|
|
||||||
expect(taskLines).toHaveLength(1);
|
|
||||||
|
|
||||||
// The last metadata update wins — subAgentActivity "Edit" takes priority
|
|
||||||
expect(taskLines[0]).toBe("[Task] Edit");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sub-agent tool calls do NOT appear as separate timeline entries", () => {
|
|
||||||
// This simulates the full flow: handleSidechainMessage only emits
|
|
||||||
// Task metadata updates, never individual sub-agent tool calls.
|
|
||||||
// So the timeline should only contain the Task call, not Read/Bash/Edit.
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "task-1",
|
|
||||||
name: "Task",
|
|
||||||
input: { description: "Fix the bug" },
|
|
||||||
status: "pending",
|
|
||||||
},
|
|
||||||
// These are the metadata-only updates from handleSidechainMessage
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "task-1",
|
|
||||||
name: "Task",
|
|
||||||
metadata: { subAgentActivity: "Read" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "task-1",
|
|
||||||
name: "Task",
|
|
||||||
metadata: { subAgentActivity: "Bash" },
|
|
||||||
},
|
|
||||||
// Task completes
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "task-1",
|
|
||||||
name: "Task",
|
|
||||||
input: { description: "Fix the bug" },
|
|
||||||
output: { result: "Bug fixed successfully" },
|
|
||||||
status: "completed",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
const lines = result.split("\n");
|
|
||||||
|
|
||||||
// No individual Read/Bash lines — only the Task
|
|
||||||
expect(lines.filter((l) => l.includes("[Read]"))).toHaveLength(0);
|
|
||||||
expect(lines.filter((l) => l.includes("[Shell]"))).toHaveLength(0);
|
|
||||||
|
|
||||||
// One Task entry with the final completed state
|
|
||||||
const taskLines = lines.filter((l) => l.includes("[Task]"));
|
|
||||||
expect(taskLines).toHaveLength(1);
|
|
||||||
expect(taskLines[0]).toBe("[Task] Fix the bug");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("multiple concurrent Task calls are tracked independently", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "task-a",
|
|
||||||
name: "Task",
|
|
||||||
input: { description: "Research API docs" },
|
|
||||||
status: "pending",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "task-b",
|
|
||||||
name: "Task",
|
|
||||||
input: { description: "Run tests" },
|
|
||||||
status: "pending",
|
|
||||||
},
|
|
||||||
// Activity updates for each
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "task-a",
|
|
||||||
name: "Task",
|
|
||||||
metadata: { subAgentActivity: "WebFetch" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "tool_call",
|
|
||||||
callId: "task-b",
|
|
||||||
name: "Task",
|
|
||||||
metadata: { subAgentActivity: "Bash" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
const lines = result.split("\n");
|
|
||||||
|
|
||||||
const taskLines = lines.filter((l) => l.includes("[Task]"));
|
|
||||||
expect(taskLines).toHaveLength(2);
|
|
||||||
// Last update for each callId wins
|
|
||||||
expect(taskLines[0]).toBe("[Task] WebFetch");
|
|
||||||
expect(taskLines[1]).toBe("[Task] Bash");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("compaction", () => {
|
|
||||||
test("renders compaction as [Compacted]", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{ type: "assistant_message", text: "Working on it..." },
|
|
||||||
{ type: "compaction", status: "completed", trigger: "auto", preTokens: 168000 },
|
|
||||||
{ type: "assistant_message", text: "Continuing after compaction" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
const lines = result.split("\n");
|
|
||||||
expect(lines).toContain("[Compacted]");
|
|
||||||
expect(lines.indexOf("[Compacted]")).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("compaction flushes preceding buffers", () => {
|
|
||||||
const timeline: AgentTimelineItem[] = [
|
|
||||||
{ type: "assistant_message", text: "Before" },
|
|
||||||
{ type: "reasoning", text: "Thinking..." },
|
|
||||||
{ type: "compaction", status: "completed", trigger: "auto" },
|
|
||||||
{ type: "assistant_message", text: "After" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
|
||||||
const lines = result.split("\n");
|
|
||||||
const compactIdx = lines.indexOf("[Compacted]");
|
|
||||||
expect(compactIdx).toBeGreaterThan(-1);
|
|
||||||
expect(lines.slice(0, compactIdx).some((l) => l.includes("Before"))).toBe(true);
|
|
||||||
expect(lines.slice(0, compactIdx).some((l) => l.includes("Thinking"))).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||||
import { parseToolCallDisplay } from "../../utils/tool-call-parsers.js";
|
|
||||||
import { isLikelyExternalToolName } from "./tool-name-normalization.js";
|
import { isLikelyExternalToolName } from "./tool-name-normalization.js";
|
||||||
|
|
||||||
const DEFAULT_MAX_ITEMS = 40;
|
const DEFAULT_MAX_ITEMS = 40;
|
||||||
@@ -45,6 +44,50 @@ function formatToolInputJson(input: unknown): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveToolDisplayName(item: Extract<AgentTimelineItem, { type: "tool_call" }>): string {
|
||||||
|
switch (item.detail?.type) {
|
||||||
|
case "shell":
|
||||||
|
return "Shell";
|
||||||
|
case "read":
|
||||||
|
return "Read";
|
||||||
|
case "edit":
|
||||||
|
return "Edit";
|
||||||
|
case "write":
|
||||||
|
return "Write";
|
||||||
|
case "search":
|
||||||
|
return "Search";
|
||||||
|
default:
|
||||||
|
return item.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveToolSummary(
|
||||||
|
item: Extract<AgentTimelineItem, { type: "tool_call" }>
|
||||||
|
): string | undefined {
|
||||||
|
if (item.name.trim().toLowerCase() === "task") {
|
||||||
|
const metadata = item.metadata as { subAgentActivity?: unknown } | undefined;
|
||||||
|
if (typeof metadata?.subAgentActivity === "string") {
|
||||||
|
const summary = metadata.subAgentActivity.trim();
|
||||||
|
if (summary.length > 0) {
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (item.detail?.type) {
|
||||||
|
case "shell":
|
||||||
|
return item.detail.command;
|
||||||
|
case "read":
|
||||||
|
case "edit":
|
||||||
|
case "write":
|
||||||
|
return item.detail.filePath;
|
||||||
|
case "search":
|
||||||
|
return item.detail.query;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collapse timeline items:
|
* Collapse timeline items:
|
||||||
* - Dedupe tool calls by callId (pending/completed -> single)
|
* - Dedupe tool calls by callId (pending/completed -> single)
|
||||||
@@ -86,26 +129,35 @@ function collapseTimeline(items: AgentTimelineItem[]): AgentTimelineItem[] {
|
|||||||
flushAssistant();
|
flushAssistant();
|
||||||
flushToolCalls();
|
flushToolCalls();
|
||||||
reasoningBuffer += item.text;
|
reasoningBuffer += item.text;
|
||||||
} else if (item.type === "tool_call" && item.callId) {
|
} else if (item.type === "tool_call") {
|
||||||
flushAssistant();
|
flushAssistant();
|
||||||
flushReasoning();
|
flushReasoning();
|
||||||
const existing = toolCallMap.get(item.callId);
|
const existing = toolCallMap.get(item.callId);
|
||||||
if (existing && existing.type === "tool_call") {
|
if (existing && existing.type === "tool_call") {
|
||||||
toolCallMap.set(item.callId, {
|
if (item.status === "failed") {
|
||||||
...existing,
|
toolCallMap.set(item.callId, {
|
||||||
...item,
|
...existing,
|
||||||
input: item.input ?? existing.input,
|
...item,
|
||||||
output: item.output ?? existing.output,
|
input: item.input ?? existing.input,
|
||||||
metadata: item.metadata,
|
output: item.output ?? existing.output,
|
||||||
});
|
detail: item.detail ?? existing.detail,
|
||||||
|
error: item.error,
|
||||||
|
metadata: item.metadata,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toolCallMap.set(item.callId, {
|
||||||
|
...existing,
|
||||||
|
...item,
|
||||||
|
input: item.input ?? existing.input,
|
||||||
|
output: item.output ?? existing.output,
|
||||||
|
detail: item.detail ?? existing.detail,
|
||||||
|
error: null,
|
||||||
|
metadata: item.metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toolCallMap.set(item.callId, item);
|
toolCallMap.set(item.callId, item);
|
||||||
}
|
}
|
||||||
} else if (item.type === "tool_call") {
|
|
||||||
flushAssistant();
|
|
||||||
flushReasoning();
|
|
||||||
flushToolCalls();
|
|
||||||
result.push(item);
|
|
||||||
} else {
|
} else {
|
||||||
flushAssistant();
|
flushAssistant();
|
||||||
flushReasoning();
|
flushReasoning();
|
||||||
@@ -159,11 +211,8 @@ export function curateAgentActivity(
|
|||||||
case "tool_call": {
|
case "tool_call": {
|
||||||
flushBuffers(lines, buffers);
|
flushBuffers(lines, buffers);
|
||||||
const inputJson = formatToolInputJson(item.input);
|
const inputJson = formatToolInputJson(item.input);
|
||||||
const { displayName, summary } = parseToolCallDisplay({
|
const displayName = resolveToolDisplayName(item);
|
||||||
name: item.name,
|
const summary = resolveToolSummary(item);
|
||||||
input: item.input,
|
|
||||||
metadata: item.metadata,
|
|
||||||
});
|
|
||||||
if (isLikelyExternalToolName(item.name) && inputJson) {
|
if (isLikelyExternalToolName(item.name) && inputJson) {
|
||||||
lines.push(`[${displayName}] ${inputJson}`);
|
lines.push(`[${displayName}] ${inputJson}`);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -103,29 +103,73 @@ export type AgentUsage = {
|
|||||||
totalCostUsd?: number;
|
totalCostUsd?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export type ToolCallDetail =
|
||||||
* Tool call kind categories for UI rendering hints.
|
| {
|
||||||
* Derived from the tool name, not sent over the wire.
|
type: "shell";
|
||||||
*/
|
command: string;
|
||||||
export type ToolCallKind = "read" | "edit" | "execute" | "search" | "other";
|
cwd?: string;
|
||||||
|
output?: string;
|
||||||
|
exitCode?: number | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "read";
|
||||||
|
filePath: string;
|
||||||
|
content?: string;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "edit";
|
||||||
|
filePath: string;
|
||||||
|
oldString?: string;
|
||||||
|
newString?: string;
|
||||||
|
unifiedDiff?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "write";
|
||||||
|
filePath: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "search";
|
||||||
|
query: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
type ToolCallBase = {
|
||||||
* Clean tool call structure.
|
|
||||||
* - `name`: Tool identifier (e.g., "Read", "Bash", "Edit", "shell", "read_file", "apply_patch")
|
|
||||||
* - `input`: Tool input parameters
|
|
||||||
* - `output`: Tool result
|
|
||||||
* - `error`: Error if tool failed
|
|
||||||
*/
|
|
||||||
export interface ToolCallTimelineItem {
|
|
||||||
type: "tool_call";
|
type: "tool_call";
|
||||||
|
callId: string;
|
||||||
name: string;
|
name: string;
|
||||||
callId?: string;
|
input: unknown | null;
|
||||||
status?: string;
|
output: unknown | null;
|
||||||
input?: unknown;
|
detail?: ToolCallDetail;
|
||||||
output?: unknown;
|
|
||||||
error?: unknown;
|
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
type ToolCallRunningTimelineItem = ToolCallBase & {
|
||||||
|
status: "running";
|
||||||
|
error: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToolCallCompletedTimelineItem = ToolCallBase & {
|
||||||
|
status: "completed";
|
||||||
|
error: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToolCallFailedTimelineItem = ToolCallBase & {
|
||||||
|
status: "failed";
|
||||||
|
error: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToolCallCanceledTimelineItem = ToolCallBase & {
|
||||||
|
status: "canceled";
|
||||||
|
error: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolCallTimelineItem =
|
||||||
|
| ToolCallRunningTimelineItem
|
||||||
|
| ToolCallCompletedTimelineItem
|
||||||
|
| ToolCallFailedTimelineItem
|
||||||
|
| ToolCallCanceledTimelineItem;
|
||||||
|
|
||||||
export type CompactionTimelineItem = {
|
export type CompactionTimelineItem = {
|
||||||
type: "compaction";
|
type: "compaction";
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ import {
|
|||||||
type SDKUserMessage,
|
type SDKUserMessage,
|
||||||
} from "@anthropic-ai/claude-agent-sdk";
|
} from "@anthropic-ai/claude-agent-sdk";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
import {
|
||||||
|
mapClaudeCanceledToolCall,
|
||||||
|
mapClaudeCompletedToolCall,
|
||||||
|
mapClaudeFailedToolCall,
|
||||||
|
mapClaudeRunningToolCall,
|
||||||
|
} from "./claude/tool-call-mapper.js";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
@@ -177,8 +183,6 @@ type PendingPermission = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ToolUseClassification = "generic" | "command" | "file_change";
|
type ToolUseClassification = "generic" | "command" | "file_change";
|
||||||
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
|
||||||
|
|
||||||
type ToolUseCacheEntry = {
|
type ToolUseCacheEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -763,11 +767,14 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (response.behavior === "allow") {
|
if (response.behavior === "allow") {
|
||||||
if (pending.request.kind === "plan") {
|
if (pending.request.kind === "plan") {
|
||||||
await this.setMode("acceptEdits");
|
await this.setMode("acceptEdits");
|
||||||
this.pushToolCall({
|
this.pushToolCall(
|
||||||
|
mapClaudeCompletedToolCall({
|
||||||
name: "plan_approval",
|
name: "plan_approval",
|
||||||
status: "granted",
|
|
||||||
callId: pending.request.id,
|
callId: pending.request.id,
|
||||||
});
|
input: pending.request.input ?? null,
|
||||||
|
output: { approved: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const result: PermissionResult = {
|
const result: PermissionResult = {
|
||||||
behavior: "allow",
|
behavior: "allow",
|
||||||
@@ -1165,16 +1172,19 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
this.activeSidechains.set(parentToolUseId, toolName);
|
this.activeSidechains.set(parentToolUseId, toolName);
|
||||||
|
|
||||||
return [{
|
return [
|
||||||
type: "timeline",
|
{
|
||||||
item: {
|
type: "timeline",
|
||||||
type: "tool_call",
|
item: mapClaudeRunningToolCall({
|
||||||
name: "Task",
|
name: "Task",
|
||||||
callId: parentToolUseId,
|
callId: parentToolUseId,
|
||||||
metadata: { subAgentActivity: toolName },
|
input: null,
|
||||||
|
output: null,
|
||||||
|
metadata: { subAgentActivity: toolName },
|
||||||
|
}),
|
||||||
|
provider: "claude",
|
||||||
},
|
},
|
||||||
provider: "claude",
|
];
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private translateMessageToEvents(message: SDKMessage, turnContext: TurnContext): AgentStreamEvent[] {
|
private translateMessageToEvents(message: SDKMessage, turnContext: TurnContext): AgentStreamEvent[] {
|
||||||
@@ -1428,23 +1438,23 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private flushPendingToolCalls() {
|
private flushPendingToolCalls() {
|
||||||
for (const [id, entry] of this.toolUseCache) {
|
for (const [id, entry] of this.toolUseCache) {
|
||||||
if (entry.started) {
|
if (entry.started) {
|
||||||
this.pushToolCall({
|
this.pushToolCall(
|
||||||
name: entry.name,
|
mapClaudeCanceledToolCall({
|
||||||
status: "failed",
|
name: entry.name,
|
||||||
callId: id,
|
callId: id,
|
||||||
input: entry.input,
|
input: entry.input ?? null,
|
||||||
error: { message: "Interrupted" },
|
output: null,
|
||||||
});
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.toolUseCache.clear();
|
this.toolUseCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private pushToolCall(
|
private pushToolCall(
|
||||||
data: Omit<ToolCallTimelineItem, "type">,
|
item: Extract<AgentTimelineItem, { type: "tool_call" }>,
|
||||||
target?: AgentTimelineItem[]
|
target?: AgentTimelineItem[]
|
||||||
) {
|
) {
|
||||||
const item: AgentTimelineItem = { type: "tool_call", ...data };
|
|
||||||
if (target) {
|
if (target) {
|
||||||
target.push(item);
|
target.push(item);
|
||||||
return;
|
return;
|
||||||
@@ -1611,12 +1621,12 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
entry.started = true;
|
entry.started = true;
|
||||||
this.toolUseCache.set(entry.id, entry);
|
this.toolUseCache.set(entry.id, entry);
|
||||||
this.pushToolCall(
|
this.pushToolCall(
|
||||||
{
|
mapClaudeRunningToolCall({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
status: "pending",
|
|
||||||
callId: entry.id,
|
callId: entry.id,
|
||||||
input: entry.input ?? this.normalizeToolInput(block.input),
|
input: entry.input ?? this.normalizeToolInput(block.input) ?? null,
|
||||||
},
|
output: null,
|
||||||
|
}),
|
||||||
items
|
items
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1624,22 +1634,37 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
|
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
|
||||||
const entry = typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined;
|
const entry = typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined;
|
||||||
const toolName = entry?.name ?? block.tool_name ?? "tool";
|
const toolName = entry?.name ?? block.tool_name ?? "tool";
|
||||||
const status = block.is_error ? "failed" : "completed";
|
const callId =
|
||||||
|
typeof block.tool_use_id === "string" && block.tool_use_id.length > 0
|
||||||
|
? block.tool_use_id
|
||||||
|
: entry?.id ?? null;
|
||||||
|
|
||||||
// Extract output from block.content (SDK always returns content in string form)
|
// Extract output from block.content (SDK always returns content in string form)
|
||||||
const output = this.buildToolOutput(block, entry);
|
const output = this.buildToolOutput(block, entry);
|
||||||
|
|
||||||
this.pushToolCall(
|
if (block.is_error) {
|
||||||
{
|
this.pushToolCall(
|
||||||
name: toolName,
|
mapClaudeFailedToolCall({
|
||||||
status,
|
name: toolName,
|
||||||
callId: typeof block.tool_use_id === "string" ? block.tool_use_id : undefined,
|
callId,
|
||||||
input: entry?.input,
|
input: entry?.input ?? null,
|
||||||
output,
|
output: output ?? null,
|
||||||
error: block.is_error ? block : undefined,
|
error: block,
|
||||||
},
|
}),
|
||||||
items
|
items
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
this.pushToolCall(
|
||||||
|
mapClaudeCompletedToolCall({
|
||||||
|
name: toolName,
|
||||||
|
callId,
|
||||||
|
input: entry?.input ?? null,
|
||||||
|
output: output ?? null,
|
||||||
|
}),
|
||||||
|
items
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof block.tool_use_id === "string") {
|
if (typeof block.tool_use_id === "string") {
|
||||||
this.toolUseCache.delete(block.tool_use_id);
|
this.toolUseCache.delete(block.tool_use_id);
|
||||||
}
|
}
|
||||||
@@ -1866,12 +1891,14 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
this.applyToolInput(entry, normalized);
|
this.applyToolInput(entry, normalized);
|
||||||
this.toolUseCache.set(toolId, entry);
|
this.toolUseCache.set(toolId, entry);
|
||||||
this.pushToolCall({
|
this.pushToolCall(
|
||||||
name: entry.name,
|
mapClaudeRunningToolCall({
|
||||||
status: "pending",
|
name: entry.name,
|
||||||
callId: toolId,
|
callId: toolId,
|
||||||
input: normalized,
|
input: normalized,
|
||||||
});
|
output: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeToolInput(input: unknown): AgentMetadata | null {
|
private normalizeToolInput(input: unknown): AgentMetadata | null {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
mapClaudeCompletedToolCall,
|
||||||
|
mapClaudeFailedToolCall,
|
||||||
|
mapClaudeRunningToolCall,
|
||||||
|
} from "./tool-call-mapper.js";
|
||||||
|
|
||||||
|
describe("claude tool-call mapper", () => {
|
||||||
|
it("maps running shell calls with canonical fields", () => {
|
||||||
|
const item = mapClaudeRunningToolCall({
|
||||||
|
callId: "claude-call-1",
|
||||||
|
name: "Bash",
|
||||||
|
input: { command: "pwd", cwd: "/tmp/repo" },
|
||||||
|
output: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item.type).toBe("tool_call");
|
||||||
|
expect(item.status).toBe("running");
|
||||||
|
expect(item.error).toBeNull();
|
||||||
|
expect(item.callId).toBe("claude-call-1");
|
||||||
|
expect(item.input).toEqual({ command: "pwd", cwd: "/tmp/repo" });
|
||||||
|
expect(item.output).toBeNull();
|
||||||
|
expect(item.detail?.type).toBe("shell");
|
||||||
|
if (item.detail?.type === "shell") {
|
||||||
|
expect(item.detail.command).toBe("pwd");
|
||||||
|
expect(item.detail.cwd).toBe("/tmp/repo");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps completed read calls with detail enrichment", () => {
|
||||||
|
const item = mapClaudeCompletedToolCall({
|
||||||
|
callId: "claude-call-2",
|
||||||
|
name: "read_file",
|
||||||
|
input: { file_path: "README.md" },
|
||||||
|
output: { content: "hello" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item.status).toBe("completed");
|
||||||
|
expect(item.error).toBeNull();
|
||||||
|
expect(item.callId).toBe("claude-call-2");
|
||||||
|
expect(item.input).toEqual({ file_path: "README.md" });
|
||||||
|
expect(item.output).toEqual({ content: "hello" });
|
||||||
|
expect(item.detail?.type).toBe("read");
|
||||||
|
if (item.detail?.type === "read") {
|
||||||
|
expect(item.detail.filePath).toBe("README.md");
|
||||||
|
expect(item.detail.content).toBe("hello");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps failed calls with required error", () => {
|
||||||
|
const item = mapClaudeFailedToolCall({
|
||||||
|
callId: "claude-call-3",
|
||||||
|
name: "shell",
|
||||||
|
input: { command: "false" },
|
||||||
|
output: null,
|
||||||
|
error: { message: "Command failed" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item.status).toBe("failed");
|
||||||
|
expect(item.error).toEqual({ message: "Command failed" });
|
||||||
|
expect(item.callId).toBe("claude-call-3");
|
||||||
|
expect(item.input).toEqual({ command: "false" });
|
||||||
|
expect(item.output).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unknown tools canonical without detail", () => {
|
||||||
|
const item = mapClaudeCompletedToolCall({
|
||||||
|
callId: "claude-call-4",
|
||||||
|
name: "my_custom_tool",
|
||||||
|
input: { foo: "bar" },
|
||||||
|
output: { ok: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item.status).toBe("completed");
|
||||||
|
expect(item.error).toBeNull();
|
||||||
|
expect(item.detail).toBeUndefined();
|
||||||
|
expect(item.input).toEqual({ foo: "bar" });
|
||||||
|
expect(item.output).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js";
|
||||||
|
|
||||||
|
type MapperParams = {
|
||||||
|
callId?: string | null;
|
||||||
|
name: string;
|
||||||
|
input?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ClaudeMapperParamsSchema = z
|
||||||
|
.object({
|
||||||
|
callId: z.string().optional().nullable(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
input: z.unknown().optional(),
|
||||||
|
output: z.unknown().optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const ClaudeFailedMapperParamsSchema = ClaudeMapperParamsSchema.extend({
|
||||||
|
error: z.unknown(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ClaudeShellToolNameSchema = z.union([
|
||||||
|
z.literal("Bash"),
|
||||||
|
z.literal("bash"),
|
||||||
|
z.literal("shell"),
|
||||||
|
z.literal("exec_command"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ClaudeReadToolNameSchema = z.union([
|
||||||
|
z.literal("Read"),
|
||||||
|
z.literal("read"),
|
||||||
|
z.literal("read_file"),
|
||||||
|
z.literal("view_file"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ClaudeWriteToolNameSchema = z.union([
|
||||||
|
z.literal("Write"),
|
||||||
|
z.literal("write"),
|
||||||
|
z.literal("write_file"),
|
||||||
|
z.literal("create_file"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ClaudeEditToolNameSchema = z.union([
|
||||||
|
z.literal("Edit"),
|
||||||
|
z.literal("edit"),
|
||||||
|
z.literal("multi_edit"),
|
||||||
|
z.literal("multiedit"),
|
||||||
|
z.literal("apply_patch"),
|
||||||
|
z.literal("apply_diff"),
|
||||||
|
z.literal("str_replace_editor"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ClaudeSearchToolNameSchema = z.union([
|
||||||
|
z.literal("WebSearch"),
|
||||||
|
z.literal("web_search"),
|
||||||
|
z.literal("websearch"),
|
||||||
|
z.literal("search"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ClaudeFileReferenceSchema = z
|
||||||
|
.object({
|
||||||
|
file_path: z.string().optional(),
|
||||||
|
filePath: z.string().optional(),
|
||||||
|
path: z.string().optional(),
|
||||||
|
target_path: z.string().optional(),
|
||||||
|
targetPath: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const ClaudeFileReferenceCollectionSchema = ClaudeFileReferenceSchema.extend({
|
||||||
|
files: z.array(ClaudeFileReferenceSchema).optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const ClaudeTextLikeSchema = z
|
||||||
|
.object({
|
||||||
|
output: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const ClaudeShellInputSchema = z
|
||||||
|
.object({
|
||||||
|
command: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
|
cmd: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
|
cwd: z.string().optional(),
|
||||||
|
directory: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const ClaudeShellOutputObjectSchema = z
|
||||||
|
.object({
|
||||||
|
command: z.string().optional(),
|
||||||
|
output: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
aggregated_output: z.string().optional(),
|
||||||
|
exitCode: z.number().finite().optional(),
|
||||||
|
exit_code: z.number().finite().optional(),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
exitCode: z.number().finite().optional(),
|
||||||
|
exit_code: z.number().finite().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.optional(),
|
||||||
|
structuredContent: ClaudeTextLikeSchema.optional(),
|
||||||
|
structured_content: ClaudeTextLikeSchema.optional(),
|
||||||
|
result: z
|
||||||
|
.object({
|
||||||
|
command: z.string().optional(),
|
||||||
|
output: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const ClaudeShellOutputSchema = z.union([z.string(), ClaudeShellOutputObjectSchema]);
|
||||||
|
|
||||||
|
const ClaudeReadInputSchema = ClaudeFileReferenceCollectionSchema.extend({
|
||||||
|
offset: z.number().finite().optional(),
|
||||||
|
limit: z.number().finite().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const ClaudeReadOutputSchema = z.union([
|
||||||
|
z.string(),
|
||||||
|
ClaudeFileReferenceCollectionSchema.extend({
|
||||||
|
content: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
output: z.string().optional(),
|
||||||
|
data: ClaudeTextLikeSchema.optional(),
|
||||||
|
structuredContent: ClaudeTextLikeSchema.optional(),
|
||||||
|
structured_content: ClaudeTextLikeSchema.optional(),
|
||||||
|
}).passthrough(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ClaudeWriteInputSchema = ClaudeFileReferenceCollectionSchema.extend({
|
||||||
|
content: z.string().optional(),
|
||||||
|
new_content: z.string().optional(),
|
||||||
|
newContent: z.string().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const ClaudeWriteOutputSchema = ClaudeFileReferenceCollectionSchema.extend({
|
||||||
|
content: z.string().optional(),
|
||||||
|
new_content: z.string().optional(),
|
||||||
|
newContent: z.string().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const ClaudeEditInputSchema = ClaudeFileReferenceCollectionSchema.extend({
|
||||||
|
old_string: z.string().optional(),
|
||||||
|
old_str: z.string().optional(),
|
||||||
|
oldContent: z.string().optional(),
|
||||||
|
old_content: z.string().optional(),
|
||||||
|
new_string: z.string().optional(),
|
||||||
|
new_str: z.string().optional(),
|
||||||
|
newContent: z.string().optional(),
|
||||||
|
new_content: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
patch: z.string().optional(),
|
||||||
|
diff: z.string().optional(),
|
||||||
|
unified_diff: z.string().optional(),
|
||||||
|
unifiedDiff: z.string().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const ClaudeEditOutputSchema = ClaudeFileReferenceCollectionSchema.extend({
|
||||||
|
content: z.string().optional(),
|
||||||
|
new_content: z.string().optional(),
|
||||||
|
newContent: z.string().optional(),
|
||||||
|
patch: z.string().optional(),
|
||||||
|
diff: z.string().optional(),
|
||||||
|
unified_diff: z.string().optional(),
|
||||||
|
unifiedDiff: z.string().optional(),
|
||||||
|
files: z
|
||||||
|
.array(
|
||||||
|
ClaudeFileReferenceSchema.extend({
|
||||||
|
patch: z.string().optional(),
|
||||||
|
diff: z.string().optional(),
|
||||||
|
unified_diff: z.string().optional(),
|
||||||
|
unifiedDiff: z.string().optional(),
|
||||||
|
}).passthrough()
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const ClaudeSearchInputSchema = z
|
||||||
|
.object({
|
||||||
|
query: z.string().optional(),
|
||||||
|
q: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const ClaudeShellDetailCandidateSchema = z
|
||||||
|
.object({
|
||||||
|
name: ClaudeShellToolNameSchema,
|
||||||
|
input: z.unknown().nullable(),
|
||||||
|
output: z.unknown().nullable(),
|
||||||
|
})
|
||||||
|
.transform(({ input, output }) => resolveShellDetail(input, output));
|
||||||
|
|
||||||
|
const ClaudeReadDetailCandidateSchema = z
|
||||||
|
.object({
|
||||||
|
name: ClaudeReadToolNameSchema,
|
||||||
|
input: z.unknown().nullable(),
|
||||||
|
output: z.unknown().nullable(),
|
||||||
|
})
|
||||||
|
.transform(({ input, output }) => resolveReadDetail(input, output));
|
||||||
|
|
||||||
|
const ClaudeWriteDetailCandidateSchema = z
|
||||||
|
.object({
|
||||||
|
name: ClaudeWriteToolNameSchema,
|
||||||
|
input: z.unknown().nullable(),
|
||||||
|
output: z.unknown().nullable(),
|
||||||
|
})
|
||||||
|
.transform(({ input, output }) => resolveWriteDetail(input, output));
|
||||||
|
|
||||||
|
const ClaudeEditDetailCandidateSchema = z
|
||||||
|
.object({
|
||||||
|
name: ClaudeEditToolNameSchema,
|
||||||
|
input: z.unknown().nullable(),
|
||||||
|
output: z.unknown().nullable(),
|
||||||
|
})
|
||||||
|
.transform(({ input, output }) => resolveEditDetail(input, output));
|
||||||
|
|
||||||
|
const ClaudeSearchDetailCandidateSchema = z
|
||||||
|
.object({
|
||||||
|
name: ClaudeSearchToolNameSchema,
|
||||||
|
input: z.unknown().nullable(),
|
||||||
|
output: z.unknown().nullable(),
|
||||||
|
})
|
||||||
|
.transform(({ input }) => resolveSearchDetail(input));
|
||||||
|
|
||||||
|
const ClaudeKnownToolDetailSchema = z.union([
|
||||||
|
ClaudeShellDetailCandidateSchema,
|
||||||
|
ClaudeReadDetailCandidateSchema,
|
||||||
|
ClaudeWriteDetailCandidateSchema,
|
||||||
|
ClaudeEditDetailCandidateSchema,
|
||||||
|
ClaudeSearchDetailCandidateSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function hashText(value: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < value.length; i += 1) {
|
||||||
|
hash = (hash << 5) - hash + value.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceCallId(callId: string | null | undefined, name: string, input: unknown): string {
|
||||||
|
if (typeof callId === "string" && callId.trim().length > 0) {
|
||||||
|
return callId;
|
||||||
|
}
|
||||||
|
let serialized = "";
|
||||||
|
try {
|
||||||
|
serialized = JSON.stringify(input) ?? "";
|
||||||
|
} catch {
|
||||||
|
serialized = String(input);
|
||||||
|
}
|
||||||
|
return `claude-${hashText(`${name}:${serialized}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNonEmpty(...values: Array<string | undefined>): string | undefined {
|
||||||
|
return values.find((value) => typeof value === "string" && value.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandFromValue(value: string | string[] | undefined): string | undefined {
|
||||||
|
if (typeof value === "string" && value.length > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const tokens = value.filter((token): token is string => typeof token === "string" && token.length > 0);
|
||||||
|
if (tokens.length > 0) {
|
||||||
|
return tokens.join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFilePath(value: z.infer<typeof ClaudeFileReferenceCollectionSchema>): string | undefined {
|
||||||
|
return firstNonEmpty(
|
||||||
|
value.file_path,
|
||||||
|
value.filePath,
|
||||||
|
value.path,
|
||||||
|
value.target_path,
|
||||||
|
value.targetPath,
|
||||||
|
value.files?.[0]?.path,
|
||||||
|
value.files?.[0]?.filePath,
|
||||||
|
value.files?.[0]?.file_path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveShellDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||||
|
const parsedInput = ClaudeShellInputSchema.safeParse(input);
|
||||||
|
const parsedOutput = ClaudeShellOutputSchema.safeParse(output);
|
||||||
|
|
||||||
|
const command =
|
||||||
|
(parsedInput.success
|
||||||
|
? commandFromValue(parsedInput.data.command) ?? commandFromValue(parsedInput.data.cmd)
|
||||||
|
: undefined) ??
|
||||||
|
(parsedOutput.success && typeof parsedOutput.data !== "string"
|
||||||
|
? firstNonEmpty(parsedOutput.data.command, parsedOutput.data.result?.command)
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputText =
|
||||||
|
parsedOutput.success
|
||||||
|
? typeof parsedOutput.data === "string"
|
||||||
|
? parsedOutput.data
|
||||||
|
: firstNonEmpty(
|
||||||
|
parsedOutput.data.output,
|
||||||
|
parsedOutput.data.text,
|
||||||
|
parsedOutput.data.content,
|
||||||
|
parsedOutput.data.aggregated_output,
|
||||||
|
parsedOutput.data.structuredContent?.output,
|
||||||
|
parsedOutput.data.structuredContent?.text,
|
||||||
|
parsedOutput.data.structuredContent?.content,
|
||||||
|
parsedOutput.data.structured_content?.output,
|
||||||
|
parsedOutput.data.structured_content?.text,
|
||||||
|
parsedOutput.data.structured_content?.content,
|
||||||
|
parsedOutput.data.result?.output,
|
||||||
|
parsedOutput.data.result?.text,
|
||||||
|
parsedOutput.data.result?.content
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const exitCode =
|
||||||
|
parsedOutput.success && typeof parsedOutput.data !== "string"
|
||||||
|
? parsedOutput.data.exitCode ??
|
||||||
|
parsedOutput.data.exit_code ??
|
||||||
|
parsedOutput.data.metadata?.exitCode ??
|
||||||
|
parsedOutput.data.metadata?.exit_code ??
|
||||||
|
null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const cwd =
|
||||||
|
parsedInput.success
|
||||||
|
? firstNonEmpty(parsedInput.data.cwd, parsedInput.data.directory)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "shell",
|
||||||
|
command,
|
||||||
|
...(cwd !== undefined ? { cwd } : {}),
|
||||||
|
...(outputText !== undefined ? { output: outputText } : {}),
|
||||||
|
...(exitCode !== null ? { exitCode } : { exitCode: null }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReadDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||||
|
const parsedInput = ClaudeReadInputSchema.safeParse(input);
|
||||||
|
const parsedOutput = ClaudeReadOutputSchema.safeParse(output);
|
||||||
|
|
||||||
|
const inputPath = parsedInput.success ? resolveFilePath(parsedInput.data) : undefined;
|
||||||
|
const outputPath =
|
||||||
|
parsedOutput.success && typeof parsedOutput.data !== "string"
|
||||||
|
? resolveFilePath(parsedOutput.data)
|
||||||
|
: undefined;
|
||||||
|
const filePath = firstNonEmpty(inputPath, outputPath);
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content =
|
||||||
|
parsedOutput.success
|
||||||
|
? typeof parsedOutput.data === "string"
|
||||||
|
? parsedOutput.data
|
||||||
|
: firstNonEmpty(
|
||||||
|
parsedOutput.data.content,
|
||||||
|
parsedOutput.data.text,
|
||||||
|
parsedOutput.data.output,
|
||||||
|
parsedOutput.data.data?.content,
|
||||||
|
parsedOutput.data.data?.text,
|
||||||
|
parsedOutput.data.data?.output,
|
||||||
|
parsedOutput.data.structuredContent?.content,
|
||||||
|
parsedOutput.data.structuredContent?.text,
|
||||||
|
parsedOutput.data.structuredContent?.output,
|
||||||
|
parsedOutput.data.structured_content?.content,
|
||||||
|
parsedOutput.data.structured_content?.text,
|
||||||
|
parsedOutput.data.structured_content?.output
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const offset = parsedInput.success ? parsedInput.data.offset : undefined;
|
||||||
|
const limit = parsedInput.success ? parsedInput.data.limit : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "read",
|
||||||
|
filePath,
|
||||||
|
...(content !== undefined ? { content } : {}),
|
||||||
|
...(offset !== undefined ? { offset } : {}),
|
||||||
|
...(limit !== undefined ? { limit } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWriteDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||||
|
const parsedInput = ClaudeWriteInputSchema.safeParse(input);
|
||||||
|
const parsedOutput = ClaudeWriteOutputSchema.safeParse(output);
|
||||||
|
|
||||||
|
const filePath = firstNonEmpty(
|
||||||
|
parsedInput.success ? resolveFilePath(parsedInput.data) : undefined,
|
||||||
|
parsedOutput.success ? resolveFilePath(parsedOutput.data) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = firstNonEmpty(
|
||||||
|
parsedInput.success
|
||||||
|
? firstNonEmpty(parsedInput.data.content, parsedInput.data.new_content, parsedInput.data.newContent)
|
||||||
|
: undefined,
|
||||||
|
parsedOutput.success
|
||||||
|
? firstNonEmpty(parsedOutput.data.content, parsedOutput.data.new_content, parsedOutput.data.newContent)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "write",
|
||||||
|
filePath,
|
||||||
|
...(content !== undefined ? { content } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEditDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||||
|
const parsedInput = ClaudeEditInputSchema.safeParse(input);
|
||||||
|
const parsedOutput = ClaudeEditOutputSchema.safeParse(output);
|
||||||
|
|
||||||
|
const filePath = firstNonEmpty(
|
||||||
|
parsedInput.success ? resolveFilePath(parsedInput.data) : undefined,
|
||||||
|
parsedOutput.success ? resolveFilePath(parsedOutput.data) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldString = parsedInput.success
|
||||||
|
? firstNonEmpty(
|
||||||
|
parsedInput.data.old_string,
|
||||||
|
parsedInput.data.old_str,
|
||||||
|
parsedInput.data.oldContent,
|
||||||
|
parsedInput.data.old_content
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const newString = firstNonEmpty(
|
||||||
|
parsedInput.success
|
||||||
|
? firstNonEmpty(
|
||||||
|
parsedInput.data.new_string,
|
||||||
|
parsedInput.data.new_str,
|
||||||
|
parsedInput.data.newContent,
|
||||||
|
parsedInput.data.new_content,
|
||||||
|
parsedInput.data.content
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
parsedOutput.success
|
||||||
|
? firstNonEmpty(parsedOutput.data.newContent, parsedOutput.data.new_content, parsedOutput.data.content)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const unifiedDiff = firstNonEmpty(
|
||||||
|
parsedInput.success
|
||||||
|
? firstNonEmpty(
|
||||||
|
parsedInput.data.patch,
|
||||||
|
parsedInput.data.diff,
|
||||||
|
parsedInput.data.unified_diff,
|
||||||
|
parsedInput.data.unifiedDiff
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
parsedOutput.success
|
||||||
|
? firstNonEmpty(
|
||||||
|
parsedOutput.data.patch,
|
||||||
|
parsedOutput.data.diff,
|
||||||
|
parsedOutput.data.unified_diff,
|
||||||
|
parsedOutput.data.unifiedDiff,
|
||||||
|
parsedOutput.data.files?.[0]?.patch,
|
||||||
|
parsedOutput.data.files?.[0]?.diff,
|
||||||
|
parsedOutput.data.files?.[0]?.unified_diff,
|
||||||
|
parsedOutput.data.files?.[0]?.unifiedDiff
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "edit",
|
||||||
|
filePath,
|
||||||
|
...(oldString !== undefined ? { oldString } : {}),
|
||||||
|
...(newString !== undefined ? { newString } : {}),
|
||||||
|
...(unifiedDiff !== undefined ? { unifiedDiff } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSearchDetail(input: unknown): ToolCallDetail | undefined {
|
||||||
|
const parsedInput = ClaudeSearchInputSchema.safeParse(input);
|
||||||
|
if (!parsedInput.success) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = firstNonEmpty(parsedInput.data.query, parsedInput.data.q);
|
||||||
|
if (!query) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "search",
|
||||||
|
query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveDetail(name: string, input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||||
|
const parsed = ClaudeKnownToolDetailSchema.safeParse({
|
||||||
|
name,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBase(params: MapperParams): {
|
||||||
|
callId: string;
|
||||||
|
name: string;
|
||||||
|
input: unknown | null;
|
||||||
|
output: unknown | null;
|
||||||
|
detail?: ToolCallDetail;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
} {
|
||||||
|
const parsedParams = ClaudeMapperParamsSchema.parse(params);
|
||||||
|
const callId = coerceCallId(parsedParams.callId, parsedParams.name, parsedParams.input);
|
||||||
|
const input = parsedParams.input ?? null;
|
||||||
|
const output = parsedParams.output ?? null;
|
||||||
|
const detail = deriveDetail(parsedParams.name, input, output);
|
||||||
|
|
||||||
|
return {
|
||||||
|
callId,
|
||||||
|
name: parsedParams.name,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
...(detail ? { detail } : {}),
|
||||||
|
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapClaudeRunningToolCall(params: MapperParams): ToolCallTimelineItem {
|
||||||
|
const base = buildBase(params);
|
||||||
|
return {
|
||||||
|
type: "tool_call",
|
||||||
|
...base,
|
||||||
|
status: "running",
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapClaudeCompletedToolCall(params: MapperParams): ToolCallTimelineItem {
|
||||||
|
const base = buildBase(params);
|
||||||
|
return {
|
||||||
|
type: "tool_call",
|
||||||
|
...base,
|
||||||
|
status: "completed",
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapClaudeFailedToolCall(
|
||||||
|
params: MapperParams & { error: unknown }
|
||||||
|
): ToolCallTimelineItem {
|
||||||
|
const parsedParams = ClaudeFailedMapperParamsSchema.parse(params);
|
||||||
|
const base = buildBase(parsedParams);
|
||||||
|
return {
|
||||||
|
type: "tool_call",
|
||||||
|
...base,
|
||||||
|
status: "failed",
|
||||||
|
error: parsedParams.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapClaudeCanceledToolCall(params: MapperParams): ToolCallTimelineItem {
|
||||||
|
const base = buildBase(params);
|
||||||
|
return {
|
||||||
|
type: "tool_call",
|
||||||
|
...base,
|
||||||
|
status: "canceled",
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,7 +21,6 @@ import type {
|
|||||||
ListModelsOptions,
|
ListModelsOptions,
|
||||||
ListPersistedAgentsOptions,
|
ListPersistedAgentsOptions,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
ToolCallTimelineItem,
|
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
@@ -35,6 +34,7 @@ import path from "node:path";
|
|||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { loadCodexPersistedTimeline } from "./codex-rollout-timeline.js";
|
import { loadCodexPersistedTimeline } from "./codex-rollout-timeline.js";
|
||||||
|
import { mapCodexToolCallFromThreadItem } from "./codex/tool-call-mapper.js";
|
||||||
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||||
@@ -634,12 +634,6 @@ function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createToolCallTimelineItem(
|
|
||||||
data: Omit<ToolCallTimelineItem, "type">
|
|
||||||
): AgentTimelineItem {
|
|
||||||
return { type: "tool_call", ...data };
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractUserText(content: unknown): string | null {
|
function extractUserText(content: unknown): string | null {
|
||||||
if (!Array.isArray(content)) return null;
|
if (!Array.isArray(content)) return null;
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
@@ -654,20 +648,6 @@ function extractUserText(content: unknown): string | null {
|
|||||||
return parts.length > 0 ? parts.join("\n") : null;
|
return parts.length > 0 ? parts.join("\n") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractContentText(content: unknown): string | null {
|
|
||||||
if (!Array.isArray(content)) return null;
|
|
||||||
const parts: string[] = [];
|
|
||||||
for (const item of content) {
|
|
||||||
if (item && typeof item === "object") {
|
|
||||||
const obj = item as { text?: string };
|
|
||||||
if (typeof obj.text === "string") {
|
|
||||||
parts.push(obj.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parts.length > 0 ? parts.join("\n") : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePlanTextToTodoItems(text: string): { text: string; completed: boolean }[] {
|
function parsePlanTextToTodoItems(text: string): { text: string; completed: boolean }[] {
|
||||||
const lines = text
|
const lines = text
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -692,19 +672,6 @@ function planStepsToTodoItems(steps: Array<{ step: string; status: string }>): {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCodexFilePath(filePath: unknown, cwd: string | null | undefined): string | null {
|
|
||||||
if (typeof filePath !== "string") return null;
|
|
||||||
const trimmed = filePath.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
if (typeof cwd === "string" && cwd.trim().length > 0) {
|
|
||||||
const normalizedCwd = cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`;
|
|
||||||
if (trimmed.startsWith(normalizedCwd)) {
|
|
||||||
return trimmed.slice(normalizedCwd.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function threadItemToTimeline(
|
function threadItemToTimeline(
|
||||||
item: any,
|
item: any,
|
||||||
options?: { includeUserMessage?: boolean; cwd?: string | null }
|
options?: { includeUserMessage?: boolean; cwd?: string | null }
|
||||||
@@ -734,79 +701,11 @@ function threadItemToTimeline(
|
|||||||
const text = summary || content;
|
const text = summary || content;
|
||||||
return text ? { type: "reasoning", text } : null;
|
return text ? { type: "reasoning", text } : null;
|
||||||
}
|
}
|
||||||
case "commandExecution": {
|
case "commandExecution":
|
||||||
const output = {
|
case "fileChange":
|
||||||
type: "command",
|
case "mcpToolCall":
|
||||||
command: item.command,
|
case "webSearch":
|
||||||
output: item.aggregatedOutput ?? "",
|
return mapCodexToolCallFromThreadItem(item, { cwd });
|
||||||
exitCode: item.exitCode ?? undefined,
|
|
||||||
};
|
|
||||||
return createToolCallTimelineItem({
|
|
||||||
name: "shell",
|
|
||||||
status: item.status,
|
|
||||||
callId: item.id,
|
|
||||||
input: { command: item.command, cwd: item.cwd },
|
|
||||||
output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
case "fileChange": {
|
|
||||||
const files = Array.isArray(item.changes)
|
|
||||||
? item.changes.map((change: any) => ({
|
|
||||||
path: normalizeCodexFilePath(change.path, cwd) ?? change.path,
|
|
||||||
kind: change.kind,
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
const outputFiles = Array.isArray(item.changes)
|
|
||||||
? item.changes.map((change: any) => ({
|
|
||||||
path: normalizeCodexFilePath(change.path, cwd) ?? change.path,
|
|
||||||
patch:
|
|
||||||
typeof change.diff === "string"
|
|
||||||
? truncateUtf8Bytes(change.diff, MAX_FILE_PATCH_BYTES).text
|
|
||||||
: change.diff,
|
|
||||||
kind: change.kind,
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
return createToolCallTimelineItem({
|
|
||||||
name: "apply_patch",
|
|
||||||
status: item.status,
|
|
||||||
callId: item.id,
|
|
||||||
input: { files },
|
|
||||||
output: { files: outputFiles },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
case "mcpToolCall": {
|
|
||||||
if (item.tool === "read_file") {
|
|
||||||
const pathValue = item.arguments?.path ?? item.arguments?.file_path ?? null;
|
|
||||||
const content = extractContentText(item.result?.content) ?? "";
|
|
||||||
return createToolCallTimelineItem({
|
|
||||||
name: "read_file",
|
|
||||||
status: item.status,
|
|
||||||
callId: item.id,
|
|
||||||
input: pathValue ? { path: pathValue } : item.arguments,
|
|
||||||
output: pathValue
|
|
||||||
? { type: "read_file", path: pathValue, content }
|
|
||||||
: item.result ?? undefined,
|
|
||||||
error: item.error ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return createToolCallTimelineItem({
|
|
||||||
name: `${item.server}.${item.tool}`,
|
|
||||||
status: item.status,
|
|
||||||
callId: item.id,
|
|
||||||
input: item.arguments,
|
|
||||||
output: item.result ?? undefined,
|
|
||||||
error: item.error ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
case "webSearch": {
|
|
||||||
return createToolCallTimelineItem({
|
|
||||||
name: "web_search",
|
|
||||||
status: "completed",
|
|
||||||
callId: item.id,
|
|
||||||
input: { query: item.query },
|
|
||||||
output: item.action ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -856,21 +755,6 @@ function normalizeImageData(mimeType: string, data: string): ImageDataPayload {
|
|||||||
return { mimeType, data };
|
return { mimeType, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateUtf8Bytes(text: string, maxBytes: number): { text: string; truncated: boolean } {
|
|
||||||
if (maxBytes <= 0) {
|
|
||||||
return { text: "", truncated: text.length > 0 };
|
|
||||||
}
|
|
||||||
const bytes = Buffer.byteLength(text, "utf8");
|
|
||||||
if (bytes <= maxBytes) {
|
|
||||||
return { text, truncated: false };
|
|
||||||
}
|
|
||||||
const buffer = Buffer.from(text, "utf8");
|
|
||||||
const sliced = buffer.subarray(0, maxBytes);
|
|
||||||
return { text: sliced.toString("utf8"), truncated: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_FILE_PATCH_BYTES = 128 * 1024;
|
|
||||||
|
|
||||||
const ThreadStartedNotificationSchema = z.object({
|
const ThreadStartedNotificationSchema = z.object({
|
||||||
thread: z.object({ id: z.string() }).passthrough(),
|
thread: z.object({ id: z.string() }).passthrough(),
|
||||||
}).passthrough();
|
}).passthrough();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import type { AgentTimelineItem } from "../agent-sdk-types.js";
|
import type { AgentTimelineItem } from "../agent-sdk-types.js";
|
||||||
|
import { mapCodexRolloutToolCall } from "./codex/tool-call-mapper.js";
|
||||||
|
|
||||||
const MAX_ROLLOUT_SEARCH_DEPTH = 4;
|
const MAX_ROLLOUT_SEARCH_DEPTH = 4;
|
||||||
|
|
||||||
@@ -434,14 +435,12 @@ export async function parseRolloutFile(
|
|||||||
? [record.item]
|
? [record.item]
|
||||||
: record.kind === "call"
|
: record.kind === "call"
|
||||||
? [
|
? [
|
||||||
{
|
mapCodexRolloutToolCall({
|
||||||
type: "tool_call",
|
callId: record.callId ?? null,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
callId: record.callId,
|
input: record.input ?? null,
|
||||||
status: "completed",
|
output: record.callId ? outputsByCallId.get(record.callId) ?? null : null,
|
||||||
input: record.input,
|
}),
|
||||||
output: record.callId ? outputsByCallId.get(record.callId) : undefined,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
mapCodexRolloutToolCall,
|
||||||
|
mapCodexToolCallFromThreadItem,
|
||||||
|
} from "./tool-call-mapper.js";
|
||||||
|
|
||||||
|
describe("codex tool-call mapper", () => {
|
||||||
|
it("maps commandExecution start into running canonical call", () => {
|
||||||
|
const item = mapCodexToolCallFromThreadItem({
|
||||||
|
type: "commandExecution",
|
||||||
|
id: "codex-call-1",
|
||||||
|
status: "running",
|
||||||
|
command: "pwd",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item).toBeTruthy();
|
||||||
|
expect(item?.status).toBe("running");
|
||||||
|
expect(item?.error).toBeNull();
|
||||||
|
expect(item?.callId).toBe("codex-call-1");
|
||||||
|
expect(item?.name).toBe("shell");
|
||||||
|
expect(item?.input).toEqual({ command: "pwd", cwd: "/tmp/repo" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps mcp read_file completion with detail", () => {
|
||||||
|
const item = mapCodexToolCallFromThreadItem(
|
||||||
|
{
|
||||||
|
type: "mcpToolCall",
|
||||||
|
id: "codex-call-2",
|
||||||
|
status: "completed",
|
||||||
|
tool: "read_file",
|
||||||
|
arguments: { path: "/tmp/repo/README.md" },
|
||||||
|
result: { content: "hello" },
|
||||||
|
},
|
||||||
|
{ cwd: "/tmp/repo" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(item).toBeTruthy();
|
||||||
|
expect(item?.status).toBe("completed");
|
||||||
|
expect(item?.error).toBeNull();
|
||||||
|
expect(item?.callId).toBe("codex-call-2");
|
||||||
|
expect(item?.name).toBe("read_file");
|
||||||
|
expect(item?.detail?.type).toBe("read");
|
||||||
|
if (item?.detail?.type === "read") {
|
||||||
|
expect(item.detail.filePath).toBe("README.md");
|
||||||
|
expect(item.detail.content).toBe("hello");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps failed tool calls with required error", () => {
|
||||||
|
const item = mapCodexToolCallFromThreadItem({
|
||||||
|
type: "mcpToolCall",
|
||||||
|
id: "codex-call-3",
|
||||||
|
status: "failed",
|
||||||
|
server: "custom",
|
||||||
|
tool: "run",
|
||||||
|
arguments: { foo: "bar" },
|
||||||
|
result: null,
|
||||||
|
error: { message: "boom" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item).toBeTruthy();
|
||||||
|
expect(item?.status).toBe("failed");
|
||||||
|
expect(item?.error).toEqual({ message: "boom" });
|
||||||
|
expect(item?.callId).toBe("codex-call-3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unknown tools canonical without detail", () => {
|
||||||
|
const item = mapCodexRolloutToolCall({
|
||||||
|
callId: "codex-call-4",
|
||||||
|
name: "my_custom_tool",
|
||||||
|
input: { foo: "bar" },
|
||||||
|
output: { ok: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item.status).toBe("completed");
|
||||||
|
expect(item.error).toBeNull();
|
||||||
|
expect(item.detail).toBeUndefined();
|
||||||
|
expect(item.callId).toBe("codex-call-4");
|
||||||
|
expect(item.input).toEqual({ foo: "bar" });
|
||||||
|
expect(item.output).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,875 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js";
|
||||||
|
|
||||||
|
type CodexMapperOptions = { cwd?: string | null };
|
||||||
|
|
||||||
|
const FAILED_STATUSES = new Set(["failed", "error", "errored", "rejected", "denied"]);
|
||||||
|
const CANCELED_STATUSES = new Set(["canceled", "cancelled", "interrupted", "aborted"]);
|
||||||
|
const COMPLETED_STATUSES = new Set(["completed", "complete", "done", "success", "succeeded"]);
|
||||||
|
|
||||||
|
const CodexRolloutToolCallParamsSchema = z
|
||||||
|
.object({
|
||||||
|
callId: z.string().optional().nullable(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
input: z.unknown().optional(),
|
||||||
|
output: z.unknown().optional(),
|
||||||
|
error: z.unknown().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const CodexFileReferenceSchema = z
|
||||||
|
.object({
|
||||||
|
file_path: z.string().optional(),
|
||||||
|
filePath: z.string().optional(),
|
||||||
|
path: z.string().optional(),
|
||||||
|
target_path: z.string().optional(),
|
||||||
|
targetPath: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const CodexFileReferenceCollectionSchema = CodexFileReferenceSchema.extend({
|
||||||
|
files: z.array(CodexFileReferenceSchema).optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const CodexTextLikeSchema = z
|
||||||
|
.object({
|
||||||
|
output: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const CodexShellInputSchema = z
|
||||||
|
.object({
|
||||||
|
command: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
|
cmd: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
|
cwd: z.string().optional(),
|
||||||
|
directory: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const CodexShellOutputObjectSchema = z
|
||||||
|
.object({
|
||||||
|
command: z.string().optional(),
|
||||||
|
output: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
exitCode: z.number().nullable().optional(),
|
||||||
|
exit_code: z.number().nullable().optional(),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
exitCode: z.number().nullable().optional(),
|
||||||
|
exit_code: z.number().nullable().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.optional(),
|
||||||
|
structuredContent: CodexTextLikeSchema.optional(),
|
||||||
|
structured_content: CodexTextLikeSchema.optional(),
|
||||||
|
result: CodexTextLikeSchema.optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const CodexShellOutputSchema = z.union([z.string(), CodexShellOutputObjectSchema]);
|
||||||
|
|
||||||
|
const CodexReadInputSchema = CodexFileReferenceCollectionSchema.extend({
|
||||||
|
offset: z.number().finite().optional(),
|
||||||
|
limit: z.number().finite().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const CodexReadOutputSchema = z.union([
|
||||||
|
z.string(),
|
||||||
|
CodexFileReferenceCollectionSchema.extend({
|
||||||
|
content: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
output: z.string().optional(),
|
||||||
|
structuredContent: CodexTextLikeSchema.optional(),
|
||||||
|
structured_content: CodexTextLikeSchema.optional(),
|
||||||
|
data: CodexTextLikeSchema.optional(),
|
||||||
|
}).passthrough(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CodexWriteInputSchema = CodexFileReferenceCollectionSchema.extend({
|
||||||
|
content: z.string().optional(),
|
||||||
|
newContent: z.string().optional(),
|
||||||
|
new_content: z.string().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const CodexWriteOutputSchema = CodexFileReferenceCollectionSchema.extend({
|
||||||
|
content: z.string().optional(),
|
||||||
|
newContent: z.string().optional(),
|
||||||
|
new_content: z.string().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const CodexEditInputSchema = CodexFileReferenceCollectionSchema.extend({
|
||||||
|
old_string: z.string().optional(),
|
||||||
|
old_str: z.string().optional(),
|
||||||
|
oldContent: z.string().optional(),
|
||||||
|
old_content: z.string().optional(),
|
||||||
|
new_string: z.string().optional(),
|
||||||
|
new_str: z.string().optional(),
|
||||||
|
newContent: z.string().optional(),
|
||||||
|
new_content: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
patch: z.string().optional(),
|
||||||
|
diff: z.string().optional(),
|
||||||
|
unified_diff: z.string().optional(),
|
||||||
|
unifiedDiff: z.string().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const CodexEditOutputSchema = CodexFileReferenceCollectionSchema.extend({
|
||||||
|
patch: z.string().optional(),
|
||||||
|
diff: z.string().optional(),
|
||||||
|
unified_diff: z.string().optional(),
|
||||||
|
unifiedDiff: z.string().optional(),
|
||||||
|
files: z
|
||||||
|
.array(
|
||||||
|
CodexFileReferenceSchema.extend({
|
||||||
|
patch: z.string().optional(),
|
||||||
|
diff: z.string().optional(),
|
||||||
|
unified_diff: z.string().optional(),
|
||||||
|
unifiedDiff: z.string().optional(),
|
||||||
|
}).passthrough()
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const CodexSearchInputSchema = z
|
||||||
|
.object({
|
||||||
|
query: z.string().optional(),
|
||||||
|
q: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const CodexShellToolNameSchema = z.union([
|
||||||
|
z.literal("shell"),
|
||||||
|
z.literal("bash"),
|
||||||
|
z.literal("exec"),
|
||||||
|
z.literal("exec_command"),
|
||||||
|
z.literal("command"),
|
||||||
|
z.literal("Bash"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CodexReadToolNameSchema = z.union([
|
||||||
|
z.literal("read"),
|
||||||
|
z.literal("read_file"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CodexWriteToolNameSchema = z.union([
|
||||||
|
z.literal("write"),
|
||||||
|
z.literal("write_file"),
|
||||||
|
z.literal("create_file"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CodexEditToolNameSchema = z.union([
|
||||||
|
z.literal("edit"),
|
||||||
|
z.literal("apply_patch"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CodexSearchToolNameSchema = z.union([
|
||||||
|
z.literal("web_search"),
|
||||||
|
z.literal("search"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CodexBuiltinToolNameSchema = z.enum([
|
||||||
|
"shell",
|
||||||
|
"bash",
|
||||||
|
"exec",
|
||||||
|
"exec_command",
|
||||||
|
"command",
|
||||||
|
"read",
|
||||||
|
"read_file",
|
||||||
|
"write",
|
||||||
|
"write_file",
|
||||||
|
"create_file",
|
||||||
|
"edit",
|
||||||
|
"apply_patch",
|
||||||
|
"web_search",
|
||||||
|
"search",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CodexShellDetailCandidateSchema = z
|
||||||
|
.object({
|
||||||
|
name: CodexShellToolNameSchema,
|
||||||
|
input: z.unknown().nullable(),
|
||||||
|
output: z.unknown().nullable(),
|
||||||
|
cwd: z.string().optional().nullable(),
|
||||||
|
})
|
||||||
|
.transform(({ input, output }) => resolveShellDetail(input, output));
|
||||||
|
|
||||||
|
const CodexReadDetailCandidateSchema = z
|
||||||
|
.object({
|
||||||
|
name: CodexReadToolNameSchema,
|
||||||
|
input: z.unknown().nullable(),
|
||||||
|
output: z.unknown().nullable(),
|
||||||
|
cwd: z.string().optional().nullable(),
|
||||||
|
})
|
||||||
|
.transform(({ input, output, cwd }) => resolveReadDetail(input, output, { cwd }));
|
||||||
|
|
||||||
|
const CodexWriteDetailCandidateSchema = z
|
||||||
|
.object({
|
||||||
|
name: CodexWriteToolNameSchema,
|
||||||
|
input: z.unknown().nullable(),
|
||||||
|
output: z.unknown().nullable(),
|
||||||
|
cwd: z.string().optional().nullable(),
|
||||||
|
})
|
||||||
|
.transform(({ input, output, cwd }) => resolveWriteDetail(input, output, { cwd }));
|
||||||
|
|
||||||
|
const CodexEditDetailCandidateSchema = z
|
||||||
|
.object({
|
||||||
|
name: CodexEditToolNameSchema,
|
||||||
|
input: z.unknown().nullable(),
|
||||||
|
output: z.unknown().nullable(),
|
||||||
|
cwd: z.string().optional().nullable(),
|
||||||
|
})
|
||||||
|
.transform(({ input, output, cwd }) => resolveEditDetail(input, output, { cwd }));
|
||||||
|
|
||||||
|
const CodexSearchDetailCandidateSchema = z
|
||||||
|
.object({
|
||||||
|
name: CodexSearchToolNameSchema,
|
||||||
|
input: z.unknown().nullable(),
|
||||||
|
output: z.unknown().nullable(),
|
||||||
|
cwd: z.string().optional().nullable(),
|
||||||
|
})
|
||||||
|
.transform(({ input }) => resolveSearchDetail(input));
|
||||||
|
|
||||||
|
const CodexKnownToolDetailSchema = z.union([
|
||||||
|
CodexShellDetailCandidateSchema,
|
||||||
|
CodexReadDetailCandidateSchema,
|
||||||
|
CodexWriteDetailCandidateSchema,
|
||||||
|
CodexEditDetailCandidateSchema,
|
||||||
|
CodexSearchDetailCandidateSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CodexCommandExecutionItemSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("commandExecution"),
|
||||||
|
id: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
error: z.unknown().optional(),
|
||||||
|
command: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
|
cwd: z.string().optional(),
|
||||||
|
aggregatedOutput: z.string().optional(),
|
||||||
|
exitCode: z.number().nullable().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const CodexFileChangeItemSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("fileChange"),
|
||||||
|
id: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
error: z.unknown().optional(),
|
||||||
|
changes: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
path: z.string().optional(),
|
||||||
|
kind: z.string().optional(),
|
||||||
|
diff: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const CodexMcpToolCallItemSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("mcpToolCall"),
|
||||||
|
id: z.string().optional(),
|
||||||
|
callID: z.string().optional(),
|
||||||
|
call_id: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
error: z.unknown().optional(),
|
||||||
|
tool: z.string().optional(),
|
||||||
|
server: z.string().optional(),
|
||||||
|
arguments: z.unknown().optional(),
|
||||||
|
result: z.unknown().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const CodexWebSearchItemSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("webSearch"),
|
||||||
|
id: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
error: z.unknown().optional(),
|
||||||
|
query: z.string().optional(),
|
||||||
|
action: z.unknown().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const CodexThreadItemSchema = z.discriminatedUnion("type", [
|
||||||
|
CodexCommandExecutionItemSchema,
|
||||||
|
CodexFileChangeItemSchema,
|
||||||
|
CodexMcpToolCallItemSchema,
|
||||||
|
CodexWebSearchItemSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function hashText(value: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < value.length; i += 1) {
|
||||||
|
hash = (hash << 5) - hash + value.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceCallId(raw: string | null | undefined, name: string, input: unknown): string {
|
||||||
|
if (typeof raw === "string" && raw.trim().length > 0) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
let serialized = "";
|
||||||
|
try {
|
||||||
|
serialized = JSON.stringify(input) ?? "";
|
||||||
|
} catch {
|
||||||
|
serialized = String(input);
|
||||||
|
}
|
||||||
|
return `codex-${hashText(`${name}:${serialized}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCodexFilePath(filePath: string | undefined, cwd: string | null | undefined): string | undefined {
|
||||||
|
if (typeof filePath !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = filePath.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof cwd === "string" && cwd.length > 0) {
|
||||||
|
const prefix = cwd.endsWith("/") ? cwd : `${cwd}/`;
|
||||||
|
if (trimmed.startsWith(prefix)) {
|
||||||
|
return trimmed.slice(prefix.length) || ".";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNonEmpty(...values: Array<string | undefined>): string | undefined {
|
||||||
|
return values.find((value) => typeof value === "string" && value.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandFromValue(value: string | string[] | undefined): string | undefined {
|
||||||
|
if (typeof value === "string" && value.length > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const tokens = value.filter((token): token is string => typeof token === "string" && token.length > 0);
|
||||||
|
if (tokens.length > 0) {
|
||||||
|
return tokens.join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFilePath(
|
||||||
|
value: z.infer<typeof CodexFileReferenceCollectionSchema>,
|
||||||
|
cwd: string | null | undefined
|
||||||
|
): string | undefined {
|
||||||
|
return normalizeCodexFilePath(
|
||||||
|
firstNonEmpty(
|
||||||
|
value.file_path,
|
||||||
|
value.filePath,
|
||||||
|
value.path,
|
||||||
|
value.target_path,
|
||||||
|
value.targetPath,
|
||||||
|
value.files?.[0]?.path,
|
||||||
|
value.files?.[0]?.filePath,
|
||||||
|
value.files?.[0]?.file_path
|
||||||
|
),
|
||||||
|
cwd
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStatus(rawStatus: string | undefined, error: unknown, output: unknown): ToolCallTimelineItem["status"] {
|
||||||
|
if (error !== undefined && error !== null) {
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawStatus === "string") {
|
||||||
|
const normalized = rawStatus.trim().toLowerCase();
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
if (FAILED_STATUSES.has(normalized)) {
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
if (CANCELED_STATUSES.has(normalized)) {
|
||||||
|
return "canceled";
|
||||||
|
}
|
||||||
|
if (COMPLETED_STATUSES.has(normalized)) {
|
||||||
|
return "completed";
|
||||||
|
}
|
||||||
|
return "running";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output !== null && output !== undefined ? "completed" : "running";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveShellDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||||
|
const parsedInput = CodexShellInputSchema.safeParse(input);
|
||||||
|
const parsedOutput = CodexShellOutputSchema.safeParse(output);
|
||||||
|
|
||||||
|
const command =
|
||||||
|
(parsedInput.success
|
||||||
|
? commandFromValue(parsedInput.data.command) ?? commandFromValue(parsedInput.data.cmd)
|
||||||
|
: undefined) ??
|
||||||
|
(parsedOutput.success && typeof parsedOutput.data !== "string"
|
||||||
|
? parsedOutput.data.command
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = parsedInput.success
|
||||||
|
? firstNonEmpty(parsedInput.data.cwd, parsedInput.data.directory)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const outputText =
|
||||||
|
parsedOutput.success
|
||||||
|
? typeof parsedOutput.data === "string"
|
||||||
|
? parsedOutput.data
|
||||||
|
: firstNonEmpty(
|
||||||
|
parsedOutput.data.output,
|
||||||
|
parsedOutput.data.text,
|
||||||
|
parsedOutput.data.content,
|
||||||
|
parsedOutput.data.structuredContent?.output,
|
||||||
|
parsedOutput.data.structuredContent?.text,
|
||||||
|
parsedOutput.data.structuredContent?.content,
|
||||||
|
parsedOutput.data.structured_content?.output,
|
||||||
|
parsedOutput.data.structured_content?.text,
|
||||||
|
parsedOutput.data.structured_content?.content,
|
||||||
|
parsedOutput.data.result?.output,
|
||||||
|
parsedOutput.data.result?.text,
|
||||||
|
parsedOutput.data.result?.content
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const exitCode =
|
||||||
|
parsedOutput.success && typeof parsedOutput.data !== "string"
|
||||||
|
? parsedOutput.data.exitCode ??
|
||||||
|
parsedOutput.data.exit_code ??
|
||||||
|
parsedOutput.data.metadata?.exitCode ??
|
||||||
|
parsedOutput.data.metadata?.exit_code ??
|
||||||
|
null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "shell",
|
||||||
|
command,
|
||||||
|
...(cwd !== undefined ? { cwd } : {}),
|
||||||
|
...(outputText !== undefined ? { output: outputText } : {}),
|
||||||
|
...(exitCode !== null ? { exitCode } : { exitCode: null }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReadDetail(input: unknown, output: unknown, options?: CodexMapperOptions): ToolCallDetail | undefined {
|
||||||
|
const parsedInput = CodexReadInputSchema.safeParse(input);
|
||||||
|
const parsedOutput = CodexReadOutputSchema.safeParse(output);
|
||||||
|
|
||||||
|
const filePath = firstNonEmpty(
|
||||||
|
parsedInput.success ? resolveFilePath(parsedInput.data, options?.cwd) : undefined,
|
||||||
|
parsedOutput.success && typeof parsedOutput.data !== "string"
|
||||||
|
? resolveFilePath(parsedOutput.data, options?.cwd)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content =
|
||||||
|
parsedOutput.success
|
||||||
|
? typeof parsedOutput.data === "string"
|
||||||
|
? parsedOutput.data
|
||||||
|
: firstNonEmpty(
|
||||||
|
parsedOutput.data.content,
|
||||||
|
parsedOutput.data.text,
|
||||||
|
parsedOutput.data.output,
|
||||||
|
parsedOutput.data.structuredContent?.content,
|
||||||
|
parsedOutput.data.structuredContent?.text,
|
||||||
|
parsedOutput.data.structuredContent?.output,
|
||||||
|
parsedOutput.data.structured_content?.content,
|
||||||
|
parsedOutput.data.structured_content?.text,
|
||||||
|
parsedOutput.data.structured_content?.output,
|
||||||
|
parsedOutput.data.data?.content,
|
||||||
|
parsedOutput.data.data?.text,
|
||||||
|
parsedOutput.data.data?.output
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "read",
|
||||||
|
filePath,
|
||||||
|
...(content !== undefined ? { content } : {}),
|
||||||
|
...(parsedInput.success && parsedInput.data.offset !== undefined ? { offset: parsedInput.data.offset } : {}),
|
||||||
|
...(parsedInput.success && parsedInput.data.limit !== undefined ? { limit: parsedInput.data.limit } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWriteDetail(input: unknown, output: unknown, options?: CodexMapperOptions): ToolCallDetail | undefined {
|
||||||
|
const parsedInput = CodexWriteInputSchema.safeParse(input);
|
||||||
|
const parsedOutput = CodexWriteOutputSchema.safeParse(output);
|
||||||
|
|
||||||
|
const filePath = firstNonEmpty(
|
||||||
|
parsedInput.success ? resolveFilePath(parsedInput.data, options?.cwd) : undefined,
|
||||||
|
parsedOutput.success ? resolveFilePath(parsedOutput.data, options?.cwd) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = firstNonEmpty(
|
||||||
|
parsedInput.success
|
||||||
|
? firstNonEmpty(parsedInput.data.content, parsedInput.data.newContent, parsedInput.data.new_content)
|
||||||
|
: undefined,
|
||||||
|
parsedOutput.success
|
||||||
|
? firstNonEmpty(parsedOutput.data.content, parsedOutput.data.newContent, parsedOutput.data.new_content)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "write",
|
||||||
|
filePath,
|
||||||
|
...(content !== undefined ? { content } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEditDetail(input: unknown, output: unknown, options?: CodexMapperOptions): ToolCallDetail | undefined {
|
||||||
|
const parsedInput = CodexEditInputSchema.safeParse(input);
|
||||||
|
const parsedOutput = CodexEditOutputSchema.safeParse(output);
|
||||||
|
|
||||||
|
const filePath = firstNonEmpty(
|
||||||
|
parsedInput.success ? resolveFilePath(parsedInput.data, options?.cwd) : undefined,
|
||||||
|
parsedOutput.success ? resolveFilePath(parsedOutput.data, options?.cwd) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldString = parsedInput.success
|
||||||
|
? firstNonEmpty(
|
||||||
|
parsedInput.data.old_string,
|
||||||
|
parsedInput.data.old_str,
|
||||||
|
parsedInput.data.oldContent,
|
||||||
|
parsedInput.data.old_content
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const newString = parsedInput.success
|
||||||
|
? firstNonEmpty(
|
||||||
|
parsedInput.data.new_string,
|
||||||
|
parsedInput.data.new_str,
|
||||||
|
parsedInput.data.newContent,
|
||||||
|
parsedInput.data.new_content,
|
||||||
|
parsedInput.data.content
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const unifiedDiff = firstNonEmpty(
|
||||||
|
parsedInput.success
|
||||||
|
? firstNonEmpty(
|
||||||
|
parsedInput.data.patch,
|
||||||
|
parsedInput.data.diff,
|
||||||
|
parsedInput.data.unified_diff,
|
||||||
|
parsedInput.data.unifiedDiff
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
parsedOutput.success
|
||||||
|
? firstNonEmpty(
|
||||||
|
parsedOutput.data.patch,
|
||||||
|
parsedOutput.data.diff,
|
||||||
|
parsedOutput.data.unified_diff,
|
||||||
|
parsedOutput.data.unifiedDiff,
|
||||||
|
parsedOutput.data.files?.[0]?.patch,
|
||||||
|
parsedOutput.data.files?.[0]?.diff,
|
||||||
|
parsedOutput.data.files?.[0]?.unified_diff,
|
||||||
|
parsedOutput.data.files?.[0]?.unifiedDiff
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "edit",
|
||||||
|
filePath,
|
||||||
|
...(oldString !== undefined ? { oldString } : {}),
|
||||||
|
...(newString !== undefined ? { newString } : {}),
|
||||||
|
...(unifiedDiff !== undefined ? { unifiedDiff } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSearchDetail(input: unknown): ToolCallDetail | undefined {
|
||||||
|
const parsedInput = CodexSearchInputSchema.safeParse(input);
|
||||||
|
if (!parsedInput.success) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const query = firstNonEmpty(parsedInput.data.query, parsedInput.data.q);
|
||||||
|
if (!query) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "search",
|
||||||
|
query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveDetail(name: string, input: unknown, output: unknown, options?: CodexMapperOptions): ToolCallDetail | undefined {
|
||||||
|
const parsed = CodexKnownToolDetailSchema.safeParse({
|
||||||
|
name,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
cwd: options?.cwd ?? null,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolCall(
|
||||||
|
params: {
|
||||||
|
callId: string;
|
||||||
|
name: string;
|
||||||
|
status: ToolCallTimelineItem["status"];
|
||||||
|
input: unknown | null;
|
||||||
|
output: unknown | null;
|
||||||
|
error: unknown | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
options?: CodexMapperOptions
|
||||||
|
): ToolCallTimelineItem {
|
||||||
|
const detail = deriveDetail(params.name, params.input, params.output, options);
|
||||||
|
|
||||||
|
if (params.status === "failed") {
|
||||||
|
return {
|
||||||
|
type: "tool_call",
|
||||||
|
callId: params.callId,
|
||||||
|
name: params.name,
|
||||||
|
status: "failed",
|
||||||
|
input: params.input,
|
||||||
|
output: params.output,
|
||||||
|
error: params.error ?? { message: "Tool call failed" },
|
||||||
|
...(detail ? { detail } : {}),
|
||||||
|
...(params.metadata ? { metadata: params.metadata } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "tool_call",
|
||||||
|
callId: params.callId,
|
||||||
|
name: params.name,
|
||||||
|
status: params.status,
|
||||||
|
input: params.input,
|
||||||
|
output: params.output,
|
||||||
|
error: null,
|
||||||
|
...(detail ? { detail } : {}),
|
||||||
|
...(params.metadata ? { metadata: params.metadata } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMcpToolName(server: string | undefined, tool: string): string {
|
||||||
|
const trimmedTool = tool.trim();
|
||||||
|
if (!trimmedTool) {
|
||||||
|
return "tool";
|
||||||
|
}
|
||||||
|
|
||||||
|
const builtin = CodexBuiltinToolNameSchema.safeParse(trimmedTool);
|
||||||
|
if (builtin.success) {
|
||||||
|
return builtin.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedServer = typeof server === "string" ? server.trim() : "";
|
||||||
|
if (trimmedServer.length > 0) {
|
||||||
|
return `${trimmedServer}.${trimmedTool}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNullableObject(value: Record<string, unknown>): Record<string, unknown> | null {
|
||||||
|
return Object.keys(value).length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCommandExecutionItem(
|
||||||
|
item: z.infer<typeof CodexCommandExecutionItemSchema>,
|
||||||
|
options?: CodexMapperOptions
|
||||||
|
): ToolCallTimelineItem {
|
||||||
|
const command = commandFromValue(item.command);
|
||||||
|
const input = toNullableObject({
|
||||||
|
...(command !== undefined ? { command } : {}),
|
||||||
|
...(item.cwd !== undefined ? { cwd: item.cwd } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const output =
|
||||||
|
item.aggregatedOutput !== undefined || item.exitCode !== undefined
|
||||||
|
? {
|
||||||
|
...(command !== undefined ? { command } : {}),
|
||||||
|
...(item.aggregatedOutput !== undefined ? { output: item.aggregatedOutput } : {}),
|
||||||
|
...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const name = "shell";
|
||||||
|
const callId = coerceCallId(item.id, name, input);
|
||||||
|
const error = item.error ?? null;
|
||||||
|
const status = resolveStatus(item.status, error, output);
|
||||||
|
|
||||||
|
return buildToolCall(
|
||||||
|
{
|
||||||
|
callId,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFileChangeItem(
|
||||||
|
item: z.infer<typeof CodexFileChangeItemSchema>,
|
||||||
|
options?: CodexMapperOptions
|
||||||
|
): ToolCallTimelineItem {
|
||||||
|
const changes = item.changes ?? [];
|
||||||
|
|
||||||
|
const files = changes.map((change) => ({
|
||||||
|
...(normalizeCodexFilePath(change.path, options?.cwd) !== undefined
|
||||||
|
? { path: normalizeCodexFilePath(change.path, options?.cwd) }
|
||||||
|
: {}),
|
||||||
|
...(change.kind !== undefined ? { kind: change.kind } : {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const outputFiles = changes.map((change) => ({
|
||||||
|
...(normalizeCodexFilePath(change.path, options?.cwd) !== undefined
|
||||||
|
? { path: normalizeCodexFilePath(change.path, options?.cwd) }
|
||||||
|
: {}),
|
||||||
|
...(change.diff !== undefined ? { patch: change.diff } : {}),
|
||||||
|
...(change.kind !== undefined ? { kind: change.kind } : {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const input = toNullableObject({ ...(files.length > 0 ? { files } : {}) });
|
||||||
|
const output = toNullableObject({ ...(outputFiles.length > 0 ? { files: outputFiles } : {}) });
|
||||||
|
const name = "apply_patch";
|
||||||
|
const callId = coerceCallId(item.id, name, input);
|
||||||
|
const error = item.error ?? null;
|
||||||
|
const status = resolveStatus(item.status, error, output);
|
||||||
|
|
||||||
|
return buildToolCall(
|
||||||
|
{
|
||||||
|
callId,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMcpToolCallItem(
|
||||||
|
item: z.infer<typeof CodexMcpToolCallItemSchema>,
|
||||||
|
options?: CodexMapperOptions
|
||||||
|
): ToolCallTimelineItem {
|
||||||
|
const tool = item.tool?.trim() || "tool";
|
||||||
|
const name = buildMcpToolName(item.server, tool);
|
||||||
|
const input = item.arguments ?? null;
|
||||||
|
const output = item.result ?? null;
|
||||||
|
const error = item.error ?? null;
|
||||||
|
const callId = coerceCallId(item.id ?? item.callID ?? item.call_id, name, input);
|
||||||
|
const status = resolveStatus(item.status, error, output);
|
||||||
|
|
||||||
|
return buildToolCall(
|
||||||
|
{
|
||||||
|
callId,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWebSearchItem(
|
||||||
|
item: z.infer<typeof CodexWebSearchItemSchema>,
|
||||||
|
options?: CodexMapperOptions
|
||||||
|
): ToolCallTimelineItem {
|
||||||
|
const input = item.query !== undefined ? { query: item.query } : null;
|
||||||
|
const output = item.action ?? null;
|
||||||
|
const name = "web_search";
|
||||||
|
const callId = coerceCallId(item.id, name, input);
|
||||||
|
const error = item.error ?? null;
|
||||||
|
const status = resolveStatus(item.status ?? "completed", error, output);
|
||||||
|
|
||||||
|
return buildToolCall(
|
||||||
|
{
|
||||||
|
callId,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCodexThreadItemToTimelineSchema(options?: CodexMapperOptions) {
|
||||||
|
return CodexThreadItemSchema.transform((item): ToolCallTimelineItem => {
|
||||||
|
switch (item.type) {
|
||||||
|
case "commandExecution":
|
||||||
|
return mapCommandExecutionItem(item, options);
|
||||||
|
case "fileChange":
|
||||||
|
return mapFileChangeItem(item, options);
|
||||||
|
case "mcpToolCall":
|
||||||
|
return mapMcpToolCallItem(item, options);
|
||||||
|
case "webSearch":
|
||||||
|
return mapWebSearchItem(item, options);
|
||||||
|
default: {
|
||||||
|
const exhaustiveCheck: never = item;
|
||||||
|
throw new Error(`Unhandled Codex thread item type: ${String(exhaustiveCheck)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapCodexToolCallFromThreadItem(
|
||||||
|
item: unknown,
|
||||||
|
options?: CodexMapperOptions
|
||||||
|
): ToolCallTimelineItem | null {
|
||||||
|
const parsed = createCodexThreadItemToTimelineSchema(options).safeParse(item);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapCodexRolloutToolCall(params: {
|
||||||
|
callId?: string | null;
|
||||||
|
name: string;
|
||||||
|
input?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
error?: unknown;
|
||||||
|
}): ToolCallTimelineItem {
|
||||||
|
const parsed = CodexRolloutToolCallParamsSchema.parse(params);
|
||||||
|
const input = parsed.input ?? null;
|
||||||
|
const output = parsed.output ?? null;
|
||||||
|
const error = parsed.error ?? null;
|
||||||
|
const status = resolveStatus("completed", error, output);
|
||||||
|
const callId = coerceCallId(parsed.callId, parsed.name, input);
|
||||||
|
|
||||||
|
return buildToolCall({
|
||||||
|
callId,
|
||||||
|
name: parsed.name,
|
||||||
|
status,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
McpServerConfig,
|
McpServerConfig,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
|
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||||
|
|
||||||
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
@@ -620,15 +621,14 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
yield {
|
yield {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
item: {
|
item: mapOpencodeToolCall({
|
||||||
type: "tool_call",
|
toolName,
|
||||||
name: toolName,
|
|
||||||
callId: toolPart.callID ?? toolPart.id,
|
callId: toolPart.callID ?? toolPart.id,
|
||||||
status: this.mapToolState(state?.status),
|
status: state?.status,
|
||||||
input: state?.input,
|
input: state?.input,
|
||||||
output: state?.output,
|
output: state?.output,
|
||||||
error: state?.error,
|
error: state?.error,
|
||||||
},
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -895,15 +895,14 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
events.push({
|
events.push({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
item: {
|
item: mapOpencodeToolCall({
|
||||||
type: "tool_call",
|
toolName,
|
||||||
name: toolName,
|
callId: (part.callID as string | undefined) ?? (part.id as string | undefined),
|
||||||
callId: part.callID as string | undefined,
|
status,
|
||||||
status: this.mapToolState(status),
|
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
error,
|
error,
|
||||||
},
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (partType === "step-finish") {
|
} else if (partType === "step-finish") {
|
||||||
@@ -983,21 +982,6 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapToolState(state?: string): string {
|
|
||||||
switch (state) {
|
|
||||||
case "pending":
|
|
||||||
return "pending";
|
|
||||||
case "running":
|
|
||||||
return "running";
|
|
||||||
case "complete":
|
|
||||||
return "completed";
|
|
||||||
case "error":
|
|
||||||
return "failed";
|
|
||||||
default:
|
|
||||||
return "pending";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractAndResetUsage(): AgentUsage | undefined {
|
private extractAndResetUsage(): AgentUsage | undefined {
|
||||||
const usage = this.accumulatedUsage;
|
const usage = this.accumulatedUsage;
|
||||||
this.accumulatedUsage = {};
|
this.accumulatedUsage = {};
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { mapOpencodeToolCall } from "./tool-call-mapper.js";
|
||||||
|
|
||||||
|
describe("opencode tool-call mapper", () => {
|
||||||
|
it("maps running shell calls", () => {
|
||||||
|
const item = mapOpencodeToolCall({
|
||||||
|
toolName: "shell",
|
||||||
|
callId: "opencode-call-1",
|
||||||
|
status: "running",
|
||||||
|
input: { command: "pwd", cwd: "/tmp/repo" },
|
||||||
|
output: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item.status).toBe("running");
|
||||||
|
expect(item.error).toBeNull();
|
||||||
|
expect(item.callId).toBe("opencode-call-1");
|
||||||
|
expect(item.detail?.type).toBe("shell");
|
||||||
|
if (item.detail?.type === "shell") {
|
||||||
|
expect(item.detail.command).toBe("pwd");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps completed read calls", () => {
|
||||||
|
const item = mapOpencodeToolCall({
|
||||||
|
toolName: "read_file",
|
||||||
|
callId: "opencode-call-2",
|
||||||
|
status: "complete",
|
||||||
|
input: { file_path: "README.md" },
|
||||||
|
output: { content: "hello" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item.status).toBe("completed");
|
||||||
|
expect(item.error).toBeNull();
|
||||||
|
expect(item.callId).toBe("opencode-call-2");
|
||||||
|
expect(item.detail?.type).toBe("read");
|
||||||
|
if (item.detail?.type === "read") {
|
||||||
|
expect(item.detail.filePath).toBe("README.md");
|
||||||
|
expect(item.detail.content).toBe("hello");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps failed calls with required error", () => {
|
||||||
|
const item = mapOpencodeToolCall({
|
||||||
|
toolName: "shell",
|
||||||
|
callId: "opencode-call-3",
|
||||||
|
status: "error",
|
||||||
|
input: { command: "false" },
|
||||||
|
output: null,
|
||||||
|
error: "command failed",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item.status).toBe("failed");
|
||||||
|
expect(item.error).toBe("command failed");
|
||||||
|
expect(item.callId).toBe("opencode-call-3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unknown tools canonical without detail", () => {
|
||||||
|
const item = mapOpencodeToolCall({
|
||||||
|
toolName: "my_custom_tool",
|
||||||
|
callId: "opencode-call-4",
|
||||||
|
status: "completed",
|
||||||
|
input: { foo: "bar" },
|
||||||
|
output: { ok: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item.status).toBe("completed");
|
||||||
|
expect(item.error).toBeNull();
|
||||||
|
expect(item.detail).toBeUndefined();
|
||||||
|
expect(item.input).toEqual({ foo: "bar" });
|
||||||
|
expect(item.output).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js";
|
||||||
|
|
||||||
|
type OpencodeToolCallParams = {
|
||||||
|
toolName: string;
|
||||||
|
callId?: string | null;
|
||||||
|
status?: unknown;
|
||||||
|
input?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
error?: unknown;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FAILED_STATUSES = new Set(["error", "failed", "failure"]);
|
||||||
|
const CANCELED_STATUSES = new Set(["canceled", "cancelled", "aborted", "interrupted"]);
|
||||||
|
const COMPLETED_STATUSES = new Set(["complete", "completed", "success", "succeeded", "done"]);
|
||||||
|
|
||||||
|
const OpencodeToolCallParamsSchema = z
|
||||||
|
.object({
|
||||||
|
toolName: z.string().min(1),
|
||||||
|
callId: z.string().optional().nullable(),
|
||||||
|
status: z.unknown().optional(),
|
||||||
|
input: z.unknown().optional(),
|
||||||
|
output: z.unknown().optional(),
|
||||||
|
error: z.unknown().optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const OpencodeShellToolNameSchema = z.union([
|
||||||
|
z.literal("shell"),
|
||||||
|
z.literal("bash"),
|
||||||
|
z.literal("exec_command"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const OpencodeReadToolNameSchema = z.union([
|
||||||
|
z.literal("read"),
|
||||||
|
z.literal("read_file"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const OpencodeWriteToolNameSchema = z.union([
|
||||||
|
z.literal("write"),
|
||||||
|
z.literal("write_file"),
|
||||||
|
z.literal("create_file"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const OpencodeEditToolNameSchema = z.union([
|
||||||
|
z.literal("edit"),
|
||||||
|
z.literal("apply_patch"),
|
||||||
|
z.literal("apply_diff"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const OpencodeSearchToolNameSchema = z.union([
|
||||||
|
z.literal("search"),
|
||||||
|
z.literal("web_search"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const OpencodeFileReferenceSchema = z
|
||||||
|
.object({
|
||||||
|
file_path: z.string().optional(),
|
||||||
|
filePath: z.string().optional(),
|
||||||
|
path: z.string().optional(),
|
||||||
|
target_path: z.string().optional(),
|
||||||
|
targetPath: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const OpencodeFileReferenceCollectionSchema = OpencodeFileReferenceSchema.extend({
|
||||||
|
files: z.array(OpencodeFileReferenceSchema).optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const OpencodeTextLikeSchema = z
|
||||||
|
.object({
|
||||||
|
output: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const OpencodeShellInputSchema = z
|
||||||
|
.object({
|
||||||
|
command: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
|
cmd: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
|
cwd: z.string().optional(),
|
||||||
|
directory: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const OpencodeShellOutputObjectSchema = z
|
||||||
|
.object({
|
||||||
|
command: z.string().optional(),
|
||||||
|
output: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
exitCode: z.number().nullable().optional(),
|
||||||
|
exit_code: z.number().nullable().optional(),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
exitCode: z.number().nullable().optional(),
|
||||||
|
exit_code: z.number().nullable().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.optional(),
|
||||||
|
structuredContent: OpencodeTextLikeSchema.optional(),
|
||||||
|
structured_content: OpencodeTextLikeSchema.optional(),
|
||||||
|
result: OpencodeTextLikeSchema.optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const OpencodeShellOutputSchema = z.union([z.string(), OpencodeShellOutputObjectSchema]);
|
||||||
|
|
||||||
|
const OpencodeReadInputSchema = OpencodeFileReferenceCollectionSchema.extend({
|
||||||
|
offset: z.number().finite().optional(),
|
||||||
|
limit: z.number().finite().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const OpencodeReadOutputSchema = z.union([
|
||||||
|
z.string(),
|
||||||
|
OpencodeFileReferenceCollectionSchema.extend({
|
||||||
|
content: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
output: z.string().optional(),
|
||||||
|
structuredContent: OpencodeTextLikeSchema.optional(),
|
||||||
|
structured_content: OpencodeTextLikeSchema.optional(),
|
||||||
|
data: OpencodeTextLikeSchema.optional(),
|
||||||
|
}).passthrough(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const OpencodeWriteInputSchema = OpencodeFileReferenceCollectionSchema.extend({
|
||||||
|
content: z.string().optional(),
|
||||||
|
newContent: z.string().optional(),
|
||||||
|
new_content: z.string().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const OpencodeWriteOutputSchema = OpencodeFileReferenceCollectionSchema.extend({
|
||||||
|
content: z.string().optional(),
|
||||||
|
newContent: z.string().optional(),
|
||||||
|
new_content: z.string().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const OpencodeEditInputSchema = OpencodeFileReferenceCollectionSchema.extend({
|
||||||
|
old_string: z.string().optional(),
|
||||||
|
old_str: z.string().optional(),
|
||||||
|
oldContent: z.string().optional(),
|
||||||
|
old_content: z.string().optional(),
|
||||||
|
new_string: z.string().optional(),
|
||||||
|
new_str: z.string().optional(),
|
||||||
|
newContent: z.string().optional(),
|
||||||
|
new_content: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
patch: z.string().optional(),
|
||||||
|
diff: z.string().optional(),
|
||||||
|
unified_diff: z.string().optional(),
|
||||||
|
unifiedDiff: z.string().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const OpencodeEditOutputSchema = OpencodeFileReferenceCollectionSchema.extend({
|
||||||
|
patch: z.string().optional(),
|
||||||
|
diff: z.string().optional(),
|
||||||
|
unified_diff: z.string().optional(),
|
||||||
|
unifiedDiff: z.string().optional(),
|
||||||
|
files: z
|
||||||
|
.array(
|
||||||
|
OpencodeFileReferenceSchema.extend({
|
||||||
|
patch: z.string().optional(),
|
||||||
|
diff: z.string().optional(),
|
||||||
|
unified_diff: z.string().optional(),
|
||||||
|
unifiedDiff: z.string().optional(),
|
||||||
|
}).passthrough()
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const OpencodeSearchInputSchema = z
|
||||||
|
.object({
|
||||||
|
query: z.string().optional(),
|
||||||
|
q: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const OpencodeShellDetailCandidateSchema = z
|
||||||
|
.object({
|
||||||
|
toolName: OpencodeShellToolNameSchema,
|
||||||
|
input: z.unknown().nullable(),
|
||||||
|
output: z.unknown().nullable(),
|
||||||
|
})
|
||||||
|
.transform(({ input, output }) => resolveShellDetail(input, output));
|
||||||
|
|
||||||
|
const OpencodeReadDetailCandidateSchema = z
|
||||||
|
.object({
|
||||||
|
toolName: OpencodeReadToolNameSchema,
|
||||||
|
input: z.unknown().nullable(),
|
||||||
|
output: z.unknown().nullable(),
|
||||||
|
})
|
||||||
|
.transform(({ input, output }) => resolveReadDetail(input, output));
|
||||||
|
|
||||||
|
const OpencodeWriteDetailCandidateSchema = z
|
||||||
|
.object({
|
||||||
|
toolName: OpencodeWriteToolNameSchema,
|
||||||
|
input: z.unknown().nullable(),
|
||||||
|
output: z.unknown().nullable(),
|
||||||
|
})
|
||||||
|
.transform(({ input, output }) => resolveWriteDetail(input, output));
|
||||||
|
|
||||||
|
const OpencodeEditDetailCandidateSchema = z
|
||||||
|
.object({
|
||||||
|
toolName: OpencodeEditToolNameSchema,
|
||||||
|
input: z.unknown().nullable(),
|
||||||
|
output: z.unknown().nullable(),
|
||||||
|
})
|
||||||
|
.transform(({ input, output }) => resolveEditDetail(input, output));
|
||||||
|
|
||||||
|
const OpencodeSearchDetailCandidateSchema = z
|
||||||
|
.object({
|
||||||
|
toolName: OpencodeSearchToolNameSchema,
|
||||||
|
input: z.unknown().nullable(),
|
||||||
|
output: z.unknown().nullable(),
|
||||||
|
})
|
||||||
|
.transform(({ input }) => resolveSearchDetail(input));
|
||||||
|
|
||||||
|
const OpencodeKnownToolDetailSchema = z.union([
|
||||||
|
OpencodeShellDetailCandidateSchema,
|
||||||
|
OpencodeReadDetailCandidateSchema,
|
||||||
|
OpencodeWriteDetailCandidateSchema,
|
||||||
|
OpencodeEditDetailCandidateSchema,
|
||||||
|
OpencodeSearchDetailCandidateSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function firstNonEmpty(...values: Array<string | undefined>): string | undefined {
|
||||||
|
return values.find((value) => typeof value === "string" && value.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandFromValue(value: string | string[] | undefined): string | undefined {
|
||||||
|
if (typeof value === "string" && value.length > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const tokens = value.filter((token): token is string => typeof token === "string" && token.length > 0);
|
||||||
|
if (tokens.length > 0) {
|
||||||
|
return tokens.join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFilePath(value: z.infer<typeof OpencodeFileReferenceCollectionSchema>): string | undefined {
|
||||||
|
return firstNonEmpty(
|
||||||
|
value.file_path,
|
||||||
|
value.filePath,
|
||||||
|
value.path,
|
||||||
|
value.target_path,
|
||||||
|
value.targetPath,
|
||||||
|
value.files?.[0]?.path,
|
||||||
|
value.files?.[0]?.filePath,
|
||||||
|
value.files?.[0]?.file_path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashText(value: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < value.length; i += 1) {
|
||||||
|
hash = (hash << 5) - hash + value.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceCallId(callId: string | null | undefined, toolName: string, input: unknown): string {
|
||||||
|
if (typeof callId === "string" && callId.trim().length > 0) {
|
||||||
|
return callId;
|
||||||
|
}
|
||||||
|
|
||||||
|
let serialized = "";
|
||||||
|
try {
|
||||||
|
serialized = JSON.stringify(input) ?? "";
|
||||||
|
} catch {
|
||||||
|
serialized = String(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `opencode-${hashText(`${toolName}:${serialized}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStatus(rawStatus: unknown, error: unknown, output: unknown): ToolCallTimelineItem["status"] {
|
||||||
|
if (error !== null && error !== undefined) {
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawStatus === "string") {
|
||||||
|
const normalized = rawStatus.trim().toLowerCase();
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
if (FAILED_STATUSES.has(normalized)) {
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
if (CANCELED_STATUSES.has(normalized)) {
|
||||||
|
return "canceled";
|
||||||
|
}
|
||||||
|
if (COMPLETED_STATUSES.has(normalized)) {
|
||||||
|
return "completed";
|
||||||
|
}
|
||||||
|
return "running";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output !== null && output !== undefined ? "completed" : "running";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveShellDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||||
|
const parsedInput = OpencodeShellInputSchema.safeParse(input);
|
||||||
|
const parsedOutput = OpencodeShellOutputSchema.safeParse(output);
|
||||||
|
|
||||||
|
const command =
|
||||||
|
(parsedInput.success
|
||||||
|
? commandFromValue(parsedInput.data.command) ?? commandFromValue(parsedInput.data.cmd)
|
||||||
|
: undefined) ??
|
||||||
|
(parsedOutput.success && typeof parsedOutput.data !== "string"
|
||||||
|
? parsedOutput.data.command
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = parsedInput.success
|
||||||
|
? firstNonEmpty(parsedInput.data.cwd, parsedInput.data.directory)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const outputText =
|
||||||
|
parsedOutput.success
|
||||||
|
? typeof parsedOutput.data === "string"
|
||||||
|
? parsedOutput.data
|
||||||
|
: firstNonEmpty(
|
||||||
|
parsedOutput.data.output,
|
||||||
|
parsedOutput.data.text,
|
||||||
|
parsedOutput.data.content,
|
||||||
|
parsedOutput.data.structuredContent?.output,
|
||||||
|
parsedOutput.data.structuredContent?.text,
|
||||||
|
parsedOutput.data.structuredContent?.content,
|
||||||
|
parsedOutput.data.structured_content?.output,
|
||||||
|
parsedOutput.data.structured_content?.text,
|
||||||
|
parsedOutput.data.structured_content?.content,
|
||||||
|
parsedOutput.data.result?.output,
|
||||||
|
parsedOutput.data.result?.text,
|
||||||
|
parsedOutput.data.result?.content
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const exitCode =
|
||||||
|
parsedOutput.success && typeof parsedOutput.data !== "string"
|
||||||
|
? parsedOutput.data.exitCode ?? parsedOutput.data.exit_code ?? parsedOutput.data.metadata?.exitCode ?? parsedOutput.data.metadata?.exit_code ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "shell",
|
||||||
|
command,
|
||||||
|
...(cwd !== undefined ? { cwd } : {}),
|
||||||
|
...(outputText !== undefined ? { output: outputText } : {}),
|
||||||
|
...(exitCode !== null ? { exitCode } : { exitCode: null }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReadDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||||
|
const parsedInput = OpencodeReadInputSchema.safeParse(input);
|
||||||
|
const parsedOutput = OpencodeReadOutputSchema.safeParse(output);
|
||||||
|
|
||||||
|
const filePath = firstNonEmpty(
|
||||||
|
parsedInput.success ? resolveFilePath(parsedInput.data) : undefined,
|
||||||
|
parsedOutput.success && typeof parsedOutput.data !== "string"
|
||||||
|
? resolveFilePath(parsedOutput.data)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content =
|
||||||
|
parsedOutput.success
|
||||||
|
? typeof parsedOutput.data === "string"
|
||||||
|
? parsedOutput.data
|
||||||
|
: firstNonEmpty(
|
||||||
|
parsedOutput.data.content,
|
||||||
|
parsedOutput.data.text,
|
||||||
|
parsedOutput.data.output,
|
||||||
|
parsedOutput.data.structuredContent?.content,
|
||||||
|
parsedOutput.data.structuredContent?.text,
|
||||||
|
parsedOutput.data.structuredContent?.output,
|
||||||
|
parsedOutput.data.structured_content?.content,
|
||||||
|
parsedOutput.data.structured_content?.text,
|
||||||
|
parsedOutput.data.structured_content?.output,
|
||||||
|
parsedOutput.data.data?.content,
|
||||||
|
parsedOutput.data.data?.text,
|
||||||
|
parsedOutput.data.data?.output
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "read",
|
||||||
|
filePath,
|
||||||
|
...(content !== undefined ? { content } : {}),
|
||||||
|
...(parsedInput.success && parsedInput.data.offset !== undefined ? { offset: parsedInput.data.offset } : {}),
|
||||||
|
...(parsedInput.success && parsedInput.data.limit !== undefined ? { limit: parsedInput.data.limit } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWriteDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||||
|
const parsedInput = OpencodeWriteInputSchema.safeParse(input);
|
||||||
|
const parsedOutput = OpencodeWriteOutputSchema.safeParse(output);
|
||||||
|
|
||||||
|
const filePath = firstNonEmpty(
|
||||||
|
parsedInput.success ? resolveFilePath(parsedInput.data) : undefined,
|
||||||
|
parsedOutput.success ? resolveFilePath(parsedOutput.data) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = firstNonEmpty(
|
||||||
|
parsedInput.success
|
||||||
|
? firstNonEmpty(parsedInput.data.content, parsedInput.data.newContent, parsedInput.data.new_content)
|
||||||
|
: undefined,
|
||||||
|
parsedOutput.success
|
||||||
|
? firstNonEmpty(parsedOutput.data.content, parsedOutput.data.newContent, parsedOutput.data.new_content)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "write",
|
||||||
|
filePath,
|
||||||
|
...(content !== undefined ? { content } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEditDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||||
|
const parsedInput = OpencodeEditInputSchema.safeParse(input);
|
||||||
|
const parsedOutput = OpencodeEditOutputSchema.safeParse(output);
|
||||||
|
|
||||||
|
const filePath = firstNonEmpty(
|
||||||
|
parsedInput.success ? resolveFilePath(parsedInput.data) : undefined,
|
||||||
|
parsedOutput.success ? resolveFilePath(parsedOutput.data) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldString = parsedInput.success
|
||||||
|
? firstNonEmpty(
|
||||||
|
parsedInput.data.old_string,
|
||||||
|
parsedInput.data.old_str,
|
||||||
|
parsedInput.data.oldContent,
|
||||||
|
parsedInput.data.old_content
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const newString = parsedInput.success
|
||||||
|
? firstNonEmpty(
|
||||||
|
parsedInput.data.new_string,
|
||||||
|
parsedInput.data.new_str,
|
||||||
|
parsedInput.data.newContent,
|
||||||
|
parsedInput.data.new_content,
|
||||||
|
parsedInput.data.content
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const unifiedDiff = firstNonEmpty(
|
||||||
|
parsedInput.success
|
||||||
|
? firstNonEmpty(
|
||||||
|
parsedInput.data.patch,
|
||||||
|
parsedInput.data.diff,
|
||||||
|
parsedInput.data.unified_diff,
|
||||||
|
parsedInput.data.unifiedDiff
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
parsedOutput.success
|
||||||
|
? firstNonEmpty(
|
||||||
|
parsedOutput.data.patch,
|
||||||
|
parsedOutput.data.diff,
|
||||||
|
parsedOutput.data.unified_diff,
|
||||||
|
parsedOutput.data.unifiedDiff,
|
||||||
|
parsedOutput.data.files?.[0]?.patch,
|
||||||
|
parsedOutput.data.files?.[0]?.diff,
|
||||||
|
parsedOutput.data.files?.[0]?.unified_diff,
|
||||||
|
parsedOutput.data.files?.[0]?.unifiedDiff
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "edit",
|
||||||
|
filePath,
|
||||||
|
...(oldString !== undefined ? { oldString } : {}),
|
||||||
|
...(newString !== undefined ? { newString } : {}),
|
||||||
|
...(unifiedDiff !== undefined ? { unifiedDiff } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSearchDetail(input: unknown): ToolCallDetail | undefined {
|
||||||
|
const parsedInput = OpencodeSearchInputSchema.safeParse(input);
|
||||||
|
if (!parsedInput.success) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = firstNonEmpty(parsedInput.data.query, parsedInput.data.q);
|
||||||
|
if (!query) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "search",
|
||||||
|
query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveDetail(toolName: string, input: unknown, output: unknown): ToolCallDetail | undefined {
|
||||||
|
const parsed = OpencodeKnownToolDetailSchema.safeParse({
|
||||||
|
toolName,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTimelineItem {
|
||||||
|
const parsedParams = OpencodeToolCallParamsSchema.parse(params);
|
||||||
|
const input = parsedParams.input ?? null;
|
||||||
|
const output = parsedParams.output ?? null;
|
||||||
|
const status = resolveStatus(parsedParams.status, parsedParams.error, output);
|
||||||
|
const callId = coerceCallId(parsedParams.callId, parsedParams.toolName, input);
|
||||||
|
const detail = deriveDetail(parsedParams.toolName, input, output);
|
||||||
|
|
||||||
|
if (status === "failed") {
|
||||||
|
return {
|
||||||
|
type: "tool_call",
|
||||||
|
callId,
|
||||||
|
name: parsedParams.toolName,
|
||||||
|
status: "failed",
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
error: parsedParams.error ?? { message: "Tool call failed" },
|
||||||
|
...(detail ? { detail } : {}),
|
||||||
|
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "tool_call",
|
||||||
|
callId,
|
||||||
|
name: parsedParams.toolName,
|
||||||
|
status,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
error: null,
|
||||||
|
...(detail ? { detail } : {}),
|
||||||
|
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1929,6 +1929,8 @@ export class Session {
|
|||||||
worktreePath: worktree.worktreePath,
|
worktreePath: worktree.worktreePath,
|
||||||
branchName: worktree.branchName,
|
branchName: worktree.branchName,
|
||||||
},
|
},
|
||||||
|
output: null,
|
||||||
|
error: null,
|
||||||
});
|
});
|
||||||
if (!started) {
|
if (!started) {
|
||||||
return;
|
return;
|
||||||
@@ -1945,6 +1947,10 @@ export class Session {
|
|||||||
name: "paseo_worktree_setup",
|
name: "paseo_worktree_setup",
|
||||||
callId,
|
callId,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
|
input: {
|
||||||
|
worktreePath: worktree.worktreePath,
|
||||||
|
branchName: worktree.branchName,
|
||||||
|
},
|
||||||
output: {
|
output: {
|
||||||
worktreePath: worktree.worktreePath,
|
worktreePath: worktree.worktreePath,
|
||||||
commands: results.map((result) => ({
|
commands: results.map((result) => ({
|
||||||
@@ -1954,6 +1960,7 @@ export class Session {
|
|||||||
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
|
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
error: null,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof WorktreeSetupError) {
|
if (error instanceof WorktreeSetupError) {
|
||||||
@@ -1965,6 +1972,10 @@ export class Session {
|
|||||||
name: "paseo_worktree_setup",
|
name: "paseo_worktree_setup",
|
||||||
callId,
|
callId,
|
||||||
status: "failed",
|
status: "failed",
|
||||||
|
input: {
|
||||||
|
worktreePath: worktree.worktreePath,
|
||||||
|
branchName: worktree.branchName,
|
||||||
|
},
|
||||||
output: {
|
output: {
|
||||||
worktreePath: worktree.worktreePath,
|
worktreePath: worktree.worktreePath,
|
||||||
commands: results.map((result) => ({
|
commands: results.map((result) => ({
|
||||||
|
|||||||
@@ -246,7 +246,9 @@ class FakeAgentSession implements AgentSession {
|
|||||||
name: tool.name,
|
name: tool.name,
|
||||||
callId,
|
callId,
|
||||||
status: "running",
|
status: "running",
|
||||||
input: tool.input ?? undefined,
|
input: tool.input ?? null,
|
||||||
|
output: null,
|
||||||
|
error: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await this.appendHistoryEvent(toolRunning);
|
await this.appendHistoryEvent(toolRunning);
|
||||||
@@ -325,8 +327,9 @@ class FakeAgentSession implements AgentSession {
|
|||||||
name: tool.name,
|
name: tool.name,
|
||||||
callId,
|
callId,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: tool.input ?? undefined,
|
input: tool.input ?? null,
|
||||||
output: toolOutput ?? { ok: true },
|
output: toolOutput ?? { ok: true },
|
||||||
|
error: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await this.appendHistoryEvent(toolCompleted);
|
await this.appendHistoryEvent(toolCompleted);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"type": "agent_stream_snapshot",
|
||||||
|
"payload": {
|
||||||
|
"agentId": "agent_fixture_legacy",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"timestamp": "2026-02-08T20:00:00.000Z",
|
||||||
|
"event": {
|
||||||
|
"type": "timeline",
|
||||||
|
"provider": "codex",
|
||||||
|
"item": {
|
||||||
|
"type": "tool_call",
|
||||||
|
"callId": "call_fixture_legacy",
|
||||||
|
"name": "shell",
|
||||||
|
"status": "inProgress",
|
||||||
|
"input": {
|
||||||
|
"command": "pwd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AgentStreamMessageSchema,
|
||||||
|
AgentStreamSnapshotMessageSchema,
|
||||||
|
WSOutboundMessageSchema,
|
||||||
|
} from "./messages.js";
|
||||||
|
|
||||||
|
function loadFixture(name: string): unknown {
|
||||||
|
const url = new URL(`./__fixtures__/${name}`, import.meta.url);
|
||||||
|
return JSON.parse(readFileSync(url, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("shared messages stream parsing", () => {
|
||||||
|
it("parses legacy inProgress tool_call snapshots and normalizes status", () => {
|
||||||
|
const fixture = loadFixture("legacy-agent-stream-snapshot-inProgress.json");
|
||||||
|
const parsed = AgentStreamSnapshotMessageSchema.parse(fixture);
|
||||||
|
|
||||||
|
const first = parsed.payload.events[0]?.event;
|
||||||
|
expect(first?.type).toBe("timeline");
|
||||||
|
if (first?.type === "timeline" && first.item.type === "tool_call") {
|
||||||
|
expect(first.item.status).toBe("running");
|
||||||
|
expect(first.item.error).toBeNull();
|
||||||
|
expect(first.item.output).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses representative agent_stream tool_call event", () => {
|
||||||
|
const parsed = AgentStreamMessageSchema.parse({
|
||||||
|
type: "agent_stream",
|
||||||
|
payload: {
|
||||||
|
agentId: "agent_live",
|
||||||
|
timestamp: "2026-02-08T20:10:00.000Z",
|
||||||
|
event: {
|
||||||
|
type: "timeline",
|
||||||
|
provider: "claude",
|
||||||
|
item: {
|
||||||
|
type: "tool_call",
|
||||||
|
callId: "call_live",
|
||||||
|
name: "shell",
|
||||||
|
status: "running",
|
||||||
|
input: { command: "ls" },
|
||||||
|
output: null,
|
||||||
|
error: null,
|
||||||
|
detail: {
|
||||||
|
type: "shell",
|
||||||
|
command: "ls",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.payload.event.type).toBe("timeline");
|
||||||
|
if (parsed.payload.event.type === "timeline") {
|
||||||
|
expect(parsed.payload.event.item.type).toBe("tool_call");
|
||||||
|
if (parsed.payload.event.item.type === "tool_call") {
|
||||||
|
expect(parsed.payload.event.item.status).toBe("running");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses websocket envelope for agent_stream_snapshot with legacy status", () => {
|
||||||
|
const fixture = loadFixture("legacy-agent-stream-snapshot-inProgress.json") as {
|
||||||
|
type: "agent_stream_snapshot";
|
||||||
|
payload: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapped = WSOutboundMessageSchema.parse({
|
||||||
|
type: "session",
|
||||||
|
message: fixture,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (wrapped.type === "session" && wrapped.message.type === "agent_stream_snapshot") {
|
||||||
|
const first = wrapped.message.payload.events[0]?.event;
|
||||||
|
expect(first?.type).toBe("timeline");
|
||||||
|
if (first?.type === "timeline" && first.item.type === "tool_call") {
|
||||||
|
expect(first.item.status).toBe("running");
|
||||||
|
expect(first.item.error).toBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { AgentTimelineItemPayloadSchema } from "./messages.js";
|
||||||
|
|
||||||
|
function canonicalBase() {
|
||||||
|
return {
|
||||||
|
type: "tool_call" as const,
|
||||||
|
callId: "call_123",
|
||||||
|
name: "shell",
|
||||||
|
input: { command: "pwd" },
|
||||||
|
output: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("shared messages tool_call schema", () => {
|
||||||
|
it("parses each status-discriminated tool_call variant at runtime", () => {
|
||||||
|
const running = AgentTimelineItemPayloadSchema.parse({
|
||||||
|
...canonicalBase(),
|
||||||
|
status: "running",
|
||||||
|
error: null,
|
||||||
|
detail: {
|
||||||
|
type: "shell",
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const completed = AgentTimelineItemPayloadSchema.parse({
|
||||||
|
...canonicalBase(),
|
||||||
|
status: "completed",
|
||||||
|
error: null,
|
||||||
|
output: { output: "/tmp/repo" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const failed = AgentTimelineItemPayloadSchema.parse({
|
||||||
|
...canonicalBase(),
|
||||||
|
status: "failed",
|
||||||
|
error: { message: "command failed" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const canceled = AgentTimelineItemPayloadSchema.parse({
|
||||||
|
...canonicalBase(),
|
||||||
|
status: "canceled",
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(running.type).toBe("tool_call");
|
||||||
|
expect(completed.type).toBe("tool_call");
|
||||||
|
expect(failed.type).toBe("tool_call");
|
||||||
|
expect(canceled.type).toBe("tool_call");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-recoverable invalid tool_call payloads", () => {
|
||||||
|
const missingCallId = AgentTimelineItemPayloadSchema.safeParse({
|
||||||
|
type: "tool_call",
|
||||||
|
name: "shell",
|
||||||
|
status: "running",
|
||||||
|
input: { command: "pwd" },
|
||||||
|
output: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const unknownStatus = AgentTimelineItemPayloadSchema.safeParse({
|
||||||
|
...canonicalBase(),
|
||||||
|
status: "mystery_status",
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(missingCallId.success).toBe(false);
|
||||||
|
expect(unknownStatus.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes recoverable legacy status/error combinations", () => {
|
||||||
|
const completedWithError = AgentTimelineItemPayloadSchema.safeParse({
|
||||||
|
...canonicalBase(),
|
||||||
|
status: "completed",
|
||||||
|
error: { message: "unexpected" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const failedWithoutError = AgentTimelineItemPayloadSchema.safeParse({
|
||||||
|
...canonicalBase(),
|
||||||
|
status: "failed",
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const missingOutput = AgentTimelineItemPayloadSchema.safeParse({
|
||||||
|
type: "tool_call",
|
||||||
|
callId: "call_missing_output",
|
||||||
|
name: "shell",
|
||||||
|
status: "running",
|
||||||
|
input: { command: "pwd" },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(completedWithError.success).toBe(true);
|
||||||
|
expect(failedWithoutError.success).toBe(true);
|
||||||
|
expect(missingOutput.success).toBe(true);
|
||||||
|
|
||||||
|
if (completedWithError.success && completedWithError.data.type === "tool_call") {
|
||||||
|
expect(completedWithError.data.error).toBeNull();
|
||||||
|
}
|
||||||
|
if (failedWithoutError.success && failedWithoutError.data.type === "tool_call") {
|
||||||
|
expect(failedWithoutError.data.error).toEqual({ message: "Tool call failed" });
|
||||||
|
}
|
||||||
|
if (missingOutput.success && missingOutput.data.type === "tool_call") {
|
||||||
|
expect(missingOutput.data.output).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,8 @@ import type {
|
|||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
AgentRuntimeInfo,
|
AgentRuntimeInfo,
|
||||||
AgentTimelineItem,
|
AgentTimelineItem,
|
||||||
|
ToolCallDetail,
|
||||||
|
ToolCallTimelineItem,
|
||||||
AgentUsage,
|
AgentUsage,
|
||||||
} from "../server/agent/agent-sdk-types.js";
|
} from "../server/agent/agent-sdk-types.js";
|
||||||
|
|
||||||
@@ -147,8 +149,150 @@ export type StructuredToolResult =
|
|||||||
| { type: "file_read"; filePath: string; content: string }
|
| { type: "file_read"; filePath: string; content: string }
|
||||||
| { type: "generic"; data: unknown };
|
| { type: "generic"; data: unknown };
|
||||||
|
|
||||||
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
|
const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUnion("type", [
|
||||||
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 NonUndefinedUnknownSchema = z.union([
|
||||||
|
z.null(),
|
||||||
|
z.boolean(),
|
||||||
|
z.number(),
|
||||||
|
z.string(),
|
||||||
|
z.array(z.unknown()),
|
||||||
|
z.object({}).passthrough(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const NonNullUnknownSchema = z.union([
|
||||||
|
z.boolean(),
|
||||||
|
z.number(),
|
||||||
|
z.string(),
|
||||||
|
z.array(z.unknown()),
|
||||||
|
z.object({}).passthrough(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ToolCallBasePayloadSchema = z.object({
|
||||||
|
type: z.literal("tool_call"),
|
||||||
|
callId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
input: NonUndefinedUnknownSchema,
|
||||||
|
output: NonUndefinedUnknownSchema,
|
||||||
|
detail: ToolCallDetailPayloadSchema.optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ToolCallRunningPayloadSchema = ToolCallBasePayloadSchema.extend({
|
||||||
|
status: z.literal("running"),
|
||||||
|
error: z.null(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ToolCallCompletedPayloadSchema = ToolCallBasePayloadSchema.extend({
|
||||||
|
status: z.literal("completed"),
|
||||||
|
error: z.null(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ToolCallFailedPayloadSchema = ToolCallBasePayloadSchema.extend({
|
||||||
|
status: z.literal("failed"),
|
||||||
|
error: NonNullUnknownSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ToolCallCanceledPayloadSchema = ToolCallBasePayloadSchema.extend({
|
||||||
|
status: z.literal("canceled"),
|
||||||
|
error: z.null(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const LEGACY_TOOL_CALL_STATUS_MAP: Record<string, ToolCallTimelineItem["status"]> = {
|
||||||
|
inprogress: "running",
|
||||||
|
in_progress: "running",
|
||||||
|
started: "running",
|
||||||
|
complete: "completed",
|
||||||
|
done: "completed",
|
||||||
|
success: "completed",
|
||||||
|
errored: "failed",
|
||||||
|
error: "failed",
|
||||||
|
cancelled: "canceled",
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeLegacyToolCallPayload(value: unknown): unknown {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (record.type !== "tool_call") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: Record<string, unknown> = { ...record };
|
||||||
|
const rawStatus = typeof record.status === "string" ? record.status.trim() : "";
|
||||||
|
|
||||||
|
if (rawStatus.length > 0) {
|
||||||
|
const statusKey = rawStatus.toLowerCase().replace(/[\s-]+/g, "_");
|
||||||
|
const mappedStatus = LEGACY_TOOL_CALL_STATUS_MAP[statusKey];
|
||||||
|
if (mappedStatus) {
|
||||||
|
normalized.status = mappedStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("input" in normalized)) {
|
||||||
|
normalized.input = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("output" in normalized)) {
|
||||||
|
normalized.output = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.status === "failed") {
|
||||||
|
if (normalized.error === undefined || normalized.error === null) {
|
||||||
|
normalized.error = { message: "Tool call failed" };
|
||||||
|
}
|
||||||
|
} else if (normalized.error === undefined || normalized.error !== null) {
|
||||||
|
normalized.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, z.ZodTypeDef, unknown> = z.preprocess(
|
||||||
|
normalizeLegacyToolCallPayload,
|
||||||
|
z.union([
|
||||||
|
ToolCallRunningPayloadSchema,
|
||||||
|
ToolCallCompletedPayloadSchema,
|
||||||
|
ToolCallFailedPayloadSchema,
|
||||||
|
ToolCallCanceledPayloadSchema,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, z.ZodTypeDef, unknown> =
|
||||||
|
z.union([
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("user_message"),
|
type: z.literal("user_message"),
|
||||||
text: z.string(),
|
text: z.string(),
|
||||||
@@ -162,16 +306,7 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
|
|||||||
type: z.literal("reasoning"),
|
type: z.literal("reasoning"),
|
||||||
text: z.string(),
|
text: z.string(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
ToolCallTimelineItemPayloadSchema,
|
||||||
type: z.literal("tool_call"),
|
|
||||||
name: z.string(),
|
|
||||||
callId: z.string().optional(),
|
|
||||||
status: z.string().optional(),
|
|
||||||
input: z.unknown().optional(),
|
|
||||||
output: z.unknown().optional(),
|
|
||||||
error: z.unknown().optional(),
|
|
||||||
metadata: z.record(z.unknown()).optional(),
|
|
||||||
}),
|
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("todo"),
|
type: z.literal("todo"),
|
||||||
items: z.array(
|
items: z.array(
|
||||||
|
|||||||
@@ -1,331 +1,32 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
stripShellWrapperPrefix,
|
extractTodos,
|
||||||
normalizeToolDisplayName,
|
|
||||||
stripCwdPrefix,
|
stripCwdPrefix,
|
||||||
parseToolCallDisplay,
|
stripShellWrapperPrefix,
|
||||||
} from "./tool-call-parsers.js";
|
} from "./tool-call-parsers.js";
|
||||||
|
|
||||||
describe("stripShellWrapperPrefix", () => {
|
describe("tool-call-parsers utilities", () => {
|
||||||
test("strips /bin/zsh -lc cd path && prefix", () => {
|
it("strips cwd prefixes", () => {
|
||||||
const command = "/bin/zsh -lc cd /Users/me/dev/blankpage/editor && npm run format";
|
expect(stripCwdPrefix("/tmp/repo/src/index.ts", "/tmp/repo")).toBe("src/index.ts");
|
||||||
expect(stripShellWrapperPrefix(command)).toBe("npm run format");
|
expect(stripCwdPrefix("/tmp/repo", "/tmp/repo")).toBe(".");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("strips /bin/zsh -lc \"cd path &&\" wrapper", () => {
|
it("strips shell wrapper prefixes", () => {
|
||||||
const command = '/bin/zsh -lc "cd /Users/me/dev/blankpage/editor && npm run format"';
|
const wrapped = '/bin/zsh -lc "cd /tmp/repo && npm test"';
|
||||||
expect(stripShellWrapperPrefix(command)).toBe("npm run format");
|
expect(stripShellWrapperPrefix(wrapped)).toBe("npm test");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("strips /bin/zsh -c cd path && prefix", () => {
|
it("extracts todo entries", () => {
|
||||||
const command = "/bin/zsh -c cd /path/to/project && git status";
|
expect(
|
||||||
expect(stripShellWrapperPrefix(command)).toBe("git status");
|
extractTodos({
|
||||||
});
|
todos: [
|
||||||
|
{ content: "Task 1", status: "pending" },
|
||||||
|
{ content: "Task 2", status: "completed" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).toHaveLength(2);
|
||||||
|
|
||||||
test("strips /bin/bash -lc cd path && prefix", () => {
|
expect(extractTodos({ plan: [] })).toEqual([]);
|
||||||
const command = "/bin/bash -lc cd /home/user/project && npm test";
|
|
||||||
expect(stripShellWrapperPrefix(command)).toBe("npm test");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("strips /bin/sh -c cd path && prefix", () => {
|
|
||||||
const command = "/bin/sh -c cd /tmp && ls -la";
|
|
||||||
expect(stripShellWrapperPrefix(command)).toBe("ls -la");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns command unchanged when no prefix", () => {
|
|
||||||
const command = "npm run build";
|
|
||||||
expect(stripShellWrapperPrefix(command)).toBe("npm run build");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("strips shell prefix even without cd", () => {
|
|
||||||
const command = "/bin/zsh -lc npm run build";
|
|
||||||
expect(stripShellWrapperPrefix(command)).toBe("npm run build");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("strips when cd path includes spaces in quotes", () => {
|
|
||||||
const command = '/bin/zsh -lc cd "/path with spaces" && npm test';
|
|
||||||
expect(stripShellWrapperPrefix(command)).toBe("npm test");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("strips /bin/zsh -lc with quoted complex command", () => {
|
|
||||||
const command =
|
|
||||||
'/bin/zsh -lc "adb shell pm list packages | rg \'sh\\.paseo(\\.dev)?\' && adb shell cmd package resolve-activity --brief sh.paseo.dev | tail -n 1"';
|
|
||||||
expect(stripShellWrapperPrefix(command)).toBe(
|
|
||||||
"adb shell pm list packages | rg 'sh\\.paseo(\\.dev)?' && adb shell cmd package resolve-activity --brief sh.paseo.dev | tail -n 1"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("normalizeToolDisplayName", () => {
|
|
||||||
test("normalizes plain speak name", () => {
|
|
||||||
expect(normalizeToolDisplayName("speak")).toBe("Speak");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("normalizes codex namespaced speak name", () => {
|
|
||||||
expect(normalizeToolDisplayName("paseo_voice.speak")).toBe("Speak");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("normalizes claude mcp speak name", () => {
|
|
||||||
expect(normalizeToolDisplayName("mcp__paseo_voice__speak")).toBe("Speak");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps non-speak names unchanged", () => {
|
|
||||||
expect(normalizeToolDisplayName("paseo__create_agent")).toBe(
|
|
||||||
"paseo__create_agent"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("stripCwdPrefix", () => {
|
|
||||||
test("strips cwd prefix from file path", () => {
|
|
||||||
expect(stripCwdPrefix("/Users/dev/project/src/file.ts", "/Users/dev/project")).toBe("src/file.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns . for exact cwd match", () => {
|
|
||||||
expect(stripCwdPrefix("/Users/dev/project", "/Users/dev/project")).toBe(".");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns path unchanged when no cwd prefix", () => {
|
|
||||||
expect(stripCwdPrefix("/other/path/file.ts", "/Users/dev/project")).toBe("/other/path/file.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns path unchanged when no cwd provided", () => {
|
|
||||||
expect(stripCwdPrefix("/Users/dev/project/file.ts")).toBe("/Users/dev/project/file.ts");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseToolCallDisplay", () => {
|
|
||||||
describe("summary (was extractPrincipalParam)", () => {
|
|
||||||
test("extracts and strips shell wrapper from command string", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
provider: "claude",
|
|
||||||
name: "Bash",
|
|
||||||
input: { command: "/bin/zsh -lc cd /Users/dev/project && npm run format" },
|
|
||||||
});
|
|
||||||
expect(result.summary).toBe("npm run format");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts and strips shell wrapper from command array", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
name: "shell",
|
|
||||||
input: { command: ["/bin/bash", "-lc", "cd /path && git status"] },
|
|
||||||
});
|
|
||||||
expect(result.summary).toBe("git status");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts plain command without shell wrapper", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
name: "Bash",
|
|
||||||
input: { command: "npm run build" },
|
|
||||||
});
|
|
||||||
expect(result.summary).toBe("npm run build");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to output command when shell input is missing", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
name: "Bash",
|
|
||||||
output: { type: "command", command: "pwd", output: "/tmp" },
|
|
||||||
});
|
|
||||||
expect(result.summary).toBe("pwd");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts file_path and strips cwd", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
name: "Read",
|
|
||||||
input: { file_path: "/Users/dev/project/src/file.ts" },
|
|
||||||
cwd: "/Users/dev/project",
|
|
||||||
});
|
|
||||||
expect(result.summary).toBe("src/file.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to read output path when input is missing", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
provider: "codex",
|
|
||||||
name: "Read",
|
|
||||||
output: {
|
|
||||||
type: "file_read",
|
|
||||||
filePath: "/Users/dev/project/src/file.ts",
|
|
||||||
content: "hello",
|
|
||||||
},
|
|
||||||
cwd: "/Users/dev/project",
|
|
||||||
});
|
|
||||||
expect(result.summary).toBe("src/file.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to edit output path when input is missing", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
name: "Edit",
|
|
||||||
output: {
|
|
||||||
type: "file_edit",
|
|
||||||
filePath: "/Users/dev/project/src/file.ts",
|
|
||||||
oldContent: "a",
|
|
||||||
newContent: "b",
|
|
||||||
},
|
|
||||||
cwd: "/Users/dev/project",
|
|
||||||
});
|
|
||||||
expect(result.summary).toBe("src/file.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts pattern without modification", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
name: "Grep",
|
|
||||||
input: { pattern: "*.ts" },
|
|
||||||
});
|
|
||||||
expect(result.summary).toBe("*.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts query without modification", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
name: "WebSearch",
|
|
||||||
input: { query: "search term" },
|
|
||||||
});
|
|
||||||
expect(result.summary).toBe("search term");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts url without modification", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
name: "WebFetch",
|
|
||||||
input: { url: "https://example.com" },
|
|
||||||
});
|
|
||||||
expect(result.summary).toBe("https://example.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns undefined summary for empty input", () => {
|
|
||||||
const result = parseToolCallDisplay({ name: "Unknown", input: {} });
|
|
||||||
expect(result.summary).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts description from Task tool", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
name: "Task",
|
|
||||||
input: { description: "Explore the codebase" },
|
|
||||||
});
|
|
||||||
expect(result.summary).toBe("Explore the codebase");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("summary from metadata", () => {
|
|
||||||
test("subAgentActivity in metadata takes priority for Task", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
name: "Task",
|
|
||||||
input: { description: "Explore codebase" },
|
|
||||||
metadata: { subAgentActivity: "Read" },
|
|
||||||
});
|
|
||||||
expect(result.summary).toBe("Read");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("metadata merges with input for summary parsing", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
name: "Task",
|
|
||||||
input: {},
|
|
||||||
metadata: { subAgentActivity: "Bash" },
|
|
||||||
});
|
|
||||||
expect(result.summary).toBe("Bash");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("non-Task tools keep their parsed summary even when metadata is present", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
name: "Bash",
|
|
||||||
input: { command: "pwd" },
|
|
||||||
metadata: { subAgentActivity: "Read" },
|
|
||||||
});
|
|
||||||
expect(result.summary).toBe("pwd");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("kind", () => {
|
|
||||||
test("Read -> read", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "Read" }).kind).toBe("read");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("read_file -> read", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "read_file" }).kind).toBe("read");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Edit -> edit", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "Edit" }).kind).toBe("edit");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Write -> edit", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "Write" }).kind).toBe("edit");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("apply_patch -> edit", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "apply_patch" }).kind).toBe("edit");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Bash -> execute", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "Bash" }).kind).toBe("execute");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shell -> execute", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "shell" }).kind).toBe("execute");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Grep -> search", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "Grep" }).kind).toBe("search");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Glob -> search", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "Glob" }).kind).toBe("search");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("thinking -> thinking", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "thinking" }).kind).toBe("thinking");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unknown tool -> tool", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "MyCustomTool" }).kind).toBe("tool");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("displayName", () => {
|
|
||||||
test("shell -> Shell", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "shell" }).displayName).toBe("Shell");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Bash -> Shell", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "Bash" }).displayName).toBe("Shell");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("read_file -> Read", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "read_file" }).displayName).toBe("Read");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("apply_patch -> Edit", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "apply_patch" }).displayName).toBe("Edit");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("preserves unknown tool names", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "MyCustomTool" }).displayName).toBe("MyCustomTool");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("normalizes speak variants", () => {
|
|
||||||
expect(parseToolCallDisplay({ name: "paseo_voice.speak" }).displayName).toBe("Speak");
|
|
||||||
expect(parseToolCallDisplay({ name: "mcp__paseo_voice__speak" }).displayName).toBe("Speak");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("errorText", () => {
|
|
||||||
test("formats string error", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
name: "Bash",
|
|
||||||
error: "something broke",
|
|
||||||
});
|
|
||||||
expect(result.errorText).toBe("something broke");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts content from tool_result error", () => {
|
|
||||||
const result = parseToolCallDisplay({
|
|
||||||
name: "Bash",
|
|
||||||
error: { type: "tool_result", content: "Exit code 1", is_error: true },
|
|
||||||
});
|
|
||||||
expect(result.errorText).toBe("Exit code 1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns undefined for no error", () => {
|
|
||||||
const result = parseToolCallDisplay({ name: "Read" });
|
|
||||||
expect(result.errorText).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user