diff --git a/cli/src/db/write.ts b/cli/src/db/write.ts index 12e87334..e6ae6593 100644 --- a/cli/src/db/write.ts +++ b/cli/src/db/write.ts @@ -313,6 +313,13 @@ export function insertMessages(session: ParsedSession, isForce = false): void { const stmts = getStmts(); const tx = db.transaction((messages: ParsedMessage[]) => { + if (isForce) { + // Delete all existing messages for this session before re-inserting. + // Parser fixes can change message IDs (e.g. adding session-scoped prefixes), + // so DELETE + INSERT is safer than INSERT OR REPLACE which leaves orphaned + // rows when IDs change. + db.prepare('DELETE FROM messages WHERE session_id = ?').run(session.id); + } const stmt = isForce ? stmts.replaceMessage : stmts.insertMessage; for (const msg of messages) { stmt.run( diff --git a/cli/src/providers/codex.test.ts b/cli/src/providers/codex.test.ts index d4ad1127..c700db1d 100644 --- a/cli/src/providers/codex.test.ts +++ b/cli/src/providers/codex.test.ts @@ -39,6 +39,90 @@ function taskCompleteLine(): string { }); } +// v0.131+ task_complete no longer carries usage — usage lives in token_count events +function taskCompleteLineNew(): string { + return JSON.stringify({ + type: 'event_msg', + timestamp: '2026-01-01T10:03:00Z', + payload: { + type: 'task_complete', + turn_id: 'turn-001', + last_agent_message: 'Done.', + completed_at: 1746000000, + duration_ms: 12345, + time_to_first_token_ms: 1500, + }, + }); +} + +function tokenCountLine(opts: { + input_tokens: number; + cached_input_tokens?: number; + output_tokens: number; + reasoning_output_tokens?: number; + total_tokens: number; +}): string { + return JSON.stringify({ + type: 'event_msg', + timestamp: '2026-01-01T10:02:50Z', + payload: { + type: 'token_count', + info: { + total_token_usage: opts, + last_token_usage: opts, + }, + }, + }); +} + +function functionCallLine(name: string, args: Record, callId: string): string { + return JSON.stringify({ + type: 'response_item', + timestamp: '2026-01-01T10:02:10Z', + payload: { type: 'function_call', name, arguments: JSON.stringify(args), call_id: callId }, + }); +} + +function functionCallOutputLine(callId: string, output: string): string { + return JSON.stringify({ + type: 'response_item', + timestamp: '2026-01-01T10:02:20Z', + payload: { type: 'function_call_output', call_id: callId, output }, + }); +} + +function customToolCallLine(name: string, input: string, callId: string): string { + return JSON.stringify({ + type: 'response_item', + timestamp: '2026-01-01T10:02:10Z', + payload: { type: 'custom_tool_call', name, input, call_id: callId, status: 'completed' }, + }); +} + +function customToolCallOutputLine(callId: string, output: string): string { + return JSON.stringify({ + type: 'response_item', + timestamp: '2026-01-01T10:02:20Z', + payload: { type: 'custom_tool_call_output', call_id: callId, output: JSON.stringify({ output }) }, + }); +} + +function reasoningLine(summaryItems: Array<{ type: string; text: string }>): string { + return JSON.stringify({ + type: 'response_item', + timestamp: '2026-01-01T10:02:00Z', + payload: { type: 'reasoning', summary: summaryItems, content: null }, + }); +} + +function turnAbortedLine(): string { + return JSON.stringify({ + type: 'event_msg', + timestamp: '2026-01-01T10:02:30Z', + payload: { type: 'turn_aborted', turn_id: 'turn-001', reason: 'interrupted' }, + }); +} + function buildJSONL(lines: string[]): string { return lines.join('\n'); } @@ -238,3 +322,311 @@ describe('CodexProvider — Format A system context filtering', () => { expect(session!.slashCommands).toEqual([]); }); }); + +// --------------------------------------------------------------------------- +// token_count usage capture (v0.131+ format) +// --------------------------------------------------------------------------- + +describe('CodexProvider — Format A token_count usage capture', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('captures usage from token_count events when task_complete has no usage field', async () => { + const content = buildJSONL([ + sessionMeta(), + userMessageLine('Refactor this module'), + assistantLine('I will refactor it for you.'), + tokenCountLine({ input_tokens: 25000, cached_input_tokens: 3000, output_tokens: 500, total_tokens: 25500 }), + taskCompleteLineNew(), + ]); + + const filePath = path.join(tempDir, 'rollout-token-count-usage.jsonl'); + fs.writeFileSync(filePath, content); + + const provider = new CodexProvider(); + const session = await provider.parse(filePath); + + expect(session).not.toBeNull(); + expect(session!.usage).toBeDefined(); + expect(session!.usage!.totalInputTokens).toBe(25000); + expect(session!.usage!.totalOutputTokens).toBe(500); + expect(session!.usage!.cacheReadTokens).toBe(3000); + }); + + it('uses the last token_count event (cumulative total) when multiple are emitted', async () => { + const content = buildJSONL([ + sessionMeta(), + userMessageLine('Write some code'), + assistantLine('Here is the code.'), + // First token_count (after first API call) + tokenCountLine({ input_tokens: 1000, output_tokens: 100, total_tokens: 1100 }), + // Second token_count (cumulative after second API call) + tokenCountLine({ input_tokens: 5000, output_tokens: 400, total_tokens: 5400 }), + taskCompleteLineNew(), + ]); + + const filePath = path.join(tempDir, 'rollout-multi-token-count.jsonl'); + fs.writeFileSync(filePath, content); + + const provider = new CodexProvider(); + const session = await provider.parse(filePath); + + expect(session).not.toBeNull(); + expect(session!.usage).toBeDefined(); + // Should use the LAST (most cumulative) token_count + expect(session!.usage!.totalInputTokens).toBe(5000); + expect(session!.usage!.totalOutputTokens).toBe(400); + }); + + it('still captures usage from old-format task_complete.usage when present', async () => { + const content = buildJSONL([ + sessionMeta(), + userMessageLine('Explain this code'), + assistantLine('This code does X.'), + // Old-format task_complete with inline usage + taskCompleteLine(), + ]); + + const filePath = path.join(tempDir, 'rollout-old-task-complete.jsonl'); + fs.writeFileSync(filePath, content); + + const provider = new CodexProvider(); + const session = await provider.parse(filePath); + + expect(session).not.toBeNull(); + expect(session!.usage).toBeDefined(); + expect(session!.usage!.totalInputTokens).toBe(100); + expect(session!.usage!.totalOutputTokens).toBe(50); + }); + + it('returns undefined usage when token counts are all zero', async () => { + // External-import sessions replay with zero token counts + const content = buildJSONL([ + sessionMeta(), + userMessageLine('Do something'), + assistantLine('Done.'), + tokenCountLine({ input_tokens: 0, cached_input_tokens: 0, output_tokens: 0, total_tokens: 0 }), + taskCompleteLineNew(), + ]); + + const filePath = path.join(tempDir, 'rollout-zero-usage.jsonl'); + fs.writeFileSync(filePath, content); + + const provider = new CodexProvider(); + const session = await provider.parse(filePath); + + expect(session).not.toBeNull(); + expect(session!.usage).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Tool call parsing +// --------------------------------------------------------------------------- + +describe('CodexProvider — Format A tool call parsing', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('parses function_call and function_call_output events into toolCallCount', async () => { + const content = buildJSONL([ + sessionMeta(), + userMessageLine('List the files'), + functionCallLine('exec_command', { cmd: 'ls -la' }, 'call-1'), + functionCallOutputLine('call-1', 'file1.ts\nfile2.ts'), + assistantLine('I found two files.'), + taskCompleteLineNew(), + ]); + + const filePath = path.join(tempDir, 'rollout-function-calls.jsonl'); + fs.writeFileSync(filePath, content); + + const provider = new CodexProvider(); + const session = await provider.parse(filePath); + + expect(session).not.toBeNull(); + expect(session!.toolCallCount).toBe(1); + expect(session!.userMessageCount).toBe(1); + expect(session!.assistantMessageCount).toBe(1); + }); + + it('counts multiple parallel function_calls correctly', async () => { + const content = buildJSONL([ + sessionMeta(), + userMessageLine('Read three files'), + functionCallLine('exec_command', { cmd: 'cat a.ts' }, 'call-a'), + functionCallLine('exec_command', { cmd: 'cat b.ts' }, 'call-b'), + functionCallLine('exec_command', { cmd: 'cat c.ts' }, 'call-c'), + functionCallOutputLine('call-a', 'content a'), + functionCallOutputLine('call-b', 'content b'), + functionCallOutputLine('call-c', 'content c'), + assistantLine('I read all three files.'), + taskCompleteLineNew(), + ]); + + const filePath = path.join(tempDir, 'rollout-parallel-calls.jsonl'); + fs.writeFileSync(filePath, content); + + const provider = new CodexProvider(); + const session = await provider.parse(filePath); + + expect(session).not.toBeNull(); + expect(session!.toolCallCount).toBe(3); + }); + + it('parses custom_tool_call (apply_patch) events', async () => { + const content = buildJSONL([ + sessionMeta(), + userMessageLine('Fix the bug in main.ts'), + functionCallLine('exec_command', { cmd: 'cat main.ts' }, 'call-read'), + functionCallOutputLine('call-read', 'const x = 1;'), + customToolCallLine('apply_patch', '*** Begin Patch\n*** End Patch', 'call-patch'), + customToolCallOutputLine('call-patch', 'Applied successfully'), + assistantLine('I fixed the bug.'), + taskCompleteLineNew(), + ]); + + const filePath = path.join(tempDir, 'rollout-custom-tool.jsonl'); + fs.writeFileSync(filePath, content); + + const provider = new CodexProvider(); + const session = await provider.parse(filePath); + + expect(session).not.toBeNull(); + expect(session!.toolCallCount).toBe(2); // exec_command + apply_patch + }); +}); + +// --------------------------------------------------------------------------- +// Aborted and interrupted sessions (turn_aborted replaces task_complete) +// --------------------------------------------------------------------------- + +describe('CodexProvider — Format A aborted sessions', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('still produces a valid session when turn_aborted replaces task_complete', async () => { + const content = buildJSONL([ + sessionMeta(), + userMessageLine('Do a long task'), + functionCallLine('exec_command', { cmd: 'sleep 60' }, 'call-long'), + // User interrupted — no task_complete + turnAbortedLine(), + ]); + + const filePath = path.join(tempDir, 'rollout-aborted.jsonl'); + fs.writeFileSync(filePath, content); + + const provider = new CodexProvider(); + const session = await provider.parse(filePath); + + expect(session).not.toBeNull(); + expect(session!.userMessageCount).toBe(1); + // Assistant turn flushed at end even without task_complete + expect(session!.assistantMessageCount).toBe(1); + expect(session!.toolCallCount).toBe(1); + }); + + it('captures token_count usage even when turn was aborted before task_complete', async () => { + const content = buildJSONL([ + sessionMeta(), + userMessageLine('Analyze this codebase'), + functionCallLine('exec_command', { cmd: 'find . -name "*.ts"' }, 'call-find'), + functionCallOutputLine('call-find', 'src/index.ts\nsrc/utils.ts'), + tokenCountLine({ input_tokens: 8000, output_tokens: 200, total_tokens: 8200 }), + turnAbortedLine(), + ]); + + const filePath = path.join(tempDir, 'rollout-aborted-with-usage.jsonl'); + fs.writeFileSync(filePath, content); + + const provider = new CodexProvider(); + const session = await provider.parse(filePath); + + expect(session).not.toBeNull(); + expect(session!.usage).toBeDefined(); + expect(session!.usage!.totalInputTokens).toBe(8000); + expect(session!.usage!.totalOutputTokens).toBe(200); + }); +}); + +// --------------------------------------------------------------------------- +// Reasoning events +// --------------------------------------------------------------------------- + +describe('CodexProvider — Format A reasoning events', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('extracts thinking text from reasoning summary_text items', async () => { + const content = buildJSONL([ + sessionMeta(), + userMessageLine('Solve this problem'), + reasoningLine([{ type: 'summary_text', text: 'The user wants X, so I should do Y.' }]), + assistantLine('Here is the solution.'), + taskCompleteLineNew(), + ]); + + const filePath = path.join(tempDir, 'rollout-reasoning-text.jsonl'); + fs.writeFileSync(filePath, content); + + const provider = new CodexProvider(); + const session = await provider.parse(filePath); + + expect(session).not.toBeNull(); + const assistantMsg = session!.messages.find(m => m.type === 'assistant'); + expect(assistantMsg).toBeDefined(); + expect(assistantMsg!.thinking).toContain('The user wants X'); + }); + + it('handles empty reasoning summary (encrypted reasoning in v0.131+) without error', async () => { + const content = buildJSONL([ + sessionMeta(), + userMessageLine('Solve a hard problem'), + reasoningLine([]), // empty summary — reasoning is encrypted + assistantLine('Here is the answer.'), + taskCompleteLineNew(), + ]); + + const filePath = path.join(tempDir, 'rollout-encrypted-reasoning.jsonl'); + fs.writeFileSync(filePath, content); + + const provider = new CodexProvider(); + const session = await provider.parse(filePath); + + expect(session).not.toBeNull(); + expect(session!.userMessageCount).toBe(1); + expect(session!.assistantMessageCount).toBe(1); + const assistantMsg = session!.messages.find(m => m.type === 'assistant'); + // thinking is null when summary is empty + expect(assistantMsg!.thinking).toBeNull(); + }); +}); diff --git a/cli/src/providers/codex.ts b/cli/src/providers/codex.ts index 3357e14f..79e32ddc 100644 --- a/cli/src/providers/codex.ts +++ b/cli/src/providers/codex.ts @@ -137,6 +137,8 @@ interface CodexUsage { cached_input_tokens?: number; output_tokens?: number; reasoning?: number; + reasoning_output_tokens?: number; // v0.131+ format + total_tokens?: number; // v0.131+ cumulative total } // Format B: top-level JSON structure @@ -207,6 +209,8 @@ function parseFormatA(content: string): ParsedSession | null { const usageEntries: CodexUsage[] = []; let model = meta.model || ''; let lastTimestamp = new Date(meta.timestamp); + // v0.131+ emits cumulative token totals in token_count events instead of task_complete + let cumulativeTokenCount: CodexUsage | null = null; // Accumulator for current assistant turn let currentAssistantText = ''; @@ -230,7 +234,7 @@ function parseFormatA(content: string): ParsedSession | null { } : null; messages.push({ - id: `codex-assistant-${messages.length}`, + id: `${sessionId}-assistant-${messages.length}`, sessionId: sessionId, type: 'assistant', content: text.slice(0, 10000), @@ -284,7 +288,7 @@ function parseFormatA(content: string): ParsedSession | null { } lastTimestamp = parseEnvelopeTimestamp(event) || lastTimestamp; } - // Skip role === 'user' — handled by event_msg/user_message case. + // role === 'user' is handled by event_msg/user_message case. // Both response_item/message(role=user) and event_msg/user_message fire for // every user prompt, so only capturing from one source avoids doubling the count. break; @@ -298,7 +302,7 @@ function parseFormatA(content: string): ParsedSession | null { const msgText = (payload.message as string) || ''; if (msgText && !isSystemContextMessage(msgText)) { messages.push({ - id: (payload.id as string) || `codex-user-${messages.length}`, + id: `${sessionId}-user-${messages.length}`, sessionId: sessionId, type: 'user', content: msgText.slice(0, 10000), @@ -326,7 +330,7 @@ function parseFormatA(content: string): ParsedSession | null { // response_item/function_call: tool invocation (exec_command, etc.) // payload: { type, name, arguments (JSON string), call_id, status? } toolCounter++; - const callId = (payload.call_id as string) || `codex-tool-${toolCounter}`; + const callId = (payload.call_id as string) || `${sessionId}-tool-${toolCounter}`; let args: Record = {}; try { args = JSON.parse(payload.arguments as string) as Record; @@ -358,7 +362,7 @@ function parseFormatA(content: string): ParsedSession | null { // response_item/custom_tool_call: apply_patch and similar custom tools // payload: { type, name, call_id, input (string), status? } toolCounter++; - const ctcCallId = (payload.call_id as string) || `codex-custom-${toolCounter}`; + const ctcCallId = (payload.call_id as string) || `${sessionId}-custom-${toolCounter}`; currentToolCalls.push({ id: ctcCallId, name: (payload.name as string) || 'custom_tool', @@ -438,12 +442,22 @@ function parseFormatA(content: string): ParsedSession | null { break; } + case 'token_count': { + // event_msg/token_count: cumulative session token usage (v0.131+) + // payload.info.total_token_usage holds running totals; track the last seen value + const tcInfo = payload.info as Record | undefined; + const total = tcInfo?.total_token_usage as CodexUsage | undefined; + if (total) cumulativeTokenCount = total; + break; + } + case 'turn.started': case 'thread.started': case 'session_meta': case 'task_started': - case 'token_count': case 'turn_context': + case 'turn_aborted': // interrupted session — final flush handles remaining content + case 'patch_apply_end': // apply_patch confirmation event // Lifecycle/telemetry events — skip break; @@ -455,6 +469,12 @@ function parseFormatA(content: string): ParsedSession | null { // Flush any remaining assistant content after all lines processed flushAssistantTurn(); + // If no per-turn usage was captured from task_complete (old format), fall back to the + // cumulative total from the last token_count event (v0.131+ format) + if (usageEntries.length === 0 && cumulativeTokenCount) { + usageEntries.push(cumulativeTokenCount); + } + return buildSession(sessionId, meta.cwd || 'codex://unknown', meta.cli_version || null, meta.timestamp, messages, usageEntries, model); } @@ -493,7 +513,7 @@ function parseFormatB(content: string): ParsedSession | null { if (currentToolCalls.length === 0 && !currentThinking) return; messages.push({ - id: `codex-assistant-${messages.length}`, + id: `${sessionId}-assistant-${messages.length}`, sessionId: sessionId, type: 'assistant', content: '', @@ -519,7 +539,7 @@ function parseFormatB(content: string): ParsedSession | null { const userContent = extractFormatBContent(item.content); if (userContent && !isSystemContextMessage(userContent)) { messages.push({ - id: `codex-user-${messages.length}`, + id: `${sessionId}-user-${messages.length}`, sessionId: sessionId, type: 'user', content: userContent.slice(0, 10000), @@ -551,7 +571,7 @@ function parseFormatB(content: string): ParsedSession | null { case 'function_call': { // item: { type, id, name, arguments (JSON string), call_id, status } toolCounter++; - const callId = item.call_id || item.id || `codex-tool-${toolCounter}`; + const callId = item.call_id || item.id || `${sessionId}-tool-${toolCounter}`; let args: Record = {}; if (item.arguments) { try {