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:",