diff --git a/src/codex-cache.ts b/src/codex-cache.ts index 72708c4b..9cc61842 100644 --- a/src/codex-cache.ts +++ b/src/codex-cache.ts @@ -6,7 +6,9 @@ import { homedir } from 'os' import type { ParsedProviderCall } from './providers/types.js' -const CODEX_CACHE_VERSION = 3 +// v4: attribute MCP calls emitted as event_msg/mcp_tool_call_end (issue #478). +// Recent Codex sessions cached under v3 dropped these, so force a re-parse. +const CODEX_CACHE_VERSION = 4 const CACHE_FILE = 'codex-results.json' type FileFingerprint = { mtimeMs: number; sizeBytes: number } diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 9182800e..e41a58b2 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -405,6 +405,22 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars continue } + // Recent Codex emits MCP calls as `event_msg`/`mcp_tool_call_end` + // instead of a `function_call` response_item, so the call was never + // attributed. Rebuild the canonical `mcp____` name the + // classifier recognizes. + if (entry.type === 'event_msg' && entry.payload?.type === 'mcp_tool_call_end') { + const inv = (entry.payload as Record)['invocation'] as Record | undefined + const server = typeof inv?.['server'] === 'string' ? inv['server'] as string : '' + const tool = typeof inv?.['tool'] === 'string' ? inv['tool'] as string : '' + if (server && tool) { + const name = `mcp__${server}__${tool}` + pendingTools.push(name) + pendingToolSequence.push([{ tool: name }]) + } + continue + } + if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload?.role === 'user') { const texts = normalizeContentBlocks(entry.payload.content) .filter(c => c.type === 'input_text') diff --git a/tests/providers/codex.test.ts b/tests/providers/codex.test.ts index 3ef4c30c..2b7fb962 100644 --- a/tests/providers/codex.test.ts +++ b/tests/providers/codex.test.ts @@ -70,6 +70,20 @@ function functionCall(name: string, timestamp?: string) { }) } +function mcpToolCallEnd(server: string, tool: string, timestamp?: string) { + return JSON.stringify({ + type: 'event_msg', + timestamp: timestamp ?? '2026-04-14T10:00:30Z', + payload: { + type: 'mcp_tool_call_end', + call_id: 'call-1', + invocation: { server, tool, arguments: {} }, + duration: '1.2s', + result: { Ok: { content: [] } }, + }, + }) +} + function userMessage(text: string, timestamp?: string) { return JSON.stringify({ type: 'response_item', @@ -279,6 +293,30 @@ describe('codex provider - JSONL parsing', () => { expect(call.deduplicationKey).toContain('codex:') }) + it('attributes MCP calls emitted as event_msg/mcp_tool_call_end', async () => { + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp.jsonl', [ + sessionMeta({ session_id: 'sess-mcp', model: 'gpt-5.5' }), + userMessage('look up the issue'), + mcpToolCallEnd('github', 'get_issue'), + tokenCount({ + timestamp: '2026-04-14T10:01:00Z', + last: { input: 300, output: 100 }, + total: { total: 400 }, + }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const parser = provider.createSessionParser(source, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['mcp__github__get_issue']) + }) + it('normalizes Codex subagent tool calls to Agent', async () => { const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-agent.jsonl', [ sessionMeta({ session_id: 'sess-agent', model: 'gpt-5.5' }),