From 4bfcc020f6f57dfa1847adaca46f0808540e3168 Mon Sep 17 00:00:00 2001 From: "chengchuan.zhou" Date: Fri, 31 Jul 2026 18:34:18 +0800 Subject: [PATCH] fix(aggregator): apply --project/--exclude to the durable headline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The durable headline unions the carry-forward daily cache with today's live parse. Cached days were sliced to the requested provider (sliceDayToProvider) but never to the requested project, so a --project/--exclude query counted every historical day WHOLE while the detail panels — By Project / By Activity / By Model, all built from the name-filtered live parse — left the filtered projects out. The Overview total and the panels below it could not be reconciled; on a real one-week corpus the headline ran $2,428.85 over the By Project sum, which is exactly the excluded projects' spend. Add sliceDayToProject, the project-level counterpart of sliceDayToProvider, and apply it to the cache-sourced days. Cost, calls, sessions and savings come out of the per-project day stats the cache has carried since v15, so they stay exact and still include days whose session files have expired. Provider slices carry their own project split, so --provider on top of a project filter stays consistent. Fields the cache has no per-project split for are handled explicitly rather than reported as the filtered projects' own: - tokens/models/categories come from the (project-filtered) live parse when a project filter is active, which is exact for every surviving session and matches the panels that read the same parse; - days, or provider slices, carried from before v15 have no project split at all and cannot be attributed, so they contribute nothing and their cost is returned as unattributedCostUSD and footnoted by the terminal overview instead of being folded into a filtered total. --- CHANGELOG.md | 1 + src/main.ts | 1 + src/overview.ts | 10 + src/usage-aggregator.ts | 160 +++++++++- tests/project-filter-durable-totals.test.ts | 332 ++++++++++++++++++++ 5 files changed, 500 insertions(+), 4 deletions(-) create mode 100644 tests/project-filter-durable-totals.test.ts 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) + }) +})