diff --git a/src/codex-cache.ts b/src/codex-cache.ts index 4c12fa60..eac100f0 100644 --- a/src/codex-cache.ts +++ b/src/codex-cache.ts @@ -43,7 +43,8 @@ import type { ParsedProviderCall } from './providers/types.js' // v15: builtin alias prices `codex-auto-review` (#1047). Exact-hit cache // entries still hold the pre-alias $0; bump so unchanged rollouts reprice. // Must be max(main v14 #1092, this)+1 — #1092 spent v14 on MCP/skills. -export const CODEX_CACHE_VERSION = 15 +// Missing cumulative usage no longer collapses distinct records. +export const CODEX_CACHE_VERSION = 16 export const CODEX_LEGACY_CACHE_FILE = 'codex-results.json' export function codexCacheFileName(version = CODEX_CACHE_VERSION): string { return `codex-results.v${version}.json` diff --git a/src/providers/codex.ts b/src/providers/codex.ts index fb1d4978..8382d3fa 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -1142,13 +1142,12 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: { } const cumulativeTotal = info.total_token_usage?.total_tokens ?? 0 - // Dedup guard. Two consecutive events with cumulativeTotal=0 but - // non-empty last_token_usage would have been double-counted with - // the previous `> 0` clause. The null sentinel ensures the FIRST - // event always passes (so a session that never reports cumulative - // doesn't lose its opening turn). - if (prevCumulativeTotal !== null && cumulativeTotal === prevCumulativeTotal) continue - prevCumulativeTotal = cumulativeTotal + // Missing/null/partial cumulative data is not a repeated zero total. + const reportsCumulative = typeof info.total_token_usage?.total_tokens === 'number' + && Number.isFinite(info.total_token_usage.total_tokens) + && info.total_token_usage.total_tokens >= 0 + if (reportsCumulative && prevCumulativeTotal !== null && cumulativeTotal === prevCumulativeTotal) continue + prevCumulativeTotal = reportsCumulative ? cumulativeTotal : null const last = info.last_token_usage let inputTokens = 0 @@ -1227,12 +1226,13 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: { // are computed against a running `prev` that the fork advances // differently once the 5s cutoff skips some replays, so a delta-based // key would spuriously diverge on a replay and double-count it. - const dedupKey = `codex:${forkedFromId || sessionId}:${cumulativeTotal}:${total?.input_tokens ?? 0}:${total?.cached_input_tokens ?? 0}:${total?.output_tokens ?? 0}:${total?.reasoning_output_tokens ?? 0}` + // Without cumulative identity, equal usage can be distinct requests. + // Use the physical record position: stable on cache resume/re-read, + // but deliberately do not guess cross-file replay identity. + const dedupKey = reportsCumulative + ? `codex:${forkedFromId || sessionId}:${cumulativeTotal}:${total?.input_tokens ?? 0}:${total?.cached_input_tokens ?? 0}:${total?.output_tokens ?? 0}:${total?.reasoning_output_tokens ?? 0}` + : `codex:record:${JSON.stringify([source.path, tracker.lastCompleteLineOffset])}` - // A drop here can only be a byte-identical replay: the - // prevCumulativeTotal guard above already discards a repeated - // running total, so nothing reaching this point ever loses real - // tokens -- no active-time rescaling needed (#1088 investigation). if (seenKeys.has(dedupKey)) continue seenKeys.add(dedupKey) diff --git a/src/session-cache.ts b/src/session-cache.ts index 54a78ae9..3ab815db 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -341,7 +341,7 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // activity-price-v1: `codex-auto-review` now prices via the recommended // review model. session-cache.json would otherwise keep the pre-alias $0. // Compose all four — a take-ours merge would drop #1075, #1079, or #1092. - codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1-session-meta-fields-v1-codex-pricing-v1-codex-tps-v1-codex-mcp-skills-v1-activity-price-v1', + codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1-session-meta-fields-v1-codex-pricing-v1-codex-tps-v1-codex-mcp-skills-v1-activity-price-v1-missing-cumulative-v1', cursor: 'composer-anchored-crediting-v1-est-cost', 'cursor-agent': 'workspaceless-transcript-v1', // source-provenance-v1 (#944): CLI sessions were misread as VS Code diff --git a/tests/codex-missing-cumulative.test.ts b/tests/codex-missing-cumulative.test.ts new file mode 100644 index 00000000..ebc82037 --- /dev/null +++ b/tests/codex-missing-cumulative.test.ts @@ -0,0 +1,34 @@ +import { it, expect, vi } from 'vitest' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createCodexProvider } from '../src/providers/codex.js' + +vi.mock('../src/codex-cache.js', async (original) => ({ + ...await original(), + readCachedCodexResults: async () => null, + readCodexResume: async () => null, + writeCachedCodexResults: async () => {}, +})) + +for (const total of [undefined, null, {}]) { + it(`preserves distinct equal-usage records with cumulative ${JSON.stringify(total)}`, async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-missing-total-')) + try { + const path = join(dir, 'rollout-synthetic.jsonl') + const lines = [ + { type: 'session_meta', timestamp: '2026-09-01T10:00:00Z', payload: { session_id: 's', model: 'gpt-5.3-codex' } }, + ...[1, 2, 3].map(n => ({ type: 'event_msg', timestamp: `2026-09-01T10:00:0${n}Z`, payload: { + type: 'token_count', info: { last_token_usage: { input_tokens: 100, output_tokens: 20 }, total_token_usage: total }, + } })), + ] + await writeFile(path, lines.map(line => JSON.stringify(line)).join('\n') + '\n') + const parser = createCodexProvider(dir).createSessionParser({ path, project: 'test', provider: 'codex' }, new Set()) + const calls = [] + for await (const call of parser.parse()) calls.push(call) + expect(calls).toHaveLength(3) + expect(calls.reduce((sum, call) => sum + call.outputTokens, 0)).toBe(60) + expect(new Set(calls.map(call => call.deduplicationKey)).size).toBe(3) + } finally { await rm(dir, { recursive: true, force: true }) } + }) +}