fix(parser): keep both halves of a midnight-straddling turn, and stop --provider leaking claude

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 <other>` 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.
This commit is contained in:
ozymandiashh 2026-08-05 12:53:42 +03:00
parent c49fa23590
commit 556b59d7d8
9 changed files with 874 additions and 119 deletions

View file

@ -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

View file

@ -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

View file

@ -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
}
}
}

View file

@ -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<T extends { timestamp: string }>(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<string>): 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<strin
const anchors: SessionSummary[] = [...(project.subagentAnchors ?? [])]
const survivingIdentities = new Set<string>()
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<string>()
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 <other> 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[] = []

View file

@ -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<string, string | undefined>
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<void> {
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<void> {
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)
})

View file

@ -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<ProjectSummary[]> => {
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

View file

@ -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.
// 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 in src/main.ts (bucket the
// whole turn — all its calls — on the turn's user-message date).
// 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<string, number> {
const byDate: Record<string, number> = {}
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)
})
})

View file

@ -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 <other> 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<ReturnType<typeof parseAllSessions>>): Set<string> =>
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<string> {
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())
})
})

View file

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