diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e9ffe8..fb03d0e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,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 - Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611) diff --git a/src/main.ts b/src/main.ts index d24789ef..be980557 100644 --- a/src/main.ts +++ b/src/main.ts @@ -984,6 +984,7 @@ program cacheWriteTokens: durable.data.cacheWriteTokens, days: durable.days, carriedCostUSD: durable.carriedCostUSD, + unattributedCostUSD: durable.unattributedCostUSD, }, })) }) diff --git a/src/overview.ts b/src/overview.ts index 4881ae9a..4f6cabdc 100644 --- a/src/overview.ts +++ b/src/overview.ts @@ -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' } diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index baf4c319..86d0e2e2 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -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, + include: string[], + exclude: string[], +): { cost: number; calls: number; savingsUSD: number; sessions: number; projects: Record; matched: number } { + const out = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, projects: {} as Record, 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 = {} + 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 | 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 } } /** diff --git a/tests/project-filter-durable-totals.test.ts b/tests/project-filter-durable-totals.test.ts new file mode 100644 index 00000000..746a67bf --- /dev/null +++ b/tests/project-filter-durable-totals.test.ts @@ -0,0 +1,332 @@ +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 { getDateRange } from '../src/cli-date.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 + +// 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 { + 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 { + 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 }) +}) + +const monthRange = (): DateRange => getDateRange('month').range + +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 = monthRange() + + 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 = monthRange() + + 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 = monthRange() + + 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('contributes nothing from a carried day whose every project is excluded', async () => { + await seedCache(carriedDayWithProjects(daysAgoStr(10))) + await seedLiveTodaySession() + const range = monthRange() + + 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 = monthRange() + + // 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 = monthRange() + + 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 = monthRange() + + 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 = monthRange() + + 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 = monthRange() + + 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 = monthRange() + + 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 = monthRange() + + 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) + }) +})