Merge pull request #864 from GodCC6/fix/project-filter-durable-headline
Some checks failed
CI / semgrep (push) Has been cancelled

fix: --project/--exclude are ignored by the durable headline totals
This commit is contained in:
Resham Joshi 2026-08-01 16:19:03 -07:00 committed by GitHub
commit 2de4d100bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 538 additions and 5 deletions

View file

@ -9,6 +9,7 @@
- **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo)
### Fixed (CLI)
- **`--project` / `--exclude` now apply to the headline totals, not just the detail panels.** The durable headline unions the carry-forward daily cache with today's live parse, and the cached days were sliced to the requested provider but never to the requested project — so the Overview panel counted excluded projects while By Project / By Activity / By Model (built from the name-filtered parse) left them out, and the two could not be reconciled. Cost, calls, sessions and savings are now sliced out of the per-project day stats the cache has carried since v15. Tokens, models and categories have no per-project split in the cache, so under a project filter they come from the (project-filtered) live parse instead; cached days — or provider slices — carried from before v15 have no project split at all, so they cannot be attributed to a filtered project, and the terminal overview now states how much was set aside rather than folding it into the total. (#864)
- **Codex parser corrections**: fork-replay no longer double-counts `patch_apply_end` and `mcp_tool_call_end`; `exec` is normalized to Bash; `custom_tool_call` events are handled; token_count lines larger than 32 KiB now parse exact token counts instead of estimating. Codex session cache bumps from v7 to v8 for a one-time re-parse. Only tool attribution changes for ordinary sessions, leaving their cost identical; sessions that logged an oversized token_count line are repriced from exact counts instead of an estimate. (#805)
### Fixed

View file

@ -265,7 +265,13 @@ function sanitizeProjects(raw: unknown): { projects?: DailyEntry['projects'] } {
if (!isRecord(raw)) return {}
const out: NonNullable<DailyEntry['projects']> = {}
for (const [name, p] of Object.entries(raw)) {
if (name in Object.prototype || !isRecord(p)) continue
// A project key is a directory basename, so it can legitimately be a
// prototype-member name ("constructor", "valueOf", ...). `setOwn` writes it
// as an own property via defineProperty, so keeping it is pollution-safe —
// and dropping it would silently subtract that project's cost from a
// --project/--exclude total (the day's split would no longer sum to its own
// cost, which the filtered headline relies on).
if (!isRecord(p)) continue
setOwn(out, name, {
cost: num(p.cost),
calls: num(p.calls),

View file

@ -992,6 +992,7 @@ program
cacheWriteTokens: durable.data.cacheWriteTokens,
days: durable.days,
carriedCostUSD: durable.carriedCostUSD,
unattributedCostUSD: durable.unattributedCostUSD,
},
}))
})

View file

@ -97,6 +97,9 @@ export type OverviewDurable = {
cacheWriteTokens: number
days: DailyEntry[]
carriedCostUSD: number
/// Cost a --project/--exclude filter could not attribute (cached days with no
/// per-project split). Optional so callers that never filter can omit it.
unattributedCostUSD?: number
}
export function renderOverview(
@ -350,5 +353,12 @@ export function renderOverview(
out.push(c.dim(` includes ${formatCost(durable.carriedCostUSD)} preserved from expired session logs`))
}
// A project filter cannot claim days the cache holds without a project split
// (recorded before that split existed), so they sit outside this total. Say how
// much rather than let the filtered figure look inexplicably short.
if (durable && (durable.unattributedCostUSD ?? 0) > 0) {
out.push(c.dim(` excludes ${formatCost(durable.unattributedCostUSD!)} from days with no per-project history`))
}
return out.join('\n') + '\n'
}

View file

