mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-23 15:34:19 +00:00
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:
parent
c49fa23590
commit
556b59d7d8
9 changed files with 874 additions and 119 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue