diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts index f741fde6..9459449b 100644 --- a/cli/src/codex/codexLocalLauncher.test.ts +++ b/cli/src/codex/codexLocalLauncher.test.ts @@ -459,7 +459,7 @@ describe('codexLocalLauncher', () => { }); }); - it('replays semantic chat events once and keeps a same-turn preface before its plan', async () => { + it('replays semantic chat and tool events once and keeps a same-turn preface before its plan', async () => { const transcriptPath = join(tempDir, 'codex-import-response-item-transcript.jsonl'); const { session, userMessages, agentMessages, getUserActivityCount } = createSessionStub('default', undefined, '/tmp/worktree', null, true); let releaseRunBarrier: (() => void) | undefined; @@ -483,6 +483,111 @@ describe('codexLocalLauncher', () => { type: 'event_msg', payload: { type: 'user_message', message: 'visible user message' } }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', + name: 'LegacyTool', + call_id: 'call-function', + arguments: '{"path":"README.md"}' + } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: 'call-function', + output: { ok: true } + } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'custom_tool_call', + call_id: 'call-exec', + name: 'exec', + input: 'pwd' + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'exec_command_end', + call_id: 'call-exec', + output: '/tmp/worktree' + } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'custom_tool_call_output', + call_id: 'call-exec', + output: [{ type: 'input_text', text: '/tmp/worktree' }] + } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'custom_tool_call', + call_id: 'call-patch', + name: 'apply_patch', + input: '*** Begin Patch\n*** End Patch' + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'patch_apply_end', + call_id: 'call-patch', + output: 'Done!' + } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'custom_tool_call_output', + call_id: 'call-patch', + output: 'Done!' + } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'tool_search_call', + call_id: 'call-tool-search', + arguments: { query: 'hapi change title', limit: 5 } + } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'tool_search_output', + call_id: 'call-tool-search', + execution: 'client', + status: 'completed', + tools: [{ name: 'mcp__hapi', description: 'Hapi tools' }] + } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'web_search_call', + status: 'failed', + action: { + type: 'search', + query: 'Codex transcript format', + queries: ['Codex transcript format'] + } + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { type: 'web_search_end', query: 'Codex transcript format' } + }), + JSON.stringify({ + type: 'event_msg', + payload: { type: 'mcp_tool_call_end', call_id: 'call-mcp-summary' } + }), JSON.stringify({ type: 'event_msg', payload: { @@ -546,6 +651,69 @@ describe('codexLocalLauncher', () => { expect(userMessages).toEqual(['visible user message']); expect(getUserActivityCount()).toBe(0); expect(agentMessages).toEqual([{ + type: 'tool-call', + name: 'LegacyTool', + callId: 'call-function', + input: { path: 'README.md' }, + id: expect.any(String) + }, { + type: 'tool-call-result', + callId: 'call-function', + output: { ok: true }, + id: expect.any(String) + }, { + type: 'tool-call', + name: 'exec', + callId: 'call-exec', + input: 'pwd', + id: expect.any(String) + }, { + type: 'tool-call-result', + callId: 'call-exec', + output: [{ type: 'input_text', text: '/tmp/worktree' }], + id: expect.any(String) + }, { + type: 'tool-call', + name: 'apply_patch', + callId: 'call-patch', + input: '*** Begin Patch\n*** End Patch', + id: expect.any(String) + }, { + type: 'tool-call-result', + callId: 'call-patch', + output: 'Done!', + id: expect.any(String) + }, { + type: 'tool-call', + name: 'ToolSearch', + callId: 'call-tool-search', + input: { query: 'hapi change title', limit: 5 }, + id: expect.any(String) + }, { + type: 'tool-call-result', + callId: 'call-tool-search', + output: { + execution: 'client', + tools: [{ name: 'mcp__hapi', description: 'Hapi tools' }] + }, + id: expect.any(String) + }, { + type: 'tool-call', + name: 'WebSearch', + callId: expect.any(String), + input: { + type: 'search', + query: 'Codex transcript format', + queries: ['Codex transcript format'] + }, + id: expect.any(String) + }, { + type: 'tool-call-result', + callId: expect.any(String), + output: null, + id: expect.any(String), + is_error: true + }, { type: 'message', message: 'visible assistant preface', id: expect.any(String) @@ -561,6 +729,9 @@ describe('codexLocalLauncher', () => { output: null, id: 'plan-1:result' }]); + expect(agentMessages[9]).toMatchObject({ + callId: (agentMessages[8] as { callId: string }).callId + }); }); it('replays a plan-only turn when the turn completes', async () => { diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 4589a259..bce20442 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -151,12 +151,12 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch } else if (converted?.userActivity) { session.notifyUserActivity(); } - if (converted?.message) { - if (converted.message.type === 'proposed_plan') { + for (const message of converted?.messages ?? []) { + if (message.type === 'proposed_plan') { // Codex may complete the Plan item before emitting its final text preface. - pendingPlansByTurnId.set(converted.message.turnId, converted.message); + pendingPlansByTurnId.set(message.turnId, message); } else { - session.sendAgentMessage(converted.message); + session.sendAgentMessage(message); } } if (converted?.finishedTurnId) { diff --git a/cli/src/codex/utils/codexEventConverter.test.ts b/cli/src/codex/utils/codexEventConverter.test.ts index d7cf1008..c1f42c1f 100644 --- a/cli/src/codex/utils/codexEventConverter.test.ts +++ b/cli/src/codex/utils/codexEventConverter.test.ts @@ -17,7 +17,7 @@ describe('convertCodexEvent', () => { payload: { type: 'agent_message', message: 'hello' } }); - expect(result?.message).toMatchObject({ + expect(result?.messages?.[0]).toMatchObject({ type: 'message', message: 'hello' }); @@ -42,7 +42,7 @@ describe('convertCodexEvent', () => { } }); - expect(result?.message).toMatchObject({ + expect(result?.messages?.[0]).toMatchObject({ type: 'proposed_plan', plan: '## Plan\n\n1. Inspect\n2. Implement', id: 'plan-1', @@ -127,7 +127,7 @@ describe('convertCodexEvent', () => { payload: { type: 'agent_reasoning', text: 'thinking' } }); - expect(result?.message).toMatchObject({ + expect(result?.messages?.[0]).toMatchObject({ type: 'reasoning', message: 'thinking' }); @@ -139,7 +139,7 @@ describe('convertCodexEvent', () => { payload: { type: 'agent_reasoning_delta', delta: 'step' } }); - expect(result?.message).toEqual({ + expect(result?.messages?.[0]).toEqual({ type: 'reasoning-delta', delta: 'step' }); @@ -156,7 +156,7 @@ describe('convertCodexEvent', () => { } }); - expect(result?.message).toMatchObject({ + expect(result?.messages?.[0]).toMatchObject({ type: 'tool-call', name: 'ToolName', callId: 'call-1', @@ -174,10 +174,188 @@ describe('convertCodexEvent', () => { } }); - expect(result?.message).toMatchObject({ + expect(result?.messages?.[0]).toMatchObject({ type: 'tool-call-result', callId: 'call-2', output: { ok: true } }); }); + + it.each([ + ['exec', 'ls -la'], + ['apply_patch', '*** Begin Patch\n*** End Patch'] + ])('converts %s custom_tool_call items', (name, input) => { + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'custom_tool_call', + name, + call_id: `call-${name}`, + input + } + }); + + expect(result?.messages?.[0]).toMatchObject({ + type: 'tool-call', + name, + callId: `call-${name}`, + input + }); + }); + + it.each([ + ['string', 'command output'], + ['array', [{ type: 'input_text', text: 'patch applied' }]] + ])('preserves %s custom_tool_call_output values', (_name, output) => { + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'custom_tool_call_output', + call_id: 'call-custom-output', + output + } + }); + + expect(result?.messages?.[0]).toMatchObject({ + type: 'tool-call-result', + callId: 'call-custom-output', + output + }); + }); + + it('converts tool_search_call items', () => { + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'tool_search_call', + call_id: 'call-tool-search', + arguments: { query: 'hapi change title', limit: 5 } + } + }); + + expect(result?.messages?.[0]).toMatchObject({ + type: 'tool-call', + name: 'ToolSearch', + callId: 'call-tool-search', + input: { query: 'hapi change title', limit: 5 } + }); + }); + + it('converts tool_search_output items', () => { + const tools = [{ name: 'mcp__hapi', description: 'Hapi tools' }]; + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'tool_search_output', + call_id: 'call-tool-search', + execution: 'client', + status: 'completed', + tools + } + }); + + expect(result?.messages?.[0]).toMatchObject({ + type: 'tool-call-result', + callId: 'call-tool-search', + output: { execution: 'client', tools } + }); + }); + + it('converts a completed web_search_call into a paired call and result', () => { + const action = { + type: 'search', + query: 'Codex transcript format', + queries: ['Codex transcript format'] + }; + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'web_search_call', + status: 'completed', + action + } + }); + + expect(result?.messages).toEqual([{ + type: 'tool-call', + name: 'WebSearch', + callId: expect.any(String), + input: action, + id: expect.any(String) + }, { + type: 'tool-call-result', + callId: expect.any(String), + output: null, + id: expect.any(String) + }]); + expect(result?.messages?.[1]).toMatchObject({ + callId: result?.messages?.[0]?.type === 'tool-call' + ? result.messages[0].callId + : undefined + }); + }); + + it('uses an empty input for older web_search_call items without an action', () => { + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'web_search_call', + status: 'completed' + } + }); + + expect(result?.messages?.[0]).toMatchObject({ + type: 'tool-call', + name: 'WebSearch', + input: {} + }); + }); + + it.each(['failed', 'error'])('marks a %s web_search_call result as an error', (status) => { + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'web_search_call', + status, + action: { type: 'search', query: 'failing query' } + } + }); + + expect(result?.messages?.[1]).toMatchObject({ + type: 'tool-call-result', + output: null, + is_error: true + }); + }); + + it.each([ + ['custom tool call without a name', { + type: 'custom_tool_call', + call_id: 'call-missing-name', + input: 'pwd' + }], + ['custom tool call without a call id', { + type: 'custom_tool_call', + name: 'exec', + input: 'pwd' + }], + ['custom tool output without a call id', { + type: 'custom_tool_call_output', + output: 'done' + }], + ['tool search call without a call id', { + type: 'tool_search_call', + arguments: { query: 'missing id' } + }], + ['tool search output without a call id', { + type: 'tool_search_output', + execution: 'client', + tools: [] + }] + ])('ignores %s', (_name, payload) => { + expect(convertCodexEvent({ + type: 'response_item', + payload + })).toBeNull(); + }); }); diff --git a/cli/src/codex/utils/codexEventConverter.ts b/cli/src/codex/utils/codexEventConverter.ts index 11024ccc..70dbc427 100644 --- a/cli/src/codex/utils/codexEventConverter.ts +++ b/cli/src/codex/utils/codexEventConverter.ts @@ -41,11 +41,12 @@ export type CodexMessage = { callId: string; output: unknown; id: string; + is_error?: boolean; }; export type CodexConversionResult = { sessionId?: string; - message?: CodexMessage; + messages?: CodexMessage[]; userMessage?: string; userActivity?: true; finishedTurnId?: string; @@ -72,7 +73,7 @@ function parseArguments(value: unknown): unknown { try { return JSON.parse(trimmed); } catch (error) { - logger.debug('[codexEventConverter] Failed to parse function_call arguments as JSON:', error); + logger.debug('[codexEventConverter] Failed to parse tool call input as JSON:', error); } } @@ -141,11 +142,11 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + messages: [{ type: 'message', message, id: randomUUID() - } + }] }; } @@ -158,12 +159,12 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + messages: [{ type: 'proposed_plan', plan: message, id: asString(item?.id) ?? randomUUID(), turnId - } + }] }; } @@ -178,11 +179,11 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + messages: [{ type: 'reasoning', message, id: randomUUID() - } + }] }; } @@ -192,10 +193,10 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + messages: [{ type: 'reasoning-delta', delta - } + }] }; } @@ -205,11 +206,11 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + messages: [{ type: 'token_count', info, id: randomUUID() - } + }] }; } @@ -234,13 +235,13 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + messages: [{ type: 'tool-call', name, callId, input: parseArguments(payloadRecord.arguments), id: randomUUID() - } + }] }; } @@ -250,12 +251,100 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + messages: [{ type: 'tool-call-result', callId, output: payloadRecord.output, id: randomUUID() - } + }] + }; + } + + if (itemType === 'custom_tool_call') { + const name = asString(payloadRecord.name); + const callId = extractCallId(payloadRecord); + if (!name || !callId) { + return null; + } + return { + messages: [{ + type: 'tool-call', + name, + callId, + input: parseArguments(payloadRecord.input), + id: randomUUID() + }] + }; + } + + if (itemType === 'custom_tool_call_output') { + const callId = extractCallId(payloadRecord); + if (!callId) { + return null; + } + return { + messages: [{ + type: 'tool-call-result', + callId, + output: payloadRecord.output, + id: randomUUID() + }] + }; + } + + if (itemType === 'tool_search_call') { + const callId = extractCallId(payloadRecord); + if (!callId) { + return null; + } + return { + messages: [{ + type: 'tool-call', + name: 'ToolSearch', + callId, + input: parseArguments(payloadRecord.arguments), + id: randomUUID() + }] + }; + } + + if (itemType === 'tool_search_output') { + const callId = extractCallId(payloadRecord); + if (!callId) { + return null; + } + return { + messages: [{ + type: 'tool-call-result', + callId, + output: { + execution: payloadRecord.execution, + tools: payloadRecord.tools + }, + id: randomUUID() + }] + }; + } + + if (itemType === 'web_search_call') { + // Transcript web searches have neither a call id nor a separate output item. + const callId = randomUUID(); + const status = asString(payloadRecord.status)?.toLowerCase(); + const isError = status === 'failed' || status === 'error'; + return { + messages: [{ + type: 'tool-call', + name: 'WebSearch', + callId, + input: payloadRecord.action ?? {}, + id: randomUUID() + }, { + type: 'tool-call-result', + callId, + output: null, + id: randomUUID(), + ...(isError ? { is_error: true } : {}) + }] }; }