diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx
index 7e832606c..a7098530e 100644
--- a/packages/app/src/components/tool-call-details.tsx
+++ b/packages/app/src/components/tool-call-details.tsx
@@ -177,7 +177,7 @@ export function ToolCallDetailsContent({
if (plainInputText !== null) {
sections.push(
- {plainInputText}
+ {plainInputText}
);
} else {
@@ -298,6 +298,13 @@ const styles = StyleSheet.create((theme) => {
gap: theme.spacing[2],
padding: theme.spacing[3],
},
+ plainText: {
+ fontFamily: Fonts.sans,
+ fontSize: theme.fontSize.base,
+ color: theme.colors.foreground,
+ lineHeight: 22,
+ overflowWrap: "anywhere",
+ },
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
diff --git a/packages/app/src/utils/tool-call-icon.ts b/packages/app/src/utils/tool-call-icon.ts
index c60fed502..d3e8cb320 100644
--- a/packages/app/src/utils/tool-call-icon.ts
+++ b/packages/app/src/utils/tool-call-icon.ts
@@ -1,5 +1,14 @@
import type { ComponentType } from "react";
-import { Bot, Brain, Eye, Pencil, Search, SquareTerminal, Wrench } from "lucide-react-native";
+import {
+ Bot,
+ Brain,
+ Eye,
+ MicVocal,
+ 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 }>;
@@ -20,6 +29,9 @@ export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail):
if (lowerName === "thinking" && (!detail || detail.type === "unknown")) {
return Brain;
}
+ if (lowerName === "speak") {
+ return MicVocal;
+ }
if (detail) {
return TOOL_DETAIL_ICONS[detail.type];
diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts
index 9300248bc..b35063d8f 100644
--- a/packages/server/src/server/agent/providers/claude-agent.ts
+++ b/packages/server/src/server/agent/providers/claude-agent.ts
@@ -1300,16 +1300,21 @@ class ClaudeAgentSession implements AgentSession {
}
this.activeSidechains.set(parentToolUseId, toolName);
+ const toolCall = mapClaudeRunningToolCall({
+ name: "Task",
+ callId: parentToolUseId,
+ input: null,
+ output: null,
+ metadata: { subAgentActivity: toolName },
+ });
+ if (!toolCall) {
+ return [];
+ }
+
return [
{
type: "timeline",
- item: mapClaudeRunningToolCall({
- name: "Task",
- callId: parentToolUseId,
- input: null,
- output: null,
- metadata: { subAgentActivity: toolName },
- }),
+ item: toolCall,
provider: "claude",
},
];
@@ -1497,14 +1502,14 @@ class ClaudeAgentSession implements AgentSession {
if (toolName === "ExitPlanMode" && typeof input.plan === "string") {
metadata.planText = input.plan;
}
- const detail =
+ const toolDetail =
kind === "tool"
? mapClaudeRunningToolCall({
name: toolName,
callId: options.toolUseID ?? requestId,
input,
output: null,
- }).detail
+ })?.detail
: undefined;
const request: AgentPermissionRequest = {
@@ -1513,7 +1518,7 @@ class ClaudeAgentSession implements AgentSession {
name: toolName,
kind,
input,
- detail,
+ detail: toolDetail,
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
metadata: Object.keys(metadata).length ? metadata : undefined,
};
@@ -1591,9 +1596,12 @@ class ClaudeAgentSession implements AgentSession {
}
private pushToolCall(
- item: Extract,
+ item: Extract | null,
target?: AgentTimelineItem[]
) {
+ if (!item) {
+ return;
+ }
if (target) {
target.push(item);
return;
diff --git a/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts
index b1d21ff55..e41bf2d84 100644
--- a/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts
+++ b/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts
@@ -19,15 +19,58 @@ import {
toolDetailBranchByName,
} from "../tool-call-detail-primitives.js";
-const ClaudeKnownToolDetailSchema = z.union([
+const ClaudeToolEnvelopeSchema = z
+ .object({
+ name: z.string().min(1),
+ input: z.unknown().nullable(),
+ output: z.unknown().nullable(),
+ })
+ .passthrough();
+
+const ClaudeSpeakToolDetailSchema = z
+ .object({
+ name: z.literal("speak"),
+ input: z
+ .union([
+ z.string().transform((text) => ({ text })),
+ z.object({ text: z.string() }).passthrough(),
+ ])
+ .nullable(),
+ output: z.unknown().nullable(),
+ })
+ .transform(({ input }) => {
+ const text = input?.text?.trim() ?? "";
+ if (!text) {
+ return undefined;
+ }
+ return {
+ type: "unknown",
+ input: text,
+ output: null,
+ } satisfies ToolCallDetail;
+ });
+
+const ClaudeToolDetailPass2Schema = z.union([
toolDetailBranchByName("Bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByName("bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByName("shell", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByName("exec_command", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
- toolDetailBranchByName("Read", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
- toolDetailBranchByName("read", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
- toolDetailBranchByName("read_file", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
- toolDetailBranchByName("view_file", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
+ toolDetailBranchByName("Read", ToolReadInputSchema, z.unknown(), (input, output) => {
+ const parsedOutput = ToolReadOutputSchema.safeParse(output);
+ return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
+ }),
+ toolDetailBranchByName("read", ToolReadInputSchema, z.unknown(), (input, output) => {
+ const parsedOutput = ToolReadOutputSchema.safeParse(output);
+ return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
+ }),
+ toolDetailBranchByName("read_file", ToolReadInputSchema, z.unknown(), (input, output) => {
+ const parsedOutput = ToolReadOutputSchema.safeParse(output);
+ return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
+ }),
+ toolDetailBranchByName("view_file", ToolReadInputSchema, z.unknown(), (input, output) => {
+ const parsedOutput = ToolReadOutputSchema.safeParse(output);
+ return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
+ }),
toolDetailBranchByName("Write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByName("write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByName("write_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
@@ -38,7 +81,12 @@ const ClaudeKnownToolDetailSchema = z.union([
toolDetailBranchByName("edit", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName("apply_patch", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName("apply_diff", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
- toolDetailBranchByName("str_replace_editor", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
+ toolDetailBranchByName(
+ "str_replace_editor",
+ ToolEditInputSchema,
+ ToolEditOutputSchema,
+ toEditToolDetail
+ ),
toolDetailBranchByName("WebSearch", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
),
@@ -48,6 +96,7 @@ const ClaudeKnownToolDetailSchema = z.union([
toolDetailBranchByName("search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
),
+ ClaudeSpeakToolDetailSchema,
]);
export function deriveClaudeToolDetail(
@@ -55,17 +104,27 @@ export function deriveClaudeToolDetail(
input: unknown,
output: unknown
): ToolCallDetail {
- const parsed = ClaudeKnownToolDetailSchema.safeParse({
+ const pass1 = ClaudeToolEnvelopeSchema.safeParse({
name,
- input,
- output,
- });
- if (parsed.success && parsed.data) {
- return parsed.data;
- }
- return {
- type: "unknown",
input: input ?? null,
output: output ?? null,
+ });
+ if (!pass1.success) {
+ return {
+ type: "unknown",
+ input: input ?? null,
+ output: output ?? null,
+ };
+ }
+
+ const pass2 = ClaudeToolDetailPass2Schema.safeParse(pass1.data);
+ if (pass2.success && pass2.data) {
+ return pass2.data;
+ }
+
+ return {
+ type: "unknown",
+ input: pass1.data.input,
+ output: pass1.data.output,
};
}
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 9304cb4d3..c957617f7 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
@@ -6,14 +6,24 @@ import {
mapClaudeRunningToolCall,
} from "./tool-call-mapper.js";
+function expectMapped(item: T | null): T {
+ expect(item).toBeTruthy();
+ if (!item) {
+ throw new Error("Expected mapped tool call");
+ }
+ return item;
+}
+
describe("claude tool-call mapper", () => {
it("maps running shell calls with canonical fields", () => {
- const item = mapClaudeRunningToolCall({
- callId: "claude-call-1",
- name: "Bash",
- input: { command: "pwd", cwd: "/tmp/repo" },
- output: null,
- });
+ const item = expectMapped(
+ mapClaudeRunningToolCall({
+ callId: "claude-call-1",
+ name: "Bash",
+ input: { command: "pwd", cwd: "/tmp/repo" },
+ output: null,
+ })
+ );
expect(item.type).toBe("tool_call");
expect(item.status).toBe("running");
@@ -27,45 +37,53 @@ 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,
- });
+ const readItem = expectMapped(
+ 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,
- });
+ const writeItem = expectMapped(
+ 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,
- });
+ const editItem = expectMapped(
+ 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,
- });
+ const searchItem = expectMapped(
+ 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",
@@ -73,12 +91,14 @@ describe("claude tool-call mapper", () => {
});
it("maps completed read calls with detail enrichment", () => {
- const item = mapClaudeCompletedToolCall({
- callId: "claude-call-2",
- name: "read_file",
- input: { file_path: "README.md" },
- output: { content: "hello" },
- });
+ const item = expectMapped(
+ mapClaudeCompletedToolCall({
+ callId: "claude-call-2",
+ name: "read_file",
+ input: { file_path: "README.md" },
+ output: { content: "hello" },
+ })
+ );
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -91,33 +111,37 @@ 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" },
- ],
- },
- });
+ const arrayContent = expectMapped(
+ 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" },
+ const objectContent = expectMapped(
+ 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") {
@@ -126,13 +150,15 @@ describe("claude tool-call mapper", () => {
});
it("maps failed calls with required error", () => {
- const item = mapClaudeFailedToolCall({
- callId: "claude-call-3",
- name: "shell",
- input: { command: "false" },
- output: null,
- error: { message: "Command failed" },
- });
+ const item = expectMapped(
+ mapClaudeFailedToolCall({
+ callId: "claude-call-3",
+ name: "shell",
+ input: { command: "false" },
+ output: null,
+ error: { message: "Command failed" },
+ })
+ );
expect(item.status).toBe("failed");
expect(item.error).toEqual({ message: "Command failed" });
@@ -140,35 +166,41 @@ describe("claude tool-call mapper", () => {
});
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,
- });
+ const writeItem = expectMapped(
+ 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,
- });
+ const editItem = expectMapped(
+ 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,
- });
+ const searchItem = expectMapped(
+ 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",
@@ -176,12 +208,14 @@ describe("claude tool-call mapper", () => {
});
it("maps unknown tools to unknown detail with raw payloads", () => {
- const item = mapClaudeCompletedToolCall({
- callId: "claude-call-4",
- name: "my_custom_tool",
- input: { foo: "bar" },
- output: { ok: true },
- });
+ const item = expectMapped(
+ mapClaudeCompletedToolCall({
+ callId: "claude-call-4",
+ name: "my_custom_tool",
+ input: { foo: "bar" },
+ output: { ok: true },
+ })
+ );
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -191,4 +225,33 @@ describe("claude tool-call mapper", () => {
output: { ok: true },
});
});
+
+ it("normalizes claude speak tool names through schema transforms", () => {
+ const item = expectMapped(
+ mapClaudeCompletedToolCall({
+ callId: "claude-speak-1",
+ name: "mcp__paseo__speak",
+ input: { text: "Voice response from Claude." },
+ output: { ok: true },
+ })
+ );
+
+ expect(item.name).toBe("speak");
+ expect(item.detail).toEqual({
+ type: "unknown",
+ input: "Voice response from Claude.",
+ output: null,
+ });
+ });
+
+ it("drops tool calls when callId is missing", () => {
+ const item = mapClaudeCompletedToolCall({
+ callId: null,
+ name: "read_file",
+ input: { file_path: "README.md" },
+ output: { content: "hello" },
+ });
+
+ expect(item).toBeNull();
+ });
});
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 b7b3d1e48..8f70ba106 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,7 +1,6 @@
import { z } from "zod";
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
-import { coerceToolCallId } from "../tool-call-mapper-utils.js";
import { deriveClaudeToolDetail } from "./tool-call-detail-parser.js";
type MapperParams = {
@@ -12,87 +11,170 @@ type MapperParams = {
metadata?: Record;
};
-const ClaudeMapperParamsSchema = z
+const ClaudeToolCallStatusSchema = z.enum([
+ "running",
+ "completed",
+ "failed",
+ "canceled",
+]);
+
+const ClaudeRawToolCallSchema = z
.object({
callId: z.string().optional().nullable(),
name: z.string().min(1),
input: z.unknown().optional(),
output: z.unknown().optional(),
- metadata: z.record(z.unknown()).optional(),
+ metadata: z.record(z.string(), z.unknown()).optional(),
+ error: z.unknown().nullable().optional(),
+ status: ClaudeToolCallStatusSchema,
})
.passthrough();
-const ClaudeFailedMapperParamsSchema = ClaudeMapperParamsSchema.extend({
- error: z.unknown(),
+const ClaudeToolCallPass1Schema = ClaudeRawToolCallSchema.transform((raw) => ({
+ callId:
+ typeof raw.callId === "string" && raw.callId.trim().length > 0
+ ? raw.callId
+ : null,
+ name: raw.name.trim(),
+ input: raw.input ?? null,
+ output: raw.output ?? null,
+ metadata: raw.metadata,
+ error: raw.error ?? null,
+ status: raw.status,
+}));
+
+const ClaudeToolCallPass2BaseSchema = z.object({
+ callId: z.string().min(1),
+ name: z.string().min(1),
+ input: z.unknown().nullable(),
+ output: z.unknown().nullable(),
+ metadata: z.record(z.string(), z.unknown()).optional(),
+ error: z.unknown().nullable(),
+ status: ClaudeToolCallStatusSchema,
+ toolKind: z.enum(["speak", "other"]),
});
-function coerceCallId(callId: string | null | undefined, name: string, input: unknown): string {
- return coerceToolCallId({
- providerPrefix: "claude",
- rawCallId: callId,
- toolName: name,
- input,
+const ClaudeToolCallPass2InputSchema = ClaudeToolCallPass2BaseSchema.omit({
+ toolKind: true,
+});
+
+const ClaudeToolCallPass2EnvelopeSchema = z.union([
+ ClaudeToolCallPass2InputSchema.extend({
+ name: z.literal("mcp__paseo__speak"),
+ }).transform((normalized) => ({
+ ...normalized,
+ name: normalized.name.trim(),
+ toolKind: "speak" as const,
+ })),
+ ClaudeToolCallPass2InputSchema.transform((normalized) => ({
+ ...normalized,
+ name: normalized.name.trim(),
+ toolKind: "other" as const,
+ })),
+]);
+
+const ClaudeToolCallPass2Schema = z.discriminatedUnion("toolKind", [
+ ClaudeToolCallPass2BaseSchema.extend({
+ toolKind: z.literal("speak"),
+ name: z.literal("mcp__paseo__speak"),
+ }).transform((normalized): ToolCallTimelineItem => {
+ const name = "speak" as const;
+ const detail = deriveClaudeToolDetail(name, normalized.input, normalized.output);
+ if (normalized.status === "failed") {
+ return {
+ type: "tool_call",
+ callId: normalized.callId,
+ name,
+ detail,
+ status: "failed",
+ error: normalized.error ?? { message: "Tool call failed" },
+ ...(normalized.metadata ? { metadata: normalized.metadata } : {}),
+ };
+ }
+ return {
+ type: "tool_call",
+ callId: normalized.callId,
+ name,
+ detail,
+ status: normalized.status,
+ error: null,
+ ...(normalized.metadata ? { metadata: normalized.metadata } : {}),
+ };
+ }),
+ ClaudeToolCallPass2BaseSchema.extend({
+ toolKind: z.literal("other"),
+ }).transform((normalized): ToolCallTimelineItem => {
+ const detail = deriveClaudeToolDetail(normalized.name, normalized.input, normalized.output);
+ if (normalized.status === "failed") {
+ return {
+ type: "tool_call",
+ callId: normalized.callId,
+ name: normalized.name,
+ detail,
+ status: "failed",
+ error: normalized.error ?? { message: "Tool call failed" },
+ ...(normalized.metadata ? { metadata: normalized.metadata } : {}),
+ };
+ }
+ return {
+ type: "tool_call",
+ callId: normalized.callId,
+ name: normalized.name,
+ detail,
+ status: normalized.status,
+ error: null,
+ ...(normalized.metadata ? { metadata: normalized.metadata } : {}),
+ };
+ }),
+]);
+
+function mapClaudeToolCall(
+ params: MapperParams,
+ status: z.infer,
+ error: unknown | null
+): ToolCallTimelineItem | null {
+ const pass1 = ClaudeToolCallPass1Schema.safeParse({
+ ...params,
+ status,
+ error,
});
+ if (!pass1.success) {
+ return null;
+ }
+
+ const pass2Envelope = ClaudeToolCallPass2EnvelopeSchema.safeParse(pass1.data);
+ if (!pass2Envelope.success) {
+ return null;
+ }
+
+ const pass2 = ClaudeToolCallPass2Schema.safeParse(pass2Envelope.data);
+ if (!pass2.success) {
+ return null;
+ }
+
+ return pass2.data;
}
-function buildBase(params: MapperParams): {
- callId: string;
- name: string;
- detail: Extract["detail"];
- metadata?: Record;
-} {
- const parsedParams = ClaudeMapperParamsSchema.parse(params);
- const input = parsedParams.input ?? null;
- const output = parsedParams.output ?? null;
- const detail = deriveClaudeToolDetail(parsedParams.name, input, output);
-
- return {
- callId: coerceCallId(parsedParams.callId, parsedParams.name, input),
- name: parsedParams.name,
- detail,
- ...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
- };
+export function mapClaudeRunningToolCall(
+ params: MapperParams
+): ToolCallTimelineItem | null {
+ return mapClaudeToolCall(params, "running", null);
}
-export function mapClaudeRunningToolCall(params: MapperParams): ToolCallTimelineItem {
- const base = buildBase(params);
- return {
- type: "tool_call",
- ...base,
- status: "running",
- error: null,
- };
-}
-
-export function mapClaudeCompletedToolCall(params: MapperParams): ToolCallTimelineItem {
- const base = buildBase(params);
- return {
- type: "tool_call",
- ...base,
- status: "completed",
- error: null,
- };
+export function mapClaudeCompletedToolCall(
+ params: MapperParams
+): ToolCallTimelineItem | null {
+ return mapClaudeToolCall(params, "completed", null);
}
export function mapClaudeFailedToolCall(
params: MapperParams & { error: unknown }
-): ToolCallTimelineItem {
- const parsedParams = ClaudeFailedMapperParamsSchema.parse(params);
- const base = buildBase(parsedParams);
- return {
- type: "tool_call",
- ...base,
- status: "failed",
- error: parsedParams.error,
- };
+): ToolCallTimelineItem | null {
+ return mapClaudeToolCall(params, "failed", params.error);
}
-export function mapClaudeCanceledToolCall(params: MapperParams): ToolCallTimelineItem {
- const base = buildBase(params);
- return {
- type: "tool_call",
- ...base,
- status: "canceled",
- error: null,
- };
+export function mapClaudeCanceledToolCall(
+ params: MapperParams
+): ToolCallTimelineItem | null {
+ return mapClaudeToolCall(params, "canceled", null);
}
diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts
index 3d4d382f8..4fa1996a5 100644
--- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts
+++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts
@@ -982,6 +982,9 @@ function mapCodexExecNotificationToToolCall(params: {
: null,
cwd: params.cwd ?? null,
});
+ if (!mapped) {
+ return null;
+ }
return params.running ? toRunningToolCall(mapped) : mapped;
}
@@ -993,7 +996,7 @@ function mapCodexPatchNotificationToToolCall(params: {
stderr?: string | null;
success?: boolean | null;
running: boolean;
-}): ToolCallTimelineItem {
+}): ToolCallTimelineItem | null {
const files = parseCodexPatchChanges(params.changes);
const firstPath = files[0]?.path;
const firstPatchText = files
@@ -1038,6 +1041,9 @@ function mapCodexPatchNotificationToToolCall(params: {
: { message: params.stderr?.trim() || "Patch apply failed" },
cwd: params.cwd ?? null,
});
+ if (!mapped) {
+ return null;
+ }
return params.running ? toRunningToolCall(mapped) : mapped;
}
@@ -2565,11 +2571,13 @@ class CodexAppServerAgentSession implements AgentSession {
cwd: this.config.cwd ?? null,
running: true,
});
- this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", {
- callId: parsed.callId,
- changes: parsed.changes,
- });
- this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
+ if (timelineItem) {
+ this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", {
+ callId: parsed.callId,
+ changes: parsed.changes,
+ });
+ this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
+ }
return;
}
@@ -2587,12 +2595,14 @@ class CodexAppServerAgentSession implements AgentSession {
success: parsed.success,
running: false,
});
- this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", {
- callId: parsed.callId,
- changes: parsed.changes,
- stdout: parsed.stdout,
- });
- this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
+ if (timelineItem) {
+ this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", {
+ callId: parsed.callId,
+ changes: parsed.changes,
+ stdout: parsed.stdout,
+ });
+ this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
+ }
return;
}
diff --git a/packages/server/src/server/agent/providers/codex-rollout-timeline.ts b/packages/server/src/server/agent/providers/codex-rollout-timeline.ts
index acc383d4a..0051528a6 100644
--- a/packages/server/src/server/agent/providers/codex-rollout-timeline.ts
+++ b/packages/server/src/server/agent/providers/codex-rollout-timeline.ts
@@ -503,14 +503,15 @@ export async function parseRolloutFile(
record.kind === "timeline"
? [record.item]
: record.kind === "call"
- ? [
- mapCodexRolloutToolCall({
+ ? (() => {
+ const mapped = mapCodexRolloutToolCall({
callId: record.callId ?? null,
name: record.name,
input: record.input ?? null,
output: record.callId ? outputsByCallId.get(record.callId) ?? null : null,
- }),
- ]
+ });
+ return mapped ? [mapped] : [];
+ })()
: []
);
return dedupeMirroredTextTimelineItems(timeline);
diff --git a/packages/server/src/server/agent/providers/codex/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/codex/tool-call-detail-parser.ts
index 268e298d6..6ccd2e965 100644
--- a/packages/server/src/server/agent/providers/codex/tool-call-detail-parser.ts
+++ b/packages/server/src/server/agent/providers/codex/tool-call-detail-parser.ts
@@ -24,23 +24,43 @@ export type CodexToolDetailContext = {
cwd?: string | null;
};
-export 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",
-]);
+const CodexToolEnvelopeSchema = z
+ .object({
+ name: z.string().min(1),
+ input: z.unknown().nullable(),
+ output: z.unknown().nullable(),
+ cwd: z.string().nullable().optional(),
+ })
+ .passthrough();
+
+const CodexSpeakToolDetailSchema = z
+ .object({
+ name: z.literal("speak"),
+ input: z
+ .union([
+ z.string().transform((text) => ({ text })),
+ z.object({ text: z.string() }).passthrough(),
+ ])
+ .nullable(),
+ output: z.unknown().nullable(),
+ cwd: z.string().nullable().optional(),
+ })
+ .transform(({ input }) => {
+ const text = input?.text?.trim() ?? "";
+ if (!text) {
+ return undefined;
+ }
+ return {
+ type: "unknown",
+ input: text,
+ output: null,
+ } satisfies ToolCallDetail;
+ });
+
+const CodexLooseEditOutputSchema = z.unknown().transform((value) => {
+ const parsed = ToolEditOutputSchema.safeParse(value);
+ return parsed.success ? parsed.data : null;
+});
export function normalizeCodexFilePath(
filePath: string,
@@ -60,7 +80,7 @@ function normalizePathForCwd(cwd: string | null): (filePath: string) => string |
return (filePath) => normalizeCodexFilePath(filePath, cwd);
}
-const CodexKnownToolDetailSchema = z.union([
+const CodexToolDetailPass2Schema = z.union([
toolDetailBranchByNameWithCwd("Bash", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
toShellToolDetail(input, output)
),
@@ -82,14 +102,22 @@ const CodexKnownToolDetailSchema = z.union([
toolDetailBranchByNameWithCwd("command", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
toShellToolDetail(input, output)
),
- toolDetailBranchByNameWithCwd("read", ToolReadInputSchema, ToolReadOutputWithPathSchema, (input, output, cwd) =>
- toReadToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
- ),
+ toolDetailBranchByNameWithCwd("read", ToolReadInputSchema, z.unknown(), (input, output, cwd) => {
+ const parsedOutput = ToolReadOutputWithPathSchema.safeParse(output);
+ return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null, {
+ normalizePath: normalizePathForCwd(cwd),
+ });
+ }),
toolDetailBranchByNameWithCwd(
"read_file",
ToolReadInputSchema,
- ToolReadOutputWithPathSchema,
- (input, output, cwd) => toReadToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
+ z.unknown(),
+ (input, output, cwd) => {
+ const parsedOutput = ToolReadOutputWithPathSchema.safeParse(output);
+ return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null, {
+ normalizePath: normalizePathForCwd(cwd),
+ });
+ }
),
toolDetailBranchByNameWithCwd("write", ToolWriteInputSchema, ToolWriteOutputSchema, (input, output, cwd) =>
toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
@@ -106,19 +134,19 @@ const CodexKnownToolDetailSchema = z.union([
ToolWriteOutputSchema,
(input, output, cwd) => toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
),
- toolDetailBranchByNameWithCwd("edit", ToolEditInputSchema, ToolEditOutputSchema, (input, output, cwd) =>
+ toolDetailBranchByNameWithCwd("edit", ToolEditInputSchema, CodexLooseEditOutputSchema, (input, output, cwd) =>
toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
),
toolDetailBranchByNameWithCwd(
"apply_patch",
ToolEditInputSchema,
- ToolEditOutputSchema,
+ CodexLooseEditOutputSchema,
(input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
),
toolDetailBranchByNameWithCwd(
"apply_diff",
ToolEditInputSchema,
- ToolEditOutputSchema,
+ CodexLooseEditOutputSchema,
(input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
),
toolDetailBranchByNameWithCwd("search", ToolSearchInputSchema, z.unknown(), (input) =>
@@ -127,6 +155,7 @@ const CodexKnownToolDetailSchema = z.union([
toolDetailBranchByNameWithCwd("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
),
+ CodexSpeakToolDetailSchema,
]);
export function deriveCodexToolDetail(params: {
@@ -135,18 +164,28 @@ export function deriveCodexToolDetail(params: {
output: unknown;
cwd?: string | null;
}): ToolCallDetail {
- const parsed = CodexKnownToolDetailSchema.safeParse({
+ const pass1 = CodexToolEnvelopeSchema.safeParse({
name: params.name,
- input: params.input,
- output: params.output,
- cwd: params.cwd ?? null,
- });
- if (parsed.success && parsed.data) {
- return parsed.data;
- }
- return {
- type: "unknown",
input: params.input ?? null,
output: params.output ?? null,
+ cwd: params.cwd ?? null,
+ });
+ if (!pass1.success) {
+ return {
+ type: "unknown",
+ input: params.input ?? null,
+ output: params.output ?? null,
+ };
+ }
+
+ const pass2 = CodexToolDetailPass2Schema.safeParse(pass1.data);
+ if (pass2.success && pass2.data) {
+ return pass2.data;
+ }
+
+ return {
+ type: "unknown",
+ input: pass1.data.input,
+ output: pass1.data.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 e274f5843..28aafbf95 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
@@ -5,6 +5,14 @@ import {
mapCodexToolCallFromThreadItem,
} from "./tool-call-mapper.js";
+function expectMapped(item: T | null): T {
+ expect(item).toBeTruthy();
+ if (!item) {
+ throw new Error("Expected mapped tool call");
+ }
+ return item;
+}
+
describe("codex tool-call mapper", () => {
it("maps commandExecution start into running canonical call", () => {
const item = mapCodexToolCallFromThreadItem({
@@ -301,12 +309,14 @@ describe("codex tool-call mapper", () => {
});
it("maps unknown tools to unknown detail with raw payloads", () => {
- const item = mapCodexRolloutToolCall({
- callId: "codex-call-4",
- name: "my_custom_tool",
- input: { foo: "bar" },
- output: { ok: true },
- });
+ const item = expectMapped(
+ mapCodexRolloutToolCall({
+ callId: "codex-call-4",
+ name: "my_custom_tool",
+ input: { foo: "bar" },
+ output: { ok: true },
+ })
+ );
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -327,13 +337,15 @@ describe("codex tool-call mapper", () => {
"+new",
"*** End Patch",
].join("\n");
- const item = mapCodexRolloutToolCall({
- callId: "codex-call-apply",
- name: "apply_patch",
- input: patch,
- output: '{"output":"Success. Updated the following files:\\nM src/index.ts\\n"}',
- cwd: "/tmp/repo",
- });
+ const item = expectMapped(
+ mapCodexRolloutToolCall({
+ callId: "codex-call-apply",
+ name: "apply_patch",
+ input: patch,
+ output: '{"output":"Success. Updated the following files:\\nM src/index.ts\\n"}',
+ cwd: "/tmp/repo",
+ })
+ );
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -359,16 +371,18 @@ describe("codex tool-call mapper", () => {
"*** End Patch",
].join("\n");
- const item = mapCodexRolloutToolCall({
- callId: "codex-call-apply-object",
- name: "apply_patch",
- input: {
- path: "/tmp/repo/src/object.ts",
- content: patch,
- },
- output: null,
- cwd: "/tmp/repo",
- });
+ const item = expectMapped(
+ mapCodexRolloutToolCall({
+ callId: "codex-call-apply-object",
+ name: "apply_patch",
+ input: {
+ path: "/tmp/repo/src/object.ts",
+ content: patch,
+ },
+ output: null,
+ cwd: "/tmp/repo",
+ })
+ );
expect(item.detail.type).toBe("edit");
if (item.detail.type === "edit") {
@@ -412,58 +426,6 @@ describe("codex tool-call mapper", () => {
}
});
- it("maps fileChange patch alias fields into edit unified diff detail", () => {
- const item = mapCodexToolCallFromThreadItem(
- {
- type: "fileChange",
- id: "codex-file-change-patch-alias",
- status: "completed",
- changes: [
- {
- path: "/tmp/repo/src/from-patch-alias.ts",
- kind: "modify",
- patch: "@@\n-oldAlias\n+newAlias\n",
- },
- ],
- },
- { cwd: "/tmp/repo" }
- );
-
- expect(item?.detail?.type).toBe("edit");
- if (item?.detail?.type === "edit") {
- expect(item.detail.filePath).toBe("src/from-patch-alias.ts");
- expect(item.detail.unifiedDiff).toContain("-oldAlias");
- expect(item.detail.unifiedDiff).toContain("+newAlias");
- expect(item.detail.newString).toBeUndefined();
- }
- });
-
- it("maps fileChange unifiedDiff alias fields into edit unified diff detail", () => {
- const item = mapCodexToolCallFromThreadItem(
- {
- type: "fileChange",
- id: "codex-file-change-unified-diff-alias",
- status: "completed",
- changes: [
- {
- path: "/tmp/repo/src/from-unified-diff-alias.ts",
- kind: "modify",
- unified_diff: "@@\n-beforeAlias\n+afterAlias\n",
- },
- ],
- },
- { cwd: "/tmp/repo" }
- );
-
- expect(item?.detail?.type).toBe("edit");
- if (item?.detail?.type === "edit") {
- expect(item.detail.filePath).toBe("src/from-unified-diff-alias.ts");
- expect(item.detail.unifiedDiff).toContain("-beforeAlias");
- expect(item.detail.unifiedDiff).toContain("+afterAlias");
- expect(item.detail.newString).toBeUndefined();
- }
- });
-
it("maps path-only fileChange payloads to unknown detail instead of empty edit detail", () => {
const item = mapCodexToolCallFromThreadItem(
{
@@ -484,17 +446,80 @@ describe("codex tool-call mapper", () => {
});
it("maps path-only apply_patch rollout payloads to unknown detail instead of empty edit detail", () => {
- const item = mapCodexRolloutToolCall({
- callId: "codex-call-apply-path-only",
- name: "apply_patch",
- input: { path: "/tmp/repo/src/path-only-rollout.ts" },
- output: { files: [{ path: "/tmp/repo/src/path-only-rollout.ts", kind: "modify" }] },
- cwd: "/tmp/repo",
- });
+ const item = expectMapped(
+ mapCodexRolloutToolCall({
+ callId: "codex-call-apply-path-only",
+ name: "apply_patch",
+ input: { path: "/tmp/repo/src/path-only-rollout.ts" },
+ output: { files: [{ path: "/tmp/repo/src/path-only-rollout.ts", kind: "modify" }] },
+ cwd: "/tmp/repo",
+ })
+ );
expect(item.detail.type).toBe("unknown");
if (item.detail.type === "unknown") {
expect(item.detail.input).toEqual({ path: "/tmp/repo/src/path-only-rollout.ts" });
}
});
+
+ it("normalizes codex paseo speak mcp calls and extracts spoken text", () => {
+ const item = mapCodexToolCallFromThreadItem({
+ type: "mcpToolCall",
+ id: "codex-speak-thread-1",
+ status: "completed",
+ server: "paseo",
+ tool: "speak",
+ arguments: { text: "Voice response from Codex." },
+ result: { ok: true },
+ });
+
+ expect(item).toBeTruthy();
+ expect(item?.name).toBe("speak");
+ expect(item?.detail).toEqual({
+ type: "unknown",
+ input: "Voice response from Codex.",
+ output: null,
+ });
+ });
+
+ it("normalizes codex paseo speak rollout names and extracts spoken text", () => {
+ const item = expectMapped(
+ mapCodexRolloutToolCall({
+ callId: "codex-speak-rollout-1",
+ name: "paseo.speak",
+ input: { text: "Rollout speech text." },
+ output: { ok: true },
+ })
+ );
+
+ expect(item.name).toBe("speak");
+ expect(item.detail).toEqual({
+ type: "unknown",
+ input: "Rollout speech text.",
+ output: null,
+ });
+ });
+
+ it("drops rollout tool calls when callId is missing", () => {
+ const item = mapCodexRolloutToolCall({
+ callId: null,
+ name: "read_file",
+ input: { path: "/tmp/repo/README.md" },
+ output: { content: "hello" },
+ });
+
+ expect(item).toBeNull();
+ });
+
+ it("drops thread mcp tool calls when id is missing", () => {
+ const item = mapCodexToolCallFromThreadItem({
+ type: "mcpToolCall",
+ status: "completed",
+ tool: "read_file",
+ arguments: { path: "/tmp/repo/README.md" },
+ result: { content: "hello" },
+ });
+
+ expect(item).toBeNull();
+ });
});
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 3b544c5ed..1c848bfb6 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,14 +1,11 @@
import { z } from "zod";
-import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js";
-import { CommandValueSchema } from "../tool-call-detail-primitives.js";
+import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
import {
- coerceToolCallId,
extractCodexShellOutput,
truncateDiffText,
} from "../tool-call-mapper-utils.js";
import {
- CODEX_BUILTIN_TOOL_NAMES,
deriveCodexToolDetail,
normalizeCodexFilePath,
} from "./tool-call-detail-parser.js";
@@ -18,6 +15,14 @@ type CodexMapperOptions = { cwd?: string | null };
const FAILED_STATUSES = new Set(["failed", "error", "errored", "rejected", "denied"]);
const CANCELED_STATUSES = new Set(["canceled", "cancelled", "interrupted", "aborted"]);
const COMPLETED_STATUSES = new Set(["completed", "complete", "done", "success", "succeeded"]);
+const CodexCommandValueSchema = z.union([z.string(), z.array(z.string())]);
+
+const CodexToolCallStatusSchema = z.enum([
+ "running",
+ "completed",
+ "failed",
+ "canceled",
+]);
const CodexRolloutToolCallParamsSchema = z
.object({
@@ -29,6 +34,326 @@ const CodexRolloutToolCallParamsSchema = z
})
.passthrough();
+type CodexNormalizedToolCallEnvelope = {
+ callId: string;
+ name: string;
+ input: unknown | null;
+ output: unknown | null;
+ status: ToolCallTimelineItem["status"];
+ error: unknown | null;
+ metadata?: Record;
+ cwd?: string | null;
+};
+
+const CodexNormalizedToolCallPass1Schema = z
+ .object({
+ callId: z.string().min(1),
+ name: z.string().min(1),
+ input: z.unknown().nullable(),
+ output: z.unknown().nullable(),
+ status: CodexToolCallStatusSchema,
+ error: z.unknown().nullable(),
+ metadata: z.record(z.string(), z.unknown()).optional(),
+ cwd: z.string().nullable().optional(),
+ })
+ .passthrough();
+
+const CodexShellToolNameSchema = z.union([
+ z.literal("Bash"),
+ z.literal("shell"),
+ z.literal("bash"),
+ z.literal("exec"),
+ z.literal("exec_command"),
+ z.literal("command"),
+]);
+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"),
+ z.literal("apply_diff"),
+]);
+const CodexSearchToolNameSchema = z.union([z.literal("search"), z.literal("web_search")]);
+const CodexSpeakToolNameSchema = z.literal("paseo.speak");
+
+const CodexToolKindSchema = z.enum(["shell", "read", "write", "edit", "search", "speak", "unknown"]);
+
+const CodexToolCallPass2BaseSchema = CodexNormalizedToolCallPass1Schema.extend({
+ toolKind: CodexToolKindSchema,
+});
+
+const CodexToolCallPass2EnvelopeSchema = z.union([
+ CodexNormalizedToolCallPass1Schema.extend({
+ name: CodexShellToolNameSchema,
+ }).transform((envelope) => ({ ...envelope, toolKind: "shell" as const })),
+ CodexNormalizedToolCallPass1Schema.extend({
+ name: CodexReadToolNameSchema,
+ }).transform((envelope) => ({ ...envelope, toolKind: "read" as const })),
+ CodexNormalizedToolCallPass1Schema.extend({
+ name: CodexWriteToolNameSchema,
+ }).transform((envelope) => ({ ...envelope, toolKind: "write" as const })),
+ CodexNormalizedToolCallPass1Schema.extend({
+ name: CodexEditToolNameSchema,
+ }).transform((envelope) => ({ ...envelope, toolKind: "edit" as const })),
+ CodexNormalizedToolCallPass1Schema.extend({
+ name: CodexSearchToolNameSchema,
+ }).transform((envelope) => ({ ...envelope, toolKind: "search" as const })),
+ CodexNormalizedToolCallPass1Schema.extend({
+ name: CodexSpeakToolNameSchema,
+ }).transform((envelope) => ({ ...envelope, toolKind: "speak" as const })),
+ CodexNormalizedToolCallPass1Schema.transform((envelope) => ({
+ ...envelope,
+ name: envelope.name.trim(),
+ toolKind: "unknown" as const,
+ })),
+]);
+
+const CodexNormalizedToolCallPass2Schema = z.discriminatedUnion("toolKind", [
+ CodexToolCallPass2BaseSchema.extend({
+ toolKind: z.literal("shell"),
+ name: CodexShellToolNameSchema,
+ }).transform((envelope): ToolCallTimelineItem => {
+ const detail = deriveCodexToolDetail({
+ name: envelope.name,
+ input: envelope.input,
+ output: envelope.output,
+ cwd: envelope.cwd ?? null,
+ });
+
+ if (envelope.status === "failed") {
+ return {
+ type: "tool_call",
+ callId: envelope.callId,
+ name: envelope.name,
+ status: "failed",
+ error: envelope.error ?? { message: "Tool call failed" },
+ detail,
+ ...(envelope.metadata ? { metadata: envelope.metadata } : {}),
+ };
+ }
+
+ return {
+ type: "tool_call",
+ callId: envelope.callId,
+ name: envelope.name,
+ status: envelope.status,
+ error: null,
+ detail,
+ ...(envelope.metadata ? { metadata: envelope.metadata } : {}),
+ };
+ }),
+ CodexToolCallPass2BaseSchema.extend({
+ toolKind: z.literal("read"),
+ name: CodexReadToolNameSchema,
+ }).transform((envelope): ToolCallTimelineItem => {
+ const detail = deriveCodexToolDetail({
+ name: envelope.name,
+ input: envelope.input,
+ output: envelope.output,
+ cwd: envelope.cwd ?? null,
+ });
+
+ if (envelope.status === "failed") {
+ return {
+ type: "tool_call",
+ callId: envelope.callId,
+ name: envelope.name,
+ status: "failed",
+ error: envelope.error ?? { message: "Tool call failed" },
+ detail,
+ ...(envelope.metadata ? { metadata: envelope.metadata } : {}),
+ };
+ }
+
+ return {
+ type: "tool_call",
+ callId: envelope.callId,
+ name: envelope.name,
+ status: envelope.status,
+ error: null,
+ detail,
+ ...(envelope.metadata ? { metadata: envelope.metadata } : {}),
+ };
+ }),
+ CodexToolCallPass2BaseSchema.extend({
+ toolKind: z.literal("write"),
+ name: CodexWriteToolNameSchema,
+ }).transform((envelope): ToolCallTimelineItem => {
+ const detail = deriveCodexToolDetail({
+ name: envelope.name,
+ input: envelope.input,
+ output: envelope.output,
+ cwd: envelope.cwd ?? null,
+ });
+
+ if (envelope.status === "failed") {
+ return {
+ type: "tool_call",
+ callId: envelope.callId,
+ name: envelope.name,
+ status: "failed",
+ error: envelope.error ?? { message: "Tool call failed" },
+ detail,
+ ...(envelope.metadata ? { metadata: envelope.metadata } : {}),
+ };
+ }
+
+ return {
+ type: "tool_call",
+ callId: envelope.callId,
+ name: envelope.name,
+ status: envelope.status,
+ error: null,
+ detail,
+ ...(envelope.metadata ? { metadata: envelope.metadata } : {}),
+ };
+ }),
+ CodexToolCallPass2BaseSchema.extend({
+ toolKind: z.literal("edit"),
+ name: CodexEditToolNameSchema,
+ }).transform((envelope): ToolCallTimelineItem => {
+ const parsedDetail = deriveCodexToolDetail({
+ name: envelope.name,
+ input: envelope.input,
+ output: envelope.output,
+ cwd: envelope.cwd ?? null,
+ });
+ const detail: ToolCallTimelineItem["detail"] =
+ envelope.status === "running" || hasRenderableEditDetail(parsedDetail)
+ ? parsedDetail
+ : {
+ type: "unknown",
+ input: envelope.input,
+ output: envelope.output,
+ };
+
+ if (envelope.status === "failed") {
+ return {
+ type: "tool_call",
+ callId: envelope.callId,
+ name: envelope.name,
+ status: "failed",
+ error: envelope.error ?? { message: "Tool call failed" },
+ detail,
+ ...(envelope.metadata ? { metadata: envelope.metadata } : {}),
+ };
+ }
+
+ return {
+ type: "tool_call",
+ callId: envelope.callId,
+ name: envelope.name,
+ status: envelope.status,
+ error: null,
+ detail,
+ ...(envelope.metadata ? { metadata: envelope.metadata } : {}),
+ };
+ }),
+ CodexToolCallPass2BaseSchema.extend({
+ toolKind: z.literal("search"),
+ name: CodexSearchToolNameSchema,
+ }).transform((envelope): ToolCallTimelineItem => {
+ const detail = deriveCodexToolDetail({
+ name: envelope.name,
+ input: envelope.input,
+ output: envelope.output,
+ cwd: envelope.cwd ?? null,
+ });
+
+ if (envelope.status === "failed") {
+ return {
+ type: "tool_call",
+ callId: envelope.callId,
+ name: envelope.name,
+ status: "failed",
+ error: envelope.error ?? { message: "Tool call failed" },
+ detail,
+ ...(envelope.metadata ? { metadata: envelope.metadata } : {}),
+ };
+ }
+
+ return {
+ type: "tool_call",
+ callId: envelope.callId,
+ name: envelope.name,
+ status: envelope.status,
+ error: null,
+ detail,
+ ...(envelope.metadata ? { metadata: envelope.metadata } : {}),
+ };
+ }),
+ CodexToolCallPass2BaseSchema.extend({
+ toolKind: z.literal("speak"),
+ name: CodexSpeakToolNameSchema,
+ }).transform((envelope): ToolCallTimelineItem => {
+ const canonicalName = "speak";
+ const detail = deriveCodexToolDetail({
+ name: canonicalName,
+ input: envelope.input,
+ output: envelope.output,
+ cwd: envelope.cwd ?? null,
+ });
+
+ if (envelope.status === "failed") {
+ return {
+ type: "tool_call",
+ callId: envelope.callId,
+ name: canonicalName,
+ status: "failed",
+ error: envelope.error ?? { message: "Tool call failed" },
+ detail,
+ ...(envelope.metadata ? { metadata: envelope.metadata } : {}),
+ };
+ }
+
+ return {
+ type: "tool_call",
+ callId: envelope.callId,
+ name: canonicalName,
+ status: envelope.status,
+ error: null,
+ detail,
+ ...(envelope.metadata ? { metadata: envelope.metadata } : {}),
+ };
+ }),
+ CodexToolCallPass2BaseSchema.extend({
+ toolKind: z.literal("unknown"),
+ }).transform((envelope): ToolCallTimelineItem => {
+ const detail = deriveCodexToolDetail({
+ name: envelope.name,
+ input: envelope.input,
+ output: envelope.output,
+ cwd: envelope.cwd ?? null,
+ });
+
+ if (envelope.status === "failed") {
+ return {
+ type: "tool_call",
+ callId: envelope.callId,
+ name: envelope.name,
+ status: "failed",
+ error: envelope.error ?? { message: "Tool call failed" },
+ detail,
+ ...(envelope.metadata ? { metadata: envelope.metadata } : {}),
+ };
+ }
+
+ return {
+ type: "tool_call",
+ callId: envelope.callId,
+ name: envelope.name,
+ status: envelope.status,
+ error: null,
+ detail,
+ ...(envelope.metadata ? { metadata: envelope.metadata } : {}),
+ };
+ }),
+]);
+
// ---------------------------------------------------------------------------
// Thread-item parsing
// ---------------------------------------------------------------------------
@@ -36,10 +361,10 @@ const CodexRolloutToolCallParamsSchema = z
const CodexCommandExecutionItemSchema = z
.object({
type: z.literal("commandExecution"),
- id: z.string().optional(),
+ id: z.string().min(1),
status: z.string().optional(),
error: z.unknown().optional(),
- command: CommandValueSchema.optional(),
+ command: CodexCommandValueSchema.optional(),
cwd: z.string().optional(),
aggregatedOutput: z.string().optional(),
exitCode: z.number().nullable().optional(),
@@ -49,7 +374,7 @@ const CodexCommandExecutionItemSchema = z
const CodexFileChangeItemSchema = z
.object({
type: z.literal("fileChange"),
- id: z.string().optional(),
+ id: z.string().min(1),
status: z.string().optional(),
error: z.unknown().optional(),
changes: z
@@ -60,10 +385,7 @@ const CodexFileChangeItemSchema = z
kind: z.string().optional(),
diff: z.string().optional(),
patch: z.string().optional(),
- unified_diff: z.string().optional(),
- unifiedDiff: z.string().optional(),
content: z.string().optional(),
- newString: z.string().optional(),
})
.passthrough()
)
@@ -74,12 +396,10 @@ const CodexFileChangeItemSchema = z
const CodexMcpToolCallItemSchema = z
.object({
type: z.literal("mcpToolCall"),
- id: z.string().optional(),
- callID: z.string().optional(),
- call_id: z.string().optional(),
+ id: z.string().min(1),
status: z.string().optional(),
error: z.unknown().optional(),
- tool: z.string().optional(),
+ tool: z.string().min(1),
server: z.string().optional(),
arguments: z.unknown().optional(),
result: z.unknown().optional(),
@@ -89,7 +409,7 @@ const CodexMcpToolCallItemSchema = z
const CodexWebSearchItemSchema = z
.object({
type: z.literal("webSearch"),
- id: z.string().optional(),
+ id: z.string().min(1),
status: z.string().optional(),
error: z.unknown().optional(),
query: z.string().optional(),
@@ -104,15 +424,6 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
CodexWebSearchItemSchema,
]);
-function coerceCallId(raw: string | null | undefined, name: string, input: unknown): string {
- return coerceToolCallId({
- providerPrefix: "codex",
- rawCallId: raw,
- toolName: name,
- input,
- });
-}
-
function maybeUnwrapShellWrapperCommand(command: string): string {
const trimmed = command.trim();
const wrapperMatch = trimmed.match(
@@ -174,6 +485,10 @@ type CodexApplyPatchDirective = {
path: string;
};
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
function parseCodexApplyPatchDirective(line: string): CodexApplyPatchDirective | null {
const trimmed = line.trim();
if (trimmed.startsWith("*** Add File:")) {
@@ -188,6 +503,16 @@ function parseCodexApplyPatchDirective(line: string): CodexApplyPatchDirective |
return null;
}
+function extractPatchPrimaryFilePath(patch: string): string | undefined {
+ for (const line of patch.split(/\r?\n/)) {
+ const directive = parseCodexApplyPatchDirective(line);
+ if (directive && directive.path.length > 0) {
+ return directive.path;
+ }
+ }
+ return undefined;
+}
+
function looksLikeCodexApplyPatch(text: string): boolean {
const normalized = text.trimStart();
if (!normalized) {
@@ -285,6 +610,64 @@ function asEditTextFields(
return { newString: text };
}
+function normalizeRolloutEditInput(input: unknown): unknown {
+ if (typeof input === "string") {
+ const textFields = asEditTextFields(input);
+ const path = extractPatchPrimaryFilePath(input);
+ return {
+ ...(path ? { path } : {}),
+ ...(textFields.unifiedDiff ? { patch: textFields.unifiedDiff } : {}),
+ ...(textFields.newString ? { content: textFields.newString } : {}),
+ };
+ }
+ if (!isRecord(input)) {
+ return input;
+ }
+
+ const candidatePatchText =
+ (typeof input.patch === "string" && input.patch) ||
+ (typeof input.diff === "string" && input.diff) ||
+ (typeof input.unified_diff === "string" && input.unified_diff) ||
+ (typeof input.unifiedDiff === "string" && input.unifiedDiff) ||
+ (typeof input.content === "string" && input.content) ||
+ undefined;
+ if (!candidatePatchText) {
+ return input;
+ }
+
+ const textFields = asEditTextFields(candidatePatchText);
+ const rawPath =
+ (typeof input.path === "string" && input.path.trim().length > 0 ? input.path : undefined) ||
+ (typeof input.file_path === "string" && input.file_path.trim().length > 0
+ ? input.file_path
+ : undefined) ||
+ (typeof input.filePath === "string" && input.filePath.trim().length > 0
+ ? input.filePath
+ : undefined) ||
+ extractPatchPrimaryFilePath(candidatePatchText);
+
+ const {
+ patch: _patch,
+ diff: _diff,
+ unified_diff: _unifiedDiffSnake,
+ unifiedDiff: _unifiedDiffCamel,
+ ...rest
+ } = input;
+
+ const normalized: Record = {
+ ...rest,
+ ...(rawPath ? { path: rawPath } : {}),
+ ...(textFields.unifiedDiff ? { patch: textFields.unifiedDiff } : {}),
+ ...(textFields.newString ? { content: textFields.newString } : {}),
+ };
+
+ if (textFields.unifiedDiff && "content" in normalized) {
+ delete normalized.content;
+ }
+
+ return normalized;
+}
+
function asEditFileOutputFields(
text: string | undefined
): { patch?: string; content?: string } {
@@ -298,28 +681,6 @@ function asEditFileOutputFields(
return { content: text };
}
-function asPatchOrContentFields(text: string | undefined): { patch?: string; content?: string } {
- if (typeof text !== "string" || text.length === 0) {
- return {};
- }
- const classified = classifyDiffLikeText(text);
- if (classified.isDiff) {
- return { patch: truncateDiffText(classified.text) };
- }
- return { content: text };
-}
-
-function hasRenderableEditContent(detail: ToolCallDetail): boolean {
- if (detail.type !== "edit") {
- return false;
- }
- return (
- (typeof detail.unifiedDiff === "string" && detail.unifiedDiff.length > 0) ||
- (typeof detail.newString === "string" && detail.newString.length > 0) ||
- (typeof detail.oldString === "string" && detail.oldString.length > 0)
- );
-}
-
function pickFirstPatchLikeString(values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.length > 0) {
@@ -329,15 +690,15 @@ function pickFirstPatchLikeString(values: unknown[]): string | undefined {
return undefined;
}
-function removePatchLikeFields(input: Record): Record {
- const {
- patch: _patch,
- diff: _diff,
- unified_diff: _unifiedDiffSnake,
- unifiedDiff: _unifiedDiffCamel,
- ...rest
- } = input;
- return rest;
+function hasRenderableEditDetail(detail: ToolCallTimelineItem["detail"]): boolean {
+ if (detail.type !== "edit") {
+ return true;
+ }
+ return (
+ (typeof detail.unifiedDiff === "string" && detail.unifiedDiff.trim().length > 0) ||
+ (typeof detail.newString === "string" && detail.newString.trim().length > 0) ||
+ (typeof detail.oldString === "string" && detail.oldString.trim().length > 0)
+ );
}
function resolveStatus(
@@ -368,47 +729,12 @@ function resolveStatus(
return output !== null && output !== undefined ? "completed" : "running";
}
-function buildToolCall(params: {
- callId: string;
- name: string;
- status: ToolCallTimelineItem["status"];
- error: unknown | null;
- detail: ToolCallDetail;
- metadata?: Record;
-}): ToolCallTimelineItem {
- if (params.status === "failed") {
- return {
- type: "tool_call",
- callId: params.callId,
- name: params.name,
- status: "failed",
- error: params.error ?? { message: "Tool call failed" },
- detail: params.detail,
- ...(params.metadata ? { metadata: params.metadata } : {}),
- };
- }
-
- return {
- type: "tool_call",
- callId: params.callId,
- name: params.name,
- status: params.status,
- error: null,
- detail: params.detail,
- ...(params.metadata ? { metadata: params.metadata } : {}),
- };
-}
-
function buildMcpToolName(server: string | undefined, tool: string): string {
const trimmedTool = tool.trim();
if (!trimmedTool) {
return "tool";
}
- if (CODEX_BUILTIN_TOOL_NAMES.has(trimmedTool)) {
- return trimmedTool;
- }
-
const trimmedServer = typeof server === "string" ? server.trim() : "";
if (trimmedServer.length > 0) {
return `${trimmedServer}.${trimmedTool}`;
@@ -421,109 +747,23 @@ function toNullableObject(value: Record): Record 0 ? value : null;
}
-function extractPatchPrimaryFilePath(patch: string): string | undefined {
- for (const line of patch.split(/\r?\n/)) {
- const trimmed = line.trim();
- if (trimmed.startsWith("*** Add File:")) {
- return trimmed.replace("*** Add File:", "").trim();
- }
- if (trimmed.startsWith("*** Update File:")) {
- return trimmed.replace("*** Update File:", "").trim();
- }
- if (trimmed.startsWith("*** Delete File:")) {
- return trimmed.replace("*** Delete File:", "").trim();
- }
- }
- return undefined;
-}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-function normalizeApplyPatchInput(input: unknown): unknown {
- if (typeof input === "string") {
- const filePath = extractPatchPrimaryFilePath(input);
- const textFields = asPatchOrContentFields(input);
- return filePath ? { path: filePath, ...textFields } : textFields;
- }
-
- if (!isRecord(input)) {
- return input;
- }
-
- const existingPath =
- (typeof input.path === "string" && input.path.trim().length > 0 && input.path.trim()) ||
- (typeof input.file_path === "string" &&
- input.file_path.trim().length > 0 &&
- input.file_path.trim()) ||
- (typeof input.filePath === "string" &&
- input.filePath.trim().length > 0 &&
- input.filePath.trim());
- const patchText =
- (typeof input.patch === "string" && input.patch) ||
- (typeof input.diff === "string" && input.diff) ||
- (typeof input.unified_diff === "string" && input.unified_diff) ||
- (typeof input.unifiedDiff === "string" && input.unifiedDiff) ||
- undefined;
- const contentText = typeof input.content === "string" ? input.content : undefined;
- const inferredPatchFromContent = !patchText && typeof contentText === "string" ? contentText : undefined;
- const patchOrContentText = patchText ?? inferredPatchFromContent;
-
- if (existingPath && !patchOrContentText) {
- return input;
- }
-
- if (!patchOrContentText) {
- return input;
- }
-
- const base = removePatchLikeFields(input);
- if (inferredPatchFromContent) {
- delete (base as { content?: unknown }).content;
- }
- const filePath = existingPath || extractPatchPrimaryFilePath(patchOrContentText);
- const textFields = asPatchOrContentFields(patchOrContentText);
- return filePath ? { ...base, path: filePath, ...textFields } : { ...base, ...textFields };
-}
-
-function deriveApplyPatchDetailFromInput(
- input: unknown,
- cwd: string | null | undefined
-): ToolCallDetail | null {
- if (!isRecord(input)) {
+function toToolCallFromNormalizedEnvelope(
+ envelope: CodexNormalizedToolCallEnvelope
+): ToolCallTimelineItem | null {
+ const pass2Envelope = CodexToolCallPass2EnvelopeSchema.safeParse(envelope);
+ if (!pass2Envelope.success) {
return null;
}
-
- const pathValue =
- (typeof input.path === "string" && input.path.trim()) ||
- (typeof input.file_path === "string" && input.file_path.trim()) ||
- (typeof input.filePath === "string" && input.filePath.trim()) ||
- "";
- if (!pathValue) {
+ const parsed = CodexNormalizedToolCallPass2Schema.safeParse(pass2Envelope.data);
+ if (!parsed.success) {
return null;
}
-
- const normalizedPath = normalizeCodexFilePath(pathValue, cwd) ?? pathValue;
- const diffText =
- (typeof input.patch === "string" && input.patch) ||
- (typeof input.diff === "string" && input.diff) ||
- (typeof input.unified_diff === "string" && input.unified_diff) ||
- (typeof input.unifiedDiff === "string" && input.unifiedDiff) ||
- (typeof input.content === "string" && input.content) ||
- undefined;
-
- const textFields = asEditTextFields(diffText);
- return {
- type: "edit",
- filePath: normalizedPath,
- ...textFields,
- };
+ return parsed.data;
}
function mapCommandExecutionItem(
item: z.infer
-): ToolCallTimelineItem {
+): CodexNormalizedToolCallEnvelope {
const command = normalizeCommandExecutionCommand(item.command);
const parsedOutput = extractCodexShellOutput(item.aggregatedOutput);
const input = toNullableObject({
@@ -540,38 +780,25 @@ function mapCommandExecutionItem(
}
: null;
- const detail = command
- ? {
- type: "shell" as const,
- command,
- ...(item.cwd ? { cwd: item.cwd } : {}),
- ...(parsedOutput ? { output: parsedOutput } : {}),
- ...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
- }
- : {
- type: "unknown" as const,
- input,
- output,
- };
-
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,
+ return {
+ callId: item.id,
name,
+ input,
+ output,
status,
error,
- detail,
- });
+ cwd: item.cwd ?? null,
+ };
}
function mapFileChangeItem(
item: z.infer,
options?: CodexMapperOptions
-): ToolCallTimelineItem {
+): CodexNormalizedToolCallEnvelope {
const changes = item.changes ?? [];
const files = changes
@@ -587,16 +814,13 @@ function mapFileChangeItem(
diff: pickFirstPatchLikeString([
change.diff,
change.patch,
- change.unified_diff,
- change.unifiedDiff,
change.content,
- change.newString,
]),
};
})
.filter((change) => change.path !== undefined);
- const input = toNullableObject({
+ const inputBase = {
...(files.length > 0
? {
files: files.map((file) => ({
@@ -605,7 +829,7 @@ function mapFileChangeItem(
})),
}
: {}),
- });
+ };
const output = toNullableObject({
...(files.length > 0
@@ -619,112 +843,97 @@ function mapFileChangeItem(
: {}),
});
+ const name = "apply_patch";
+ const error = item.error ?? null;
+ const status = resolveStatus(item.status, error, output);
const firstFile = files[0];
const firstTextFields = asEditTextFields(firstFile?.diff);
const hasFirstTextFields = Object.keys(firstTextFields).length > 0;
- const detail = firstFile?.path
- ? hasFirstTextFields
- ? {
- type: "edit" as const,
- filePath: firstFile.path,
- ...firstTextFields,
- }
- : {
- type: "unknown" as const,
- input,
- output,
- }
- : {
- type: "unknown" as const,
- input,
- output,
- };
+ const input = toNullableObject({
+ ...inputBase,
+ ...(firstFile?.path && hasFirstTextFields ? { path: firstFile.path } : {}),
+ ...(hasFirstTextFields && firstTextFields.unifiedDiff
+ ? { patch: firstTextFields.unifiedDiff }
+ : {}),
+ ...(hasFirstTextFields && firstTextFields.newString
+ ? { content: firstTextFields.newString }
+ : {}),
+ });
- 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,
+ return {
+ callId: item.id,
name,
+ input,
+ output,
status,
error,
- detail,
- });
+ cwd: options?.cwd ?? null,
+ };
}
function mapMcpToolCallItem(
item: z.infer,
options?: CodexMapperOptions
-): ToolCallTimelineItem {
- const tool = item.tool?.trim() || "tool";
+): CodexNormalizedToolCallEnvelope | null {
+ const tool = item.tool.trim();
+ if (!tool) {
+ return null;
+ }
const name = buildMcpToolName(item.server, tool);
const input = item.arguments ?? null;
const output = item.result ?? null;
const error = item.error ?? null;
- const callId = coerceCallId(item.id ?? item.callID ?? item.call_id, name, input);
const status = resolveStatus(item.status, error, output);
- const detail = deriveCodexToolDetail({
- name: tool,
+
+ return {
+ callId: item.id,
+ name,
input,
output,
- cwd: options?.cwd ?? null,
- });
-
- return buildToolCall({
- callId,
- name,
status,
error,
- detail,
- });
+ cwd: options?.cwd ?? null,
+ };
}
-function mapWebSearchItem(item: z.infer): ToolCallTimelineItem {
+function mapWebSearchItem(
+ item: z.infer
+): CodexNormalizedToolCallEnvelope {
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,
- }
- : {
- type: "unknown" as const,
- input,
- output,
- };
- return buildToolCall({
- callId,
+ return {
+ callId: item.id,
name,
+ input,
+ output,
status,
error,
- detail,
- });
+ cwd: null,
+ };
}
-function createCodexThreadItemToTimelineSchema(options?: CodexMapperOptions) {
- return CodexThreadItemSchema.transform((item): ToolCallTimelineItem => {
- switch (item.type) {
- case "commandExecution":
- return mapCommandExecutionItem(item);
- case "fileChange":
- return mapFileChangeItem(item, options);
- case "mcpToolCall":
- return mapMcpToolCallItem(item, options);
- case "webSearch":
- return mapWebSearchItem(item);
- default: {
- const exhaustiveCheck: never = item;
- throw new Error(`Unhandled Codex thread item type: ${String(exhaustiveCheck)}`);
- }
+function mapThreadItemToNormalizedEnvelope(
+ item: z.infer,
+ options?: CodexMapperOptions
+): CodexNormalizedToolCallEnvelope | null {
+ switch (item.type) {
+ case "commandExecution":
+ return mapCommandExecutionItem(item);
+ case "fileChange":
+ return mapFileChangeItem(item, options);
+ case "mcpToolCall":
+ return mapMcpToolCallItem(item, options);
+ case "webSearch":
+ return mapWebSearchItem(item);
+ default: {
+ const exhaustiveCheck: never = item;
+ throw new Error(`Unhandled Codex thread item type: ${String(exhaustiveCheck)}`);
}
- });
+ }
}
// ---------------------------------------------------------------------------
@@ -735,11 +944,15 @@ export function mapCodexToolCallFromThreadItem(
item: unknown,
options?: CodexMapperOptions
): ToolCallTimelineItem | null {
- const parsed = createCodexThreadItemToTimelineSchema(options).safeParse(item);
+ const parsed = CodexThreadItemSchema.safeParse(item);
if (!parsed.success) {
return null;
}
- return parsed.data;
+ const envelope = mapThreadItemToNormalizedEnvelope(parsed.data, options);
+ if (!envelope) {
+ return null;
+ }
+ return toToolCallFromNormalizedEnvelope(envelope);
}
export function mapCodexRolloutToolCall(params: {
@@ -749,43 +962,36 @@ export function mapCodexRolloutToolCall(params: {
output?: unknown;
error?: unknown;
cwd?: string | null;
-}): ToolCallTimelineItem {
- const parsed = CodexRolloutToolCallParamsSchema.parse(params);
- const rawInput = parsed.input ?? null;
- const normalizedName = parsed.name.trim().toLowerCase();
- const input =
- normalizedName === "apply_patch" || normalizedName === "apply_diff"
- ? normalizeApplyPatchInput(rawInput)
- : rawInput;
- const output = parsed.output ?? null;
- const error = parsed.error ?? null;
- const status = resolveStatus("completed", error, output);
- const callId = coerceCallId(parsed.callId, parsed.name, input);
- let detail = deriveCodexToolDetail({
- name: parsed.name,
- input,
- output,
- cwd: params.cwd ?? null,
- });
- if (detail.type === "unknown" && (normalizedName === "apply_patch" || normalizedName === "apply_diff")) {
- const fallbackDetail = deriveApplyPatchDetailFromInput(input, params.cwd ?? null);
- if (fallbackDetail) {
- detail = fallbackDetail;
- }
- }
- if (detail.type === "edit" && !hasRenderableEditContent(detail)) {
- detail = {
- type: "unknown",
- input,
- output,
- };
+}): ToolCallTimelineItem | null {
+ const parsed = CodexRolloutToolCallParamsSchema.safeParse(params);
+ if (!parsed.success) {
+ return null;
}
- return buildToolCall({
- callId,
- name: parsed.name,
- status,
- error,
- detail,
+ const normalizedName = parsed.data.name.trim();
+ const normalizedInput =
+ normalizedName === "apply_patch" || normalizedName === "apply_diff"
+ ? normalizeRolloutEditInput(parsed.data.input ?? null)
+ : parsed.data.input ?? null;
+
+ const pass1 = CodexNormalizedToolCallPass1Schema.safeParse({
+ callId:
+ typeof parsed.data.callId === "string" ? parsed.data.callId.trim() : "",
+ name: normalizedName,
+ input: normalizedInput,
+ output: parsed.data.output ?? null,
+ error: parsed.data.error ?? null,
+ status: resolveStatus("completed", parsed.data.error ?? null, parsed.data.output ?? null),
+ cwd: params.cwd ?? null,
});
+ if (!pass1.success) {
+ return null;
+ }
+
+ const mapped = toToolCallFromNormalizedEnvelope(pass1.data);
+ if (!mapped) {
+ return null;
+ }
+
+ return mapped;
}
diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts
index d3fbbd580..d359948ae 100644
--- a/packages/server/src/server/agent/providers/opencode-agent.ts
+++ b/packages/server/src/server/agent/providers/opencode-agent.ts
@@ -2,6 +2,7 @@ import { execSync, spawn, type ChildProcess } from "node:child_process";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client";
import net from "node:net";
import type { Logger } from "pino";
+import { z } from "zod";
import type {
AgentCapabilityFlags,
@@ -69,6 +70,84 @@ type OpenCodeMcpConfig =
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
+const OpencodeToolStateSchema = z
+ .object({
+ status: z.string().optional(),
+ input: z.unknown().optional(),
+ output: z.unknown().optional(),
+ error: z.unknown().optional(),
+ })
+ .passthrough();
+
+const OpencodeToolPartBaseSchema = z
+ .object({
+ tool: z.string().trim().min(1),
+ state: OpencodeToolStateSchema.optional(),
+ })
+ .passthrough();
+
+const OpencodeToolPartWithCallIdSchema = OpencodeToolPartBaseSchema.extend({
+ callID: z.string().trim().min(1),
+ id: z.string().optional(),
+}).transform((part) => ({
+ toolName: part.tool,
+ callId: part.callID,
+ status: part.state?.status,
+ input: part.state?.input,
+ output: part.state?.output,
+ error: part.state?.error,
+}));
+
+const OpencodeToolPartWithIdSchema = OpencodeToolPartBaseSchema.extend({
+ id: z.string().trim().min(1),
+ callID: z.string().optional(),
+}).transform((part) => ({
+ toolName: part.tool,
+ callId: part.id,
+ status: part.state?.status,
+ input: part.state?.input,
+ output: part.state?.output,
+ error: part.state?.error,
+}));
+
+const OpencodeToolPartWithoutIdSchema = OpencodeToolPartBaseSchema.extend({
+ id: z.string().optional(),
+ callID: z.string().optional(),
+}).transform((part) => ({
+ toolName: part.tool,
+ callId: undefined,
+ status: part.state?.status,
+ input: part.state?.input,
+ output: part.state?.output,
+ error: part.state?.error,
+}));
+
+const OpencodeToolPartSchema = z.union([
+ OpencodeToolPartWithCallIdSchema,
+ OpencodeToolPartWithIdSchema,
+ OpencodeToolPartWithoutIdSchema,
+]);
+
+const OpencodeToolPartTimelineEnvelopeSchema = OpencodeToolPartSchema.transform((part) => ({
+ toolName: part.toolName,
+ callId: part.callId,
+ status: part.status,
+ input: part.input,
+ output: part.output,
+ error: part.error,
+}));
+
+const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSchema.transform((part) =>
+ mapOpencodeToolCall({
+ toolName: part.toolName,
+ callId: part.callId,
+ status: part.status,
+ input: part.input,
+ output: part.output,
+ error: part.error,
+ })
+);
+
function resolveOpenCodeBinary(): string {
try {
const opencodePath = execSync("which opencode", { encoding: "utf8" }).trim();
@@ -650,33 +729,15 @@ class OpenCodeAgentSession implements AgentSession {
};
}
} else if (partType === "tool") {
- const toolPart = part as {
- id?: string;
- tool?: string;
- callID?: string;
- state?: {
- status?: string;
- input?: AgentMetadata;
- output?: string;
- error?: string;
- };
- };
- const toolName = toolPart.tool;
- const state = toolPart.state;
-
- if (toolName) {
- yield {
- type: "timeline",
- provider: "opencode",
- item: mapOpencodeToolCall({
- toolName,
- callId: toolPart.callID ?? toolPart.id,
- status: state?.status,
- input: state?.input,
- output: state?.output,
- error: state?.error,
- }),
- };
+ const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
+ if (parsedToolPart.success) {
+ if (parsedToolPart.data) {
+ yield {
+ type: "timeline",
+ provider: "opencode",
+ item: parsedToolPart.data,
+ };
+ }
}
}
}
@@ -931,26 +992,15 @@ class OpenCodeAgentSession implements AgentSession {
}
} else if (partType === "tool") {
// Tool parts: { tool: string, state: { status, input, output?, error? } }
- const toolName = part.tool as string | undefined;
- const state = part.state as AgentMetadata | undefined;
- const status = state?.status as string | undefined;
- const input = state?.input as AgentMetadata | undefined;
- const output = state?.output as string | undefined;
- const error = state?.error as string | undefined;
-
- if (toolName) {
- events.push({
- type: "timeline",
- provider: "opencode",
- item: mapOpencodeToolCall({
- toolName,
- callId: (part.callID as string | undefined) ?? (part.id as string | undefined),
- status,
- input,
- output,
- error,
- }),
- });
+ const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
+ if (parsedToolPart.success) {
+ if (parsedToolPart.data) {
+ events.push({
+ type: "timeline",
+ provider: "opencode",
+ item: parsedToolPart.data,
+ });
+ }
}
} else if (partType === "step-finish") {
// Extract usage from step-finish parts
diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts
index f1681c30c..4143cb746 100644
--- a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts
+++ b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts
@@ -23,8 +23,14 @@ const OpencodeKnownToolDetailSchema = z.union([
toolDetailBranchByToolName("shell", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByToolName("bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByToolName("exec_command", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
- toolDetailBranchByToolName("read", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
- toolDetailBranchByToolName("read_file", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
+ toolDetailBranchByToolName("read", ToolReadInputSchema, z.unknown(), (input, output) => {
+ const parsedOutput = ToolReadOutputSchema.safeParse(output);
+ return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
+ }),
+ toolDetailBranchByToolName("read_file", ToolReadInputSchema, z.unknown(), (input, output) => {
+ const parsedOutput = ToolReadOutputSchema.safeParse(output);
+ return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
+ }),
toolDetailBranchByToolName("write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByToolName("write_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByToolName("create_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
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 8a8aaf757..9f179acc2 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
@@ -2,15 +2,25 @@ import { describe, expect, it } from "vitest";
import { mapOpencodeToolCall } from "./tool-call-mapper.js";
+function expectMapped(item: T | null): T {
+ expect(item).toBeTruthy();
+ if (!item) {
+ throw new Error("Expected mapped tool call");
+ }
+ return item;
+}
+
describe("opencode tool-call mapper", () => {
it("maps running shell calls", () => {
- const item = mapOpencodeToolCall({
- toolName: "shell",
- callId: "opencode-call-1",
- status: "running",
- input: { command: "pwd", cwd: "/tmp/repo" },
- output: null,
- });
+ const item = expectMapped(
+ mapOpencodeToolCall({
+ toolName: "shell",
+ callId: "opencode-call-1",
+ status: "running",
+ input: { command: "pwd", cwd: "/tmp/repo" },
+ output: null,
+ })
+ );
expect(item.status).toBe("running");
expect(item.error).toBeNull();
@@ -22,49 +32,57 @@ 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,
- });
+ const readItem = expectMapped(
+ 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,
- });
+ const writeItem = expectMapped(
+ 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,
- });
+ const editItem = expectMapped(
+ 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,
- });
+ const searchItem = expectMapped(
+ 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",
@@ -72,13 +90,15 @@ describe("opencode tool-call mapper", () => {
});
it("maps completed read calls", () => {
- const item = mapOpencodeToolCall({
- toolName: "read_file",
- callId: "opencode-call-2",
- status: "complete",
- input: { file_path: "README.md" },
- output: { content: "hello" },
- });
+ const item = expectMapped(
+ mapOpencodeToolCall({
+ toolName: "read_file",
+ callId: "opencode-call-2",
+ status: "complete",
+ input: { file_path: "README.md" },
+ output: { content: "hello" },
+ })
+ );
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -91,35 +111,39 @@ 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" },
- ],
- },
- });
+ const arrayContent = expectMapped(
+ 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" },
+ const objectContent = expectMapped(
+ 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") {
@@ -128,14 +152,16 @@ describe("opencode tool-call mapper", () => {
});
it("maps failed calls with required error", () => {
- const item = mapOpencodeToolCall({
- toolName: "shell",
- callId: "opencode-call-3",
- status: "error",
- input: { command: "false" },
- output: null,
- error: "command failed",
- });
+ const item = expectMapped(
+ mapOpencodeToolCall({
+ toolName: "shell",
+ callId: "opencode-call-3",
+ status: "error",
+ input: { command: "false" },
+ output: null,
+ error: "command failed",
+ })
+ );
expect(item.status).toBe("failed");
expect(item.error).toBe("command failed");
@@ -143,38 +169,44 @@ describe("opencode tool-call mapper", () => {
});
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,
- });
+ const writeItem = expectMapped(
+ 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,
- });
+ const editItem = expectMapped(
+ 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,
- });
+ const searchItem = expectMapped(
+ 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",
@@ -182,13 +214,15 @@ describe("opencode tool-call mapper", () => {
});
it("maps unknown tools to unknown detail with raw payloads", () => {
- const item = mapOpencodeToolCall({
- toolName: "my_custom_tool",
- callId: "opencode-call-4",
- status: "completed",
- input: { foo: "bar" },
- output: { ok: true },
- });
+ const item = expectMapped(
+ mapOpencodeToolCall({
+ toolName: "my_custom_tool",
+ callId: "opencode-call-4",
+ status: "completed",
+ input: { foo: "bar" },
+ output: { ok: true },
+ })
+ );
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -198,4 +232,35 @@ describe("opencode tool-call mapper", () => {
output: { ok: true },
});
});
+
+ it("does not apply cross-provider speak normalization in opencode mapper", () => {
+ const item = expectMapped(
+ mapOpencodeToolCall({
+ toolName: "paseo_voice.speak",
+ callId: "opencode-call-voice-1",
+ status: "completed",
+ input: { text: "Voice response from OpenCode." },
+ output: { ok: true },
+ })
+ );
+
+ expect(item.name).toBe("paseo_voice.speak");
+ expect(item.detail).toEqual({
+ type: "unknown",
+ input: { text: "Voice response from OpenCode." },
+ output: { ok: true },
+ });
+ });
+
+ it("drops tool calls when callId is missing", () => {
+ const item = mapOpencodeToolCall({
+ toolName: "read_file",
+ callId: null,
+ status: "completed",
+ input: { file_path: "README.md" },
+ output: { content: "hello" },
+ });
+
+ expect(item).toBeNull();
+ });
});
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 a9af1efca..da6165429 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,7 +1,6 @@
import { z } from "zod";
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
-import { coerceToolCallId } from "../tool-call-mapper-utils.js";
import { deriveOpencodeToolDetail } from "./tool-call-detail-parser.js";
type OpencodeToolCallParams = {
@@ -18,7 +17,14 @@ const FAILED_STATUSES = new Set(["error", "failed", "failure"]);
const CANCELED_STATUSES = new Set(["canceled", "cancelled", "aborted", "interrupted"]);
const COMPLETED_STATUSES = new Set(["complete", "completed", "success", "succeeded", "done"]);
-const OpencodeToolCallParamsSchema = z
+const OpencodeToolCallStatusSchema = z.enum([
+ "running",
+ "completed",
+ "failed",
+ "canceled",
+]);
+
+const OpencodeRawToolCallSchema = z
.object({
toolName: z.string().min(1),
callId: z.string().optional().nullable(),
@@ -26,74 +32,163 @@ const OpencodeToolCallParamsSchema = z
input: z.unknown().optional(),
output: z.unknown().optional(),
error: z.unknown().optional(),
- metadata: z.record(z.unknown()).optional(),
+ metadata: z.record(z.string(), z.unknown()).optional(),
})
.passthrough();
-function coerceCallId(callId: string | null | undefined, toolName: string, input: unknown): string {
- return coerceToolCallId({
- providerPrefix: "opencode",
- rawCallId: callId,
- toolName,
- input,
- });
-}
+const OpencodeNormalizedToolCallPass1Schema = OpencodeRawToolCallSchema.transform((raw) => {
+ const input = raw.input ?? null;
+ const output = raw.output ?? null;
+ const error = raw.error ?? null;
+ const callId =
+ typeof raw.callId === "string" && raw.callId.trim().length > 0
+ ? raw.callId.trim()
+ : null;
+ let status: z.infer;
-function resolveStatus(
- rawStatus: unknown,
- error: unknown,
- output: unknown
-): ToolCallTimelineItem["status"] {
- if (error !== null && error !== undefined) {
- return "failed";
- }
-
- if (typeof rawStatus === "string") {
- const normalized = rawStatus.trim().toLowerCase();
- if (normalized.length > 0) {
- if (FAILED_STATUSES.has(normalized)) {
- return "failed";
- }
- if (CANCELED_STATUSES.has(normalized)) {
- return "canceled";
- }
- if (COMPLETED_STATUSES.has(normalized)) {
- return "completed";
- }
- return "running";
+ if (error !== null) {
+ status = "failed";
+ } else if (typeof raw.status === "string") {
+ const normalized = raw.status.trim().toLowerCase();
+ if (FAILED_STATUSES.has(normalized)) {
+ status = "failed";
+ } else if (CANCELED_STATUSES.has(normalized)) {
+ status = "canceled";
+ } else if (COMPLETED_STATUSES.has(normalized)) {
+ status = "completed";
+ } else {
+ status = "running";
}
- }
-
- return output !== null && output !== undefined ? "completed" : "running";
-}
-
-export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTimelineItem {
- const parsedParams = OpencodeToolCallParamsSchema.parse(params);
- const input = parsedParams.input ?? null;
- const output = parsedParams.output ?? null;
- const status = resolveStatus(parsedParams.status, parsedParams.error, output);
- const callId = coerceCallId(parsedParams.callId, parsedParams.toolName, input);
- const detail = deriveOpencodeToolDetail(parsedParams.toolName, input, output);
-
- if (status === "failed") {
- return {
- type: "tool_call",
- callId,
- name: parsedParams.toolName,
- status: "failed",
- detail,
- error: parsedParams.error ?? { message: "Tool call failed" },
- ...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
- };
+ } else {
+ status = output !== null ? "completed" : "running";
}
return {
- type: "tool_call",
callId,
- name: parsedParams.toolName,
+ name: raw.toolName.trim(),
+ input,
+ output,
+ error,
+ metadata: raw.metadata,
status,
- detail,
- error: null,
- ...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
};
+});
+
+const OpencodeKnownToolNameSchema = z.union([
+ z.literal("shell"),
+ z.literal("bash"),
+ z.literal("exec_command"),
+ z.literal("read"),
+ z.literal("read_file"),
+ z.literal("write"),
+ z.literal("write_file"),
+ z.literal("create_file"),
+ z.literal("edit"),
+ z.literal("apply_patch"),
+ z.literal("apply_diff"),
+ z.literal("search"),
+ z.literal("web_search"),
+]);
+
+const OpencodeToolCallPass2BaseSchema = z.object({
+ callId: z.string().min(1),
+ name: z.string().min(1),
+ input: z.unknown().nullable(),
+ output: z.unknown().nullable(),
+ error: z.unknown().nullable(),
+ metadata: z.record(z.string(), z.unknown()).optional(),
+ status: OpencodeToolCallStatusSchema,
+ toolKind: z.enum(["known", "other"]),
+});
+
+const OpencodeToolCallPass2InputSchema = OpencodeToolCallPass2BaseSchema.omit({
+ toolKind: true,
+});
+
+const OpencodeToolCallPass2EnvelopeSchema = z.union([
+ OpencodeToolCallPass2InputSchema.extend({
+ name: OpencodeKnownToolNameSchema,
+ }).transform((normalized) => ({
+ ...normalized,
+ name: normalized.name.trim(),
+ toolKind: "known" as const,
+ })),
+ OpencodeToolCallPass2InputSchema.transform((normalized) => ({
+ ...normalized,
+ name: normalized.name.trim(),
+ toolKind: "other" as const,
+ })),
+]);
+
+const OpencodeToolCallPass2Schema = z.discriminatedUnion("toolKind", [
+ OpencodeToolCallPass2BaseSchema.extend({
+ toolKind: z.literal("known"),
+ name: OpencodeKnownToolNameSchema,
+ }).transform((normalized): ToolCallTimelineItem => {
+ const detail = deriveOpencodeToolDetail(normalized.name, normalized.input, normalized.output);
+ if (normalized.status === "failed") {
+ return {
+ type: "tool_call",
+ callId: normalized.callId,
+ name: normalized.name,
+ status: "failed",
+ detail,
+ error: normalized.error ?? { message: "Tool call failed" },
+ ...(normalized.metadata ? { metadata: normalized.metadata } : {}),
+ };
+ }
+ return {
+ type: "tool_call",
+ callId: normalized.callId,
+ name: normalized.name,
+ status: normalized.status,
+ detail,
+ error: null,
+ ...(normalized.metadata ? { metadata: normalized.metadata } : {}),
+ };
+ }),
+ OpencodeToolCallPass2BaseSchema.extend({
+ toolKind: z.literal("other"),
+ }).transform((normalized): ToolCallTimelineItem => {
+ const detail = deriveOpencodeToolDetail(normalized.name, normalized.input, normalized.output);
+ if (normalized.status === "failed") {
+ return {
+ type: "tool_call",
+ callId: normalized.callId,
+ name: normalized.name,
+ status: "failed",
+ detail,
+ error: normalized.error ?? { message: "Tool call failed" },
+ ...(normalized.metadata ? { metadata: normalized.metadata } : {}),
+ };
+ }
+ return {
+ type: "tool_call",
+ callId: normalized.callId,
+ name: normalized.name,
+ status: normalized.status,
+ detail,
+ error: null,
+ ...(normalized.metadata ? { metadata: normalized.metadata } : {}),
+ };
+ }),
+]);
+
+export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTimelineItem | null {
+ const pass1 = OpencodeNormalizedToolCallPass1Schema.safeParse(params);
+ if (!pass1.success) {
+ return null;
+ }
+
+ const pass2Envelope = OpencodeToolCallPass2EnvelopeSchema.safeParse(pass1.data);
+ if (!pass2Envelope.success) {
+ return null;
+ }
+
+ const pass2 = OpencodeToolCallPass2Schema.safeParse(pass2Envelope.data);
+ if (!pass2.success) {
+ return null;
+ }
+
+ return pass2.data;
}
diff --git a/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts b/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts
index 39c184b91..74747fdb9 100644
--- a/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts
+++ b/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts
@@ -2,7 +2,6 @@ import { z } from "zod";
import type { ToolCallDetail } from "../agent-sdk-types.js";
import {
- commandFromValue,
extractCodexShellOutput,
flattenReadContent as flattenToolReadContent,
nonEmptyString,
@@ -29,9 +28,19 @@ export const ToolShellInputSchema = z
.passthrough(),
])
.transform((value) => {
- const commandValue = "command" in value ? value.command : value.cmd;
+ const parsedCommand = CommandValueSchema.safeParse(
+ "command" in value ? value.command : value.cmd
+ );
+ const command = parsedCommand.success
+ ? typeof parsedCommand.data === "string"
+ ? nonEmptyString(parsedCommand.data)
+ : parsedCommand.data
+ .map((token) => token.trim())
+ .filter((token) => token.length > 0)
+ .join(" ") || undefined
+ : undefined;
return {
- command: commandFromValue(commandValue),
+ command,
cwd: nonEmptyString(value.cwd) ?? nonEmptyString(value.directory),
};
});
@@ -314,17 +323,12 @@ type ToolReadOutputValue = {
content?: string;
};
-export const ToolReadOutputSchema: z.ZodType<
- ToolReadOutputValue,
- z.ZodTypeDef,
- unknown
-> = ToolReadOutputContentSchema;
+export const ToolReadOutputSchema = ToolReadOutputContentSchema;
-export const ToolReadOutputWithPathSchema: z.ZodType<
- ToolReadOutputValue,
- z.ZodTypeDef,
- unknown
-> = z.union([ToolReadOutputContentSchema, ToolReadOutputPathSchema]);
+export const ToolReadOutputWithPathSchema = z.union([
+ ToolReadOutputContentSchema,
+ ToolReadOutputPathSchema,
+]);
export const ToolWriteContentSchema = z
.object({
@@ -508,8 +512,8 @@ export const ToolSearchInputSchema = z.union([
export type ParsedToolShellInput = z.infer;
export type ParsedToolShellOutput = z.infer;
export type ParsedToolReadInput = z.infer;
-export type ParsedToolReadOutput = z.infer;
-export type ParsedToolReadOutputWithPath = z.infer;
+export type ParsedToolReadOutput = ToolReadOutputValue;
+export type ParsedToolReadOutputWithPath = ToolReadOutputValue;
export type ParsedToolWriteInput = z.infer;
export type ParsedToolWriteOutput = z.infer;
export type ParsedToolEditInput = z.infer;
@@ -649,7 +653,13 @@ export function toolDetailBranchByName<
input: inputSchema.nullable(),
output: outputSchema.nullable(),
})
- .transform(({ input, output }) => mapper(input, output));
+ .transform((value) => {
+ const parsed = value as unknown as {
+ input: z.infer | null;
+ output: z.infer | null;
+ };
+ return mapper(parsed.input, parsed.output);
+ });
}
export function toolDetailBranchByToolName<
@@ -671,7 +681,13 @@ export function toolDetailBranchByToolName<
input: inputSchema.nullable(),
output: outputSchema.nullable(),
})
- .transform(({ input, output }) => mapper(input, output));
+ .transform((value) => {
+ const parsed = value as unknown as {
+ input: z.infer | null;
+ output: z.infer | null;
+ };
+ return mapper(parsed.input, parsed.output);
+ });
}
export function toolDetailBranchByNameWithCwd<
@@ -695,5 +711,12 @@ export function toolDetailBranchByNameWithCwd<
output: outputSchema.nullable(),
cwd: z.string().optional().nullable(),
})
- .transform(({ input, output, cwd }) => mapper(input, output, cwd ?? null));
+ .transform((value) => {
+ const parsed = value as unknown as {
+ input: z.infer | null;
+ output: z.infer | null;
+ cwd?: string | null;
+ };
+ return mapper(parsed.input, parsed.output, parsed.cwd ?? null);
+ });
}
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
index 443aa22da..d203225e8 100644
--- a/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts
+++ b/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts
@@ -8,19 +8,6 @@ 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;
-}
-
const CODEX_SHELL_ENVELOPE_HEADER_LINES = new Set([
"chunk id:",
"wall time:",