Skip to content

Commit 7ec5232

Browse files
committed
fix(codex): normalize tool names to canonical frontend set (Bash, Edit, WebSearch)
All three Codex mappers (live streaming, block streaming, JSONL history) now output the same tool names the frontend recognizes, so Codex sessions render with proper tool UIs instead of falling through to the generic card.
1 parent a62d739 commit 7ec5232

3 files changed

Lines changed: 64 additions & 14 deletions

File tree

packages/cli/src/providers/codex.ts

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,12 @@ export function mapThreadEvent(event: ThreadEvent, model = "o3"): AgentEvent | n
7878
if (item.type === "command_execution") {
7979
return {
8080
type: "message",
81-
blocks: [{ type: "tool_use", id: item.id ?? `codex-${Date.now()}`, name: "command", input: { command: item.command } }],
81+
blocks: [{ type: "tool_use", id: item.id ?? `codex-${Date.now()}`, name: "Bash", input: { command: item.command } }],
8282
};
8383
}
8484
if (item.type === "file_change") {
8585
const files = item.changes.map((c) => `${c.kind} ${c.path}`).join(", ");
86-
return { type: "message", blocks: [{ type: "tool_use", id: item.id ?? `codex-${Date.now()}`, name: "file_change", input: { files } }] };
86+
return { type: "message", blocks: [{ type: "tool_use", id: item.id ?? `codex-${Date.now()}`, name: "Edit", input: { files } }] };
8787
}
8888
if (item.type === "reasoning" && item.text) {
8989
return { type: "message", blocks: [{ type: "thinking", text: item.text }] };
@@ -131,15 +131,15 @@ function mapItemToBlock(item: { id?: string; type: string; [k: string]: any }):
131131
case "agent_message":
132132
return item.text ? { type: "text", text: item.text } : null;
133133
case "command_execution":
134-
return { type: "tool_use", id: item.id ?? `codex-${Date.now()}`, name: "command", input: { command: item.command } };
134+
return { type: "tool_use", id: item.id ?? `codex-${Date.now()}`, name: "Bash", input: { command: item.command } };
135135
case "file_change": {
136136
const files = item.changes?.map((c: any) => `${c.kind} ${c.path}`).join(", ") ?? "";
137-
return { type: "tool_use", id: item.id ?? `codex-${Date.now()}`, name: "file_change", input: { files } };
137+
return { type: "tool_use", id: item.id ?? `codex-${Date.now()}`, name: "Edit", input: { files } };
138138
}
139139
case "mcp_tool_call":
140140
return { type: "tool_use", id: item.id ?? `codex-${Date.now()}`, name: item.name ?? "mcp_tool", input: item.arguments ?? {} };
141141
case "web_search":
142-
return { type: "tool_use", id: item.id ?? `codex-${Date.now()}`, name: "web_search", input: { query: item.query ?? "" } };
142+
return { type: "tool_use", id: item.id ?? `codex-${Date.now()}`, name: "WebSearch", input: { query: item.query ?? "" } };
143143
case "reasoning":
144144
return item.text ? { type: "thinking", text: item.text } : null;
145145
default:
@@ -311,6 +311,52 @@ function findSessionFile(threadId: string): string | null {
311311
return null;
312312
}
313313

314+
/**
315+
* Normalize a JSONL function_call to the same name/input shape as live
316+
* streaming (`mapItemToBlock`). This ensures the frontend renders history
317+
* and live events identically.
318+
*/
319+
/**
320+
* Normalize a JSONL function_call to the canonical tool names the frontend
321+
* recognizes (Bash, Edit, Read, WebSearch, etc.), matching live streaming output.
322+
*/
323+
function normalizeFunctionCall(name: string, rawArgs: Record<string, unknown>): { name: string; input: Record<string, unknown> } | null {
324+
switch (name) {
325+
// Shell execution — multiple Codex generations use different names/shapes
326+
case "exec_command":
327+
return { name: "Bash", input: { command: String(rawArgs.cmd ?? "") } };
328+
case "shell": {
329+
const cmd = Array.isArray(rawArgs.command) ? rawArgs.command.join(" ") : String(rawArgs.command ?? "");
330+
return { name: "Bash", input: { command: cmd } };
331+
}
332+
case "shell_command":
333+
return { name: "Bash", input: { command: String(rawArgs.command ?? "") } };
334+
case "write_stdin": {
335+
// write_stdin with empty chars is a poll for command output — skip it
336+
const chars = String(rawArgs.chars ?? "");
337+
if (!chars) return null;
338+
return { name: "Bash", input: { command: chars } };
339+
}
340+
341+
// Image viewing → Read (closest frontend equivalent)
342+
case "view_image":
343+
return { name: "Read", input: { file_path: String(rawArgs.path ?? "") } };
344+
345+
// User interaction → AskUserQuestion
346+
case "request_user_input":
347+
return { name: "AskUserQuestion", input: rawArgs };
348+
349+
// Internal planner — no specific UI, keep original name for fallback
350+
case "update_plan":
351+
case "list_mcp_resources":
352+
case "read_mcp_resource":
353+
return { name, input: rawArgs };
354+
355+
default:
356+
return { name, input: rawArgs };
357+
}
358+
}
359+
314360
function mapResponseItem(payload: Record<string, any>): AgentEvent | null {
315361
switch (payload.type) {
316362
case "message": {
@@ -327,17 +373,19 @@ function mapResponseItem(payload: Record<string, any>): AgentEvent | null {
327373
return null;
328374
}
329375
case "function_call": {
330-
let input: Record<string, unknown> = {};
376+
let rawArgs: Record<string, unknown> = {};
331377
if (payload.arguments) {
332378
try {
333-
input = JSON.parse(payload.arguments);
379+
rawArgs = JSON.parse(payload.arguments);
334380
} catch {
335-
input = { raw: payload.arguments };
381+
rawArgs = { raw: payload.arguments };
336382
}
337383
}
384+
const normalized = normalizeFunctionCall(payload.name ?? "tool", rawArgs);
385+
if (!normalized) return null;
338386
return {
339387
type: "message",
340-
blocks: [{ type: "tool_use", id: payload.call_id ?? `codex-hist-${Date.now()}`, name: payload.name ?? "tool", input }],
388+
blocks: [{ type: "tool_use", id: payload.call_id ?? `codex-hist-${Date.now()}`, name: normalized.name, input: normalized.input }],
341389
};
342390
}
343391
case "function_call_output":

packages/cli/tests/providers.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ describe("mapThreadEvent — item.completed", () => {
363363
const result = mapThreadEvent(event);
364364
expect(result?.type).toBe("message");
365365
if (result?.type === "message") {
366-
expect(result.blocks[0]).toEqual({ type: "tool_use", id: "i1", name: "command", input: { command: "ls" } });
366+
expect(result.blocks[0]).toEqual({ type: "tool_use", id: "i1", name: "Bash", input: { command: "ls" } });
367367
}
368368
});
369369

tests/codex-history.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,9 @@ describe("getCodexHistory — function_call payload", () => {
250250
const block = events[0].event.blocks[0];
251251
expect(block.type).toBe("tool_use");
252252
if (block.type === "tool_use") {
253-
expect(block.name).toBe("exec_command");
253+
expect(block.name).toBe("Bash");
254254
expect(block.id).toBe("call-1");
255-
expect(block.input).toEqual({ cmd: "ls -la" });
255+
expect(block.input).toEqual({ command: "ls -la" });
256256
}
257257
}
258258
});
@@ -265,7 +265,7 @@ describe("getCodexHistory — function_call payload", () => {
265265
payload: {
266266
type: "function_call",
267267
name: "exec_command",
268-
arguments: JSON.stringify({ command: "git status", cwd: "/tmp" }),
268+
arguments: JSON.stringify({ cmd: "git status", cwd: "/tmp" }),
269269
call_id: "c2",
270270
},
271271
},
@@ -274,7 +274,9 @@ describe("getCodexHistory — function_call payload", () => {
274274
if (events[0].event.type === "message") {
275275
const block = events[0].event.blocks[0];
276276
if (block.type === "tool_use") {
277-
expect(block.input).toEqual({ command: "git status", cwd: "/tmp" });
277+
// exec_command is normalized: name → "Bash", cmd field → command field
278+
expect(block.name).toBe("Bash");
279+
expect(block.input).toEqual({ command: "git status" });
278280
}
279281
}
280282
});

0 commit comments

Comments
 (0)