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 } : {}),
+ };
+}