diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 42271e7be..c578c1780 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -46,13 +46,6 @@ import { Check, CheckSquare, X, - Wrench, - Pencil, - Eye, - SquareTerminal, - Search, - Brain, - Bot, Copy, TriangleAlertIcon, Scissors, @@ -72,9 +65,8 @@ import type { TodoEntry } from "@/types/stream"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import { buildToolCallDisplayModel, - formatToolCallError, - type ToolCallKind, } from "@/utils/tool-call-display"; +import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf"; import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path"; export type { InlinePathTarget } from "@/utils/inline-path"; @@ -110,29 +102,6 @@ function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) { return disableOuterSpacing ?? contextValue; } -function hexToRgba(hexColor: string, alpha: number) { - const clampedAlpha = Math.max(0, Math.min(1, alpha)); - const hex = hexColor.replace("#", ""); - const normalized = - hex.length === 3 - ? hex - .split("") - .map((char) => `${char}${char}`) - .join("") - : hex; - - if (!/^[\da-fA-F]{6}$/.test(normalized)) { - return `rgba(255, 255, 255, ${clampedAlpha})`; - } - - const intValue = Number.parseInt(normalized, 16); - const red = (intValue >> 16) & 255; - const green = (intValue >> 8) & 255; - const blue = intValue & 255; - - return `rgba(${red}, ${green}, ${blue}, ${clampedAlpha})`; -} - const userMessageStylesheet = StyleSheet.create((theme) => ({ container: { flexDirection: "row", @@ -445,9 +414,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ }, shimmerOverlay: { position: "absolute", - top: 3, - bottom: 3, - borderRadius: 999, + top: 0, + bottom: 0, }, })); @@ -1044,7 +1012,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ if (isLoading) { shimmer.value = -1; shimmer.value = withRepeat( - withTiming(1, { duration: 3200, easing: Easing.linear }), + withTiming(1, { duration: 2400, easing: Easing.bezier(0.4, 0, 0.6, 1) }), -1 ); } else { @@ -1053,15 +1021,7 @@ 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 shimmerBandWidth = 18; const shimmerStyle = useAnimatedStyle(() => { const travel = badgeWidth + shimmerBandWidth; return { @@ -1148,13 +1108,13 @@ const ExpandableBadge = memo(function ExpandableBadge({ x2="1" y2="0" > - - - - - - - + + + + + + + @@ -1191,9 +1151,9 @@ const ExpandableBadge = memo(function ExpandableBadge({ interface ToolCallProps { toolName: string; - args: any; - result?: any; - error?: any; + args: unknown | null; + result?: unknown | null; + error?: unknown | null; status: "executing" | "running" | "completed" | "failed" | "canceled"; detail?: ToolCallDetail; cwd?: string; @@ -1204,15 +1164,6 @@ interface ToolCallProps { onInlineDetailsExpandedChange?: (expanded: boolean) => void; } -// Icon mapping for tool kinds -const toolKindIcons: Record = { - edit: Pencil, - read: Eye, - execute: SquareTerminal, - search: Search, - thinking: Brain, - agent: Bot, -}; const TOOL_CALL_LOG_TAG = "[ToolCall]"; const TOOL_CALL_COMMIT_THRESHOLD_MS = 16; @@ -1241,14 +1192,24 @@ export const ToolCall = memo(function ToolCall({ UnistylesRuntime.breakpoint === "sm"; const displayModel = useMemo( - () => buildToolCallDisplayModel({ name: toolName, detail, metadata, cwd }), - [toolName, detail, metadata, cwd] + () => + buildToolCallDisplayModel({ + name: toolName, + status: status === "executing" ? "running" : status, + input: args ?? null, + output: result ?? null, + error: error ?? null, + detail, + metadata, + cwd, + }), + [toolName, status, args, result, error, detail, metadata, cwd] ); const displayName = displayModel.displayName; - const kind: ToolCallKind = displayModel.kind; const summary = displayModel.summary; - const errorText = useMemo(() => formatToolCallError(error), [error]); - const IconComponent = toolKindIcons[kind] || Wrench; + const errorText = displayModel.errorText; + const iconCategory = detail?.type ?? toolName.trim().toLowerCase(); + const IconComponent = resolveToolCallIcon(toolName, detail); // Check if there's any content to display const hasDetails = @@ -1260,7 +1221,7 @@ export const ToolCall = memo(function ToolCall({ } if (isMobile) { openToolCall({ - kind, + toolName, displayName, summary, detail, @@ -1271,7 +1232,7 @@ export const ToolCall = memo(function ToolCall({ } else { setIsExpanded((prev) => !prev); } - }, [isMobile, openToolCall, kind, displayName, summary, detail, args, result, errorText]); + }, [isMobile, openToolCall, toolName, displayName, summary, detail, args, result, errorText]); useEffect(() => { if (isMobile || !isPerfLoggingEnabled()) { @@ -1288,7 +1249,7 @@ export const ToolCall = memo(function ToolCall({ perfLog(TOOL_CALL_LOG_TAG, { event: isExpanded ? "expand_commit" : "collapse_commit", toolName, - kind, + iconCategory, durationMs: Math.round(durationMs), }); } @@ -1298,7 +1259,7 @@ export const ToolCall = memo(function ToolCall({ } else { logCommit(); } - }, [isExpanded, isMobile, toolName, kind]); + }, [isExpanded, isMobile, toolName, iconCategory]); useEffect(() => { if (!onInlineDetailsHoverChange || isMobile || isExpanded) { diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 6288b1b78..d1422af7f 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -15,15 +15,15 @@ import { BottomSheetBackdrop, BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; -import { Pencil, Eye, SquareTerminal, Search, Bot, Wrench, X } from "lucide-react-native"; +import { X } from "lucide-react-native"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; -import type { ToolCallKind } from "@/utils/tool-call-display"; +import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { ToolCallDetailsContent } from "./tool-call-details"; // ----- Types ----- export type ToolCallSheetData = { - kind: ToolCallKind; + toolName: string; displayName: string; summary?: string; detail?: ToolCallDetail; @@ -49,16 +49,6 @@ export function useToolCallSheet(): ToolCallSheetContextValue { return context; } -// ----- Icon Mapping ----- - -const toolKindIcons: Record> = { - edit: Pencil, - read: Eye, - execute: SquareTerminal, - search: Search, - agent: Bot, -}; - // ----- Custom Background Component ----- function CustomSheetBackground({ style }: BottomSheetBackgroundProps) { @@ -142,9 +132,9 @@ interface ToolCallSheetContentProps { } function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { - const { kind, displayName, detail, input, output, errorText } = data; + const { toolName, displayName, detail, input, output, errorText } = data; - const IconComponent = toolKindIcons[kind] || Wrench; + const IconComponent = resolveToolCallIcon(toolName, detail); return ( diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 17a212f3a..58d671b43 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -175,6 +175,10 @@ describe("stream reducer canonical tool calls", () => { const summary = buildToolCallDisplayModel({ name: tool.payload.data.name, + status: tool.payload.data.status, + input: tool.payload.data.input, + output: tool.payload.data.result, + error: tool.payload.data.error, detail: tool.payload.data.detail, }).summary; assert.strictEqual(summary, "npm test"); @@ -204,12 +208,47 @@ describe("stream reducer canonical tool calls", () => { const summary = buildToolCallDisplayModel({ name: tool.payload.data.name, + status: tool.payload.data.status, + input: tool.payload.data.input, + output: tool.payload.data.result, + error: tool.payload.data.error, detail: tool.payload.data.detail, cwd: "/tmp/repo", }).summary; assert.strictEqual(summary, "README.md"); }); + it("does not infer command summary when detail is absent", () => { + const callId = "running-summary-shell-input-only"; + const state = hydrateStreamState([ + { + event: canonicalToolTimeline({ + provider: "codex", + callId, + name: "exec_command", + status: "running", + input: { command: "npm run lint" }, + output: null, + }), + timestamp: new Date("2025-01-01T10:17:00Z"), + }, + ]); + + const tool = findToolByCallId(state, callId); + assert.ok(tool); + + const display = buildToolCallDisplayModel({ + name: tool.payload.data.name, + status: tool.payload.data.status, + input: tool.payload.data.input, + output: tool.payload.data.result, + error: tool.payload.data.error, + detail: tool.payload.data.detail, + }); + assert.strictEqual(display.summary, undefined); + assert.strictEqual(display.displayName, "Exec Command"); + }); + it("preserves early input when later updates contain null input", () => { const callId = "null-input-preserve"; const updates = [ diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 396217c8c..c9e55ae17 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -158,6 +158,10 @@ export interface TodoListItem { items: TodoEntry[]; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function normalizeChunk(text: string): { chunk: string; hasContent: boolean } { if (!text) { return { chunk: "", hasContent: false }; @@ -169,6 +173,16 @@ function normalizeChunk(text: string): { chunk: string; hasContent: boolean } { return { chunk, hasContent: /\S/.test(chunk) }; } +function markThoughtReady(item: ThoughtItem): ThoughtItem { + if (item.status === "ready") { + return item; + } + return { + ...item, + status: "ready", + }; +} + function appendUserMessage( state: StreamItem[], text: string, @@ -228,7 +242,7 @@ function appendAssistantMessage( const secondLast = state[state.length - 2]; if (last?.kind === "user_message" && secondLast?.kind === "assistant_message") { const updated: AssistantMessageItem = { - ...(secondLast as AssistantMessageItem), + ...secondLast, text: `${secondLast.text}${chunk}`, timestamp, }; @@ -290,7 +304,7 @@ function finalizeActiveThoughts(state: StreamItem[]): StreamItem[] { const nextState = state.map((entry) => { if (entry.kind === "thought" && entry.status !== "ready") { mutated = true; - return { ...entry, status: "ready" as ThoughtStatus }; + return markThoughtReady(entry); } return entry; }); @@ -311,12 +325,7 @@ function findExistingAgentToolCallIndex( } function hasNonEmptyObject(value: unknown): boolean { - return Boolean( - value && - typeof value === "object" && - !Array.isArray(value) && - Object.keys(value as Record).length > 0 - ); + return isRecord(value) && Object.keys(value).length > 0; } function mergeCanonicalValue( @@ -362,7 +371,10 @@ function appendAgentToolCall( if (existingIndex >= 0) { const next = [...state]; - const existing = next[existingIndex] as AgentToolCallItem; + const existing = next[existingIndex]; + if (!existing || !isAgentToolCallItem(existing)) { + return state; + } const mergedInput = mergeCanonicalValue(existing.payload.data.input, data.input); const mergedResult = mergeCanonicalValue(existing.payload.data.result, data.result); const mergedStatus = mergeAgentToolCallStatus( @@ -570,7 +582,10 @@ export function reduceStreamUpdate( // Avoid rendering legacy plan-mode todo timeline items as Tasks. break; } - const items = (item.items ?? []) as TodoEntry[]; + const items: TodoEntry[] = (item.items ?? []).map((todo) => ({ + text: todo.text, + completed: Boolean(todo.completed), + })); nextState = appendTodoList(state, event.provider, items, timestamp); break; } @@ -591,7 +606,10 @@ export function reduceStreamUpdate( (s) => s.kind === "compaction" && s.status === "loading" ); if (loadingIdx >= 0) { - const existing = state[loadingIdx] as CompactionItem; + const existing = state[loadingIdx]; + if (!existing || existing.kind !== "compaction") { + break; + } const updated: CompactionItem = { ...existing, status: "completed", @@ -704,7 +722,7 @@ function getEventItemKind( function finalizeHeadItems(head: StreamItem[]): StreamItem[] { return head.map((item) => { if (item.kind === "thought" && item.status !== "ready") { - return { ...item, status: "ready" as ThoughtStatus }; + return markThoughtReady(item); } return item; }); @@ -881,7 +899,7 @@ export function applyStreamEventWithBuffer(params: { const head: StreamItem[] = params.buffer ? [ { - kind: "assistant_message" as const, + kind: "assistant_message", id: params.buffer.id, text: params.buffer.text, timestamp: params.buffer.timestamp, diff --git a/packages/app/src/utils/tool-call-display.test.ts b/packages/app/src/utils/tool-call-display.test.ts index 3b1299d2d..04c00c354 100644 --- a/packages/app/src/utils/tool-call-display.test.ts +++ b/packages/app/src/utils/tool-call-display.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from "vitest"; -import { buildToolCallDisplayModel, formatToolCallError } from "./tool-call-display"; +import { buildToolCallDisplayModel } from "./tool-call-display"; describe("tool-call-display", () => { it("builds display model from canonical shell detail", () => { const display = buildToolCallDisplayModel({ name: "shell", + status: "running", + input: { command: "npm test" }, + output: null, + error: null, detail: { type: "shell", command: "npm test", @@ -13,7 +17,6 @@ describe("tool-call-display", () => { }); expect(display).toEqual({ - kind: "execute", displayName: "Shell", summary: "npm test", }); @@ -22,6 +25,10 @@ describe("tool-call-display", () => { it("builds display model from canonical read detail", () => { const display = buildToolCallDisplayModel({ name: "read_file", + status: "completed", + input: { path: "/tmp/repo/src/index.ts" }, + output: { content: "hello" }, + error: null, detail: { type: "read", filePath: "/tmp/repo/src/index.ts", @@ -30,7 +37,6 @@ describe("tool-call-display", () => { }); expect(display).toEqual({ - kind: "read", displayName: "Read", summary: "src/index.ts", }); @@ -39,13 +45,16 @@ describe("tool-call-display", () => { it("uses metadata summary for task tool calls", () => { const display = buildToolCallDisplayModel({ name: "task", + status: "running", + input: null, + output: null, + error: null, metadata: { subAgentActivity: "Running tests", }, }); expect(display).toEqual({ - kind: "agent", displayName: "Task", summary: "Running tests", }); @@ -54,15 +63,40 @@ describe("tool-call-display", () => { it("falls back to humanized tool name for unknown tools", () => { const display = buildToolCallDisplayModel({ name: "custom_tool_name", + status: "completed", + input: null, + output: null, + error: null, }); expect(display).toEqual({ - kind: "tool", displayName: "Custom Tool Name", }); }); - it("formats non-string errors", () => { - expect(formatToolCallError({ message: "boom" })).toBe('{\n "message": "boom"\n}'); + it("does not derive command summary from raw input when detail is missing", () => { + const display = buildToolCallDisplayModel({ + name: "exec_command", + status: "running", + input: { command: "npm run test" }, + output: null, + error: null, + }); + + expect(display).toEqual({ + displayName: "Exec Command", + }); + }); + + it("returns formatted errorText from the same display pipeline", () => { + const display = buildToolCallDisplayModel({ + name: "shell", + status: "failed", + input: { command: "false" }, + output: null, + error: { message: "boom" }, + }); + + expect(display.errorText).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 index 93a3cce93..fc1eb80ba 100644 --- a/packages/app/src/utils/tool-call-display.ts +++ b/packages/app/src/utils/tool-call-display.ts @@ -1,211 +1,5 @@ -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); - } -} +export { + buildToolCallDisplayModel, + type ToolCallDisplayInput, + type ToolCallDisplayModel, +} from "@server/shared/tool-call-display"; diff --git a/packages/app/src/utils/tool-call-icon.ts b/packages/app/src/utils/tool-call-icon.ts new file mode 100644 index 000000000..ba095b4f9 --- /dev/null +++ b/packages/app/src/utils/tool-call-icon.ts @@ -0,0 +1,28 @@ +import type { ComponentType } from "react"; +import { Bot, Brain, Eye, Pencil, Search, SquareTerminal, Wrench } from "lucide-react-native"; +import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; + +export type ToolCallIconComponent = ComponentType<{ size?: number; color?: string }>; + +const TOOL_DETAIL_ICONS: Record = { + shell: SquareTerminal, + read: Eye, + edit: Pencil, + write: Pencil, + search: Search, +}; + +export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail): ToolCallIconComponent { + if (detail) { + return TOOL_DETAIL_ICONS[detail.type]; + } + + const lowerName = toolName.trim().toLowerCase(); + if (lowerName === "task") { + return Bot; + } + if (lowerName === "thinking") { + return Brain; + } + return Wrench; +} diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 9374a8a4e..a1c440221 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -179,13 +179,14 @@ describe("DaemonClient", () => { await expect(promise).rejects.toThrow("boom"); // Ensure we didn't leave a waiter behind that will reject later. - expect((client as any).waiters.size).toBe(0); + const internal = client as unknown as { waiters: Set }; + expect(internal.waiters.size).toBe(0); vi.runOnlyPendingTimers(); vi.useRealTimers(); }); - test("parses agent_stream tool_call payloads (including legacy inProgress) without crashing", async () => { + test("parses canonical agent_stream tool_call payloads without crashing", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -219,8 +220,10 @@ describe("DaemonClient", () => { type: "tool_call", callId: "call_cli_stream", name: "shell", - status: "inProgress", + status: "running", input: { command: "pwd" }, + output: null, + error: null, }, }, }, @@ -250,7 +253,55 @@ describe("DaemonClient", () => { expect(logger.warn).not.toHaveBeenCalled(); }); - test("parses agent_stream_snapshot tool_call payloads without crashing", async () => { + test("drops legacy agent_stream tool_call payloads and logs validation warning", 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_legacy", + name: "shell", + status: "inProgress", + input: { command: "pwd" }, + }, + }, + }, + }) + ); + + unsubscribe(); + + expect(received).toHaveLength(0); + expect(logger.warn).toHaveBeenCalled(); + }); + + test("parses canonical agent_stream_snapshot tool_call payloads without crashing", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -271,8 +322,32 @@ describe("DaemonClient", () => { received.push(msg); }); - const snapshot = loadLegacySnapshotFixture(); - mock.triggerMessage(wrapSessionMessage(snapshot)); + mock.triggerMessage( + wrapSessionMessage({ + type: "agent_stream_snapshot", + payload: { + agentId: "agent_cli", + events: [ + { + timestamp: "2026-02-08T20:20:00.000Z", + event: { + type: "timeline", + provider: "codex", + item: { + type: "tool_call", + callId: "call_cli_snapshot", + name: "shell", + status: "running", + input: { command: "pwd" }, + output: null, + error: null, + }, + }, + }, + ], + }, + }) + ); unsubscribe(); @@ -302,4 +377,34 @@ describe("DaemonClient", () => { } expect(logger.warn).not.toHaveBeenCalled(); }); + + test("drops legacy agent_stream_snapshot tool_call payloads and logs validation warning", 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(0); + expect(logger.warn).toHaveBeenCalled(); + }); }); diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 837a97e82..2210d1240 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -70,6 +70,38 @@ describe("curateAgentActivity", () => { expect(result).toContain("[Shell] npm test"); }); + it("does not infer summary from raw input when detail is missing", () => { + const timeline: AgentTimelineItem[] = [ + toolCallItem({ + callId: "shell-no-detail", + name: "exec_command", + status: "running", + input: { command: "npm run lint" }, + }), + toolCallItem({ + callId: "read-no-detail", + name: "read_file", + status: "running", + input: { path: "src/index.ts" }, + }), + toolCallItem({ + callId: "search-no-detail", + name: "web_search", + status: "running", + input: { query: "zod union" }, + }), + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toContain("[Exec Command]"); + expect(result).toContain("[Read File]"); + expect(result).toContain("[Web Search]"); + expect(result).not.toContain("npm run lint"); + expect(result).not.toContain("src/index.ts"); + expect(result).not.toContain("zod union"); + }); + it("falls back to input json for likely external tools", () => { const timeline: AgentTimelineItem[] = [ toolCallItem({ diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index d0f2077b3..b2bf8b7fc 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -1,5 +1,6 @@ import type { AgentTimelineItem } from "./agent-sdk-types.js"; import { isLikelyExternalToolName } from "./tool-name-normalization.js"; +import { buildToolCallDisplayModel } from "../../shared/tool-call-display.js"; const DEFAULT_MAX_ITEMS = 40; const MAX_TOOL_INPUT_CHARS = 400; @@ -44,50 +45,6 @@ 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) @@ -211,8 +168,17 @@ export function curateAgentActivity( case "tool_call": { flushBuffers(lines, buffers); const inputJson = formatToolInputJson(item.input); - const displayName = resolveToolDisplayName(item); - const summary = resolveToolSummary(item); + const display = buildToolCallDisplayModel({ + name: item.name, + status: item.status, + input: item.input, + output: item.output, + error: item.error, + detail: item.detail, + metadata: item.metadata, + }); + const displayName = display.displayName; + const summary = display.summary; if (isLikelyExternalToolName(item.name) && inputJson) { lines.push(`[${displayName}] ${inputJson}`); break; 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 index 7084b6c3e..84840b581 100644 --- 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 @@ -28,6 +28,52 @@ describe("claude tool-call mapper", () => { } }); + it("maps running known tool variants with detail for early summaries", () => { + const readItem = mapClaudeRunningToolCall({ + callId: "claude-running-read", + name: "read_file", + input: { file_path: "README.md" }, + output: null, + }); + expect(readItem.detail).toEqual({ + type: "read", + filePath: "README.md", + }); + + const writeItem = mapClaudeRunningToolCall({ + callId: "claude-running-write", + name: "write_file", + input: { file_path: "src/new.ts" }, + output: null, + }); + expect(writeItem.detail).toEqual({ + type: "write", + filePath: "src/new.ts", + }); + + const editItem = mapClaudeRunningToolCall({ + callId: "claude-running-edit", + name: "apply_patch", + input: { file_path: "src/index.ts" }, + output: null, + }); + expect(editItem.detail).toEqual({ + type: "edit", + filePath: "src/index.ts", + }); + + const searchItem = mapClaudeRunningToolCall({ + callId: "claude-running-search", + name: "web_search", + input: { query: "tool call mapping" }, + output: null, + }); + expect(searchItem.detail).toEqual({ + type: "search", + query: "tool call mapping", + }); + }); + it("maps completed read calls with detail enrichment", () => { const item = mapClaudeCompletedToolCall({ callId: "claude-call-2", @@ -48,6 +94,41 @@ describe("claude tool-call mapper", () => { } }); + it("preserves read content from array/object output variants", () => { + const arrayContent = mapClaudeCompletedToolCall({ + callId: "claude-read-array", + name: "read_file", + input: { file_path: "README.md" }, + output: { + content: [ + { type: "output_text", text: "alpha" }, + { type: "output_text", content: "beta" }, + ], + }, + }); + + expect(arrayContent.detail?.type).toBe("read"); + if (arrayContent.detail?.type === "read") { + expect(arrayContent.detail.content).toBe("alpha\nbeta"); + } + + const objectContent = mapClaudeCompletedToolCall({ + callId: "claude-read-object", + name: "read_file", + input: { file_path: "README.md" }, + output: { + structured_content: { + content: { type: "output_text", text: "gamma" }, + }, + }, + }); + + expect(objectContent.detail?.type).toBe("read"); + if (objectContent.detail?.type === "read") { + expect(objectContent.detail.content).toBe("gamma"); + } + }); + it("maps failed calls with required error", () => { const item = mapClaudeFailedToolCall({ callId: "claude-call-3", @@ -64,6 +145,42 @@ describe("claude tool-call mapper", () => { expect(item.output).toBeNull(); }); + it("maps write/edit/search known shapes with distinct detail types", () => { + const writeItem = mapClaudeCompletedToolCall({ + callId: "claude-write-1", + name: "write_file", + input: { file_path: "src/new.ts", content: "export const x = 1;" }, + output: null, + }); + expect(writeItem.detail?.type).toBe("write"); + if (writeItem.detail?.type === "write") { + expect(writeItem.detail.filePath).toBe("src/new.ts"); + } + + const editItem = mapClaudeCompletedToolCall({ + callId: "claude-edit-1", + name: "apply_patch", + input: { file_path: "src/index.ts", patch: "@@\\n-old\\n+new\\n" }, + output: null, + }); + expect(editItem.detail?.type).toBe("edit"); + if (editItem.detail?.type === "edit") { + expect(editItem.detail.filePath).toBe("src/index.ts"); + expect(editItem.detail.unifiedDiff).toContain("@@"); + } + + const searchItem = mapClaudeCompletedToolCall({ + callId: "claude-search-1", + name: "web_search", + input: { query: "tool call mapping" }, + output: null, + }); + expect(searchItem.detail).toEqual({ + type: "search", + query: "tool call mapping", + }); + }); + it("keeps unknown tools canonical without detail", () => { const item = mapClaudeCompletedToolCall({ callId: "claude-call-4", 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 index 7053d6760..5f1913799 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts @@ -1,6 +1,13 @@ import { z } from "zod"; import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js"; +import { + coerceToolCallId, + commandFromValue, + flattenReadContent as flattenToolReadContent, + nonEmptyString, + truncateDiffText, +} from "../tool-call-mapper-utils.js"; type MapperParams = { callId?: string | null; @@ -10,6 +17,8 @@ type MapperParams = { metadata?: Record; }; +const MAX_DIFF_TEXT_CHARS = 12_000; + const ClaudeMapperParamsSchema = z .object({ callId: z.string().optional().nullable(), @@ -24,74 +33,32 @@ 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 CommandValueSchema = z.union([z.string(), z.array(z.string())]); 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(); + .union([ + z + .object({ + command: CommandValueSchema, + cwd: z.string().optional(), + directory: z.string().optional(), + }) + .passthrough(), + z + .object({ + cmd: CommandValueSchema, + cwd: z.string().optional(), + directory: z.string().optional(), + }) + .passthrough(), + ]) + .transform((value) => { + const commandValue = "command" in value ? value.command : value.cmd; + return { + command: commandFromValue(commandValue), + cwd: nonEmptyString(value.cwd) ?? nonEmptyString(value.directory), + }; + }); const ClaudeShellOutputObjectSchema = z .object({ @@ -100,17 +67,31 @@ const ClaudeShellOutputObjectSchema = z text: z.string().optional(), content: z.string().optional(), aggregated_output: z.string().optional(), - exitCode: z.number().finite().optional(), - exit_code: z.number().finite().optional(), + exitCode: z.number().finite().nullable().optional(), + exit_code: z.number().finite().nullable().optional(), metadata: z .object({ - exitCode: z.number().finite().optional(), - exit_code: z.number().finite().optional(), + exitCode: z.number().finite().nullable().optional(), + exit_code: z.number().finite().nullable().optional(), + }) + .passthrough() + .optional(), + structuredContent: z + .object({ + output: z.string().optional(), + text: z.string().optional(), + content: z.string().optional(), + }) + .passthrough() + .optional(), + structured_content: z + .object({ + output: z.string().optional(), + text: z.string().optional(), + content: z.string().optional(), }) .passthrough() .optional(), - structuredContent: ClaudeTextLikeSchema.optional(), - structured_content: ClaudeTextLikeSchema.optional(), result: z .object({ command: z.string().optional(), @@ -123,402 +104,501 @@ const ClaudeShellOutputObjectSchema = z }) .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 ClaudeShellOutputSchema = z.union([ + z.string().transform((value) => ({ + command: undefined, + output: nonEmptyString(value), + exitCode: undefined, + })), + ClaudeShellOutputObjectSchema.transform((value) => ({ + command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command), + output: + nonEmptyString(value.output) ?? + nonEmptyString(value.text) ?? + nonEmptyString(value.content) ?? + nonEmptyString(value.aggregated_output) ?? + nonEmptyString(value.structuredContent?.output) ?? + nonEmptyString(value.structuredContent?.text) ?? + nonEmptyString(value.structuredContent?.content) ?? + nonEmptyString(value.structured_content?.output) ?? + nonEmptyString(value.structured_content?.text) ?? + nonEmptyString(value.structured_content?.content) ?? + nonEmptyString(value.result?.output) ?? + nonEmptyString(value.result?.text) ?? + nonEmptyString(value.result?.content), + exitCode: + value.exitCode ?? + value.exit_code ?? + value.metadata?.exitCode ?? + value.metadata?.exit_code ?? + undefined, + })), ]); -const ClaudeWriteInputSchema = ClaudeFileReferenceCollectionSchema.extend({ - content: z.string().optional(), - new_content: z.string().optional(), - newContent: z.string().optional(), -}).passthrough(); +const ClaudeReadPathInputSchema = z.union([ + z + .object({ + file_path: z.string(), + offset: z.number().finite().optional(), + limit: z.number().finite().optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.file_path, + offset: value.offset, + limit: value.limit, + })), + z + .object({ + path: z.string(), + offset: z.number().finite().optional(), + limit: z.number().finite().optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.path, + offset: value.offset, + limit: value.limit, + })), + z + .object({ + filePath: z.string(), + offset: z.number().finite().optional(), + limit: z.number().finite().optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.filePath, + offset: value.offset, + limit: value.limit, + })), +]); -const ClaudeWriteOutputSchema = ClaudeFileReferenceCollectionSchema.extend({ - content: z.string().optional(), - new_content: z.string().optional(), - newContent: z.string().optional(), -}).passthrough(); +const ClaudeReadChunkSchema = z.union([ + z + .object({ + text: z.string(), + content: z.string().optional(), + output: z.string().optional(), + }) + .passthrough(), + z + .object({ + text: z.string().optional(), + content: z.string(), + output: z.string().optional(), + }) + .passthrough(), + z + .object({ + text: z.string().optional(), + content: z.string().optional(), + output: z.string(), + }) + .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 ClaudeReadContentSchema = z.union([ + z.string(), + ClaudeReadChunkSchema, + z.array(ClaudeReadChunkSchema), +]); -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 ClaudeReadPayloadSchema = z.union([ + z + .object({ + content: ClaudeReadContentSchema, + text: ClaudeReadContentSchema.optional(), + output: ClaudeReadContentSchema.optional(), + }) + .passthrough(), + z + .object({ + content: ClaudeReadContentSchema.optional(), + text: ClaudeReadContentSchema, + output: ClaudeReadContentSchema.optional(), + }) + .passthrough(), + z + .object({ + content: ClaudeReadContentSchema.optional(), + text: ClaudeReadContentSchema.optional(), + output: ClaudeReadContentSchema, + }) + .passthrough(), +]); -const ClaudeSearchInputSchema = z +const ClaudeReadOutputSchema = z.union([ + z.string().transform((value) => ({ content: nonEmptyString(value) })), + ClaudeReadChunkSchema.transform((value) => ({ content: flattenReadContent(value) })), + z.array(ClaudeReadChunkSchema).transform((value) => ({ content: flattenReadContent(value) })), + ClaudeReadPayloadSchema.transform((value) => ({ + content: + flattenReadContent(value.content) ?? + flattenReadContent(value.text) ?? + flattenReadContent(value.output), + })), + z + .object({ data: ClaudeReadPayloadSchema }) + .passthrough() + .transform((value) => ({ + content: + flattenReadContent(value.data.content) ?? + flattenReadContent(value.data.text) ?? + flattenReadContent(value.data.output), + })), + z + .object({ structuredContent: ClaudeReadPayloadSchema }) + .passthrough() + .transform((value) => ({ + content: + flattenReadContent(value.structuredContent.content) ?? + flattenReadContent(value.structuredContent.text) ?? + flattenReadContent(value.structuredContent.output), + })), + z + .object({ structured_content: ClaudeReadPayloadSchema }) + .passthrough() + .transform((value) => ({ + content: + flattenReadContent(value.structured_content.content) ?? + flattenReadContent(value.structured_content.text) ?? + flattenReadContent(value.structured_content.output), + })), +]); + +const ClaudeWritePathInputSchema = z.union([ + z.object({ file_path: z.string() }).passthrough().transform((value) => ({ filePath: value.file_path })), + z.object({ path: z.string() }).passthrough().transform((value) => ({ filePath: value.path })), + z.object({ filePath: z.string() }).passthrough().transform((value) => ({ filePath: value.filePath })), +]); + +const ClaudeWriteContentSchema = z .object({ - query: z.string().optional(), - q: z.string().optional(), + content: z.string().optional(), + new_content: z.string().optional(), + newContent: 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 ClaudeWriteInputSchema = z + .intersection(ClaudeWritePathInputSchema, ClaudeWriteContentSchema) + .transform((value) => ({ + filePath: value.filePath, + content: + nonEmptyString(value.content) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.newContent), + })); -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, +const ClaudeWriteOutputSchema = z.union([ + z + .intersection(ClaudeWritePathInputSchema, ClaudeWriteContentSchema) + .transform((value) => ({ + filePath: value.filePath, + content: + nonEmptyString(value.content) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.newContent), + })), + ClaudeWriteContentSchema.transform((value) => ({ + filePath: undefined, + content: + nonEmptyString(value.content) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.newContent), + })), ]); -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); +const ClaudeEditTextSchema = z + .object({ + 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 ClaudeEditInputSchema = z + .intersection(ClaudeWritePathInputSchema, ClaudeEditTextSchema) + .transform((value) => ({ + filePath: value.filePath, + oldString: + nonEmptyString(value.old_string) ?? + nonEmptyString(value.old_str) ?? + nonEmptyString(value.oldContent) ?? + nonEmptyString(value.old_content), + newString: + nonEmptyString(value.new_string) ?? + nonEmptyString(value.new_str) ?? + nonEmptyString(value.newContent) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.content), + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff), + MAX_DIFF_TEXT_CHARS + ), + })); + +const ClaudeEditOutputFileSchema = z.union([ + z + .object({ + path: z.string(), + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.path, + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff), + MAX_DIFF_TEXT_CHARS + ), + })), + z + .object({ + file_path: z.string(), + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.file_path, + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff), + MAX_DIFF_TEXT_CHARS + ), + })), + z + .object({ + filePath: z.string(), + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.filePath, + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff), + MAX_DIFF_TEXT_CHARS + ), + })), +]); + +const ClaudeEditOutputSchema = z.union([ + z + .intersection(ClaudeWritePathInputSchema, ClaudeEditTextSchema) + .transform((value) => ({ + filePath: value.filePath, + newString: + nonEmptyString(value.newContent) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.content), + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff), + MAX_DIFF_TEXT_CHARS + ), + })), + z + .object({ files: z.array(ClaudeEditOutputFileSchema).min(1) }) + .passthrough() + .transform((value) => ({ + filePath: value.files[0]?.filePath, + unifiedDiff: value.files[0]?.unifiedDiff, + newString: undefined, + })), + ClaudeEditTextSchema.transform((value) => ({ + filePath: undefined, + newString: + nonEmptyString(value.newContent) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.content), + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff), + MAX_DIFF_TEXT_CHARS + ), + })), +]); + +const ClaudeSearchInputSchema = z.union([ + z.object({ query: z.string() }).passthrough().transform((value) => ({ query: value.query })), + z.object({ q: z.string() }).passthrough().transform((value) => ({ query: value.q })), +]); + +function flattenReadContent( + value: z.infer | undefined +): string | undefined { + return flattenToolReadContent(value); } 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}`)}`; + return coerceToolCallId({ + providerPrefix: "claude", + rawCallId: callId, + toolName: name, + input, + }); } -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); - +function toShellDetail( + input: z.infer | null, + output: z.infer | null +): ToolCallDetail | undefined { + const command = input?.command ?? output?.command; 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 }), + ...(input?.cwd ? { cwd: input.cwd } : {}), + ...(output?.output ? { output: output.output } : {}), + ...(output?.exitCode !== undefined ? { exitCode: output.exitCode } : {}), }; } -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) { +function toReadDetail( + input: z.infer | null, + output: z.infer | null +): ToolCallDetail | undefined { + if (!input?.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 } : {}), + filePath: input.filePath, + ...(output?.content ? { content: output.content } : {}), + ...(input.offset !== undefined ? { offset: input.offset } : {}), + ...(input.limit !== undefined ? { limit: input.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 - ); - +function toWriteDetail( + input: z.infer | null, + output: z.infer | null +): ToolCallDetail | undefined { + const filePath = input?.filePath ?? output?.filePath; 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 } : {}), + ...(input?.content ? { content: input.content } : output?.content ? { content: output.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 - ); - +function toEditDetail( + input: z.infer | null, + output: z.infer | null +): ToolCallDetail | undefined { + const filePath = input?.filePath ?? output?.filePath; 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 } : {}), + ...(input?.oldString ? { oldString: input.oldString } : {}), + ...(input?.newString ? { newString: input.newString } : output?.newString ? { newString: output.newString } : {}), + ...(input?.unifiedDiff + ? { unifiedDiff: input.unifiedDiff } + : output?.unifiedDiff + ? { unifiedDiff: output.unifiedDiff } + : {}), }; } -function resolveSearchDetail(input: unknown): ToolCallDetail | undefined { - const parsedInput = ClaudeSearchInputSchema.safeParse(input); - if (!parsedInput.success) { +function toSearchDetail(input: z.infer | null): ToolCallDetail | undefined { + if (!input?.query) { return undefined; } - - const query = firstNonEmpty(parsedInput.data.query, parsedInput.data.q); - if (!query) { - return undefined; - } - return { type: "search", - query, + query: input.query, }; } +function claudeToolBranch( + name: Name, + inputSchema: InputSchema, + outputSchema: OutputSchema, + mapper: ( + input: z.infer | null, + output: z.infer | null + ) => ToolCallDetail | undefined +) { + return z + .object({ + name: z.literal(name), + input: inputSchema.nullable(), + output: outputSchema.nullable(), + }) + .transform(({ input, output }) => mapper(input, output)); +} + +const ClaudeKnownToolDetailSchema = z.union([ + claudeToolBranch("Bash", ClaudeShellInputSchema, ClaudeShellOutputSchema, toShellDetail), + claudeToolBranch("bash", ClaudeShellInputSchema, ClaudeShellOutputSchema, toShellDetail), + claudeToolBranch("shell", ClaudeShellInputSchema, ClaudeShellOutputSchema, toShellDetail), + claudeToolBranch("exec_command", ClaudeShellInputSchema, ClaudeShellOutputSchema, toShellDetail), + claudeToolBranch("Read", ClaudeReadPathInputSchema, ClaudeReadOutputSchema, toReadDetail), + claudeToolBranch("read", ClaudeReadPathInputSchema, ClaudeReadOutputSchema, toReadDetail), + claudeToolBranch("read_file", ClaudeReadPathInputSchema, ClaudeReadOutputSchema, toReadDetail), + claudeToolBranch("view_file", ClaudeReadPathInputSchema, ClaudeReadOutputSchema, toReadDetail), + claudeToolBranch("Write", ClaudeWriteInputSchema, ClaudeWriteOutputSchema, toWriteDetail), + claudeToolBranch("write", ClaudeWriteInputSchema, ClaudeWriteOutputSchema, toWriteDetail), + claudeToolBranch("write_file", ClaudeWriteInputSchema, ClaudeWriteOutputSchema, toWriteDetail), + claudeToolBranch("create_file", ClaudeWriteInputSchema, ClaudeWriteOutputSchema, toWriteDetail), + claudeToolBranch("Edit", ClaudeEditInputSchema, ClaudeEditOutputSchema, toEditDetail), + claudeToolBranch("MultiEdit", ClaudeEditInputSchema, ClaudeEditOutputSchema, toEditDetail), + claudeToolBranch("multi_edit", ClaudeEditInputSchema, ClaudeEditOutputSchema, toEditDetail), + claudeToolBranch("edit", ClaudeEditInputSchema, ClaudeEditOutputSchema, toEditDetail), + claudeToolBranch("apply_patch", ClaudeEditInputSchema, ClaudeEditOutputSchema, toEditDetail), + claudeToolBranch("apply_diff", ClaudeEditInputSchema, ClaudeEditOutputSchema, toEditDetail), + claudeToolBranch("str_replace_editor", ClaudeEditInputSchema, ClaudeEditOutputSchema, toEditDetail), + claudeToolBranch("WebSearch", ClaudeSearchInputSchema, z.unknown(), (input) => toSearchDetail(input)), + claudeToolBranch("web_search", ClaudeSearchInputSchema, z.unknown(), (input) => toSearchDetail(input)), + claudeToolBranch("search", ClaudeSearchInputSchema, z.unknown(), (input) => toSearchDetail(input)), +]); + function deriveDetail(name: string, input: unknown, output: unknown): ToolCallDetail | undefined { const parsed = ClaudeKnownToolDetailSchema.safeParse({ name, @@ -540,13 +620,12 @@ function buildBase(params: MapperParams): { 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, + callId: coerceCallId(parsedParams.callId, parsedParams.name, input), name: parsedParams.name, input, output, 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 index f200422b0..4798dbccd 100644 --- 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 @@ -23,6 +23,68 @@ describe("codex tool-call mapper", () => { expect(item?.input).toEqual({ command: "pwd", cwd: "/tmp/repo" }); }); + it("maps running known tool variants with detail for early summaries", () => { + const readItem = mapCodexToolCallFromThreadItem( + { + type: "mcpToolCall", + id: "codex-running-read", + status: "running", + tool: "read_file", + arguments: { path: "/tmp/repo/README.md" }, + result: null, + }, + { cwd: "/tmp/repo" } + ); + expect(readItem?.detail).toEqual({ + type: "read", + filePath: "README.md", + }); + + const writeItem = mapCodexToolCallFromThreadItem( + { + type: "mcpToolCall", + id: "codex-running-write", + status: "running", + tool: "write_file", + arguments: { file_path: "/tmp/repo/src/new.ts" }, + result: null, + }, + { cwd: "/tmp/repo" } + ); + expect(writeItem?.detail).toEqual({ + type: "write", + filePath: "src/new.ts", + }); + + const editItem = mapCodexToolCallFromThreadItem( + { + type: "mcpToolCall", + id: "codex-running-edit", + status: "running", + tool: "apply_patch", + arguments: { file_path: "/tmp/repo/src/index.ts" }, + result: null, + }, + { cwd: "/tmp/repo" } + ); + expect(editItem?.detail).toEqual({ + type: "edit", + filePath: "src/index.ts", + }); + + const searchItem = mapCodexToolCallFromThreadItem({ + type: "webSearch", + id: "codex-running-search", + status: "running", + query: "codex timeline", + action: null, + }); + expect(searchItem?.detail).toEqual({ + type: "search", + query: "codex timeline", + }); + }); + it("maps mcp read_file completion with detail", () => { const item = mapCodexToolCallFromThreadItem( { @@ -48,6 +110,96 @@ describe("codex tool-call mapper", () => { } }); + it("retains read_file content when provider returns content array objects", () => { + const item = mapCodexToolCallFromThreadItem( + { + type: "mcpToolCall", + id: "codex-read-array", + status: "completed", + tool: "read_file", + arguments: { path: "/tmp/repo/README.md" }, + result: { + content: [{ type: "text", text: "line one" }, { type: "text", text: "line two" }], + }, + }, + { cwd: "/tmp/repo" } + ); + + expect(item).toBeTruthy(); + expect(item?.detail?.type).toBe("read"); + if (item?.detail?.type === "read") { + expect(item.detail.filePath).toBe("README.md"); + expect(item.detail.content).toBe("line one\nline two"); + } + }); + + it("truncates large diff payloads deterministically in canonical output", () => { + const hugeDiff = `@@\\n-${"a".repeat(14_000)}\\n+${"b".repeat(14_000)}\\n`; + const item = mapCodexToolCallFromThreadItem( + { + type: "fileChange", + id: "codex-diff-1", + status: "completed", + changes: [{ path: "/tmp/repo/src/index.ts", kind: "modify", diff: hugeDiff }], + }, + { cwd: "/tmp/repo" } + ); + + expect(item).toBeTruthy(); + expect(item?.status).toBe("completed"); + expect(item?.output).toBeTruthy(); + const output = item?.output as { files?: Array<{ patch?: string }> }; + expect(output.files?.[0]?.patch).toBeDefined(); + expect(output.files?.[0]?.patch?.includes("...[truncated ")).toBe(true); + expect((output.files?.[0]?.patch?.length ?? 0) < hugeDiff.length).toBe(true); + }); + + it("maps write/edit/search known variants with distinct detail types", () => { + const writeItem = mapCodexToolCallFromThreadItem( + { + type: "mcpToolCall", + id: "codex-write-1", + status: "completed", + tool: "write_file", + arguments: { file_path: "/tmp/repo/src/new.ts", content: "export {}" }, + result: null, + }, + { cwd: "/tmp/repo" } + ); + expect(writeItem?.detail?.type).toBe("write"); + if (writeItem?.detail?.type === "write") { + expect(writeItem.detail.filePath).toBe("src/new.ts"); + } + + const editItem = mapCodexToolCallFromThreadItem( + { + type: "mcpToolCall", + id: "codex-edit-1", + status: "completed", + tool: "apply_patch", + arguments: { file_path: "/tmp/repo/src/index.ts", patch: "@@\\n-a\\n+b\\n" }, + result: null, + }, + { cwd: "/tmp/repo" } + ); + expect(editItem?.detail?.type).toBe("edit"); + if (editItem?.detail?.type === "edit") { + expect(editItem.detail.filePath).toBe("src/index.ts"); + } + + const searchItem = mapCodexToolCallFromThreadItem({ + type: "webSearch", + id: "codex-search-1", + status: "completed", + query: "codex timeline", + action: { results: [] }, + }); + expect(searchItem?.detail).toEqual({ + type: "search", + query: "codex timeline", + }); + }); + it("maps failed tool calls with required error", () => { const item = mapCodexToolCallFromThreadItem({ type: "mcpToolCall", 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 index fce0b48e5..167cc9f7b 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -1,6 +1,13 @@ import { z } from "zod"; import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js"; +import { + coerceToolCallId, + commandFromValue, + flattenReadContent as flattenToolReadContent, + nonEmptyString, + truncateDiffText, +} from "../tool-call-mapper-utils.js"; type CodexMapperOptions = { cwd?: string | null }; @@ -18,36 +25,32 @@ const CodexRolloutToolCallParamsSchema = z }) .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 CommandValueSchema = z.union([z.string(), z.array(z.string())]); 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(); + .union([ + z + .object({ + command: CommandValueSchema, + cwd: z.string().optional(), + directory: z.string().optional(), + }) + .passthrough(), + z + .object({ + cmd: CommandValueSchema, + cwd: z.string().optional(), + directory: z.string().optional(), + }) + .passthrough(), + ]) + .transform((value) => { + const commandValue = "command" in value ? value.command : value.cmd; + return { + command: commandFromValue(commandValue), + cwd: nonEmptyString(value.cwd) ?? nonEmptyString(value.directory), + }; + }); const CodexShellOutputObjectSchema = z .object({ @@ -55,190 +58,427 @@ const CodexShellOutputObjectSchema = z output: z.string().optional(), text: z.string().optional(), content: z.string().optional(), - exitCode: z.number().nullable().optional(), - exit_code: z.number().nullable().optional(), + aggregatedOutput: z.string().optional(), + exitCode: z.number().finite().nullable().optional(), + exit_code: z.number().finite().nullable().optional(), metadata: z .object({ - exitCode: z.number().nullable().optional(), - exit_code: z.number().nullable().optional(), + exitCode: z.number().finite().nullable().optional(), + exit_code: z.number().finite().nullable().optional(), + }) + .passthrough() + .optional(), + structuredContent: z + .object({ + output: z.string().optional(), + text: z.string().optional(), + content: z.string().optional(), + }) + .passthrough() + .optional(), + structured_content: z + .object({ + output: z.string().optional(), + text: z.string().optional(), + content: z.string().optional(), + }) + .passthrough() + .optional(), + result: z + .object({ + command: z.string().optional(), + output: z.string().optional(), + text: z.string().optional(), + content: z.string().optional(), }) .passthrough() .optional(), - structuredContent: CodexTextLikeSchema.optional(), - structured_content: CodexTextLikeSchema.optional(), - result: CodexTextLikeSchema.optional(), }) .passthrough(); -const CodexShellOutputSchema = z.union([z.string(), CodexShellOutputObjectSchema]); +const CodexShellOutputSchema = z.union([ + z.string().transform((value) => ({ + command: undefined, + output: nonEmptyString(value), + exitCode: undefined, + })), + CodexShellOutputObjectSchema.transform((value) => ({ + command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command), + output: + nonEmptyString(value.output) ?? + nonEmptyString(value.text) ?? + nonEmptyString(value.content) ?? + nonEmptyString(value.aggregatedOutput) ?? + nonEmptyString(value.structuredContent?.output) ?? + nonEmptyString(value.structuredContent?.text) ?? + nonEmptyString(value.structuredContent?.content) ?? + nonEmptyString(value.structured_content?.output) ?? + nonEmptyString(value.structured_content?.text) ?? + nonEmptyString(value.structured_content?.content) ?? + nonEmptyString(value.result?.output) ?? + nonEmptyString(value.result?.text) ?? + nonEmptyString(value.result?.content), + exitCode: + value.exitCode ?? + value.exit_code ?? + value.metadata?.exitCode ?? + value.metadata?.exit_code ?? + undefined, + })), +]); -const CodexReadInputSchema = CodexFileReferenceCollectionSchema.extend({ - offset: z.number().finite().optional(), - limit: z.number().finite().optional(), -}).passthrough(); +const CodexPathSchema = z.union([ + z.object({ path: z.string() }).passthrough().transform((value) => value.path), + z.object({ file_path: z.string() }).passthrough().transform((value) => value.file_path), + z.object({ filePath: z.string() }).passthrough().transform((value) => value.filePath), +]); -const CodexReadOutputSchema = z.union([ - z.string(), - CodexFileReferenceCollectionSchema.extend({ +const CodexReadArgumentsSchema = z.union([ + z + .object({ + path: z.string(), + offset: z.number().finite().optional(), + limit: z.number().finite().optional(), + }) + .passthrough() + .transform((value) => ({ filePath: value.path, offset: value.offset, limit: value.limit })), + z + .object({ + file_path: z.string(), + offset: z.number().finite().optional(), + limit: z.number().finite().optional(), + }) + .passthrough() + .transform((value) => ({ filePath: value.file_path, offset: value.offset, limit: value.limit })), + z + .object({ + filePath: z.string(), + offset: z.number().finite().optional(), + limit: z.number().finite().optional(), + }) + .passthrough() + .transform((value) => ({ filePath: value.filePath, offset: value.offset, limit: value.limit })), +]); + +const CodexReadChunkSchema = z.union([ + z + .object({ + text: z.string(), + content: z.string().optional(), + output: z.string().optional(), + }) + .passthrough(), + z + .object({ + text: z.string().optional(), + content: z.string(), + output: z.string().optional(), + }) + .passthrough(), + z + .object({ + text: z.string().optional(), + content: z.string().optional(), + output: z.string(), + }) + .passthrough(), +]); + +const CodexReadContentSchema = z.union([z.string(), CodexReadChunkSchema, z.array(CodexReadChunkSchema)]); + +const CodexReadPayloadSchema = z.union([ + z + .object({ + content: CodexReadContentSchema, + text: CodexReadContentSchema.optional(), + output: CodexReadContentSchema.optional(), + }) + .passthrough(), + z + .object({ + content: CodexReadContentSchema.optional(), + text: CodexReadContentSchema, + output: CodexReadContentSchema.optional(), + }) + .passthrough(), + z + .object({ + content: CodexReadContentSchema.optional(), + text: CodexReadContentSchema.optional(), + output: CodexReadContentSchema, + }) + .passthrough(), +]); + +const CodexReadResultWithPathSchema = z.union([ + z + .object({ + path: z.string(), + content: CodexReadContentSchema.optional(), + text: CodexReadContentSchema.optional(), + output: CodexReadContentSchema.optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.path, + content: + flattenReadContent(value.content) ?? + flattenReadContent(value.text) ?? + flattenReadContent(value.output), + })), + z + .object({ + file_path: z.string(), + content: CodexReadContentSchema.optional(), + text: CodexReadContentSchema.optional(), + output: CodexReadContentSchema.optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.file_path, + content: + flattenReadContent(value.content) ?? + flattenReadContent(value.text) ?? + flattenReadContent(value.output), + })), + z + .object({ + filePath: z.string(), + content: CodexReadContentSchema.optional(), + text: CodexReadContentSchema.optional(), + output: CodexReadContentSchema.optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.filePath, + content: + flattenReadContent(value.content) ?? + flattenReadContent(value.text) ?? + flattenReadContent(value.output), + })), +]); + +const CodexReadResultSchema = z.union([ + z.string().transform((value) => ({ filePath: undefined, content: nonEmptyString(value) })), + CodexReadChunkSchema.transform((value) => ({ filePath: undefined, content: flattenReadContent(value) })), + z.array(CodexReadChunkSchema).transform((value) => ({ filePath: undefined, content: flattenReadContent(value) })), + CodexReadPayloadSchema.transform((value) => ({ + filePath: undefined, + content: + flattenReadContent(value.content) ?? + flattenReadContent(value.text) ?? + flattenReadContent(value.output), + })), + z + .object({ data: CodexReadPayloadSchema }) + .passthrough() + .transform((value) => ({ + filePath: undefined, + content: + flattenReadContent(value.data.content) ?? + flattenReadContent(value.data.text) ?? + flattenReadContent(value.data.output), + })), + z + .object({ structuredContent: CodexReadPayloadSchema }) + .passthrough() + .transform((value) => ({ + filePath: undefined, + content: + flattenReadContent(value.structuredContent.content) ?? + flattenReadContent(value.structuredContent.text) ?? + flattenReadContent(value.structuredContent.output), + })), + z + .object({ structured_content: CodexReadPayloadSchema }) + .passthrough() + .transform((value) => ({ + filePath: undefined, + content: + flattenReadContent(value.structured_content.content) ?? + flattenReadContent(value.structured_content.text) ?? + flattenReadContent(value.structured_content.output), + })), + CodexReadResultWithPathSchema, +]); + +const CodexWriteContentSchema = z + .object({ 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(), + new_content: z.string().optional(), + newContent: 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 CodexWriteArgumentsSchema = z + .intersection(CodexPathSchema.transform((filePath) => ({ filePath })), CodexWriteContentSchema) + .transform((value) => ({ + filePath: value.filePath, + content: + nonEmptyString(value.content) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.newContent), + })); + +const CodexWriteResultSchema = z.union([ + z + .intersection(CodexPathSchema.transform((filePath) => ({ filePath })), CodexWriteContentSchema) + .transform((value) => ({ + filePath: value.filePath, + content: + nonEmptyString(value.content) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.newContent), + })), + CodexWriteContentSchema.transform((value) => ({ + filePath: undefined, + content: + nonEmptyString(value.content) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.newContent), + })), ]); -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 +const CodexEditTextSchema = z .object({ - name: CodexShellToolNameSchema, - input: z.unknown().nullable(), - output: z.unknown().nullable(), - cwd: z.string().optional().nullable(), + 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(), }) - .transform(({ input, output }) => resolveShellDetail(input, output)); + .passthrough(); -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 CodexEditArgumentsSchema = z + .intersection(CodexPathSchema.transform((filePath) => ({ filePath })), CodexEditTextSchema) + .transform((value) => ({ + filePath: value.filePath, + oldString: + nonEmptyString(value.old_string) ?? + nonEmptyString(value.old_str) ?? + nonEmptyString(value.oldContent) ?? + nonEmptyString(value.old_content), + newString: + nonEmptyString(value.new_string) ?? + nonEmptyString(value.new_str) ?? + nonEmptyString(value.newContent) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.content), + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff) + ), + })); -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 CodexEditResultFileSchema = z.union([ + z + .object({ + path: z.string(), + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.path, + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff) + ), + })), + z + .object({ + file_path: z.string(), + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.file_path, + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff) + ), + })), + z + .object({ + filePath: z.string(), + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.filePath, + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff) + ), + })), +]); -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 CodexEditResultSchema = z.union([ + z + .intersection(CodexPathSchema.transform((filePath) => ({ filePath })), CodexEditTextSchema) + .transform((value) => ({ + filePath: value.filePath, + newString: + nonEmptyString(value.newContent) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.content), + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff) + ), + })), + z + .object({ files: z.array(CodexEditResultFileSchema).min(1) }) + .passthrough() + .transform((value) => ({ + filePath: value.files[0]?.filePath, + unifiedDiff: value.files[0]?.unifiedDiff, + newString: undefined, + })), + CodexEditTextSchema.transform((value) => ({ + filePath: undefined, + newString: + nonEmptyString(value.newContent) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.content), + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff) + ), + })), +]); -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 CodexSearchArgumentsSchema = z.union([ + z.object({ query: z.string() }).passthrough().transform((value) => ({ query: value.query })), + z.object({ q: z.string() }).passthrough().transform((value) => ({ query: value.q })), ]); const CodexCommandExecutionItemSchema = z @@ -247,7 +487,7 @@ const CodexCommandExecutionItemSchema = z id: z.string().optional(), status: z.string().optional(), error: z.unknown().optional(), - command: z.union([z.string(), z.array(z.string())]).optional(), + command: CommandValueSchema.optional(), cwd: z.string().optional(), aggregatedOutput: z.string().optional(), exitCode: z.number().nullable().optional(), @@ -307,26 +547,19 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [ 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 flattenReadContent( + value: z.infer | undefined +): string | undefined { + return flattenToolReadContent(value); } 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}`)}`; + return coerceToolCallId({ + providerPrefix: "codex", + rawCallId: raw, + toolName: name, + input, + }); } function normalizeCodexFilePath(filePath: string | undefined, cwd: string | null | undefined): string | undefined { @@ -346,42 +579,6 @@ function normalizeCodexFilePath(filePath: string | undefined, cwd: string | null 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"; @@ -406,221 +603,210 @@ function resolveStatus(rawStatus: string | undefined, error: unknown, output: un 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); - +function toShellDetail( + input: z.infer | null, + output: z.infer | null +): ToolCallDetail | undefined { + const command = input?.command ?? output?.command; 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 }), + ...(input?.cwd ? { cwd: input.cwd } : {}), + ...(output?.output ? { output: output.output } : {}), + ...(output?.exitCode !== undefined ? { exitCode: output.exitCode } : {}), }; } -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 - ); - +function toReadDetail( + input: z.infer | null, + output: z.infer | null, + cwd: string | null | undefined +): ToolCallDetail | undefined { + const filePath = normalizeCodexFilePath(input?.filePath ?? output?.filePath, cwd); 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 } : {}), + ...(output?.content ? { content: output.content } : {}), + ...(input?.offset !== undefined ? { offset: input.offset } : {}), + ...(input?.limit !== undefined ? { limit: input.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 - ); - +function toWriteDetail( + input: z.infer | null, + output: z.infer | null, + cwd: string | null | undefined +): ToolCallDetail | undefined { + const filePath = normalizeCodexFilePath(input?.filePath ?? output?.filePath, cwd); 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 } : {}), + ...(input?.content ? { content: input.content } : output?.content ? { content: output.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 - ); - +function toEditDetail( + input: z.infer | null, + output: z.infer | null, + cwd: string | null | undefined +): ToolCallDetail | undefined { + const filePath = normalizeCodexFilePath(input?.filePath ?? output?.filePath, cwd); 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 } : {}), + ...(input?.oldString ? { oldString: input.oldString } : {}), + ...(input?.newString ? { newString: input.newString } : output?.newString ? { newString: output.newString } : {}), + ...(input?.unifiedDiff + ? { unifiedDiff: input.unifiedDiff } + : output?.unifiedDiff + ? { unifiedDiff: output.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) { +function toSearchDetail(input: z.infer | null): ToolCallDetail | undefined { + if (!input?.query) { return undefined; } return { type: "search", - query, + query: input.query, }; } -function deriveDetail(name: string, input: unknown, output: unknown, options?: CodexMapperOptions): ToolCallDetail | undefined { - const parsed = CodexKnownToolDetailSchema.safeParse({ - name, - input, - output, +function codexMcpToolBranch( + tool: ToolName, + inputSchema: InputSchema, + outputSchema: OutputSchema, + mapper: ( + input: z.infer | null, + output: z.infer | null, + cwd: string | null | undefined + ) => ToolCallDetail | undefined +) { + return z + .object({ + tool: z.literal(tool), + arguments: inputSchema.nullable(), + result: outputSchema.nullable(), + cwd: z.string().optional().nullable(), + }) + .transform(({ arguments: input, result: output, cwd }) => mapper(input, output, cwd)); +} + +const CodexKnownMcpToolDetailSchema = z.union([ + codexMcpToolBranch("shell", CodexShellInputSchema, CodexShellOutputSchema, (input, output) => + toShellDetail(input, output) + ), + codexMcpToolBranch("bash", CodexShellInputSchema, CodexShellOutputSchema, (input, output) => + toShellDetail(input, output) + ), + codexMcpToolBranch("exec", CodexShellInputSchema, CodexShellOutputSchema, (input, output) => + toShellDetail(input, output) + ), + codexMcpToolBranch("exec_command", CodexShellInputSchema, CodexShellOutputSchema, (input, output) => + toShellDetail(input, output) + ), + codexMcpToolBranch("command", CodexShellInputSchema, CodexShellOutputSchema, (input, output) => + toShellDetail(input, output) + ), + codexMcpToolBranch("read", CodexReadArgumentsSchema, CodexReadResultSchema, toReadDetail), + codexMcpToolBranch("read_file", CodexReadArgumentsSchema, CodexReadResultSchema, toReadDetail), + codexMcpToolBranch("write", CodexWriteArgumentsSchema, CodexWriteResultSchema, toWriteDetail), + codexMcpToolBranch("write_file", CodexWriteArgumentsSchema, CodexWriteResultSchema, toWriteDetail), + codexMcpToolBranch("create_file", CodexWriteArgumentsSchema, CodexWriteResultSchema, toWriteDetail), + codexMcpToolBranch("edit", CodexEditArgumentsSchema, CodexEditResultSchema, toEditDetail), + codexMcpToolBranch("apply_patch", CodexEditArgumentsSchema, CodexEditResultSchema, toEditDetail), + codexMcpToolBranch("apply_diff", CodexEditArgumentsSchema, CodexEditResultSchema, toEditDetail), + codexMcpToolBranch("search", CodexSearchArgumentsSchema, z.unknown(), (input) => toSearchDetail(input)), + codexMcpToolBranch("web_search", CodexSearchArgumentsSchema, z.unknown(), (input) => toSearchDetail(input)), +]); + +function codexRolloutToolBranch( + name: Name, + inputSchema: InputSchema, + outputSchema: OutputSchema, + mapper: ( + input: z.infer | null, + output: z.infer | null + ) => ToolCallDetail | undefined +) { + return z + .object({ + name: z.literal(name), + input: inputSchema.nullable(), + output: outputSchema.nullable(), + }) + .transform(({ input, output }) => mapper(input, output)); +} + +const CodexKnownRolloutDetailSchema = z.union([ + codexRolloutToolBranch("Bash", CodexShellInputSchema, CodexShellOutputSchema, (input, output) => + toShellDetail(input, output) + ), + codexRolloutToolBranch("shell", CodexShellInputSchema, CodexShellOutputSchema, (input, output) => + toShellDetail(input, output) + ), + codexRolloutToolBranch("bash", CodexShellInputSchema, CodexShellOutputSchema, (input, output) => + toShellDetail(input, output) + ), + codexRolloutToolBranch("exec_command", CodexShellInputSchema, CodexShellOutputSchema, (input, output) => + toShellDetail(input, output) + ), + codexRolloutToolBranch("read", CodexReadArgumentsSchema, CodexReadResultSchema, (input, output) => + toReadDetail(input, output, null) + ), + codexRolloutToolBranch("read_file", CodexReadArgumentsSchema, CodexReadResultSchema, (input, output) => + toReadDetail(input, output, null) + ), + codexRolloutToolBranch("write", CodexWriteArgumentsSchema, CodexWriteResultSchema, (input, output) => + toWriteDetail(input, output, null) + ), + codexRolloutToolBranch("write_file", CodexWriteArgumentsSchema, CodexWriteResultSchema, (input, output) => + toWriteDetail(input, output, null) + ), + codexRolloutToolBranch("create_file", CodexWriteArgumentsSchema, CodexWriteResultSchema, (input, output) => + toWriteDetail(input, output, null) + ), + codexRolloutToolBranch("edit", CodexEditArgumentsSchema, CodexEditResultSchema, (input, output) => + toEditDetail(input, output, null) + ), + codexRolloutToolBranch("apply_patch", CodexEditArgumentsSchema, CodexEditResultSchema, (input, output) => + toEditDetail(input, output, null) + ), + codexRolloutToolBranch("apply_diff", CodexEditArgumentsSchema, CodexEditResultSchema, (input, output) => + toEditDetail(input, output, null) + ), + codexRolloutToolBranch("search", CodexSearchArgumentsSchema, z.unknown(), (input) => toSearchDetail(input)), + codexRolloutToolBranch("web_search", CodexSearchArgumentsSchema, z.unknown(), (input) => toSearchDetail(input)), +]); + +function deriveMcpToolDetail( + tool: string, + input: unknown, + output: unknown, + options?: CodexMapperOptions +): ToolCallDetail | undefined { + const parsed = CodexKnownMcpToolDetailSchema.safeParse({ + tool, + arguments: input, + result: output, cwd: options?.cwd ?? null, }); if (!parsed.success) { @@ -629,20 +815,28 @@ function deriveDetail(name: string, input: unknown, output: unknown, options?: C 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); +function deriveRolloutDetail(name: string, input: unknown, output: unknown): ToolCallDetail | undefined { + const parsed = CodexKnownRolloutDetailSchema.safeParse({ + name, + input, + output, + }); + 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; + detail?: ToolCallDetail; + metadata?: Record; +}): ToolCallTimelineItem { if (params.status === "failed") { return { type: "tool_call", @@ -652,7 +846,7 @@ function buildToolCall( input: params.input, output: params.output, error: params.error ?? { message: "Tool call failed" }, - ...(detail ? { detail } : {}), + ...(params.detail ? { detail: params.detail } : {}), ...(params.metadata ? { metadata: params.metadata } : {}), }; } @@ -665,20 +859,37 @@ function buildToolCall( input: params.input, output: params.output, error: null, - ...(detail ? { detail } : {}), + ...(params.detail ? { detail: params.detail } : {}), ...(params.metadata ? { metadata: params.metadata } : {}), }; } +const CODEX_BUILTIN_TOOL_NAMES = new Set([ + "shell", + "bash", + "exec", + "exec_command", + "command", + "read", + "read_file", + "write", + "write_file", + "create_file", + "edit", + "apply_patch", + "apply_diff", + "web_search", + "search", +]); + 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; + if (CODEX_BUILTIN_TOOL_NAMES.has(trimmedTool)) { + return trimmedTool; } const trimmedServer = typeof server === "string" ? server.trim() : ""; @@ -694,10 +905,9 @@ function toNullableObject(value: Record): Record, - options?: CodexMapperOptions + item: z.infer ): ToolCallTimelineItem { - const command = commandFromValue(item.command); + const command = item.command ? commandFromValue(item.command) : undefined; const input = toNullableObject({ ...(command !== undefined ? { command } : {}), ...(item.cwd !== undefined ? { cwd: item.cwd } : {}), @@ -712,22 +922,30 @@ function mapCommandExecutionItem( } : null; + const detail = command + ? { + type: "shell" as const, + command, + ...(item.cwd ? { cwd: item.cwd } : {}), + ...(item.aggregatedOutput ? { output: item.aggregatedOutput } : {}), + ...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}), + } + : undefined; + 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 - ); + return buildToolCall({ + callId, + name, + status, + input, + output, + error, + ...(detail ? { detail } : {}), + }); } function mapFileChangeItem( @@ -736,39 +954,60 @@ function mapFileChangeItem( ): 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 files = changes + .map((change) => ({ + path: normalizeCodexFilePath(change.path, options?.cwd), + kind: change.kind, + diff: change.diff, + })) + .filter((change) => change.path !== undefined); - const outputFiles = changes.map((change) => ({ - ...(normalizeCodexFilePath(change.path, options?.cwd) !== undefined - ? { path: normalizeCodexFilePath(change.path, options?.cwd) } + const input = toNullableObject({ + ...(files.length > 0 + ? { + files: files.map((file) => ({ + path: file.path, + ...(file.kind !== undefined ? { kind: file.kind } : {}), + })), + } : {}), - ...(change.diff !== undefined ? { patch: change.diff } : {}), - ...(change.kind !== undefined ? { kind: change.kind } : {}), - })); + }); + + const output = toNullableObject({ + ...(files.length > 0 + ? { + files: files.map((file) => ({ + path: file.path, + ...(file.kind !== undefined ? { kind: file.kind } : {}), + ...(file.diff !== undefined ? { patch: truncateDiffText(file.diff) } : {}), + })), + } + : {}), + }); + + const firstFile = files[0]; + const detail = firstFile?.path + ? { + type: "edit" as const, + filePath: firstFile.path, + ...(firstFile.diff !== undefined ? { unifiedDiff: truncateDiffText(firstFile.diff) } : {}), + } + : undefined; - 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 - ); + return buildToolCall({ + callId, + name, + status, + input, + output, + error, + ...(detail ? { detail } : {}), + }); } function mapMcpToolCallItem( @@ -782,55 +1021,55 @@ function mapMcpToolCallItem( const error = item.error ?? null; const callId = coerceCallId(item.id ?? item.callID ?? item.call_id, name, input); const status = resolveStatus(item.status, error, output); + const detail = deriveMcpToolDetail(tool, input, output, options); - return buildToolCall( - { - callId, - name, - status, - input, - output, - error, - }, - options - ); + return buildToolCall({ + callId, + name, + status, + input, + output, + error, + ...(detail ? { detail } : {}), + }); } -function mapWebSearchItem( - item: z.infer, - options?: CodexMapperOptions -): ToolCallTimelineItem { +function mapWebSearchItem(item: z.infer): 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); + const detail = item.query + ? { + type: "search" as const, + query: item.query, + } + : undefined; - return buildToolCall( - { - callId, - name, - status, - input, - output, - error, - }, - options - ); + return buildToolCall({ + callId, + name, + status, + input, + output, + error, + ...(detail ? { detail } : {}), + }); } function createCodexThreadItemToTimelineSchema(options?: CodexMapperOptions) { return CodexThreadItemSchema.transform((item): ToolCallTimelineItem => { switch (item.type) { case "commandExecution": - return mapCommandExecutionItem(item, options); + return mapCommandExecutionItem(item); case "fileChange": return mapFileChangeItem(item, options); case "mcpToolCall": return mapMcpToolCallItem(item, options); case "webSearch": - return mapWebSearchItem(item, options); + return mapWebSearchItem(item); default: { const exhaustiveCheck: never = item; throw new Error(`Unhandled Codex thread item type: ${String(exhaustiveCheck)}`); @@ -863,6 +1102,7 @@ export function mapCodexRolloutToolCall(params: { const error = parsed.error ?? null; const status = resolveStatus("completed", error, output); const callId = coerceCallId(parsed.callId, parsed.name, input); + const detail = deriveRolloutDetail(parsed.name, input, output); return buildToolCall({ callId, @@ -871,5 +1111,6 @@ export function mapCodexRolloutToolCall(params: { input, output, error, + ...(detail ? { detail } : {}), }); } 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 index dd0ff30bb..8105d99e4 100644 --- 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 @@ -21,6 +21,56 @@ describe("opencode tool-call mapper", () => { } }); + it("maps running known tool variants with detail for early summaries", () => { + const readItem = mapOpencodeToolCall({ + toolName: "read_file", + callId: "opencode-running-read", + status: "running", + input: { file_path: "README.md" }, + output: null, + }); + expect(readItem.detail).toEqual({ + type: "read", + filePath: "README.md", + }); + + const writeItem = mapOpencodeToolCall({ + toolName: "write_file", + callId: "opencode-running-write", + status: "running", + input: { file_path: "src/new.ts" }, + output: null, + }); + expect(writeItem.detail).toEqual({ + type: "write", + filePath: "src/new.ts", + }); + + const editItem = mapOpencodeToolCall({ + toolName: "apply_patch", + callId: "opencode-running-edit", + status: "running", + input: { file_path: "src/index.ts" }, + output: null, + }); + expect(editItem.detail).toEqual({ + type: "edit", + filePath: "src/index.ts", + }); + + const searchItem = mapOpencodeToolCall({ + toolName: "web_search", + callId: "opencode-running-search", + status: "running", + input: { query: "opencode mapper" }, + output: null, + }); + expect(searchItem.detail).toEqual({ + type: "search", + query: "opencode mapper", + }); + }); + it("maps completed read calls", () => { const item = mapOpencodeToolCall({ toolName: "read_file", @@ -40,6 +90,43 @@ describe("opencode tool-call mapper", () => { } }); + it("preserves read content from array/object output variants", () => { + const arrayContent = mapOpencodeToolCall({ + toolName: "read_file", + callId: "opencode-read-array", + status: "completed", + input: { file_path: "README.md" }, + output: { + content: [ + { type: "output_text", text: "alpha" }, + { type: "output_text", output: "beta" }, + ], + }, + }); + + expect(arrayContent.detail?.type).toBe("read"); + if (arrayContent.detail?.type === "read") { + expect(arrayContent.detail.content).toBe("alpha\nbeta"); + } + + const objectContent = mapOpencodeToolCall({ + toolName: "read_file", + callId: "opencode-read-object", + status: "completed", + input: { file_path: "README.md" }, + output: { + data: { + content: { type: "output_text", text: "gamma" }, + }, + }, + }); + + expect(objectContent.detail?.type).toBe("read"); + if (objectContent.detail?.type === "read") { + expect(objectContent.detail.content).toBe("gamma"); + } + }); + it("maps failed calls with required error", () => { const item = mapOpencodeToolCall({ toolName: "shell", @@ -55,6 +142,45 @@ describe("opencode tool-call mapper", () => { expect(item.callId).toBe("opencode-call-3"); }); + it("maps write/edit/search known variants into canonical detail", () => { + const writeItem = mapOpencodeToolCall({ + toolName: "write_file", + callId: "opencode-write-1", + status: "completed", + input: { file_path: "src/new.ts", content: "const x = 1;" }, + output: null, + }); + expect(writeItem.detail?.type).toBe("write"); + if (writeItem.detail?.type === "write") { + expect(writeItem.detail.filePath).toBe("src/new.ts"); + } + + const editItem = mapOpencodeToolCall({ + toolName: "apply_patch", + callId: "opencode-edit-1", + status: "completed", + input: { file_path: "src/index.ts", diff: "@@\\n-old\\n+new\\n" }, + output: null, + }); + expect(editItem.detail?.type).toBe("edit"); + if (editItem.detail?.type === "edit") { + expect(editItem.detail.filePath).toBe("src/index.ts"); + expect(editItem.detail.unifiedDiff).toContain("@@"); + } + + const searchItem = mapOpencodeToolCall({ + toolName: "web_search", + callId: "opencode-search-1", + status: "completed", + input: { query: "opencode mapper" }, + output: null, + }); + expect(searchItem.detail).toEqual({ + type: "search", + query: "opencode mapper", + }); + }); + it("keeps unknown tools canonical without detail", () => { const item = mapOpencodeToolCall({ toolName: "my_custom_tool", 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 index 6431a734f..397a92df2 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts @@ -1,6 +1,13 @@ import { z } from "zod"; import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js"; +import { + coerceToolCallId, + commandFromValue, + flattenReadContent as flattenToolReadContent, + nonEmptyString, + truncateDiffText, +} from "../tool-call-mapper-utils.js"; type OpencodeToolCallParams = { toolName: string; @@ -12,6 +19,8 @@ type OpencodeToolCallParams = { metadata?: Record; }; +const MAX_DIFF_TEXT_CHARS = 12_000; + 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"]); @@ -28,64 +37,32 @@ const OpencodeToolCallParamsSchema = z }) .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 CommandValueSchema = z.union([z.string(), z.array(z.string())]); 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(); + .union([ + z + .object({ + command: CommandValueSchema, + cwd: z.string().optional(), + directory: z.string().optional(), + }) + .passthrough(), + z + .object({ + cmd: CommandValueSchema, + cwd: z.string().optional(), + directory: z.string().optional(), + }) + .passthrough(), + ]) + .transform((value) => { + const commandValue = "command" in value ? value.command : value.cmd; + return { + command: commandFromValue(commandValue), + cwd: nonEmptyString(value.cwd) ?? nonEmptyString(value.directory), + }; + }); const OpencodeShellOutputObjectSchema = z .object({ @@ -93,192 +70,409 @@ const OpencodeShellOutputObjectSchema = z output: z.string().optional(), text: z.string().optional(), content: z.string().optional(), - exitCode: z.number().nullable().optional(), - exit_code: z.number().nullable().optional(), + exitCode: z.number().finite().nullable().optional(), + exit_code: z.number().finite().nullable().optional(), metadata: z .object({ - exitCode: z.number().nullable().optional(), - exit_code: z.number().nullable().optional(), + exitCode: z.number().finite().nullable().optional(), + exit_code: z.number().finite().nullable().optional(), + }) + .passthrough() + .optional(), + structuredContent: z + .object({ + output: z.string().optional(), + text: z.string().optional(), + content: z.string().optional(), + }) + .passthrough() + .optional(), + structured_content: z + .object({ + output: z.string().optional(), + text: z.string().optional(), + content: z.string().optional(), + }) + .passthrough() + .optional(), + result: z + .object({ + command: z.string().optional(), + output: z.string().optional(), + text: z.string().optional(), + content: z.string().optional(), }) .passthrough() .optional(), - structuredContent: OpencodeTextLikeSchema.optional(), - structured_content: OpencodeTextLikeSchema.optional(), - result: OpencodeTextLikeSchema.optional(), }) .passthrough(); -const OpencodeShellOutputSchema = z.union([z.string(), OpencodeShellOutputObjectSchema]); +const OpencodeShellOutputSchema = z.union([ + z.string().transform((value) => ({ + command: undefined, + output: nonEmptyString(value), + exitCode: undefined, + })), + OpencodeShellOutputObjectSchema.transform((value) => ({ + command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command), + output: + nonEmptyString(value.output) ?? + nonEmptyString(value.text) ?? + nonEmptyString(value.content) ?? + nonEmptyString(value.structuredContent?.output) ?? + nonEmptyString(value.structuredContent?.text) ?? + nonEmptyString(value.structuredContent?.content) ?? + nonEmptyString(value.structured_content?.output) ?? + nonEmptyString(value.structured_content?.text) ?? + nonEmptyString(value.structured_content?.content) ?? + nonEmptyString(value.result?.output) ?? + nonEmptyString(value.result?.text) ?? + nonEmptyString(value.result?.content), + exitCode: + value.exitCode ?? + value.exit_code ?? + value.metadata?.exitCode ?? + value.metadata?.exit_code ?? + undefined, + })), +]); -const OpencodeReadInputSchema = OpencodeFileReferenceCollectionSchema.extend({ - offset: z.number().finite().optional(), - limit: z.number().finite().optional(), -}).passthrough(); +const OpencodeReadPathInputSchema = z.union([ + z + .object({ + file_path: z.string(), + offset: z.number().finite().optional(), + limit: z.number().finite().optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.file_path, + offset: value.offset, + limit: value.limit, + })), + z + .object({ + path: z.string(), + offset: z.number().finite().optional(), + limit: z.number().finite().optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.path, + offset: value.offset, + limit: value.limit, + })), + z + .object({ + filePath: z.string(), + offset: z.number().finite().optional(), + limit: z.number().finite().optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.filePath, + offset: value.offset, + limit: value.limit, + })), +]); + +const OpencodeReadChunkSchema = z.union([ + z + .object({ + text: z.string(), + content: z.string().optional(), + output: z.string().optional(), + }) + .passthrough(), + z + .object({ + text: z.string().optional(), + content: z.string(), + output: z.string().optional(), + }) + .passthrough(), + z + .object({ + text: z.string().optional(), + content: z.string().optional(), + output: z.string(), + }) + .passthrough(), +]); + +const OpencodeReadContentSchema = z.union([ + z.string(), + OpencodeReadChunkSchema, + z.array(OpencodeReadChunkSchema), +]); + +const OpencodeReadPayloadSchema = z.union([ + z + .object({ + content: OpencodeReadContentSchema, + text: OpencodeReadContentSchema.optional(), + output: OpencodeReadContentSchema.optional(), + }) + .passthrough(), + z + .object({ + content: OpencodeReadContentSchema.optional(), + text: OpencodeReadContentSchema, + output: OpencodeReadContentSchema.optional(), + }) + .passthrough(), + z + .object({ + content: OpencodeReadContentSchema.optional(), + text: OpencodeReadContentSchema.optional(), + output: OpencodeReadContentSchema, + }) + .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(), + z.string().transform((value) => ({ content: nonEmptyString(value) })), + OpencodeReadChunkSchema.transform((value) => ({ content: flattenReadContent(value) })), + z.array(OpencodeReadChunkSchema).transform((value) => ({ content: flattenReadContent(value) })), + OpencodeReadPayloadSchema.transform((value) => ({ + content: + flattenReadContent(value.content) ?? + flattenReadContent(value.text) ?? + flattenReadContent(value.output), + })), + z + .object({ data: OpencodeReadPayloadSchema }) + .passthrough() + .transform((value) => ({ + content: + flattenReadContent(value.data.content) ?? + flattenReadContent(value.data.text) ?? + flattenReadContent(value.data.output), + })), + z + .object({ structuredContent: OpencodeReadPayloadSchema }) + .passthrough() + .transform((value) => ({ + content: + flattenReadContent(value.structuredContent.content) ?? + flattenReadContent(value.structuredContent.text) ?? + flattenReadContent(value.structuredContent.output), + })), + z + .object({ structured_content: OpencodeReadPayloadSchema }) + .passthrough() + .transform((value) => ({ + content: + flattenReadContent(value.structured_content.content) ?? + flattenReadContent(value.structured_content.text) ?? + flattenReadContent(value.structured_content.output), + })), ]); -const OpencodeWriteInputSchema = OpencodeFileReferenceCollectionSchema.extend({ - content: z.string().optional(), - newContent: z.string().optional(), - new_content: z.string().optional(), -}).passthrough(); +const OpencodeWritePathInputSchema = z.union([ + z.object({ file_path: z.string() }).passthrough().transform((value) => ({ filePath: value.file_path })), + z.object({ path: z.string() }).passthrough().transform((value) => ({ filePath: value.path })), + z.object({ filePath: z.string() }).passthrough().transform((value) => ({ filePath: value.filePath })), +]); -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 +const OpencodeWriteContentSchema = z .object({ - query: z.string().optional(), - q: z.string().optional(), + content: z.string().optional(), + new_content: z.string().optional(), + newContent: 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 OpencodeWriteInputSchema = z + .intersection(OpencodeWritePathInputSchema, OpencodeWriteContentSchema) + .transform((value) => ({ + filePath: value.filePath, + content: + nonEmptyString(value.content) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.newContent), + })); -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, +const OpencodeWriteOutputSchema = z.union([ + z + .intersection(OpencodeWritePathInputSchema, OpencodeWriteContentSchema) + .transform((value) => ({ + filePath: value.filePath, + content: + nonEmptyString(value.content) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.newContent), + })), + OpencodeWriteContentSchema.transform((value) => ({ + filePath: undefined, + content: + nonEmptyString(value.content) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.newContent), + })), ]); -function firstNonEmpty(...values: Array): string | undefined { - return values.find((value) => typeof value === "string" && value.length > 0); -} +const OpencodeEditTextSchema = z + .object({ + 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(); -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; -} +const OpencodeEditInputSchema = z + .intersection(OpencodeWritePathInputSchema, OpencodeEditTextSchema) + .transform((value) => ({ + filePath: value.filePath, + oldString: + nonEmptyString(value.old_string) ?? + nonEmptyString(value.old_str) ?? + nonEmptyString(value.oldContent) ?? + nonEmptyString(value.old_content), + newString: + nonEmptyString(value.new_string) ?? + nonEmptyString(value.new_str) ?? + nonEmptyString(value.newContent) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.content), + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff), + MAX_DIFF_TEXT_CHARS + ), + })); -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 - ); -} +const OpencodeEditOutputFileSchema = z.union([ + z + .object({ + path: z.string(), + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.path, + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff), + MAX_DIFF_TEXT_CHARS + ), + })), + z + .object({ + file_path: z.string(), + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.file_path, + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff), + MAX_DIFF_TEXT_CHARS + ), + })), + z + .object({ + filePath: z.string(), + patch: z.string().optional(), + diff: z.string().optional(), + unified_diff: z.string().optional(), + unifiedDiff: z.string().optional(), + }) + .passthrough() + .transform((value) => ({ + filePath: value.filePath, + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff), + MAX_DIFF_TEXT_CHARS + ), + })), +]); -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); +const OpencodeEditOutputSchema = z.union([ + z + .intersection(OpencodeWritePathInputSchema, OpencodeEditTextSchema) + .transform((value) => ({ + filePath: value.filePath, + newString: + nonEmptyString(value.newContent) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.content), + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff), + MAX_DIFF_TEXT_CHARS + ), + })), + z + .object({ files: z.array(OpencodeEditOutputFileSchema).min(1) }) + .passthrough() + .transform((value) => ({ + filePath: value.files[0]?.filePath, + unifiedDiff: value.files[0]?.unifiedDiff, + newString: undefined, + })), + OpencodeEditTextSchema.transform((value) => ({ + filePath: undefined, + newString: + nonEmptyString(value.newContent) ?? + nonEmptyString(value.new_content) ?? + nonEmptyString(value.content), + unifiedDiff: truncateDiffText( + nonEmptyString(value.patch) ?? + nonEmptyString(value.diff) ?? + nonEmptyString(value.unified_diff) ?? + nonEmptyString(value.unifiedDiff), + MAX_DIFF_TEXT_CHARS + ), + })), +]); + +const OpencodeSearchInputSchema = z.union([ + z.object({ query: z.string() }).passthrough().transform((value) => ({ query: value.query })), + z.object({ q: z.string() }).passthrough().transform((value) => ({ query: value.q })), +]); + +function flattenReadContent( + value: z.infer | undefined +): string | undefined { + return flattenToolReadContent(value); } 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}`)}`; + return coerceToolCallId({ + providerPrefix: "opencode", + rawCallId: callId, + toolName, + input, + }); } function resolveStatus(rawStatus: unknown, error: unknown, output: unknown): ToolCallTimelineItem["status"] { @@ -305,214 +499,127 @@ function resolveStatus(rawStatus: unknown, error: unknown, output: unknown): Too 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); - +function toShellDetail( + input: z.infer | null, + output: z.infer | null +): ToolCallDetail | undefined { + const command = input?.command ?? output?.command; 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 }), + ...(input?.cwd ? { cwd: input.cwd } : {}), + ...(output?.output ? { output: output.output } : {}), + ...(output?.exitCode !== undefined ? { exitCode: output.exitCode } : {}), }; } -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) { +function toReadDetail( + input: z.infer | null, + output: z.infer | null +): ToolCallDetail | undefined { + if (!input?.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 } : {}), + filePath: input.filePath, + ...(output?.content ? { content: output.content } : {}), + ...(input.offset !== undefined ? { offset: input.offset } : {}), + ...(input.limit !== undefined ? { limit: input.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 - ); - +function toWriteDetail( + input: z.infer | null, + output: z.infer | null +): ToolCallDetail | undefined { + const filePath = input?.filePath ?? output?.filePath; 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 } : {}), + ...(input?.content ? { content: input.content } : output?.content ? { content: output.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 - ); - +function toEditDetail( + input: z.infer | null, + output: z.infer | null +): ToolCallDetail | undefined { + const filePath = input?.filePath ?? output?.filePath; 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 } : {}), + ...(input?.oldString ? { oldString: input.oldString } : {}), + ...(input?.newString ? { newString: input.newString } : output?.newString ? { newString: output.newString } : {}), + ...(input?.unifiedDiff + ? { unifiedDiff: input.unifiedDiff } + : output?.unifiedDiff + ? { unifiedDiff: output.unifiedDiff } + : {}), }; } -function resolveSearchDetail(input: unknown): ToolCallDetail | undefined { - const parsedInput = OpencodeSearchInputSchema.safeParse(input); - if (!parsedInput.success) { +function toSearchDetail(input: z.infer | null): ToolCallDetail | undefined { + if (!input?.query) { return undefined; } - - const query = firstNonEmpty(parsedInput.data.query, parsedInput.data.q); - if (!query) { - return undefined; - } - return { type: "search", - query, + query: input.query, }; } +function opencodeToolBranch< + ToolName extends string, + InputSchema extends z.ZodTypeAny, + OutputSchema extends z.ZodTypeAny, +>( + toolName: ToolName, + inputSchema: InputSchema, + outputSchema: OutputSchema, + mapper: ( + input: z.infer | null, + output: z.infer | null + ) => ToolCallDetail | undefined +) { + return z + .object({ + toolName: z.literal(toolName), + input: inputSchema.nullable(), + output: outputSchema.nullable(), + }) + .transform(({ input, output }) => mapper(input, output)); +} + +const OpencodeKnownToolDetailSchema = z.union([ + opencodeToolBranch("shell", OpencodeShellInputSchema, OpencodeShellOutputSchema, toShellDetail), + opencodeToolBranch("bash", OpencodeShellInputSchema, OpencodeShellOutputSchema, toShellDetail), + opencodeToolBranch("exec_command", OpencodeShellInputSchema, OpencodeShellOutputSchema, toShellDetail), + opencodeToolBranch("read", OpencodeReadPathInputSchema, OpencodeReadOutputSchema, toReadDetail), + opencodeToolBranch("read_file", OpencodeReadPathInputSchema, OpencodeReadOutputSchema, toReadDetail), + opencodeToolBranch("write", OpencodeWriteInputSchema, OpencodeWriteOutputSchema, toWriteDetail), + opencodeToolBranch("write_file", OpencodeWriteInputSchema, OpencodeWriteOutputSchema, toWriteDetail), + opencodeToolBranch("create_file", OpencodeWriteInputSchema, OpencodeWriteOutputSchema, toWriteDetail), + opencodeToolBranch("edit", OpencodeEditInputSchema, OpencodeEditOutputSchema, toEditDetail), + opencodeToolBranch("apply_patch", OpencodeEditInputSchema, OpencodeEditOutputSchema, toEditDetail), + opencodeToolBranch("apply_diff", OpencodeEditInputSchema, OpencodeEditOutputSchema, toEditDetail), + opencodeToolBranch("search", OpencodeSearchInputSchema, z.unknown(), (input) => toSearchDetail(input)), + opencodeToolBranch("web_search", OpencodeSearchInputSchema, z.unknown(), (input) => toSearchDetail(input)), +]); + function deriveDetail(toolName: string, input: unknown, output: unknown): ToolCallDetail | undefined { const parsed = OpencodeKnownToolDetailSchema.safeParse({ toolName, diff --git a/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts b/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts new file mode 100644 index 000000000..bfb4b0272 --- /dev/null +++ b/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts @@ -0,0 +1,93 @@ +type ReadChunkLike = { + text?: string; + content?: string; + output?: string; +}; + +export function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function commandFromValue(value: unknown): string | undefined { + if (typeof value === "string") { + return nonEmptyString(value); + } + if (!Array.isArray(value)) { + return undefined; + } + const tokens = value.filter( + (token): token is string => typeof token === "string" && token.length > 0 + ); + return tokens.length > 0 ? tokens.join(" ") : undefined; +} + +export function flattenReadContent( + value: string | Chunk | Chunk[] | undefined +): string | undefined { + if (typeof value === "string") { + return nonEmptyString(value); + } + if (!value) { + return undefined; + } + if (Array.isArray(value)) { + const parts = value + .map( + (chunk) => + nonEmptyString(chunk.text) ?? + nonEmptyString(chunk.content) ?? + nonEmptyString(chunk.output) + ) + .filter((part): part is string => typeof part === "string"); + return parts.length > 0 ? parts.join("\n") : undefined; + } + return ( + nonEmptyString(value.text) ?? + nonEmptyString(value.content) ?? + nonEmptyString(value.output) + ); +} + +export function truncateDiffText( + text: string | undefined, + maxChars: number = 12_000 +): string | undefined { + if (typeof text !== "string") { + return undefined; + } + if (text.length <= maxChars) { + return text; + } + + const truncatedCount = text.length - maxChars; + return `${text.slice(0, maxChars)}\n...[truncated ${truncatedCount} chars]`; +} + +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); +} + +export function coerceToolCallId(params: { + providerPrefix: string; + rawCallId: string | null | undefined; + toolName: string; + input: unknown; +}): string { + if (typeof params.rawCallId === "string" && params.rawCallId.trim().length > 0) { + return params.rawCallId; + } + + let serialized = ""; + try { + serialized = JSON.stringify(params.input) ?? ""; + } catch { + serialized = String(params.input); + } + + return `${params.providerPrefix}-${hashText(`${params.toolName}:${serialized}`)}`; +} diff --git a/packages/server/src/server/messages.test.ts b/packages/server/src/server/messages.test.ts index 1045e5c9c..a5dd83da3 100644 --- a/packages/server/src/server/messages.test.ts +++ b/packages/server/src/server/messages.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test } from "vitest"; +import type { AgentStreamEvent } from "./agent/agent-sdk-types.js"; import { serializeAgentStreamEvent } from "./messages.js"; describe("serializeAgentStreamEvent", () => { test("preserves user_message text as-is", () => { - const event = { + const event: AgentStreamEvent = { type: "timeline", provider: "claude", item: { @@ -12,10 +13,56 @@ describe("serializeAgentStreamEvent", () => { text: "\nX\n\n\nHello", messageId: "m1", }, - } as any; + }; - const serialized = serializeAgentStreamEvent(event) as any; + const serialized = serializeAgentStreamEvent(event); + expect(serialized).not.toBeNull(); + if (!serialized || serialized.type !== "timeline" || serialized.item.type !== "user_message") { + throw new Error("Expected timeline.user_message event"); + } expect(serialized.item.text).toBe(event.item.text); expect(serialized.item.messageId).toBe("m1"); }); + + test("passes canonical tool_call payloads through unchanged", () => { + const event: AgentStreamEvent = { + type: "timeline", + provider: "codex", + item: { + type: "tool_call", + callId: "call_1", + name: "shell", + status: "running", + input: { command: "pwd" }, + output: null, + error: null, + }, + }; + + const serialized = serializeAgentStreamEvent(event); + expect(serialized).not.toBeNull(); + if (!serialized || serialized.type !== "timeline" || serialized.item.type !== "tool_call") { + throw new Error("Expected timeline.tool_call event"); + } + expect(serialized.item.status).toBe("running"); + expect(serialized.item.output).toBeNull(); + expect(serialized.item.error).toBeNull(); + }); + + test("drops invalid legacy tool_call items", () => { + const event = { + type: "timeline", + provider: "codex", + item: { + type: "tool_call", + callId: "call_legacy", + name: "shell", + status: "inProgress", + input: { command: "pwd" }, + }, + } satisfies unknown; + + const serialized = serializeAgentStreamEvent(event as AgentStreamEvent); + expect(serialized).toBeNull(); + }); }); diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index 0374a74ae..51809fe6c 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import type { ManagedAgent } from "./agent/agent-manager.js"; import { toAgentPayload } from "./agent/agent-projections.js"; import type { AgentStreamEvent } from "./agent/agent-sdk-types.js"; @@ -5,8 +6,20 @@ import type { AgentSnapshotPayload, AgentStreamEventPayload, } from "../shared/messages.js"; +import { AgentStreamEventPayloadSchema as AgentStreamEventPayloadRuntimeSchema } from "../shared/messages.js"; export * from "../shared/messages.js"; +type AgentStreamEventPayloadInput = z.input; + +function validateStreamEventPayload( + payload: AgentStreamEventPayloadInput +): AgentStreamEventPayload | null { + const parsed = AgentStreamEventPayloadRuntimeSchema.safeParse(payload); + if (!parsed.success) { + return null; + } + return parsed.data; +} export function serializeAgentSnapshot( agent: ManagedAgent, @@ -17,21 +30,19 @@ export function serializeAgentSnapshot( export function serializeAgentStreamEvent( event: AgentStreamEvent -): AgentStreamEventPayload { +): AgentStreamEventPayload | null { if (event.type === "attention_required") { // Providers may emit attention_required without per-client notification context. // The websocket server emits attention_required with shouldNotify computed per client. // Normalize provider events so they satisfy the shared schema. - return { - ...(event as Omit), + return validateStreamEventPayload({ + type: "attention_required", + provider: event.provider, + reason: event.reason, + timestamp: event.timestamp, shouldNotify: false, - } as AgentStreamEventPayload; + }); } - if (event.type !== "timeline") { - return event as AgentStreamEventPayload; - } - if (event.item.type !== "user_message") { - return event as AgentStreamEventPayload; - } - return event as AgentStreamEventPayload; + + return validateStreamEventPayload(event as AgentStreamEventPayloadInput); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d12afffcb..51e8b29d8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -663,9 +663,14 @@ export class Session { } } + const serializedEvent = serializeAgentStreamEvent(event.event); + if (!serializedEvent) { + return; + } + const payload = { agentId: event.agentId, - event: serializeAgentStreamEvent(event.event), + event: serializedEvent, timestamp: new Date().toISOString(), } as const; @@ -4287,14 +4292,22 @@ export class Session { private emitAgentTimelineSnapshot(agent: ManagedAgent): number { const timeline = this.agentManager.getTimeline(agent.id); - const events = timeline.map((item) => ({ - event: serializeAgentStreamEvent({ + const events = timeline.flatMap((item) => { + const serializedEvent = serializeAgentStreamEvent({ type: "timeline", provider: agent.provider, item, - }), - timestamp: new Date().toISOString(), - })); + }); + if (!serializedEvent) { + return []; + } + return [ + { + event: serializedEvent, + timestamp: new Date().toISOString(), + }, + ]; + }); this.emit({ type: "agent_stream_snapshot", diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts index 9fbf8b259..1c307c3aa 100644 --- a/packages/server/src/shared/messages.stream-parsing.test.ts +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -13,17 +13,10 @@ function loadFixture(name: string): unknown { } describe("shared messages stream parsing", () => { - it("parses legacy inProgress tool_call snapshots and normalizes status", () => { + it("rejects legacy inProgress tool_call snapshots", () => { 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(); - } + const parsed = AgentStreamSnapshotMessageSchema.safeParse(fixture); + expect(parsed.success).toBe(false); }); it("parses representative agent_stream tool_call event", () => { @@ -61,24 +54,16 @@ describe("shared messages stream parsing", () => { } }); - it("parses websocket envelope for agent_stream_snapshot with legacy status", () => { + it("rejects 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({ + const wrapped = WSOutboundMessageSchema.safeParse({ 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(); - } - } + expect(wrapped.success).toBe(false); }); }); diff --git a/packages/server/src/shared/messages.tool-call-schema.test.ts b/packages/server/src/shared/messages.tool-call-schema.test.ts index 62dd02046..5f0369ab7 100644 --- a/packages/server/src/shared/messages.tool-call-schema.test.ts +++ b/packages/server/src/shared/messages.tool-call-schema.test.ts @@ -69,7 +69,7 @@ describe("shared messages tool_call schema", () => { expect(unknownStatus.success).toBe(false); }); - it("normalizes recoverable legacy status/error combinations", () => { + it("rejects legacy status/error combinations without normalization", () => { const completedWithError = AgentTimelineItemPayloadSchema.safeParse({ ...canonicalBase(), status: "completed", @@ -91,18 +91,15 @@ describe("shared messages tool_call schema", () => { error: null, }); - expect(completedWithError.success).toBe(true); - expect(failedWithoutError.success).toBe(true); - expect(missingOutput.success).toBe(true); + const legacyStatus = AgentTimelineItemPayloadSchema.safeParse({ + ...canonicalBase(), + status: "inProgress", + error: null, + }); - 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(); - } + expect(completedWithError.success).toBe(false); + expect(failedWithoutError.success).toBe(false); + expect(missingOutput.success).toBe(false); + expect(legacyStatus.success).toBe(false); }); }); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index c99721a83..7b3033462 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -229,67 +229,13 @@ const ToolCallCanceledPayloadSchema = ToolCallBasePayloadSchema.extend({ 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, +const ToolCallTimelineItemPayloadSchema: z.ZodType = z.union([ ToolCallRunningPayloadSchema, ToolCallCompletedPayloadSchema, ToolCallFailedPayloadSchema, ToolCallCanceledPayloadSchema, - ]) -); + ]); export const AgentTimelineItemPayloadSchema: z.ZodType = z.union([ @@ -388,7 +334,7 @@ const AgentPersistenceHandleSchema: z.ZodType = .object({ provider: AgentProviderSchema, sessionId: z.string(), - nativeHandle: z.any().optional(), + nativeHandle: z.string().optional(), metadata: z.record(z.unknown()).optional(), }) .nullable(); diff --git a/packages/server/src/shared/tool-call-display.test.ts b/packages/server/src/shared/tool-call-display.test.ts new file mode 100644 index 000000000..e27043e13 --- /dev/null +++ b/packages/server/src/shared/tool-call-display.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { buildToolCallDisplayModel } from "./tool-call-display.js"; + +describe("shared tool-call display mapping", () => { + it("builds summary from canonical detail", () => { + const display = buildToolCallDisplayModel({ + name: "read_file", + status: "running", + input: { path: "/tmp/repo/src/index.ts" }, + output: null, + error: null, + detail: { + type: "read", + filePath: "/tmp/repo/src/index.ts", + }, + cwd: "/tmp/repo", + }); + + expect(display).toEqual({ + displayName: "Read", + summary: "src/index.ts", + }); + }); + + it("does not infer summaries from raw input when detail is missing", () => { + const display = buildToolCallDisplayModel({ + name: "exec_command", + status: "running", + input: { command: "npm test" }, + output: null, + error: null, + }); + + expect(display).toEqual({ + displayName: "Exec Command", + }); + }); + + it("keeps task metadata summary without detail", () => { + const display = buildToolCallDisplayModel({ + name: "task", + status: "running", + input: null, + output: null, + error: null, + metadata: { + subAgentActivity: "Running tests", + }, + }); + + expect(display).toEqual({ + displayName: "Task", + summary: "Running tests", + }); + }); + + it("provides errorText for failed calls", () => { + const display = buildToolCallDisplayModel({ + name: "shell", + status: "failed", + input: null, + output: null, + error: { message: "boom" }, + }); + + expect(display.errorText).toBe('{\n "message": "boom"\n}'); + }); +}); diff --git a/packages/server/src/shared/tool-call-display.ts b/packages/server/src/shared/tool-call-display.ts new file mode 100644 index 000000000..99a76a7e3 --- /dev/null +++ b/packages/server/src/shared/tool-call-display.ts @@ -0,0 +1,119 @@ +import type { ToolCallTimelineItem } from "../server/agent/agent-sdk-types.js"; + +export type ToolCallDisplayInput = Pick< + ToolCallTimelineItem, + "name" | "status" | "input" | "output" | "error" | "metadata" | "detail" +> & { + cwd?: string; +}; + +export type ToolCallDisplayModel = { + displayName: string; + summary?: string; + errorText?: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function humanizeToolName(name: string): string { + const trimmed = name.trim(); + if (!trimmed) { + return name; + } + if (/[:./]/.test(trimmed) || trimmed.includes("__")) { + return trimmed; + } + + return trimmed + .replace(/[._-]+/g, " ") + .split(" ") + .filter((segment) => segment.length > 0) + .map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`) + .join(" "); +} + +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 formatErrorText(error: unknown): string | undefined { + if (error === null || error === undefined) { + return undefined; + } + if (typeof error === "string") { + return error; + } + if (isRecord(error) && typeof error.content === "string") { + return error.content; + } + try { + return JSON.stringify(error, null, 2); + } catch { + return String(error); + } +} + +export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel { + const lowerName = input.name.trim().toLowerCase(); + + let displayName = humanizeToolName(input.name); + let summary: string | undefined; + + if (input.detail) { + switch (input.detail.type) { + case "shell": + displayName = "Shell"; + summary = input.detail.command; + break; + case "read": + displayName = "Read"; + summary = stripCwdPrefix(input.detail.filePath, input.cwd); + break; + case "edit": + displayName = "Edit"; + summary = stripCwdPrefix(input.detail.filePath, input.cwd); + break; + case "write": + displayName = "Write"; + summary = stripCwdPrefix(input.detail.filePath, input.cwd); + break; + case "search": + displayName = "Search"; + summary = input.detail.query; + break; + } + } else if (lowerName === "task") { + displayName = "Task"; + summary = isRecord(input.metadata) ? readString(input.metadata.subAgentActivity) : undefined; + } else if (lowerName === "thinking") { + displayName = "Thinking"; + } + + const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined; + + return { + displayName, + ...(summary ? { summary } : {}), + ...(errorText ? { errorText } : {}), + }; +}