From 556b59d7d85c3d42140adceb42ba0fb3cf1be54c Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:53:42 +0300 Subject: [PATCH] fix(parser): keep both halves of a midnight-straddling turn, and stop --provider leaking claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports upstream fixes for two defects this branch never received. **A turn that spans local midnight was filtered as a unit.** The range and day filter keyed on the turn's FIRST call, so every later call that landed in the requested day was discarded along with it. A long autonomous Codex run, or Claude work that crossed midnight, made `codeburn today` under-report until the turn ended, and multi-day totals attributed the whole turn to its start day. Range and day filters now slice inside the turn: only the calls inside the requested window survive, and the turn's timestamp re-anchors to its first surviving call so turn-anchored rollups — category, editTurns, oneShotTurns, the daily cache — land on the day the retained calls actually happened. Cost, calls, savings and tokens bucket under each call's own local day, so day N plus day N+1 conserves the whole-range total. A sliced turn is still classified from its FULL call list, because category, hasEdits and retries describe the whole exchange rather than the surviving slice — matching the Claude path. **The inverse leak hit provider-filtered runs.** `--provider ` still entered the claude scan, whose orphan pass read the entire cached claude section, treated every cached PR-bearing transcript as no-longer-discovered and re-injected it. By Project and By Model listed Anthropic spend under `--provider cursor` while the headline showed cursor alone. The scan is now guarded by an explicit in-scope check — deliberately not a directory-count check, so when claude IS in scope but every transcript has been pruned, PR-attributed orphans still survive. **The daily cache is bumped to 17**, because leaving it at 15 would double-count the post-midnight half of a straddling turn for an upgrading user. A v15 rollup finalized by the pre-fix binary holds the WHOLE turn on its start day, and the new slicing then also puts the post-midnight call on the next day — the same cost twice, in a cache whose ten-year retention never ages it out. The bump mints a fresh filename; adoption marks the merged result incomplete, so the next hydration re-derives every day whose sources survive under per-call bucketing and carries forward only what it cannot re-derive. 16 is deliberately skipped: main already spent it on the codex structural-discovery fix (eece4cf), so claiming 16 here would load a main-built v16 cache — which holds only the codex fix — as current and complete and the invalidation would never fire. Blast radius: daily-history rows, the JSON daily fallback and range-query session totals change shape for straddling days, as call-derived values move to the call's own day — the intended correction, asserted by this commit's tests. The session-cache FORMAT is unchanged. One caveat worth stating rather than leaving to be found. The multi-day all-provider By Activity rollup still derives today's slice from the unsliced range parse, so a straddling turn's category cost stays anchored on its start day and categories can sum below the headline on that surface; upstream has a follow-up for it. --- packages/cli/src/daily-cache.ts | 43 +++- packages/cli/src/day-aggregator.ts | 59 +++-- packages/cli/src/main.ts | 14 +- packages/cli/src/parser.ts | 243 ++++++++++++++---- packages/cli/tests/cli-durable-totals.test.ts | 209 ++++++++++++++- .../tests/daily-cache-carry-forward.test.ts | 60 ++++- packages/cli/tests/day-aggregator.test.ts | 75 +++--- packages/cli/tests/parser.test.ts | 164 ++++++++++++ .../cli/tests/provider-turn-grouping.test.ts | 128 +++++++++ 9 files changed, 875 insertions(+), 120 deletions(-) diff --git a/packages/cli/src/daily-cache.ts b/packages/cli/src/daily-cache.ts index c5439c34..6e2a0eb9 100644 --- a/packages/cli/src/daily-cache.ts +++ b/packages/cli/src/daily-cache.ts @@ -5,7 +5,31 @@ import { homedir } from 'os' import { join } from 'path' import type { DateRange, ProjectSummary } from './types.js' -// Bumped to 15: per-project daily rollups. Days and provider slices now carry +// Bumped to 17: range and day filters now slice a midnight-straddling turn +// instead of filtering it as a unit (issue #852), so call-derived values +// (cost, calls, savings, tokens) bucket under each call's own local day. A +// v15 rollup finalized by the pre-fix binary holds the WHOLE turn on its +// start day, and the new slicing then also puts the post-midnight half on +// the next day — the same cost twice. Nothing downstream can notice on its +// own: `usage-aggregator` serves every day before today from this cache, and +// retention is ten years, so an upgrading user would keep the stale +// whole-turn day N forever while day N+1 grew the sliced half. Raising +// MIN_SUPPORTED_VERSION forces the one-time re-derivation: the new version +// mints a fresh filename, adoption marks the result incomplete, and the next +// hydration rebuilds every day within the retention window whose sources +// survive under per-call bucketing — the re-derive parses the FULL retention +// window, not the 365-day product backfill, so an old-but-still-sourced day +// gets corrected too (days whose sources are gone are carried forward). +// +// v16 is SKIPPED: main already spent it on the codex structural-discovery +// fix (eece4cf, #873/#626), which raised these same two constants to 16 with +// a DIFFERENT meaning. Claiming 16 here too would make this binary load a +// main-built v16 cache — which holds only the codex fix — as current and +// complete, so the straddle re-derivation would never fire for anyone who +// ever ran a main build. The next bump must take the next free number, not +// the last one main used. +// +// v15: per-project daily rollups. Days and provider slices now carry // a `projects` breakdown (cost/calls/savings/sessions per project) so project // history outlives the session files, like models and categories already do. // This bump is the first to ride the v14 carry-forward: the old cache is @@ -57,8 +81,8 @@ import type { DateRange, ProjectSummary } from './types.js' // that older binaries skipped. v8 added local-model savings to the daily // rollup; the `savingsConfigHash` field is invalidated separately when the // user changes their `localModelSavings` mapping. -export const DAILY_CACHE_VERSION = 15 -const MIN_SUPPORTED_VERSION = 15 +export const DAILY_CACHE_VERSION = 17 +const MIN_SUPPORTED_VERSION = 17 // Version-suffixed so different binaries each own a distinct file and never // clobber an incompatible schema. Bumping the version mints a fresh filename; // adoptOlderDailyCaches then unions days out of every previous file (including @@ -691,10 +715,17 @@ export async function ensureCacheHydrated( const tzChanged = c.tzKey !== undefined && c.tzKey !== tzKey if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true || tzChanged) { const baseline = c.days - const backfillStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS) + // Re-derive the WHOLE retention window, not just the 365-day product + // backfill (BACKFILL_DAYS): these triggers invalidate ALL cached days, and + // a day older than the backfill whose sources still survive must be + // corrected too — otherwise the v17 straddle double-count (or a stale + // savings/tz bucketing) lingers on it for the rest of retention. The cost + // is bounded by the surviving session files, and the path only runs on + // the rare invalidations, never on the daily gap parse. + const rederiveStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - DAILY_CACHE_RETENTION_DAYS) let freshDays: DailyEntry[] = [] - if (backfillStart.getTime() <= yesterdayEnd.getTime()) { - freshDays = aggregateDays(await parseSessions({ start: backfillStart, end: yesterdayEnd })) + if (rederiveStart.getTime() <= yesterdayEnd.getTime()) { + freshDays = aggregateDays(await parseSessions({ start: rederiveStart, end: yesterdayEnd })) } const parseWasComplete = sessionComplete() // A PARTIAL parse must not overwrite finalized baseline days with diff --git a/packages/cli/src/day-aggregator.ts b/packages/cli/src/day-aggregator.ts index 5563de61..e239ac3e 100644 --- a/packages/cli/src/day-aggregator.ts +++ b/packages/cli/src/day-aggregator.ts @@ -75,14 +75,30 @@ 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). + // Unsliced it is the opposite: the judgments stay entirely on the + // turn's start day and never reach the tail, so the tail day emits + // the post-midnight call's cost with zero turn counts (and no + // category entry) — cost without turns, the mirror of the sliced + // case's turns on both sides. + // - 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 +156,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 +187,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/packages/cli/src/main.ts b/packages/cli/src/main.ts index ae6867cf..649e1657 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/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/packages/cli/src/parser.ts b/packages/cli/src/parser.ts index 71d2f599..2bdba25a 100644 --- a/packages/cli/src/parser.ts +++ b/packages/cli/src/parser.ts @@ -50,6 +50,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 { ClassifiedTurn, @@ -821,17 +822,26 @@ async function scanProjectDirs( let carriedPrRefs: string[] | undefined let prRefsAtRangeStart: string[] | undefined let frozePrRefs = !dateRange - let classifiedTurns = cachedFile.turns.map(turn => { + let classifiedTurns = cachedFile.turns.flatMap(turn => { if (turn.gitBranch) carriedBranch = turn.gitBranch - if (dateRange && !frozePrRefs) { - const firstTs = turn.calls[0]?.timestamp - if (firstTs && new Date(firstTs) >= dateRange.start) { - prRefsAtRangeStart = carriedPrRefs - frozePrRefs = true - } + const classified = cachedTurnToClassified(turn, carriedBranch) + // 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. + const sliced = dateRange ? classifiedTurnSlicedToRange(classified, dateRange) : classified + // Freeze the PR set active at range start the moment the first turn that + // CONTRIBUTES in-range calls is reached. Keying the freeze on the + // unsliced first call missed a turn whose TAIL is the range's first + // content (its first call predates the range start): prRefsAtRangeStart + // was never set, and a by-PR report on the range lost the pre-range PR + // row entirely. A contributing turn's own refs are carried AFTER the + // freeze, so the seed is the state entering the range, not the turn's. + if (dateRange && !frozePrRefs && sliced) { + prRefsAtRangeStart = carriedPrRefs + frozePrRefs = true } if (turn.prRefs?.length) carriedPrRefs = turn.prRefs - return cachedTurnToClassified(turn, carriedBranch) + return sliced ? [sliced] : [] }) // Captured from the FULL turn list, before the date slice below can drop the // turn a branch was first seen on. Lets the by-branch report keep this @@ -844,16 +854,6 @@ async function scanProjectDirs( // sessions that both spawned subagents and referenced a PR. 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 - }) - } - // A PR-linked parent that spawned subagents is kept even when its OWN turns all // fall out of range, as a 0-cost fold ANCHOR: an in-range child (an async agent // that outlived the parent's last in-range turn) still needs the parent's @@ -874,6 +874,13 @@ async function scanProjectDirs( if (everHadBranch) session.everHadBranch = true const observedPrLinks = new Set(classifiedTurns.flatMap(turn => turn.prRefs ?? [])) for (const link of cachedFile.prLinks ?? []) observedPrLinks.add(link) + // A reference made before the range is still a reference this session made: + // without it, a range that slices every PR-referencing turn away leaves the + // session with NO prLinks, and the by-PR report gates on prLinks + // (buildPrAttribution) — the session would be skipped and its in-range + // spend would never surface as a By-PR row, even though prRefsAtRangeStart + // holds exactly the PRs to attribute it to. + for (const link of prRefsAtRangeStart ?? []) observedPrLinks.add(link) if (observedPrLinks.size) { session.prLinks = [...observedPrLinks].sort() session.prAttributionSource = cachedFile.prLinks?.length ? 'transcript' : 'explicit-reference' @@ -1426,6 +1433,101 @@ export function createScanProgress(label: string, total: number) { } } +// Shared by the turn-range slicers below: which of a turn's calls are kept for +// dateRange. Returns null when none are (the turn should be dropped entirely, +// not kept with an empty call list). The kept list is the calls that actually +// fall inside dateRange PLUS any call whose timestamp will not parse: such a +// call has no own day, so it cannot be placed in the range, and it rides with +// the turn whenever the turn has an in-range call — the old whole-turn filter +// kept it, and the day aggregators anchor such a call to the turn's day +// (day-aggregator.ts, main.ts), so dropping it here would silently lose its +// cost and disagree with those fallbacks. +function callsInRange(calls: T[], dateRange: DateRange): T[] | null { + const placed = calls.filter(c => { + const ts = new Date(c.timestamp) + return !Number.isNaN(ts.getTime()) && ts >= dateRange.start && ts <= dateRange.end + }) + if (placed.length === 0) return null + const unparseable = calls.filter(c => Number.isNaN(new Date(c.timestamp).getTime())) + if (placed.length + unparseable.length === calls.length) return calls + return [...placed, ...unparseable] +} + +// 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 the calls inside the range — plus any call whose +// timestamp cannot be parsed, which rides with the turn (see callsInRange). +// `timestamp` is re-anchored to the first surviving in-range 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 + // Re-anchor whenever the anchor is wrong, not only when calls were removed: + // when ALL calls survive the filter, an anchor (user-message time) that + // still sits outside the window would keep turn-anchored stats (category, + // editTurns) on the pre-range day while calls/cost land inside it. + const anchorTs = new Date(turn.timestamp) + const anchorInRange = !Number.isNaN(anchorTs.getTime()) && anchorTs >= dateRange.start && anchorTs <= dateRange.end + if (inRangeCalls.length === turn.calls.length && anchorInRange) return turn + // First call that actually placed in the range: when unparseable calls ride + // along, the head of `inRangeCalls` can be one of them, and re-anchoring to + // it would keep a garbage anchor. + const firstInRange = turn.calls.find(c => { + const ts = new Date(c.timestamp) + return !Number.isNaN(ts.getTime()) && ts >= dateRange.start && ts <= dateRange.end + }) + return { ...turn, calls: inRangeCalls, timestamp: firstInRange!.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 + // Same re-anchor rule as turnSlicedToRange: all calls surviving does not + // make a pre-range anchor right — turn-anchored stats would bucket outside + // the window while calls/cost land inside it. + const anchorTs = new Date(turn.timestamp) + const anchorInRange = !Number.isNaN(anchorTs.getTime()) && anchorTs >= dateRange.start && anchorTs <= dateRange.end + if (inRangeCalls.length === turn.assistantCalls.length && anchorInRange) return turn + const firstInRange = turn.assistantCalls.find(c => { + const ts = new Date(c.timestamp) + return !Number.isNaN(ts.getTime()) && ts >= dateRange.start && ts <= dateRange.end + }) + return { ...turn, assistantCalls: inRangeCalls, timestamp: firstInRange!.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. An +// unparseable call timestamp has no own day and rides with the turn when it +// has a call on a selected day, matching callsInRange and the aggregators' +// turn-day fallback. +function classifiedTurnSlicedToDays(turn: ClassifiedTurn, days: Set): ClassifiedTurn | null { + const placed = turn.assistantCalls.filter(c => { + const ts = new Date(c.timestamp) + return !Number.isNaN(ts.getTime()) && days.has(dateKey(c.timestamp)) + }) + if (placed.length === 0) return null + const unparseable = turn.assistantCalls.filter(c => Number.isNaN(new Date(c.timestamp).getTime())) + // Same re-anchor rule as the range slicers: all calls surviving does not + // make an off-day anchor right — the anchor must land on a selected day too, + // or turn-anchored stats bucket off-selection while calls/cost land on it. + const anchorTs = new Date(turn.timestamp) + const anchorOnSelectedDay = !Number.isNaN(anchorTs.getTime()) && days.has(dateKey(turn.timestamp)) + if (placed.length + unparseable.length === turn.assistantCalls.length && anchorOnSelectedDay) return turn + return { ...turn, assistantCalls: [...placed, ...unparseable], timestamp: placed[0]!.timestamp } +} + async function parseProviderSources( providerName: string, sources: SessionSource[], @@ -1636,24 +1738,32 @@ 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 + // Classify the FULL turn, then keep only the in-range calls: category / + // hasEdits / retries are whole-exchange judgments, not per-call sums, so a + // midnight-straddling turn is classified identically to the Claude path + // (scanProjectDirs) rather than being re-derived from a partial slice. + // Cost/calls come from the retained calls, unchanged. + const classifiedFull = cachedTurnToClassified(turn) + const classified = dateRange + ? (classifiedTurnSlicedToRange(classifiedFull, dateRange) ?? classifiedFull) + : classifiedFull + 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) @@ -1662,8 +1772,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 } : {}), @@ -1685,25 +1795,31 @@ 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 + // Classify the FULL turn, then keep only the in-range calls (same rule + // as the loop above and the Claude path): whole-exchange judgments stay + // whole-turn; cost/calls come from the retained calls. + const classifiedFull = cachedTurnToClassified(turn) + const classified = dateRange + ? (classifiedTurnSlicedToRange(classifiedFull, dateRange) ?? classifiedFull) + : classifiedFull + 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] }) } } } @@ -1796,14 +1912,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 @@ -1932,9 +2040,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) @@ -2141,7 +2253,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 @@ -2276,15 +2394,26 @@ async function runParse( ? { id: s.sourceId, label: s.sourceLabel, path: s.sourcePath, kind: s.sourceKind } : undefined, })) + // Claude is scanned through scanProjectDirs rather than parseProviderSources, so + // it needs the same provider-filter guard the durable-orphan loop below applies at + // its own level. Without it a --provider run still enters scanProjectDirs + // with an empty dirs list, and the orphan pass there (which reads the whole cached + // claude section) treats every cached file as "no longer discovered" and re-injects + // it into the result. Note this is deliberately NOT a `claudeDirs.length > 0` check: + // when claude IS in scope but every transcript has been pruned from disk, that + // orphan pass is exactly what keeps PR-attributed spend from vanishing. + const claudeInScope = !providerFilter || providerFilter === 'all' || providerFilter === 'claude' if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'start' }) let claudeProjects: ProjectSummary[] = [] - try { - claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress, readOnly) - if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length }) - } catch (err) { - if (!isPermissionError(err)) throw err - process.stderr.write(`codeburn: skipped claude data (permission denied; grant Full Disk Access to include it)\n`) - emitScanProgress({ kind: 'provider', provider: 'claude', state: 'skipped' }) + if (claudeInScope) { + try { + claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress, readOnly) + if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length }) + } catch (err) { + if (!isPermissionError(err)) throw err + process.stderr.write(`codeburn: skipped claude data (permission denied; grant Full Disk Access to include it)\n`) + emitScanProgress({ kind: 'provider', provider: 'claude', state: 'skipped' }) + } } const otherProjects: ProjectSummary[] = [] diff --git a/packages/cli/tests/cli-durable-totals.test.ts b/packages/cli/tests/cli-durable-totals.test.ts index 27a20b24..0d83b188 100644 --- a/packages/cli/tests/cli-durable-totals.test.ts +++ b/packages/cli/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,8 @@ 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 { buildPrAttribution } from '../src/sessions-report.js' import { renderOverview } from '../src/overview.js' import type { DateRange } from '../src/types.js' @@ -28,7 +29,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 @@ -126,11 +127,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() }) @@ -265,3 +277,194 @@ 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) + + it('keeps range-start PR attribution when a straddling tail is the first in-range content', async () => { + // M3: the PR set active at range start (prRefsAtRangeStart) was frozen on + // the first turn whose UNsliced first call fell in the range. A turn whose + // tail is the range's first content (first call before the start, later + // calls inside) never froze it, so a session whose only in-range spend is + // that tail lost the pre-range PR: the by-PR report emitted NO row. + const PR_A = 'https://github.com/o/r/pull/1' + const projectDir = join(ROOT, 'home', '.claude', 'projects', 'pr-straddle-proj') + await mkdir(projectDir, { recursive: true }) + const line = (obj: unknown): string => JSON.stringify(obj) + await writeFile(join(projectDir, 's-pr-straddle.jsonl'), [ + // Turn 1, fully BEFORE the range: references PR_A, call at 23:52. + line({ type: 'user', sessionId: 's-pr-straddle', timestamp: new Date(2026, 6, 27, 23, 50, 0).toISOString(), cwd: '/tmp/pr-straddle-proj', message: { role: 'user', content: `fix ${PR_A}` } }), + line({ type: 'assistant', sessionId: 's-pr-straddle', timestamp: new Date(2026, 6, 27, 23, 52, 0).toISOString(), cwd: '/tmp/pr-straddle-proj', message: { id: 'm0', type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', content: [], usage: { input_tokens: 500, output_tokens: 100, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } }), + // Turn 2, straddles the range start: user message WITHOUT a PR ref, one + // call before the range (23:59, sliced away) and the tail at 00:10 (in). + line({ type: 'user', sessionId: 's-pr-straddle', timestamp: new Date(2026, 6, 27, 23, 57, 0).toISOString(), cwd: '/tmp/pr-straddle-proj', message: { role: 'user', content: 'work through midnight' } }), + line({ type: 'assistant', sessionId: 's-pr-straddle', timestamp: new Date(2026, 6, 27, 23, 59, 0).toISOString(), cwd: '/tmp/pr-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-pr-straddle', timestamp: new Date(2026, 6, 28, 0, 10, 0).toISOString(), cwd: '/tmp/pr-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') + + clearSessionCache() + const projects = await parseAllSessions({ start: new Date(2026, 6, 28, 0, 0, 0), end: new Date() }, 'claude') + const session = projects.flatMap(p => p.sessions)[0]! + + // Only the tail survives the range. + expect(session.apiCalls).toBe(1) + // The pre-range PR state survived the slice... + expect(session.prRefsAtRangeStart).toEqual([PR_A]) + // ...and the session counts as PR-linked even though its only PR reference + // was sliced away, so the by-PR report emits a real (non-approx) row for it. + const { rows } = buildPrAttribution(projects) + expect(rows).toHaveLength(1) + expect(rows[0]!.url).toBe(PR_A) + expect(rows[0]!.approx).toBe(false) + expect(rows[0]!.cost).toBeGreaterThan(0) + }, 60_000) +}) diff --git a/packages/cli/tests/daily-cache-carry-forward.test.ts b/packages/cli/tests/daily-cache-carry-forward.test.ts index 62197fae..a4f2afbc 100644 --- a/packages/cli/tests/daily-cache-carry-forward.test.ts +++ b/packages/cli/tests/daily-cache-carry-forward.test.ts @@ -4,7 +4,7 @@ import { existsSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import type { ProjectSummary } from '../src/types.js' +import type { ProjectSummary, DateRange } from '../src/types.js' import { buildPeriodDataFromDays } from '../src/day-aggregator.js' import { @@ -394,6 +394,64 @@ describe('never-lose invariant: invalidations with vanished sources', () => { }) }) +describe('re-derive covers the retention window, not just the 365-day backfill', () => { + it('rebuilds a day OLDER than BACKFILL_DAYS whose sources survive', async () => { + // The version-bump re-derive must correct EVERY day within the ten-year + // retention whose sources still exist — not only the newest 365. A 400-day + // old day (beyond the product backfill horizon, well within retention) that + // the parse CAN re-derive must take the fresh value; carrying it forward + // would keep the pre-fix accounting (e.g. the v17 straddle double-count) + // alive for the rest of retention. + const old = daysAgoStr(400) + const cache: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: 'cfg-A', + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + days: [day(old, { claude: slice(230.06, 400) })], + // Simulate the bump: incomplete, so the next hydration re-derives. + complete: false, + } + await saveDailyCache(cache) + + let requestedStart: DateRange['start'] | null = null + const parseSessions = async (range: DateRange): Promise => { + requestedStart = range.start + return [] + } + const out = await ensureCacheHydrated(parseSessions, () => [day(old, { claude: slice(14.0, 400) })], 'cfg-A') + + // The parse window must reach back past the 400-day-old day. Before the + // fix it started 365 days back, so this day was carried with its old value. + expect(requestedStart).not.toBeNull() + expect(requestedStart!.getTime()).toBeLessThanOrEqual(new Date(`${old}T00:00:00`).getTime()) + + // The re-derivable day takes the fresh value and is NOT marked carried. + expect(out.days).toHaveLength(1) + expect(out.days[0]!.providers['claude']!.cost).toBe(14.0) + expect(out.days[0]!.carried).toBeUndefined() + }) + + it('still carries a beyond-backfill day the parse cannot re-derive', async () => { + // The widened window must not change the never-lose invariant: a 400-day + // old day with NO surviving sources is carried forward, not dropped. + const old = daysAgoStr(400) + const cache: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: 'cfg-A', + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + days: [day(old, { claude: slice(230.06, 400) })], + complete: false, + } + await saveDailyCache(cache) + + const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A') + expect(out.days).toHaveLength(1) + expect(out.days[0]).toMatchObject({ date: old, cost: 230.06, carried: true }) + }) +}) + describe('adoption union across older cache files', () => { it('reconstructs the fullest history from every older daily-cache file, .bak included', async () => { // The real machine scenario, miniaturized: a v13 file whose rebuild lost diff --git a/packages/cli/tests/day-aggregator.test.ts b/packages/cli/tests/day-aggregator.test.ts index 24b9c163..7d70d14b 100644 --- a/packages/cli/tests/day-aggregator.test.ts +++ b/packages/cli/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/packages/cli/tests/parser.test.ts b/packages/cli/tests/parser.test.ts index 5de2dadc..6e1ba5a5 100644 --- a/packages/cli/tests/parser.test.ts +++ b/packages/cli/tests/parser.test.ts @@ -15,6 +15,7 @@ import { createRequire } from 'node:module' import { isSqliteAvailable } from '../src/sqlite.js' import { clearSessionCache, parseAllSessions } from '../src/parser.js' import { loadCache, saveCache, sessionCachePath } from '../src/session-cache.js' +import type { DateRange } from '../src/types.js' import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/providers/types.js' // ── Synthetic provider state ─────────────────────────────────────────────── @@ -488,3 +489,166 @@ describe('(g) skill attribution is independent of turn category', () => { expect(session!.skillBreakdown['telemetry-review']?.turns).toBe(1) }) }) + +// ═══════════════════════════════════════════════════════════════════════════ +// (h) Provider filter isolates claude: a --provider run must not +// re-surface cached claude sessions through the orphan pass, while a run +// that DOES include claude still preserves PR-bearing orphans. +// ═══════════════════════════════════════════════════════════════════════════ +describe('(h) provider filter excludes claude from the orphan pass', () => { + const SYNTH_SOURCE = (path: string): SessionSource[] => + [{ path, project: 'synth-proj', provider: 'test-synthetic' }] + + // The provider lives on each parsed call, not on SessionSummary. + const providersOf = (projects: Awaited>): Set => + new Set(projects + .flatMap(p => p.sessions) + .flatMap(s => s.turns) + .flatMap(t => t.assistantCalls) + .map(c => c.provider)) + + const SYNTH_CALL: ParsedProviderCall = { + provider: 'test-synthetic', model: 'gpt-4o', + inputTokens: 10, outputTokens: 5, + cacheCreationInputTokens: 0, cacheReadInputTokens: 0, + cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, + costUSD: 0.25, tools: [], bashCommands: [], + skills: [], + timestamp: '2026-07-18T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'synth-isolation-call', + userMessage: '', sessionId: 'synth-isolation-session', + } + + // A claude transcript carrying a pr-link: `prLinks` is exactly what lets a + // cached entry survive the write-mode orphan gate, so it is the shape that + // leaks. Cost is deliberately far larger than the synthetic call's, so a leak + // is unmistakable rather than a rounding difference. + async function writeClaudeSessionWithPrLink(): Promise { + const projectDir = join(tmpHome, '.claude', 'projects', 'leaky-app') + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, 'session.jsonl') + await writeFile(filePath, [ + JSON.stringify({ + type: 'user', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:00.000Z', + message: { role: 'user', content: 'ship it' }, + }), + JSON.stringify({ + type: 'assistant', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:10.000Z', + message: { + id: 'msg-leak-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'done' }], + usage: { input_tokens: 900_000, output_tokens: 90_000 }, + }, + }), + JSON.stringify({ + type: 'pr-link', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:20.000Z', + prUrl: 'https://github.com/getagentseal/codeburn/pull/1', + }), + ].join('\n') + '\n') + return filePath + } + + it('does not surface cached claude sessions when filtering to another provider', async () => { + const synthFile = join(tmpHome, 'synth-isolation.txt') + await writeFile(synthFile, 'placeholder') + await writeClaudeSessionWithPrLink() + + _synthSources = SYNTH_SOURCE(synthFile) + _synthYields = [SYNTH_CALL] + + // Baseline: what the synthetic provider costs on its own, before anything + // claude-shaped has ever entered the session cache. Self-calibrating, since + // cost is re-derived from tokens by the pricing engine. + const baseline = await parseAllSessions(undefined, 'test-synthetic') + const synthOnlyCost = totalCost(baseline) + expect([...providersOf(baseline)]).toEqual(['test-synthetic']) + clearSessionCache() + + // Warm the session cache so the claude file is persisted WITH its prLinks. + const all = await parseAllSessions(undefined, 'all') + expect(providersOf(all)).toContain('claude') + expect(totalCost(all)).toBeGreaterThan(synthOnlyCost) + + clearSessionCache() + + // Filtering to the synthetic provider must yield ONLY its own spend. Before + // the fix, claudeDirs was empty yet scanProjectDirs still ran, so every + // cached PR-bearing claude file was treated as a pruned orphan and re-added. + const filtered = await parseAllSessions(undefined, 'test-synthetic') + + expect([...providersOf(filtered)]).toEqual(['test-synthetic']) + expect(totalCost(filtered)).toBeCloseTo(synthOnlyCost, 10) + }) + + it('still preserves a PR-bearing claude orphan when claude IS in scope', async () => { + const filePath = await writeClaudeSessionWithPrLink() + _synthSources = [] + _synthYields = [] + + const before = await parseAllSessions(undefined, 'all') + const costBefore = totalCost(before) + expect(costBefore).toBeGreaterThan(0) + + // Every claude transcript disappears from disk. Claude is still in scope, so + // the orphan pass must keep the PR-attributed spend alive — this is the case + // a naive `claudeDirs.length > 0` guard would silently break. + await unlink(filePath) + clearSessionCache() + + const after = await parseAllSessions(undefined, 'all') + expect(totalCost(after)).toBeCloseTo(costBefore, 10) + }) +}) + +describe('(i) range slicing keeps unparseable-timestamp calls riding with the turn', () => { + const SYNTH_SOURCE = (path: string): SessionSource[] => + [{ path, project: 'synth-proj', provider: 'test-synthetic' }] + + it('retains an unparseable call beside an in-range call and re-anchors to the in-range one', async () => { + // Pinned policy, not an accident: a call whose timestamp cannot be parsed + // has no own day, so it cannot be placed in the range — but dropping it + // would silently lose its cost (the day aggregators anchor it to the + // turn's day). It rides with the turn whenever the turn has an in-range + // call, and the slice re-anchors to the first in-range call. + const synthFile = join(tmpHome, 'synth-unparseable.txt') + await writeFile(synthFile, 'placeholder') + + const inRangeTs = new Date() + const range: DateRange = { + start: new Date(inRangeTs.getFullYear(), inRangeTs.getMonth(), inRangeTs.getDate() - 1), + end: inRangeTs, + } + + const base: ParsedProviderCall = { + provider: 'test-synthetic', model: 'gpt-4o', + inputTokens: 10, outputTokens: 5, + cacheCreationInputTokens: 0, cacheReadInputTokens: 0, + cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, + costUSD: 0.25, tools: [], bashCommands: [], + skills: [], + speed: 'standard', + timestamp: '', deduplicationKey: '', + userMessage: 'do the thing', sessionId: 'synth-unparseable-session', turnId: 't1', + } + _synthSources = SYNTH_SOURCE(synthFile) + _synthYields = [ + // Grouped first, so the turn's anchor timestamp is the unparseable one. + { ...base, timestamp: 'not-a-timestamp', deduplicationKey: 'unparseable-call' }, + { ...base, timestamp: inRangeTs.toISOString(), deduplicationKey: 'in-range-call' }, + ] + + const projects = await parseAllSessions(range, 'test-synthetic') + const session = projects.flatMap(p => p.sessions)[0]! + const calls = session.turns.flatMap(t => t.assistantCalls) + + // The unparseable call survives the range filter alongside the in-range one. + expect(calls).toHaveLength(2) + expect(calls.some(c => c.deduplicationKey === 'unparseable-call')).toBe(true) + expect(calls.some(c => c.deduplicationKey === 'in-range-call')).toBe(true) + + // The anchor was the unparseable first call; the slice re-anchors the turn + // to the first call that actually placed in the range. + expect(new Date(session.turns[0]!.timestamp).getTime()).toBe(new Date(inRangeTs.toISOString()).getTime()) + }) +}) diff --git a/packages/cli/tests/provider-turn-grouping.test.ts b/packages/cli/tests/provider-turn-grouping.test.ts index bee7585d..7d21a1af 100644 --- a/packages/cli/tests/provider-turn-grouping.test.ts +++ b/packages/cli/tests/provider-turn-grouping.test.ts @@ -98,6 +98,47 @@ describe('provider turn grouping', () => { expect(session.categoryBreakdown[turn.category].oneShotTurns).toBe(0) }) + it('classifies a range-sliced turn from the whole turn, not the surviving calls (#852)', async () => { + const chatsDir = join(home, '.gemini', 'tmp', 'project-b', 'chats') + await mkdir(chatsDir, { recursive: true }) + await writeFile(join(chatsDir, 'session-slice.json'), JSON.stringify({ + sessionId: 'gemini-slice-1', + startTime: '2026-05-16T10:00:00.000Z', + messages: [ + { id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'read then edit src/parser.ts' }, + { + id: 'g1', timestamp: '2026-05-16T10:00:00.000Z', type: 'gemini', content: 'reading', + model: 'gemini-3.1-pro-preview', tokens: { input: 100, output: 30 }, + toolCalls: [{ id: 't1', name: 'read_file', args: { path: 'src/parser.ts' } }], + }, + { + id: 'g2', timestamp: '2026-05-16T11:00:00.000Z', type: 'gemini', content: 'editing', + model: 'gemini-3.1-pro-preview', tokens: { input: 90, output: 25 }, + toolCalls: [{ id: 't2', name: 'edit_file', args: { path: 'src/parser.ts' } }], + }, + ], + })) + + const parseAllSessions = await loadParser() + // A range that keeps the 10:00 Read call but excludes the 11:00 Edit call, + // so the turn is sliced. `turnSlicedToRange`/`callsInRange` compare absolute + // times, so this is timezone-independent. + const sliceRange: DateRange = { + start: new Date('2026-05-16T10:00:00.000Z'), + end: new Date('2026-05-16T10:30:00.000Z'), + } + const projects = await parseAllSessions(sliceRange, 'gemini') + const turn = projects[0]!.sessions[0]!.turns[0]! + + // Cost/calls are sliced to the range: only the Read call survives. + expect(turn.assistantCalls.map(c => c.deduplicationKey)).toEqual(['gemini:gemini-slice-1:g1']) + // But category/hasEdits are whole-turn judgments — the Edit is part of the + // exchange — so they stay classified from the FULL turn, matching the Claude + // path rather than being re-derived from the partial slice (which alone reads + // as a no-edit exploration turn). + expect(turn.hasEdits).toBe(true) + }) + it('groups Mistral Vibe assistant messages and uses Vibe session_cost when present', async () => { const sessionDir = join(vibeHome, 'logs', 'session', 'session_20260516_100000_vibe') await mkdir(sessionDir, { recursive: true }) @@ -197,3 +238,90 @@ 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'] + } + }) + + it('re-anchors a turn whose calls ALL survive but whose anchor sits before the range', async () => { + // The removal-path test above exercises a turn whose pre-range call is + // dropped. This is the mirror case the first fix missed: a turn whose user + // message predates the range but whose calls ALL land inside it. The early + // return ("all calls survived") kept the pre-range anchor, so turn-anchored + // stats (category, editTurns, session day) bucketed to the OLD day while + // calls/cost landed on the new one. The slice must re-anchor to the first + // in-range call whenever the anchor is wrong, not only when calls were cut. + 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-anchor', 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-16T00:10:00Z', payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command: 'npm test' }) } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-05-16T00:15: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:20:00Z', payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command: 'npm run build' }) } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-05-16T00:25: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-anchor.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]! + + // BOTH calls are inside the range — nothing was removed. + expect(turn.assistantCalls.map(call => new Date(call.timestamp).toISOString())).toEqual([ + '2026-05-16T00:15:00.000Z', + '2026-05-16T00:25:00.000Z', + ]) + // Yet the anchor (user-message time, 2026-05-15T23:57Z) must re-anchor to + // the first in-range call, or turn-anchored bucketing would land the + // turn's stats on the day BEFORE the window while its calls land inside. + expect(new Date(turn.timestamp).toISOString()).toBe('2026-05-16T00:15:00.000Z') + expect(session.totalInputTokens).toBe(180) + expect(session.totalOutputTokens).toBe(50) + } finally { + delete process.env['CODEX_HOME'] + } + }) +})