@ -12,7 +12,7 @@ import { aggregateModels } from './models-report.js'
import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js'
import { buildPrAttribution, aggregateByBranch } from './sessions-report.js'
import { scanAndDetect } from './optimize.js'
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry } from './daily-cache.js'
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
import { buildGranularHistory } from './granular-history.js'
// Row caps for the by-PR / by-branch payload aggregations, ranked by cost.
@ -226,25 +226,130 @@ function sliceDayToProvider(day: DailyEntry, provider: string): DailyEntry {
}
}
/// Does a cached day's project entry pass the active name filters? Mirrors
/// parser.filterProjectsByName exactly — case-insensitive substring match
/// against the project name OR its filesystem path, include first then exclude —
/// so a filter selects the same projects whether it is resolved against a fresh
/// parse or against the day cache. Patterns arrive pre-lowercased. `path` is
/// absent on entries whose sessions were gone before it could be recorded; the
/// name is then all there is to match on, as it is for the display layers.
function dayProjectMatches(name: string, path: string | undefined, include: string[], exclude: string[]): boolean {
const n = name.toLowerCase()
const p = (path ?? '').toLowerCase()
const hit = (pattern: string): boolean => n.includes(pattern) || (p !== '' && p.includes(pattern))
if (include.length > 0 && !include.some(hit)) return false
if (exclude.length > 0 && exclude.some(hit)) return false
return true
}
/// Sum the per-project day stats that pass the filters. `defineProperty` so a
/// project directory named "__proto__" stays an own key instead of mutating the
/// prototype link (same reason day-aggregator does it when writing them).
function sumMatchingProjects(
projects: Record<string, ProjectDayStats>,
include: string[],
exclude: string[],
): { cost: number; calls: number; savingsUSD: number; sessions: number; projects: Record<string, ProjectDayStats>; matched: number } {
const out = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, projects: {} as Record<string, ProjectDayStats>, matched: 0 }
for (const [name, p] of Object.entries(projects)) {
if (!dayProjectMatches(name, p.path, include, exclude)) continue
out.cost += p.cost
out.calls += p.calls
out.savingsUSD += p.savingsUSD ?? 0
out.sessions += p.sessions ?? 0
out.matched += 1
Object.defineProperty(out.projects, name, { value: p, enumerable: true, writable: true, configurable: true })
}
return out
}
/// Collapse a day to the slice matching the active --project/--exclude filters,
/// the project-level counterpart of sliceDayToProvider. Without this a
/// project-filtered headline counted every historical day WHOLE — excluded
/// projects included — while every detail panel (By Project / By Activity / By
/// Model, all built from the name-filtered live parse) left them out, so the two
/// could not be reconciled.
///
/// `DailyEntry.projects` (cache v15+) carries cost/calls/savingsUSD/sessions per
/// project, so those four are recomputed EXACTLY. The day's tokens, models and
/// categories have no per-project split to slice, so they are dropped here
/// rather than reported as if they belonged to the surviving projects;
/// buildDurablePeriod refills them from the project-filtered live parse, which
/// is exact for every session that still exists.
///
/// A day recorded before v15 has no `projects` at all and nothing can
/// reconstruct one once the sources are gone. Such a day cannot be attributed to
/// any project, so it contributes nothing to a project-filtered total and its
/// cost is surfaced as `unattributedCostUSD` instead of being silently folded in
/// (understating with a stated figure beats overstating with excluded spend).
function sliceDayToProject(day: DailyEntry, include: string[], exclude: string[]): DailyEntry {
const zeroDay = (): DailyEntry => ({
date: day.date, cost: 0, savingsUSD: 0, calls: 0, sessions: 0,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
editTurns: 0, oneShotTurns: 0, models: {}, categories: {}, providers: {},
...(day.carried ? { carried: true as const } : {}),
})
if (!day.projects) return zeroDay()
const totals = sumMatchingProjects(day.projects, include, exclude)
if (totals.matched === 0) return zeroDay()
// Provider slices carry their own per-project split, so `--provider X` on top
// of a project filter stays consistent with the day-level slice. A slice
// adopted from a pre-v15 cache has no split and is dropped for the same
// reason the day-level one is.
const providers: Record<string, ProviderDaySlice> = {}
for (const [name, slice] of Object.entries(day.providers)) {
if (!slice.projects) continue
const sliced = sumMatchingProjects(slice.projects, include, exclude)
if (sliced.matched === 0) continue
Object.defineProperty(providers, name, {
value: { cost: sliced.cost, calls: sliced.calls, savingsUSD: sliced.savingsUSD, sessions: sliced.sessions, projects: sliced.projects },
enumerable: true, writable: true, configurable: true,
})
}
return {
date: day.date,
cost: totals.cost,
savingsUSD: totals.savingsUSD,
calls: totals.calls,
sessions: totals.sessions,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
editTurns: 0, oneShotTurns: 0, models: {}, categories: {},
providers,
projects: totals.projects,
...(day.carried ? { carried: true as const } : {}),
}
}
/// The durable day set behind a period's headline: historical days from the
/// carry-forward cache (up to yesterday, INCLUDING days whose session files have
/// expired) unioned with today parsed live, then narrowed to the requested range
/// and (when given) the heatmap day selection. Identical construction to the
/// menubar's all-provider headline — this IS that construction, extracted.
///
/// `sliceHistorical` narrows the cache-sourced days only. Today's days come from
/// a parse the caller already name-filtered, so re-slicing them would be a no-op
/// at best and could only lose data the filter meant to keep.
function unionDaysForPeriod(
cache: DailyCache,
todayAllDays: DailyEntry[],
periodInfo: PeriodInfo,
daysSelection: Set<string> | null,
sliceHistorical?: (day: DailyEntry) => DailyEntry,
): DailyEntry[] {
const now = new Date()
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
const rangeStartStr = toDateString(periodInfo.range.start)
const rangeEndStr = toDateString(periodInfo.range.end)
const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr
const historicalDays = rangeStartStr <= historicalRangeEndStr
const cacheDays = rangeStartStr <= historicalRangeEndStr
? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr)
: []
// Apply the day selection BEFORE slicing so a day the heatmap filtered out
// never reaches the slicer (which tallies what it could not attribute).
const selectedCacheDays = daysSelection ? cacheDays.filter(d => daysSelection.has(d.date)) : cacheDays
const historicalDays = sliceHistorical ? selectedCacheDays.map(d => sliceHistorical(d)) : selectedCacheDays
const todayInRange = todayAllDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
const unfiltered = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
return daysSelection ? unfiltered.filter(d => daysSelection.has(d.date)) : unfiltered
@ -266,6 +371,12 @@ export type DurablePeriod = {
days: DailyEntry[]
/// Sum of `cost` on `carried` days included in the period (footnote source).
carriedCostUSD: number
/// Cost the active --project/--exclude filter had to set aside: cached days
/// recorded before per-project day stats existed (v15) carry no project split,
/// so they cannot be attributed to the filtered projects. Always 0 when no
/// project filter is active. Reported so a filtered total that is short of the
/// unfiltered one says so instead of just looking wrong.
unattributedCostUSD: number
/// Fresh per-period parse (provider + name filtered) for detail views that
/// still need surviving session files.
liveProjects: ProjectSummary[]
@ -325,7 +436,33 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
scanRange = isTodayOnly ? todayRange : periodInfo.range
}
const allDays = unionDaysForPeriod(cache, todayAllDays, periodInfo, daysSelection?.days ?? null)
// Name filters must reach the cache-sourced days too. Today's parse is already
// name-filtered above (`fp`), but the historical remainder comes straight out
// of the day cache, so without this slice a --project/--exclude headline
// counted every expired-source day whole while the detail panels did not.
const projectInclude = (opts.project ?? []).map(s => s.toLowerCase())
const projectExclude = (opts.exclude ?? []).map(s => s.toLowerCase())
const hasProjectFilter = projectInclude.length > 0 || projectExclude.length > 0
// What a filtered total cannot claim, and therefore has to leave out: a cached
// day with no project split at all, or — with a provider filter also active,
// since the headline then reads that provider's slice — a slice carried from a
// cache generation that predates per-project splits. Both are stated back to
// the caller (footnoted by the overview) instead of vanishing from the total.
const unattributableCost = (day: DailyEntry): number => {
if (pf === 'all') return day.projects ? 0 : day.cost
const slice = Object.hasOwn(day.providers, pf) ? day.providers[pf] : undefined
if (!slice) return 0
return !day.projects || !slice.projects ? slice.cost : 0
}
let unattributedCostUSD = 0
const sliceHistorical = hasProjectFilter
? (day: DailyEntry): DailyEntry => {
unattributedCostUSD += unattributableCost(day)
return sliceDayToProject(day, projectInclude, projectExclude)
}
: undefined
const allDays = unionDaysForPeriod(cache, todayAllDays, periodInfo, daysSelection?.days ?? null, sliceHistorical)
const days = pf === 'all' ? allDays : allDays.map(d => sliceDayToProvider(d, pf))
const data = buildPeriodDataFromDays(days, periodInfo.label)
@ -342,6 +479,21 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
// Cache buckets a session on its START day, the scan on any ACTIVE day; both
// are lower bounds of distinct sessions, so max is the tightest safe bound.
data.sessions = Math.max(data.sessions, scanData.sessions)
// Tokens/models/categories have no per-project split in the day cache, so
// sliceDayToProject drops them (see there). Under a project filter they come
// from the live parse instead: exact for the filtered projects, bounded by
// source retention like every other scan-derived field above, and consistent
// with the By Model / By Activity panels that read the same parse. Cost, calls,
// sessions and savings stay durable — sliced out of the cache, expired days
// included.
if (hasProjectFilter) {
data.inputTokens = scanData.inputTokens
data.outputTokens = scanData.outputTokens
data.cacheReadTokens = scanData.cacheReadTokens
data.cacheWriteTokens = scanData.cacheWriteTokens
data.models = scanData.models
data.categories = scanData.categories
}
const estimatedByModel = new Map(
scanData.models.filter(m => m.estimatedCostUSD != null).map(m => [m.name, m.estimatedCostUSD!]),
)
@ -352,7 +504,7 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
}
const carriedCostUSD = days.reduce((s, d) => s + (d.carried ? d.cost : 0), 0)
return { data, days, carriedCostUSD, liveProjects, cache, todayAllDays, scanRange }
return { data, days, carriedCostUSD, unattributedCostUSD, liveProjects, cache, todayAllDays, scanRange }
}
/**

View file

@ -0,0 +1,363 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, rm, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry } from '../src/daily-cache.js'
import { loadPricing } from '../src/models.js'
import { buildDurablePeriod, buildMenubarPayloadForRange, buildPeriodData, getDailyCacheConfigHash } from '../src/usage-aggregator.js'
import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js'
import { renderOverview } from '../src/overview.js'
import type { DateRange } from '../src/types.js'
// The durable headline (overview / report Overview panel / menubar current) is
// built from the carry-forward daily cache unioned with today's live parse, so
// days whose session files expired still count. Historical days were sliced to
// the requested PROVIDER but never to the requested PROJECT, so
// `--project` / `--exclude` were silently ignored for every day except today:
// the Overview total counted excluded projects while every detail panel (By
// Project / By Activity / By Model, all built from the name-filtered live
// parse) left them out, and the two panels could not be reconciled.
//
// Per-project day stats exist in the cache since v15 (DailyEntry.projects), so
// the historical days CAN be sliced — that is what these tests pin down.
const ROOT = join(tmpdir(), `codeburn-project-filter-${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
let savedEnv: Record<string, string | undefined>
// One carried day, split across two projects. The day totals are the sum, so a
// correct project slice returns strictly less than the whole day.
const KEEP = { cost: 30, calls: 10, sessions: 1 }
const DROP = { cost: 70, calls: 30, sessions: 2 }
const DAY_COST = KEEP.cost + DROP.cost
const DAY_CALLS = KEEP.calls + DROP.calls
const DAY_SESSIONS = KEEP.sessions + DROP.sessions
function daysAgoStr(n: number): string {
const d = new Date(Date.now() - n * 24 * 60 * 60 * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** A day only the cache holds (sources aged out), split across two projects. */
function carriedDayWithProjects(date: string): DailyEntry {
const projects = {
'keep-me': { cost: KEEP.cost, calls: KEEP.calls, savingsUSD: 0, sessions: KEEP.sessions, path: '/Users/gone/keep-me' },
'drop-me': { cost: DROP.cost, calls: DROP.calls, savingsUSD: 0, sessions: DROP.sessions, path: '/Users/gone/drop-me' },
}
return {
date,
cost: DAY_COST,
savingsUSD: 0,
calls: DAY_CALLS,
sessions: DAY_SESSIONS,
inputTokens: 5000,
outputTokens: 2000,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 4,
oneShotTurns: 2,
models: { 'Opus 4.8': { calls: DAY_CALLS, cost: DAY_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
categories: { coding: { turns: 10, cost: DAY_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
providers: {
claude: {
calls: DAY_CALLS, cost: DAY_COST, savingsUSD: 0, sessions: DAY_SESSIONS,
inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0,
editTurns: 4, oneShotTurns: 2,
models: { 'Opus 4.8': { calls: DAY_CALLS, cost: DAY_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
categories: { coding: { turns: 10, cost: DAY_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
projects,
},
},
projects,
carried: true,
}
}
/** A carried day recorded before v15: totals, but no per-project split at all. */
function carriedDayWithoutProjects(date: string): DailyEntry {
const day = carriedDayWithProjects(date)
delete day.projects
delete day.providers['claude']!.projects
return day
}
async function seedCache(day: DailyEntry): Promise<void> {
const cache: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: getDailyCacheConfigHash(),
tzKey: currentTzKey(),
lastComputedDate: daysAgoStr(1),
days: [day],
complete: true,
}
await writeFile(join(ROOT, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`), JSON.stringify(cache), 'utf-8')
}
/** A real, priced Claude session dated TODAY, under project dir `live-proj`. */
async function seedLiveTodaySession(): Promise<void> {
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'live-proj')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const at = (h: number, m: number): string => new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m, 0).toISOString()
const line = (id: string, t: string): string => JSON.stringify({
type: 'assistant',
timestamp: t,
sessionId: 's-today',
message: {
type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', id,
content: [],
usage: { input_tokens: 90000, output_tokens: 12000, cache_creation_input_tokens: 0, cache_read_input_tokens: 300000 },
},
})
await writeFile(join(projectDir, 's-today.jsonl'), [line('m1', at(12, 0)), line('m2', at(12, 30))].join('\n') + '\n', 'utf-8')
}
/** What the name-filtered live parse alone reports — the By Project panel's source. */
async function liveOnly(range: DateRange, include: string[], exclude: string[]): Promise<{ cost: number; calls: number }> {
clearSessionCache()
const projects = filterProjectsByName(await parseAllSessions(range, 'all'), include, exclude)
const data = buildPeriodData('live', projects)
return { cost: data.cost, calls: data.calls }
}
beforeAll(async () => {
await loadPricing()
})
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 })
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']
clearSessionCache()
})
afterEach(async () => {
clearSessionCache()
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k]
else process.env[k] = savedEnv[k]
}
if (existsSync(ROOT)) await rm(ROOT, { recursive: true, force: true })
})
// A fixed 20-day window back from now: it always spans the carried day (10 days
// ago) and today, so the period never depends on where "now" falls in the
// calendar. A real `month` range drops the 10-days-ago day whenever today is
// within 10 days of the 1st, which made these tests flake early in each month.
const coveringRange = (): DateRange => ({
start: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000),
end: new Date(),
})
describe('durable headline honours --project / --exclude on carried days', () => {
it('drops an excluded project from the headline so it reconciles with the By Project panel', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
const live = await liveOnly(range, [], ['drop-me'])
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'] })
// The headline counts the surviving sessions plus ONLY the kept project's
// carried cost — not the whole carried day.
expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
expect(durable.data.calls).toBe(live.calls + KEEP.calls)
// And it reconciles with the detail panels, which see the same live parse.
const byProject = durable.liveProjects.reduce((s, p) => s + p.totalCostUSD, 0)
expect(durable.data.cost).toBeCloseTo(byProject + KEEP.cost, 6)
})
it('keeps only the named project when --project is given', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
const live = await liveOnly(range, ['keep-me'], [])
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', project: ['keep-me'] })
expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
expect(durable.data.calls).toBe(live.calls + KEEP.calls)
})
it('matches a filter against the project path as well as its name', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
const live = await liveOnly(range, [], ['/Users/gone/drop-me'])
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['/Users/gone/drop-me'] })
expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
})
it('keeps a project whose name is a prototype member (constructor) in the sliced total', async () => {
// A project directory can legitimately be named "constructor"/"valueOf"/etc.
// The day cache must carry it as an own key: if the load path drops it, the
// day's per-project split no longer sums to the day cost, and the sliced
// headline silently loses that project's spend.
const day = carriedDayWithProjects(daysAgoStr(10))
const protoName = 'constructor'
Object.defineProperty(day.projects!, protoName, {
value: { cost: 25, calls: 5, savingsUSD: 0, sessions: 1, path: '/Users/gone/constructor' },
enumerable: true, writable: true, configurable: true,
})
day.cost += 25
day.calls += 5
await seedCache(day)
await seedLiveTodaySession()
const range = coveringRange()
// Keep everything (exclude a non-matching term): the constructor project's
// $25 must be present alongside keep-me and drop-me.
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['zzz-nomatch'] })
const live = await liveOnly(range, [], ['zzz-nomatch'])
expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST + 25, 6)
expect(durable.unattributedCostUSD).toBe(0)
})
it('contributes nothing from a carried day whose every project is excluded', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
const live = await liveOnly(range, [], ['keep-me', 'drop-me'])
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['keep-me', 'drop-me'] })
expect(durable.data.cost).toBeCloseTo(live.cost, 6)
expect(durable.carriedCostUSD).toBe(0)
})
it('applies the project filter underneath a provider filter', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
// Stated as the delta the filter must produce, so the assertion holds
// whatever the provider-scoped live parse contributes.
clearSessionCache()
const unfiltered = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude' })
clearSessionCache()
const filtered = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude', exclude: ['drop-me'] })
expect(unfiltered.data.cost - filtered.data.cost).toBeCloseTo(DROP.cost, 6)
expect(unfiltered.data.calls - filtered.data.calls).toBe(DROP.calls)
})
it('keeps the menubar payload in step with the report under a project filter', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
clearSessionCache()
const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'], optimize: false, timeline: false })
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'] })
// Both surfaces route the headline through the one shared builder, so a
// project filter must land on them identically.
expect(menubar.current.cost).toBe(durable.data.cost)
expect(menubar.current.calls).toBe(durable.data.calls)
expect(menubar.current.inputTokens).toBe(durable.data.inputTokens)
})
it('leaves the unfiltered headline exactly as it was', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
const live = await liveOnly(range, [], [])
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all' })
expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST, 6)
expect(durable.data.calls).toBe(live.calls + DAY_CALLS)
expect(durable.carriedCostUSD).toBeCloseTo(DAY_COST, 6)
})
})
describe('carried days with no per-project split (pre-v15)', () => {
it('sets the unfilterable day aside instead of leaking it into a filtered headline', async () => {
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
const live = await liveOnly(range, [], ['drop-me'])
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'] })
// The day cannot be attributed to any project, so a project-filtered total
// cannot claim it. It is reported separately rather than silently folded in.
expect(durable.data.cost).toBeCloseTo(live.cost, 6)
expect(durable.unattributedCostUSD).toBeCloseTo(DAY_COST, 6)
})
it('reports a provider slice with no project split as unattributed too', async () => {
// A v14-era provider slice carried into a v15 day: the day knows its project
// split, that provider's slice does not. Under --provider the headline reads
// the slice, so the day drops out — say how much rather than lose it quietly.
const day = carriedDayWithProjects(daysAgoStr(10))
delete day.providers['claude']!.projects
await seedCache(day)
await seedLiveTodaySession()
const range = coveringRange()
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude', exclude: ['drop-me'] })
expect(durable.unattributedCostUSD).toBeCloseTo(DAY_COST, 6)
expect(durable.carriedCostUSD).toBe(0)
})
it('says so in the overview instead of just showing a short total', async () => {
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'This month' }, { provider: 'all', exclude: ['drop-me'] })
expect(durable.unattributedCostUSD).toBeGreaterThan(0)
const rendered = renderOverview(durable.liveProjects, {
label: 'This month',
color: false,
durable: {
cost: durable.data.cost,
savingsUSD: durable.data.savingsUSD,
calls: durable.data.calls,
sessions: durable.data.sessions,
inputTokens: durable.data.inputTokens,
outputTokens: durable.data.outputTokens,
cacheReadTokens: durable.data.cacheReadTokens,
cacheWriteTokens: durable.data.cacheWriteTokens,
days: durable.days,
carriedCostUSD: durable.carriedCostUSD,
unattributedCostUSD: durable.unattributedCostUSD,
},
})
expect(rendered).toContain('no per-project history')
})
it('still counts the day in full when no project filter is active', async () => {
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
const live = await liveOnly(range, [], [])
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all' })
expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST, 6)
expect(durable.unattributedCostUSD).toBe(0)
})
})