refactor(ui): extract diff viewer and tool call parsers into reusable components

This commit is contained in:
Mohamed Boudra
2025-11-15 16:28:57 +01:00
parent 1886898bf2
commit edcfdf970d
10 changed files with 1402 additions and 870 deletions
+34 -6
View File
@@ -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",
+120
View File
@@ -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,
+56
View File
@@ -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);
});
+77 -16
View File
@@ -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",
+706
View File
@@ -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)[] = [];
+6
View File
@@ -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,