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'] + } + }) +})