refactor(ui): extract diff viewer and tool call parsers into reusable components
This commit is contained in:
@@ -4,6 +4,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
|
||||
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
|
||||
interface AgentListProps {
|
||||
agents: Map<string, Agent>;
|
||||
@@ -27,6 +28,7 @@ export function AgentList({ agents }: AgentListProps) {
|
||||
const statusColor = getAgentStatusColor(agent.status);
|
||||
const statusLabel = getAgentStatusLabel(agent.status);
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const providerLabel = getAgentProviderDefinition(agent.provider).label;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -53,13 +55,26 @@ export function AgentList({ agents }: AgentListProps) {
|
||||
</Text>
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<View style={styles.statusBadge}>
|
||||
<View style={styles.statusGroup}>
|
||||
<View
|
||||
style={[styles.statusDot, { backgroundColor: statusColor }]}
|
||||
/>
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>
|
||||
{statusLabel}
|
||||
</Text>
|
||||
style={[styles.providerBadge, { backgroundColor: theme.colors.muted }]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.providerText, { color: theme.colors.mutedForeground }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{providerLabel}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statusBadge}>
|
||||
<View
|
||||
style={[styles.statusDot, { backgroundColor: statusColor }]}
|
||||
/>
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>
|
||||
{statusLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.timeAgo}>
|
||||
@@ -111,6 +126,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
statusGroup: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
providerBadge: {
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
providerText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
statusBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { ScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { DiffLine } from "@/utils/tool-call-parsers";
|
||||
|
||||
interface DiffViewerProps {
|
||||
diffLines: DiffLine[];
|
||||
maxHeight?: number;
|
||||
emptyLabel?: string;
|
||||
}
|
||||
|
||||
export function DiffViewer({ diffLines, maxHeight = 280, emptyLabel = "No changes to display" }: DiffViewerProps) {
|
||||
if (!diffLines.length) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyText}>{emptyLabel}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.verticalScroll, { maxHeight }]}
|
||||
contentContainerStyle={styles.verticalContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.horizontalContent}
|
||||
>
|
||||
<View style={styles.linesContainer}>
|
||||
{diffLines.map((line, index) => (
|
||||
<View
|
||||
key={`${line.type}-${index}`}
|
||||
style={[
|
||||
styles.line,
|
||||
line.type === "header" && styles.headerLine,
|
||||
line.type === "add" && styles.addLine,
|
||||
line.type === "remove" && styles.removeLine,
|
||||
line.type === "context" && styles.contextLine,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.lineText,
|
||||
line.type === "header" && styles.headerText,
|
||||
line.type === "add" && styles.addText,
|
||||
line.type === "remove" && styles.removeText,
|
||||
line.type === "context" && styles.contextText,
|
||||
]}
|
||||
>
|
||||
{line.content}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
verticalScroll: {},
|
||||
verticalContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
horizontalContent: {
|
||||
flexDirection: "column" as const,
|
||||
},
|
||||
linesContainer: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
line: {
|
||||
minWidth: "100%",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: "monospace",
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
headerLine: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
},
|
||||
headerText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
addLine: {
|
||||
backgroundColor: theme.colors.palette.green[900],
|
||||
},
|
||||
addText: {
|
||||
color: theme.colors.palette.green[200],
|
||||
},
|
||||
removeLine: {
|
||||
backgroundColor: theme.colors.palette.red[900],
|
||||
},
|
||||
removeText: {
|
||||
color: theme.colors.palette.red[200],
|
||||
},
|
||||
contextLine: {
|
||||
backgroundColor: theme.colors.card,
|
||||
},
|
||||
contextText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
emptyState: {
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center" as const,
|
||||
justifyContent: "center" as const,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
}));
|
||||
@@ -5,700 +5,20 @@ import { ScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { SelectedToolCall } from "@/types/shared";
|
||||
import { z } from "zod";
|
||||
|
||||
type DiffLine = {
|
||||
type: "add" | "remove" | "context" | "header";
|
||||
content: string;
|
||||
};
|
||||
|
||||
type EditEntry = {
|
||||
filePath?: string;
|
||||
diffLines: DiffLine[];
|
||||
};
|
||||
|
||||
type ReadEntry = {
|
||||
filePath?: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type CommandDetails = {
|
||||
command?: string;
|
||||
cwd?: string;
|
||||
output?: string;
|
||||
exitCode?: number | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function splitIntoLines(text: string): string[] {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return text.replace(/\r\n/g, "\n").split("\n");
|
||||
}
|
||||
|
||||
function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
|
||||
const originalLines = splitIntoLines(originalText);
|
||||
const updatedLines = splitIntoLines(updatedText);
|
||||
|
||||
const hasAnyContent = originalLines.length > 0 || updatedLines.length > 0;
|
||||
if (!hasAnyContent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const m = originalLines.length;
|
||||
const n = updatedLines.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||
Array(n + 1).fill(0)
|
||||
);
|
||||
|
||||
for (let i = m - 1; i >= 0; i -= 1) {
|
||||
for (let j = n - 1; j >= 0; j -= 1) {
|
||||
if (originalLines[i] === updatedLines[j]) {
|
||||
dp[i][j] = dp[i + 1][j + 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const diff: DiffLine[] = [];
|
||||
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (originalLines[i] === updatedLines[j]) {
|
||||
diff.push({ type: "context", content: ` ${originalLines[i]}` });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
diff.push({ type: "remove", content: `-${originalLines[i]}` });
|
||||
i += 1;
|
||||
} else {
|
||||
diff.push({ type: "add", content: `+${updatedLines[j]}` });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
while (i < m) {
|
||||
diff.push({ type: "remove", content: `-${originalLines[i]}` });
|
||||
i += 1;
|
||||
}
|
||||
|
||||
while (j < n) {
|
||||
diff.push({ type: "add", content: `+${updatedLines[j]}` });
|
||||
j += 1;
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
function parseUnifiedDiff(diffText?: string): DiffLine[] {
|
||||
if (!diffText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = splitIntoLines(diffText);
|
||||
const diff: DiffLine[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.length) {
|
||||
diff.push({ type: "context", content: line });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("@@")) {
|
||||
diff.push({ type: "header", content: line });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("+")) {
|
||||
if (!line.startsWith("+++")) {
|
||||
diff.push({ type: "add", content: line });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("-")) {
|
||||
if (!line.startsWith("---")) {
|
||||
diff.push({ type: "remove", content: line });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
line.startsWith("diff --git") ||
|
||||
line.startsWith("index ") ||
|
||||
line.startsWith("---") ||
|
||||
line.startsWith("+++")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("\\ No newline")) {
|
||||
diff.push({ type: "header", content: line });
|
||||
continue;
|
||||
}
|
||||
|
||||
diff.push({ type: "context", content: line });
|
||||
}
|
||||
|
||||
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 mergeEditEntries(
|
||||
value.flatMap((entry) => parseEditArguments(entry, depth + 1))
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: EditEntry[] = [];
|
||||
const filePathHint = getFilePathFromRecord(value);
|
||||
|
||||
const changesParse = z.record(z.unknown()).safeParse(value["changes"]);
|
||||
if (changesParse.success) {
|
||||
for (const [filePath, changeValue] of Object.entries(changesParse.data)) {
|
||||
const nested = parseEditArguments(changeValue, depth + 1);
|
||||
entries.push(
|
||||
...nested.map((entry) => ({
|
||||
...entry,
|
||||
filePath: entry.filePath ?? filePath ?? filePathHint,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value["files"])) {
|
||||
for (const fileEntry of value["files"] as unknown[]) {
|
||||
const nested = parseEditArguments(fileEntry, depth + 1);
|
||||
const derivedFilePath = isRecord(fileEntry)
|
||||
? getFilePathFromRecord(fileEntry)
|
||||
: undefined;
|
||||
const resolvedFilePath = derivedFilePath ?? filePathHint;
|
||||
entries.push(
|
||||
...nested.map((entry) => ({
|
||||
...entry,
|
||||
filePath: entry.filePath ?? resolvedFilePath,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const direct = buildEditEntryFromBlock(filePathHint, value);
|
||||
if (direct) {
|
||||
entries.push(direct);
|
||||
} else {
|
||||
const patchCandidates = [
|
||||
getString(value["patch"]),
|
||||
getString(value["diff"]),
|
||||
getString(value["unified_diff"]),
|
||||
getString(value["unifiedDiff"]),
|
||||
].filter(Boolean) as string[];
|
||||
for (const patch of patchCandidates) {
|
||||
if (patch && looksLikePatch(patch)) {
|
||||
const diffLines = parsePatchText(patch);
|
||||
if (diffLines.length) {
|
||||
entries.push({ filePath: filePathHint, diffLines });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nestedKeys = [
|
||||
"input",
|
||||
"update",
|
||||
"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 mergeEditEntries(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 parseReadEntries(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) => parseReadEntries(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(...parseReadEntries(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 ((typeof exitCandidate === "number" || exitCandidate === null) && target.exitCode === undefined) {
|
||||
target.exitCode = exitCandidate ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const resultParsed = CommandResultSchema.safeParse(value);
|
||||
if (resultParsed.success) {
|
||||
const data = resultParsed.data;
|
||||
const outputCandidate =
|
||||
getString(data.output) ??
|
||||
getString(data.structuredContent?.output) ??
|
||||
getString(data.structuredContent?.text) ??
|
||||
getString(data.structured_content?.output) ??
|
||||
getString(data.structured_content?.text) ??
|
||||
undefined;
|
||||
if (!target.output && outputCandidate) {
|
||||
target.output = outputCandidate;
|
||||
}
|
||||
const exitCandidate = data.exitCode ?? data.metadata?.exit_code;
|
||||
if ((typeof exitCandidate === "number" || exitCandidate === null) && target.exitCode === undefined) {
|
||||
target.exitCode = exitCandidate ?? null;
|
||||
}
|
||||
if (data.result !== undefined) {
|
||||
collectCommandDetails(target, data.result, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const nestedKeys = [
|
||||
"input",
|
||||
"output",
|
||||
"result",
|
||||
"raw",
|
||||
"data",
|
||||
"payload",
|
||||
"structuredContent",
|
||||
"structured_content",
|
||||
] as const;
|
||||
for (const key of nestedKeys) {
|
||||
if (value[key] !== undefined) {
|
||||
collectCommandDetails(target, value[key], depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseCommandDetails(args: unknown, result: unknown): CommandDetails | null {
|
||||
const details: CommandDetails = {};
|
||||
collectCommandDetails(details, args);
|
||||
collectCommandDetails(details, result);
|
||||
if (details.command || details.output) {
|
||||
return details;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
import {
|
||||
extractCommandDetails,
|
||||
extractEditEntries,
|
||||
extractReadEntries,
|
||||
type CommandDetails,
|
||||
type EditEntry,
|
||||
type ReadEntry,
|
||||
} from "@/utils/tool-call-parsers";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
|
||||
interface ToolCallBottomSheetProps {
|
||||
bottomSheetRef: React.RefObject<BottomSheetModal | null>;
|
||||
@@ -727,13 +47,16 @@ export function ToolCallBottomSheet({
|
||||
);
|
||||
|
||||
// Extract data based on source
|
||||
const { toolName, args, result, error } = useMemo(() => {
|
||||
const { toolName, args, result, error, parsedEdits, parsedReads, parsedCommand } = useMemo(() => {
|
||||
if (!selectedToolCall) {
|
||||
return {
|
||||
toolName: "Tool Call",
|
||||
args: undefined,
|
||||
result: undefined,
|
||||
error: undefined,
|
||||
parsedEdits: undefined,
|
||||
parsedReads: undefined,
|
||||
parsedCommand: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -746,6 +69,9 @@ export function ToolCallBottomSheet({
|
||||
args: data.raw,
|
||||
result: data.result,
|
||||
error: data.error,
|
||||
parsedEdits: data.parsedEdits,
|
||||
parsedReads: data.parsedReads,
|
||||
parsedCommand: data.parsedCommand,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -755,29 +81,30 @@ export function ToolCallBottomSheet({
|
||||
args: data.arguments,
|
||||
result: data.result,
|
||||
error: data.error,
|
||||
parsedEdits: undefined,
|
||||
parsedReads: undefined,
|
||||
parsedCommand: undefined,
|
||||
};
|
||||
}, [selectedToolCall]);
|
||||
|
||||
const editEntries = useMemo(() => {
|
||||
const entries = [
|
||||
...parseEditArguments(args),
|
||||
...parseEditArguments(result),
|
||||
];
|
||||
return mergeEditEntries(entries);
|
||||
}, [args, result]);
|
||||
|
||||
const readEntries = useMemo(() => {
|
||||
const entries = [
|
||||
...parseReadEntries(result),
|
||||
...parseReadEntries(args),
|
||||
];
|
||||
return mergeReadEntries(entries);
|
||||
}, [args, result]);
|
||||
|
||||
const commandDetails = useMemo(() => parseCommandDetails(args, result), [
|
||||
const fallbackEditEntries = useMemo(() => extractEditEntries(args, result), [
|
||||
args,
|
||||
result,
|
||||
]);
|
||||
const editEntries: EditEntry[] = parsedEdits ?? fallbackEditEntries;
|
||||
|
||||
const fallbackReadEntries = useMemo(() => extractReadEntries(result, args), [
|
||||
args,
|
||||
result,
|
||||
]);
|
||||
const readEntries: ReadEntry[] = parsedReads ?? fallbackReadEntries;
|
||||
|
||||
const fallbackCommandDetails = useMemo(
|
||||
() => extractCommandDetails(args, result),
|
||||
[args, result]
|
||||
);
|
||||
const commandDetails: CommandDetails | null =
|
||||
parsedCommand ?? fallbackCommandDetails;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
@@ -812,52 +139,7 @@ export function ToolCallBottomSheet({
|
||||
|
||||
<Text style={styles.sectionTitle}>Diff</Text>
|
||||
<View style={styles.diffContainer}>
|
||||
{entry.diffLines.length === 0 ? (
|
||||
<View style={styles.diffEmptyState}>
|
||||
<Text style={styles.diffEmptyText}>No changes to display</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.diffScrollVertical}
|
||||
contentContainerStyle={styles.diffVerticalContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.diffScrollContent}
|
||||
>
|
||||
<View style={styles.diffLinesContainer}>
|
||||
{entry.diffLines.map((line, lineIndex) => (
|
||||
<View
|
||||
key={`${line.type}-${lineIndex}`}
|
||||
style={[
|
||||
styles.diffLine,
|
||||
line.type === "header" && styles.diffHeaderLine,
|
||||
line.type === "add" && styles.diffAddLine,
|
||||
line.type === "remove" && styles.diffRemoveLine,
|
||||
line.type === "context" && styles.diffContextLine,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.diffLineText,
|
||||
line.type === "header" && styles.diffHeaderText,
|
||||
line.type === "add" && styles.diffAddText,
|
||||
line.type === "remove" && styles.diffRemoveText,
|
||||
line.type === "context" && styles.diffContextText,
|
||||
]}
|
||||
>
|
||||
{line.content}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
)}
|
||||
<DiffViewer diffLines={entry.diffLines} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
@@ -1039,61 +321,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.card,
|
||||
overflow: "hidden",
|
||||
},
|
||||
diffScrollVertical: {
|
||||
maxHeight: 280,
|
||||
},
|
||||
diffVerticalContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
diffScrollContent: {
|
||||
flexDirection: "column" as const,
|
||||
},
|
||||
diffLinesContainer: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
diffLine: {
|
||||
minWidth: "100%",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
diffLineText: {
|
||||
fontFamily: "monospace",
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
diffHeaderLine: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
},
|
||||
diffHeaderText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
diffAddLine: {
|
||||
backgroundColor: theme.colors.palette.green[900],
|
||||
},
|
||||
diffAddText: {
|
||||
color: theme.colors.palette.green[200],
|
||||
},
|
||||
diffRemoveLine: {
|
||||
backgroundColor: theme.colors.palette.red[900],
|
||||
},
|
||||
diffRemoveText: {
|
||||
color: theme.colors.palette.red[200],
|
||||
},
|
||||
diffContextLine: {
|
||||
backgroundColor: theme.colors.card,
|
||||
},
|
||||
diffContextText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
diffEmptyState: {
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center" as const,
|
||||
justifyContent: "center" as const,
|
||||
},
|
||||
diffEmptyText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
jsonContainer: {
|
||||
backgroundColor: theme.colors.background,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
|
||||
@@ -1064,6 +1064,60 @@ function testToolCallDeduplicationHydrated() {
|
||||
});
|
||||
}
|
||||
|
||||
function buildOutOfOrderToolCallSequence(provider: ToolCallProvider) {
|
||||
const timestamps = [
|
||||
new Date('2025-01-01T13:00:00Z'),
|
||||
new Date('2025-01-01T13:00:05Z'),
|
||||
];
|
||||
const callId = `${provider}-out-of-order`;
|
||||
return [
|
||||
{
|
||||
event: toolTimeline(
|
||||
'shell',
|
||||
'completed',
|
||||
{ type: 'tool_result', provider, tool_call_id: callId },
|
||||
{ provider, server: 'command', tool: 'shell', callId }
|
||||
),
|
||||
timestamp: timestamps[0],
|
||||
},
|
||||
{
|
||||
event: toolTimeline(
|
||||
'shell',
|
||||
'executing',
|
||||
{ type: 'tool_use', provider },
|
||||
{ provider, server: 'command', tool: 'shell', callId: null }
|
||||
),
|
||||
timestamp: timestamps[1],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function testOutOfOrderToolCallMergingLive() {
|
||||
(['claude', 'codex'] as const).forEach((provider) => {
|
||||
const updates = buildOutOfOrderToolCallSequence(provider);
|
||||
const finalState = updates.reduce<StreamItem[]>((state, { event, timestamp }) => {
|
||||
return reduceStreamUpdate(state, event, timestamp);
|
||||
}, []);
|
||||
const toolCalls = finalState.filter(
|
||||
(item): item is ToolCallItem => item.kind === 'tool_call' && item.payload.source === 'agent'
|
||||
);
|
||||
assert.strictEqual(toolCalls.length, 1, `${provider} live stream should not duplicate out-of-order calls`);
|
||||
assert.strictEqual(toolCalls[0]?.payload.data.status, 'completed', `${provider} live stream should keep completed status`);
|
||||
});
|
||||
}
|
||||
|
||||
function testOutOfOrderToolCallMergingHydrated() {
|
||||
(['claude', 'codex'] as const).forEach((provider) => {
|
||||
const updates = buildOutOfOrderToolCallSequence(provider);
|
||||
const hydrated = hydrateStreamState(updates);
|
||||
const toolCalls = hydrated.filter(
|
||||
(item): item is ToolCallItem => item.kind === 'tool_call' && item.payload.source === 'agent'
|
||||
);
|
||||
assert.strictEqual(toolCalls.length, 1, `${provider} hydration should not duplicate out-of-order calls`);
|
||||
assert.strictEqual(toolCalls[0]?.payload.data.status, 'completed', `${provider} hydration should keep completed status`);
|
||||
});
|
||||
}
|
||||
|
||||
describe('stream timeline reducers', () => {
|
||||
it('produces deterministic hydration results', testIdempotentReduction);
|
||||
it('deduplicates pending/completed tool entries in place', testUserMessageDeduplication);
|
||||
@@ -1083,4 +1137,6 @@ describe('stream timeline reducers', () => {
|
||||
it('keeps timeline ids stable after list shrinkage', testTimelineIdStabilityAfterRemovals);
|
||||
it('deduplicates live tool call entries', testToolCallDeduplicationLive);
|
||||
it('deduplicates hydrated tool call entries', testToolCallDeduplicationHydrated);
|
||||
it('merges out-of-order tool call updates without duplicating entries (live)', testOutOfOrderToolCallMergingLive);
|
||||
it('merges out-of-order tool call updates without duplicating entries (hydrated)', testOutOfOrderToolCallMergingHydrated);
|
||||
});
|
||||
|
||||
@@ -35,13 +35,30 @@ function createTimelineId(prefix: string, text: string, timestamp: Date): string
|
||||
|
||||
function createUniqueTimelineId(
|
||||
state: StreamItem[],
|
||||
prefix: "assistant" | "thought",
|
||||
prefix: "assistant" | "thought" | "user",
|
||||
text: string,
|
||||
timestamp: Date
|
||||
): string {
|
||||
const base = createTimelineId(prefix, text, timestamp);
|
||||
const suffixSeed = state.length.toString(36);
|
||||
return `${base}_${suffixSeed}`;
|
||||
let suffixSeed = state.length;
|
||||
let candidate = `${base}_${suffixSeed.toString(36)}`;
|
||||
|
||||
// Fast path when the generated id hasn't been used yet
|
||||
const hasCollision = state.some((entry) => entry.id === candidate);
|
||||
if (!hasCollision) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// If the id already exists (e.g. prior items were pruned/replaced),
|
||||
// spin until we find an unused suffix. Building the lookup Set lazily keeps
|
||||
// the common case cheap while still guaranteeing uniqueness.
|
||||
const usedIds = new Set(state.map((entry) => entry.id));
|
||||
while (usedIds.has(candidate)) {
|
||||
suffixSeed += 1;
|
||||
candidate = `${base}_${suffixSeed.toString(36)}`;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export type StreamItem =
|
||||
@@ -157,7 +174,9 @@ function appendUserMessage(
|
||||
return state;
|
||||
}
|
||||
|
||||
const entryId = messageId ?? createTimelineId("user", chunk.trim() || chunk, timestamp);
|
||||
const chunkSeed = chunk.trim() || chunk;
|
||||
const entryId =
|
||||
messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp);
|
||||
const existingIndex = state.findIndex(
|
||||
(entry) => entry.kind === "user_message" && entry.id === entryId
|
||||
);
|
||||
@@ -297,23 +316,27 @@ function findExistingAgentToolCallIndex(
|
||||
}
|
||||
|
||||
const fallbackCandidates: Array<{ index: number; item: AgentToolCallItem }> = [];
|
||||
const metadataMatches: Array<{ index: number; item: AgentToolCallItem }> = [];
|
||||
for (let i = 0; i < state.length; i += 1) {
|
||||
const entry = state[i];
|
||||
if (entry.kind !== "tool_call" || entry.payload.source !== "agent") {
|
||||
continue;
|
||||
}
|
||||
const payload = entry.payload.data;
|
||||
const providerMatches =
|
||||
payload.provider === data.provider &&
|
||||
payload.server === data.server &&
|
||||
payload.tool === data.tool;
|
||||
if (providerMatches) {
|
||||
metadataMatches.push({ index: i, item: entry as AgentToolCallItem });
|
||||
}
|
||||
if (payload.callId) {
|
||||
continue;
|
||||
}
|
||||
if (payload.status !== "executing") {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
payload.provider === data.provider &&
|
||||
payload.server === data.server &&
|
||||
payload.tool === data.tool
|
||||
) {
|
||||
if (providerMatches) {
|
||||
fallbackCandidates.push({ index: i, item: entry as AgentToolCallItem });
|
||||
}
|
||||
}
|
||||
@@ -346,14 +369,34 @@ function findExistingAgentToolCallIndex(
|
||||
normalizedDisplayName
|
||||
);
|
||||
|
||||
const byKind = filterByComparableField(
|
||||
byDisplayName,
|
||||
(entry) => normalizeComparableString(entry.payload.data.kind),
|
||||
normalizedKind
|
||||
);
|
||||
const selectCandidate = (
|
||||
candidates: Array<{ index: number; item: AgentToolCallItem }>
|
||||
): number => {
|
||||
const byDisplayName = filterByComparableField(
|
||||
candidates,
|
||||
(entry) => normalizeComparableString(entry.payload.data.displayName),
|
||||
normalizedDisplayName
|
||||
);
|
||||
|
||||
// Fall back to the oldest candidate (FIFO) to avoid duplicating the initial "executing" pill.
|
||||
return byKind[0]?.index ?? -1;
|
||||
const byKind = filterByComparableField(
|
||||
byDisplayName,
|
||||
(entry) => normalizeComparableString(entry.payload.data.kind),
|
||||
normalizedKind
|
||||
);
|
||||
|
||||
return byKind[0]?.index ?? -1;
|
||||
};
|
||||
|
||||
if (fallbackCandidates.length) {
|
||||
return selectCandidate(fallbackCandidates);
|
||||
}
|
||||
|
||||
// If this update still lacks a call id, fall back to metadata matches (e.g. replayed hydration events)
|
||||
if (!normalizedCallId && metadataMatches.length) {
|
||||
return selectCandidate(metadataMatches);
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function appendAgentToolCall(
|
||||
@@ -389,6 +432,10 @@ function appendAgentToolCall(
|
||||
payloadData.error !== undefined
|
||||
? payloadData.error
|
||||
: existing.payload.data.error;
|
||||
const mergedStatus = mergeToolCallStatus(
|
||||
existing.payload.data.status,
|
||||
payloadData.status ?? existing.payload.data.status ?? "executing"
|
||||
);
|
||||
const parsed = computeParsedToolPayload(mergedRaw, mergedResult);
|
||||
next[existingIndex] = {
|
||||
...existing,
|
||||
@@ -398,6 +445,7 @@ function appendAgentToolCall(
|
||||
data: {
|
||||
...existing.payload.data,
|
||||
...payloadData,
|
||||
status: mergedStatus,
|
||||
raw: mergedRaw,
|
||||
result: mergedResult,
|
||||
error: mergedError,
|
||||
@@ -579,6 +627,19 @@ function normalizeToolCallStatus(
|
||||
return normalizedFromStatus ?? "executing";
|
||||
}
|
||||
|
||||
function mergeToolCallStatus(
|
||||
existing: "executing" | "completed" | "failed" | undefined,
|
||||
incoming: "executing" | "completed" | "failed"
|
||||
): "executing" | "completed" | "failed" {
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export type DiffLine = {
|
||||
type: "add" | "remove" | "context" | "header";
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type EditEntry = {
|
||||
filePath?: string;
|
||||
diffLines: DiffLine[];
|
||||
};
|
||||
|
||||
export type ReadEntry = {
|
||||
filePath?: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type CommandDetails = {
|
||||
command?: string;
|
||||
cwd?: string;
|
||||
output?: string;
|
||||
exitCode?: number | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function splitIntoLines(text: string): string[] {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return text.replace(/\r\n/g, "\n").split("\n");
|
||||
}
|
||||
|
||||
function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
|
||||
const originalLines = splitIntoLines(originalText);
|
||||
const updatedLines = splitIntoLines(updatedText);
|
||||
|
||||
const hasAnyContent = originalLines.length > 0 || updatedLines.length > 0;
|
||||
if (!hasAnyContent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const m = originalLines.length;
|
||||
const n = updatedLines.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||
Array(n + 1).fill(0)
|
||||
);
|
||||
|
||||
for (let i = m - 1; i >= 0; i -= 1) {
|
||||
for (let j = n - 1; j >= 0; j -= 1) {
|
||||
if (originalLines[i] === updatedLines[j]) {
|
||||
dp[i][j] = dp[i + 1][j + 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const diff: DiffLine[] = [];
|
||||
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (originalLines[i] === updatedLines[j]) {
|
||||
diff.push({ type: "context", content: ` ${originalLines[i]}` });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
diff.push({ type: "remove", content: `-${originalLines[i]}` });
|
||||
i += 1;
|
||||
} else {
|
||||
diff.push({ type: "add", content: `+${updatedLines[j]}` });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
while (i < m) {
|
||||
diff.push({ type: "remove", content: `-${originalLines[i]}` });
|
||||
i += 1;
|
||||
}
|
||||
|
||||
while (j < n) {
|
||||
diff.push({ type: "add", content: `+${updatedLines[j]}` });
|
||||
j += 1;
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
function parseUnifiedDiff(diffText?: string): DiffLine[] {
|
||||
if (!diffText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = splitIntoLines(diffText);
|
||||
const diff: DiffLine[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.length) {
|
||||
diff.push({ type: "context", content: line });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("@@")) {
|
||||
diff.push({ type: "header", content: line });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("+")) {
|
||||
if (!line.startsWith("+++")) {
|
||||
diff.push({ type: "add", content: line });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("-")) {
|
||||
if (!line.startsWith("---")) {
|
||||
diff.push({ type: "remove", content: line });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
line.startsWith("diff --git") ||
|
||||
line.startsWith("index ") ||
|
||||
line.startsWith("---") ||
|
||||
line.startsWith("+++")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("\\ No newline")) {
|
||||
diff.push({ type: "header", content: line });
|
||||
continue;
|
||||
}
|
||||
|
||||
diff.push({ type: "context", content: line });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
|
||||
import { hydrateStreamState, type StreamItem, type ToolCallItem, type AgentToolCallData } from "../../../../../app/src/types/stream.js";
|
||||
import type { AgentStreamEventPayload } from "../../messages.js";
|
||||
import type { AgentSessionConfig, AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "claude-agent-e2e-"));
|
||||
return dir;
|
||||
try {
|
||||
return realpathSync(dir);
|
||||
} catch {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
async function autoApprove(session: Awaited<ReturnType<ClaudeAgentClient["createSession"]>>, event: AgentStreamEvent) {
|
||||
@@ -283,4 +289,284 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
},
|
||||
180_000
|
||||
);
|
||||
|
||||
test(
|
||||
"hydrates persisted tool call results into the UI stream",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new ClaudeAgentClient();
|
||||
const config: AgentSessionConfig = {
|
||||
provider: "claude",
|
||||
cwd,
|
||||
extra: { claude: { maxThinkingTokens: 4096 } },
|
||||
};
|
||||
const session = await client.createSession(config);
|
||||
const prompt = [
|
||||
"You are verifying the hydrate regression test.",
|
||||
"Follow these steps exactly and report 'hydration test complete' at the end:",
|
||||
"1. Run the Bash command 'pwd' via the terminal tool.",
|
||||
"2. Use your editor tools (not the shell) to create a file named hydrate-proof.txt with the content:",
|
||||
" HYDRATION_PROOF_LINE_ONE",
|
||||
" HYDRATION_PROOF_LINE_TWO",
|
||||
"3. Read hydrate-proof.txt via the editor read_file tool to confirm the contents.",
|
||||
"4. Summarize the diff/write results briefly and then stop.",
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
const liveTimelineUpdates: StreamHydrationUpdate[] = [];
|
||||
const events = session.stream(prompt);
|
||||
|
||||
let completed = false;
|
||||
try {
|
||||
for await (const event of events) {
|
||||
await autoApprove(session, event);
|
||||
recordTimelineUpdate(liveTimelineUpdates, event);
|
||||
if (event.type === "turn_completed") {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
throw new Error(event.error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
expect(completed).toBe(true);
|
||||
const liveState = hydrateStreamState(liveTimelineUpdates);
|
||||
const liveSnapshots = extractAgentToolSnapshots(liveState);
|
||||
const commandTool = liveSnapshots.find((snapshot) =>
|
||||
(snapshot.data.displayName ?? "").toLowerCase().includes("pwd")
|
||||
);
|
||||
const editTool = liveSnapshots.find((snapshot) =>
|
||||
rawContainsText(snapshot.data.raw, "hydrate-proof.txt")
|
||||
);
|
||||
const readTool = liveSnapshots.find((snapshot) =>
|
||||
rawContainsText(snapshot.data.raw, "HYDRATION_PROOF_LINE_TWO")
|
||||
);
|
||||
|
||||
expect(commandTool).toBeTruthy();
|
||||
expect(editTool).toBeTruthy();
|
||||
expect(readTool).toBeTruthy();
|
||||
|
||||
const handle = session.describePersistence();
|
||||
expect(handle).toBeTruthy();
|
||||
const sessionId = handle?.sessionId ?? handle?.nativeHandle;
|
||||
expect(typeof sessionId).toBe("string");
|
||||
|
||||
const historyPaths = getClaudeHistoryPaths(cwd, sessionId!);
|
||||
expect(await waitForHistoryFile(historyPaths)).toBe(true);
|
||||
|
||||
const resumed = await client.resumeSession(handle!, { cwd });
|
||||
const hydrationUpdates: StreamHydrationUpdate[] = [];
|
||||
try {
|
||||
for await (const event of resumed.streamHistory()) {
|
||||
recordTimelineUpdate(hydrationUpdates, event);
|
||||
}
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
|
||||
expect(hydrationUpdates.length).toBeGreaterThan(0);
|
||||
|
||||
const hydratedState = hydrateStreamState(hydrationUpdates);
|
||||
const hydratedSnapshots = extractAgentToolSnapshots(hydratedState);
|
||||
const hydratedMap = new Map(
|
||||
hydratedSnapshots.map((entry) => [entry.key, entry.data])
|
||||
);
|
||||
|
||||
assertHydratedReplica(commandTool!, hydratedMap, (data) => rawContainsText(data.raw, cwd));
|
||||
assertHydratedReplica(editTool!, hydratedMap, (data) =>
|
||||
rawContainsText(data.raw, "hydrate-proof.txt")
|
||||
);
|
||||
assertHydratedReplica(readTool!, hydratedMap, (data) =>
|
||||
rawContainsText(data.raw, "HYDRATION_PROOF_LINE_TWO")
|
||||
);
|
||||
} finally {
|
||||
cleanupClaudeHistory(cwd);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
240_000
|
||||
);
|
||||
});
|
||||
|
||||
describe("convertClaudeHistoryEntry", () => {
|
||||
test("maps user tool results to timeline items", () => {
|
||||
const toolUseId = "toolu_test";
|
||||
const entry = {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: toolUseId,
|
||||
content: [{ type: "text", text: "file contents" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const stubTimeline: AgentTimelineItem[] = [
|
||||
{
|
||||
type: "tool_call",
|
||||
server: "editor",
|
||||
tool: "read_file",
|
||||
status: "completed",
|
||||
} as AgentTimelineItem,
|
||||
];
|
||||
|
||||
const mapBlocks = vi.fn().mockReturnValue(stubTimeline);
|
||||
const result = convertClaudeHistoryEntry(entry, mapBlocks);
|
||||
|
||||
expect(result).toEqual(stubTimeline);
|
||||
expect(mapBlocks).toHaveBeenCalledTimes(1);
|
||||
const arg = mapBlocks.mock.calls[0][0];
|
||||
expect(Array.isArray(arg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns user messages when no tool blocks exist", () => {
|
||||
const entry = {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: "Run npm test",
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertClaudeHistoryEntry(entry, () => []);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
text: "Run npm test",
|
||||
raw: entry.message,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
type StreamHydrationUpdate = {
|
||||
event: Extract<AgentStreamEventPayload, { type: "timeline" }>;
|
||||
timestamp: Date;
|
||||
};
|
||||
|
||||
type ToolSnapshot = { key: string; data: AgentToolCallData };
|
||||
|
||||
function recordTimelineUpdate(target: StreamHydrationUpdate[], event: AgentStreamEvent) {
|
||||
if (event.type !== "timeline") {
|
||||
return;
|
||||
}
|
||||
const payload = event as Extract<AgentStreamEvent, { type: "timeline" }>;
|
||||
target.push({
|
||||
event: payload as StreamHydrationUpdate["event"],
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
function extractAgentToolSnapshots(state: StreamItem[]): ToolSnapshot[] {
|
||||
return state
|
||||
.filter(
|
||||
(item): item is ToolCallItem =>
|
||||
item.kind === "tool_call" && item.payload.source === "agent"
|
||||
)
|
||||
.map((item) => ({
|
||||
key: buildToolSnapshotKey(item.payload.data, item.id),
|
||||
data: item.payload.data,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildToolSnapshotKey(data: AgentToolCallData, fallbackId: string): string {
|
||||
const normalized = typeof data.callId === "string" && data.callId.trim().length > 0 ? data.callId.trim() : null;
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
const display = typeof data.displayName === "string" && data.displayName.trim().length > 0 ? data.displayName.trim() : fallbackId;
|
||||
return `${data.server}:${data.tool}:${display}`;
|
||||
}
|
||||
|
||||
function assertHydratedReplica(
|
||||
liveSnapshot: ToolSnapshot,
|
||||
hydratedMap: Map<string, AgentToolCallData>,
|
||||
predicate: (data: AgentToolCallData) => boolean
|
||||
) {
|
||||
expect(predicate(liveSnapshot.data)).toBe(true);
|
||||
const hydrated = hydratedMap.get(liveSnapshot.key);
|
||||
expect(hydrated).toBeTruthy();
|
||||
expect(hydrated?.status).toBe(liveSnapshot.data.status);
|
||||
expect(hydrated?.server).toBe(liveSnapshot.data.server);
|
||||
expect(hydrated?.tool).toBe(liveSnapshot.data.tool);
|
||||
expect(hydrated?.displayName).toBe(liveSnapshot.data.displayName);
|
||||
expect(predicate(hydrated!)).toBe(true);
|
||||
}
|
||||
|
||||
function sanitizeClaudeProjectName(cwd: string): string {
|
||||
return cwd.replace(/[\\/]/g, "-").replace(/_/g, "-");
|
||||
}
|
||||
|
||||
function resolveClaudeHistoryPath(cwd: string, sessionId: string): string {
|
||||
const sanitized = sanitizeClaudeProjectName(cwd);
|
||||
return path.join(os.homedir(), ".claude", "projects", sanitized, `${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
function getClaudeHistoryPaths(cwd: string, sessionId: string): string[] {
|
||||
return normalizeCwdCandidates(cwd).map((candidate) =>
|
||||
resolveClaudeHistoryPath(candidate, sessionId)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCwdCandidates(cwd: string): string[] {
|
||||
const candidates = new Set<string>([cwd]);
|
||||
try {
|
||||
const resolved = realpathSync(cwd);
|
||||
candidates.add(resolved);
|
||||
} catch {
|
||||
// ignore resolution errors
|
||||
}
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
function cleanupClaudeHistory(cwd: string) {
|
||||
for (const candidate of normalizeCwdCandidates(cwd)) {
|
||||
const sanitized = sanitizeClaudeProjectName(candidate);
|
||||
const projectDir = path.join(os.homedir(), ".claude", "projects", sanitized);
|
||||
if (existsSync(projectDir)) {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHistoryFile(historyPaths: string | string[], timeoutMs = 10_000): Promise<boolean> {
|
||||
const candidates = Array.isArray(historyPaths) ? Array.from(new Set(historyPaths)) : [historyPaths];
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (candidates.some((entry) => existsSync(entry))) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
return candidates.some((entry) => existsSync(entry));
|
||||
}
|
||||
|
||||
function rawContainsText(raw: unknown, text: string, depth = 0): boolean {
|
||||
if (!raw || typeof text !== "string" || !text) {
|
||||
return false;
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
return raw.includes(text);
|
||||
}
|
||||
if (depth > 6) {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.some((entry) => rawContainsText(entry, text, depth + 1));
|
||||
}
|
||||
if (typeof raw === "object") {
|
||||
return Object.values(raw as Record<string, unknown>).some((value) =>
|
||||
rawContainsText(value, text, depth + 1)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -811,41 +811,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private convertHistoryEntry(entry: any): AgentTimelineItem[] {
|
||||
const message = entry?.message;
|
||||
if (!message || !("content" in message)) {
|
||||
return [];
|
||||
}
|
||||
const content = message.content as string | ClaudeContentChunk[];
|
||||
|
||||
if (entry.type === "assistant" && content) {
|
||||
return this.mapBlocksToTimeline(content);
|
||||
}
|
||||
|
||||
if (entry.type === "user") {
|
||||
const text = extractUserMessageText(content);
|
||||
if (text) {
|
||||
return [
|
||||
{
|
||||
type: "user_message",
|
||||
text,
|
||||
raw: message,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const hasToolBlock = content.some(
|
||||
(block: ClaudeContentChunk) =>
|
||||
typeof block?.type === "string" && block.type.includes("tool")
|
||||
);
|
||||
if (hasToolBlock) {
|
||||
return this.mapBlocksToTimeline(content);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content));
|
||||
}
|
||||
|
||||
private mapBlocksToTimeline(content: string | ClaudeContentChunk[]): AgentTimelineItem[] {
|
||||
@@ -1120,6 +1086,63 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
function hasToolLikeBlock(block?: ClaudeContentChunk | null): boolean {
|
||||
if (!block || typeof block !== "object") {
|
||||
return false;
|
||||
}
|
||||
const type = typeof block.type === "string" ? block.type.toLowerCase() : "";
|
||||
return type.includes("tool");
|
||||
}
|
||||
|
||||
function normalizeHistoryBlocks(
|
||||
content: string | ClaudeContentChunk[]
|
||||
): ClaudeContentChunk[] | null {
|
||||
if (Array.isArray(content)) {
|
||||
return content;
|
||||
}
|
||||
if (content && typeof content === "object") {
|
||||
return [content as ClaudeContentChunk];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function convertClaudeHistoryEntry(
|
||||
entry: any,
|
||||
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[]
|
||||
): AgentTimelineItem[] {
|
||||
const message = entry?.message;
|
||||
if (!message || !("content" in message)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = message.content as string | ClaudeContentChunk[];
|
||||
const normalizedBlocks = normalizeHistoryBlocks(content);
|
||||
const hasToolBlock = normalizedBlocks?.some((block) => hasToolLikeBlock(block)) ?? false;
|
||||
|
||||
if (hasToolBlock && normalizedBlocks) {
|
||||
return mapBlocks(Array.isArray(content) ? content : normalizedBlocks);
|
||||
}
|
||||
|
||||
if (entry.type === "assistant" && content) {
|
||||
return mapBlocks(content);
|
||||
}
|
||||
|
||||
if (entry.type === "user") {
|
||||
const text = extractUserMessageText(content);
|
||||
if (text) {
|
||||
return [
|
||||
{
|
||||
type: "user_message",
|
||||
text,
|
||||
raw: message,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
class Pushable<T> implements AsyncIterable<T> {
|
||||
private queue: T[] = [];
|
||||
private resolvers: ((value: IteratorResult<T>) => void)[] = [];
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@server": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 30000,
|
||||
|
||||
Reference in New Issue
Block a user