diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts index 5563de61..cdac6a20 100644 --- a/src/day-aggregator.ts +++ b/src/day-aggregator.ts @@ -75,14 +75,25 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr for (const turn of session.turns) { if (turn.assistantCalls.length === 0) continue - // Turn-anchored bucketing: attribute the WHOLE turn — every one of its - // calls — to the day of the turn's user-message timestamp, matching the - // live headline/report rollup (main.ts daily). Falls back to the first - // assistant-call timestamp when the user line is missing (continuation - // sessions that begin mid-conversation). Previously the calls were - // bucketed per-call by each call's own timestamp, so a midnight- - // straddling turn split across two days and history.daily / the provider - // breakdown never reconciled to current.cost (a constant offset). + // Two bucketing rules, deliberately different per level: + // - Turn-level judgments (category, editTurns, oneShotTurns) stay + // anchored to the turn's day (its timestamp — the user-message time, + // or the re-anchored first surviving call when the parser sliced + // the turn to a range, and falling back to the first assistant call + // when the user line is missing). They describe the whole exchange, + // not a per-call sum, so a sliced straddling turn reports them on + // each side's anchor day — summed across days they inflate, which + // is the accepted, documented semantics (see review on #852). + // - Call-derived values (cost/savings/calls/tokens and the model, + // project, and provider-slice rollups built from them) bucket under + // EACH CALL's own local day (the per-call loop below). The parser + // slices straddling turns per range (issue #852), so every parse + // only holds in-range calls and per-call bucketing keeps day-N + + // day-N+1 equal to the whole range — and history.daily reconciled + // to the headline built from the same days. (Before the parser + // sliced per call, per-call bucketing here was what caused the + // constant offset against the whole-turn headline; the slice is + // what makes it exact now.) const turnDate = dateKey(turn.timestamp || turn.assistantCalls[0]!.timestamp) const turnDay = ensure(turnDate) @@ -140,21 +151,26 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr for (const call of turn.assistantCalls) { const callSavings = call.savingsUSD ?? 0 + // Call-derived values bucket under the call's OWN day (see the + // two-rule comment above). An unparseable call timestamp falls back + // to the turn's anchor day rather than producing a garbage date key. + const callDate = Number.isNaN(new Date(call.timestamp).getTime()) ? turnDate : dateKey(call.timestamp) + const callDay = ensure(callDate) - turnDay.cost += call.costUSD - turnDay.savingsUSD += callSavings - turnDay.calls += 1 - turnDay.inputTokens += call.usage.inputTokens - turnDay.outputTokens += call.usage.outputTokens - turnDay.cacheReadTokens += call.usage.cacheReadInputTokens - turnDay.cacheWriteTokens += call.usage.cacheCreationInputTokens + callDay.cost += call.costUSD + callDay.savingsUSD += callSavings + callDay.calls += 1 + callDay.inputTokens += call.usage.inputTokens + callDay.outputTokens += call.usage.outputTokens + callDay.cacheReadTokens += call.usage.cacheReadInputTokens + callDay.cacheWriteTokens += call.usage.cacheCreationInputTokens - const dayProject = ensureProject(turnDay, session.project, project.projectPath) + const dayProject = ensureProject(callDay, session.project, project.projectPath) dayProject.cost += call.costUSD dayProject.calls += 1 dayProject.savingsUSD += callSavings - const model = turnDay.models[call.model] ?? { + const model = callDay.models[call.model] ?? { calls: 0, cost: 0, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, @@ -166,9 +182,9 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr model.outputTokens += call.usage.outputTokens model.cacheReadTokens += call.usage.cacheReadInputTokens model.cacheWriteTokens += call.usage.cacheCreationInputTokens - turnDay.models[call.model] = model + callDay.models[call.model] = model - const slice = ensureSlice(turnDay, call.provider) + const slice = ensureSlice(callDay, call.provider) slice.calls += 1 slice.cost += call.costUSD slice.savingsUSD += callSavings diff --git a/src/main.ts b/src/main.ts index ae6867cf..649e1657 100644 --- a/src/main.ts +++ b/src/main.ts @@ -497,9 +497,17 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: if (turn.retries === 0) dailyMap[day].oneShotTurns += 1 } for (const call of turn.assistantCalls) { - dailyMap[day].cost += call.costUSD - dailyMap[day].savings += call.savingsUSD ?? 0 - dailyMap[day].calls += 1 + // Cost/savings/calls bucket under each call's OWN day — the same + // per-call rule as the durable day set (day-aggregator.ts), so this + // fallback and durable.days never diverge on a midnight-straddling + // turn (issue #852). Turn counts/edit stats stay anchored on the + // turn's day above. An unparseable call timestamp falls back to the + // turn's day rather than producing a garbage date key. + const callDay = Number.isNaN(new Date(call.timestamp).getTime()) ? day : dateKey(call.timestamp) + if (!dailyMap[callDay]) { dailyMap[callDay] = { cost: 0, savings: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } } + dailyMap[callDay].cost += call.costUSD + dailyMap[callDay].savings += call.savingsUSD ?? 0 + dailyMap[callDay].calls += 1 } } } diff --git a/src/parser.ts b/src/parser.ts index ee165e3a..2b4f3f79 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -27,6 +27,7 @@ import { saveCache, } from './session-cache.js' import { acquireCacheRefreshLock, type RefreshLockHandle } from './cache-refresh-lock.js' +import { dateKey } from './day-aggregator.js' import type { ParsedProviderCall, SessionSource } from './providers/types.js' import type { ApiUsageIteration, @@ -2200,12 +2201,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 +2787,59 @@ export function createScanProgress(label: string, total: number) { } } +// Shared by the turn-range slicers below: which of a turn's calls actually +// fall inside dateRange. Returns null when none do (the 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 discards every later call that lands in the requested day (issue +// #852). Instead, keep only the calls actually inside the range. `timestamp` +// is re-anchored to the first surviving call so downstream turn-anchored +// bucketing (session day, report rollups) keys the slice under the day its +// retained calls actually fall in, not the pre-slice turn's original +// (possibly prior-day) start. Returns null when no call is in range. +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 } +} + +// Day-set variant of classifiedTurnSlicedToRange for the menubar/history day +// selection: keep only the calls whose own local day is selected and +// re-anchor `timestamp` to the first survivor — the same split rule. +function classifiedTurnSlicedToDays(turn: ClassifiedTurn, days: Set): ClassifiedTurn | null { + const inRangeCalls = turn.assistantCalls.filter(c => { + const ts = new Date(c.timestamp) + return !Number.isNaN(ts.getTime()) && days.has(dateKey(c.timestamp)) + }) + if (inRangeCalls.length === 0) 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 +3047,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 +3073,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 +3096,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] }) } } } @@ -3153,14 +3207,6 @@ export function filterProjectsByName( return result } -function turnIsInDateRange(turn: ClassifiedTurn, dateRange: DateRange): boolean { - 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 -} - function turnDayString(turn: ClassifiedTurn): string | null { if (turn.assistantCalls.length === 0) return null const ts = turn.assistantCalls[0]!.timestamp @@ -3289,9 +3335,13 @@ export function filterProjectsByDays(projects: ProjectSummary[], days: Set() for (const session of project.sessions) { - const turns = session.turns.filter(turn => { - const ds = turnDayString(turn) - return ds !== null && days.has(ds) + // Slice turns per call by the selected days (not whole-turn keep/drop): + // a midnight-straddling turn contributes the calls that actually + // happened on each selected day (issue #852, same split rule as the + // range slicers — see classifiedTurnSlicedToDays). + const turns = session.turns.flatMap(turn => { + const sliced = classifiedTurnSlicedToDays(turn, days) + return sliced ? [sliced] : [] }) if (turns.length === 0) { if (isSpawnParent(session)) anchors.push(session) @@ -3498,7 +3548,13 @@ export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange: const anchors: SessionSummary[] = [...(project.subagentAnchors ?? [])] const survivingIdentities = new Set() for (const session of project.sessions) { - const turns = session.turns.filter(turn => turnIsInDateRange(turn, dateRange)) + // Slice turns per call (not whole-turn keep/drop) so a midnight- + // straddling turn keeps the calls that landed inside the range — the + // same split rule as the parse-time slicers (issue #852). + const turns = session.turns.flatMap(turn => { + const sliced = classifiedTurnSlicedToRange(turn, dateRange) + return sliced ? [sliced] : [] + }) if (turns.length === 0) { if (isSpawnParent(session)) anchors.push(session) continue diff --git a/tests/cli-durable-totals.test.ts b/tests/cli-durable-totals.test.ts index 82f1e44c..8a19b655 100644 --- a/tests/cli-durable-totals.test.ts +++ b/tests/cli-durable-totals.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdir, rm, writeFile } from 'fs/promises' import { existsSync } from 'fs' import { tmpdir } from 'os' @@ -13,7 +13,7 @@ import { buildPeriodData, getDailyCacheConfigHash, } from '../src/usage-aggregator.js' -import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js' +import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, filterProjectsByDays, clearSessionCache } from '../src/parser.js' import { renderOverview } from '../src/overview.js' import type { DateRange } from '../src/types.js' @@ -28,7 +28,7 @@ import type { DateRange } from '../src/types.js' // queries, and in the plain live regime with no carried days at all. const ROOT = join(tmpdir(), `codeburn-durable-totals-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) -const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME'] as const +const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME', 'USERPROFILE', 'KIMI_CODE_HOME', 'CODEBURN_DESKTOP_SESSIONS_DIR'] as const let savedEnv: Record const CARRIED_COST = 100 @@ -120,11 +120,22 @@ beforeEach(async () => { savedEnv = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]])) await mkdir(join(ROOT, 'home', '.claude'), { recursive: true }) await mkdir(join(ROOT, 'cache'), { recursive: true }) + await mkdir(join(ROOT, 'no-desktop-sessions'), { recursive: true }) + await mkdir(join(ROOT, 'no-kimi-home'), { recursive: true }) process.env['HOME'] = join(ROOT, 'home') process.env['CODEBURN_CACHE_DIR'] = join(ROOT, 'cache') process.env['CLAUDE_CONFIG_DIR'] = join(ROOT, 'home', '.claude') delete process.env['CLAUDE_CONFIG_DIRS'] delete process.env['CODEX_HOME'] + // Keep real provider data on the machine out of every parse: absolute-count + // assertions are meaningless when the host's own sessions leak in. + // USERPROFILE matters on Windows, where os.homedir() ignores HOME; + // KIMI_CODE_HOME / the desktop-sessions override redirect the two env-aware + // discovery roots. (The codex provider captures its home at import time, so + // it is redirected separately in vi.hoisted below.) + process.env['USERPROFILE'] = join(ROOT, 'home') + process.env['KIMI_CODE_HOME'] = join(ROOT, 'no-kimi-home') + process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(ROOT, 'no-desktop-sessions') clearSessionCache() }) @@ -259,3 +270,156 @@ describe('terminal overview carried-day footnote', () => { expect(noCarried).not.toContain('preserved from expired session logs') }) }) + +// Issue #852 review: per-call slicing is only conservation-correct when day +// bucketing attributes call-derived values to each call's own day. These +// tests pin the straddling-turn case the review reproduced end-to-end through +// buildDurablePeriod: a turn starting the previous day at 23:57 with one call +// before and one after local midnight must keep BOTH calls across a multi-day +// period (cache ≤ yesterday + live today union), each on its own day. +// +// The codex provider captures CODEX_HOME when its module is first imported, +// so the redirect must happen before module evaluation (vi.hoisted) rather +// than in beforeEach. The captured dir is per-test-process and empty except +// for the fixture written below, which also shields the suite from any real +// ~/.codex on the machine running it. +const CODEX_ROOT = vi.hoisted(() => { + const root = `${process.env['TMPDIR'] || '/tmp'}/codeburn-straddle-codex-${process.pid}-${Date.now()}` + process.env['CODEX_HOME'] = `${root}/codex` + return root +}) + +describe('midnight-straddling turn conservation (issue #852)', () => { + // Day N = 2026-07-27, day N+1 ("today") = 2026-07-28 — LOCAL dates built + // from constructor args so the case is machine-TZ independent (dateKey / + // toDateString use the same local getters). + const NOW = new Date(2026, 6, 28, 12, 0, 0) + const DAY_N = '2026-07-27' + const DAY_N1 = '2026-07-28' + + function fakeNow(): void { + // Date only: the daily-cache lock and retry helpers must keep real timers. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) + } + + beforeEach(async () => { + await rm(CODEX_ROOT, { recursive: true, force: true }) + }) + + afterEach(async () => { + await rm(CODEX_ROOT, { recursive: true, force: true }) + }) + + // One codex turn (the issue's provider) with a token_count call on each + // side of local midnight: 23:58 (input 1000/output 200), 00:10 (2000/400). + async function seedStraddlingCodexTurn(): Promise { + const sessionDir = join(CODEX_ROOT, 'codex', 'sessions', '2026', '07', '27') + await mkdir(sessionDir, { recursive: true }) + const line = (obj: unknown): string => JSON.stringify(obj) + await writeFile(join(sessionDir, 'rollout-straddle.jsonl'), [ + line({ type: 'session_meta', timestamp: new Date(2026, 6, 27, 23, 55, 0).toISOString(), payload: { session_id: 'sess-straddle', model: 'gpt-5.5', cwd: '/tmp/straddle-proj', originator: 'codex_cli_rs' } }), + line({ type: 'response_item', timestamp: new Date(2026, 6, 27, 23, 57, 0).toISOString(), payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'work through midnight' }] } }), + line({ type: 'event_msg', timestamp: new Date(2026, 6, 27, 23, 58, 0).toISOString(), payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 1000, output_tokens: 200 }, total_token_usage: { total_tokens: 1200 } } } }), + line({ type: 'event_msg', timestamp: new Date(2026, 6, 28, 0, 10, 0).toISOString(), payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 2000, output_tokens: 400 }, total_token_usage: { total_tokens: 3600 } } } }), + ].join('\n') + '\n', 'utf-8') + } + + // The same straddle through the Claude Code path (scanProjectDirs). + async function seedStraddlingClaudeTurn(): Promise { + const projectDir = join(ROOT, 'home', '.claude', 'projects', 'straddle-proj') + await mkdir(projectDir, { recursive: true }) + const line = (obj: unknown): string => JSON.stringify(obj) + await writeFile(join(projectDir, 's-straddle.jsonl'), [ + line({ type: 'user', sessionId: 's-straddle', timestamp: new Date(2026, 6, 27, 23, 57, 0).toISOString(), cwd: '/tmp/straddle-proj', message: { role: 'user', content: 'work through midnight' } }), + line({ type: 'assistant', sessionId: 's-straddle', timestamp: new Date(2026, 6, 27, 23, 58, 0).toISOString(), cwd: '/tmp/straddle-proj', message: { id: 'm1', type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', content: [], usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } }), + line({ type: 'assistant', sessionId: 's-straddle', timestamp: new Date(2026, 6, 28, 0, 10, 0).toISOString(), cwd: '/tmp/straddle-proj', message: { id: 'm2', type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', content: [], usage: { input_tokens: 2000, output_tokens: 400, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } }), + ].join('\n') + '\n', 'utf-8') + } + + // The two calls' exact costs from an unfiltered parse (pricing-agnostic truth). + async function truthCosts(provider: string): Promise<[number, number]> { + clearSessionCache() + const projects = await parseAllSessions(undefined, provider) + const calls = projects.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls) + expect(calls).toHaveLength(2) + return [calls[0]!.costUSD, calls[1]!.costUSD] + } + + it('keeps day-N + day-N+1 equal to the whole-range totals through buildDurablePeriod', async () => { + fakeNow() + try { + await seedStraddlingCodexTurn() + const [costN, costN1] = await truthCosts('codex') + expect(costN + costN1).toBeGreaterThan(0) + + const range: DateRange = { start: new Date(2026, 6, 27, 0, 0, 0), end: new Date() } + clearSessionCache() + const durable = await buildDurablePeriod({ range, label: '2d' }, { provider: 'all' }) + + const dayN = durable.days.find(d => d.date === DAY_N) + const dayN1 = durable.days.find(d => d.date === DAY_N1) + // Each side of the turn lands on its own day... + expect(dayN?.calls).toBe(1) + expect(dayN1?.calls).toBe(1) + expect(dayN!.cost).toBeCloseTo(costN, 8) + expect(dayN1!.cost).toBeCloseTo(costN1, 8) + // ...and the two sides conserve the whole-range totals (the review's + // week/month leak returned 1 call and only the pre-midnight cost). + expect(durable.data.calls).toBe(2) + expect(dayN!.calls + dayN1!.calls).toBe(durable.data.calls) + expect(durable.data.cost).toBeCloseTo(costN + costN1, 8) + expect(dayN!.cost + dayN1!.cost).toBeCloseTo(durable.data.cost, 8) + expect(durable.data.inputTokens).toBe(3000) + expect(durable.data.outputTokens).toBe(600) + expect(dayN!.inputTokens + dayN1!.inputTokens).toBe(durable.data.inputTokens) + expect(dayN!.outputTokens + dayN1!.outputTokens).toBe(durable.data.outputTokens) + } finally { + vi.useRealTimers() + } + }, 60_000) + + it('shows the post-midnight call in the today-only view on the Claude Code path', async () => { + fakeNow() + try { + await seedStraddlingClaudeTurn() + const [, costN1] = await truthCosts('claude') + + clearSessionCache() + const durable = await buildDurablePeriod({ range: getDateRange('today').range, label: 'today' }, { provider: 'all' }) + expect(durable.data.calls).toBe(1) + expect(durable.data.cost).toBeCloseTo(costN1, 8) + expect(durable.data.inputTokens).toBe(2000) + expect(durable.data.outputTokens).toBe(400) + } finally { + vi.useRealTimers() + } + }, 60_000) + + it('slices the straddling turn per call in the dashboard/menubar surface filters', async () => { + fakeNow() + try { + await seedStraddlingClaudeTurn() + clearSessionCache() + const all = await parseAllSessions(undefined, 'claude') + const callsOf = (ps: typeof all) => ps.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls) + const inputOf = (ps: typeof all) => ps.flatMap(p => p.sessions).reduce((s, sess) => s + sess.totalInputTokens, 0) + + // Dashboard Today/7-Days narrowing over an unfiltered parse. + const dashToday = filterProjectsByDateRange(all, getDateRange('today').range) + expect(callsOf(dashToday)).toHaveLength(1) + expect(inputOf(dashToday)).toBe(2000) + + // Menubar/history day selection, on each side of midnight. + const menubarToday = filterProjectsByDays(all, new Set([DAY_N1])) + expect(callsOf(menubarToday)).toHaveLength(1) + expect(inputOf(menubarToday)).toBe(2000) + + const menubarYesterday = filterProjectsByDays(all, new Set([DAY_N])) + expect(callsOf(menubarYesterday)).toHaveLength(1) + expect(inputOf(menubarYesterday)).toBe(1000) + } finally { + vi.useRealTimers() + } + }, 60_000) +}) diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts index 24b9c163..7d70d14b 100644 --- a/tests/day-aggregator.test.ts +++ b/tests/day-aggregator.test.ts @@ -74,10 +74,12 @@ function makeSingleTurnProject( } describe('aggregateProjectsIntoDays', () => { - it('buckets a whole turn (all its calls) on the turn user-message date', () => { - // Turn-anchored bucketing: a turn whose calls straddle midnight lands wholly - // on the day of its user-message timestamp — matching the live headline/ - // report rollup — instead of splitting per-call across two days. + it("buckets call-derived values under each call's own date when a turn straddles midnight", () => { + // Per-call bucketing (issue #852): a turn whose calls straddle midnight + // puts each call's cost/calls/tokens on the day the call happened, so + // day-N + day-N+1 reconcile with a range parse that sliced the turn at + // the same boundary. Turn-level judgments (editTurns, category turns) + // stay anchored on the turn's day. const projects: ProjectSummary[] = [ makeProject({ sessions: [{ @@ -116,9 +118,17 @@ describe('aggregateProjectsIntoDays', () => { ] const days = aggregateProjectsIntoDays(projects) - expect(days.map(d => d.date)).toEqual(['2026-04-09']) - expect(days[0]!.cost).toBe(10) - expect(days[0]!.calls).toBe(2) + expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10']) + expect(days[0]!.cost).toBe(4) + expect(days[0]!.calls).toBe(1) + expect(days[1]!.cost).toBe(6) + expect(days[1]!.calls).toBe(1) + // Turn-level stats anchor on the turn's day only — they describe the + // whole exchange, not a per-call sum. + expect(days[0]!.editTurns).toBe(1) + expect(days[1]!.editTurns).toBe(0) + expect(days[0]!.categories['coding']?.turns).toBe(1) + expect(days[1]!.categories['coding']).toBeUndefined() }) it('attributes category turns + editTurns + oneShotTurns to the first call date of the turn', () => { @@ -406,17 +416,17 @@ describe('buildPeriodDataFromDays', () => { expect(pd.models).toEqual([]) }) - it('attributes a midnight-straddling turn to the user-message date, matching the live report', () => { - // A turn whose user message sits on one side of midnight and whose assistant - // response lands on the other must bucket by the USER-MESSAGE timestamp, so - // the daily cache (history.daily + provider breakdown) reconciles exactly to - // the live headline/report rollup (main.ts daily), which anchors on the same - // turn timestamp. The prior per-call bucketing split such turns and left a - // constant offset between the trend bars and current.cost. + it("attributes a midnight-straddling turn's cost to the call's own date", () => { + // A turn whose user message sits on one side of midnight and whose + // assistant response lands on the other buckets its cost under the CALL's + // day (issue #852's per-call rule), so the daily cache (history.daily + + // provider breakdown) reconciles exactly to a range parse that slices the + // same turn at the same boundary — and day-N + day-N+1 sum to the period + // total with nothing lost on either side. const userTs = '2026-04-20T23:58:00Z' const assistantTs = '2026-04-21T00:30:00Z' - const userLocal = new Date(userTs) - const expectedDate = `${userLocal.getFullYear()}-${String(userLocal.getMonth() + 1).padStart(2, '0')}-${String(userLocal.getDate()).padStart(2, '0')}` + const assistantLocal = new Date(assistantTs) + const expectedDate = `${assistantLocal.getFullYear()}-${String(assistantLocal.getMonth() + 1).padStart(2, '0')}-${String(assistantLocal.getDate()).padStart(2, '0')}` const projects: ProjectSummary[] = [ makeProject({ @@ -457,21 +467,22 @@ describe('daily-cache ↔ report daily-bucket parity', () => { // headline (main.ts daily rollup) must bucket days by the SAME rule, or their // per-day totals drift and their period sums diverge from current.cost at // window boundaries — the V1 audit's constant -$3.45/-81-calls finding. Both - // are now TURN-anchored: this asserts per-day equality against a reference - // that mirrors main.ts:486-499 (turn.timestamp anchor), plus the invariant - // history.daily Σ == report.daily Σ == total call cost. - - // Mirrors the live report/headline daily rollup in src/main.ts (bucket the - // whole turn — all its calls — on the turn's user-message date). + // are now PER-CALL for cost/savings/calls (issue #852) with turn-level stats + // still turn-anchored: this asserts per-day equality against a reference + // that mirrors main.ts buildJsonReport's dailyMap fallback (each call on its + // own date), plus the invariant history.daily Σ == report.daily Σ == total + // call cost. + + // Mirrors the live report/headline daily rollup fallback in src/main.ts + // (cost/savings/calls bucket under each call's own date). function reportDailyByDate(projects: ProjectSummary[]): Record { const byDate: Record = {} for (const p of projects) { for (const sess of p.sessions) { for (const turn of sess.turns) { - if (turn.assistantCalls.length === 0) continue - const ts = turn.timestamp || turn.assistantCalls[0]!.timestamp - const day = dateKey(ts) - for (const call of turn.assistantCalls) byDate[day] = (byDate[day] ?? 0) + call.costUSD + for (const call of turn.assistantCalls) { + byDate[dateKey(call.timestamp)] = (byDate[dateKey(call.timestamp)] ?? 0) + call.costUSD + } } } } @@ -492,8 +503,8 @@ describe('daily-cache ↔ report daily-bucket parity', () => { expect(dayA).not.toBe(dayB) // sanity: the fixture really straddles local midnight // A midnight-straddling turn (calls on both days) plus a same-day turn, so - // per-CALL bucketing would produce DIFFERENT per-day totals than the turn- - // anchored report — the case the old code got wrong. + // whole-TURN anchoring would produce DIFFERENT per-day totals than the + // per-call rule — the case the old code got wrong. const projects: ProjectSummary[] = [ makeProject({ sessions: [{ @@ -541,8 +552,10 @@ describe('daily-cache ↔ report daily-bucket parity', () => { const totalCallCost = 2 + 3 + 7 expect(historySum).toBeCloseTo(totalCallCost, 10) expect(reportSum).toBeCloseTo(totalCallCost, 10) - // Day A owns the WHOLE straddling turn (2+3=5), not just its first call (2). - expect(historyByDate[dayA]).toBe(5) - expect(historyByDate[dayB]).toBe(7) + // Day A owns only the straddling turn's pre-midnight call (2); day B owns + // the post-midnight call plus the same-day turn (3+7=10). Both paths agree + // per day and the period total is conserved. + expect(historyByDate[dayA]).toBe(2) + expect(historyByDate[dayB]).toBe(10) }) }) diff --git a/tests/provider-turn-grouping.test.ts b/tests/provider-turn-grouping.test.ts index bee7585d..71da3e2b 100644 --- a/tests/provider-turn-grouping.test.ts +++ b/tests/provider-turn-grouping.test.ts @@ -197,3 +197,46 @@ describe('provider turn grouping', () => { } }) }) + +describe('provider turn range filtering', () => { + it('keeps the in-range calls of a codex turn that spans midnight instead of dropping the whole turn', async () => { + // Regression test for #852: the range filter keyed on the turn's FIRST + // call timestamp, so a long autonomous turn starting 23:59 the previous + // day was excluded from the next day's view entirely, losing every + // post-midnight call. One turn (t1) here has two token_count events + // straddling midnight; only the post-midnight call may survive. + const codexHome = join(home, 'codex') + const sessionDir = join(codexHome, 'sessions', '2026', '05', '15') + await mkdir(sessionDir, { recursive: true }) + const lines = [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-05-15T23:55:00Z', payload: { session_id: 'sess-span', model: 'gpt-5.5', cwd: '/Users/test/project-a', originator: 'codex_cli_rs' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-05-15T23:57:00Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'run the long task' }] } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-05-15T23:58:00Z', payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command: 'npm test' }) } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-05-15T23:59:00Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 100, output_tokens: 30 }, total_token_usage: { total_tokens: 130 } } } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-05-16T00:10:00Z', payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command: 'npm run build' }) } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-05-16T00:15:00Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 80, output_tokens: 20 }, total_token_usage: { total_tokens: 230 } } } }), + ] + await writeFile(join(sessionDir, 'rollout-span.jsonl'), lines.join('\n') + '\n') + + process.env['CODEX_HOME'] = codexHome + try { + const parseAllSessions = await loadParser() + const projects = await parseAllSessions(dayRange(), 'codex') + const session = projects[0]!.sessions[0]! + const turn = session.turns[0]! + + expect(session.turns).toHaveLength(1) + expect(turn.assistantCalls.map(call => new Date(call.timestamp).toISOString())).toEqual([ + '2026-05-16T00:15:00.000Z', + ]) + // The slice re-anchors the turn's timestamp from the user-message time + // (2026-05-15T23:57Z) to the first surviving call, so turn-anchored + // bucketing lands the slice on the day its calls actually fall in. + expect(new Date(turn.timestamp).toISOString()).toBe('2026-05-16T00:15:00.000Z') + expect(session.totalInputTokens).toBe(80) + expect(session.totalOutputTokens).toBe(20) + } finally { + delete process.env['CODEX_HOME'] + } + }) +})