diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 5f38e0b33..d88a604d6 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -47,7 +47,6 @@ import type { Agent } from "@/contexts/session-context"; import { useSessionStore } from "@/stores/session-store"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import type { DaemonClient } from "@server/client/daemon-client"; -import { parseToolCallDisplay } from "@/utils/tool-call-parsers"; import { ToolCallDetailsContent } from "./tool-call-details"; import { QuestionFormCard } from "./question-form-card"; import { ToolCallSheetProvider } from "./tool-call-sheet"; @@ -363,11 +362,11 @@ export function AgentStreamView({ return ( { - if (isPlanRequest) { - return null; - } - return parseToolCallDisplay({ - name: request.name ?? "unknown", - provider: request.provider, - input: request.input, - }); - }, [isPlanRequest, request.name, request.provider, request.input]); - const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownRules = useMemo(() => { @@ -1133,8 +1121,12 @@ function PermissionRequestCard({ ) : null} - {!isPlanRequest && toolCallDisplay ? ( - + {!isPlanRequest ? ( + ) : null} `${char}${char}`) + .join("") + : hex; + + if (!/^[\da-fA-F]{6}$/.test(normalized)) { + return `rgba(255, 255, 255, ${clampedAlpha})`; + } + + const intValue = Number.parseInt(normalized, 16); + const red = (intValue >> 16) & 255; + const green = (intValue >> 8) & 255; + const blue = intValue & 255; + + return `rgba(${red}, ${green}, ${blue}, ${clampedAlpha})`; +} + const userMessageStylesheet = StyleSheet.create((theme) => ({ container: { flexDirection: "row", @@ -418,8 +445,9 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ }, shimmerOverlay: { position: "absolute", - top: 0, - bottom: 0, + top: 3, + bottom: 3, + borderRadius: 999, }, })); @@ -1026,6 +1054,14 @@ const ExpandableBadge = memo(function ExpandableBadge({ }, [isLoading]); const shimmerBandWidth = 14; + const shimmerGradientColors = useMemo( + () => ({ + edge: hexToRgba(theme.colors.foreground, 0), + soft: hexToRgba(theme.colors.foreground, 0.08), + core: hexToRgba(theme.colors.foreground, 0.24), + }), + [theme.colors.foreground] + ); const shimmerStyle = useAnimatedStyle(() => { const travel = badgeWidth + shimmerBandWidth; return { @@ -1112,13 +1148,13 @@ const ExpandableBadge = memo(function ExpandableBadge({ x2="1" y2="0" > - - - - - - - + + + + + + + @@ -1155,11 +1191,11 @@ const ExpandableBadge = memo(function ExpandableBadge({ interface ToolCallProps { toolName: string; - provider?: string; args: any; result?: any; error?: any; - status: "executing" | "completed" | "failed"; + status: "executing" | "running" | "completed" | "failed" | "canceled"; + detail?: ToolCallDetail; cwd?: string; metadata?: Record; isLastInSequence?: boolean; @@ -1183,11 +1219,11 @@ const TOOL_CALL_COMMIT_THRESHOLD_MS = 16; export const ToolCall = memo(function ToolCall({ toolName, - provider, args, result, error, status, + detail, cwd, metadata, isLastInSequence = false, @@ -1204,20 +1240,14 @@ export const ToolCall = memo(function ToolCall({ UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const displayInfo = useMemo( - () => - parseToolCallDisplay({ - name: toolName, - provider, - input: args, - output: result, - error, - metadata, - cwd, - }), - [toolName, provider, args, result, error, metadata, cwd] + const displayModel = useMemo( + () => buildToolCallDisplayModel({ name: toolName, detail, metadata, cwd }), + [toolName, detail, metadata, cwd] ); - const { kind, displayName, summary, detail, errorText } = displayInfo; + const displayName = displayModel.displayName; + const kind: ToolCallKind = displayModel.kind; + const summary = displayModel.summary; + const errorText = useMemo(() => formatToolCallError(error), [error]); const IconComponent = toolKindIcons[kind] || Wrench; // Check if there's any content to display @@ -1229,11 +1259,19 @@ export const ToolCall = memo(function ToolCall({ toggleStartRef.current = getNowMs(); } if (isMobile) { - openToolCall(displayInfo); + openToolCall({ + kind, + displayName, + summary, + detail, + input: args, + output: result, + errorText, + }); } else { setIsExpanded((prev) => !prev); } - }, [isMobile, openToolCall, displayInfo]); + }, [isMobile, openToolCall, kind, displayName, summary, detail, args, result, errorText]); useEffect(() => { if (isMobile || !isPerfLoggingEnabled()) { @@ -1292,8 +1330,16 @@ export const ToolCall = memo(function ToolCall({ // Render inline details for desktop const renderDetails = useCallback(() => { if (isMobile) return null; - return ; - }, [isMobile, detail, errorText]); + return ( + + ); + }, [isMobile, detail, args, result, errorText]); return ( null : undefined } - isLoading={status === "executing"} + isLoading={status === "executing" || status === "running"} isError={status === "failed"} isLastInSequence={isLastInSequence} disableOuterSpacing={disableOuterSpacing} diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index cbef5360f..a3bce0fd5 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -3,10 +3,10 @@ import { View, Text, Platform, ScrollView as RNScrollView } from "react-native"; import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; +import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import { buildLineDiff, parseUnifiedDiff, - type ToolCallDetail, } from "@/utils/tool-call-parsers"; import { DiffViewer } from "./diff-viewer"; import { getCodeInsets } from "./code-insets"; @@ -16,34 +16,39 @@ const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView; // ---- Content Component ---- interface ToolCallDetailsContentProps { - detail: ToolCallDetail; + detail?: ToolCallDetail; + input?: unknown | null; + output?: unknown | null; errorText?: string; maxHeight?: number; } export function ToolCallDetailsContent({ detail, + input, + output, errorText, maxHeight = 300, }: ToolCallDetailsContentProps) { // Compute diff lines for edit type const diffLines = useMemo(() => { - if (detail.type !== "edit") return undefined; + if (!detail || detail.type !== "edit") return undefined; // Use pre-computed unified diff if available (e.g., from apply_patch) if (detail.unifiedDiff) { return parseUnifiedDiff(detail.unifiedDiff); } - return buildLineDiff(detail.oldString, detail.newString); + return buildLineDiff(detail.oldString ?? "", detail.newString ?? ""); }, [detail]); const sections: ReactNode[] = []; - const isFullBleed = detail.type === "edit" || detail.type === "shell"; + const isFullBleed = + detail?.type === "edit" || detail?.type === "shell" || detail?.type === "write"; const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer; - if (detail.type === "shell") { + if (detail?.type === "shell") { const command = detail.command.replace(/\n+$/, ""); - const output = detail.output.replace(/^\n+/, ""); - const hasOutput = output.length > 0; + const commandOutput = (detail.output ?? "").replace(/^\n+/, ""); + const hasOutput = commandOutput.length > 0; sections.push( @@ -63,7 +68,7 @@ export function ToolCallDetailsContent({ $ {command} - {hasOutput ? `\n\n${output}` : ""} + {hasOutput ? `\n\n${commandOutput}` : ""} @@ -71,7 +76,7 @@ export function ToolCallDetailsContent({ ); - } else if (detail.type === "edit") { + } else if (detail?.type === "edit") { sections.push( {diffLines ? ( @@ -81,7 +86,28 @@ export function ToolCallDetailsContent({ ) : null} ); - } else if (detail.type === "read") { + } else if (detail?.type === "write") { + sections.push( + + {detail.content ? ( + + + {detail.content} + + + ) : null} + + ); + } else if (detail?.type === "read") { sections.push( {(detail.offset !== undefined || detail.limit !== undefined) ? ( @@ -109,68 +135,49 @@ export function ToolCallDetailsContent({ ) : null} ); - } else if (detail.type === "thinking") { - // Thinking: display the content as plain text + } else if (detail?.type === "search") { sections.push( - - - {detail.content} - + + {detail.query} ); } else { - // Generic tool: show input/output as key-value pairs - if (detail.input.length > 0) { - sections.push( - - Input - - ); - detail.input.forEach((pair, index) => { - sections.push( - - {pair.key} - - {pair.value} - - - ); - }); - } + const sectionsFromTopLevel = [ + { title: "Input", value: input }, + { title: "Output", value: output }, + ].filter((entry) => entry.value !== null && entry.value !== undefined); - if (detail.output.length > 0) { + for (const section of sectionsFromTopLevel) { + let value = ""; + try { + value = + typeof section.value === "string" + ? section.value + : JSON.stringify(section.value, null, 2); + } catch { + value = String(section.value); + } + if (!value.length) { + continue; + } sections.push( - - Output + + {section.title} + + ); + sections.push( + + + {value} + ); - detail.output.forEach((pair, index) => { - sections.push( - - {pair.key} - - {pair.value} - - - ); - }); } } diff --git a/packages/app/src/components/tool-call-preview.test.ts b/packages/app/src/components/tool-call-preview.test.ts deleted file mode 100644 index 18558d5c8..000000000 --- a/packages/app/src/components/tool-call-preview.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { resolveToolCallPreview } from "./tool-call-preview"; -import type { CommandDetails, EditEntry, ReadEntry } from "@/utils/tool-call-parsers"; - -describe("resolveToolCallPreview", () => { - it("prefers parsed hydration payloads when provided", () => { - const parsedEdits: EditEntry[] = [ - { - filePath: "README.md", - diffLines: [ - { type: "header", content: "@@ -1 +1 @@" }, - { type: "remove", content: "-Old" }, - { type: "add", content: "+New" }, - ], - }, - ]; - const parsedReads: ReadEntry[] = [ - { - filePath: "README.md", - content: "# Hydrated\nFinal text\n", - }, - ]; - const parsedCommand: CommandDetails = { - command: "ls", - cwd: "/tmp/hydrated", - output: "README.md\npackages\n", - exitCode: 0, - }; - - const preview = resolveToolCallPreview({ - parsedEditEntries: parsedEdits, - parsedReadEntries: parsedReads, - parsedCommandDetails: parsedCommand, - }); - - expect(preview.editEntries).toBe(parsedEdits); - expect(preview.readEntries).toBe(parsedReads); - expect(preview.commandDetails).toBe(parsedCommand); - }); - - it("falls back to derived parser output when hydration metadata is missing", () => { - const args = { - type: "mcp_tool_use", - id: "call_fallback", - name: "apply_patch", - server: "editor", - input: { - file_path: "README.md", - patch: "*** Begin Patch\n*** Update File: README.md\n@@\n-Old\n+New\n*** End Patch", - }, - }; - const result = { - changes: [ - { - file_path: "README.md", - previous_content: "Old\n", - content: "New\n", - }, - ], - }; - - const preview = resolveToolCallPreview({ - args, - result, - }); - - expect(preview.editEntries[0]?.diffLines.length).toBeGreaterThan(0); - expect( - preview.editEntries[0]?.diffLines.some((line) => line.content.includes("+New")) - ).toBe(true); - expect(preview.commandDetails).toBeNull(); - }); -}); diff --git a/packages/app/src/components/tool-call-preview.ts b/packages/app/src/components/tool-call-preview.ts deleted file mode 100644 index c66ddd571..000000000 --- a/packages/app/src/components/tool-call-preview.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - extractCommandDetails, - extractEditEntries, - extractReadEntries, - type CommandDetails, - type EditEntry, - type ReadEntry, -} from "@/utils/tool-call-parsers"; - -export type ToolCallPreviewSource = { - args?: unknown; - result?: unknown; - parsedEditEntries?: EditEntry[] | undefined; - parsedReadEntries?: ReadEntry[] | undefined; - parsedCommandDetails?: CommandDetails | null | undefined; -}; - -export type ToolCallPreview = { - editEntries: EditEntry[]; - readEntries: ReadEntry[]; - commandDetails: CommandDetails | null | undefined; -}; - -export function resolveToolCallPreview({ - args, - result, - parsedEditEntries, - parsedReadEntries, - parsedCommandDetails, -}: ToolCallPreviewSource): ToolCallPreview { - const fallbackEditEntries = extractEditEntries(args, result); - const fallbackReadEntries = extractReadEntries(result, args); - const fallbackCommandDetails = extractCommandDetails(args, result); - - return { - editEntries: parsedEditEntries ?? fallbackEditEntries, - readEntries: parsedReadEntries ?? fallbackReadEntries, - commandDetails: parsedCommandDetails ?? fallbackCommandDetails, - }; -} diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 4b60b78bf..6288b1b78 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -16,12 +16,21 @@ import { BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import { Pencil, Eye, SquareTerminal, Search, Bot, Wrench, X } from "lucide-react-native"; -import type { ToolCallDisplayInfo } from "@/utils/tool-call-parsers"; +import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; +import type { ToolCallKind } from "@/utils/tool-call-display"; import { ToolCallDetailsContent } from "./tool-call-details"; // ----- Types ----- -export type ToolCallSheetData = ToolCallDisplayInfo; +export type ToolCallSheetData = { + kind: ToolCallKind; + displayName: string; + summary?: string; + detail?: ToolCallDetail; + input?: unknown | null; + output?: unknown | null; + errorText?: string; +}; interface ToolCallSheetContextValue { openToolCall: (data: ToolCallSheetData) => void; @@ -133,7 +142,7 @@ interface ToolCallSheetContentProps { } function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { - const { kind, displayName, detail, errorText } = data; + const { kind, displayName, detail, input, output, errorText } = data; const IconComponent = toolKindIcons[kind] || Wrench; @@ -157,7 +166,12 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { style={styles.content} contentContainerStyle={styles.contentContainer} > - + ); diff --git a/packages/app/src/types/stream-buffer.test.ts b/packages/app/src/types/stream-buffer.test.ts index 40e120a53..3d67a5704 100644 --- a/packages/app/src/types/stream-buffer.test.ts +++ b/packages/app/src/types/stream-buffer.test.ts @@ -23,8 +23,12 @@ const toolCallEvent = (): AgentStreamEventPayload => ({ provider: "codex", item: { type: "tool_call", + callId: "buffer-tool-call", name: "run", - status: "executing", + status: "running", + input: { command: "echo hi" }, + output: null, + error: null, }, }); diff --git a/packages/app/src/types/stream.harness.test.ts b/packages/app/src/types/stream.harness.test.ts index c29376517..b95ba03af 100644 --- a/packages/app/src/types/stream.harness.test.ts +++ b/packages/app/src/types/stream.harness.test.ts @@ -4,12 +4,13 @@ import { hydrateStreamState, type StreamItem, type AgentToolCallItem, - type ToolCallStatus, isAgentToolCallItem, } from "./stream"; import type { AgentStreamEventPayload } from "@server/shared/messages"; +import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; type HarnessUpdate = { event: AgentStreamEventPayload; timestamp: Date }; +type ToolStatus = "running" | "completed" | "failed" | "canceled"; const HARNESS_CALL_IDS = { command: "harness-command", @@ -31,60 +32,80 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [ timestamp: new Date("2025-02-01T10:00:00Z"), }, { - event: buildToolStartEvent({ + event: buildToolEvent({ callId: HARNESS_CALL_IDS.edit, name: "apply_patch", + status: "running", input: { file_path: "README.md", patch: "*** Begin Patch\n*** Update File: README.md\n@@\n-Old line\n+New line\n*** End Patch", }, + detail: { + type: "edit", + filePath: "README.md", + unifiedDiff: "@@\n-Old line\n+New line", + }, }), timestamp: new Date("2025-02-01T10:00:01Z"), }, { - event: buildToolResultEvent({ + event: buildToolEvent({ callId: HARNESS_CALL_IDS.edit, name: "apply_patch", + status: "completed", output: { - changes: [ + files: [ { - file_path: "README.md", - previous_content: "Old line\n", - content: "New line\n", + path: "README.md", + patch: "@@\n-Old line\n+New line", }, ], }, + input: null, }), timestamp: new Date("2025-02-01T10:00:02Z"), }, { - event: buildToolStartEvent({ + event: buildToolEvent({ callId: HARNESS_CALL_IDS.read, name: "read_file", + status: "running", input: { file_path: "README.md" }, + detail: { + type: "read", + filePath: "README.md", + }, }), timestamp: new Date("2025-02-01T10:00:03Z"), }, { - event: buildToolResultEvent({ + event: buildToolEvent({ callId: HARNESS_CALL_IDS.read, name: "read_file", + status: "completed", output: { content: "# README\nNew line\n" }, + input: null, }), timestamp: new Date("2025-02-01T10:00:04Z"), }, { - event: buildToolStartEvent({ + event: buildToolEvent({ callId: HARNESS_CALL_IDS.command, name: "shell", + status: "running", input: { command: "ls" }, + detail: { + type: "shell", + command: "ls", + }, }), timestamp: new Date("2025-02-01T10:00:05Z"), }, { - event: buildToolResultEvent({ + event: buildToolEvent({ callId: HARNESS_CALL_IDS.command, name: "shell", + status: "completed", output: { result: { command: "ls", @@ -92,12 +113,12 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [ }, metadata: { exit_code: 0, cwd: "/tmp/harness" }, }, + input: null, }), timestamp: new Date("2025-02-01T10:00:06Z"), }, ]; -// Hydration snapshot recorded after refreshing the chat – this is the broken state we need to codify. const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [ { event: { @@ -112,96 +133,112 @@ const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [ timestamp: new Date("2025-02-01T10:05:00Z"), }, { - event: buildToolStartEvent({ + event: buildToolEvent({ callId: HARNESS_CALL_IDS.edit, name: "apply_patch", status: "completed", + input: { + file_path: "README.md", + }, + output: null, }), timestamp: new Date("2025-02-01T10:05:01Z"), }, { - event: buildToolStartEvent({ + event: buildToolEvent({ callId: HARNESS_CALL_IDS.read, name: "read_file", status: "completed", + input: { file_path: "README.md" }, + output: null, }), timestamp: new Date("2025-02-01T10:05:02Z"), }, { - event: buildToolStartEvent({ + event: buildToolEvent({ callId: HARNESS_CALL_IDS.command, name: "shell", status: "completed", + input: { command: "ls" }, + output: null, }), timestamp: new Date("2025-02-01T10:05:03Z"), }, ]; -describe("stream harness captures hydrated regression", () => { - it("records tool payloads during the live run", () => { +describe("stream harness canonical payloads", () => { + it("keeps provider detail payloads during live run", () => { const liveState = hydrateStreamState(STREAM_HARNESS_LIVE); const snapshots = extractHarnessSnapshots(liveState); - expect(snapshots.edit?.payload.data.parsedEdits?.[0]?.diffLines.length).toBeGreaterThan(0); - expect(snapshots.read?.payload.data.parsedReads?.[0]?.content).toContain("New line"); - expect(snapshots.command?.payload.data.parsedCommand?.output).toContain("README.md"); + expect(snapshots.edit?.payload.data.detail).toEqual({ + type: "edit", + filePath: "README.md", + unifiedDiff: "@@\n-Old line\n+New line", + }); + expect(snapshots.read?.payload.data.detail).toEqual({ + type: "read", + filePath: "README.md", + }); + expect(snapshots.command?.payload.data.detail).toEqual({ + type: "shell", + command: "ls", + }); }); - it("documents that hydrated events without output lose parsed payloads", () => { - // After a refresh, hydrated events only contain status but no input/output data. - // Without full data, parsed payloads cannot be reconstructed. + it("keeps tool records hydrated even when output is missing", () => { const hydratedState = hydrateStreamState(STREAM_HARNESS_HYDRATED); const snapshots = extractHarnessSnapshots(hydratedState); - // Hydrated events exist but lack parsed content since input/output were not provided - expect(snapshots.edit?.payload.data.parsedEdits).toBeUndefined(); - expect(snapshots.read?.payload.data.parsedReads).toBeUndefined(); - expect(snapshots.command?.payload.data.parsedCommand).toBeUndefined(); + expect(snapshots.edit?.payload.data.status).toBe("completed"); + expect(snapshots.read?.payload.data.status).toBe("completed"); + expect(snapshots.command?.payload.data.status).toBe("completed"); }); }); -function buildToolStartEvent({ +function buildToolEvent({ callId, name, - input, - status = "executing", + status, + input = null, + output = null, + error, + detail, }: { callId: string; name: string; - input?: Record; - status?: ToolCallStatus; + status: ToolStatus; + input?: Record | null; + output?: Record | null; + error?: unknown; + detail?: ToolCallDetail; }): AgentStreamEventPayload { - return { - type: "timeline", - provider: "claude", - item: { - type: "tool_call", - name, - status, - callId, - input, - }, + const baseItem = { + type: "tool_call" as const, + name, + status, + callId, + input, + output, + ...(detail ? { detail } : {}), }; -} -function buildToolResultEvent({ - callId, - name, - output, -}: { - callId: string; - name: string; - output?: Record; -}): AgentStreamEventPayload { + const item = + status === "failed" + ? { + ...baseItem, + status: "failed" as const, + error: error ?? { message: "failed" }, + } + : { + ...baseItem, + error: null, + }; + return { type: "timeline", provider: "claude", - item: { - type: "tool_call", - name, - callId, - output, - }, + item, }; } diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 7898cda4b..17a212f3a 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -2,27 +2,18 @@ import assert from "node:assert/strict"; import { describe, it } from "vitest"; import { - reduceStreamUpdate, hydrateStreamState, - type StreamItem, - type ToolCallItem, - type TodoListItem, type AgentToolCallItem, + type StreamItem, isAgentToolCallItem, } from "./stream"; +import type { AgentProvider, ToolCallDetail } from "@server/server/agent/agent-sdk-types"; +import type { AgentStreamEventPayload } from "@server/shared/messages"; +import { buildToolCallDisplayModel } from "@/utils/tool-call-display"; -type AgentStreamEventPayload = Parameters[1]; -type TestAgentProvider = "claude" | "codex"; +type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled"; -function expectAgentToolCallItem( - item: ToolCallItem | undefined, - message = "Expected agent tool call" -): asserts item is AgentToolCallItem { - assert.ok(item, message); - assert.ok(isAgentToolCallItem(item), message); -} - -function assistantTimeline(text: string, provider: TestAgentProvider = "claude"): AgentStreamEventPayload { +function assistantTimeline(text: string, provider: AgentProvider = "claude"): AgentStreamEventPayload { return { type: "timeline", provider, @@ -30,7 +21,7 @@ function assistantTimeline(text: string, provider: TestAgentProvider = "claude") }; } -function reasoningTimeline(text: string, provider: TestAgentProvider = "claude"): AgentStreamEventPayload { +function reasoningTimeline(text: string, provider: AgentProvider = "claude"): AgentStreamEventPayload { return { type: "timeline", provider, @@ -38,50 +29,44 @@ function reasoningTimeline(text: string, provider: TestAgentProvider = "claude") }; } -function toolTimeline( - id: string, - status: string, - _raw?: unknown, - options?: { callId?: string | null; provider?: "claude" | "codex"; name?: string } -): AgentStreamEventPayload { - const explicitCallIdProvided = - options && Object.prototype.hasOwnProperty.call(options, "callId"); - const callIdValue = explicitCallIdProvided - ? options?.callId === null - ? undefined - : options?.callId - : id; - const provider = options?.provider ?? "claude"; - const name = options?.name ?? id; - return { - type: "timeline", - provider, - item: { - type: "tool_call", - name, - status, - callId: callIdValue, - }, - }; -} - -function toolTimelineWithInput(options: { - provider: TestAgentProvider; +function canonicalToolTimeline(params: { + provider: AgentProvider; + callId: string; name: string; - status: string; - callId?: string; - input: unknown; + status: CanonicalToolStatus; + input?: unknown | null; + output?: unknown | null; + error?: unknown; + metadata?: Record; + detail?: ToolCallDetail; }): AgentStreamEventPayload { + const baseItem = { + type: "tool_call" as const, + callId: params.callId, + name: params.name, + status: params.status, + input: params.input ?? null, + output: params.output ?? null, + metadata: params.metadata, + ...(params.detail ? { detail: params.detail } : {}), + }; + + const item = + params.status === "failed" + ? { + ...baseItem, + status: "failed" as const, + error: params.error ?? { message: "failed" }, + } + : { + ...baseItem, + error: null, + }; + return { type: "timeline", - provider: options.provider, - item: { - type: "tool_call", - name: options.name, - status: options.status, - callId: options.callId ?? options.name, - input: options.input, - }, + provider: params.provider, + item, }; } @@ -96,1091 +81,278 @@ function todoTimeline(items: { text: string; completed: boolean }[]): AgentStrea }; } -function userTimeline(text: string, messageId?: string, provider: TestAgentProvider = "claude"): AgentStreamEventPayload { - return { - type: "timeline", - provider, - item: { - type: "user_message", - text, - messageId, - }, - }; -} - -// Test 1: Same updates applied twice should be idempotent -function testIdempotentReduction() { - const timestamp1 = new Date('2025-01-01T10:00:00Z'); - const timestamp2 = new Date('2025-01-01T10:00:01Z'); - const timestamp3 = new Date('2025-01-01T10:00:02Z'); - - // Create a sequence of updates - const updates = [ - { event: assistantTimeline("Hello! "), timestamp: timestamp2 }, - { event: assistantTimeline("How can I help you?"), timestamp: timestamp2 }, - { event: reasoningTimeline("Thinking..."), timestamp: timestamp3 }, - ]; - - // Apply updates once - const state1 = hydrateStreamState(updates); - - // Apply same updates again from scratch - const state2 = hydrateStreamState(updates); - - const state1Str = JSON.stringify(state1); - const state2Str = JSON.stringify(state2); - - const assistantMsg = state1.find(item => item.kind === "assistant_message"); - assert.strictEqual(state1Str, state2Str, "Hydrated stream should be deterministic"); - assert.strictEqual( - assistantMsg?.text, - "Hello! How can I help you?", - "Assistant chunks should concatenate with preserved spacing" - ); -} - -// Test 2: Duplicate user messages should not create duplicates -function testUserMessageDeduplication() { - const timestamp = new Date("2025-01-01T10:00:00Z"); - - const updates = [ - { event: toolTimeline("tool-1", "pending"), timestamp }, - { event: toolTimeline("tool-1", "completed"), timestamp }, - ]; - - const state = hydrateStreamState(updates); - - const toolCalls = state.filter(isAgentToolCallItem); - - assert.strictEqual(toolCalls.length, 1, "Pending/completed tool entries should reconcile"); - assert.strictEqual(toolCalls[0].payload.source, "agent"); - assert.strictEqual(toolCalls[0].payload.data.status, "completed"); -} - -// Test 3: Multiple assistant messages with different IDs -function testMultipleMessages() { - const timestamp1 = new Date("2025-01-01T10:00:00Z"); - const timestamp2 = new Date("2025-01-01T10:00:05Z"); - - const updates = [ - { event: assistantTimeline("First message"), timestamp: timestamp1 }, - { event: toolTimeline("tool-2", "pending"), timestamp: timestamp1 }, - { event: toolTimeline("tool-2", "failed"), timestamp: timestamp1 }, - { event: assistantTimeline("Second message"), timestamp: timestamp2 }, - ]; - - const state = hydrateStreamState(updates); - - const assistantMessages = state.filter((item) => item.kind === "assistant_message"); - - assert.strictEqual(assistantMessages.length, 2, "Assistant messages should remain distinct"); - assert.strictEqual(assistantMessages[0].text, "First message"); - assert.strictEqual(assistantMessages[1].text, "Second message"); -} - -// Test 4: Tool call raw input should survive completion updates -// Test 5: Completed tool calls without status should infer completion for hydrated state -function testToolCallStatusInference() { - const toolCallId = 'tool-completion'; - const timestamp1 = new Date('2025-01-01T10:10:00Z'); - const timestamp2 = new Date('2025-01-01T10:10:05Z'); - - const startEvent: AgentStreamEventPayload = { - type: 'timeline', - provider: 'claude', - item: { - type: 'tool_call', - name: 'read', - status: 'pending', - callId: toolCallId, - }, - }; - - const completionEvent: AgentStreamEventPayload = { - type: 'timeline', - provider: 'claude', - item: { - type: 'tool_call', - name: 'read', - callId: toolCallId, - output: { content: 'Hello world' }, - }, - }; - - const state = hydrateStreamState([ - { event: startEvent, timestamp: timestamp1 }, - { event: completionEvent, timestamp: timestamp2 }, - ]); - - const toolEntry = state.find( - (item): item is AgentToolCallItem => - isAgentToolCallItem(item) && item.payload.data.callId === toolCallId - ); - - assert.ok(toolEntry, "Tool entry should exist after hydration"); - assert.strictEqual(toolEntry.payload.data.status, 'completed'); - assert.strictEqual( - (toolEntry.payload.data.result as { content?: string }).content, - 'Hello world' - ); -} - -function testToolCallStatusInferenceFromRawOnly() { - const toolCallId = 'raw-status'; - const timestamp = new Date('2025-01-01T10:20:00Z'); - - const rawEvent: AgentStreamEventPayload = { - type: 'timeline', - provider: 'claude', - item: { - type: 'tool_call', - name: 'shell', - callId: toolCallId, - status: 'completed', - output: { metadata: { exit_code: 0 } }, - }, - }; - - const state = hydrateStreamState([{ event: rawEvent, timestamp }]); - const toolEntry = state.find(isAgentToolCallItem); - - assert.strictEqual(toolEntry?.payload.data.status, 'completed'); -} - -function testToolCallFailureInferenceFromError() { - const toolCallId = 'raw-error'; - const timestamp = new Date('2025-01-01T10:25:00Z'); - - const rawEvent: AgentStreamEventPayload = { - type: 'timeline', - provider: 'claude', - item: { - type: 'tool_call', - name: 'shell', - callId: toolCallId, - error: { message: 'Command failed' }, - }, - }; - - const state = hydrateStreamState([{ event: rawEvent, timestamp }]); - const toolEntry = state.find(isAgentToolCallItem); - expectAgentToolCallItem(toolEntry); - assert.strictEqual(toolEntry.payload.data.status, 'failed'); -} - -function testToolCallLateCallIdReconciliation() { - const timestampStart = new Date('2025-01-01T10:15:00Z'); - const timestampFinish = new Date('2025-01-01T10:15:05Z'); - - const updates = [ - { - event: toolTimeline('late-call', 'pending', { foo: 'bar' }, { callId: null }), - timestamp: timestampStart, - }, - { event: toolTimeline('late-call', 'completed'), timestamp: timestampFinish }, - ]; - - const state = hydrateStreamState(updates); - const toolCalls = state.filter(isAgentToolCallItem); - - assert.strictEqual(toolCalls.length, 1, 'late call ids should merge entries'); - assert.strictEqual(toolCalls[0].payload.data.status, 'completed'); - assert.strictEqual(toolCalls[0].payload.data.callId, 'late-call'); -} - -function testToolCallParsedPayloadHydration() { - const timestampStart = new Date('2025-01-01T10:35:00Z'); - const timestampFinish = new Date('2025-01-01T10:35:05Z'); - - const readCallId = 'parsed-read'; - const commandCallId = 'parsed-command'; - - const updates: Array<{ event: AgentStreamEventPayload; timestamp: Date }> = [ - { - event: { - type: 'timeline', - provider: 'claude', - item: { - type: 'tool_call', - name: 'read_file', - status: 'pending', - callId: readCallId, - input: { file_path: 'README.md' }, - }, - }, - timestamp: timestampStart, - }, - { - event: { - type: 'timeline', - provider: 'claude', - item: { - type: 'tool_call', - name: 'read_file', - callId: readCallId, - output: { content: 'Hello world' }, - }, - }, - timestamp: timestampFinish, - }, - { - event: { - type: 'timeline', - provider: 'claude', - item: { - type: 'tool_call', - name: 'shell', - status: 'pending', - callId: commandCallId, - input: { command: 'pwd' }, - }, - }, - timestamp: timestampStart, - }, - { - event: { - type: 'timeline', - provider: 'claude', - item: { - type: 'tool_call', - name: 'shell', - callId: commandCallId, - output: { - result: { - command: 'pwd', - output: '/Users/dev/paseo', - }, - metadata: { exit_code: 0 }, - }, - }, - }, - timestamp: timestampFinish, - }, - ]; - - const state = hydrateStreamState(updates); - const readEntry = state.find( - (item): item is AgentToolCallItem => - isAgentToolCallItem(item) && item.payload.data.callId === readCallId - ); - const commandEntry = state.find( - (item): item is AgentToolCallItem => - isAgentToolCallItem(item) && item.payload.data.callId === commandCallId - ); - - const readPass = Boolean( - readEntry?.payload.data.parsedReads && - readEntry.payload.data.parsedReads.length === 1 && - readEntry.payload.data.parsedReads[0]?.content.includes('Hello world') - ); - - const commandPass = Boolean( - commandEntry?.payload.data.parsedCommand && - commandEntry.payload.data.parsedCommand.command === 'pwd' && - commandEntry.payload.data.parsedCommand.output?.includes('/paseo') - ); - - assert.ok(readPass, 'Read payload should persist across hydration'); - assert.ok(commandPass, 'Command payload should persist across hydration'); -} - -function testNullToolPayloadDoesNotEraseKnownInput() { - const timestampStart = new Date("2025-01-01T10:36:00Z"); - const timestampFinish = new Date("2025-01-01T10:36:05Z"); - const callId = "null-input-preserve"; - - const updates: Array<{ event: AgentStreamEventPayload; timestamp: Date }> = [ - { - event: { - type: "timeline", - provider: "claude", - item: { - type: "tool_call", - name: "shell", - status: "pending", - callId, - input: { command: "pwd" }, - }, - }, - timestamp: timestampStart, - }, - { - event: { - type: "timeline", - provider: "claude", - item: { - type: "tool_call", - name: "shell", - status: "completed", - callId, - input: null, - output: { type: "command", output: "/tmp" }, - }, - }, - timestamp: timestampFinish, - }, - ]; - - const state = hydrateStreamState(updates); - const commandEntry = state.find( +function findToolByCallId(state: StreamItem[], callId: string): AgentToolCallItem | undefined { + return state.find( (item): item is AgentToolCallItem => isAgentToolCallItem(item) && item.payload.data.callId === callId ); - - assert.ok(commandEntry, "Tool call should exist"); - assert.strictEqual(commandEntry.payload.data.status, "completed"); - assert.deepStrictEqual(commandEntry.payload.data.input, { command: "pwd" }); } -function buildClaudeToolUseBlock({ - id, - name, - server, - input, -}: { - id: string; - name: string; - server: string; - input: Record; -}) { - return { - type: 'mcp_tool_use', - id, - name, - server, - input, - }; -} - -function buildClaudeToolResultBlock({ - toolUseId, - server, - toolName, - content, - isError, -}: { - toolUseId: string; - server: string; - toolName: string; - content: Array>; - isError?: boolean; -}) { - return { - type: 'mcp_tool_result', - tool_use_id: toolUseId, - server, - tool_name: toolName, - is_error: Boolean(isError), - content, - }; -} - -function testClaudeHydratedToolBodies() { - const timestampStart = new Date('2025-01-01T10:40:00Z'); - const timestampFinish = new Date('2025-01-01T10:40:05Z'); - - const editCallId = 'claude-edit-hydration'; - const readCallId = 'claude-read-hydration'; - const commandCallId = 'claude-command-hydration'; - - const updates: Array<{ event: AgentStreamEventPayload; timestamp: Date }> = [ - { - event: { - type: 'timeline', - provider: 'claude', - item: { - type: 'tool_call', - name: 'apply_patch', - status: 'pending', - callId: editCallId, - input: { - file_path: 'src/example.ts', - patch: '*** Begin Patch...', - }, - }, +describe("stream reducer canonical tool calls", () => { + it("is deterministic for equivalent hydration sequences", () => { + const updates = [ + { + event: assistantTimeline("Hello "), + timestamp: new Date("2025-01-01T10:00:00Z"), }, - timestamp: timestampStart, - }, - { - event: { - type: 'timeline', - provider: 'claude', - item: { - type: 'tool_call', - name: 'apply_patch', - callId: editCallId, - output: { - changes: [ - { - file_path: 'src/example.ts', - previous_content: 'export const answer = 41;\n', - content: 'export const answer = 42;\n', - }, - ], - }, - }, + { + event: assistantTimeline("world"), + timestamp: new Date("2025-01-01T10:00:01Z"), }, - timestamp: timestampFinish, - }, - { - event: { - type: 'timeline', - provider: 'claude', - item: { - type: 'tool_call', - name: 'read_file', - status: 'pending', - callId: readCallId, - input: { file_path: 'README.md' }, - }, + { + event: reasoningTimeline("Thinking..."), + timestamp: new Date("2025-01-01T10:00:02Z"), }, - timestamp: timestampStart, - }, - { - event: { - type: 'timeline', - provider: 'claude', - item: { - type: 'tool_call', - name: 'read_file', - callId: readCallId, - output: { content: '# Hydrated test file\nHello Claude!' }, - }, - }, - timestamp: timestampFinish, - }, - { - event: { - type: 'timeline', - provider: 'claude', - item: { - type: 'tool_call', - name: 'shell', - status: 'pending', - callId: commandCallId, - input: { command: 'ls' }, - }, - }, - timestamp: timestampStart, - }, - { - event: { - type: 'timeline', - provider: 'claude', - item: { - type: 'tool_call', - name: 'shell', - callId: commandCallId, - output: { - result: { - command: 'ls', - output: 'README.md\npackages\n', - }, - metadata: { exit_code: 0 }, - }, - }, - }, - timestamp: timestampFinish, - }, - ]; - - const state = hydrateStreamState(updates); - const editEntry = state.find( - (item): item is AgentToolCallItem => - isAgentToolCallItem(item) && item.payload.data.callId === editCallId - ); - const readEntry = state.find( - (item): item is AgentToolCallItem => - isAgentToolCallItem(item) && item.payload.data.callId === readCallId - ); - const commandEntry = state.find( - (item): item is AgentToolCallItem => - isAgentToolCallItem(item) && item.payload.data.callId === commandCallId - ); - - const editHasDiff = Boolean( - editEntry?.payload.data.parsedEdits && - editEntry.payload.data.parsedEdits.length > 0 && - editEntry.payload.data.parsedEdits[0]?.diffLines.length - ); - const readHasContent = Boolean( - readEntry?.payload.data.parsedReads && - readEntry.payload.data.parsedReads.length > 0 && - readEntry.payload.data.parsedReads[0]?.content.includes('Hydrated test file') - ); - const commandHasOutput = Boolean( - commandEntry?.payload.data.parsedCommand && - commandEntry.payload.data.parsedCommand.command === 'ls' && - commandEntry.payload.data.parsedCommand.output?.includes('README.md') - ); - - assert.ok(editHasDiff, 'Edit tool should expose parsed diff payloads'); - assert.ok(readHasContent, 'Read tool should expose hydrated file content'); - assert.ok(commandHasOutput, 'Command tool should expose hydrated stdout'); -} - -// Test 6: Assistant message chunks should preserve whitespace between words -function testAssistantWhitespacePreservation() { - const timestamp = new Date('2025-01-01T11:00:00Z'); - - const updates = [ - { event: assistantTimeline("Hello "), timestamp }, - { event: assistantTimeline("world"), timestamp }, - { event: assistantTimeline(" !"), timestamp }, - ]; - - const state = hydrateStreamState(updates); - const assistantMsg = state.find((item) => item.kind === "assistant_message"); - - assert.strictEqual(assistantMsg?.text, "Hello world !"); -} - -// Test 7: User messages should persist through hydration and deduplicate with live events -function testUserMessageHydration() { - const timestamp = new Date('2025-01-01T11:30:00Z'); - const messageId = 'msg_user_1'; - - const updates = [ - { event: userTimeline('Run npm test', messageId), timestamp }, - { event: assistantTimeline('On it!'), timestamp }, - ]; - - const hydrated = hydrateStreamState(updates); - const hydratedUser = hydrated.find((item) => item.kind === 'user_message'); - - assert.strictEqual(hydratedUser?.text, 'Run npm test'); - assert.strictEqual(hydratedUser?.id, messageId); - - const optimisticState: StreamItem[] = [ - { kind: 'user_message', id: messageId, text: 'Run npm test', timestamp }, - ]; - - const afterServerEvent = reduceStreamUpdate( - optimisticState, - userTimeline('Run npm test', messageId), - timestamp - ); - - assert.strictEqual(afterServerEvent.length, 1); - assert.strictEqual(afterServerEvent[0].kind, 'user_message'); -} - -function testHydratedUserMessagesPersist() { - (['claude', 'codex'] as const).forEach((provider) => { - const snapshotTimestamp = new Date('2025-01-01T12:15:00Z'); - const snapshot = [ - { event: userTimeline('Tell me more', undefined, provider), timestamp: snapshotTimestamp }, - { event: userTimeline('Tell me more', undefined, provider), timestamp: snapshotTimestamp }, - { event: assistantTimeline('Sure thing', provider), timestamp: snapshotTimestamp }, ]; - const hydrated = hydrateStreamState(snapshot); - const hydratedUsers = hydrated.filter((item) => item.kind === 'user_message'); + const first = hydrateStreamState(updates); + const second = hydrateStreamState(updates); - assert.strictEqual( - hydratedUsers.length, - 2, - `${provider} hydrated snapshots should retain duplicate-text user entries` - ); - - const replayed = reduceStreamUpdate( - hydrated, - assistantTimeline('Continuing after hydration', provider), - new Date('2025-01-01T12:16:00Z') - ); - const replayedUsers = replayed.filter((item) => item.kind === 'user_message'); - - assert.strictEqual( - replayedUsers.length, - 2, - `${provider} live updates should preserve hydrated user entries` - ); - }); -} - -// Test 8: Todo lists should consolidate into a single entry and update completions -function testTodoListConsolidation() { - const timestamp1 = new Date('2025-01-01T12:30:00Z'); - const timestamp2 = new Date('2025-01-01T12:31:00Z'); - - const firstPlan = [ - { text: 'Outline approach', completed: false }, - { text: 'Write code', completed: false }, - ]; - - const secondPlan = [ - { text: 'Outline approach', completed: true }, - { text: 'Write code', completed: false }, - ]; - - const updates = [ - { event: todoTimeline(firstPlan), timestamp: timestamp1 }, - { event: todoTimeline(secondPlan), timestamp: timestamp2 }, - ]; - - const state = hydrateStreamState(updates); - const todoEntries = state.filter( - (item): item is TodoListItem => item.kind === 'todo_list' - ); - - assert.strictEqual(todoEntries.length, 1); - assert.ok( - todoEntries[0].items.some( - (entry) => entry.text === 'Outline approach' && entry.completed - ), - 'Todo entries should consolidate into a single list' - ); -} - -function testTodoWriteToolCallCreatesTodoList() { - const timestamp = new Date("2025-01-01T12:32:00Z"); - const event = toolTimelineWithInput({ - provider: "claude", - name: "TodoWrite", - status: "completed", - input: { - todos: [ - { content: "First task", status: "pending" }, - { content: "Second task", status: "completed" }, - ], - }, + assert.strictEqual(JSON.stringify(first), JSON.stringify(second)); + const assistantMessage = first.find((item) => item.kind === "assistant_message"); + assert.strictEqual(assistantMessage?.text, "Hello world"); }); - const state = reduceStreamUpdate([], event, timestamp); - const todoEntries = state.filter( - (item): item is TodoListItem => item.kind === "todo_list" - ); - const toolCalls = state.filter((item) => item.kind === "tool_call"); + it("merges running and completed events by callId", () => { + const callId = "tool-merge-1"; + const updates = [ + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + name: "shell", + status: "running", + input: { command: "pwd" }, + }), + timestamp: new Date("2025-01-01T10:10:00Z"), + }, + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + name: "shell", + status: "completed", + input: null, + output: { output: "/tmp/repo\n", exitCode: 0 }, + }), + timestamp: new Date("2025-01-01T10:10:01Z"), + }, + ]; - assert.strictEqual(todoEntries.length, 1); - assert.strictEqual( - toolCalls.length, - 0, - "TodoWrite should render as a task list, not a tool call" - ); - assert.ok( - todoEntries[0].items.some( - (entry) => entry.text === "First task" && !entry.completed - ) - ); - assert.ok( - todoEntries[0].items.some( - (entry) => entry.text === "Second task" && entry.completed - ) - ); -} + const state = hydrateStreamState(updates); + const tools = state.filter(isAgentToolCallItem); -function testTodoWriteToolCallExecutingDoesNotRenderToolCall() { - const timestamp = new Date("2025-01-01T12:32:00Z"); - const event = toolTimelineWithInput({ - provider: "claude", - name: "TodoWrite", - status: "executing", - input: { - todos: [{ content: "Task", status: "pending" }], - }, + assert.strictEqual(tools.length, 1); + assert.strictEqual(tools[0].payload.data.status, "completed"); + assert.deepStrictEqual(tools[0].payload.data.input, { command: "pwd" }); + assert.deepStrictEqual(tools[0].payload.data.result, { + output: "/tmp/repo\n", + exitCode: 0, + }); }); - const state = reduceStreamUpdate([], event, timestamp); - const todoEntries = state.filter( - (item): item is TodoListItem => item.kind === "todo_list" - ); - const toolCalls = state.filter((item) => item.kind === "tool_call"); + it("exposes shell summary from running input before completion", () => { + const callId = "running-summary-shell"; + const state = hydrateStreamState([ + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + name: "shell", + status: "running", + input: { command: "npm test" }, + detail: { + type: "shell", + command: "npm test", + }, + }), + timestamp: new Date("2025-01-01T10:15:00Z"), + }, + ]); - assert.strictEqual(todoEntries.length, 1); - assert.strictEqual( - toolCalls.length, - 0, - "TodoWrite (executing) should not render as a tool call" - ); -} + const tool = findToolByCallId(state, callId); + assert.ok(tool); -function testTimelineIdStabilityAfterRemovals() { - const timestamp = new Date('2025-01-01T12:35:00Z'); - - // Assistant stream entries - let assistantState: StreamItem[] = []; - for (let i = 0; i < 4; i += 1) { - assistantState = reduceStreamUpdate( - assistantState, - userTimeline(`assistant-prefill-${i}`), - timestamp - ); - } - assistantState = reduceStreamUpdate( - assistantState, - assistantTimeline('Repeatable assistant text'), - timestamp - ); - assistantState = reduceStreamUpdate( - assistantState, - userTimeline('assistant-separator'), - timestamp - ); - assistantState = assistantState.filter( - (item) => - !( - item.kind === 'user_message' && - (item.text === 'assistant-prefill-0' || item.text === 'assistant-prefill-1') - ) - ); - assistantState = reduceStreamUpdate( - assistantState, - assistantTimeline('Repeatable assistant text'), - timestamp - ); - - const assistantIds = assistantState - .filter((item) => item.kind === 'assistant_message') - .map((item) => item.id); - const assistantUnique = new Set(assistantIds); - - assert.strictEqual( - assistantIds.length, - assistantUnique.size, - 'Assistant ids should stay unique after state shrink' - ); - - // Thought stream entries - let thoughtState: StreamItem[] = []; - for (let i = 0; i < 3; i += 1) { - thoughtState = reduceStreamUpdate( - thoughtState, - userTimeline(`thought-prefill-${i}`), - timestamp - ); - } - thoughtState = reduceStreamUpdate( - thoughtState, - reasoningTimeline('Repeatable reasoning text'), - timestamp - ); - thoughtState = reduceStreamUpdate( - thoughtState, - userTimeline('thought-separator'), - timestamp - ); - thoughtState = thoughtState.filter( - (item) => - !( - item.kind === 'user_message' && - (item.text === 'thought-prefill-0' || item.text === 'thought-prefill-1') - ) - ); - thoughtState = reduceStreamUpdate( - thoughtState, - reasoningTimeline('Repeatable reasoning text'), - timestamp - ); - - const thoughtIds = thoughtState - .filter((item) => item.kind === 'thought') - .map((item) => item.id); - const thoughtUnique = new Set(thoughtIds); - - assert.strictEqual( - thoughtIds.length, - thoughtUnique.size, - 'Thought ids should stay unique after state shrink' - ); -} - -type ToolCallProvider = 'claude' | 'codex'; - -function buildConcurrentToolCallUpdates(provider: ToolCallProvider) { - const timestamps = [ - new Date('2025-01-01T12:40:00Z'), - new Date('2025-01-01T12:40:05Z'), - new Date('2025-01-01T12:40:10Z'), - new Date('2025-01-01T12:40:15Z'), - ]; - - const baseOptions = { - provider, - name: 'shell', - } as const; - - return [ - { - event: toolTimeline( - 'shell', - 'executing', - { type: 'tool_use', provider, step: 'start-1' }, - { ...baseOptions, callId: null } - ), - timestamp: timestamps[0], - }, - { - event: toolTimeline( - 'shell', - 'executing', - { type: 'tool_use', provider, step: 'start-2' }, - { ...baseOptions, callId: null } - ), - timestamp: timestamps[1], - }, - { - event: toolTimeline( - 'shell', - 'completed', - { type: 'tool_result', provider, step: 'finish-1' }, - { ...baseOptions, callId: `${provider}-tool-1` } - ), - timestamp: timestamps[2], - }, - { - event: toolTimeline( - 'shell', - 'completed', - { type: 'tool_result', provider, step: 'finish-2' }, - { ...baseOptions, callId: `${provider}-tool-2` } - ), - timestamp: timestamps[3], - }, - ]; -} - -function validateToolCallDeduplication( - updates: Array<{ event: AgentStreamEventPayload; timestamp: Date }>, - mode: 'live' | 'hydrated' -): AgentToolCallItem[] { - const finalState = - mode === 'live' - ? updates.reduce((state, { event, timestamp }) => { - return reduceStreamUpdate(state, event, timestamp); - }, []) - : hydrateStreamState(updates); - - return finalState.filter(isAgentToolCallItem); -} - -function testToolCallDeduplicationLive() { - (['claude', 'codex'] as const).forEach((provider) => { - const updates = buildConcurrentToolCallUpdates(provider); - const toolCalls = validateToolCallDeduplication(updates, 'live'); - const callIds = toolCalls.map((entry) => entry.payload.data.callId).filter(Boolean); - const statuses = toolCalls.map((entry) => entry.payload.data.status); - - assert.strictEqual(toolCalls.length, 2, `${provider} live tool calls should dedupe`); - assert.ok( - callIds.includes(`${provider}-tool-1`) && callIds.includes(`${provider}-tool-2`), - `${provider} live stream should retain tool call identifiers` - ); - assert.ok( - statuses.every((status) => status === 'completed'), - `${provider} live stream should mark calls as completed` - ); + const summary = buildToolCallDisplayModel({ + name: tool.payload.data.name, + detail: tool.payload.data.detail, + }).summary; + assert.strictEqual(summary, "npm test"); }); -} -function testToolCallDeduplicationHydrated() { - (['claude', 'codex'] as const).forEach((provider) => { - const updates = buildConcurrentToolCallUpdates(provider); - const toolCalls = validateToolCallDeduplication(updates, 'hydrated'); - const callIds = toolCalls.map((entry) => entry.payload.data.callId).filter(Boolean); - const statuses = toolCalls.map((entry) => entry.payload.data.status); + it("exposes file path summary from running read input before completion", () => { + const callId = "running-summary-read"; + const state = hydrateStreamState([ + { + event: canonicalToolTimeline({ + provider: "codex", + callId, + name: "read_file", + status: "running", + input: { path: "/tmp/repo/README.md" }, + detail: { + type: "read", + filePath: "/tmp/repo/README.md", + }, + }), + timestamp: new Date("2025-01-01T10:16:00Z"), + }, + ]); - assert.strictEqual(toolCalls.length, 2, `${provider} hydration should dedupe tool calls`); - assert.ok( - callIds.includes(`${provider}-tool-1`) && callIds.includes(`${provider}-tool-2`), - `${provider} hydration should retain tool call identifiers` - ); - assert.ok( - statuses.every((status) => status === 'completed'), - `${provider} hydration should mark calls completed` - ); + const tool = findToolByCallId(state, callId); + assert.ok(tool); + + const summary = buildToolCallDisplayModel({ + name: tool.payload.data.name, + detail: tool.payload.data.detail, + cwd: "/tmp/repo", + }).summary; + assert.strictEqual(summary, "README.md"); }); -} -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, name: 'shell', callId } - ), - timestamp: timestamps[0], - }, - { - event: toolTimeline( - 'shell', - 'executing', - { type: 'tool_use', provider }, - { provider, name: 'shell', callId: null } - ), - timestamp: timestamps[1], - }, - ]; -} + it("preserves early input when later updates contain null input", () => { + const callId = "null-input-preserve"; + const updates = [ + { + event: canonicalToolTimeline({ + provider: "codex", + callId, + name: "read_file", + status: "running", + input: { path: "README.md" }, + }), + timestamp: new Date("2025-01-01T10:20:00Z"), + }, + { + event: canonicalToolTimeline({ + provider: "codex", + callId, + name: "read_file", + status: "completed", + input: null, + output: { content: "hello" }, + }), + timestamp: new Date("2025-01-01T10:20:01Z"), + }, + ]; -function testOutOfOrderToolCallMergingLive() { - (['claude', 'codex'] as const).forEach((provider) => { - const updates = buildOutOfOrderToolCallSequence(provider); - const finalState = updates.reduce((state, { event, timestamp }) => { - return reduceStreamUpdate(state, event, timestamp); - }, []); - const toolCalls = finalState.filter(isAgentToolCallItem); - 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`); + const state = hydrateStreamState(updates); + const tool = findToolByCallId(state, callId); + + assert.ok(tool); + assert.deepStrictEqual(tool.payload.data.input, { path: "README.md" }); + assert.strictEqual(tool.payload.data.status, "completed"); }); -} -function testOutOfOrderToolCallMergingHydrated() { - (['claude', 'codex'] as const).forEach((provider) => { - const updates = buildOutOfOrderToolCallSequence(provider); - const hydrated = hydrateStreamState(updates); - const toolCalls = hydrated.filter(isAgentToolCallItem); - 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`); + it("keeps terminal status when a stale running update arrives later", () => { + const callId = "out-of-order"; + const updates = [ + { + event: canonicalToolTimeline({ + provider: "codex", + callId, + name: "shell", + status: "completed", + input: { command: "ls" }, + output: { output: "README.md" }, + }), + timestamp: new Date("2025-01-01T10:30:00Z"), + }, + { + event: canonicalToolTimeline({ + provider: "codex", + callId, + name: "shell", + status: "running", + input: { command: "ls" }, + output: null, + }), + timestamp: new Date("2025-01-01T10:30:01Z"), + }, + ]; + + const state = hydrateStreamState(updates); + const tool = findToolByCallId(state, callId); + + assert.ok(tool); + assert.strictEqual(tool.payload.data.status, "completed"); }); -} -function buildMetadataReplaySequence(provider: ToolCallProvider) { - const timestamps = [ - new Date('2025-01-01T14:00:00Z'), - new Date('2025-01-01T14:00:02Z'), - new Date('2025-01-01T14:00:04Z'), - ]; - const firstCallId = `${provider}-metadata-1`; - const secondCallId = `${provider}-metadata-2`; - return [ - { - event: toolTimeline( - 'shell', - 'completed', - { type: 'tool_result', provider, tool_call_id: firstCallId }, - { provider, name: 'shell', callId: firstCallId } - ), - timestamp: timestamps[0], - }, - { - event: toolTimeline( - 'shell', - 'completed', - { type: 'tool_result', provider, tool_call_id: secondCallId }, - { provider, name: 'shell', callId: secondCallId } - ), - timestamp: timestamps[1], - }, - { - event: toolTimeline( - 'shell', - 'executing', - { type: 'tool_use', provider }, - { provider, name: 'shell', callId: null } - ), - timestamp: timestamps[2], - }, - ]; -} + it("does not duplicate tool pills during hydration replay", () => { + const callId = "replay-dedupe"; + const start = canonicalToolTimeline({ + provider: "claude", + callId, + name: "shell", + status: "running", + input: { command: "echo hi" }, + }); + const finish = canonicalToolTimeline({ + provider: "claude", + callId, + name: "shell", + status: "completed", + output: { output: "hi" }, + input: null, + }); -function validateMetadataReplayDeduplication( - updates: Array<{ event: AgentStreamEventPayload; timestamp: Date }>, - mode: 'live' | 'hydrated' -): AgentToolCallItem[] { - const finalState = - mode === 'live' - ? updates.reduce((state, { event, timestamp }) => { - return reduceStreamUpdate(state, event, timestamp); - }, []) - : hydrateStreamState(updates); + const updates = [ + { event: start, timestamp: new Date("2025-01-01T10:40:00Z") }, + { event: finish, timestamp: new Date("2025-01-01T10:40:01Z") }, + { event: start, timestamp: new Date("2025-01-01T10:40:02Z") }, + { event: finish, timestamp: new Date("2025-01-01T10:40:03Z") }, + ]; - return finalState.filter(isAgentToolCallItem); -} + const state = hydrateStreamState(updates); + const tools = state.filter(isAgentToolCallItem); -function testMetadataReplayDeduplicationLive() { - (['claude', 'codex'] as const).forEach((provider) => { - const toolCalls = validateMetadataReplayDeduplication( - buildMetadataReplaySequence(provider), - 'live' - ); - assert.strictEqual(toolCalls.length, 2, `${provider} live replay should not add duplicate tool pills`); - assert.strictEqual(toolCalls[0]?.payload.data.status, 'completed', `${provider} live replay should keep the original completion status`); - assert.strictEqual(toolCalls[1]?.payload.data.status, 'completed', `${provider} live replay should keep later completion intact`); + assert.strictEqual(tools.length, 1); + assert.strictEqual(tools[0].payload.data.callId, callId); + assert.strictEqual(tools[0].payload.data.status, "completed"); }); -} -function testMetadataReplayDeduplicationHydrated() { - (['claude', 'codex'] as const).forEach((provider) => { - const toolCalls = validateMetadataReplayDeduplication( - buildMetadataReplaySequence(provider), - 'hydrated' - ); - assert.strictEqual(toolCalls.length, 2, `${provider} hydration replay should not add duplicate tool pills`); - assert.strictEqual(toolCalls[0]?.payload.data.status, 'completed', `${provider} hydration replay should keep the original completion status`); - assert.strictEqual(toolCalls[1]?.payload.data.status, 'completed', `${provider} hydration replay should keep later completion intact`); + it("converts todo timeline updates to todo_list", () => { + const state = hydrateStreamState([ + { + event: todoTimeline([ + { text: "Outline", completed: false }, + { text: "Ship", completed: true }, + ]), + timestamp: new Date("2025-01-01T10:50:00Z"), + }, + ]); + + const todos = state.find((item): item is Extract => item.kind === "todo_list"); + + assert.ok(todos); + assert.strictEqual(todos.items.length, 2); + assert.strictEqual(todos.items[1]?.completed, true); }); -} -function testFallbackToolCallIdsStayUnique() { - const timestamp = new Date('2025-01-01T14:05:00Z'); - // Tool calls need different name to remain distinct when lacking callIds - const updates = [ - { - event: toolTimeline( - 'fallback-read', - 'completed', - undefined, - { callId: null, name: 'read_file' } - ), - timestamp, - }, - { - event: toolTimeline( - 'fallback-shell', - 'completed', - undefined, - { callId: null, name: 'shell' } - ), - timestamp, - }, - ]; + it("renders Claude TodoWrite as todo_list and suppresses tool call badge", () => { + const state = hydrateStreamState([ + { + event: canonicalToolTimeline({ + provider: "claude", + callId: "todo-write", + name: "TodoWrite", + status: "running", + input: { + todos: [ + { content: "Task 1", status: "pending" }, + { content: "Task 2", status: "completed" }, + ], + }, + }), + timestamp: new Date("2025-01-01T11:00:00Z"), + }, + ]); - const hydrated = hydrateStreamState(updates); - const toolCalls = hydrated.filter(isAgentToolCallItem); + const tools = state.filter(isAgentToolCallItem); + const todos = state.find((item): item is Extract => item.kind === "todo_list"); - assert.strictEqual(toolCalls.length, 2, 'Hydration should retain multiple fallback tool entries'); - const ids = toolCalls.map((item) => item.id); - assert.strictEqual( - new Set(ids).size, - ids.length, - 'Fallback-generated tool ids must be unique when name differs' - ); -} - -describe('stream timeline reducers', () => { - it('produces deterministic hydration results', testIdempotentReduction); - it('deduplicates pending/completed tool entries in place', testUserMessageDeduplication); - it('preserves distinct assistant messages', testMultipleMessages); - it('infers completion from tool result payloads', testToolCallStatusInference); - it('infers completion from output metadata', testToolCallStatusInferenceFromRawOnly); - it('infers failure from error payloads', testToolCallFailureInferenceFromError); - it('reconciles late call IDs against pending entries', testToolCallLateCallIdReconciliation); - it('persists parsed read/edit/command payloads after hydration', testToolCallParsedPayloadHydration); - it('preserves known input when later tool updates send null input', testNullToolPayloadDoesNotEraseKnownInput); - it('hydrates Claude tool bodies with parsed content', testClaudeHydratedToolBodies); - it('preserves whitespace in assistant chunk concatenation', testAssistantWhitespacePreservation); - it('hydrates user messages and deduplicates optimistic/live entries', testUserMessageHydration); - it('retains hydrated user messages across providers', testHydratedUserMessagesPersist); - it('consolidates todo list updates', testTodoListConsolidation); - it('renders TodoWrite as a task list', testTodoWriteToolCallCreatesTodoList); - it( - "does not render TodoWrite (executing) as a tool call", - testTodoWriteToolCallExecutingDoesNotRenderToolCall - ); - 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); - it('replays metadata-only tool calls without duplicating entries (live)', testMetadataReplayDeduplicationLive); - it('replays metadata-only tool calls without duplicating entries (hydrated)', testMetadataReplayDeduplicationHydrated); - it('assigns unique ids for fallback tool calls without call ids', testFallbackToolCallIdsStayUnique); + assert.strictEqual(tools.length, 0); + assert.ok(todos); + assert.strictEqual(todos.items[0]?.text, "Task 1"); + }); }); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index dd6752268..396217c8c 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -1,13 +1,10 @@ -import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; +import type { + AgentProvider, + ToolCallDetail, +} from "@server/server/agent/agent-sdk-types"; import type { AgentStreamEventPayload } from "@server/shared/messages"; import { - extractCommandDetails, - extractEditEntries, - extractReadEntries, extractTaskEntriesFromToolCall, - type CommandDetails, - type EditEntry, - type ReadEntry, } from "../utils/tool-call-parsers"; /** @@ -86,7 +83,8 @@ export interface ThoughtItem { status: ThoughtStatus; } -export type ToolCallStatus = "executing" | "completed" | "failed"; +export type OrchestratorToolCallStatus = "executing" | "completed" | "failed"; +export type AgentToolCallStatus = "running" | "completed" | "failed" | "canceled"; interface OrchestratorToolCallData { toolCallId: string; @@ -94,20 +92,18 @@ interface OrchestratorToolCallData { arguments: unknown; result?: unknown; error?: unknown; - status: ToolCallStatus; + status: OrchestratorToolCallStatus; } export interface AgentToolCallData { provider: AgentProvider; + callId: string; name: string; - status?: ToolCallStatus; - callId?: string; - input?: unknown; - result?: unknown; - error?: unknown; - parsedEdits?: EditEntry[]; - parsedReads?: ReadEntry[]; - parsedCommand?: CommandDetails | null; + status: AgentToolCallStatus; + input: unknown | null; + result: unknown | null; + error: unknown | null; + detail?: ToolCallDetail; metadata?: Record; } @@ -173,14 +169,6 @@ function normalizeChunk(text: string): { chunk: string; hasContent: boolean } { return { chunk, hasContent: /\S/.test(chunk) }; } -function coerceString(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length ? trimmed : null; -} - function appendUserMessage( state: StreamItem[], text: string, @@ -310,97 +298,59 @@ function finalizeActiveThoughts(state: StreamItem[]): StreamItem[] { return mutated ? nextState : state; } -function mergeToolCallRaw(existingRaw: unknown, nextRaw: unknown): unknown { - if (existingRaw === undefined || existingRaw === null) { - return nextRaw; - } - if (nextRaw === undefined || nextRaw === null) { - return existingRaw; - } - if (Array.isArray(existingRaw)) { - return [...existingRaw, nextRaw]; - } - return [existingRaw, nextRaw]; -} - -function computeParsedToolPayload(result: unknown): { - parsedEdits?: EditEntry[]; - parsedReads?: ReadEntry[]; - parsedCommand?: CommandDetails | null; -} { - const edits = extractEditEntries(result); - const reads = extractReadEntries(result); - const command = extractCommandDetails(result); - - return { - parsedEdits: edits.length > 0 ? edits : undefined, - parsedReads: reads.length > 0 ? reads : undefined, - parsedCommand: command ?? undefined, - }; -} - -function normalizeComparableString(value?: string | null): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim().toLowerCase(); - return trimmed.length ? trimmed : null; -} - function findExistingAgentToolCallIndex( state: StreamItem[], - callId: string | null, - data: AgentToolCallData + callId: string ): number { - const normalizedCallId = normalizeComparableString(callId); - if (normalizedCallId) { - const existingIndex = state.findIndex( - (entry) => - entry.kind === "tool_call" && - entry.payload.source === "agent" && - normalizeComparableString(entry.payload.data.callId) === - normalizedCallId - ); - if (existingIndex >= 0) { - return existingIndex; - } + return state.findIndex( + (entry) => + entry.kind === "tool_call" && + entry.payload.source === "agent" && + entry.payload.data.callId === callId + ); +} + +function hasNonEmptyObject(value: unknown): boolean { + return Boolean( + value && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value as Record).length > 0 + ); +} + +function mergeCanonicalValue( + existing: unknown | null, + incoming: unknown | null +): unknown | null { + if (incoming === null) { + return existing; } - const fallbackCandidates: Array<{ index: number; item: AgentToolCallItem }> = - []; - const metadataMatches: Array<{ index: number; item: AgentToolCallItem }> = []; - for (let i = 0; i < state.length; i += 1) { - const entry = state[i]; - if (entry.kind !== "tool_call" || entry.payload.source !== "agent") { - continue; - } - const payload = entry.payload.data; - const providerMatches = - payload.provider === data.provider && payload.name === data.name; - if (providerMatches) { - metadataMatches.push({ index: i, item: entry as AgentToolCallItem }); - } - if (payload.callId) { - continue; - } - if (payload.status !== "executing") { - continue; - } - if (providerMatches) { - fallbackCandidates.push({ index: i, item: entry as AgentToolCallItem }); - } + if (!hasNonEmptyObject(incoming) && hasNonEmptyObject(existing)) { + return existing; } - if (fallbackCandidates.length) { - return fallbackCandidates[0]?.index ?? -1; - } + return incoming; +} - // If this update still lacks a call id, fall back to metadata matches (e.g. replayed hydration events) - if (!normalizedCallId && metadataMatches.length) { - return metadataMatches[0]?.index ?? -1; +function mergeAgentToolCallStatus( + existing: AgentToolCallStatus, + incoming: AgentToolCallStatus +): AgentToolCallStatus { + if (existing === "failed" || incoming === "failed") { + return "failed"; } - - return -1; + if (existing === "canceled") { + return "canceled"; + } + if (incoming === "canceled") { + return existing === "completed" ? "completed" : "canceled"; + } + if (existing === "completed" || incoming === "completed") { + return "completed"; + } + return "running"; } function appendAgentToolCall( @@ -408,49 +358,26 @@ function appendAgentToolCall( data: AgentToolCallData, timestamp: Date ): StreamItem[] { - const normalizedStatus = normalizeToolCallStatus( - data.status, - data.result, - data.error - ); - const callId = data.callId; - - const payloadData: AgentToolCallData = { - ...data, - status: normalizedStatus, - callId: callId ?? data.callId, - }; - - const existingIndex = findExistingAgentToolCallIndex( - state, - callId ?? null, - payloadData - ); + const existingIndex = findExistingAgentToolCallIndex(state, data.callId); if (existingIndex >= 0) { const next = [...state]; const existing = next[existingIndex] as AgentToolCallItem; - const mergedInput = - hasValue(payloadData.input) - ? payloadData.input - : existing.payload.data.input; - const mergedResult = - hasValue(payloadData.result) - ? payloadData.result - : existing.payload.data.result; - const mergedError = - hasValue(payloadData.error) - ? payloadData.error - : existing.payload.data.error; - const mergedStatus = mergeToolCallStatus( + const mergedInput = mergeCanonicalValue(existing.payload.data.input, data.input); + const mergedResult = mergeCanonicalValue(existing.payload.data.result, data.result); + const mergedStatus = mergeAgentToolCallStatus( existing.payload.data.status, - payloadData.status ?? existing.payload.data.status ?? "executing" + data.status ); + const mergedError = + mergedStatus === "failed" + ? data.error ?? existing.payload.data.error ?? { message: "Tool call failed" } + : null; const mergedMetadata = - payloadData.metadata || existing.payload.data.metadata - ? { ...existing.payload.data.metadata, ...payloadData.metadata } + data.metadata || existing.payload.data.metadata + ? { ...existing.payload.data.metadata, ...data.metadata } : undefined; - const parsed = computeParsedToolPayload(mergedResult); + next[existingIndex] = { ...existing, timestamp, @@ -458,41 +385,28 @@ function appendAgentToolCall( source: "agent", data: { ...existing.payload.data, - ...payloadData, + ...data, status: mergedStatus, input: mergedInput, result: mergedResult, error: mergedError, + detail: data.detail ?? existing.payload.data.detail, metadata: mergedMetadata, - callId: payloadData.callId ?? existing.payload.data.callId, - parsedEdits: parsed.parsedEdits ?? existing.payload.data.parsedEdits, - parsedReads: parsed.parsedReads ?? existing.payload.data.parsedReads, - parsedCommand: - parsed.parsedCommand ?? existing.payload.data.parsedCommand, }, }, }; return next; } - const id = callId - ? `agent_tool_${callId}` - : createUniqueTimelineId( - state, - "tool", - `${data.provider}:${data.name}`, - timestamp - ); - const item: ToolCallItem = { kind: "tool_call", - id, + id: `agent_tool_${data.callId}`, timestamp, payload: { source: "agent", data: { - ...payloadData, - ...computeParsedToolPayload(payloadData.result), + ...data, + error: data.status === "failed" ? data.error : null, }, }, }; @@ -500,195 +414,6 @@ function appendAgentToolCall( return [...state, item]; } -const FAILED_STATUS_PATTERN = - /fail|error|deny|reject|cancel|abort|exception|refus/; -const COMPLETED_STATUS_PATTERN = - /complete|success|granted|applied|done|resolved|finish|succeed|ok/; - -function normalizeStatusString( - status?: string | null -): "executing" | "completed" | "failed" | null { - if (!status) { - return null; - } - const normalized = status.trim().toLowerCase(); - if (!normalized) { - return null; - } - if (FAILED_STATUS_PATTERN.test(normalized)) { - return "failed"; - } - if (COMPLETED_STATUS_PATTERN.test(normalized)) { - return "completed"; - } - return "executing"; -} - -function hasValue(value: unknown): boolean { - return value !== undefined && value !== null; -} - -function inferStatusFromRaw(raw: unknown): "completed" | "failed" | null { - if (!hasValue(raw)) { - return null; - } - - const queue: unknown[] = Array.isArray(raw) ? [...raw] : [raw]; - const visited = new Set(); - - while (queue.length > 0) { - const candidate = queue.shift(); - if (!candidate || typeof candidate !== "object") { - continue; - } - if (visited.has(candidate as object)) { - continue; - } - visited.add(candidate as object); - const record = candidate as Record; - - if (record.is_error === true) { - return "failed"; - } - - const statusValue = normalizeStatusString( - typeof record.status === "string" ? record.status : undefined - ); - if (statusValue === "failed") { - return "failed"; - } - if (statusValue === "completed") { - return "completed"; - } - - if ("error" in record && hasValue(record.error)) { - return "failed"; - } - - if (typeof record.stderr === "string" && record.stderr.length > 0) { - return "failed"; - } - - const typeValue = - typeof record.type === "string" ? record.type.toLowerCase() : ""; - if (typeValue) { - if (FAILED_STATUS_PATTERN.test(typeValue)) { - return "failed"; - } - if (/result|response|output|success/.test(typeValue)) { - return "completed"; - } - } - - const exitCode = - typeof record.exitCode === "number" - ? record.exitCode - : typeof record.exit_code === "number" - ? record.exit_code - : null; - if (exitCode !== null) { - return exitCode === 0 ? "completed" : "failed"; - } - - const successValue = - typeof record.success === "boolean" ? record.success : null; - if (successValue !== null) { - return successValue ? "completed" : "failed"; - } - - for (const value of Object.values(record)) { - if (typeof value === "object" && value !== null) { - queue.push(value); - } - } - } - - return null; -} - -function normalizeToolCallStatus( - status?: string, - result?: unknown, - error?: unknown -): ToolCallStatus { - const normalizedFromStatus = normalizeStatusString(status); - if (normalizedFromStatus === "failed") { - return "failed"; - } - if (normalizedFromStatus === "completed") { - return "completed"; - } - - if (hasValue(error)) { - return "failed"; - } - if (hasValue(result)) { - return "completed"; - } - - return normalizedFromStatus ?? "executing"; -} - -function mergeToolCallStatus( - existing: ToolCallStatus | undefined, - incoming: ToolCallStatus -): ToolCallStatus { - if (existing === "failed" || incoming === "failed") { - return "failed"; - } - if (existing === "completed" || incoming === "completed") { - return "completed"; - } - return incoming ?? existing ?? "executing"; -} - -const TOOL_CALL_ID_KEYS = [ - "toolCallId", - "tool_call_id", - "callId", - "call_id", - "tool_use_id", - "toolUseId", -]; - -function extractToolCallId(raw: unknown, depth = 0): string | null { - if (!raw || depth > 4) { - return null; - } - if (typeof raw === "string" || typeof raw === "number") { - return null; - } - if (Array.isArray(raw)) { - for (const entry of raw) { - const nested = extractToolCallId(entry, depth + 1); - if (nested) { - return nested; - } - } - return null; - } - if (typeof raw === "object") { - const record = raw as Record; - for (const key of TOOL_CALL_ID_KEYS) { - const value = record[key]; - if (typeof value === "string" && value.length > 0) { - return value; - } - } - const idValue = record.id; - if (typeof idValue === "string" && /tool|call/i.test(idValue)) { - return idValue; - } - for (const value of Object.values(record)) { - const nested = extractToolCallId(value, depth + 1); - if (nested) { - return nested; - } - } - } - return null; -} - function appendActivityLog( state: StreamItem[], entry: ActivityLogItem @@ -826,12 +551,13 @@ export function reduceStreamUpdate( state, { provider: event.provider, - name: item.name, - status: normalizeStatusString(item.status) ?? "executing", callId: item.callId, + name: item.name, + status: item.status, input: item.input, result: item.output, error: item.error, + detail: item.detail, metadata: item.metadata, }, timestamp diff --git a/packages/app/src/utils/tool-call-display.test.ts b/packages/app/src/utils/tool-call-display.test.ts new file mode 100644 index 000000000..3b1299d2d --- /dev/null +++ b/packages/app/src/utils/tool-call-display.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { buildToolCallDisplayModel, formatToolCallError } from "./tool-call-display"; + +describe("tool-call-display", () => { + it("builds display model from canonical shell detail", () => { + const display = buildToolCallDisplayModel({ + name: "shell", + detail: { + type: "shell", + command: "npm test", + }, + }); + + expect(display).toEqual({ + kind: "execute", + displayName: "Shell", + summary: "npm test", + }); + }); + + it("builds display model from canonical read detail", () => { + const display = buildToolCallDisplayModel({ + name: "read_file", + detail: { + type: "read", + filePath: "/tmp/repo/src/index.ts", + }, + cwd: "/tmp/repo", + }); + + expect(display).toEqual({ + kind: "read", + displayName: "Read", + summary: "src/index.ts", + }); + }); + + it("uses metadata summary for task tool calls", () => { + const display = buildToolCallDisplayModel({ + name: "task", + metadata: { + subAgentActivity: "Running tests", + }, + }); + + expect(display).toEqual({ + kind: "agent", + displayName: "Task", + summary: "Running tests", + }); + }); + + it("falls back to humanized tool name for unknown tools", () => { + const display = buildToolCallDisplayModel({ + name: "custom_tool_name", + }); + + expect(display).toEqual({ + kind: "tool", + displayName: "Custom Tool Name", + }); + }); + + it("formats non-string errors", () => { + expect(formatToolCallError({ message: "boom" })).toBe('{\n "message": "boom"\n}'); + }); +}); diff --git a/packages/app/src/utils/tool-call-display.ts b/packages/app/src/utils/tool-call-display.ts new file mode 100644 index 000000000..93a3cce93 --- /dev/null +++ b/packages/app/src/utils/tool-call-display.ts @@ -0,0 +1,211 @@ +import { z } from "zod"; + +import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; + +export type ToolCallKind = + | "read" + | "edit" + | "write" + | "execute" + | "search" + | "agent" + | "tool" + | "thinking"; + +type SummaryParams = { + name: string; + detail?: ToolCallDetail; + metadata?: Record; + cwd?: string; +}; + +export type ToolCallDisplayModel = { + kind: ToolCallKind; + displayName: string; + summary?: string; +}; + +const TOOL_CALL_DETAIL_SCHEMA: z.ZodType = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("shell"), + command: z.string(), + cwd: z.string().optional(), + output: z.string().optional(), + exitCode: z.number().nullable().optional(), + }), + z.object({ + type: z.literal("read"), + filePath: z.string(), + content: z.string().optional(), + offset: z.number().optional(), + limit: z.number().optional(), + }), + z.object({ + type: z.literal("edit"), + filePath: z.string(), + oldString: z.string().optional(), + newString: z.string().optional(), + unifiedDiff: z.string().optional(), + }), + z.object({ + type: z.literal("write"), + filePath: z.string(), + content: z.string().optional(), + }), + z.object({ + type: z.literal("search"), + query: z.string(), + }), +]); + +const TOOL_CALL_DISPLAY_INPUT_SCHEMA = z.object({ + name: z.string().min(1), + detail: TOOL_CALL_DETAIL_SCHEMA.optional(), + metadata: z.record(z.unknown()).optional(), + cwd: z.string().optional(), +}); + +function toTitleCase(words: string): string { + return words + .split(" ") + .filter((segment) => segment.length > 0) + .map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`) + .join(" "); +} + +function humanizeToolName(name: string): string { + const trimmed = name.trim(); + if (!trimmed) { + return name; + } + return toTitleCase(trimmed.replace(/[._-]+/g, " ")); +} + +export function stripCwdPrefix(filePath: string, cwd?: string): string { + if (!cwd || !filePath) return filePath; + + const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalizedPath = filePath.replace(/\\/g, "/"); + + const prefix = `${normalizedCwd}/`; + if (normalizedPath.startsWith(prefix)) { + return normalizedPath.slice(prefix.length); + } + if (normalizedPath === normalizedCwd) { + return "."; + } + return filePath; +} + +function buildDisplayFromDetail(detail: ToolCallDetail, cwd?: string): ToolCallDisplayModel { + switch (detail.type) { + case "shell": + return { + kind: "execute", + displayName: "Shell", + summary: detail.command, + }; + case "read": + return { + kind: "read", + displayName: "Read", + summary: stripCwdPrefix(detail.filePath, cwd), + }; + case "edit": + return { + kind: "edit", + displayName: "Edit", + summary: stripCwdPrefix(detail.filePath, cwd), + }; + case "write": + return { + kind: "write", + displayName: "Write", + summary: stripCwdPrefix(detail.filePath, cwd), + }; + case "search": + return { + kind: "search", + displayName: "Search", + summary: detail.query, + }; + default: + return { + kind: "tool", + displayName: "Tool", + }; + } +} + +function buildDisplayWithoutDetail(params: { + toolNameLower: string; + rawName: string; + metadata?: Record; +}): ToolCallDisplayModel { + if (params.toolNameLower === "task") { + const summary = params.metadata?.subAgentActivity; + return { + kind: "agent", + displayName: "Task", + summary: typeof summary === "string" && summary.trim().length > 0 ? summary : undefined, + }; + } + + if (params.toolNameLower === "thinking") { + return { + kind: "thinking", + displayName: "Thinking", + }; + } + + return { + kind: "tool", + displayName: humanizeToolName(params.rawName), + }; +} + +export function buildToolCallDisplayModel(params: SummaryParams): ToolCallDisplayModel { + const parsed = TOOL_CALL_DISPLAY_INPUT_SCHEMA.parse(params); + if (parsed.detail) { + return buildDisplayFromDetail(parsed.detail, parsed.cwd); + } + + return buildDisplayWithoutDetail({ + toolNameLower: parsed.name.trim().toLowerCase(), + rawName: parsed.name, + metadata: parsed.metadata, + }); +} + +export function resolveToolCallDisplayName(name: string, detail?: ToolCallDetail): string { + return buildToolCallDisplayModel({ name, detail }).displayName; +} + +export function resolveToolCallKind(name: string, detail?: ToolCallDetail): ToolCallKind { + return buildToolCallDisplayModel({ name, detail }).kind; +} + +export function resolveToolCallSummary(params: SummaryParams): string | undefined { + return buildToolCallDisplayModel(params).summary; +} + +export function formatToolCallError(error: unknown): string | undefined { + if (error === null || error === undefined) { + return undefined; + } + if (typeof error === "string") { + return error; + } + if ( + typeof error === "object" && + "content" in (error as Record) && + typeof (error as Record).content === "string" + ) { + return (error as Record).content as string; + } + try { + return JSON.stringify(error, null, 2); + } catch { + return String(error); + } +} diff --git a/packages/app/src/utils/tool-call-parsers.test.ts b/packages/app/src/utils/tool-call-parsers.test.ts index a54143e89..c02f957a0 100644 --- a/packages/app/src/utils/tool-call-parsers.test.ts +++ b/packages/app/src/utils/tool-call-parsers.test.ts @@ -1,428 +1,35 @@ -import { describe, test, expect } from "vitest"; +import { describe, expect, it } from "vitest"; + import { - extractKeyValuePairs, - parseToolCallDisplay, - type ToolCallDisplayInfo, + buildLineDiff, + parseUnifiedDiff, + extractTaskEntriesFromToolCall, } from "./tool-call-parsers"; -describe("tool-call-parsers - real runtime shapes", () => { - // Real data captured from Claude agent test: "shows the command inside pending tool calls" - // Run: npx vitest run claude-agent.test.ts -t "shows the command" +describe("tool-call-parsers", () => { + it("builds line diff for text changes", () => { + const diff = buildLineDiff("old\nline\n", "new\nline\n"); - test("bash tool call - input shape", () => { - // REAL shape from Claude SDK timeline event (status: pending/completed) - const bashInput = { - command: "pwd", - description: "Print working directory", - }; - - const pairs = extractKeyValuePairs(bashInput); - expect(pairs).toContainEqual({ key: "command", value: "pwd" }); - expect(pairs).toContainEqual({ key: "description", value: "Print working directory" }); + expect(diff.some((entry) => entry.type === "remove")).toBe(true); + expect(diff.some((entry) => entry.type === "add")).toBe(true); }); - test("bash tool call - output shape (completed)", () => { - // REAL shape from Claude SDK timeline event (status: completed) - // NOTE: output already has type: "command" discriminator! - const bashOutput = { - type: "command", - command: "pwd", - output: "/private/var/folders/xl/kkk9drfd3ms_t8x7rmy4z6900000gn/T/claude-agent-e2e-9tnmUm", - }; + it("parses unified diff", () => { + const parsed = parseUnifiedDiff("@@\n-old\n+new\n"); - const pairs = extractKeyValuePairs(bashOutput); - expect(pairs).toContainEqual({ key: "type", value: "command" }); - expect(pairs).toContainEqual({ key: "command", value: "pwd" }); - expect(pairs).toContainEqual({ key: "output", value: expect.stringContaining("claude-agent") }); - }); -}); - -describe("parseToolCallDisplay", () => { - test("parses completed bash tool call into shell detail", () => { - const input = { command: "pwd", description: "Print working directory" }; - const output = { type: "command", command: "pwd", output: "/some/path" }; - - const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Bash", input, output }); - expect(info.detail.type).toBe("shell"); - expect(info.displayName).toBe("Shell"); - if (info.detail.type === "shell") { - expect(info.detail.command).toBe("pwd"); - expect(info.detail.output).toBe("/some/path"); - } + expect(parsed.find((entry) => entry.type === "remove")?.content).toBe("-old"); + expect(parsed.find((entry) => entry.type === "add")?.content).toBe("+new"); }); - test("parses pending bash tool call into shell detail with empty output", () => { - // When tool is pending, we have input but no result yet - const input = { command: "pwd", description: "Print working directory" }; - - const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Bash", input }); - expect(info.detail.type).toBe("shell"); - expect(info.displayName).toBe("Shell"); - if (info.detail.type === "shell") { - expect(info.detail.command).toBe("pwd"); - expect(info.detail.output).toBe(""); - } - }); - - test("falls back to output command when shell input is missing", () => { - const output = { type: "command", command: "pwd", output: "/some/path" }; - - const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Bash", output }); - expect(info.summary).toBe("pwd"); - expect(info.detail.type).toBe("shell"); - if (info.detail.type === "shell") { - expect(info.detail.command).toBe("pwd"); - expect(info.detail.output).toBe("/some/path"); - } - }); - - test("falls back to read output path when input is missing", () => { - const info: ToolCallDisplayInfo = parseToolCallDisplay({ - name: "Read", - output: { - type: "file_read", - filePath: "/some/file.txt", - content: "hello", - }, + it("extracts TodoWrite task entries", () => { + const tasks = extractTaskEntriesFromToolCall("TodoWrite", { + todos: [ + { content: "Task 1", status: "pending" }, + { content: "Task 2", status: "completed" }, + ], }); - expect(info.summary).toBe("/some/file.txt"); - expect(info.detail.type).toBe("read"); - if (info.detail.type === "read") { - expect(info.detail.filePath).toBe("/some/file.txt"); - expect(info.detail.content).toBe("hello"); - } - }); - test("strips shell + cd wrapper from command (Codex exec_command style)", () => { - const input = { - command: - '/bin/zsh -lc "cd /Users/me/dev/paseo && nl -ba packages/app/src/utils/tool-call-parsers.test.ts | sed -n \'150,260p\'"', - }; - - const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "shell", input }); - expect(info.detail.type).toBe("shell"); - if (info.detail.type === "shell") { - expect(info.detail.command).toBe( - "nl -ba packages/app/src/utils/tool-call-parsers.test.ts | sed -n '150,260p'" - ); - } - }); - - test("handles command as array", () => { - const input = { command: ["git", "status"] }; - const output = { type: "command", output: "On branch main" }; - - const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "shell", input, output }); - expect(info.detail.type).toBe("shell"); - expect(info.displayName).toBe("Shell"); - if (info.detail.type === "shell") { - expect(info.detail.command).toBe("git status"); - expect(info.detail.output).toBe("On branch main"); - } - }); - - test("normalizes tool names - shell to Shell", () => { - const input = { command: "pwd" }; - const info = parseToolCallDisplay({ name: "shell", input }); - expect(info.displayName).toBe("Shell"); - }); - - test("normalizes tool names - Bash to Shell", () => { - const input = { command: "pwd" }; - const info = parseToolCallDisplay({ name: "Bash", input }); - expect(info.displayName).toBe("Shell"); - }); - - test("normalizes tool names - read_file to Read", () => { - const input = { file_path: "/some/file.txt" }; - const info = parseToolCallDisplay({ name: "read_file", input }); - expect(info.displayName).toBe("Read"); - }); - - test("uses stable label for Edit in frontend", () => { - const input = { file_path: "/some/file.txt", old_string: "a", new_string: "b" }; - const first = parseToolCallDisplay({ name: "Edit", input }); - const second = parseToolCallDisplay({ name: "Edit", input }); - expect(first.displayName).toBe("Edit"); - expect(second.displayName).toBe("Edit"); - }); - - test("normalizes tool names - paseo_voice.speak to Speak", () => { - const input = { text: "hello from namespaced speak" }; - const info = parseToolCallDisplay({ name: "paseo_voice.speak", input }); - expect(info.displayName).toBe("Speak"); - }); - - test("normalizes tool names - mcp__paseo_voice__speak to Speak", () => { - const input = { text: "hello from claude mcp speak" }; - const info = parseToolCallDisplay({ name: "mcp__paseo_voice__speak", input }); - expect(info.displayName).toBe("Speak"); - }); - - test("preserves unknown tool names", () => { - const input = { some_arg: "value" }; - const info = parseToolCallDisplay({ name: "MyCustomTool", input }); - expect(info.displayName).toBe("MyCustomTool"); - }); - - test("does not let Task metadata override non-Task summary", () => { - const info = parseToolCallDisplay({ - name: "shell", - input: { command: "pwd" }, - metadata: { subAgentActivity: "Read" }, - }); - expect(info.summary).toBe("pwd"); - }); - - test("parses non-command tool call into generic detail", () => { - const input = { file_path: "/some/file.txt" }; - const output = { content: "file contents here", lineCount: 42 }; - - const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "SomeTool", input, output }); - expect(info.detail.type).toBe("generic"); - if (info.detail.type === "generic") { - expect(info.detail.input).toContainEqual({ key: "file_path", value: "/some/file.txt" }); - expect(info.detail.output).toContainEqual({ key: "content", value: "file contents here" }); - expect(info.detail.output).toContainEqual({ key: "lineCount", value: "42" }); - } - }); - - test("handles file_write output as edit", () => { - const input = { file_path: "/some/file.txt", content: "new content" }; - const output = { type: "file_write", filePath: "/some/file.txt" }; - - const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Write", input, output }); - expect(info.detail.type).toBe("edit"); - expect(info.summary).toBe("/some/file.txt"); - if (info.detail.type === "edit") { - expect(info.detail.filePath).toBe("/some/file.txt"); - } - }); - - test("handles undefined input and output gracefully", () => { - const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "unknown" }); - expect(info.detail.type).toBe("generic"); - if (info.detail.type === "generic") { - expect(info.detail.input).toEqual([]); - expect(info.detail.output).toEqual([]); - } - }); - - test("parses edit tool call into edit detail with old_string/new_string", () => { - const input = { - file_path: "/some/file.txt", - old_string: "const foo = 1;", - new_string: "const foo = 2;", - }; - const output = { - type: "file_edit", - filePath: "/some/file.txt", - }; - - const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Edit", input, output }); - expect(info.detail.type).toBe("edit"); - if (info.detail.type === "edit") { - expect(info.detail.filePath).toBe("/some/file.txt"); - expect(info.detail.oldString).toBe("const foo = 1;"); - expect(info.detail.newString).toBe("const foo = 2;"); - } - }); - - test("parses edit tool call with old_str/new_str variants", () => { - const input = { - file_path: "/some/file.txt", - old_str: "line 1", - new_str: "line 2", - }; - - const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Edit", input }); - expect(info.detail.type).toBe("edit"); - if (info.detail.type === "edit") { - expect(info.detail.filePath).toBe("/some/file.txt"); - expect(info.detail.oldString).toBe("line 1"); - expect(info.detail.newString).toBe("line 2"); - } - }); - - test("parses pending edit tool call (no result yet)", () => { - const input = { - file_path: "/some/file.txt", - old_string: "old content", - new_string: "new content", - }; - - const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Edit", input }); - expect(info.detail.type).toBe("edit"); - if (info.detail.type === "edit") { - expect(info.detail.filePath).toBe("/some/file.txt"); - expect(info.detail.oldString).toBe("old content"); - expect(info.detail.newString).toBe("new content"); - } - }); -}); - -describe("parseToolCallDisplay - apply_patch (Codex)", () => { - test("parses apply_patch into edit detail with unified diff", () => { - const input = { - files: [ - { - path: "/Users/me/dev/blankpage/editor/.tasks/578561c8.md", - kind: "update", - }, - ], - }; - const output = { - files: [ - { - path: "/Users/me/dev/blankpage/editor/.tasks/578561c8.md", - patch: "@@ -15,3 +15,2 @@\n-This task defines the **design philosophy**\n+This task defines the **updated philosophy**", - kind: "update", - }, - ], - message: "Success. Updated the following files:\nM .tasks/578561c8.md", - success: true, - }; - - const info = parseToolCallDisplay({ name: "apply_patch", input, output }); - expect(info.detail.type).toBe("edit"); - expect(info.displayName).toBe("Edit"); - if (info.detail.type === "edit") { - expect(info.detail.filePath).toBe("/Users/me/dev/blankpage/editor/.tasks/578561c8.md"); - expect(info.detail.unifiedDiff).toBe("@@ -15,3 +15,2 @@\n-This task defines the **design philosophy**\n+This task defines the **updated philosophy**"); - expect(info.detail.oldString).toBe(""); - expect(info.detail.newString).toBe(""); - } - }); - - test("parses apply_patch with kind object (type/move_path) into edit detail", () => { - const input = { - files: [ - { - path: "/Users/me/.paseo/worktrees/paseo/naive-zebra/packages/server/src/server/daemon-keypair.ts", - kind: { type: "update", move_path: null }, - }, - ], - }; - const output = { - files: [ - { - path: "/Users/me/.paseo/worktrees/paseo/naive-zebra/packages/server/src/server/daemon-keypair.ts", - patch: "@@ -1,1 +1,1 @@\n-foo\n+bar", - kind: { type: "update", move_path: null }, - }, - ], - success: true, - }; - - const info = parseToolCallDisplay({ name: "apply_patch", input, output }); - expect(info.detail.type).toBe("edit"); - expect(info.displayName).toBe("Edit"); - if (info.detail.type === "edit") { - expect(info.detail.filePath).toBe( - "/Users/me/.paseo/worktrees/paseo/naive-zebra/packages/server/src/server/daemon-keypair.ts" - ); - expect(info.detail.unifiedDiff).toBe("@@ -1,1 +1,1 @@\n-foo\n+bar"); - } - }); - - test("prefers move_path for display but still finds patch by original path", () => { - const input = { - files: [ - { - path: "/some/old-path.txt", - kind: { type: "update", move_path: "/some/new-path.txt" }, - }, - ], - }; - const output = { - files: [ - { - path: "/some/old-path.txt", - patch: "@@ -1,1 +1,1 @@\n-old\n+new", - kind: { type: "update", move_path: "/some/new-path.txt" }, - }, - ], - success: true, - }; - - const info = parseToolCallDisplay({ name: "apply_patch", input, output }); - expect(info.detail.type).toBe("edit"); - if (info.detail.type === "edit") { - expect(info.detail.filePath).toBe("/some/new-path.txt"); - expect(info.detail.unifiedDiff).toBe("@@ -1,1 +1,1 @@\n-old\n+new"); - } - }); - - test("parses pending apply_patch (no result yet)", () => { - const input = { - files: [ - { - path: "/some/file.txt", - kind: "create", - }, - ], - }; - - const info = parseToolCallDisplay({ name: "apply_patch", input }); - expect(info.detail.type).toBe("edit"); - expect(info.displayName).toBe("Edit"); - if (info.detail.type === "edit") { - expect(info.detail.filePath).toBe("/some/file.txt"); - expect(info.detail.unifiedDiff).toBeUndefined(); - } - }); - - test("handles apply_patch with multiple files (uses first file)", () => { - const input = { - files: [ - { path: "/first/file.txt", kind: "update" }, - { path: "/second/file.txt", kind: "create" }, - ], - }; - const output = { - files: [ - { path: "/first/file.txt", patch: "@@ -1 +1 @@\n-old\n+new", kind: "update" }, - { path: "/second/file.txt", patch: "@@ -0,0 +1 @@\n+content", kind: "create" }, - ], - success: true, - }; - - const info = parseToolCallDisplay({ name: "apply_patch", input, output }); - expect(info.detail.type).toBe("edit"); - if (info.detail.type === "edit") { - expect(info.detail.filePath).toBe("/first/file.txt"); - expect(info.detail.unifiedDiff).toBe("@@ -1 +1 @@\n-old\n+new"); - } - }); -}); - -describe("parseToolCallDisplay - read_file (Codex)", () => { - test("parses Codex read_file into read detail", () => { - const input = { - path: "/Users/me/dev/blankpage/editor/.tasks/578561c8.md", - }; - const output = { - type: "read_file", - path: "/Users/me/dev/blankpage/editor/.tasks/578561c8.md", - content: "260 - Source: `**bold**|`\n261 - Action: `Shift+ArrowLeft`", - }; - - const info = parseToolCallDisplay({ name: "read_file", input, output }); - expect(info.detail.type).toBe("read"); - expect(info.displayName).toBe("Read"); - if (info.detail.type === "read") { - expect(info.detail.filePath).toBe("/Users/me/dev/blankpage/editor/.tasks/578561c8.md"); - expect(info.detail.content).toBe("260 - Source: `**bold**|`\n261 - Action: `Shift+ArrowLeft`"); - } - }); - - test("Codex read_file stays read when result is missing", () => { - const input = { - path: "/some/file.txt", - }; - - const info = parseToolCallDisplay({ name: "read_file", input }); - expect(info.detail.type).toBe("read"); - expect(info.displayName).toBe("Read"); + expect(tasks?.map((task) => task.text)).toEqual(["Task 1", "Task 2"]); + expect(tasks?.map((task) => task.completed)).toEqual([false, true]); }); }); diff --git a/packages/app/src/utils/tool-call-parsers.ts b/packages/app/src/utils/tool-call-parsers.ts index 1535db17a..fa915a94e 100644 --- a/packages/app/src/utils/tool-call-parsers.ts +++ b/packages/app/src/utils/tool-call-parsers.ts @@ -18,31 +18,6 @@ export type DiffLine = { segments?: DiffSegment[]; }; -export type EditEntry = { - filePath?: string; - diffLines: DiffLine[]; -}; - -export type ReadEntry = { - filePath?: string; - content: string; -}; - -export type CommandDetails = { - command?: string; - cwd?: string; - output?: string; - exitCode?: number | null; -}; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function getString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - function splitIntoLines(text: string): string[] { if (!text) { return []; @@ -323,612 +298,6 @@ export function parseUnifiedDiff(diffText?: string): DiffLine[] { return diff; } -function deriveDiffLines({ - unifiedDiff, - original, - updated, -}: { - unifiedDiff?: string; - original?: string; - updated?: string; -}): DiffLine[] { - if (unifiedDiff) { - const parsed = parseUnifiedDiff(unifiedDiff); - if (parsed.length > 0) { - return parsed; - } - } - - if (original !== undefined || updated !== undefined) { - return buildLineDiff(original ?? "", updated ?? ""); - } - - return []; -} - -function looksLikePatch(text: string): boolean { - if (!text) { - return false; - } - return /(\*\*\* Begin Patch|@@|diff --git|\+\+\+|--- )/.test(text); -} - -function parsePatchText(text: string): DiffLine[] { - if (!text) { - return []; - } - return parseUnifiedDiff(text); -} - -function getFilePathFromRecord(record: Record): string | 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 -): 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(); - 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(); - 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)["aggregated_output"] ?? - (data.output as Record)["output"] ?? - (data.output as Record)["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)["exit_code"] as number | undefined) ?? - ((data.output as Record)["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)["args"]); - if (nestedCommand) { - target.command = nestedCommand; - } - } - } - - const nestedKeys = [ - "input", - "output", - "result", - "response", - "data", - "raw", - "payload", - ] as const; - for (const key of nestedKeys) { - if (value[key] !== undefined) { - collectCommandDetails(target, value[key], depth + 1); - } - } -} - -export function extractEditEntries(...sources: unknown[]): EditEntry[] { - const entries = sources.flatMap((value) => parseEditArguments(value)); - return mergeEditEntries(entries); -} - -export function extractReadEntries(...sources: unknown[]): ReadEntry[] { - return mergeReadEntries(sources.flatMap((value) => parseReadEntriesInternal(value))); -} - -export function extractCommandDetails(...sources: unknown[]): CommandDetails | null { - const details: CommandDetails = {}; - sources.forEach((value) => collectCommandDetails(details, value)); - if (details.command || details.output || details.cwd) { - return details; - } - return null; -} - -// ---- Key-Value Extraction for Generic Tool Results ---- - -export interface KeyValuePair { - key: string; - value: string; -} - -function stringifyValue(value: unknown): string { - if (value === null) { - return "null"; - } - if (value === undefined) { - return "undefined"; - } - if (typeof value === "string") { - return value; - } - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - -const WrappedOutputSchema = z - .object({ output: z.record(z.unknown()) }) - .transform((data) => data.output); - -const DirectRecordSchema = z.record(z.unknown()); - -const ToolResultRecordSchema = z.union([WrappedOutputSchema, DirectRecordSchema]); - -export function extractKeyValuePairs(result: unknown): KeyValuePair[] { - const parsed = ToolResultRecordSchema.safeParse(result); - if (!parsed.success) { - return []; - } - - const record = parsed.data; - return Object.entries(record).map(([key, value]) => ({ - key, - value: stringifyValue(value), - })); -} - // ---- Task Extraction (cross-provider) ---- export type TaskStatus = "pending" | "in_progress" | "completed"; @@ -1007,19 +376,3 @@ export function extractTaskEntriesFromToolCall( return null; } - -// ---- Unified Tool Call Display ---- -// Re-export from server — single source of truth -export { - parseToolCallDisplay, - stripCwdPrefix, - extractTodos, - normalizeToolDisplayName, - stripShellWrapperPrefix, - type ToolCallInput, - type ToolCallDisplayInfo, - type ToolCallDetail, - type ToolCallKind, - type TodoItem, - type KeyValuePair as ServerKeyValuePair, -} from "@getpaseo/server/utils/tool-call-parsers"; diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index c98c16ac1..9374a8a4e 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { afterEach, describe, expect, test, vi } from "vitest"; import { DaemonClient, type DaemonTransport } from "./daemon-client"; @@ -49,6 +50,18 @@ function createMockTransport() { }; } +function loadLegacySnapshotFixture(): unknown { + const url = new URL("../shared/__fixtures__/legacy-agent-stream-snapshot-inProgress.json", import.meta.url); + return JSON.parse(readFileSync(url, "utf8")); +} + +function wrapSessionMessage(message: unknown): string { + return JSON.stringify({ + type: "session", + message, + }); +} + describe("DaemonClient", () => { const clients: DaemonClient[] = []; @@ -171,4 +184,122 @@ describe("DaemonClient", () => { vi.runOnlyPendingTimers(); vi.useRealTimers(); }); + + test("parses agent_stream tool_call payloads (including legacy inProgress) without crashing", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const received: unknown[] = []; + const unsubscribe = client.on("agent_stream", (msg) => { + received.push(msg); + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "agent_stream", + payload: { + agentId: "agent_cli", + timestamp: "2026-02-08T20:20:00.000Z", + event: { + type: "timeline", + provider: "codex", + item: { + type: "tool_call", + callId: "call_cli_stream", + name: "shell", + status: "inProgress", + input: { command: "pwd" }, + }, + }, + }, + }) + ); + + unsubscribe(); + + expect(received).toHaveLength(1); + const streamMsg = received[0] as { + payload: { + event: { + type: "timeline"; + item: { + type: "tool_call"; + status: string; + error: unknown; + output: unknown; + }; + }; + }; + }; + + expect(streamMsg.payload.event.item.status).toBe("running"); + expect(streamMsg.payload.event.item.error).toBeNull(); + expect(streamMsg.payload.event.item.output).toBeNull(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + test("parses agent_stream_snapshot tool_call payloads without crashing", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const received: unknown[] = []; + const unsubscribe = client.on("agent_stream_snapshot", (msg) => { + received.push(msg); + }); + + const snapshot = loadLegacySnapshotFixture(); + mock.triggerMessage(wrapSessionMessage(snapshot)); + + unsubscribe(); + + expect(received).toHaveLength(1); + const snapshotMsg = received[0] as { + payload: { + events: Array<{ + event: { + type: "timeline"; + item: { + type: "tool_call"; + status: string; + error: unknown; + output: unknown; + }; + }; + }>; + }; + }; + + const firstTimeline = snapshotMsg.payload.events[0]?.event; + expect(firstTimeline?.type).toBe("timeline"); + if (firstTimeline?.type === "timeline" && firstTimeline.item.type === "tool_call") { + expect(firstTimeline.item.status).toBe("running"); + expect(firstTimeline.item.error).toBeNull(); + expect(firstTimeline.item.output).toBeNull(); + } + expect(logger.warn).not.toHaveBeenCalled(); + }); }); diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 1010fbda9..837a97e82 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -1,590 +1,158 @@ -import { describe, test, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { curateAgentActivity } from "./activity-curator.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; +function toolCallItem(params: { + callId: string; + name: string; + status?: "running" | "completed" | "failed" | "canceled"; + input?: unknown | null; + output?: unknown | null; + error?: unknown; + metadata?: Record; + detail?: Extract['detail']; +}): Extract { + const status = params.status ?? "completed"; + return { + type: "tool_call", + callId: params.callId, + name: params.name, + status, + input: params.input ?? null, + output: params.output ?? null, + error: status === "failed" ? params.error ?? { message: "failed" } : null, + metadata: params.metadata, + detail: params.detail, + }; +} + describe("curateAgentActivity", () => { - describe("serializes all timeline item types", () => { - test("serializes user_message", () => { - const timeline: AgentTimelineItem[] = [ - { type: "user_message", text: "Hello, can you help me?" }, - ]; + it("renders user/assistant/reasoning entries", () => { + const timeline: AgentTimelineItem[] = [ + { type: "user_message", text: "Hello" }, + { type: "assistant_message", text: "Hi" }, + { type: "reasoning", text: "Thinking" }, + ]; - const result = curateAgentActivity(timeline); + const result = curateAgentActivity(timeline); - expect(result).toBe("[User] Hello, can you help me?"); - }); - - test("serializes assistant_message", () => { - const timeline: AgentTimelineItem[] = [ - { type: "assistant_message", text: "I can help you with that." }, - ]; - - const result = curateAgentActivity(timeline); - - expect(result).toBe("I can help you with that."); - }); - - test("serializes reasoning as [Thought]", () => { - const timeline: AgentTimelineItem[] = [ - { type: "reasoning", text: "The user wants to understand X." }, - ]; - - const result = curateAgentActivity(timeline); - - expect(result).toBe("[Thought] The user wants to understand X."); - }); - - test("serializes tool_call with name", () => { - const timeline: AgentTimelineItem[] = [ - { - type: "tool_call", - callId: "call-1", - name: "Read", - input: { file_path: "/src/index.ts" }, - status: "completed", - }, - ]; - - const result = curateAgentActivity(timeline); - - expect(result).toBe("[Read] /src/index.ts"); - }); - - test("serializes tool_call without principal param", () => { - const timeline: AgentTimelineItem[] = [ - { - type: "tool_call", - callId: "call-1", - name: "ListFiles", - input: {}, - status: "completed", - }, - ]; - - const result = curateAgentActivity(timeline); - - expect(result).toBe("[ListFiles]"); - }); - - test("does not treat generic double-underscore tool names as MCP calls", () => { - const timeline: AgentTimelineItem[] = [ - { - type: "tool_call", - callId: "call-1", - name: "custom__tool", - input: {}, - status: "completed", - }, - ]; - - const result = curateAgentActivity(timeline); - - expect(result).toBe("[custom__tool]"); - }); - - test("serializes todo items as [Tasks]", () => { - const timeline: AgentTimelineItem[] = [ - { - type: "todo", - items: [ - { text: "Read the file", completed: true }, - { text: "Fix the bug", completed: false }, - { text: "Run tests", completed: false }, - ], - }, - ]; - - const result = curateAgentActivity(timeline); - - expect(result).toContain("[Tasks]"); - expect(result).toContain("- [x] Read the file"); - expect(result).toContain("- [ ] Fix the bug"); - expect(result).toContain("- [ ] Run tests"); - }); - - test("serializes error items", () => { - const timeline: AgentTimelineItem[] = [ - { type: "error", message: "File not found: /missing.ts" }, - ]; - - const result = curateAgentActivity(timeline); - - expect(result).toBe("[Error] File not found: /missing.ts"); - }); + expect(result).toContain("[User] Hello"); + expect(result).toContain("Hi"); + expect(result).toContain("[Thought] Thinking"); }); - describe("handles complex conversations", () => { - test("serializes full conversation with multiple item types", () => { - const timeline: AgentTimelineItem[] = [ - { type: "user_message", text: "Fix the bug in auth.ts" }, - { type: "reasoning", text: "I need to read the file first." }, - { - type: "tool_call", - callId: "call-1", - name: "Read", - input: { file_path: "/src/auth.ts" }, - status: "completed", + it("uses detail enrichment for tool summaries", () => { + const timeline: AgentTimelineItem[] = [ + toolCallItem({ + callId: "read-1", + name: "read_file", + detail: { + type: "read", + filePath: "src/index.ts", + content: "console.log('hi')", }, - { type: "assistant_message", text: "I found the issue." }, - { - type: "tool_call", - callId: "call-2", - name: "Edit", - input: { file_path: "/src/auth.ts", old_string: "bug", new_string: "fix" }, - status: "completed", + }), + toolCallItem({ + callId: "shell-1", + name: "shell", + detail: { + type: "shell", + command: "npm test", + output: "ok", + exitCode: 0, }, - { type: "assistant_message", text: "The bug has been fixed." }, - ]; + }), + ]; - const result = curateAgentActivity(timeline); + const result = curateAgentActivity(timeline); - expect(result).toContain("[User] Fix the bug in auth.ts"); - expect(result).toContain("[Thought] I need to read the file first."); - expect(result).toContain("[Read] /src/auth.ts"); - expect(result).toContain("I found the issue."); - expect(result).toContain("[Edit] /src/auth.ts"); - expect(result).toContain("The bug has been fixed."); - }); - - test("preserves order of items", () => { - const timeline: AgentTimelineItem[] = [ - { type: "user_message", text: "Step 1" }, - { type: "assistant_message", text: "Step 2" }, - { type: "user_message", text: "Step 3" }, - { type: "assistant_message", text: "Step 4" }, - ]; - - const result = curateAgentActivity(timeline); - const lines = result.split("\n"); - - expect(lines[0]).toContain("Step 1"); - expect(lines[1]).toContain("Step 2"); - expect(lines[2]).toContain("Step 3"); - expect(lines[3]).toContain("Step 4"); - }); + expect(result).toContain("[Read] src/index.ts"); + expect(result).toContain("[Shell] npm test"); }); - describe("handles edge cases", () => { - test("returns default message for empty timeline", () => { - const result = curateAgentActivity([]); + it("falls back to input json for likely external tools", () => { + const timeline: AgentTimelineItem[] = [ + toolCallItem({ + callId: "mcp-1", + name: "paseo__create_agent", + input: { cwd: "/tmp/repo", initialPrompt: "do the thing" }, + }), + ]; - expect(result).toBe("No activity to display."); - }); + const result = curateAgentActivity(timeline); - test("handles whitespace-only messages", () => { - const timeline: AgentTimelineItem[] = [ - { type: "user_message", text: " \n " }, - { type: "assistant_message", text: "Real message" }, - ]; - - const result = curateAgentActivity(timeline); - - expect(result).toContain("Real message"); - }); - - test("trims whitespace from messages", () => { - const timeline: AgentTimelineItem[] = [ - { type: "user_message", text: " Hello \n" }, - ]; - - const result = curateAgentActivity(timeline); - - expect(result).toBe("[User] Hello"); - }); + expect(result).toBe( + '[paseo__create_agent] {"cwd":"/tmp/repo","initialPrompt":"do the thing"}' + ); }); - describe("collapsing behavior", () => { - test("merges consecutive assistant_message items", () => { - const timeline: AgentTimelineItem[] = [ - { type: "assistant_message", text: "Part 1. " }, - { type: "assistant_message", text: "Part 2. " }, - { type: "assistant_message", text: "Part 3." }, - ]; + it("collapses repeated tool updates by callId", () => { + const timeline: AgentTimelineItem[] = [ + toolCallItem({ + callId: "task-1", + name: "Task", + status: "running", + input: { description: "Investigate" }, + }), + toolCallItem({ + callId: "task-1", + name: "Task", + status: "running", + metadata: { subAgentActivity: "Read" }, + }), + toolCallItem({ + callId: "task-1", + name: "Task", + status: "running", + metadata: { subAgentActivity: "Edit" }, + }), + ]; - const result = curateAgentActivity(timeline); + const result = curateAgentActivity(timeline); + const lines = result.split("\n"); - expect(result).toBe("Part 1. Part 2. Part 3."); - }); - - test("merges consecutive reasoning items", () => { - const timeline: AgentTimelineItem[] = [ - { type: "reasoning", text: "First thought. " }, - { type: "reasoning", text: "Second thought." }, - ]; - - const result = curateAgentActivity(timeline); - - expect(result).toBe("[Thought] First thought. Second thought."); - }); - - test("deduplicates tool calls by callId", () => { - const timeline: AgentTimelineItem[] = [ - { - type: "tool_call", - callId: "call-1", - name: "Read", - input: { file_path: "/src/a.ts" }, - status: "pending", - }, - { - type: "tool_call", - callId: "call-1", - name: "Read", - input: { file_path: "/src/a.ts" }, - status: "completed", - }, - ]; - - const result = curateAgentActivity(timeline); - - // Should only appear once - const matches = result.match(/\[Read\]/g); - expect(matches?.length).toBe(1); - }); + expect(lines.filter((line) => line.startsWith("[Task]"))).toEqual(["[Task] Edit"]); }); - describe("maxItems limit", () => { - test("respects maxItems option", () => { - const timeline: AgentTimelineItem[] = [ - { type: "user_message", text: "Message 1" }, - { type: "user_message", text: "Message 2" }, - { type: "user_message", text: "Message 3" }, - { type: "user_message", text: "Message 4" }, - { type: "user_message", text: "Message 5" }, - ]; + it("renders todo/error/compaction entries", () => { + const timeline: AgentTimelineItem[] = [ + { + type: "todo", + items: [ + { text: "One", completed: false }, + { text: "Two", completed: true }, + ], + }, + { type: "error", message: "boom" }, + { type: "compaction", status: "completed", trigger: "auto" }, + ]; - const result = curateAgentActivity(timeline, { maxItems: 3 }); + const result = curateAgentActivity(timeline); - // Should only have the last 3 messages - expect(result).not.toContain("Message 1"); - expect(result).not.toContain("Message 2"); - expect(result).toContain("Message 3"); - expect(result).toContain("Message 4"); - expect(result).toContain("Message 5"); - }); - - test("uses default maxItems of 40", () => { - const timeline: AgentTimelineItem[] = []; - for (let i = 0; i < 50; i++) { - timeline.push({ type: "user_message", text: `Message ${i}` }); - } - - const result = curateAgentActivity(timeline); - - // First 10 should be truncated - expect(result).not.toContain("Message 0"); - expect(result).not.toContain("Message 9"); - // Last 40 should be present - expect(result).toContain("Message 10"); - expect(result).toContain("Message 49"); - }); + expect(result).toContain("[Tasks]"); + expect(result).toContain("- [ ] One"); + expect(result).toContain("- [x] Two"); + expect(result).toContain("[Error] boom"); + expect(result).toContain("[Compacted]"); }); - describe("tool call principal extraction", () => { - test("extracts file_path from Read tool", () => { - const timeline: AgentTimelineItem[] = [ - { - type: "tool_call", - callId: "1", - name: "Read", - input: { file_path: "/src/index.ts" }, - status: "completed", - }, - ]; + it("truncates to maxItems", () => { + const timeline: AgentTimelineItem[] = [ + { type: "user_message", text: "Message 1" }, + { type: "user_message", text: "Message 2" }, + { type: "user_message", text: "Message 3" }, + { type: "user_message", text: "Message 4" }, + ]; - const result = curateAgentActivity(timeline); + const result = curateAgentActivity(timeline, { maxItems: 2 }); - expect(result).toBe("[Read] /src/index.ts"); - }); - - test("extracts command from Bash tool", () => { - const timeline: AgentTimelineItem[] = [ - { - type: "tool_call", - callId: "1", - name: "Bash", - input: { command: "npm test" }, - status: "completed", - }, - ]; - - const result = curateAgentActivity(timeline); - - expect(result).toBe("[Shell] npm test"); - }); - - test("extracts pattern from Glob tool", () => { - const timeline: AgentTimelineItem[] = [ - { - type: "tool_call", - callId: "1", - name: "Glob", - input: { pattern: "**/*.ts" }, - status: "completed", - }, - ]; - - const result = curateAgentActivity(timeline); - - expect(result).toBe("[Glob] **/*.ts"); - }); - - test("extracts pattern from Grep tool", () => { - const timeline: AgentTimelineItem[] = [ - { - type: "tool_call", - callId: "1", - name: "Grep", - input: { pattern: "TODO" }, - status: "completed", - }, - ]; - - const result = curateAgentActivity(timeline); - - expect(result).toBe("[Grep] TODO"); - }); - - test("shows speak tool text input", () => { - const timeline: AgentTimelineItem[] = [ - { - type: "tool_call", - callId: "s1", - name: "speak", - input: { text: "hello from voice" }, - status: "completed", - }, - ]; - - const result = curateAgentActivity(timeline); - expect(result).toBe('[Speak] {"text":"hello from voice"}'); - }); - - test("shows MCP tool input JSON", () => { - const timeline: AgentTimelineItem[] = [ - { - type: "tool_call", - callId: "m1", - name: "paseo__create_agent", - input: { cwd: "/tmp/repo", initialPrompt: "do the thing" }, - status: "completed", - }, - ]; - - const result = curateAgentActivity(timeline); - expect(result).toBe( - '[paseo__create_agent] {"cwd":"/tmp/repo","initialPrompt":"do the thing"}' - ); - }); - - test("shows namespaced tool input JSON regardless of prefix format", () => { - const timeline: AgentTimelineItem[] = [ - { - type: "tool_call", - callId: "m2", - name: "paseo_voice.speak", - input: { text: "hello from namespaced tool" }, - status: "completed", - }, - ]; - - const result = curateAgentActivity(timeline); - expect(result).toBe('[Speak] {"text":"hello from namespaced tool"}'); - }); - - test("shows claude mcp speak tool input as Speak", () => { - const timeline: AgentTimelineItem[] = [ - { - type: "tool_call", - callId: "m3", - name: "mcp__paseo_voice__speak", - input: { text: "hello from claude mcp" }, - status: "completed", - }, - ]; - - const result = curateAgentActivity(timeline); - expect(result).toBe('[Speak] {"text":"hello from claude mcp"}'); - }); - - test("extracts description from Task tool", () => { - const timeline: AgentTimelineItem[] = [ - { - type: "tool_call", - callId: "task-1", - name: "Task", - input: { description: "Explore the codebase" }, - status: "completed", - }, - ]; - - const result = curateAgentActivity(timeline); - expect(result).toBe("[Task] Explore the codebase"); - }); + expect(result).not.toContain("Message 1"); + expect(result).not.toContain("Message 2"); + expect(result).toContain("Message 3"); + expect(result).toContain("Message 4"); }); - describe("Task tool collapse with sub-agent activity", () => { - test("collapses Task metadata updates into single entry showing latest activity", () => { - const timeline: AgentTimelineItem[] = [ - { type: "user_message", text: "Investigate the bug" }, - { - type: "tool_call", - callId: "task-1", - name: "Task", - input: { description: "Explore the codebase" }, - status: "pending", - }, - // Sub-agent activity updates (same callId, metadata-only) - { - type: "tool_call", - callId: "task-1", - name: "Task", - metadata: { subAgentActivity: "Read" }, - }, - { - type: "tool_call", - callId: "task-1", - name: "Task", - metadata: { subAgentActivity: "Grep" }, - }, - { - type: "tool_call", - callId: "task-1", - name: "Task", - metadata: { subAgentActivity: "Edit" }, - }, - ]; - - const result = curateAgentActivity(timeline); - const lines = result.split("\n"); - - // Task should appear only once (collapsed by callId) - const taskLines = lines.filter((l) => l.includes("[Task]")); - expect(taskLines).toHaveLength(1); - - // The last metadata update wins — subAgentActivity "Edit" takes priority - expect(taskLines[0]).toBe("[Task] Edit"); - }); - - test("sub-agent tool calls do NOT appear as separate timeline entries", () => { - // This simulates the full flow: handleSidechainMessage only emits - // Task metadata updates, never individual sub-agent tool calls. - // So the timeline should only contain the Task call, not Read/Bash/Edit. - const timeline: AgentTimelineItem[] = [ - { - type: "tool_call", - callId: "task-1", - name: "Task", - input: { description: "Fix the bug" }, - status: "pending", - }, - // These are the metadata-only updates from handleSidechainMessage - { - type: "tool_call", - callId: "task-1", - name: "Task", - metadata: { subAgentActivity: "Read" }, - }, - { - type: "tool_call", - callId: "task-1", - name: "Task", - metadata: { subAgentActivity: "Bash" }, - }, - // Task completes - { - type: "tool_call", - callId: "task-1", - name: "Task", - input: { description: "Fix the bug" }, - output: { result: "Bug fixed successfully" }, - status: "completed", - }, - ]; - - const result = curateAgentActivity(timeline); - const lines = result.split("\n"); - - // No individual Read/Bash lines — only the Task - expect(lines.filter((l) => l.includes("[Read]"))).toHaveLength(0); - expect(lines.filter((l) => l.includes("[Shell]"))).toHaveLength(0); - - // One Task entry with the final completed state - const taskLines = lines.filter((l) => l.includes("[Task]")); - expect(taskLines).toHaveLength(1); - expect(taskLines[0]).toBe("[Task] Fix the bug"); - }); - - test("multiple concurrent Task calls are tracked independently", () => { - const timeline: AgentTimelineItem[] = [ - { - type: "tool_call", - callId: "task-a", - name: "Task", - input: { description: "Research API docs" }, - status: "pending", - }, - { - type: "tool_call", - callId: "task-b", - name: "Task", - input: { description: "Run tests" }, - status: "pending", - }, - // Activity updates for each - { - type: "tool_call", - callId: "task-a", - name: "Task", - metadata: { subAgentActivity: "WebFetch" }, - }, - { - type: "tool_call", - callId: "task-b", - name: "Task", - metadata: { subAgentActivity: "Bash" }, - }, - ]; - - const result = curateAgentActivity(timeline); - const lines = result.split("\n"); - - const taskLines = lines.filter((l) => l.includes("[Task]")); - expect(taskLines).toHaveLength(2); - // Last update for each callId wins - expect(taskLines[0]).toBe("[Task] WebFetch"); - expect(taskLines[1]).toBe("[Task] Bash"); - }); - }); - - describe("compaction", () => { - test("renders compaction as [Compacted]", () => { - const timeline: AgentTimelineItem[] = [ - { type: "assistant_message", text: "Working on it..." }, - { type: "compaction", status: "completed", trigger: "auto", preTokens: 168000 }, - { type: "assistant_message", text: "Continuing after compaction" }, - ]; - - const result = curateAgentActivity(timeline); - const lines = result.split("\n"); - expect(lines).toContain("[Compacted]"); - expect(lines.indexOf("[Compacted]")).toBeGreaterThan(0); - }); - - test("compaction flushes preceding buffers", () => { - const timeline: AgentTimelineItem[] = [ - { type: "assistant_message", text: "Before" }, - { type: "reasoning", text: "Thinking..." }, - { type: "compaction", status: "completed", trigger: "auto" }, - { type: "assistant_message", text: "After" }, - ]; - - const result = curateAgentActivity(timeline); - const lines = result.split("\n"); - const compactIdx = lines.indexOf("[Compacted]"); - expect(compactIdx).toBeGreaterThan(-1); - expect(lines.slice(0, compactIdx).some((l) => l.includes("Before"))).toBe(true); - expect(lines.slice(0, compactIdx).some((l) => l.includes("Thinking"))).toBe(true); - }); + it("returns a default message when timeline is empty", () => { + expect(curateAgentActivity([])).toBe("No activity to display."); }); }); diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 1d28f7870..d0f2077b3 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -1,5 +1,4 @@ import type { AgentTimelineItem } from "./agent-sdk-types.js"; -import { parseToolCallDisplay } from "../../utils/tool-call-parsers.js"; import { isLikelyExternalToolName } from "./tool-name-normalization.js"; const DEFAULT_MAX_ITEMS = 40; @@ -45,6 +44,50 @@ function formatToolInputJson(input: unknown): string | null { } } +function resolveToolDisplayName(item: Extract): string { + switch (item.detail?.type) { + case "shell": + return "Shell"; + case "read": + return "Read"; + case "edit": + return "Edit"; + case "write": + return "Write"; + case "search": + return "Search"; + default: + return item.name; + } +} + +function resolveToolSummary( + item: Extract +): string | undefined { + if (item.name.trim().toLowerCase() === "task") { + const metadata = item.metadata as { subAgentActivity?: unknown } | undefined; + if (typeof metadata?.subAgentActivity === "string") { + const summary = metadata.subAgentActivity.trim(); + if (summary.length > 0) { + return summary; + } + } + } + + switch (item.detail?.type) { + case "shell": + return item.detail.command; + case "read": + case "edit": + case "write": + return item.detail.filePath; + case "search": + return item.detail.query; + default: + return undefined; + } +} + /** * Collapse timeline items: * - Dedupe tool calls by callId (pending/completed -> single) @@ -86,26 +129,35 @@ function collapseTimeline(items: AgentTimelineItem[]): AgentTimelineItem[] { flushAssistant(); flushToolCalls(); reasoningBuffer += item.text; - } else if (item.type === "tool_call" && item.callId) { + } else if (item.type === "tool_call") { flushAssistant(); flushReasoning(); const existing = toolCallMap.get(item.callId); if (existing && existing.type === "tool_call") { - toolCallMap.set(item.callId, { - ...existing, - ...item, - input: item.input ?? existing.input, - output: item.output ?? existing.output, - metadata: item.metadata, - }); + if (item.status === "failed") { + toolCallMap.set(item.callId, { + ...existing, + ...item, + input: item.input ?? existing.input, + output: item.output ?? existing.output, + detail: item.detail ?? existing.detail, + error: item.error, + metadata: item.metadata, + }); + } else { + toolCallMap.set(item.callId, { + ...existing, + ...item, + input: item.input ?? existing.input, + output: item.output ?? existing.output, + detail: item.detail ?? existing.detail, + error: null, + metadata: item.metadata, + }); + } } else { toolCallMap.set(item.callId, item); } - } else if (item.type === "tool_call") { - flushAssistant(); - flushReasoning(); - flushToolCalls(); - result.push(item); } else { flushAssistant(); flushReasoning(); @@ -159,11 +211,8 @@ export function curateAgentActivity( case "tool_call": { flushBuffers(lines, buffers); const inputJson = formatToolInputJson(item.input); - const { displayName, summary } = parseToolCallDisplay({ - name: item.name, - input: item.input, - metadata: item.metadata, - }); + const displayName = resolveToolDisplayName(item); + const summary = resolveToolSummary(item); if (isLikelyExternalToolName(item.name) && inputJson) { lines.push(`[${displayName}] ${inputJson}`); break; diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 1f7e26b55..ec88d5cb8 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -103,29 +103,73 @@ export type AgentUsage = { totalCostUsd?: number; }; -/** - * Tool call kind categories for UI rendering hints. - * Derived from the tool name, not sent over the wire. - */ -export type ToolCallKind = "read" | "edit" | "execute" | "search" | "other"; +export type ToolCallDetail = + | { + type: "shell"; + command: string; + cwd?: string; + output?: string; + exitCode?: number | null; + } + | { + type: "read"; + filePath: string; + content?: string; + offset?: number; + limit?: number; + } + | { + type: "edit"; + filePath: string; + oldString?: string; + newString?: string; + unifiedDiff?: string; + } + | { + type: "write"; + filePath: string; + content?: string; + } + | { + type: "search"; + query: string; + }; -/** - * Clean tool call structure. - * - `name`: Tool identifier (e.g., "Read", "Bash", "Edit", "shell", "read_file", "apply_patch") - * - `input`: Tool input parameters - * - `output`: Tool result - * - `error`: Error if tool failed - */ -export interface ToolCallTimelineItem { +type ToolCallBase = { type: "tool_call"; + callId: string; name: string; - callId?: string; - status?: string; - input?: unknown; - output?: unknown; - error?: unknown; + input: unknown | null; + output: unknown | null; + detail?: ToolCallDetail; metadata?: Record; -} +}; + +type ToolCallRunningTimelineItem = ToolCallBase & { + status: "running"; + error: null; +}; + +type ToolCallCompletedTimelineItem = ToolCallBase & { + status: "completed"; + error: null; +}; + +type ToolCallFailedTimelineItem = ToolCallBase & { + status: "failed"; + error: unknown; +}; + +type ToolCallCanceledTimelineItem = ToolCallBase & { + status: "canceled"; + error: null; +}; + +export type ToolCallTimelineItem = + | ToolCallRunningTimelineItem + | ToolCallCompletedTimelineItem + | ToolCallFailedTimelineItem + | ToolCallCanceledTimelineItem; export type CompactionTimelineItem = { type: "compaction"; diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 234de76cc..48a16e6ee 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -22,6 +22,12 @@ import { type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import type { Logger } from "pino"; +import { + mapClaudeCanceledToolCall, + mapClaudeCompletedToolCall, + mapClaudeFailedToolCall, + mapClaudeRunningToolCall, +} from "./claude/tool-call-mapper.js"; import type { AgentCapabilityFlags, @@ -177,8 +183,6 @@ type PendingPermission = { }; type ToolUseClassification = "generic" | "command" | "file_change"; -type ToolCallTimelineItem = Extract; - type ToolUseCacheEntry = { id: string; name: string; @@ -763,11 +767,14 @@ class ClaudeAgentSession implements AgentSession { if (response.behavior === "allow") { if (pending.request.kind === "plan") { await this.setMode("acceptEdits"); - this.pushToolCall({ + this.pushToolCall( + mapClaudeCompletedToolCall({ name: "plan_approval", - status: "granted", callId: pending.request.id, - }); + input: pending.request.input ?? null, + output: { approved: true }, + }) + ); } const result: PermissionResult = { behavior: "allow", @@ -1165,16 +1172,19 @@ class ClaudeAgentSession implements AgentSession { } this.activeSidechains.set(parentToolUseId, toolName); - return [{ - type: "timeline", - item: { - type: "tool_call", - name: "Task", - callId: parentToolUseId, - metadata: { subAgentActivity: toolName }, + return [ + { + type: "timeline", + item: mapClaudeRunningToolCall({ + name: "Task", + callId: parentToolUseId, + input: null, + output: null, + metadata: { subAgentActivity: toolName }, + }), + provider: "claude", }, - provider: "claude", - }]; + ]; } private translateMessageToEvents(message: SDKMessage, turnContext: TurnContext): AgentStreamEvent[] { @@ -1428,23 +1438,23 @@ class ClaudeAgentSession implements AgentSession { private flushPendingToolCalls() { for (const [id, entry] of this.toolUseCache) { if (entry.started) { - this.pushToolCall({ - name: entry.name, - status: "failed", - callId: id, - input: entry.input, - error: { message: "Interrupted" }, - }); + this.pushToolCall( + mapClaudeCanceledToolCall({ + name: entry.name, + callId: id, + input: entry.input ?? null, + output: null, + }) + ); } } this.toolUseCache.clear(); } private pushToolCall( - data: Omit, + item: Extract, target?: AgentTimelineItem[] ) { - const item: AgentTimelineItem = { type: "tool_call", ...data }; if (target) { target.push(item); return; @@ -1611,12 +1621,12 @@ class ClaudeAgentSession implements AgentSession { entry.started = true; this.toolUseCache.set(entry.id, entry); this.pushToolCall( - { + mapClaudeRunningToolCall({ name: entry.name, - status: "pending", callId: entry.id, - input: entry.input ?? this.normalizeToolInput(block.input), - }, + input: entry.input ?? this.normalizeToolInput(block.input) ?? null, + output: null, + }), items ); } @@ -1624,22 +1634,37 @@ class ClaudeAgentSession implements AgentSession { private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void { const entry = typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined; const toolName = entry?.name ?? block.tool_name ?? "tool"; - const status = block.is_error ? "failed" : "completed"; + const callId = + typeof block.tool_use_id === "string" && block.tool_use_id.length > 0 + ? block.tool_use_id + : entry?.id ?? null; // Extract output from block.content (SDK always returns content in string form) const output = this.buildToolOutput(block, entry); - this.pushToolCall( - { - name: toolName, - status, - callId: typeof block.tool_use_id === "string" ? block.tool_use_id : undefined, - input: entry?.input, - output, - error: block.is_error ? block : undefined, - }, - items - ); + if (block.is_error) { + this.pushToolCall( + mapClaudeFailedToolCall({ + name: toolName, + callId, + input: entry?.input ?? null, + output: output ?? null, + error: block, + }), + items + ); + } else { + this.pushToolCall( + mapClaudeCompletedToolCall({ + name: toolName, + callId, + input: entry?.input ?? null, + output: output ?? null, + }), + items + ); + } + if (typeof block.tool_use_id === "string") { this.toolUseCache.delete(block.tool_use_id); } @@ -1866,12 +1891,14 @@ class ClaudeAgentSession implements AgentSession { } this.applyToolInput(entry, normalized); this.toolUseCache.set(toolId, entry); - this.pushToolCall({ - name: entry.name, - status: "pending", - callId: toolId, - input: normalized, - }); + this.pushToolCall( + mapClaudeRunningToolCall({ + name: entry.name, + callId: toolId, + input: normalized, + output: null, + }) + ); } private normalizeToolInput(input: unknown): AgentMetadata | null { diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts new file mode 100644 index 000000000..7084b6c3e --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { + mapClaudeCompletedToolCall, + mapClaudeFailedToolCall, + mapClaudeRunningToolCall, +} from "./tool-call-mapper.js"; + +describe("claude tool-call mapper", () => { + it("maps running shell calls with canonical fields", () => { + const item = mapClaudeRunningToolCall({ + callId: "claude-call-1", + name: "Bash", + input: { command: "pwd", cwd: "/tmp/repo" }, + output: null, + }); + + expect(item.type).toBe("tool_call"); + expect(item.status).toBe("running"); + expect(item.error).toBeNull(); + expect(item.callId).toBe("claude-call-1"); + expect(item.input).toEqual({ command: "pwd", cwd: "/tmp/repo" }); + expect(item.output).toBeNull(); + expect(item.detail?.type).toBe("shell"); + if (item.detail?.type === "shell") { + expect(item.detail.command).toBe("pwd"); + expect(item.detail.cwd).toBe("/tmp/repo"); + } + }); + + it("maps completed read calls with detail enrichment", () => { + const item = mapClaudeCompletedToolCall({ + callId: "claude-call-2", + name: "read_file", + input: { file_path: "README.md" }, + output: { content: "hello" }, + }); + + expect(item.status).toBe("completed"); + expect(item.error).toBeNull(); + expect(item.callId).toBe("claude-call-2"); + expect(item.input).toEqual({ file_path: "README.md" }); + expect(item.output).toEqual({ content: "hello" }); + expect(item.detail?.type).toBe("read"); + if (item.detail?.type === "read") { + expect(item.detail.filePath).toBe("README.md"); + expect(item.detail.content).toBe("hello"); + } + }); + + it("maps failed calls with required error", () => { + const item = mapClaudeFailedToolCall({ + callId: "claude-call-3", + name: "shell", + input: { command: "false" }, + output: null, + error: { message: "Command failed" }, + }); + + expect(item.status).toBe("failed"); + expect(item.error).toEqual({ message: "Command failed" }); + expect(item.callId).toBe("claude-call-3"); + expect(item.input).toEqual({ command: "false" }); + expect(item.output).toBeNull(); + }); + + it("keeps unknown tools canonical without detail", () => { + const item = mapClaudeCompletedToolCall({ + callId: "claude-call-4", + name: "my_custom_tool", + input: { foo: "bar" }, + output: { ok: true }, + }); + + expect(item.status).toBe("completed"); + expect(item.error).toBeNull(); + expect(item.detail).toBeUndefined(); + expect(item.input).toEqual({ foo: "bar" }); + expect(item.output).toEqual({ ok: true }); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts new file mode 100644 index 000000000..7053d6760 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts @@ -0,0 +1,599 @@ +import { z } from "zod"; + +import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js"; + +type MapperParams = { + callId?: string | null; + name: string; + input?: unknown; + output?: unknown; + metadata?: Record; +}; + +const ClaudeMapperParamsSchema = z + .object({ + callId: z.string().optional().nullable(), + name: z.string().min(1), + input: z.unknown().optional(), + output: z.unknown().optional(), + metadata: z.record(z.unknown()).optional(), + }) + .passthrough(); + +const ClaudeFailedMapperParamsSchema = ClaudeMapperParamsSchema.extend({ + error: z.unknown(), +}); + +const ClaudeShellToolNameSchema = z.union([ + z.literal("Bash"), + z.literal("bash"), + z.literal("shell"), + z.literal("exec_command"), +]); + +const ClaudeReadToolNameSchema = z.union([ + z.literal("Read"), + z.literal("read"), + z.literal("read_file"), + z.literal("view_file"), +]); + +const ClaudeWriteToolNameSchema = z.union([ + z.literal("Write"), + z.literal("write"), + z.literal("write_file"), + z.literal("create_file"), +]); + +const ClaudeEditToolNameSchema = z.union([ + z.literal("Edit"), + z.literal("edit"), + z.literal("multi_edit"), + z.literal("multiedit"), + z.literal("apply_patch"), + z.literal("apply_diff"), + z.literal("str_replace_editor"), +]); + +const ClaudeSearchToolNameSchema = z.union([ + z.literal("WebSearch"), + z.literal("web_search"), + z.literal("websearch"), + z.literal("search"), +]); + +const ClaudeFileReferenceSchema = z + .object({ + file_path: z.string().optional(), + filePath: z.string().optional(), + path: z.string().optional(), + target_path: z.string().optional(), + targetPath: z.string().optional(), + }) + .passthrough(); + +const ClaudeFileReferenceCollectionSchema = ClaudeFileReferenceSchema.extend({ + files: z.array(ClaudeFileReferenceSchema).optional(), +}).passthrough(); + +const ClaudeTextLikeSchema = z + .object({ + output: z.string().optional(), + text: z.string().optional(), + content: z.string().optional(), + }) + .passthrough(); + +const ClaudeShellInputSchema = z + .object({ + command: z.union([z.string(), z.array(z.string())]).optional(), + cmd: z.union([z.string(), z.array(z.string())]).optional(), + cwd: z.string().optional(), + directory: z.string().optional(), + }) + .passthrough(); + +const ClaudeShellOutputObjectSchema = z + .object({ + command: z.string().optional(), + output: z.string().optional(), + text: z.string().optional(), + content: z.string().optional(), + aggregated_output: z.string().optional(), + exitCode: z.number().finite().optional(), + exit_code: z.number().finite().optional(), + metadata: z + .object({ + exitCode: z.number().finite().optional(), + exit_code: z.number().finite().optional(), + }) + .passthrough() + .optional(), + structuredContent: ClaudeTextLikeSchema.optional(), + structured_content: ClaudeTextLikeSchema.optional(), + result: z + .object({ + command: z.string().optional(), + output: z.string().optional(), + text: z.string().optional(), + content: z.string().optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +const ClaudeShellOutputSchema = z.union([z.string(), ClaudeShellOutputObjectSchema]); + +const ClaudeReadInputSchema = ClaudeFileReferenceCollectionSchema.extend({ + offset: z.number().finite().optional(), + limit: z.number().finite().optional(), +}).passthrough(); + +const ClaudeReadOutputSchema = z.union([ + z.string(), + ClaudeFileReferenceCollectionSchema.extend({ + content: z.string().optional(), + text: z.string().optional(), + output: z.string().optional(), + data: ClaudeTextLikeSchema.optional(), + structuredContent: ClaudeTextLikeSchema.optional(), + structured_content: ClaudeTextLikeSchema.optional(), + }).passthrough(), +]); + +const ClaudeWriteInputSchema = ClaudeFileReferenceCollectionSchema.extend({ + content: z.string().optional(), + new_content: z.string().optional(), + newContent: z.string().optional(), +}).passthrough(); + +const ClaudeWriteOutputSchema = ClaudeFileReferenceCollectionSchema.extend({ + content: z.string().optional(), + new_content: z.string().optional(), + newContent: z.string().optional(), +}).passthrough(); + +const ClaudeEditInputSchema = ClaudeFileReferenceCollectionSchema.extend({ + old_string: z.string().optional(), + old_str: z.string().optional(), + oldContent: z.string().optional(), + old_content: z.string().optional(), + new_string: z.string().optional(), + new_str: z.string().optional(), + newContent: z.string().optional(), + new_content: z.string().optional(), + content: z.string().optional(), + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), +}).passthrough(); + +const ClaudeEditOutputSchema = ClaudeFileReferenceCollectionSchema.extend({ + content: z.string().optional(), + new_content: z.string().optional(), + newContent: z.string().optional(), + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), + files: z + .array( + ClaudeFileReferenceSchema.extend({ + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), + }).passthrough() + ) + .optional(), +}).passthrough(); + +const ClaudeSearchInputSchema = z + .object({ + query: z.string().optional(), + q: z.string().optional(), + }) + .passthrough(); + +const ClaudeShellDetailCandidateSchema = z + .object({ + name: ClaudeShellToolNameSchema, + input: z.unknown().nullable(), + output: z.unknown().nullable(), + }) + .transform(({ input, output }) => resolveShellDetail(input, output)); + +const ClaudeReadDetailCandidateSchema = z + .object({ + name: ClaudeReadToolNameSchema, + input: z.unknown().nullable(), + output: z.unknown().nullable(), + }) + .transform(({ input, output }) => resolveReadDetail(input, output)); + +const ClaudeWriteDetailCandidateSchema = z + .object({ + name: ClaudeWriteToolNameSchema, + input: z.unknown().nullable(), + output: z.unknown().nullable(), + }) + .transform(({ input, output }) => resolveWriteDetail(input, output)); + +const ClaudeEditDetailCandidateSchema = z + .object({ + name: ClaudeEditToolNameSchema, + input: z.unknown().nullable(), + output: z.unknown().nullable(), + }) + .transform(({ input, output }) => resolveEditDetail(input, output)); + +const ClaudeSearchDetailCandidateSchema = z + .object({ + name: ClaudeSearchToolNameSchema, + input: z.unknown().nullable(), + output: z.unknown().nullable(), + }) + .transform(({ input }) => resolveSearchDetail(input)); + +const ClaudeKnownToolDetailSchema = z.union([ + ClaudeShellDetailCandidateSchema, + ClaudeReadDetailCandidateSchema, + ClaudeWriteDetailCandidateSchema, + ClaudeEditDetailCandidateSchema, + ClaudeSearchDetailCandidateSchema, +]); + +function hashText(value: string): string { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash << 5) - hash + value.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash).toString(36); +} + +function coerceCallId(callId: string | null | undefined, name: string, input: unknown): string { + if (typeof callId === "string" && callId.trim().length > 0) { + return callId; + } + let serialized = ""; + try { + serialized = JSON.stringify(input) ?? ""; + } catch { + serialized = String(input); + } + return `claude-${hashText(`${name}:${serialized}`)}`; +} + +function firstNonEmpty(...values: Array): string | undefined { + return values.find((value) => typeof value === "string" && value.length > 0); +} + +function commandFromValue(value: string | string[] | undefined): string | undefined { + if (typeof value === "string" && value.length > 0) { + return value; + } + if (Array.isArray(value)) { + const tokens = value.filter((token): token is string => typeof token === "string" && token.length > 0); + if (tokens.length > 0) { + return tokens.join(" "); + } + } + return undefined; +} + +function resolveFilePath(value: z.infer): string | undefined { + return firstNonEmpty( + value.file_path, + value.filePath, + value.path, + value.target_path, + value.targetPath, + value.files?.[0]?.path, + value.files?.[0]?.filePath, + value.files?.[0]?.file_path + ); +} + +function resolveShellDetail(input: unknown, output: unknown): ToolCallDetail | undefined { + const parsedInput = ClaudeShellInputSchema.safeParse(input); + const parsedOutput = ClaudeShellOutputSchema.safeParse(output); + + const command = + (parsedInput.success + ? commandFromValue(parsedInput.data.command) ?? commandFromValue(parsedInput.data.cmd) + : undefined) ?? + (parsedOutput.success && typeof parsedOutput.data !== "string" + ? firstNonEmpty(parsedOutput.data.command, parsedOutput.data.result?.command) + : undefined); + + if (!command) { + return undefined; + } + + const outputText = + parsedOutput.success + ? typeof parsedOutput.data === "string" + ? parsedOutput.data + : firstNonEmpty( + parsedOutput.data.output, + parsedOutput.data.text, + parsedOutput.data.content, + parsedOutput.data.aggregated_output, + parsedOutput.data.structuredContent?.output, + parsedOutput.data.structuredContent?.text, + parsedOutput.data.structuredContent?.content, + parsedOutput.data.structured_content?.output, + parsedOutput.data.structured_content?.text, + parsedOutput.data.structured_content?.content, + parsedOutput.data.result?.output, + parsedOutput.data.result?.text, + parsedOutput.data.result?.content + ) + : undefined; + + const exitCode = + parsedOutput.success && typeof parsedOutput.data !== "string" + ? parsedOutput.data.exitCode ?? + parsedOutput.data.exit_code ?? + parsedOutput.data.metadata?.exitCode ?? + parsedOutput.data.metadata?.exit_code ?? + null + : null; + + const cwd = + parsedInput.success + ? firstNonEmpty(parsedInput.data.cwd, parsedInput.data.directory) + : undefined; + + return { + type: "shell", + command, + ...(cwd !== undefined ? { cwd } : {}), + ...(outputText !== undefined ? { output: outputText } : {}), + ...(exitCode !== null ? { exitCode } : { exitCode: null }), + }; +} + +function resolveReadDetail(input: unknown, output: unknown): ToolCallDetail | undefined { + const parsedInput = ClaudeReadInputSchema.safeParse(input); + const parsedOutput = ClaudeReadOutputSchema.safeParse(output); + + const inputPath = parsedInput.success ? resolveFilePath(parsedInput.data) : undefined; + const outputPath = + parsedOutput.success && typeof parsedOutput.data !== "string" + ? resolveFilePath(parsedOutput.data) + : undefined; + const filePath = firstNonEmpty(inputPath, outputPath); + + if (!filePath) { + return undefined; + } + + const content = + parsedOutput.success + ? typeof parsedOutput.data === "string" + ? parsedOutput.data + : firstNonEmpty( + parsedOutput.data.content, + parsedOutput.data.text, + parsedOutput.data.output, + parsedOutput.data.data?.content, + parsedOutput.data.data?.text, + parsedOutput.data.data?.output, + parsedOutput.data.structuredContent?.content, + parsedOutput.data.structuredContent?.text, + parsedOutput.data.structuredContent?.output, + parsedOutput.data.structured_content?.content, + parsedOutput.data.structured_content?.text, + parsedOutput.data.structured_content?.output + ) + : undefined; + + const offset = parsedInput.success ? parsedInput.data.offset : undefined; + const limit = parsedInput.success ? parsedInput.data.limit : undefined; + + return { + type: "read", + filePath, + ...(content !== undefined ? { content } : {}), + ...(offset !== undefined ? { offset } : {}), + ...(limit !== undefined ? { limit } : {}), + }; +} + +function resolveWriteDetail(input: unknown, output: unknown): ToolCallDetail | undefined { + const parsedInput = ClaudeWriteInputSchema.safeParse(input); + const parsedOutput = ClaudeWriteOutputSchema.safeParse(output); + + const filePath = firstNonEmpty( + parsedInput.success ? resolveFilePath(parsedInput.data) : undefined, + parsedOutput.success ? resolveFilePath(parsedOutput.data) : undefined + ); + + if (!filePath) { + return undefined; + } + + const content = firstNonEmpty( + parsedInput.success + ? firstNonEmpty(parsedInput.data.content, parsedInput.data.new_content, parsedInput.data.newContent) + : undefined, + parsedOutput.success + ? firstNonEmpty(parsedOutput.data.content, parsedOutput.data.new_content, parsedOutput.data.newContent) + : undefined + ); + + return { + type: "write", + filePath, + ...(content !== undefined ? { content } : {}), + }; +} + +function resolveEditDetail(input: unknown, output: unknown): ToolCallDetail | undefined { + const parsedInput = ClaudeEditInputSchema.safeParse(input); + const parsedOutput = ClaudeEditOutputSchema.safeParse(output); + + const filePath = firstNonEmpty( + parsedInput.success ? resolveFilePath(parsedInput.data) : undefined, + parsedOutput.success ? resolveFilePath(parsedOutput.data) : undefined + ); + + if (!filePath) { + return undefined; + } + + const oldString = parsedInput.success + ? firstNonEmpty( + parsedInput.data.old_string, + parsedInput.data.old_str, + parsedInput.data.oldContent, + parsedInput.data.old_content + ) + : undefined; + + const newString = firstNonEmpty( + parsedInput.success + ? firstNonEmpty( + parsedInput.data.new_string, + parsedInput.data.new_str, + parsedInput.data.newContent, + parsedInput.data.new_content, + parsedInput.data.content + ) + : undefined, + parsedOutput.success + ? firstNonEmpty(parsedOutput.data.newContent, parsedOutput.data.new_content, parsedOutput.data.content) + : undefined + ); + + const unifiedDiff = firstNonEmpty( + parsedInput.success + ? firstNonEmpty( + parsedInput.data.patch, + parsedInput.data.diff, + parsedInput.data.unified_diff, + parsedInput.data.unifiedDiff + ) + : undefined, + parsedOutput.success + ? firstNonEmpty( + parsedOutput.data.patch, + parsedOutput.data.diff, + parsedOutput.data.unified_diff, + parsedOutput.data.unifiedDiff, + parsedOutput.data.files?.[0]?.patch, + parsedOutput.data.files?.[0]?.diff, + parsedOutput.data.files?.[0]?.unified_diff, + parsedOutput.data.files?.[0]?.unifiedDiff + ) + : undefined + ); + + return { + type: "edit", + filePath, + ...(oldString !== undefined ? { oldString } : {}), + ...(newString !== undefined ? { newString } : {}), + ...(unifiedDiff !== undefined ? { unifiedDiff } : {}), + }; +} + +function resolveSearchDetail(input: unknown): ToolCallDetail | undefined { + const parsedInput = ClaudeSearchInputSchema.safeParse(input); + if (!parsedInput.success) { + return undefined; + } + + const query = firstNonEmpty(parsedInput.data.query, parsedInput.data.q); + if (!query) { + return undefined; + } + + return { + type: "search", + query, + }; +} + +function deriveDetail(name: string, input: unknown, output: unknown): ToolCallDetail | undefined { + const parsed = ClaudeKnownToolDetailSchema.safeParse({ + name, + input, + output, + }); + if (!parsed.success) { + return undefined; + } + return parsed.data; +} + +function buildBase(params: MapperParams): { + callId: string; + name: string; + input: unknown | null; + output: unknown | null; + detail?: ToolCallDetail; + metadata?: Record; +} { + const parsedParams = ClaudeMapperParamsSchema.parse(params); + const callId = coerceCallId(parsedParams.callId, parsedParams.name, parsedParams.input); + const input = parsedParams.input ?? null; + const output = parsedParams.output ?? null; + const detail = deriveDetail(parsedParams.name, input, output); + + return { + callId, + name: parsedParams.name, + input, + output, + ...(detail ? { detail } : {}), + ...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}), + }; +} + +export function mapClaudeRunningToolCall(params: MapperParams): ToolCallTimelineItem { + const base = buildBase(params); + return { + type: "tool_call", + ...base, + status: "running", + error: null, + }; +} + +export function mapClaudeCompletedToolCall(params: MapperParams): ToolCallTimelineItem { + const base = buildBase(params); + return { + type: "tool_call", + ...base, + status: "completed", + error: null, + }; +} + +export function mapClaudeFailedToolCall( + params: MapperParams & { error: unknown } +): ToolCallTimelineItem { + const parsedParams = ClaudeFailedMapperParamsSchema.parse(params); + const base = buildBase(parsedParams); + return { + type: "tool_call", + ...base, + status: "failed", + error: parsedParams.error, + }; +} + +export function mapClaudeCanceledToolCall(params: MapperParams): ToolCallTimelineItem { + const base = buildBase(params); + return { + type: "tool_call", + ...base, + status: "canceled", + error: null, + }; +} diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 9dd26b336..ae245ae23 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -21,7 +21,6 @@ import type { ListModelsOptions, ListPersistedAgentsOptions, PersistedAgentDescriptor, - ToolCallTimelineItem, } from "../agent-sdk-types.js"; import type { Logger } from "pino"; @@ -35,6 +34,7 @@ import path from "node:path"; import readline from "node:readline"; import { z } from "zod"; import { loadCodexPersistedTimeline } from "./codex-rollout-timeline.js"; +import { mapCodexToolCallFromThreadItem } from "./codex/tool-call-mapper.js"; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; @@ -634,12 +634,6 @@ function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined { }; } -function createToolCallTimelineItem( - data: Omit -): AgentTimelineItem { - return { type: "tool_call", ...data }; -} - function extractUserText(content: unknown): string | null { if (!Array.isArray(content)) return null; const parts: string[] = []; @@ -654,20 +648,6 @@ function extractUserText(content: unknown): string | null { return parts.length > 0 ? parts.join("\n") : null; } -function extractContentText(content: unknown): string | null { - if (!Array.isArray(content)) return null; - const parts: string[] = []; - for (const item of content) { - if (item && typeof item === "object") { - const obj = item as { text?: string }; - if (typeof obj.text === "string") { - parts.push(obj.text); - } - } - } - return parts.length > 0 ? parts.join("\n") : null; -} - function parsePlanTextToTodoItems(text: string): { text: string; completed: boolean }[] { const lines = text .split("\n") @@ -692,19 +672,6 @@ function planStepsToTodoItems(steps: Array<{ step: string; status: string }>): { })); } -function normalizeCodexFilePath(filePath: unknown, cwd: string | null | undefined): string | null { - if (typeof filePath !== "string") return null; - const trimmed = filePath.trim(); - if (!trimmed) return null; - if (typeof cwd === "string" && cwd.trim().length > 0) { - const normalizedCwd = cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`; - if (trimmed.startsWith(normalizedCwd)) { - return trimmed.slice(normalizedCwd.length); - } - } - return trimmed; -} - function threadItemToTimeline( item: any, options?: { includeUserMessage?: boolean; cwd?: string | null } @@ -734,79 +701,11 @@ function threadItemToTimeline( const text = summary || content; return text ? { type: "reasoning", text } : null; } - case "commandExecution": { - const output = { - type: "command", - command: item.command, - output: item.aggregatedOutput ?? "", - exitCode: item.exitCode ?? undefined, - }; - return createToolCallTimelineItem({ - name: "shell", - status: item.status, - callId: item.id, - input: { command: item.command, cwd: item.cwd }, - output, - }); - } - case "fileChange": { - const files = Array.isArray(item.changes) - ? item.changes.map((change: any) => ({ - path: normalizeCodexFilePath(change.path, cwd) ?? change.path, - kind: change.kind, - })) - : []; - const outputFiles = Array.isArray(item.changes) - ? item.changes.map((change: any) => ({ - path: normalizeCodexFilePath(change.path, cwd) ?? change.path, - patch: - typeof change.diff === "string" - ? truncateUtf8Bytes(change.diff, MAX_FILE_PATCH_BYTES).text - : change.diff, - kind: change.kind, - })) - : []; - return createToolCallTimelineItem({ - name: "apply_patch", - status: item.status, - callId: item.id, - input: { files }, - output: { files: outputFiles }, - }); - } - case "mcpToolCall": { - if (item.tool === "read_file") { - const pathValue = item.arguments?.path ?? item.arguments?.file_path ?? null; - const content = extractContentText(item.result?.content) ?? ""; - return createToolCallTimelineItem({ - name: "read_file", - status: item.status, - callId: item.id, - input: pathValue ? { path: pathValue } : item.arguments, - output: pathValue - ? { type: "read_file", path: pathValue, content } - : item.result ?? undefined, - error: item.error ?? undefined, - }); - } - return createToolCallTimelineItem({ - name: `${item.server}.${item.tool}`, - status: item.status, - callId: item.id, - input: item.arguments, - output: item.result ?? undefined, - error: item.error ?? undefined, - }); - } - case "webSearch": { - return createToolCallTimelineItem({ - name: "web_search", - status: "completed", - callId: item.id, - input: { query: item.query }, - output: item.action ?? undefined, - }); - } + case "commandExecution": + case "fileChange": + case "mcpToolCall": + case "webSearch": + return mapCodexToolCallFromThreadItem(item, { cwd }); default: return null; } @@ -856,21 +755,6 @@ function normalizeImageData(mimeType: string, data: string): ImageDataPayload { return { mimeType, data }; } -function truncateUtf8Bytes(text: string, maxBytes: number): { text: string; truncated: boolean } { - if (maxBytes <= 0) { - return { text: "", truncated: text.length > 0 }; - } - const bytes = Buffer.byteLength(text, "utf8"); - if (bytes <= maxBytes) { - return { text, truncated: false }; - } - const buffer = Buffer.from(text, "utf8"); - const sliced = buffer.subarray(0, maxBytes); - return { text: sliced.toString("utf8"), truncated: true }; -} - -const MAX_FILE_PATCH_BYTES = 128 * 1024; - const ThreadStartedNotificationSchema = z.object({ thread: z.object({ id: z.string() }).passthrough(), }).passthrough(); diff --git a/packages/server/src/server/agent/providers/codex-rollout-timeline.ts b/packages/server/src/server/agent/providers/codex-rollout-timeline.ts index 2b29ddf0c..5400c11cb 100644 --- a/packages/server/src/server/agent/providers/codex-rollout-timeline.ts +++ b/packages/server/src/server/agent/providers/codex-rollout-timeline.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import type { Logger } from "pino"; import type { AgentTimelineItem } from "../agent-sdk-types.js"; +import { mapCodexRolloutToolCall } from "./codex/tool-call-mapper.js"; const MAX_ROLLOUT_SEARCH_DEPTH = 4; @@ -434,14 +435,12 @@ export async function parseRolloutFile( ? [record.item] : record.kind === "call" ? [ - { - type: "tool_call", + mapCodexRolloutToolCall({ + callId: record.callId ?? null, name: record.name, - callId: record.callId, - status: "completed", - input: record.input, - output: record.callId ? outputsByCallId.get(record.callId) : undefined, - }, + input: record.input ?? null, + output: record.callId ? outputsByCallId.get(record.callId) ?? null : null, + }), ] : [] ); diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts new file mode 100644 index 000000000..f200422b0 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { + mapCodexRolloutToolCall, + mapCodexToolCallFromThreadItem, +} from "./tool-call-mapper.js"; + +describe("codex tool-call mapper", () => { + it("maps commandExecution start into running canonical call", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "commandExecution", + id: "codex-call-1", + status: "running", + command: "pwd", + cwd: "/tmp/repo", + }); + + expect(item).toBeTruthy(); + expect(item?.status).toBe("running"); + expect(item?.error).toBeNull(); + expect(item?.callId).toBe("codex-call-1"); + expect(item?.name).toBe("shell"); + expect(item?.input).toEqual({ command: "pwd", cwd: "/tmp/repo" }); + }); + + it("maps mcp read_file completion with detail", () => { + const item = mapCodexToolCallFromThreadItem( + { + type: "mcpToolCall", + id: "codex-call-2", + status: "completed", + tool: "read_file", + arguments: { path: "/tmp/repo/README.md" }, + result: { content: "hello" }, + }, + { cwd: "/tmp/repo" } + ); + + expect(item).toBeTruthy(); + expect(item?.status).toBe("completed"); + expect(item?.error).toBeNull(); + expect(item?.callId).toBe("codex-call-2"); + expect(item?.name).toBe("read_file"); + expect(item?.detail?.type).toBe("read"); + if (item?.detail?.type === "read") { + expect(item.detail.filePath).toBe("README.md"); + expect(item.detail.content).toBe("hello"); + } + }); + + it("maps failed tool calls with required error", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "mcpToolCall", + id: "codex-call-3", + status: "failed", + server: "custom", + tool: "run", + arguments: { foo: "bar" }, + result: null, + error: { message: "boom" }, + }); + + expect(item).toBeTruthy(); + expect(item?.status).toBe("failed"); + expect(item?.error).toEqual({ message: "boom" }); + expect(item?.callId).toBe("codex-call-3"); + }); + + it("keeps unknown tools canonical without detail", () => { + const item = mapCodexRolloutToolCall({ + callId: "codex-call-4", + name: "my_custom_tool", + input: { foo: "bar" }, + output: { ok: true }, + }); + + expect(item.status).toBe("completed"); + expect(item.error).toBeNull(); + expect(item.detail).toBeUndefined(); + expect(item.callId).toBe("codex-call-4"); + expect(item.input).toEqual({ foo: "bar" }); + expect(item.output).toEqual({ ok: true }); + }); +}); diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts new file mode 100644 index 000000000..fce0b48e5 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -0,0 +1,875 @@ +import { z } from "zod"; + +import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js"; + +type CodexMapperOptions = { cwd?: string | null }; + +const FAILED_STATUSES = new Set(["failed", "error", "errored", "rejected", "denied"]); +const CANCELED_STATUSES = new Set(["canceled", "cancelled", "interrupted", "aborted"]); +const COMPLETED_STATUSES = new Set(["completed", "complete", "done", "success", "succeeded"]); + +const CodexRolloutToolCallParamsSchema = z + .object({ + callId: z.string().optional().nullable(), + name: z.string().min(1), + input: z.unknown().optional(), + output: z.unknown().optional(), + error: z.unknown().optional(), + }) + .passthrough(); + +const CodexFileReferenceSchema = z + .object({ + file_path: z.string().optional(), + filePath: z.string().optional(), + path: z.string().optional(), + target_path: z.string().optional(), + targetPath: z.string().optional(), + }) + .passthrough(); + +const CodexFileReferenceCollectionSchema = CodexFileReferenceSchema.extend({ + files: z.array(CodexFileReferenceSchema).optional(), +}).passthrough(); + +const CodexTextLikeSchema = z + .object({ + output: z.string().optional(), + text: z.string().optional(), + content: z.string().optional(), + }) + .passthrough(); + +const CodexShellInputSchema = z + .object({ + command: z.union([z.string(), z.array(z.string())]).optional(), + cmd: z.union([z.string(), z.array(z.string())]).optional(), + cwd: z.string().optional(), + directory: z.string().optional(), + }) + .passthrough(); + +const CodexShellOutputObjectSchema = z + .object({ + command: z.string().optional(), + output: z.string().optional(), + text: z.string().optional(), + content: z.string().optional(), + exitCode: z.number().nullable().optional(), + exit_code: z.number().nullable().optional(), + metadata: z + .object({ + exitCode: z.number().nullable().optional(), + exit_code: z.number().nullable().optional(), + }) + .passthrough() + .optional(), + structuredContent: CodexTextLikeSchema.optional(), + structured_content: CodexTextLikeSchema.optional(), + result: CodexTextLikeSchema.optional(), + }) + .passthrough(); + +const CodexShellOutputSchema = z.union([z.string(), CodexShellOutputObjectSchema]); + +const CodexReadInputSchema = CodexFileReferenceCollectionSchema.extend({ + offset: z.number().finite().optional(), + limit: z.number().finite().optional(), +}).passthrough(); + +const CodexReadOutputSchema = z.union([ + z.string(), + CodexFileReferenceCollectionSchema.extend({ + content: z.string().optional(), + text: z.string().optional(), + output: z.string().optional(), + structuredContent: CodexTextLikeSchema.optional(), + structured_content: CodexTextLikeSchema.optional(), + data: CodexTextLikeSchema.optional(), + }).passthrough(), +]); + +const CodexWriteInputSchema = CodexFileReferenceCollectionSchema.extend({ + content: z.string().optional(), + newContent: z.string().optional(), + new_content: z.string().optional(), +}).passthrough(); + +const CodexWriteOutputSchema = CodexFileReferenceCollectionSchema.extend({ + content: z.string().optional(), + newContent: z.string().optional(), + new_content: z.string().optional(), +}).passthrough(); + +const CodexEditInputSchema = CodexFileReferenceCollectionSchema.extend({ + old_string: z.string().optional(), + old_str: z.string().optional(), + oldContent: z.string().optional(), + old_content: z.string().optional(), + new_string: z.string().optional(), + new_str: z.string().optional(), + newContent: z.string().optional(), + new_content: z.string().optional(), + content: z.string().optional(), + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), +}).passthrough(); + +const CodexEditOutputSchema = CodexFileReferenceCollectionSchema.extend({ + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), + files: z + .array( + CodexFileReferenceSchema.extend({ + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), + }).passthrough() + ) + .optional(), +}).passthrough(); + +const CodexSearchInputSchema = z + .object({ + query: z.string().optional(), + q: z.string().optional(), + }) + .passthrough(); + +const CodexShellToolNameSchema = z.union([ + z.literal("shell"), + z.literal("bash"), + z.literal("exec"), + z.literal("exec_command"), + z.literal("command"), + z.literal("Bash"), +]); + +const CodexReadToolNameSchema = z.union([ + z.literal("read"), + z.literal("read_file"), +]); + +const CodexWriteToolNameSchema = z.union([ + z.literal("write"), + z.literal("write_file"), + z.literal("create_file"), +]); + +const CodexEditToolNameSchema = z.union([ + z.literal("edit"), + z.literal("apply_patch"), +]); + +const CodexSearchToolNameSchema = z.union([ + z.literal("web_search"), + z.literal("search"), +]); + +const CodexBuiltinToolNameSchema = z.enum([ + "shell", + "bash", + "exec", + "exec_command", + "command", + "read", + "read_file", + "write", + "write_file", + "create_file", + "edit", + "apply_patch", + "web_search", + "search", +]); + +const CodexShellDetailCandidateSchema = z + .object({ + name: CodexShellToolNameSchema, + input: z.unknown().nullable(), + output: z.unknown().nullable(), + cwd: z.string().optional().nullable(), + }) + .transform(({ input, output }) => resolveShellDetail(input, output)); + +const CodexReadDetailCandidateSchema = z + .object({ + name: CodexReadToolNameSchema, + input: z.unknown().nullable(), + output: z.unknown().nullable(), + cwd: z.string().optional().nullable(), + }) + .transform(({ input, output, cwd }) => resolveReadDetail(input, output, { cwd })); + +const CodexWriteDetailCandidateSchema = z + .object({ + name: CodexWriteToolNameSchema, + input: z.unknown().nullable(), + output: z.unknown().nullable(), + cwd: z.string().optional().nullable(), + }) + .transform(({ input, output, cwd }) => resolveWriteDetail(input, output, { cwd })); + +const CodexEditDetailCandidateSchema = z + .object({ + name: CodexEditToolNameSchema, + input: z.unknown().nullable(), + output: z.unknown().nullable(), + cwd: z.string().optional().nullable(), + }) + .transform(({ input, output, cwd }) => resolveEditDetail(input, output, { cwd })); + +const CodexSearchDetailCandidateSchema = z + .object({ + name: CodexSearchToolNameSchema, + input: z.unknown().nullable(), + output: z.unknown().nullable(), + cwd: z.string().optional().nullable(), + }) + .transform(({ input }) => resolveSearchDetail(input)); + +const CodexKnownToolDetailSchema = z.union([ + CodexShellDetailCandidateSchema, + CodexReadDetailCandidateSchema, + CodexWriteDetailCandidateSchema, + CodexEditDetailCandidateSchema, + CodexSearchDetailCandidateSchema, +]); + +const CodexCommandExecutionItemSchema = z + .object({ + type: z.literal("commandExecution"), + id: z.string().optional(), + status: z.string().optional(), + error: z.unknown().optional(), + command: z.union([z.string(), z.array(z.string())]).optional(), + cwd: z.string().optional(), + aggregatedOutput: z.string().optional(), + exitCode: z.number().nullable().optional(), + }) + .passthrough(); + +const CodexFileChangeItemSchema = z + .object({ + type: z.literal("fileChange"), + id: z.string().optional(), + status: z.string().optional(), + error: z.unknown().optional(), + changes: z + .array( + z + .object({ + path: z.string().optional(), + kind: z.string().optional(), + diff: z.string().optional(), + }) + .passthrough() + ) + .optional(), + }) + .passthrough(); + +const CodexMcpToolCallItemSchema = z + .object({ + type: z.literal("mcpToolCall"), + id: z.string().optional(), + callID: z.string().optional(), + call_id: z.string().optional(), + status: z.string().optional(), + error: z.unknown().optional(), + tool: z.string().optional(), + server: z.string().optional(), + arguments: z.unknown().optional(), + result: z.unknown().optional(), + }) + .passthrough(); + +const CodexWebSearchItemSchema = z + .object({ + type: z.literal("webSearch"), + id: z.string().optional(), + status: z.string().optional(), + error: z.unknown().optional(), + query: z.string().optional(), + action: z.unknown().optional(), + }) + .passthrough(); + +const CodexThreadItemSchema = z.discriminatedUnion("type", [ + CodexCommandExecutionItemSchema, + CodexFileChangeItemSchema, + CodexMcpToolCallItemSchema, + CodexWebSearchItemSchema, +]); + +function hashText(value: string): string { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash << 5) - hash + value.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash).toString(36); +} + +function coerceCallId(raw: string | null | undefined, name: string, input: unknown): string { + if (typeof raw === "string" && raw.trim().length > 0) { + return raw; + } + let serialized = ""; + try { + serialized = JSON.stringify(input) ?? ""; + } catch { + serialized = String(input); + } + return `codex-${hashText(`${name}:${serialized}`)}`; +} + +function normalizeCodexFilePath(filePath: string | undefined, cwd: string | null | undefined): string | undefined { + if (typeof filePath !== "string") { + return undefined; + } + const trimmed = filePath.trim(); + if (!trimmed) { + return undefined; + } + if (typeof cwd === "string" && cwd.length > 0) { + const prefix = cwd.endsWith("/") ? cwd : `${cwd}/`; + if (trimmed.startsWith(prefix)) { + return trimmed.slice(prefix.length) || "."; + } + } + return trimmed; +} + +function firstNonEmpty(...values: Array): string | undefined { + return values.find((value) => typeof value === "string" && value.length > 0); +} + +function commandFromValue(value: string | string[] | undefined): string | undefined { + if (typeof value === "string" && value.length > 0) { + return value; + } + if (Array.isArray(value)) { + const tokens = value.filter((token): token is string => typeof token === "string" && token.length > 0); + if (tokens.length > 0) { + return tokens.join(" "); + } + } + return undefined; +} + +function resolveFilePath( + value: z.infer, + cwd: string | null | undefined +): string | undefined { + return normalizeCodexFilePath( + firstNonEmpty( + value.file_path, + value.filePath, + value.path, + value.target_path, + value.targetPath, + value.files?.[0]?.path, + value.files?.[0]?.filePath, + value.files?.[0]?.file_path + ), + cwd + ); +} + +function resolveStatus(rawStatus: string | undefined, error: unknown, output: unknown): ToolCallTimelineItem["status"] { + if (error !== undefined && error !== null) { + return "failed"; + } + + if (typeof rawStatus === "string") { + const normalized = rawStatus.trim().toLowerCase(); + if (normalized.length > 0) { + if (FAILED_STATUSES.has(normalized)) { + return "failed"; + } + if (CANCELED_STATUSES.has(normalized)) { + return "canceled"; + } + if (COMPLETED_STATUSES.has(normalized)) { + return "completed"; + } + return "running"; + } + } + + return output !== null && output !== undefined ? "completed" : "running"; +} + +function resolveShellDetail(input: unknown, output: unknown): ToolCallDetail | undefined { + const parsedInput = CodexShellInputSchema.safeParse(input); + const parsedOutput = CodexShellOutputSchema.safeParse(output); + + const command = + (parsedInput.success + ? commandFromValue(parsedInput.data.command) ?? commandFromValue(parsedInput.data.cmd) + : undefined) ?? + (parsedOutput.success && typeof parsedOutput.data !== "string" + ? parsedOutput.data.command + : undefined); + + if (!command) { + return undefined; + } + + const cwd = parsedInput.success + ? firstNonEmpty(parsedInput.data.cwd, parsedInput.data.directory) + : undefined; + + const outputText = + parsedOutput.success + ? typeof parsedOutput.data === "string" + ? parsedOutput.data + : firstNonEmpty( + parsedOutput.data.output, + parsedOutput.data.text, + parsedOutput.data.content, + parsedOutput.data.structuredContent?.output, + parsedOutput.data.structuredContent?.text, + parsedOutput.data.structuredContent?.content, + parsedOutput.data.structured_content?.output, + parsedOutput.data.structured_content?.text, + parsedOutput.data.structured_content?.content, + parsedOutput.data.result?.output, + parsedOutput.data.result?.text, + parsedOutput.data.result?.content + ) + : undefined; + + const exitCode = + parsedOutput.success && typeof parsedOutput.data !== "string" + ? parsedOutput.data.exitCode ?? + parsedOutput.data.exit_code ?? + parsedOutput.data.metadata?.exitCode ?? + parsedOutput.data.metadata?.exit_code ?? + null + : null; + + return { + type: "shell", + command, + ...(cwd !== undefined ? { cwd } : {}), + ...(outputText !== undefined ? { output: outputText } : {}), + ...(exitCode !== null ? { exitCode } : { exitCode: null }), + }; +} + +function resolveReadDetail(input: unknown, output: unknown, options?: CodexMapperOptions): ToolCallDetail | undefined { + const parsedInput = CodexReadInputSchema.safeParse(input); + const parsedOutput = CodexReadOutputSchema.safeParse(output); + + const filePath = firstNonEmpty( + parsedInput.success ? resolveFilePath(parsedInput.data, options?.cwd) : undefined, + parsedOutput.success && typeof parsedOutput.data !== "string" + ? resolveFilePath(parsedOutput.data, options?.cwd) + : undefined + ); + + if (!filePath) { + return undefined; + } + + const content = + parsedOutput.success + ? typeof parsedOutput.data === "string" + ? parsedOutput.data + : firstNonEmpty( + parsedOutput.data.content, + parsedOutput.data.text, + parsedOutput.data.output, + parsedOutput.data.structuredContent?.content, + parsedOutput.data.structuredContent?.text, + parsedOutput.data.structuredContent?.output, + parsedOutput.data.structured_content?.content, + parsedOutput.data.structured_content?.text, + parsedOutput.data.structured_content?.output, + parsedOutput.data.data?.content, + parsedOutput.data.data?.text, + parsedOutput.data.data?.output + ) + : undefined; + + return { + type: "read", + filePath, + ...(content !== undefined ? { content } : {}), + ...(parsedInput.success && parsedInput.data.offset !== undefined ? { offset: parsedInput.data.offset } : {}), + ...(parsedInput.success && parsedInput.data.limit !== undefined ? { limit: parsedInput.data.limit } : {}), + }; +} + +function resolveWriteDetail(input: unknown, output: unknown, options?: CodexMapperOptions): ToolCallDetail | undefined { + const parsedInput = CodexWriteInputSchema.safeParse(input); + const parsedOutput = CodexWriteOutputSchema.safeParse(output); + + const filePath = firstNonEmpty( + parsedInput.success ? resolveFilePath(parsedInput.data, options?.cwd) : undefined, + parsedOutput.success ? resolveFilePath(parsedOutput.data, options?.cwd) : undefined + ); + + if (!filePath) { + return undefined; + } + + const content = firstNonEmpty( + parsedInput.success + ? firstNonEmpty(parsedInput.data.content, parsedInput.data.newContent, parsedInput.data.new_content) + : undefined, + parsedOutput.success + ? firstNonEmpty(parsedOutput.data.content, parsedOutput.data.newContent, parsedOutput.data.new_content) + : undefined + ); + + return { + type: "write", + filePath, + ...(content !== undefined ? { content } : {}), + }; +} + +function resolveEditDetail(input: unknown, output: unknown, options?: CodexMapperOptions): ToolCallDetail | undefined { + const parsedInput = CodexEditInputSchema.safeParse(input); + const parsedOutput = CodexEditOutputSchema.safeParse(output); + + const filePath = firstNonEmpty( + parsedInput.success ? resolveFilePath(parsedInput.data, options?.cwd) : undefined, + parsedOutput.success ? resolveFilePath(parsedOutput.data, options?.cwd) : undefined + ); + + if (!filePath) { + return undefined; + } + + const oldString = parsedInput.success + ? firstNonEmpty( + parsedInput.data.old_string, + parsedInput.data.old_str, + parsedInput.data.oldContent, + parsedInput.data.old_content + ) + : undefined; + + const newString = parsedInput.success + ? firstNonEmpty( + parsedInput.data.new_string, + parsedInput.data.new_str, + parsedInput.data.newContent, + parsedInput.data.new_content, + parsedInput.data.content + ) + : undefined; + + const unifiedDiff = firstNonEmpty( + parsedInput.success + ? firstNonEmpty( + parsedInput.data.patch, + parsedInput.data.diff, + parsedInput.data.unified_diff, + parsedInput.data.unifiedDiff + ) + : undefined, + parsedOutput.success + ? firstNonEmpty( + parsedOutput.data.patch, + parsedOutput.data.diff, + parsedOutput.data.unified_diff, + parsedOutput.data.unifiedDiff, + parsedOutput.data.files?.[0]?.patch, + parsedOutput.data.files?.[0]?.diff, + parsedOutput.data.files?.[0]?.unified_diff, + parsedOutput.data.files?.[0]?.unifiedDiff + ) + : undefined + ); + + return { + type: "edit", + filePath, + ...(oldString !== undefined ? { oldString } : {}), + ...(newString !== undefined ? { newString } : {}), + ...(unifiedDiff !== undefined ? { unifiedDiff } : {}), + }; +} + +function resolveSearchDetail(input: unknown): ToolCallDetail | undefined { + const parsedInput = CodexSearchInputSchema.safeParse(input); + if (!parsedInput.success) { + return undefined; + } + const query = firstNonEmpty(parsedInput.data.query, parsedInput.data.q); + if (!query) { + return undefined; + } + return { + type: "search", + query, + }; +} + +function deriveDetail(name: string, input: unknown, output: unknown, options?: CodexMapperOptions): ToolCallDetail | undefined { + const parsed = CodexKnownToolDetailSchema.safeParse({ + name, + input, + output, + cwd: options?.cwd ?? null, + }); + if (!parsed.success) { + return undefined; + } + return parsed.data; +} + +function buildToolCall( + params: { + callId: string; + name: string; + status: ToolCallTimelineItem["status"]; + input: unknown | null; + output: unknown | null; + error: unknown | null; + metadata?: Record; + }, + options?: CodexMapperOptions +): ToolCallTimelineItem { + const detail = deriveDetail(params.name, params.input, params.output, options); + + if (params.status === "failed") { + return { + type: "tool_call", + callId: params.callId, + name: params.name, + status: "failed", + input: params.input, + output: params.output, + error: params.error ?? { message: "Tool call failed" }, + ...(detail ? { detail } : {}), + ...(params.metadata ? { metadata: params.metadata } : {}), + }; + } + + return { + type: "tool_call", + callId: params.callId, + name: params.name, + status: params.status, + input: params.input, + output: params.output, + error: null, + ...(detail ? { detail } : {}), + ...(params.metadata ? { metadata: params.metadata } : {}), + }; +} + +function buildMcpToolName(server: string | undefined, tool: string): string { + const trimmedTool = tool.trim(); + if (!trimmedTool) { + return "tool"; + } + + const builtin = CodexBuiltinToolNameSchema.safeParse(trimmedTool); + if (builtin.success) { + return builtin.data; + } + + const trimmedServer = typeof server === "string" ? server.trim() : ""; + if (trimmedServer.length > 0) { + return `${trimmedServer}.${trimmedTool}`; + } + + return trimmedTool; +} + +function toNullableObject(value: Record): Record | null { + return Object.keys(value).length > 0 ? value : null; +} + +function mapCommandExecutionItem( + item: z.infer, + options?: CodexMapperOptions +): ToolCallTimelineItem { + const command = commandFromValue(item.command); + const input = toNullableObject({ + ...(command !== undefined ? { command } : {}), + ...(item.cwd !== undefined ? { cwd: item.cwd } : {}), + }); + + const output = + item.aggregatedOutput !== undefined || item.exitCode !== undefined + ? { + ...(command !== undefined ? { command } : {}), + ...(item.aggregatedOutput !== undefined ? { output: item.aggregatedOutput } : {}), + ...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}), + } + : null; + + const name = "shell"; + const callId = coerceCallId(item.id, name, input); + const error = item.error ?? null; + const status = resolveStatus(item.status, error, output); + + return buildToolCall( + { + callId, + name, + status, + input, + output, + error, + }, + options + ); +} + +function mapFileChangeItem( + item: z.infer, + options?: CodexMapperOptions +): ToolCallTimelineItem { + const changes = item.changes ?? []; + + const files = changes.map((change) => ({ + ...(normalizeCodexFilePath(change.path, options?.cwd) !== undefined + ? { path: normalizeCodexFilePath(change.path, options?.cwd) } + : {}), + ...(change.kind !== undefined ? { kind: change.kind } : {}), + })); + + const outputFiles = changes.map((change) => ({ + ...(normalizeCodexFilePath(change.path, options?.cwd) !== undefined + ? { path: normalizeCodexFilePath(change.path, options?.cwd) } + : {}), + ...(change.diff !== undefined ? { patch: change.diff } : {}), + ...(change.kind !== undefined ? { kind: change.kind } : {}), + })); + + const input = toNullableObject({ ...(files.length > 0 ? { files } : {}) }); + const output = toNullableObject({ ...(outputFiles.length > 0 ? { files: outputFiles } : {}) }); + const name = "apply_patch"; + const callId = coerceCallId(item.id, name, input); + const error = item.error ?? null; + const status = resolveStatus(item.status, error, output); + + return buildToolCall( + { + callId, + name, + status, + input, + output, + error, + }, + options + ); +} + +function mapMcpToolCallItem( + item: z.infer, + options?: CodexMapperOptions +): ToolCallTimelineItem { + const tool = item.tool?.trim() || "tool"; + const name = buildMcpToolName(item.server, tool); + const input = item.arguments ?? null; + const output = item.result ?? null; + const error = item.error ?? null; + const callId = coerceCallId(item.id ?? item.callID ?? item.call_id, name, input); + const status = resolveStatus(item.status, error, output); + + return buildToolCall( + { + callId, + name, + status, + input, + output, + error, + }, + options + ); +} + +function mapWebSearchItem( + item: z.infer, + options?: CodexMapperOptions +): ToolCallTimelineItem { + const input = item.query !== undefined ? { query: item.query } : null; + const output = item.action ?? null; + const name = "web_search"; + const callId = coerceCallId(item.id, name, input); + const error = item.error ?? null; + const status = resolveStatus(item.status ?? "completed", error, output); + + return buildToolCall( + { + callId, + name, + status, + input, + output, + error, + }, + options + ); +} + +function createCodexThreadItemToTimelineSchema(options?: CodexMapperOptions) { + return CodexThreadItemSchema.transform((item): ToolCallTimelineItem => { + switch (item.type) { + case "commandExecution": + return mapCommandExecutionItem(item, options); + case "fileChange": + return mapFileChangeItem(item, options); + case "mcpToolCall": + return mapMcpToolCallItem(item, options); + case "webSearch": + return mapWebSearchItem(item, options); + default: { + const exhaustiveCheck: never = item; + throw new Error(`Unhandled Codex thread item type: ${String(exhaustiveCheck)}`); + } + } + }); +} + +export function mapCodexToolCallFromThreadItem( + item: unknown, + options?: CodexMapperOptions +): ToolCallTimelineItem | null { + const parsed = createCodexThreadItemToTimelineSchema(options).safeParse(item); + if (!parsed.success) { + return null; + } + return parsed.data; +} + +export function mapCodexRolloutToolCall(params: { + callId?: string | null; + name: string; + input?: unknown; + output?: unknown; + error?: unknown; +}): ToolCallTimelineItem { + const parsed = CodexRolloutToolCallParamsSchema.parse(params); + const input = parsed.input ?? null; + const output = parsed.output ?? null; + const error = parsed.error ?? null; + const status = resolveStatus("completed", error, output); + const callId = coerceCallId(parsed.callId, parsed.name, input); + + return buildToolCall({ + callId, + name: parsed.name, + status, + input, + output, + error, + }); +} diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 50843c8ce..02f893e43 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -26,6 +26,7 @@ import type { McpServerConfig, PersistedAgentDescriptor, } from "../agent-sdk-types.js"; +import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; const OPENCODE_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, @@ -620,15 +621,14 @@ class OpenCodeAgentSession implements AgentSession { yield { type: "timeline", provider: "opencode", - item: { - type: "tool_call", - name: toolName, + item: mapOpencodeToolCall({ + toolName, callId: toolPart.callID ?? toolPart.id, - status: this.mapToolState(state?.status), + status: state?.status, input: state?.input, output: state?.output, error: state?.error, - }, + }), }; } } @@ -895,15 +895,14 @@ class OpenCodeAgentSession implements AgentSession { events.push({ type: "timeline", provider: "opencode", - item: { - type: "tool_call", - name: toolName, - callId: part.callID as string | undefined, - status: this.mapToolState(status), + item: mapOpencodeToolCall({ + toolName, + callId: (part.callID as string | undefined) ?? (part.id as string | undefined), + status, input, output, error, - }, + }), }); } } else if (partType === "step-finish") { @@ -983,21 +982,6 @@ class OpenCodeAgentSession implements AgentSession { return events; } - private mapToolState(state?: string): string { - switch (state) { - case "pending": - return "pending"; - case "running": - return "running"; - case "complete": - return "completed"; - case "error": - return "failed"; - default: - return "pending"; - } - } - private extractAndResetUsage(): AgentUsage | undefined { const usage = this.accumulatedUsage; this.accumulatedUsage = {}; diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts new file mode 100644 index 000000000..dd0ff30bb --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { mapOpencodeToolCall } from "./tool-call-mapper.js"; + +describe("opencode tool-call mapper", () => { + it("maps running shell calls", () => { + const item = mapOpencodeToolCall({ + toolName: "shell", + callId: "opencode-call-1", + status: "running", + input: { command: "pwd", cwd: "/tmp/repo" }, + output: null, + }); + + expect(item.status).toBe("running"); + expect(item.error).toBeNull(); + expect(item.callId).toBe("opencode-call-1"); + expect(item.detail?.type).toBe("shell"); + if (item.detail?.type === "shell") { + expect(item.detail.command).toBe("pwd"); + } + }); + + it("maps completed read calls", () => { + const item = mapOpencodeToolCall({ + toolName: "read_file", + callId: "opencode-call-2", + status: "complete", + input: { file_path: "README.md" }, + output: { content: "hello" }, + }); + + expect(item.status).toBe("completed"); + expect(item.error).toBeNull(); + expect(item.callId).toBe("opencode-call-2"); + expect(item.detail?.type).toBe("read"); + if (item.detail?.type === "read") { + expect(item.detail.filePath).toBe("README.md"); + expect(item.detail.content).toBe("hello"); + } + }); + + it("maps failed calls with required error", () => { + const item = mapOpencodeToolCall({ + toolName: "shell", + callId: "opencode-call-3", + status: "error", + input: { command: "false" }, + output: null, + error: "command failed", + }); + + expect(item.status).toBe("failed"); + expect(item.error).toBe("command failed"); + expect(item.callId).toBe("opencode-call-3"); + }); + + it("keeps unknown tools canonical without detail", () => { + const item = mapOpencodeToolCall({ + toolName: "my_custom_tool", + callId: "opencode-call-4", + status: "completed", + input: { foo: "bar" }, + output: { ok: true }, + }); + + expect(item.status).toBe("completed"); + expect(item.error).toBeNull(); + expect(item.detail).toBeUndefined(); + expect(item.input).toEqual({ foo: "bar" }); + expect(item.output).toEqual({ ok: true }); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts new file mode 100644 index 000000000..6431a734f --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts @@ -0,0 +1,561 @@ +import { z } from "zod"; + +import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js"; + +type OpencodeToolCallParams = { + toolName: string; + callId?: string | null; + status?: unknown; + input?: unknown; + output?: unknown; + error?: unknown; + metadata?: Record; +}; + +const FAILED_STATUSES = new Set(["error", "failed", "failure"]); +const CANCELED_STATUSES = new Set(["canceled", "cancelled", "aborted", "interrupted"]); +const COMPLETED_STATUSES = new Set(["complete", "completed", "success", "succeeded", "done"]); + +const OpencodeToolCallParamsSchema = z + .object({ + toolName: z.string().min(1), + callId: z.string().optional().nullable(), + status: z.unknown().optional(), + input: z.unknown().optional(), + output: z.unknown().optional(), + error: z.unknown().optional(), + metadata: z.record(z.unknown()).optional(), + }) + .passthrough(); + +const OpencodeShellToolNameSchema = z.union([ + z.literal("shell"), + z.literal("bash"), + z.literal("exec_command"), +]); + +const OpencodeReadToolNameSchema = z.union([ + z.literal("read"), + z.literal("read_file"), +]); + +const OpencodeWriteToolNameSchema = z.union([ + z.literal("write"), + z.literal("write_file"), + z.literal("create_file"), +]); + +const OpencodeEditToolNameSchema = z.union([ + z.literal("edit"), + z.literal("apply_patch"), + z.literal("apply_diff"), +]); + +const OpencodeSearchToolNameSchema = z.union([ + z.literal("search"), + z.literal("web_search"), +]); + +const OpencodeFileReferenceSchema = z + .object({ + file_path: z.string().optional(), + filePath: z.string().optional(), + path: z.string().optional(), + target_path: z.string().optional(), + targetPath: z.string().optional(), + }) + .passthrough(); + +const OpencodeFileReferenceCollectionSchema = OpencodeFileReferenceSchema.extend({ + files: z.array(OpencodeFileReferenceSchema).optional(), +}).passthrough(); + +const OpencodeTextLikeSchema = z + .object({ + output: z.string().optional(), + text: z.string().optional(), + content: z.string().optional(), + }) + .passthrough(); + +const OpencodeShellInputSchema = z + .object({ + command: z.union([z.string(), z.array(z.string())]).optional(), + cmd: z.union([z.string(), z.array(z.string())]).optional(), + cwd: z.string().optional(), + directory: z.string().optional(), + }) + .passthrough(); + +const OpencodeShellOutputObjectSchema = z + .object({ + command: z.string().optional(), + output: z.string().optional(), + text: z.string().optional(), + content: z.string().optional(), + exitCode: z.number().nullable().optional(), + exit_code: z.number().nullable().optional(), + metadata: z + .object({ + exitCode: z.number().nullable().optional(), + exit_code: z.number().nullable().optional(), + }) + .passthrough() + .optional(), + structuredContent: OpencodeTextLikeSchema.optional(), + structured_content: OpencodeTextLikeSchema.optional(), + result: OpencodeTextLikeSchema.optional(), + }) + .passthrough(); + +const OpencodeShellOutputSchema = z.union([z.string(), OpencodeShellOutputObjectSchema]); + +const OpencodeReadInputSchema = OpencodeFileReferenceCollectionSchema.extend({ + offset: z.number().finite().optional(), + limit: z.number().finite().optional(), +}).passthrough(); + +const OpencodeReadOutputSchema = z.union([ + z.string(), + OpencodeFileReferenceCollectionSchema.extend({ + content: z.string().optional(), + text: z.string().optional(), + output: z.string().optional(), + structuredContent: OpencodeTextLikeSchema.optional(), + structured_content: OpencodeTextLikeSchema.optional(), + data: OpencodeTextLikeSchema.optional(), + }).passthrough(), +]); + +const OpencodeWriteInputSchema = OpencodeFileReferenceCollectionSchema.extend({ + content: z.string().optional(), + newContent: z.string().optional(), + new_content: z.string().optional(), +}).passthrough(); + +const OpencodeWriteOutputSchema = OpencodeFileReferenceCollectionSchema.extend({ + content: z.string().optional(), + newContent: z.string().optional(), + new_content: z.string().optional(), +}).passthrough(); + +const OpencodeEditInputSchema = OpencodeFileReferenceCollectionSchema.extend({ + old_string: z.string().optional(), + old_str: z.string().optional(), + oldContent: z.string().optional(), + old_content: z.string().optional(), + new_string: z.string().optional(), + new_str: z.string().optional(), + newContent: z.string().optional(), + new_content: z.string().optional(), + content: z.string().optional(), + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), +}).passthrough(); + +const OpencodeEditOutputSchema = OpencodeFileReferenceCollectionSchema.extend({ + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), + files: z + .array( + OpencodeFileReferenceSchema.extend({ + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), + }).passthrough() + ) + .optional(), +}).passthrough(); + +const OpencodeSearchInputSchema = z + .object({ + query: z.string().optional(), + q: z.string().optional(), + }) + .passthrough(); + +const OpencodeShellDetailCandidateSchema = z + .object({ + toolName: OpencodeShellToolNameSchema, + input: z.unknown().nullable(), + output: z.unknown().nullable(), + }) + .transform(({ input, output }) => resolveShellDetail(input, output)); + +const OpencodeReadDetailCandidateSchema = z + .object({ + toolName: OpencodeReadToolNameSchema, + input: z.unknown().nullable(), + output: z.unknown().nullable(), + }) + .transform(({ input, output }) => resolveReadDetail(input, output)); + +const OpencodeWriteDetailCandidateSchema = z + .object({ + toolName: OpencodeWriteToolNameSchema, + input: z.unknown().nullable(), + output: z.unknown().nullable(), + }) + .transform(({ input, output }) => resolveWriteDetail(input, output)); + +const OpencodeEditDetailCandidateSchema = z + .object({ + toolName: OpencodeEditToolNameSchema, + input: z.unknown().nullable(), + output: z.unknown().nullable(), + }) + .transform(({ input, output }) => resolveEditDetail(input, output)); + +const OpencodeSearchDetailCandidateSchema = z + .object({ + toolName: OpencodeSearchToolNameSchema, + input: z.unknown().nullable(), + output: z.unknown().nullable(), + }) + .transform(({ input }) => resolveSearchDetail(input)); + +const OpencodeKnownToolDetailSchema = z.union([ + OpencodeShellDetailCandidateSchema, + OpencodeReadDetailCandidateSchema, + OpencodeWriteDetailCandidateSchema, + OpencodeEditDetailCandidateSchema, + OpencodeSearchDetailCandidateSchema, +]); + +function firstNonEmpty(...values: Array): string | undefined { + return values.find((value) => typeof value === "string" && value.length > 0); +} + +function commandFromValue(value: string | string[] | undefined): string | undefined { + if (typeof value === "string" && value.length > 0) { + return value; + } + if (Array.isArray(value)) { + const tokens = value.filter((token): token is string => typeof token === "string" && token.length > 0); + if (tokens.length > 0) { + return tokens.join(" "); + } + } + return undefined; +} + +function resolveFilePath(value: z.infer): string | undefined { + return firstNonEmpty( + value.file_path, + value.filePath, + value.path, + value.target_path, + value.targetPath, + value.files?.[0]?.path, + value.files?.[0]?.filePath, + value.files?.[0]?.file_path + ); +} + +function hashText(value: string): string { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash << 5) - hash + value.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash).toString(36); +} + +function coerceCallId(callId: string | null | undefined, toolName: string, input: unknown): string { + if (typeof callId === "string" && callId.trim().length > 0) { + return callId; + } + + let serialized = ""; + try { + serialized = JSON.stringify(input) ?? ""; + } catch { + serialized = String(input); + } + + return `opencode-${hashText(`${toolName}:${serialized}`)}`; +} + +function resolveStatus(rawStatus: unknown, error: unknown, output: unknown): ToolCallTimelineItem["status"] { + if (error !== null && error !== undefined) { + return "failed"; + } + + if (typeof rawStatus === "string") { + const normalized = rawStatus.trim().toLowerCase(); + if (normalized.length > 0) { + if (FAILED_STATUSES.has(normalized)) { + return "failed"; + } + if (CANCELED_STATUSES.has(normalized)) { + return "canceled"; + } + if (COMPLETED_STATUSES.has(normalized)) { + return "completed"; + } + return "running"; + } + } + + return output !== null && output !== undefined ? "completed" : "running"; +} + +function resolveShellDetail(input: unknown, output: unknown): ToolCallDetail | undefined { + const parsedInput = OpencodeShellInputSchema.safeParse(input); + const parsedOutput = OpencodeShellOutputSchema.safeParse(output); + + const command = + (parsedInput.success + ? commandFromValue(parsedInput.data.command) ?? commandFromValue(parsedInput.data.cmd) + : undefined) ?? + (parsedOutput.success && typeof parsedOutput.data !== "string" + ? parsedOutput.data.command + : undefined); + + if (!command) { + return undefined; + } + + const cwd = parsedInput.success + ? firstNonEmpty(parsedInput.data.cwd, parsedInput.data.directory) + : undefined; + + const outputText = + parsedOutput.success + ? typeof parsedOutput.data === "string" + ? parsedOutput.data + : firstNonEmpty( + parsedOutput.data.output, + parsedOutput.data.text, + parsedOutput.data.content, + parsedOutput.data.structuredContent?.output, + parsedOutput.data.structuredContent?.text, + parsedOutput.data.structuredContent?.content, + parsedOutput.data.structured_content?.output, + parsedOutput.data.structured_content?.text, + parsedOutput.data.structured_content?.content, + parsedOutput.data.result?.output, + parsedOutput.data.result?.text, + parsedOutput.data.result?.content + ) + : undefined; + + const exitCode = + parsedOutput.success && typeof parsedOutput.data !== "string" + ? parsedOutput.data.exitCode ?? parsedOutput.data.exit_code ?? parsedOutput.data.metadata?.exitCode ?? parsedOutput.data.metadata?.exit_code ?? null + : null; + + return { + type: "shell", + command, + ...(cwd !== undefined ? { cwd } : {}), + ...(outputText !== undefined ? { output: outputText } : {}), + ...(exitCode !== null ? { exitCode } : { exitCode: null }), + }; +} + +function resolveReadDetail(input: unknown, output: unknown): ToolCallDetail | undefined { + const parsedInput = OpencodeReadInputSchema.safeParse(input); + const parsedOutput = OpencodeReadOutputSchema.safeParse(output); + + const filePath = firstNonEmpty( + parsedInput.success ? resolveFilePath(parsedInput.data) : undefined, + parsedOutput.success && typeof parsedOutput.data !== "string" + ? resolveFilePath(parsedOutput.data) + : undefined + ); + + if (!filePath) { + return undefined; + } + + const content = + parsedOutput.success + ? typeof parsedOutput.data === "string" + ? parsedOutput.data + : firstNonEmpty( + parsedOutput.data.content, + parsedOutput.data.text, + parsedOutput.data.output, + parsedOutput.data.structuredContent?.content, + parsedOutput.data.structuredContent?.text, + parsedOutput.data.structuredContent?.output, + parsedOutput.data.structured_content?.content, + parsedOutput.data.structured_content?.text, + parsedOutput.data.structured_content?.output, + parsedOutput.data.data?.content, + parsedOutput.data.data?.text, + parsedOutput.data.data?.output + ) + : undefined; + + return { + type: "read", + filePath, + ...(content !== undefined ? { content } : {}), + ...(parsedInput.success && parsedInput.data.offset !== undefined ? { offset: parsedInput.data.offset } : {}), + ...(parsedInput.success && parsedInput.data.limit !== undefined ? { limit: parsedInput.data.limit } : {}), + }; +} + +function resolveWriteDetail(input: unknown, output: unknown): ToolCallDetail | undefined { + const parsedInput = OpencodeWriteInputSchema.safeParse(input); + const parsedOutput = OpencodeWriteOutputSchema.safeParse(output); + + const filePath = firstNonEmpty( + parsedInput.success ? resolveFilePath(parsedInput.data) : undefined, + parsedOutput.success ? resolveFilePath(parsedOutput.data) : undefined + ); + + if (!filePath) { + return undefined; + } + + const content = firstNonEmpty( + parsedInput.success + ? firstNonEmpty(parsedInput.data.content, parsedInput.data.newContent, parsedInput.data.new_content) + : undefined, + parsedOutput.success + ? firstNonEmpty(parsedOutput.data.content, parsedOutput.data.newContent, parsedOutput.data.new_content) + : undefined + ); + + return { + type: "write", + filePath, + ...(content !== undefined ? { content } : {}), + }; +} + +function resolveEditDetail(input: unknown, output: unknown): ToolCallDetail | undefined { + const parsedInput = OpencodeEditInputSchema.safeParse(input); + const parsedOutput = OpencodeEditOutputSchema.safeParse(output); + + const filePath = firstNonEmpty( + parsedInput.success ? resolveFilePath(parsedInput.data) : undefined, + parsedOutput.success ? resolveFilePath(parsedOutput.data) : undefined + ); + + if (!filePath) { + return undefined; + } + + const oldString = parsedInput.success + ? firstNonEmpty( + parsedInput.data.old_string, + parsedInput.data.old_str, + parsedInput.data.oldContent, + parsedInput.data.old_content + ) + : undefined; + + const newString = parsedInput.success + ? firstNonEmpty( + parsedInput.data.new_string, + parsedInput.data.new_str, + parsedInput.data.newContent, + parsedInput.data.new_content, + parsedInput.data.content + ) + : undefined; + + const unifiedDiff = firstNonEmpty( + parsedInput.success + ? firstNonEmpty( + parsedInput.data.patch, + parsedInput.data.diff, + parsedInput.data.unified_diff, + parsedInput.data.unifiedDiff + ) + : undefined, + parsedOutput.success + ? firstNonEmpty( + parsedOutput.data.patch, + parsedOutput.data.diff, + parsedOutput.data.unified_diff, + parsedOutput.data.unifiedDiff, + parsedOutput.data.files?.[0]?.patch, + parsedOutput.data.files?.[0]?.diff, + parsedOutput.data.files?.[0]?.unified_diff, + parsedOutput.data.files?.[0]?.unifiedDiff + ) + : undefined + ); + + return { + type: "edit", + filePath, + ...(oldString !== undefined ? { oldString } : {}), + ...(newString !== undefined ? { newString } : {}), + ...(unifiedDiff !== undefined ? { unifiedDiff } : {}), + }; +} + +function resolveSearchDetail(input: unknown): ToolCallDetail | undefined { + const parsedInput = OpencodeSearchInputSchema.safeParse(input); + if (!parsedInput.success) { + return undefined; + } + + const query = firstNonEmpty(parsedInput.data.query, parsedInput.data.q); + if (!query) { + return undefined; + } + + return { + type: "search", + query, + }; +} + +function deriveDetail(toolName: string, input: unknown, output: unknown): ToolCallDetail | undefined { + const parsed = OpencodeKnownToolDetailSchema.safeParse({ + toolName, + input, + output, + }); + if (!parsed.success) { + return undefined; + } + return parsed.data; +} + +export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTimelineItem { + const parsedParams = OpencodeToolCallParamsSchema.parse(params); + const input = parsedParams.input ?? null; + const output = parsedParams.output ?? null; + const status = resolveStatus(parsedParams.status, parsedParams.error, output); + const callId = coerceCallId(parsedParams.callId, parsedParams.toolName, input); + const detail = deriveDetail(parsedParams.toolName, input, output); + + if (status === "failed") { + return { + type: "tool_call", + callId, + name: parsedParams.toolName, + status: "failed", + input, + output, + error: parsedParams.error ?? { message: "Tool call failed" }, + ...(detail ? { detail } : {}), + ...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}), + }; + } + + return { + type: "tool_call", + callId, + name: parsedParams.toolName, + status, + input, + output, + error: null, + ...(detail ? { detail } : {}), + ...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}), + }; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9ee0d58c2..d12afffcb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1929,6 +1929,8 @@ export class Session { worktreePath: worktree.worktreePath, branchName: worktree.branchName, }, + output: null, + error: null, }); if (!started) { return; @@ -1945,6 +1947,10 @@ export class Session { name: "paseo_worktree_setup", callId, status: "completed", + input: { + worktreePath: worktree.worktreePath, + branchName: worktree.branchName, + }, output: { worktreePath: worktree.worktreePath, commands: results.map((result) => ({ @@ -1954,6 +1960,7 @@ export class Session { output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(), })), }, + error: null, }); } catch (error: any) { if (error instanceof WorktreeSetupError) { @@ -1965,6 +1972,10 @@ export class Session { name: "paseo_worktree_setup", callId, status: "failed", + input: { + worktreePath: worktree.worktreePath, + branchName: worktree.branchName, + }, output: { worktreePath: worktree.worktreePath, commands: results.map((result) => ({ diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 14282df2a..141024df0 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -246,7 +246,9 @@ class FakeAgentSession implements AgentSession { name: tool.name, callId, status: "running", - input: tool.input ?? undefined, + input: tool.input ?? null, + output: null, + error: null, }, }; await this.appendHistoryEvent(toolRunning); @@ -325,8 +327,9 @@ class FakeAgentSession implements AgentSession { name: tool.name, callId, status: "completed", - input: tool.input ?? undefined, + input: tool.input ?? null, output: toolOutput ?? { ok: true }, + error: null, }, }; await this.appendHistoryEvent(toolCompleted); diff --git a/packages/server/src/shared/__fixtures__/legacy-agent-stream-snapshot-inProgress.json b/packages/server/src/shared/__fixtures__/legacy-agent-stream-snapshot-inProgress.json new file mode 100644 index 000000000..7ed165b59 --- /dev/null +++ b/packages/server/src/shared/__fixtures__/legacy-agent-stream-snapshot-inProgress.json @@ -0,0 +1,24 @@ +{ + "type": "agent_stream_snapshot", + "payload": { + "agentId": "agent_fixture_legacy", + "events": [ + { + "timestamp": "2026-02-08T20:00:00.000Z", + "event": { + "type": "timeline", + "provider": "codex", + "item": { + "type": "tool_call", + "callId": "call_fixture_legacy", + "name": "shell", + "status": "inProgress", + "input": { + "command": "pwd" + } + } + } + } + ] + } +} diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts new file mode 100644 index 000000000..9fbf8b259 --- /dev/null +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -0,0 +1,84 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { + AgentStreamMessageSchema, + AgentStreamSnapshotMessageSchema, + WSOutboundMessageSchema, +} from "./messages.js"; + +function loadFixture(name: string): unknown { + const url = new URL(`./__fixtures__/${name}`, import.meta.url); + return JSON.parse(readFileSync(url, "utf8")); +} + +describe("shared messages stream parsing", () => { + it("parses legacy inProgress tool_call snapshots and normalizes status", () => { + const fixture = loadFixture("legacy-agent-stream-snapshot-inProgress.json"); + const parsed = AgentStreamSnapshotMessageSchema.parse(fixture); + + const first = parsed.payload.events[0]?.event; + expect(first?.type).toBe("timeline"); + if (first?.type === "timeline" && first.item.type === "tool_call") { + expect(first.item.status).toBe("running"); + expect(first.item.error).toBeNull(); + expect(first.item.output).toBeNull(); + } + }); + + it("parses representative agent_stream tool_call event", () => { + const parsed = AgentStreamMessageSchema.parse({ + type: "agent_stream", + payload: { + agentId: "agent_live", + timestamp: "2026-02-08T20:10:00.000Z", + event: { + type: "timeline", + provider: "claude", + item: { + type: "tool_call", + callId: "call_live", + name: "shell", + status: "running", + input: { command: "ls" }, + output: null, + error: null, + detail: { + type: "shell", + command: "ls", + }, + }, + }, + }, + }); + + expect(parsed.payload.event.type).toBe("timeline"); + if (parsed.payload.event.type === "timeline") { + expect(parsed.payload.event.item.type).toBe("tool_call"); + if (parsed.payload.event.item.type === "tool_call") { + expect(parsed.payload.event.item.status).toBe("running"); + } + } + }); + + it("parses websocket envelope for agent_stream_snapshot with legacy status", () => { + const fixture = loadFixture("legacy-agent-stream-snapshot-inProgress.json") as { + type: "agent_stream_snapshot"; + payload: unknown; + }; + + const wrapped = WSOutboundMessageSchema.parse({ + type: "session", + message: fixture, + }); + + if (wrapped.type === "session" && wrapped.message.type === "agent_stream_snapshot") { + const first = wrapped.message.payload.events[0]?.event; + expect(first?.type).toBe("timeline"); + if (first?.type === "timeline" && first.item.type === "tool_call") { + expect(first.item.status).toBe("running"); + expect(first.item.error).toBeNull(); + } + } + }); +}); diff --git a/packages/server/src/shared/messages.tool-call-schema.test.ts b/packages/server/src/shared/messages.tool-call-schema.test.ts new file mode 100644 index 000000000..62dd02046 --- /dev/null +++ b/packages/server/src/shared/messages.tool-call-schema.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { AgentTimelineItemPayloadSchema } from "./messages.js"; + +function canonicalBase() { + return { + type: "tool_call" as const, + callId: "call_123", + name: "shell", + input: { command: "pwd" }, + output: null, + }; +} + +describe("shared messages tool_call schema", () => { + it("parses each status-discriminated tool_call variant at runtime", () => { + const running = AgentTimelineItemPayloadSchema.parse({ + ...canonicalBase(), + status: "running", + error: null, + detail: { + type: "shell", + command: "pwd", + }, + }); + + const completed = AgentTimelineItemPayloadSchema.parse({ + ...canonicalBase(), + status: "completed", + error: null, + output: { output: "/tmp/repo" }, + }); + + const failed = AgentTimelineItemPayloadSchema.parse({ + ...canonicalBase(), + status: "failed", + error: { message: "command failed" }, + }); + + const canceled = AgentTimelineItemPayloadSchema.parse({ + ...canonicalBase(), + status: "canceled", + error: null, + }); + + expect(running.type).toBe("tool_call"); + expect(completed.type).toBe("tool_call"); + expect(failed.type).toBe("tool_call"); + expect(canceled.type).toBe("tool_call"); + }); + + it("rejects non-recoverable invalid tool_call payloads", () => { + const missingCallId = AgentTimelineItemPayloadSchema.safeParse({ + type: "tool_call", + name: "shell", + status: "running", + input: { command: "pwd" }, + output: null, + error: null, + }); + + const unknownStatus = AgentTimelineItemPayloadSchema.safeParse({ + ...canonicalBase(), + status: "mystery_status", + error: null, + }); + + expect(missingCallId.success).toBe(false); + expect(unknownStatus.success).toBe(false); + }); + + it("normalizes recoverable legacy status/error combinations", () => { + const completedWithError = AgentTimelineItemPayloadSchema.safeParse({ + ...canonicalBase(), + status: "completed", + error: { message: "unexpected" }, + }); + + const failedWithoutError = AgentTimelineItemPayloadSchema.safeParse({ + ...canonicalBase(), + status: "failed", + error: null, + }); + + const missingOutput = AgentTimelineItemPayloadSchema.safeParse({ + type: "tool_call", + callId: "call_missing_output", + name: "shell", + status: "running", + input: { command: "pwd" }, + error: null, + }); + + expect(completedWithError.success).toBe(true); + expect(failedWithoutError.success).toBe(true); + expect(missingOutput.success).toBe(true); + + if (completedWithError.success && completedWithError.data.type === "tool_call") { + expect(completedWithError.data.error).toBeNull(); + } + if (failedWithoutError.success && failedWithoutError.data.type === "tool_call") { + expect(failedWithoutError.data.error).toEqual({ message: "Tool call failed" }); + } + if (missingOutput.success && missingOutput.data.type === "tool_call") { + expect(missingOutput.data.output).toBeNull(); + } + }); +}); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 92e91747b..c99721a83 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -10,6 +10,8 @@ import type { AgentPersistenceHandle, AgentRuntimeInfo, AgentTimelineItem, + ToolCallDetail, + ToolCallTimelineItem, AgentUsage, } from "../server/agent/agent-sdk-types.js"; @@ -147,8 +149,150 @@ export type StructuredToolResult = | { type: "file_read"; filePath: string; content: string } | { type: "generic"; data: unknown }; -export const AgentTimelineItemPayloadSchema: z.ZodType = - z.discriminatedUnion("type", [ +const ToolCallDetailPayloadSchema: z.ZodType = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("shell"), + command: z.string(), + cwd: z.string().optional(), + output: z.string().optional(), + exitCode: z.number().nullable().optional(), + }), + z.object({ + type: z.literal("read"), + filePath: z.string(), + content: z.string().optional(), + offset: z.number().optional(), + limit: z.number().optional(), + }), + z.object({ + type: z.literal("edit"), + filePath: z.string(), + oldString: z.string().optional(), + newString: z.string().optional(), + unifiedDiff: z.string().optional(), + }), + z.object({ + type: z.literal("write"), + filePath: z.string(), + content: z.string().optional(), + }), + z.object({ + type: z.literal("search"), + query: z.string(), + }), +]); + +const NonUndefinedUnknownSchema = z.union([ + z.null(), + z.boolean(), + z.number(), + z.string(), + z.array(z.unknown()), + z.object({}).passthrough(), +]); + +const NonNullUnknownSchema = z.union([ + z.boolean(), + z.number(), + z.string(), + z.array(z.unknown()), + z.object({}).passthrough(), +]); + +const ToolCallBasePayloadSchema = z.object({ + type: z.literal("tool_call"), + callId: z.string(), + name: z.string(), + input: NonUndefinedUnknownSchema, + output: NonUndefinedUnknownSchema, + detail: ToolCallDetailPayloadSchema.optional(), + metadata: z.record(z.unknown()).optional(), +}); + +const ToolCallRunningPayloadSchema = ToolCallBasePayloadSchema.extend({ + status: z.literal("running"), + error: z.null(), +}); + +const ToolCallCompletedPayloadSchema = ToolCallBasePayloadSchema.extend({ + status: z.literal("completed"), + error: z.null(), +}); + +const ToolCallFailedPayloadSchema = ToolCallBasePayloadSchema.extend({ + status: z.literal("failed"), + error: NonNullUnknownSchema, +}); + +const ToolCallCanceledPayloadSchema = ToolCallBasePayloadSchema.extend({ + status: z.literal("canceled"), + error: z.null(), +}); + +const LEGACY_TOOL_CALL_STATUS_MAP: Record = { + inprogress: "running", + in_progress: "running", + started: "running", + complete: "completed", + done: "completed", + success: "completed", + errored: "failed", + error: "failed", + cancelled: "canceled", +}; + +function normalizeLegacyToolCallPayload(value: unknown): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value; + } + + const record = value as Record; + if (record.type !== "tool_call") { + return value; + } + + const normalized: Record = { ...record }; + const rawStatus = typeof record.status === "string" ? record.status.trim() : ""; + + if (rawStatus.length > 0) { + const statusKey = rawStatus.toLowerCase().replace(/[\s-]+/g, "_"); + const mappedStatus = LEGACY_TOOL_CALL_STATUS_MAP[statusKey]; + if (mappedStatus) { + normalized.status = mappedStatus; + } + } + + if (!("input" in normalized)) { + normalized.input = null; + } + + if (!("output" in normalized)) { + normalized.output = null; + } + + if (normalized.status === "failed") { + if (normalized.error === undefined || normalized.error === null) { + normalized.error = { message: "Tool call failed" }; + } + } else if (normalized.error === undefined || normalized.error !== null) { + normalized.error = null; + } + + return normalized; +} + +const ToolCallTimelineItemPayloadSchema: z.ZodType = z.preprocess( + normalizeLegacyToolCallPayload, + z.union([ + ToolCallRunningPayloadSchema, + ToolCallCompletedPayloadSchema, + ToolCallFailedPayloadSchema, + ToolCallCanceledPayloadSchema, + ]) +); + +export const AgentTimelineItemPayloadSchema: z.ZodType = + z.union([ z.object({ type: z.literal("user_message"), text: z.string(), @@ -162,16 +306,7 @@ export const AgentTimelineItemPayloadSchema: z.ZodType = type: z.literal("reasoning"), text: z.string(), }), - z.object({ - type: z.literal("tool_call"), - name: z.string(), - callId: z.string().optional(), - status: z.string().optional(), - input: z.unknown().optional(), - output: z.unknown().optional(), - error: z.unknown().optional(), - metadata: z.record(z.unknown()).optional(), - }), + ToolCallTimelineItemPayloadSchema, z.object({ type: z.literal("todo"), items: z.array( diff --git a/packages/server/src/utils/tool-call-parsers.test.ts b/packages/server/src/utils/tool-call-parsers.test.ts index d6325011e..50c15ada3 100644 --- a/packages/server/src/utils/tool-call-parsers.test.ts +++ b/packages/server/src/utils/tool-call-parsers.test.ts @@ -1,331 +1,32 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "vitest"; + import { - stripShellWrapperPrefix, - normalizeToolDisplayName, + extractTodos, stripCwdPrefix, - parseToolCallDisplay, + stripShellWrapperPrefix, } from "./tool-call-parsers.js"; -describe("stripShellWrapperPrefix", () => { - test("strips /bin/zsh -lc cd path && prefix", () => { - const command = "/bin/zsh -lc cd /Users/me/dev/blankpage/editor && npm run format"; - expect(stripShellWrapperPrefix(command)).toBe("npm run format"); +describe("tool-call-parsers utilities", () => { + it("strips cwd prefixes", () => { + expect(stripCwdPrefix("/tmp/repo/src/index.ts", "/tmp/repo")).toBe("src/index.ts"); + expect(stripCwdPrefix("/tmp/repo", "/tmp/repo")).toBe("."); }); - test("strips /bin/zsh -lc \"cd path &&\" wrapper", () => { - const command = '/bin/zsh -lc "cd /Users/me/dev/blankpage/editor && npm run format"'; - expect(stripShellWrapperPrefix(command)).toBe("npm run format"); + it("strips shell wrapper prefixes", () => { + const wrapped = '/bin/zsh -lc "cd /tmp/repo && npm test"'; + expect(stripShellWrapperPrefix(wrapped)).toBe("npm test"); }); - test("strips /bin/zsh -c cd path && prefix", () => { - const command = "/bin/zsh -c cd /path/to/project && git status"; - expect(stripShellWrapperPrefix(command)).toBe("git status"); - }); + it("extracts todo entries", () => { + expect( + extractTodos({ + todos: [ + { content: "Task 1", status: "pending" }, + { content: "Task 2", status: "completed" }, + ], + }) + ).toHaveLength(2); - test("strips /bin/bash -lc cd path && prefix", () => { - const command = "/bin/bash -lc cd /home/user/project && npm test"; - expect(stripShellWrapperPrefix(command)).toBe("npm test"); - }); - - test("strips /bin/sh -c cd path && prefix", () => { - const command = "/bin/sh -c cd /tmp && ls -la"; - expect(stripShellWrapperPrefix(command)).toBe("ls -la"); - }); - - test("returns command unchanged when no prefix", () => { - const command = "npm run build"; - expect(stripShellWrapperPrefix(command)).toBe("npm run build"); - }); - - test("strips shell prefix even without cd", () => { - const command = "/bin/zsh -lc npm run build"; - expect(stripShellWrapperPrefix(command)).toBe("npm run build"); - }); - - test("strips when cd path includes spaces in quotes", () => { - const command = '/bin/zsh -lc cd "/path with spaces" && npm test'; - expect(stripShellWrapperPrefix(command)).toBe("npm test"); - }); - - test("strips /bin/zsh -lc with quoted complex command", () => { - const command = - '/bin/zsh -lc "adb shell pm list packages | rg \'sh\\.paseo(\\.dev)?\' && adb shell cmd package resolve-activity --brief sh.paseo.dev | tail -n 1"'; - expect(stripShellWrapperPrefix(command)).toBe( - "adb shell pm list packages | rg 'sh\\.paseo(\\.dev)?' && adb shell cmd package resolve-activity --brief sh.paseo.dev | tail -n 1" - ); - }); -}); - -describe("normalizeToolDisplayName", () => { - test("normalizes plain speak name", () => { - expect(normalizeToolDisplayName("speak")).toBe("Speak"); - }); - - test("normalizes codex namespaced speak name", () => { - expect(normalizeToolDisplayName("paseo_voice.speak")).toBe("Speak"); - }); - - test("normalizes claude mcp speak name", () => { - expect(normalizeToolDisplayName("mcp__paseo_voice__speak")).toBe("Speak"); - }); - - test("keeps non-speak names unchanged", () => { - expect(normalizeToolDisplayName("paseo__create_agent")).toBe( - "paseo__create_agent" - ); - }); -}); - -describe("stripCwdPrefix", () => { - test("strips cwd prefix from file path", () => { - expect(stripCwdPrefix("/Users/dev/project/src/file.ts", "/Users/dev/project")).toBe("src/file.ts"); - }); - - test("returns . for exact cwd match", () => { - expect(stripCwdPrefix("/Users/dev/project", "/Users/dev/project")).toBe("."); - }); - - test("returns path unchanged when no cwd prefix", () => { - expect(stripCwdPrefix("/other/path/file.ts", "/Users/dev/project")).toBe("/other/path/file.ts"); - }); - - test("returns path unchanged when no cwd provided", () => { - expect(stripCwdPrefix("/Users/dev/project/file.ts")).toBe("/Users/dev/project/file.ts"); - }); -}); - -describe("parseToolCallDisplay", () => { - describe("summary (was extractPrincipalParam)", () => { - test("extracts and strips shell wrapper from command string", () => { - const result = parseToolCallDisplay({ - provider: "claude", - name: "Bash", - input: { command: "/bin/zsh -lc cd /Users/dev/project && npm run format" }, - }); - expect(result.summary).toBe("npm run format"); - }); - - test("extracts and strips shell wrapper from command array", () => { - const result = parseToolCallDisplay({ - name: "shell", - input: { command: ["/bin/bash", "-lc", "cd /path && git status"] }, - }); - expect(result.summary).toBe("git status"); - }); - - test("extracts plain command without shell wrapper", () => { - const result = parseToolCallDisplay({ - name: "Bash", - input: { command: "npm run build" }, - }); - expect(result.summary).toBe("npm run build"); - }); - - test("falls back to output command when shell input is missing", () => { - const result = parseToolCallDisplay({ - name: "Bash", - output: { type: "command", command: "pwd", output: "/tmp" }, - }); - expect(result.summary).toBe("pwd"); - }); - - test("extracts file_path and strips cwd", () => { - const result = parseToolCallDisplay({ - name: "Read", - input: { file_path: "/Users/dev/project/src/file.ts" }, - cwd: "/Users/dev/project", - }); - expect(result.summary).toBe("src/file.ts"); - }); - - test("falls back to read output path when input is missing", () => { - const result = parseToolCallDisplay({ - provider: "codex", - name: "Read", - output: { - type: "file_read", - filePath: "/Users/dev/project/src/file.ts", - content: "hello", - }, - cwd: "/Users/dev/project", - }); - expect(result.summary).toBe("src/file.ts"); - }); - - test("falls back to edit output path when input is missing", () => { - const result = parseToolCallDisplay({ - name: "Edit", - output: { - type: "file_edit", - filePath: "/Users/dev/project/src/file.ts", - oldContent: "a", - newContent: "b", - }, - cwd: "/Users/dev/project", - }); - expect(result.summary).toBe("src/file.ts"); - }); - - test("extracts pattern without modification", () => { - const result = parseToolCallDisplay({ - name: "Grep", - input: { pattern: "*.ts" }, - }); - expect(result.summary).toBe("*.ts"); - }); - - test("extracts query without modification", () => { - const result = parseToolCallDisplay({ - name: "WebSearch", - input: { query: "search term" }, - }); - expect(result.summary).toBe("search term"); - }); - - test("extracts url without modification", () => { - const result = parseToolCallDisplay({ - name: "WebFetch", - input: { url: "https://example.com" }, - }); - expect(result.summary).toBe("https://example.com"); - }); - - test("returns undefined summary for empty input", () => { - const result = parseToolCallDisplay({ name: "Unknown", input: {} }); - expect(result.summary).toBeUndefined(); - }); - - test("extracts description from Task tool", () => { - const result = parseToolCallDisplay({ - name: "Task", - input: { description: "Explore the codebase" }, - }); - expect(result.summary).toBe("Explore the codebase"); - }); - }); - - describe("summary from metadata", () => { - test("subAgentActivity in metadata takes priority for Task", () => { - const result = parseToolCallDisplay({ - name: "Task", - input: { description: "Explore codebase" }, - metadata: { subAgentActivity: "Read" }, - }); - expect(result.summary).toBe("Read"); - }); - - test("metadata merges with input for summary parsing", () => { - const result = parseToolCallDisplay({ - name: "Task", - input: {}, - metadata: { subAgentActivity: "Bash" }, - }); - expect(result.summary).toBe("Bash"); - }); - - test("non-Task tools keep their parsed summary even when metadata is present", () => { - const result = parseToolCallDisplay({ - name: "Bash", - input: { command: "pwd" }, - metadata: { subAgentActivity: "Read" }, - }); - expect(result.summary).toBe("pwd"); - }); - }); - - describe("kind", () => { - test("Read -> read", () => { - expect(parseToolCallDisplay({ name: "Read" }).kind).toBe("read"); - }); - - test("read_file -> read", () => { - expect(parseToolCallDisplay({ name: "read_file" }).kind).toBe("read"); - }); - - test("Edit -> edit", () => { - expect(parseToolCallDisplay({ name: "Edit" }).kind).toBe("edit"); - }); - - test("Write -> edit", () => { - expect(parseToolCallDisplay({ name: "Write" }).kind).toBe("edit"); - }); - - test("apply_patch -> edit", () => { - expect(parseToolCallDisplay({ name: "apply_patch" }).kind).toBe("edit"); - }); - - test("Bash -> execute", () => { - expect(parseToolCallDisplay({ name: "Bash" }).kind).toBe("execute"); - }); - - test("shell -> execute", () => { - expect(parseToolCallDisplay({ name: "shell" }).kind).toBe("execute"); - }); - - test("Grep -> search", () => { - expect(parseToolCallDisplay({ name: "Grep" }).kind).toBe("search"); - }); - - test("Glob -> search", () => { - expect(parseToolCallDisplay({ name: "Glob" }).kind).toBe("search"); - }); - - test("thinking -> thinking", () => { - expect(parseToolCallDisplay({ name: "thinking" }).kind).toBe("thinking"); - }); - - test("unknown tool -> tool", () => { - expect(parseToolCallDisplay({ name: "MyCustomTool" }).kind).toBe("tool"); - }); - }); - - describe("displayName", () => { - test("shell -> Shell", () => { - expect(parseToolCallDisplay({ name: "shell" }).displayName).toBe("Shell"); - }); - - test("Bash -> Shell", () => { - expect(parseToolCallDisplay({ name: "Bash" }).displayName).toBe("Shell"); - }); - - test("read_file -> Read", () => { - expect(parseToolCallDisplay({ name: "read_file" }).displayName).toBe("Read"); - }); - - test("apply_patch -> Edit", () => { - expect(parseToolCallDisplay({ name: "apply_patch" }).displayName).toBe("Edit"); - }); - - test("preserves unknown tool names", () => { - expect(parseToolCallDisplay({ name: "MyCustomTool" }).displayName).toBe("MyCustomTool"); - }); - - test("normalizes speak variants", () => { - expect(parseToolCallDisplay({ name: "paseo_voice.speak" }).displayName).toBe("Speak"); - expect(parseToolCallDisplay({ name: "mcp__paseo_voice__speak" }).displayName).toBe("Speak"); - }); - }); - - describe("errorText", () => { - test("formats string error", () => { - const result = parseToolCallDisplay({ - name: "Bash", - error: "something broke", - }); - expect(result.errorText).toBe("something broke"); - }); - - test("extracts content from tool_result error", () => { - const result = parseToolCallDisplay({ - name: "Bash", - error: { type: "tool_result", content: "Exit code 1", is_error: true }, - }); - expect(result.errorText).toBe("Exit code 1"); - }); - - test("returns undefined for no error", () => { - const result = parseToolCallDisplay({ name: "Read" }); - expect(result.errorText).toBeUndefined(); - }); + expect(extractTodos({ plan: [] })).toEqual([]); }); }); diff --git a/packages/server/src/utils/tool-call-parsers.ts b/packages/server/src/utils/tool-call-parsers.ts index ffbfd3756..0660b057c 100644 --- a/packages/server/src/utils/tool-call-parsers.ts +++ b/packages/server/src/utils/tool-call-parsers.ts @@ -1,80 +1,8 @@ -import stripAnsi from "strip-ansi"; import { z } from "zod"; -// ---- Tool Call Kind (icon category) ---- - -export type ToolCallKind = - | "read" - | "edit" - | "execute" - | "search" - | "thinking" - | "agent" - | "tool"; - -const TOOL_KIND_MAP: Record = { - read: "read", - read_file: "read", - edit: "edit", - write: "edit", - apply_patch: "edit", - bash: "execute", - shell: "execute", - grep: "search", - glob: "search", - web_search: "search", - thinking: "thinking", - task: "agent", -}; - -// ---- Tool Name Normalization ---- - -const TOOL_NAME_MAP: Record = { - shell: "Shell", - bash: "Shell", - read: "Read", - read_file: "Read", - apply_patch: "Edit", - edit: "Edit", - write: "Edit", - paseo_worktree_setup: "Setup", - thinking: "Thinking", -}; - -const TOOL_TOKEN_REGEX = /[a-z0-9]+/g; - -export function normalizeToolDisplayName(toolName: string): string { - const normalized = toolName.trim().toLowerCase(); - if (!normalized) { - return toolName; - } - - const tokens = normalized.match(TOOL_TOKEN_REGEX) ?? []; - const leaf = tokens[tokens.length - 1]; - if (leaf === "speak") { - return "Speak"; - } - return toolName; -} - -function resolveDisplayName(rawName: string): string { - const normalizedName = rawName.trim().toLowerCase(); - const entry = TOOL_NAME_MAP[normalizedName]; - return normalizeToolDisplayName(entry ?? rawName); -} - -function resolveKind(rawName: string): ToolCallKind { - const lower = rawName.trim().toLowerCase(); - if (TOOL_KIND_MAP[lower]) { - return TOOL_KIND_MAP[lower]; - } - if (lower.startsWith("read")) { - return "read"; - } - return "tool"; -} - -// ---- Path/Command Utilities ---- +const SHELL_WRAPPER_PREFIX_PATTERN = + /^\/bin\/(?:zsh|bash|sh)\s+(?:-[a-zA-Z]+\s+)?/; +const CD_AND_PATTERN = /^cd\s+(?:"[^"]+"|'[^']+'|\S+)\s+&&\s+/; export function stripCwdPrefix(filePath: string, cwd?: string): string { if (!cwd || !filePath) return filePath; @@ -92,10 +20,6 @@ export function stripCwdPrefix(filePath: string, cwd?: string): string { return filePath; } -const SHELL_WRAPPER_PREFIX_PATTERN = - /^\/bin\/(?:zsh|bash|sh)\s+(?:-[a-zA-Z]+\s+)?/; -const CD_AND_PATTERN = /^cd\s+(?:"[^"]+"|'[^']+'|\S+)\s+&&\s+/; - export function stripShellWrapperPrefix(command: string): string { const prefixMatch = command.match(SHELL_WRAPPER_PREFIX_PATTERN); if (!prefixMatch) { @@ -114,1114 +38,26 @@ export function stripShellWrapperPrefix(command: string): string { return rest.replace(CD_AND_PATTERN, ""); } -// ---- Detail Types ---- - -export interface KeyValuePair { - key: string; - value: string; -} - -export type ToolCallDetail = - | { type: "shell"; command: string; output: string } - | { - type: "edit"; - filePath: string; - oldString: string; - newString: string; - unifiedDiff?: string; - } - | { - type: "read"; - filePath: string; - content: string; - offset?: number; - limit?: number; - } - | { type: "thinking"; content: string } - | { type: "generic"; input: KeyValuePair[]; output: KeyValuePair[] }; - -type ParsedToolCallContent = { - detail: ToolCallDetail; - summary?: string; -}; - -// ---- Generic Detail Schema ---- - -function stringifyValue(value: unknown): string { - if (value === null) { - return "null"; - } - if (value === undefined) { - return "undefined"; - } - if (value === "") { - return ""; - } - const str = z.string().safeParse(value); - if (str.success) { - return str.data; - } - const num = z.number().safeParse(value); - if (num.success) { - return String(num.data); - } - const bool = z.boolean().safeParse(value); - if (bool.success) { - return String(bool.data); - } - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - -const KeyValuePairsSchema = z.record(z.unknown()).transform((data) => - Object.entries(data).map(([key, value]) => ({ - key, - value: stringifyValue(value), - })) -); - -const GenericDetailSchema = z - .object({ - input: z.unknown().optional(), - output: z.unknown().optional(), - }) - .transform( - (d): ToolCallDetail => ({ - type: "generic", - input: KeyValuePairsSchema.catch([] as KeyValuePair[]).parse(d.input), - output: KeyValuePairsSchema.catch([] as KeyValuePair[]).parse(d.output), - }) - ); - -// ---- Tool Call Shape -> { summary, detail } ---- - -type ProviderCaseKey = "claude" | "codex" | "opencode" | "shared"; -type ToolCaseKey = - | "thinking" - | "bash" - | "shell" - | "read" - | "read_file" - | "edit" - | "write" - | "apply_patch" - | "task" - | "todowrite" - | "todo_write" - | "update_plan" - | "web_search" - | "generic"; - -type NormalizedToolCallCase = { - name: string; - provider?: string; - input?: unknown; - output?: unknown; - metadata?: Record; - cwd?: string; - providerCase: ProviderCaseKey; - toolCase: ToolCaseKey; - caseKey: `${ProviderCaseKey}:${ToolCaseKey}`; -}; - -const TOOL_CASE_NORMALIZATION_PATTERN = /[.\s-]+/g; - -function normalizeProviderCase(provider?: string): ProviderCaseKey { - const normalized = provider?.trim().toLowerCase(); - if (normalized === "claude") { - return "claude"; - } - if (normalized === "codex") { - return "codex"; - } - if ( - normalized === "opencode" || - normalized === "open_code" || - normalized === "open-code" - ) { - return "opencode"; - } - return "shared"; -} - -function normalizeToolCaseName(name: string): string { - return name - .trim() - .replace(TOOL_CASE_NORMALIZATION_PATTERN, "_") - .toLowerCase(); -} - -function resolveToolCase(normalizedName: string): ToolCaseKey { - const compact = normalizedName.replace(/_/g, ""); - if (normalizedName === "thinking" || compact === "thinking") { - return "thinking"; - } - if (normalizedName === "bash" || compact === "bash") { - return "bash"; - } - if (normalizedName === "shell" || compact === "shell") { - return "shell"; - } - if (normalizedName === "read" || compact === "read") { - return "read"; - } - if (normalizedName === "read_file" || compact === "readfile") { - return "read_file"; - } - if (normalizedName === "edit" || compact === "edit") { - return "edit"; - } - if (normalizedName === "write" || compact === "write") { - return "write"; - } - if (normalizedName === "apply_patch" || compact === "applypatch") { - return "apply_patch"; - } - if (normalizedName === "task" || compact === "task") { - return "task"; - } - if (normalizedName === "todowrite" || compact === "todowrite") { - return "todowrite"; - } - if (normalizedName === "todo_write") { - return "todo_write"; - } - if (normalizedName === "update_plan" || compact === "updateplan") { - return "update_plan"; - } - if (normalizedName === "web_search" || compact === "websearch") { - return "web_search"; - } - return "generic"; -} - -const CLAUDE_TOOL_CASES: ReadonlySet = new Set([ - "thinking", - "bash", - "shell", - "read", - "read_file", - "edit", - "write", - "apply_patch", - "task", - "todowrite", - "todo_write", - "update_plan", - "web_search", - "generic", -]); - -const CODEX_TOOL_CASES: ReadonlySet = new Set([ - "thinking", - "bash", - "shell", - "read", - "read_file", - "edit", - "write", - "apply_patch", - "task", - "todowrite", - "todo_write", - "update_plan", - "web_search", - "generic", -]); - -const OPENCODE_TOOL_CASES: ReadonlySet = new Set([ - "thinking", - "bash", - "shell", - "read", - "read_file", - "edit", - "write", - "apply_patch", - "task", - "todowrite", - "todo_write", - "update_plan", - "web_search", - "generic", -]); - -const SHARED_TOOL_CASES: ReadonlySet = new Set([ - "thinking", - "bash", - "shell", - "read", - "read_file", - "edit", - "write", - "apply_patch", - "task", - "todowrite", - "todo_write", - "update_plan", - "web_search", - "generic", -]); - -const PROVIDER_TOOL_CASES: Record> = { - claude: CLAUDE_TOOL_CASES, - codex: CODEX_TOOL_CASES, - opencode: OPENCODE_TOOL_CASES, - shared: SHARED_TOOL_CASES, -}; - -function resolveProviderToolCase( - providerCase: ProviderCaseKey, - normalizedName: string -): ToolCaseKey { - const candidate = resolveToolCase(normalizedName); - const allowed = PROVIDER_TOOL_CASES[providerCase]; - return allowed.has(candidate) ? candidate : "generic"; -} - -const ToolCallContentInputSchema = z - .object({ - name: z.string().catch("unknown"), - provider: z.string().optional(), - input: z.unknown().optional(), - output: z.unknown().optional(), - metadata: z.record(z.unknown()).optional().catch(undefined), - cwd: z.string().optional(), - }) - .passthrough() - .transform((toolCall): NormalizedToolCallCase => { - const providerCase = normalizeProviderCase(toolCall.provider); - const normalizedName = normalizeToolCaseName(toolCall.name); - const toolCase = resolveProviderToolCase(providerCase, normalizedName); - return { - ...toolCall, - providerCase, - toolCase, - caseKey: `${providerCase}:${toolCase}`, - }; - }); - -const ShellCommandSchema = z - .union([ - z.string(), - z.array(z.string()).nonempty().transform((parts) => parts.join(" ")), - ]) - .transform((command) => stripShellWrapperPrefix(command)); - -const ShellInputSchema = z.object({ command: ShellCommandSchema }).passthrough(); - -const ShellOutputCommandSchema = z - .object({ - type: z.literal("command"), - command: ShellCommandSchema.optional(), - }) - .passthrough(); - -const ShellOutputTextSchema = z - .union([ - z.string().transform((text) => stripAnsi(text)), - z - .object({ - type: z.literal("command"), - output: z.string().optional(), - }) - .passthrough() - .transform((d) => stripAnsi(d.output ?? "")), - z - .object({ - type: z.literal("tool_result"), - content: z.string(), - is_error: z.literal(true), - }) - .passthrough() - .transform((d) => stripAnsi(d.content)), - ]) - .catch(""); - -const ReadInputSchema = z.union([ - z - .object({ - file_path: z.string(), - offset: z.number().optional(), - limit: z.number().optional(), - }) - .passthrough() - .transform((d) => ({ - filePath: d.file_path, - offset: d.offset, - limit: d.limit, - })), - z - .object({ - filePath: z.string(), - offset: z.number().optional(), - limit: z.number().optional(), - }) - .passthrough() - .transform((d) => ({ - filePath: d.filePath, - offset: d.offset, - limit: d.limit, - })), - z - .object({ - path: z.string(), - offset: z.number().optional(), - limit: z.number().optional(), - }) - .passthrough() - .transform((d) => ({ - filePath: d.path, - offset: d.offset, - limit: d.limit, - })), -]); - -const ReadOutputSchema = z.union([ - z - .object({ - type: z.literal("file_read"), - filePath: z.string(), - content: z.string(), - }) - .passthrough() - .transform((d) => ({ - filePath: d.filePath, - content: d.content, - })), - z - .object({ - type: z.literal("read_file"), - path: z.string(), - content: z.string(), - }) - .passthrough() - .transform((d) => ({ - filePath: d.path, - content: d.content, - })), - z.string().transform((content) => ({ content })), -]); - -const EditInputSchema = z.union([ - z - .object({ - file_path: z.string(), - old_string: z.string(), - new_string: z.string(), - patch: z.string().optional(), - diff: z.string().optional(), - }) - .passthrough() - .transform((d) => ({ - filePath: d.file_path, - oldString: d.old_string, - newString: d.new_string, - unifiedDiff: d.patch ?? d.diff, - })), - z - .object({ - file_path: z.string(), - old_str: z.string(), - new_str: z.string(), - patch: z.string().optional(), - diff: z.string().optional(), - }) - .passthrough() - .transform((d) => ({ - filePath: d.file_path, - oldString: d.old_str, - newString: d.new_str, - unifiedDiff: d.patch ?? d.diff, - })), - z - .object({ - file_path: z.string(), - content: z.string(), - patch: z.string().optional(), - diff: z.string().optional(), - }) - .passthrough() - .transform((d) => ({ - filePath: d.file_path, - oldString: "", - newString: d.content, - unifiedDiff: d.patch ?? d.diff, - })), - z - .object({ - file_path: z.string(), - patch: z.string().optional(), - diff: z.string().optional(), - }) - .passthrough() - .transform((d) => ({ - filePath: d.file_path, - oldString: "", - newString: "", - unifiedDiff: d.patch ?? d.diff, - })), - z - .object({ - filePath: z.string(), - oldContent: z.string().optional(), - newContent: z.string().optional(), - diff: z.string().optional(), - patch: z.string().optional(), - }) - .passthrough() - .transform((d) => ({ - filePath: d.filePath, - oldString: d.oldContent ?? "", - newString: d.newContent ?? "", - unifiedDiff: d.diff ?? d.patch, - })), - z - .object({ - path: z.string(), - content: z.string().optional(), - diff: z.string().optional(), - patch: z.string().optional(), - }) - .passthrough() - .transform((d) => ({ - filePath: d.path, - oldString: "", - newString: d.content ?? "", - unifiedDiff: d.diff ?? d.patch, - })), -]); - -const EditOutputSchema = z.union([ - z - .object({ - type: z.literal("file_edit"), - filePath: z.string(), - diff: z.string().optional(), - oldContent: z.string().optional(), - newContent: z.string().optional(), - }) - .passthrough() - .transform((d) => ({ - filePath: d.filePath, - oldString: d.oldContent ?? "", - newString: d.newContent ?? "", - unifiedDiff: d.diff, - })), - z - .object({ - type: z.literal("file_write"), - filePath: z.string(), - oldContent: z.string().optional(), - newContent: z.string().optional(), - }) - .passthrough() - .transform((d) => ({ - filePath: d.filePath, - oldString: d.oldContent ?? "", - newString: d.newContent ?? "", - unifiedDiff: undefined, - })), - z - .object({ - files: z - .array( - z - .object({ - path: z.string(), - patch: z.string().optional(), - }) - .passthrough() - ) - .nonempty(), - }) - .passthrough() - .transform((d) => { - const firstFile = d.files[0]; - return { - filePath: firstFile.path, - oldString: "", - newString: "", - unifiedDiff: firstFile.patch, - }; - }), -]); - -const ApplyPatchMovePathSchema = z - .union([ - z.string().transform(() => undefined), - z - .object({ - movePath: z.string().nullable().optional(), - move_path: z.string().nullable().optional(), - }) - .passthrough() - .transform((d) => d.movePath ?? d.move_path ?? undefined), - ]) - .optional(); - -const ApplyPatchInputFileSchema = z - .object({ - path: z.string(), - kind: ApplyPatchMovePathSchema, - }) - .transform((d) => ({ - path: d.path, - movePath: d.kind, - })); - -const ApplyPatchResultFileSchema = z - .object({ - path: z.string(), - patch: z.string().optional(), - kind: ApplyPatchMovePathSchema, - }) - .transform((d) => ({ - path: d.path, - patch: d.patch, - movePath: d.kind, - })); - -const ApplyPatchInputSchema = z - .object({ - files: z.array(ApplyPatchInputFileSchema).min(1), - }) - .passthrough(); - -const ApplyPatchOutputSchema = z - .object({ - files: z.array(ApplyPatchResultFileSchema).optional(), - message: z.string().optional(), - success: z.boolean().optional(), - }) - .passthrough(); - -const GenericPathSummaryInputSchema = z.union([ - z.object({ file_path: z.string() }).passthrough().transform((d) => d.file_path), - z.object({ filePath: z.string() }).passthrough().transform((d) => d.filePath), - z.object({ path: z.string() }).passthrough().transform((d) => d.path), -]); - -const GenericTextSummaryInputSchema = z.union([ - z.object({ description: z.string() }).passthrough().transform((d) => d.description), - z.object({ title: z.string() }).passthrough().transform((d) => d.title), - z.object({ name: z.string() }).passthrough().transform((d) => d.name), - z.object({ branch: z.string() }).passthrough().transform((d) => d.branch), - z.object({ pattern: z.string() }).passthrough().transform((d) => d.pattern), - z.object({ query: z.string() }).passthrough().transform((d) => d.query), - z.object({ url: z.string() }).passthrough().transform((d) => d.url), - z.object({ text: z.string() }).passthrough().transform((d) => d.text), -]); - -const GenericFilesSummaryInputSchema = z - .object({ - files: z.array(z.object({ path: z.string() })).nonempty(), - }) - .passthrough(); - -const GenericTodosSummaryInputSchema = z - .object({ - todos: z - .array( - z.object({ - content: z.string(), - status: z.enum(["pending", "in_progress", "completed"]), - activeForm: z.string().optional(), - }) - ) - .nonempty(), - }) - .passthrough(); - -const GenericPlanSummaryInputSchema = z - .object({ - plan: z - .array( - z.object({ - step: z.string(), - status: z.enum(["pending", "in_progress", "completed"]).catch("pending"), - }) - ) - .nonempty(), - }) - .passthrough(); - -const TaskSummaryInputSchema = z.union([ - z.object({ description: z.string() }).passthrough().transform((d) => d.description), - z.object({ title: z.string() }).passthrough().transform((d) => d.title), -]); - -function resolveTodoSummary(input: unknown): string | undefined { - const todos = GenericTodosSummaryInputSchema.safeParse(input); - if (todos.success) { - const inProgress = todos.data.todos.find( - (todo) => todo.status === "in_progress" - ); - return inProgress - ? inProgress.activeForm ?? inProgress.content - : `${todos.data.todos.length} tasks`; - } - - const plan = GenericPlanSummaryInputSchema.safeParse(input); - if (plan.success) { - const inProgress = plan.data.plan.find( - (entry) => entry.status === "in_progress" - ); - return inProgress ? inProgress.step : `${plan.data.plan.length} tasks`; - } - - return undefined; -} - -function resolveGenericSummary( - input: unknown, - cwd?: string -): string | undefined { - const path = GenericPathSummaryInputSchema.safeParse(input); - if (path.success) { - return stripCwdPrefix(path.data, cwd); - } - - const text = GenericTextSummaryInputSchema.safeParse(input); - if (text.success) { - return text.data; - } - - const files = GenericFilesSummaryInputSchema.safeParse(input); - if (files.success) { - return stripCwdPrefix(files.data.files[0].path, cwd); - } - - return resolveTodoSummary(input); -} - -function parseThinkingToolCall( - toolCall: NormalizedToolCallCase -): ParsedToolCallContent { - return { - detail: { - type: "thinking", - content: z.string().catch("").parse(toolCall.input), - }, - }; -} - -function parseShellToolCall( - toolCall: NormalizedToolCallCase -): ParsedToolCallContent { - const input = ShellInputSchema.safeParse(toolCall.input); - const outputCommand = ShellOutputCommandSchema.safeParse(toolCall.output); - const command = - input.success - ? input.data.command - : outputCommand.success - ? outputCommand.data.command ?? "" - : ""; - - return { - summary: command.length > 0 ? command : undefined, - detail: { - type: "shell", - command, - output: ShellOutputTextSchema.parse(toolCall.output), - }, - }; -} - -function parseGenericToolCall( - toolCall: NormalizedToolCallCase -): ParsedToolCallContent { - return { - summary: resolveGenericSummary(toolCall.input, toolCall.cwd), - detail: GenericDetailSchema.parse(toolCall), - }; -} - -function parseReadToolCall( - toolCall: NormalizedToolCallCase -): ParsedToolCallContent { - const input = ReadInputSchema.safeParse(toolCall.input); - const output = ReadOutputSchema.safeParse(toolCall.output); - const outputFilePath = - output.success && "filePath" in output.data - ? output.data.filePath - : undefined; - const filePath = - input.success - ? input.data.filePath - : outputFilePath; - - if (!filePath) { - return parseGenericToolCall(toolCall); - } - - return { - summary: stripCwdPrefix(filePath, toolCall.cwd), - detail: { - type: "read", - filePath, - content: output.success ? output.data.content : "", - offset: input.success ? input.data.offset : undefined, - limit: input.success ? input.data.limit : undefined, - }, - }; -} - -function parseEditToolCall( - toolCall: NormalizedToolCallCase -): ParsedToolCallContent { - const input = EditInputSchema.safeParse(toolCall.input); - const output = EditOutputSchema.safeParse(toolCall.output); - const filePath = - input.success - ? input.data.filePath - : output.success - ? output.data.filePath - : undefined; - - if (!filePath) { - return parseGenericToolCall(toolCall); - } - - const unifiedDiff = - (input.success ? input.data.unifiedDiff : undefined) ?? - (output.success ? output.data.unifiedDiff : undefined); - - return { - summary: stripCwdPrefix(filePath, toolCall.cwd), - detail: { - type: "edit", - filePath, - oldString: input.success - ? input.data.oldString - : output.success - ? output.data.oldString - : "", - newString: input.success - ? input.data.newString - : output.success - ? output.data.newString - : "", - ...(unifiedDiff ? { unifiedDiff } : {}), - }, - }; -} - -function parseApplyPatchToolCall( - toolCall: NormalizedToolCallCase -): ParsedToolCallContent { - const input = ApplyPatchInputSchema.safeParse(toolCall.input); - const output = ApplyPatchOutputSchema.safeParse(toolCall.output); - - if (input.success) { - const firstFile = input.data.files[0]; - const outputFiles = output.success ? output.data.files ?? [] : []; - const matchPaths = z.array(z.string()).parse( - [firstFile.path, firstFile.movePath].filter(Boolean) - ); - const matched = - outputFiles.find((file) => matchPaths.includes(file.path)) ?? - outputFiles[0]; - - return { - summary: stripCwdPrefix(firstFile.path, toolCall.cwd), - detail: { - type: "edit", - filePath: firstFile.movePath ?? firstFile.path, - oldString: "", - newString: "", - ...(matched?.patch ? { unifiedDiff: matched.patch } : {}), - }, - }; - } - - if (output.success && output.data.files && output.data.files.length > 0) { - const firstFile = output.data.files[0]; - return { - summary: stripCwdPrefix(firstFile.path, toolCall.cwd), - detail: { - type: "edit", - filePath: firstFile.path, - oldString: "", - newString: "", - ...(firstFile.patch ? { unifiedDiff: firstFile.patch } : {}), - }, - }; - } - - return parseGenericToolCall(toolCall); -} - -function parseTaskToolCall( - toolCall: NormalizedToolCallCase -): ParsedToolCallContent { - const parsedSummary = TaskSummaryInputSchema.safeParse(toolCall.input); - const summary = parsedSummary.success - ? parsedSummary.data - : resolveGenericSummary(toolCall.input, toolCall.cwd); - return { - summary, - detail: GenericDetailSchema.parse(toolCall), - }; -} - -function parseTodosToolCall( - toolCall: NormalizedToolCallCase -): ParsedToolCallContent { - return { - summary: - resolveTodoSummary(toolCall.input) ?? - resolveGenericSummary(toolCall.input, toolCall.cwd), - detail: GenericDetailSchema.parse(toolCall), - }; -} - -function createToolCaseSchema( - caseKey: `${ProviderCaseKey}:${ToolCaseKey}` -) { - return z - .object({ - caseKey: z.literal(caseKey), - }) - .passthrough(); -} - -function parseKnownToolCall( - toolCall: NormalizedToolCallCase -): ParsedToolCallContent { - switch (toolCall.toolCase) { - case "thinking": - return parseThinkingToolCall(toolCall); - case "bash": - case "shell": - return parseShellToolCall(toolCall); - case "read": - case "read_file": - return parseReadToolCall(toolCall); - case "edit": - case "write": - return parseEditToolCall(toolCall); - case "apply_patch": - return parseApplyPatchToolCall(toolCall); - case "task": - return parseTaskToolCall(toolCall); - case "todowrite": - case "todo_write": - case "update_plan": - return parseTodosToolCall(toolCall); - case "web_search": - case "generic": - return parseGenericToolCall(toolCall); - } -} - -const ClaudeToolCallContentSchema = z - .discriminatedUnion("caseKey", [ - createToolCaseSchema("claude:thinking"), - createToolCaseSchema("claude:bash"), - createToolCaseSchema("claude:shell"), - createToolCaseSchema("claude:read"), - createToolCaseSchema("claude:read_file"), - createToolCaseSchema("claude:edit"), - createToolCaseSchema("claude:write"), - createToolCaseSchema("claude:apply_patch"), - createToolCaseSchema("claude:task"), - createToolCaseSchema("claude:todowrite"), - createToolCaseSchema("claude:todo_write"), - createToolCaseSchema("claude:update_plan"), - createToolCaseSchema("claude:web_search"), - createToolCaseSchema("claude:generic"), - ]) - .transform((toolCall): ParsedToolCallContent => - parseKnownToolCall(toolCall as NormalizedToolCallCase) - ); - -const CodexToolCallContentSchema = z - .discriminatedUnion("caseKey", [ - createToolCaseSchema("codex:thinking"), - createToolCaseSchema("codex:bash"), - createToolCaseSchema("codex:shell"), - createToolCaseSchema("codex:read"), - createToolCaseSchema("codex:read_file"), - createToolCaseSchema("codex:edit"), - createToolCaseSchema("codex:write"), - createToolCaseSchema("codex:apply_patch"), - createToolCaseSchema("codex:task"), - createToolCaseSchema("codex:todowrite"), - createToolCaseSchema("codex:todo_write"), - createToolCaseSchema("codex:update_plan"), - createToolCaseSchema("codex:web_search"), - createToolCaseSchema("codex:generic"), - ]) - .transform((toolCall): ParsedToolCallContent => - parseKnownToolCall(toolCall as NormalizedToolCallCase) - ); - -const OpenCodeToolCallContentSchema = z - .discriminatedUnion("caseKey", [ - createToolCaseSchema("opencode:thinking"), - createToolCaseSchema("opencode:bash"), - createToolCaseSchema("opencode:shell"), - createToolCaseSchema("opencode:read"), - createToolCaseSchema("opencode:read_file"), - createToolCaseSchema("opencode:edit"), - createToolCaseSchema("opencode:write"), - createToolCaseSchema("opencode:apply_patch"), - createToolCaseSchema("opencode:task"), - createToolCaseSchema("opencode:todowrite"), - createToolCaseSchema("opencode:todo_write"), - createToolCaseSchema("opencode:update_plan"), - createToolCaseSchema("opencode:web_search"), - createToolCaseSchema("opencode:generic"), - ]) - .transform((toolCall): ParsedToolCallContent => - parseKnownToolCall(toolCall as NormalizedToolCallCase) - ); - -const SharedToolCallContentSchema = z - .discriminatedUnion("caseKey", [ - createToolCaseSchema("shared:thinking"), - createToolCaseSchema("shared:bash"), - createToolCaseSchema("shared:shell"), - createToolCaseSchema("shared:read"), - createToolCaseSchema("shared:read_file"), - createToolCaseSchema("shared:edit"), - createToolCaseSchema("shared:write"), - createToolCaseSchema("shared:apply_patch"), - createToolCaseSchema("shared:task"), - createToolCaseSchema("shared:todowrite"), - createToolCaseSchema("shared:todo_write"), - createToolCaseSchema("shared:update_plan"), - createToolCaseSchema("shared:web_search"), - createToolCaseSchema("shared:generic"), - ]) - .transform((toolCall): ParsedToolCallContent => - parseKnownToolCall(toolCall as NormalizedToolCallCase) - ); - -const ToolCallContentSchema = ToolCallContentInputSchema.pipe( - z.union([ - ClaudeToolCallContentSchema, - CodexToolCallContentSchema, - OpenCodeToolCallContentSchema, - SharedToolCallContentSchema, - ]) -); - -const MetadataSummarySchema = z - .object({ subAgentActivity: z.string() }) - .transform((d) => d.subAgentActivity); - -function resolveMetadataSummary( - toolName: string, - metadata: Record | undefined -): string | undefined { - if (toolName.trim().toLowerCase() !== "task") { - return undefined; - } - const parsed = MetadataSummarySchema.safeParse(metadata); - if (!parsed.success) { - return undefined; - } - const summary = parsed.data.trim(); - return summary.length > 0 ? summary : undefined; -} - -// ---- Error formatting ---- - -const ErrorTextSchema = z.union([ - z.undefined().transform(() => undefined), - z.null().transform(() => undefined), - z.string(), - z - .object({ - type: z.literal("tool_result"), - content: z.string(), - }) - .passthrough() - .transform((d) => d.content), - z.unknown().transform((value) => { - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } - }), -]); - -// ---- Unified ToolCallDisplayInfo ---- - -export interface ToolCallInput { - name: string; - provider?: string; - input?: unknown; - output?: unknown; - error?: unknown; - metadata?: Record; - cwd?: string; -} - -export interface ToolCallDisplayInfo { - displayName: string; - kind: ToolCallKind; - summary?: string; - detail: ToolCallDetail; - errorText?: string; -} - -export const ToolCallDisplaySchema = z - .object({ - name: z.string().catch("unknown"), - provider: z.string().optional(), - input: z.unknown().optional(), - output: z.unknown().optional(), - error: z.unknown().optional(), - metadata: z.record(z.unknown()).optional().catch(undefined), - cwd: z.string().optional().catch(undefined), - }) - .transform((toolCall): ToolCallDisplayInfo => { - const parsed = ToolCallContentSchema.parse(toolCall); - const metadataSummary = resolveMetadataSummary( - toolCall.name, - toolCall.metadata - ); - - return { - displayName: resolveDisplayName(toolCall.name), - kind: resolveKind(toolCall.name), - summary: metadataSummary ?? parsed.summary, - detail: parsed.detail, - errorText: ErrorTextSchema.parse(toolCall.error), - }; - }); - -export function parseToolCallDisplay(toolCall: ToolCallInput): ToolCallDisplayInfo { - return ToolCallDisplaySchema.parse(toolCall); -} - -// ---- TodoWrite Extraction ---- - export interface TodoItem { content: string; status: "pending" | "in_progress" | "completed"; activeForm?: string; } -export function extractTodos(value: unknown): TodoItem[] { - const parsed = z.object({ - todos: z.array(z.object({ +const TodosSchema = z.object({ + todos: z.array( + z.object({ content: z.string(), status: z.enum(["pending", "in_progress", "completed"]), activeForm: z.string().optional(), - })), - }).safeParse(value); + }) + ), +}); +export function extractTodos(value: unknown): TodoItem[] { + const parsed = TodosSchema.safeParse(value); if (!parsed.success) { return []; } - return parsed.data.todos; }