From 1643aa9ea14f84b34df654131e2a88543d7c4c01 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:51:13 +0530 Subject: [PATCH] fix(parser): keep in-range calls of a midnight-straddling turn instead of dropping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both parseProviderSources (Codex/OTel/network providers) and scanProjectDirs (Claude Code, the highest-traffic provider) range-filtered a whole turn by its first call's timestamp. A turn spanning local midnight — common for long-running autonomous sessions — had every call after the boundary silently dropped from the new day: `codeburn today` reported no usage until the turn ended, and daily history attributed the whole turn's cost to the turn-start day. Slice each turn to only its in-range calls instead of dropping it wholesale, and re-anchor `timestamp` to the first surviving call so day-aggregator's turn-anchored day bucketing (which keys off `timestamp`, not per-call timestamps) attributes the retained calls to the day they actually occurred, not the pre-slice turn's original day. - parseProviderSources: turnSlicedToRange slices a CachedTurn's raw calls, applied in both the servedSources and durableSources loops (both had the identical bug). - scanProjectDirs: classifiedTurnSlicedToRange slices a ClassifiedTurn's assistantCalls post-classification. category/subCategory/retries/hasEdits stay exactly as classified from the complete turn (turn-level judgments, not per-call sums) — only assistantCalls and timestamp are trimmed. The PR-linked subagent fold-anchor mechanism (a parent whose own turns are fully out of range still folds an in-range child with zero parent spend) is untouched: it depends on carriedBranch/spawnPrSets built from the FULL pre-slice turn list, and a slice only ever produces a non-empty assistantCalls when the turn genuinely has in-range calls, so it can't turn a real anchor into a spend-bearing turn or vice versa. - Both slicers share a small `callsInRange` predicate; the two composite objects (CachedTurn vs ClassifiedTurn) are built separately since their shapes genuinely differ. Fixes #852. Repro, root-cause diagnosis, and the initial fix shape (filter calls inside the turn rather than dropping it, applied to both provider loops) are from the issue reporter (KENSHI601), verified against the current parser and extended to the Claude Code path and the day-aggregator timestamp reconciliation. --- src/parser.ts | 94 ++++++--- .../parser-claude-turn-midnight-range.test.ts | 193 ++++++++++++++++++ tests/parser-turn-midnight-range.test.ts | 183 +++++++++++++++++ 3 files changed, 444 insertions(+), 26 deletions(-) create mode 100644 tests/parser-claude-turn-midnight-range.test.ts create mode 100644 tests/parser-turn-midnight-range.test.ts diff --git a/src/parser.ts b/src/parser.ts index ee165e3a..aab730e6 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2200,12 +2200,12 @@ async function scanProjectDirs( const spawnPrSets = cachedFile.prLinks?.length ? buildSpawnPrSets(cachedFile.turns) : {} if (dateRange) { - classifiedTurns = classifiedTurns.filter(turn => { - if (turn.assistantCalls.length === 0) return false - const firstCallTs = turn.assistantCalls[0]!.timestamp - if (!firstCallTs) return false - const ts = new Date(firstCallTs) - return ts >= dateRange.start && ts <= dateRange.end + // Slice rather than drop: a turn spanning local midnight would otherwise + // lose every call that lands in the requested day (issue #852). Only + // `assistantCalls`/`timestamp` are touched — see classifiedTurnSlicedToRange. + classifiedTurns = classifiedTurns.flatMap(turn => { + const sliced = classifiedTurnSlicedToRange(turn, dateRange) + return sliced ? [sliced] : [] }) } @@ -2786,6 +2786,48 @@ export function createScanProgress(label: string, total: number) { } } +// Shared by both turn-range slicers below: which of a turn's calls actually +// fall inside dateRange. Returns null when none do (turn should be dropped +// entirely, not kept with an empty call list). +function callsInRange(calls: T[], dateRange: DateRange): T[] | null { + const inRange = calls.filter(c => { + const ts = new Date(c.timestamp) + return !Number.isNaN(ts.getTime()) && ts >= dateRange.start && ts <= dateRange.end + }) + return inRange.length > 0 ? inRange : null +} + +// A turn can span local midnight (e.g. a long-running autonomous Codex +// session): dropping the whole turn because its FIRST call falls outside +// dateRange would discard every later call that lands in the requested day. +// Instead, keep only the calls actually inside the range so each day gets the +// calls that belong to it. Returns null when no call in the turn is in range. +// `timestamp` is re-anchored to the first surviving call so day-aggregator's +// turn-anchored bucketing (which keys off `timestamp`, not per-call +// timestamps) buckets the slice under the day its retained calls actually +// fall in, rather than the pre-slice turn's original (possibly prior-day) +// start. +function turnSlicedToRange(turn: CachedTurn, dateRange: DateRange): CachedTurn | null { + const inRangeCalls = callsInRange(turn.calls, dateRange) + if (!inRangeCalls) return null + if (inRangeCalls.length === turn.calls.length) return turn + return { ...turn, calls: inRangeCalls, timestamp: inRangeCalls[0]!.timestamp } +} + +// Same slice, applied post-classification (scanProjectDirs classifies every +// turn from its FULL call list up front, before date filtering — see the +// carriedBranch/carriedPrRefs comments in scanProjectDirs — so this only +// trims `assistantCalls` and re-anchors `timestamp`; `category`/`subCategory`/ +// `retries`/`hasEdits` stay exactly as classified from the complete turn. +// Those are turn-level judgments about the whole exchange, not a per-call +// sum, so they aren't recomputed from the partial call list. +function classifiedTurnSlicedToRange(turn: ClassifiedTurn, dateRange: DateRange): ClassifiedTurn | null { + const inRangeCalls = callsInRange(turn.assistantCalls, dateRange) + if (!inRangeCalls) return null + if (inRangeCalls.length === turn.assistantCalls.length) return turn + return { ...turn, assistantCalls: inRangeCalls, timestamp: inRangeCalls[0]!.timestamp } +} + async function parseProviderSources( providerName: string, sources: SessionSource[], @@ -2993,24 +3035,24 @@ async function parseProviderSources( for (const c of turn.calls) seenKeys.add(c.deduplicationKey) + let slicedTurn = turn if (dateRange) { - const callTs = turn.calls[0]?.timestamp - if (!callTs) continue - const ts = new Date(callTs) - if (ts < dateRange.start || ts > dateRange.end) continue + const sliced = turnSlicedToRange(turn, dateRange) + if (!sliced) continue + slicedTurn = sliced } - const classified = cachedTurnToClassified(turn) - const project = turn.calls[0]?.project ?? source.project + const classified = cachedTurnToClassified(slicedTurn) + const project = slicedTurn.calls[0]?.project ?? source.project const key = `${providerName}:${turn.sessionId}:${project}` const existing = sessionMap.get(key) if (existing) { existing.turns.push(classified) - if (!existing.projectPath && turn.calls[0]?.projectPath) { - existing.projectPath = turn.calls[0]!.projectPath + if (!existing.projectPath && slicedTurn.calls[0]?.projectPath) { + existing.projectPath = slicedTurn.calls[0]!.projectPath } - if (!existing.workingDirectory && turn.calls[0]?.workingDirectory) existing.workingDirectory = turn.calls[0].workingDirectory + if (!existing.workingDirectory && slicedTurn.calls[0]?.workingDirectory) existing.workingDirectory = slicedTurn.calls[0].workingDirectory if (cachedFile.prLinks?.length) { const links = (existing.prLinks ??= new Set()) for (const link of cachedFile.prLinks) links.add(link) @@ -3019,8 +3061,8 @@ async function parseProviderSources( } else { sessionMap.set(key, { project, - projectPath: turn.calls[0]?.projectPath, - workingDirectory: turn.calls[0]?.workingDirectory, + projectPath: slicedTurn.calls[0]?.projectPath, + workingDirectory: slicedTurn.calls[0]?.workingDirectory, turns: [classified], ...(cachedFile.prLinks?.length ? { prLinks: new Set(cachedFile.prLinks) } : {}), ...(cachedFile.title ? { title: cachedFile.title } : {}), @@ -3042,25 +3084,25 @@ async function parseProviderSources( for (const c of turn.calls) seenKeys.add(c.deduplicationKey) + let slicedTurn = turn if (dateRange) { - const callTs = turn.calls[0]?.timestamp - if (!callTs) continue - const ts = new Date(callTs) - if (ts < dateRange.start || ts > dateRange.end) continue + const sliced = turnSlicedToRange(turn, dateRange) + if (!sliced) continue + slicedTurn = sliced } - const classified = cachedTurnToClassified(turn) - const project = turn.calls[0]?.project ?? providerName + const classified = cachedTurnToClassified(slicedTurn) + const project = slicedTurn.calls[0]?.project ?? providerName const key = `${providerName}:${turn.sessionId}:${project}` const existingEntry = sessionMap.get(key) if (existingEntry) { existingEntry.turns.push(classified) - if (!existingEntry.projectPath && turn.calls[0]?.projectPath) { - existingEntry.projectPath = turn.calls[0]!.projectPath + if (!existingEntry.projectPath && slicedTurn.calls[0]?.projectPath) { + existingEntry.projectPath = slicedTurn.calls[0]!.projectPath } } else { - sessionMap.set(key, { project, projectPath: turn.calls[0]?.projectPath, workingDirectory: turn.calls[0]?.workingDirectory, turns: [classified] }) + sessionMap.set(key, { project, projectPath: slicedTurn.calls[0]?.projectPath, workingDirectory: slicedTurn.calls[0]?.workingDirectory, turns: [classified] }) } } } diff --git a/tests/parser-claude-turn-midnight-range.test.ts b/tests/parser-claude-turn-midnight-range.test.ts new file mode 100644 index 00000000..b6082686 --- /dev/null +++ b/tests/parser-claude-turn-midnight-range.test.ts @@ -0,0 +1,193 @@ +// Regression for issue #852, Claude Code path: scanProjectDirs range-filtered +// a whole turn by `turn.assistantCalls[0].timestamp`, the same defect fixed +// in parseProviderSources for Codex/OTel providers. Claude Code is the +// highest-traffic provider, so a turn spanning local midnight had the same +// data-loss symptom: `codeburn today` showed nothing until the turn ended. +// +// Deterministic across machine timezones: the plain slicing tests use a +// fixed UTC boundary as the dateRange split (no local-timezone resolution); +// the day-aggregator reconciliation test explicitly sets TZ + fakes the +// clock to reproduce the issue's UTC+8 scenario. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { parseAllSessions, clearSessionCache } from '../src/parser.js' +import { loadPricing } from '../src/models.js' +import { aggregateByPr, prLinkedTotals } from '../src/sessions-report.js' +import { aggregateProjectsIntoDays } from '../src/day-aggregator.js' +import { getDateRange } from '../src/cli-date.js' + +let tmpDir: string +let configDir: string + +const BOUNDARY = new Date('2026-07-28T16:00:00.000Z') +const DAY1_RANGE = { start: new Date('2026-07-27T16:00:00.000Z'), end: new Date(BOUNDARY.getTime() - 1) } +const DAY2_RANGE = { start: BOUNDARY, end: new Date('2026-07-29T15:59:59.999Z') } +const OUT_OF_RANGE = { start: new Date('2026-08-01T00:00:00.000Z'), end: new Date('2026-08-02T00:00:00.000Z') } + +beforeEach(async () => { + clearSessionCache() + tmpDir = await mkdtemp(join(tmpdir(), 'claude-midnight-')) + configDir = join(tmpDir, 'claude') + process.env['CLAUDE_CONFIG_DIR'] = configDir + process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache') +}) + +afterEach(async () => { + clearSessionCache() + delete process.env['CLAUDE_CONFIG_DIR'] + delete process.env['CODEBURN_CACHE_DIR'] + await rm(tmpDir, { recursive: true, force: true }) +}) + +// One user message, two assistant calls straddling BOUNDARY — the Claude +// Code equivalent of the Codex synthetic repro (one turn, two calls). +async function writeMidnightTurn(): Promise { + const projDir = join(configDir, 'projects', 'midnight-proj') + await mkdir(projDir, { recursive: true }) + const SID = '22222222-2222-4222-8222-222222222222' + await writeFile(join(projDir, `${SID}.jsonl`), + JSON.stringify({ type: 'user', sessionId: SID, timestamp: '2026-07-28T15:57:00.000Z', cwd: '/tmp/midnight-proj', message: { role: 'user', content: 'work through midnight' } }) + '\n' + + JSON.stringify({ type: 'assistant', sessionId: SID, timestamp: '2026-07-28T15:58:00.000Z', cwd: '/tmp/midnight-proj', message: { id: 'm1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [], usage: { input_tokens: 1000, output_tokens: 200 } } }) + '\n' + + JSON.stringify({ type: 'assistant', sessionId: SID, timestamp: '2026-07-28T16:10:00.000Z', cwd: '/tmp/midnight-proj', message: { id: 'm2', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [], usage: { input_tokens: 2000, output_tokens: 400 } } }) + '\n') +} + +async function writeSameDayTurn(): Promise { + const projDir = join(configDir, 'projects', 'sameday-proj') + await mkdir(projDir, { recursive: true }) + const SID = '33333333-3333-4333-8333-333333333333' + await writeFile(join(projDir, `${SID}.jsonl`), + JSON.stringify({ type: 'user', sessionId: SID, timestamp: '2026-07-27T20:01:00.000Z', cwd: '/tmp/sameday-proj', message: { role: 'user', content: 'same day work' } }) + '\n' + + JSON.stringify({ type: 'assistant', sessionId: SID, timestamp: '2026-07-27T20:02:00.000Z', cwd: '/tmp/sameday-proj', message: { id: 's1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [], usage: { input_tokens: 100, output_tokens: 20 } } }) + '\n' + + JSON.stringify({ type: 'assistant', sessionId: SID, timestamp: '2026-07-27T20:05:00.000Z', cwd: '/tmp/sameday-proj', message: { id: 's2', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [], usage: { input_tokens: 200, output_tokens: 40 } } }) + '\n') +} + +function callCount(projects: Awaited>): number { + let n = 0 + for (const p of projects) for (const s of p.sessions) for (const t of s.turns) n += t.assistantCalls.length + return n +} + +describe('Claude Code turn-range filter across a day boundary (issue #852)', () => { + it('attributes post-midnight calls to the new day instead of dropping the turn', async () => { + await loadPricing() + await writeMidnightTurn() + + const day2Projects = await parseAllSessions(DAY2_RANGE, 'claude') + expect(callCount(day2Projects)).toBe(1) + const day2Sessions = day2Projects.flatMap(p => p.sessions) + expect(day2Sessions).toHaveLength(1) + expect(day2Sessions[0]!.turns[0]!.assistantCalls[0]!.usage.inputTokens).toBe(2000) + + clearSessionCache() + const day1Projects = await parseAllSessions(DAY1_RANGE, 'claude') + expect(callCount(day1Projects)).toBe(1) + const day1Sessions = day1Projects.flatMap(p => p.sessions) + expect(day1Sessions).toHaveLength(1) + expect(day1Sessions[0]!.turns[0]!.assistantCalls[0]!.usage.inputTokens).toBe(1000) + }) + + it('leaves a same-day turn unchanged', async () => { + await loadPricing() + await writeSameDayTurn() + + const projects = await parseAllSessions(DAY1_RANGE, 'claude') + expect(callCount(projects)).toBe(2) + const sessions = projects.flatMap(p => p.sessions) + expect(sessions).toHaveLength(1) + expect(sessions[0]!.turns).toHaveLength(1) + expect(sessions[0]!.turns[0]!.assistantCalls).toHaveLength(2) + }) + + it('still excludes a turn entirely outside the requested range', async () => { + await loadPricing() + await writeMidnightTurn() + + const projects = await parseAllSessions(OUT_OF_RANGE, 'claude') + expect(callCount(projects)).toBe(0) + expect(projects.flatMap(p => p.sessions)).toHaveLength(0) + }) + + it('does not double-count calls in the unfiltered (monthly/total) aggregate', async () => { + await loadPricing() + await writeMidnightTurn() + await writeSameDayTurn() + + const totalProjects = await parseAllSessions(undefined, 'claude') + expect(callCount(totalProjects)).toBe(4) + }) + + it('reconciles with the real UTC+8 "today" range used by getDateRange/day-aggregator', async () => { + process.env['TZ'] = 'Asia/Shanghai' + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-28T16:30:00.000Z')) // local 2026-07-29 00:30 + try { + await loadPricing() + await writeMidnightTurn() + + const { range: todayRange } = getDateRange('today') + const projects = await parseAllSessions(todayRange, 'claude') + const days = aggregateProjectsIntoDays(projects) + const today = days.find(d => d.date === '2026-07-29') + expect(today).toBeDefined() + expect(today!.calls).toBe(1) + } finally { + vi.useRealTimers() + delete process.env['TZ'] + } + }) +}) + +describe('subagent fold anchor is unaffected by turn slicing (issue #852)', () => { + const CWD = '/tmp/anchor-proj' + const PR = 'https://github.com/o/r/pull/1' + const PARENT = '11111111-1111-4111-8111-111111111111' + const AGENT = 'a1234567890abcdef' + const SPAWN = 'toolu_spawn_anchor' + + async function writeAnchorTranscripts(): Promise { + const projDir = join(configDir, 'projects', 'anchor-proj') + const subDir = join(projDir, PARENT, 'subagents') + await mkdir(subDir, { recursive: true }) + + // Parent's own turn is entirely BEFORE DAY2_RANGE (not straddling it) — + // out of range in full, same as before this fix. + await writeFile(join(projDir, `${PARENT}.jsonl`), + JSON.stringify({ type: 'user', sessionId: PARENT, timestamp: '2026-07-27T23:00:00.000Z', cwd: CWD, message: { role: 'user', content: 'ship the PR and launch a reviewer' } }) + '\n' + + JSON.stringify({ type: 'assistant', sessionId: PARENT, timestamp: '2026-07-27T23:00:01.000Z', cwd: CWD, message: { id: 'm1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [{ type: 'tool_use', id: SPAWN, name: 'Agent', input: {} }], usage: { input_tokens: 10, output_tokens: 5 } } }) + '\n' + + JSON.stringify({ type: 'pr-link', sessionId: PARENT, timestamp: '2026-07-27T23:00:02.000Z', cwd: CWD, prUrl: PR }) + '\n' + + JSON.stringify({ type: 'user', sessionId: PARENT, timestamp: '2026-07-27T23:05:00.000Z', cwd: CWD, message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: SPAWN, content: 'reviewer done' }] }, toolUseResult: { status: 'completed', agentId: AGENT, content: 'reviewer done' } }) + '\n') + + // Child's work is inside DAY2_RANGE (post-boundary). + await writeFile(join(subDir, `agent-${AGENT}.jsonl`), + JSON.stringify({ type: 'user', isSidechain: true, sessionId: PARENT, agentId: AGENT, timestamp: '2026-07-28T16:10:00.000Z', cwd: CWD, message: { role: 'user', content: 'review this' } }) + '\n' + + JSON.stringify({ type: 'assistant', isSidechain: true, sessionId: PARENT, agentId: AGENT, timestamp: '2026-07-28T16:10:05.000Z', cwd: CWD, message: { id: 'c1', type: 'message', role: 'assistant', model: 'claude-opus-4-8', content: [], usage: { input_tokens: 1000, output_tokens: 500 } } }) + '\n') + } + + it('still folds an in-range child into its parent PR with zero parent spend when the parent turn is fully out of range', async () => { + await loadPricing() + await writeAnchorTranscripts() + + const projects = await parseAllSessions(DAY2_RANGE, 'claude') + + const childPresent = projects.some(p => p.sessions.some(s => s.sessionId === `agent-${AGENT}`)) + expect(childPresent).toBe(true) + const anchorInSessions = projects.some(p => p.sessions.some(s => s.sessionId === PARENT)) + expect(anchorInSessions).toBe(false) + const anchorHeldSeparately = projects.some(p => (p.subagentAnchors ?? []).some(s => s.sessionId === PARENT)) + expect(anchorHeldSeparately).toBe(true) + + const rows = aggregateByPr(projects) + const row = rows.find(r => r.url === PR) + expect(row).toBeDefined() + expect(row!.cost).toBeGreaterThan(0) + expect(row!.models).toContain('Opus 4.8') + + const totals = prLinkedTotals(projects) + expect(totals.subagentSessions).toBe(1) + expect(totals.attributedCost).toBeCloseTo(row!.cost, 6) + }) +}) diff --git a/tests/parser-turn-midnight-range.test.ts b/tests/parser-turn-midnight-range.test.ts new file mode 100644 index 00000000..aef43bb0 --- /dev/null +++ b/tests/parser-turn-midnight-range.test.ts @@ -0,0 +1,183 @@ +// Regression for issue #852: parseProviderSources range-filtered a whole turn +// by `turn.calls[0]?.timestamp`. A turn that spans local midnight (common for +// long-running autonomous Codex sessions) had every call after the boundary +// dropped from the new day, so `codeburn today` showed no usage until the +// turn ended and daily history attributed the whole turn's cost to the +// turn-start day. +// +// The repro is timezone-sensitive in the original report (reporter used +// UTC+8, whose local midnight is 16:00 UTC). To keep this test deterministic +// regardless of the machine's local zone, the "day boundary" here is just a +// fixed UTC instant used directly as the dateRange split — no local-timezone +// resolution is involved. + +import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest' +import { mkdir, rm, writeFile } from 'fs/promises' +import { join } from 'path' + +import { loadPricing } from '../src/models.js' +import { aggregateProjectsIntoDays } from '../src/day-aggregator.js' +import { getDateRange } from '../src/cli-date.js' + +const testRoot = vi.hoisted(() => { + const root = `${process.env['TMPDIR'] || '/tmp'}/codex-midnight-repro-${process.pid}-${Date.now()}` + process.env['HOME'] = `${root}/home` + process.env['USERPROFILE'] = `${root}/home` + process.env['CODEX_HOME'] = `${root}/codex` + return root +}) + +const CODEX_HOME = join(testRoot, 'codex') +const CACHE_DIR = join(testRoot, 'cache') + +// A fixed "day boundary" instant, standing in for local midnight. Everything +// below the boundary is "day 1", everything at/after is "day 2" — expressed +// purely in UTC so the test never depends on the machine's TZ. +const BOUNDARY = new Date('2026-07-28T16:00:00.000Z') +const DAY1_RANGE = { start: new Date('2026-07-27T16:00:00.000Z'), end: new Date(BOUNDARY.getTime() - 1) } +const DAY2_RANGE = { start: BOUNDARY, end: new Date('2026-07-29T15:59:59.999Z') } +const OUT_OF_RANGE = { start: new Date('2026-08-01T00:00:00.000Z'), end: new Date('2026-08-02T00:00:00.000Z') } + +beforeEach(async () => { + process.env['HOME'] = join(testRoot, 'home') + process.env['USERPROFILE'] = join(testRoot, 'home') + process.env['CODEX_HOME'] = CODEX_HOME + process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR + // Each test writes its own fixture(s) fresh — wipe any session files and + // disk cache left by a previous test in this file so ranges don't pick up + // unrelated fixtures. + await rm(join(CODEX_HOME, 'sessions'), { recursive: true, force: true }) + await rm(CACHE_DIR, { recursive: true, force: true }) +}) + +afterAll(async () => { + await rm(testRoot, { recursive: true, force: true }) +}) + +// Single turn (one user message, no second message) with two token_count +// calls straddling BOUNDARY — mirrors the issue's synthetic rollout. +async function writeMidnightTurn(): Promise { + const sessionDir = join(CODEX_HOME, 'sessions', '2026', '07', '28') + await mkdir(sessionDir, { recursive: true }) + await mkdir(CACHE_DIR, { recursive: true }) + const lines = [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-07-28T15:55:00.000Z', payload: { session_id: 'midnight-turn', originator: 'codex_cli_rs', cwd: '/tmp/t', model: 'gpt-5.5' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-07-28T15:57:00.000Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'work through midnight' }] } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-28T15:58:00.000Z', payload: { type: 'token_count', info: { total_token_usage: { input_tokens: 100000, cached_input_tokens: 50000, cache_write_input_tokens: 0, output_tokens: 2000, reasoning_output_tokens: 0, total_tokens: 102000 }, last_token_usage: { input_tokens: 100000, cached_input_tokens: 50000, cache_write_input_tokens: 0, output_tokens: 2000, reasoning_output_tokens: 0, total_tokens: 102000 } } } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-28T16:10:00.000Z', payload: { type: 'token_count', info: { total_token_usage: { input_tokens: 200000, cached_input_tokens: 100000, cache_write_input_tokens: 0, output_tokens: 4000, reasoning_output_tokens: 0, total_tokens: 204000 }, last_token_usage: { input_tokens: 100000, cached_input_tokens: 50000, cache_write_input_tokens: 0, output_tokens: 2000, reasoning_output_tokens: 0, total_tokens: 102000 } } } }), + ] + await writeFile(join(sessionDir, 'rollout-midnight.jsonl'), lines.join('\n') + '\n') +} + +// Same-day control: an identical two-call turn, both calls safely before +// BOUNDARY. Must behave exactly as before the fix (unchanged). +async function writeSameDayTurn(): Promise { + const sessionDir = join(CODEX_HOME, 'sessions', '2026', '07', '27') + await mkdir(sessionDir, { recursive: true }) + await mkdir(CACHE_DIR, { recursive: true }) + const lines = [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-07-27T20:00:00.000Z', payload: { session_id: 'sameday-turn', originator: 'codex_cli_rs', cwd: '/tmp/t', model: 'gpt-5.5' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-07-27T20:01:00.000Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'same day work' }] } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-27T20:02:00.000Z', payload: { type: 'token_count', info: { total_token_usage: { input_tokens: 10000, cached_input_tokens: 0, cache_write_input_tokens: 0, output_tokens: 200, reasoning_output_tokens: 0, total_tokens: 10200 }, last_token_usage: { input_tokens: 10000, cached_input_tokens: 0, cache_write_input_tokens: 0, output_tokens: 200, reasoning_output_tokens: 0, total_tokens: 10200 } } } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-27T20:05:00.000Z', payload: { type: 'token_count', info: { total_token_usage: { input_tokens: 20000, cached_input_tokens: 0, cache_write_input_tokens: 0, output_tokens: 400, reasoning_output_tokens: 0, total_tokens: 20400 }, last_token_usage: { input_tokens: 10000, cached_input_tokens: 0, cache_write_input_tokens: 0, output_tokens: 200, reasoning_output_tokens: 0, total_tokens: 10200 } } } }), + ] + await writeFile(join(sessionDir, 'rollout-sameday.jsonl'), lines.join('\n') + '\n') +} + +function callCount(projects: Awaited>): number { + let n = 0 + for (const p of projects) for (const s of p.sessions) for (const t of s.turns) n += t.assistantCalls.length + return n +} + +describe('turn-range filter across a day boundary (issue #852)', () => { + it('attributes post-midnight calls to the new day instead of dropping the turn', async () => { + const { clearSessionCache, parseAllSessions } = await import('../src/parser.js') + clearSessionCache() + await loadPricing() + await writeMidnightTurn() + + const day2Projects = await parseAllSessions(DAY2_RANGE, 'codex') + expect(callCount(day2Projects)).toBe(1) + const day2Sessions = day2Projects.flatMap(p => p.sessions) + expect(day2Sessions).toHaveLength(1) + // Only the post-boundary call's usage counts toward day 2. + expect(day2Sessions[0]!.turns[0]!.assistantCalls[0]!.usage.inputTokens).toBe(50000) + + clearSessionCache() + const day1Projects = await parseAllSessions(DAY1_RANGE, 'codex') + expect(callCount(day1Projects)).toBe(1) + const day1Sessions = day1Projects.flatMap(p => p.sessions) + expect(day1Sessions).toHaveLength(1) + expect(day1Sessions[0]!.turns[0]!.assistantCalls[0]!.usage.inputTokens).toBe(50000) + }) + + it('leaves a same-day turn unchanged', async () => { + const { clearSessionCache, parseAllSessions } = await import('../src/parser.js') + clearSessionCache() + await loadPricing() + await writeSameDayTurn() + + const projects = await parseAllSessions(DAY1_RANGE, 'codex') + expect(callCount(projects)).toBe(2) + const sessions = projects.flatMap(p => p.sessions) + expect(sessions).toHaveLength(1) + expect(sessions[0]!.turns).toHaveLength(1) + expect(sessions[0]!.turns[0]!.assistantCalls).toHaveLength(2) + }) + + it('still excludes a turn entirely outside the requested range', async () => { + const { clearSessionCache, parseAllSessions } = await import('../src/parser.js') + clearSessionCache() + await loadPricing() + await writeMidnightTurn() + + const projects = await parseAllSessions(OUT_OF_RANGE, 'codex') + expect(callCount(projects)).toBe(0) + expect(projects.flatMap(p => p.sessions)).toHaveLength(0) + }) + + it('does not double-count calls in the unfiltered (monthly/total) aggregate', async () => { + const { clearSessionCache, parseAllSessions } = await import('../src/parser.js') + clearSessionCache() + await loadPricing() + await writeMidnightTurn() + await writeSameDayTurn() + + const totalProjects = await parseAllSessions(undefined, 'codex') + // 2 calls from the midnight turn + 2 calls from the same-day turn = 4, + // each counted exactly once — no split-then-duplicate across days. + expect(callCount(totalProjects)).toBe(4) + }) + + // End-to-end version of the issue's actual complaint ("codeburn today shows + // No usage data found"): `aggregateProjectsIntoDays` buckets a turn by its + // `timestamp` field, not per-call timestamps (turn-anchored bucketing, see + // day-aggregator.ts). A sliced turn must have `timestamp` re-anchored to its + // surviving calls, or the retained post-midnight call still gets bucketed + // under the pre-slice (prior-day) date and vanishes from `today`'s history + // even though parseAllSessions now returns it. + it('reconciles with the real UTC+8 "today" range used by getDateRange/day-aggregator', async () => { + process.env['TZ'] = 'Asia/Shanghai' + vi.useFakeTimers() + // Local 2026-07-29 00:30 (UTC+8) == 2026-07-28T16:30:00Z — mid-turn, after + // the second (post-midnight) call. + vi.setSystemTime(new Date('2026-07-28T16:30:00.000Z')) + try { + const { clearSessionCache, parseAllSessions } = await import('../src/parser.js') + clearSessionCache() + await loadPricing() + await writeMidnightTurn() + + const { range: todayRange } = getDateRange('today') + const projects = await parseAllSessions(todayRange, 'codex') + const days = aggregateProjectsIntoDays(projects) + const today = days.find(d => d.date === '2026-07-29') + expect(today).toBeDefined() + expect(today!.calls).toBe(1) + } finally { + vi.useRealTimers() + delete process.env['TZ'] + } + }) +})