fix(report): surface estimated costs distinctly (#688)

* fix(report): surface estimated costs distinctly

Providers that estimate tokens or price (kiro, cursor, warp, copilot,
grok, hermes, codewhale, and the codex proxy path) set costIsEstimated
on their parsed calls, but the flag died at the parser boundary: it was
never carried onto ParsedApiCall or into the session aggregates, so a
figure whose tokens were synthesized from content length rendered with
the same authority as a metered one.

Plumb the truth through, mirroring the savingsUSD/isLocalSavings pattern:
a call-level boolean (ParsedApiCall.isEstimated, CachedCall.isEstimated,
persisted so it survives the session-cache round trip) and an additive
aggregate amount (estimatedCostUSD on the model breakdown, session, and
project totals, plus PeriodData and the menubar payload). The amount is
carried rather than a bare boolean so a row that is mostly metered with a
small estimated slice is not indistinguishable from a fully guessed one.

Display: report (TUI) and overview per-model rows prefix the cost with a
tilde and print one legend line; the MCP tables carry the same marker and
legend, and the machine surfaces (report --json, MCP get_usage, menubar /
web payload) expose estimatedCostUSD. Totals math is unchanged; the flag
is display/metadata only.

Bump PROVIDER_PARSE_VERSIONS for every provider that sets the flag so
already-cached sessions reparse once and pick it up. Copilot is excluded:
it is a durable provider, so changing its env fingerprint would discard
OTel cache entries whose source rows may already be pruned.

Also fix the cross-provider project merge, which summed totalCostUSD but
dropped merged-in projects' totalEstimatedCostUSD, undercounting the
project/period estimated total (the same latent gap still affects
totalSavingsUSD, left untouched here).

* test(parser): pin estimated dollars through the cross-provider merge

The merge fix for dropped totalEstimatedCostUSD was not covered: deleting
the summing line left every test green. Extract the merge into an exported
mergeProjectsByCrossProviderKey (no behavior change) and pin both the
measured-plus-estimated and both-estimated merge cases.

* docs(parser): honest merge-comment scope and load-bearing overwrite note

Re-review nits: the merge doc claimed all additive totals are summed there
while totalSavingsUSD still is not (pre-existing gap, tracked separately)
and totalProxiedCostUSD is re-derived post-merge; say so. Mark the
buildPeriodData overwrite in usage-aggregator as load-bearing for the
estimated marker so nobody optimizes it away trusting the daily cache.
This commit is contained in:
Resham Joshi 2026-07-16 10:46:21 -07:00 committed by GitHub
parent 3dd1d1c793
commit 3163ccb5c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 390 additions and 48 deletions

View file

@ -3,7 +3,7 @@ import { homedir } from 'os'
import React, { useState, useCallback, useEffect, useRef } from 'react'
import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { formatCost, formatTokens } from './format.js'
import { formatCost, formatTokens, markEstimated } from './format.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
import { parseAllSessions, filterProjectsByName, setInteractiveScanUI } from './parser.js'
import { findUnpricedModels, loadPricing } from './models.js'
@ -384,20 +384,22 @@ const MODEL_NAME_WIDTH = 14
const MIN_EDIT_TURNS_FOR_RATE = 5
function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
const modelTotals: Record<string, { calls: number; costUSD: number; freshInput: number; cacheRead: number; cacheWrite: number }> = {}
const modelTotals: Record<string, { calls: number; costUSD: number; estimatedCostUSD: number; freshInput: number; cacheRead: number; cacheWrite: number }> = {}
const modelEfficiency = aggregateModelEfficiency(projects)
for (const project of projects) {
for (const session of project.sessions) {
for (const [model, data] of Object.entries(session.modelBreakdown)) {
if (!modelTotals[model]) modelTotals[model] = { calls: 0, costUSD: 0, freshInput: 0, cacheRead: 0, cacheWrite: 0 }
if (!modelTotals[model]) modelTotals[model] = { calls: 0, costUSD: 0, estimatedCostUSD: 0, freshInput: 0, cacheRead: 0, cacheWrite: 0 }
modelTotals[model].calls += data.calls
modelTotals[model].costUSD += data.costUSD
modelTotals[model].estimatedCostUSD += data.estimatedCostUSD ?? 0
modelTotals[model].freshInput += data.tokens.inputTokens
modelTotals[model].cacheRead += data.tokens.cacheReadInputTokens
modelTotals[model].cacheWrite += data.tokens.cacheCreationInputTokens
}
}
}
const anyEstimated = Object.values(modelTotals).some(d => d.estimatedCostUSD > 0)
const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD)
const maxCost = sorted[0]?.[1]?.costUSD ?? 0
const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({
@ -422,7 +424,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
<Text key={`${model}-${i}`} wrap="truncate-end">
<HBar value={data.costUSD} max={maxCost} width={bw} />
<Text> {fit(model, MODEL_NAME_WIDTH)}</Text>
<Text color={GOLD}>{formatCost(data.costUSD).padStart(MODEL_COL_COST)}</Text>
<Text color={GOLD}>{markEstimated(formatCost(data.costUSD), data.estimatedCostUSD > 0).padStart(MODEL_COL_COST)}</Text>
<Text>{cacheLabel.padStart(MODEL_COL_CACHE)}</Text>
<Text>{String(data.calls).padStart(MODEL_COL_CALLS)}</Text>
<Text>{oneShotLabel.padStart(MODEL_COL_ONESHOT)}</Text>
@ -434,6 +436,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
{`! ${unpriced.length} model${unpriced.length === 1 ? '' : 's'} unpriced at $0, fix: codeburn model-alias (${unpriced.slice(0, 2).map(u => u.model).join(', ')}${unpriced.length > 2 ? ', ...' : ''})`}
</Text>
)}
{anyEstimated && (
<Text dimColor wrap="truncate-end">~ estimated cost (priced from estimated tokens)</Text>
)}
</Panel>
)
}

View file

@ -7,6 +7,14 @@ import type { ProjectSummary } from './types.js'
import { formatCost } from './currency.js'
export { formatCost }
/// Prefix a formatted cost with the estimated marker (`~`) when the figure is
/// priced from estimated tokens rather than metered. Keeps the marker identical
/// across the report, overview, and MCP surfaces so a legend line can explain it
/// once. `isEstimated` is typically `entry.estimatedCostUSD > 0`.
export function markEstimated(costStr: string, isEstimated: boolean): string {
return isEstimated ? `~${costStr}` : costStr
}
export function formatTokens(n: number): string {
// Guard against Infinity / NaN / negatives that would otherwise leak into
// the UI as "Infinity" or "NaN" strings when an upstream calculation glitches.

View file

@ -261,6 +261,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalSavingsUSD = projects.reduce((s, p) => s + p.totalSavingsUSD, 0)
const totalEstimatedUSD = projects.reduce((s, p) => s + (p.totalEstimatedCostUSD ?? 0), 0)
// Subscription-covered (proxied) portion of totalCostUSD, and the resulting
// out-of-pocket figure. `cost` stays the full billable/would-be amount.
const totalProxiedUSD = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
@ -334,14 +335,15 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
sessions: p.sessions.length,
}))
const modelMap: Record<string, { calls: number; cost: number; savings: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; baselineModel: string }> = {}
const modelMap: Record<string, { calls: number; cost: number; savings: number; estimatedCost: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; baselineModel: string }> = {}
const modelEfficiency = aggregateModelEfficiency(projects)
for (const sess of sessions) {
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, savings: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, baselineModel: '' } }
if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, savings: 0, estimatedCost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, baselineModel: '' } }
modelMap[model].calls += d.calls
modelMap[model].cost += d.costUSD
modelMap[model].savings += d.savingsUSD
modelMap[model].estimatedCost += d.estimatedCostUSD ?? 0
modelMap[model].inputTokens += d.tokens.inputTokens
modelMap[model].outputTokens += d.tokens.outputTokens
modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
@ -371,13 +373,14 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
}
const models = Object.entries(modelMap)
.sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings))
.map(([name, { cost, savings, baselineModel, ...rest }]) => {
.map(([name, { cost, savings, estimatedCost, baselineModel, ...rest }]) => {
const efficiency = modelEfficiency.get(name)
return {
name,
...rest,
cost: convertCost(cost),
savings: convertCost(savings),
estimatedCost: convertCost(estimatedCost),
savingsBaselineModel: baselineModel,
editTurns: efficiency?.editTurns ?? 0,
oneShotTurns: efficiency?.oneShotTurns ?? 0,
@ -480,6 +483,9 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
proxiedCost: convertCost(totalProxiedUSD),
netCost: convertCost(netCostUSD),
savings: convertCost(totalSavingsUSD),
// Portion of `cost` priced from estimated tokens (issue #639). Display/
// metadata only; never subtracted from `cost`. 0 when nothing is estimated.
estimatedCost: convertCost(totalEstimatedUSD),
calls: totalCalls,
sessions: totalSessions,
cacheHitPercent,

View file

@ -20,9 +20,9 @@ const INSTRUCTIONS =
'unless include_project_names is true. All data is read locally from this machine; last_6_months is the widest ' +
'window. Numbers reflect the most recent scan and may lag the current session by up to a few minutes.'
function breakdownRows(p: MenubarPayload, by: BreakdownBy, limit: number): Array<{ name: string; costUSD: number }> {
function breakdownRows(p: MenubarPayload, by: BreakdownBy, limit: number): Array<{ name: string; costUSD: number; estimatedCostUSD?: number }> {
const c = p.current
if (by === 'model') return c.topModels.slice(0, limit).map(m => ({ name: m.name, costUSD: m.cost }))
if (by === 'model') return c.topModels.slice(0, limit).map(m => ({ name: m.name, costUSD: m.cost, estimatedCostUSD: m.estimatedCostUSD ?? 0 }))
if (by === 'project') return c.topProjects.slice(0, limit).map(x => ({ name: x.name, costUSD: x.cost }))
if (by === 'task') return c.topActivities.slice(0, limit).map(a => ({ name: a.name, costUSD: a.cost }))
return Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => ({ name, costUSD: cost }))
@ -60,8 +60,8 @@ export function createServer(deps: { version: string; aggregate?: Aggregate }):
outputSchema: {
period: z.string(),
empty: z.boolean(),
totals: z.object({ costUSD: z.number(), calls: z.number(), sessions: z.number(), cacheHitPercent: z.number(), oneShotRate: z.number().nullable() }),
breakdown: z.array(z.object({ name: z.string(), costUSD: z.number() })).nullable(),
totals: z.object({ costUSD: z.number(), estimatedCostUSD: z.number(), calls: z.number(), sessions: z.number(), cacheHitPercent: z.number(), oneShotRate: z.number().nullable() }),
breakdown: z.array(z.object({ name: z.string(), costUSD: z.number(), estimatedCostUSD: z.number().optional() })).nullable(),
},
annotations: { title: 'CodeBurn — usage & cost', readOnlyHint: true, openWorldHint: false, idempotentHint: true },
},
@ -69,7 +69,7 @@ export function createServer(deps: { version: string; aggregate?: Aggregate }):
try {
const payload = redactProjectNames(await getPayload(period, false), include_project_names)
const c = payload.current
const totals = { costUSD: c.cost, calls: c.calls, sessions: c.sessions, cacheHitPercent: c.cacheHitPercent, oneShotRate: c.oneShotRate }
const totals = { costUSD: c.cost, estimatedCostUSD: c.estimatedCostUSD ?? 0, calls: c.calls, sessions: c.sessions, cacheHitPercent: c.cacheHitPercent, oneShotRate: c.oneShotRate }
if (c.calls === 0) {
return {
content: [{ type: 'text' as const, text: `No usage recorded for ${c.label} yet — run some coding sessions and try again.` }],
@ -85,7 +85,7 @@ export function createServer(deps: { version: string; aggregate?: Aggregate }):
} catch (err) {
return {
content: [{ type: 'text' as const, text: `codeburn: failed to read usage — ${err instanceof Error ? err.message : String(err)}` }],
structuredContent: { period: 'unknown', empty: true, totals: { costUSD: 0, calls: 0, sessions: 0, cacheHitPercent: 0, oneShotRate: null }, breakdown: null },
structuredContent: { period: 'unknown', empty: true, totals: { costUSD: 0, estimatedCostUSD: 0, calls: 0, sessions: 0, cacheHitPercent: 0, oneShotRate: null }, breakdown: null },
isError: true,
}
}

View file

@ -1,6 +1,9 @@
import { formatCost, formatTokens } from '../format.js'
import { formatCost, formatTokens, markEstimated } from '../format.js'
import type { MenubarPayload } from '../menubar-json.js'
const ESTIMATED_LEGEND = '_~ estimated cost (priced from estimated tokens)_'
const isEstimated = (m: { estimatedCostUSD?: number }) => (m.estimatedCostUSD ?? 0) > 0
export type BreakdownBy = 'project' | 'model' | 'task' | 'provider'
function mdTable(headers: string[], rows: string[][]): string {
@ -24,7 +27,8 @@ export function renderSummaryTable(p: MenubarPayload): string {
: []),
'',
'_Top models_',
mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, 5).map(m => [m.name, formatCost(m.cost), String(m.calls)])),
mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, 5).map(m => [m.name, markEstimated(formatCost(m.cost), isEstimated(m)), String(m.calls)])),
...(c.topModels.slice(0, 5).some(isEstimated) ? [ESTIMATED_LEGEND] : []),
'',
'_Top projects_',
mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, 5).map(x => [x.name, formatCost(x.cost), String(x.sessions)])),
@ -33,7 +37,11 @@ export function renderSummaryTable(p: MenubarPayload): string {
export function renderBreakdownTable(p: MenubarPayload, by: BreakdownBy, limit: number): string {
const c = p.current
if (by === 'model') return mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, limit).map(m => [m.name, formatCost(m.cost), String(m.calls)]))
if (by === 'model') {
const rows = c.topModels.slice(0, limit)
const table = mdTable(['Model', 'Cost', 'Calls'], rows.map(m => [m.name, markEstimated(formatCost(m.cost), isEstimated(m)), String(m.calls)]))
return rows.some(isEstimated) ? `${table}\n\n${ESTIMATED_LEGEND}` : table
}
if (by === 'project') return mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, limit).map(x => [x.name, formatCost(x.cost), String(x.sessions)]))
if (by === 'task') return mdTable(['Task', 'Cost', 'Turns', 'One-shot'], c.topActivities.slice(0, limit).map(a => [a.name, formatCost(a.cost), String(a.turns), oneShot(a.oneShotRate)]))
return mdTable(['Provider', 'Cost'], Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => [name, formatCost(cost)]))

View file

@ -10,6 +10,10 @@ export type PeriodData = {
/// separately from `cost` so the two never get summed into a "real
/// spend" number by accident.
savingsUSD: number
/// Portion of `cost` priced from estimated tokens (see ParsedApiCall.isEstimated).
/// Display/metadata only; never summed into `cost`. Optional so PeriodData
/// producers predating the field keep compiling.
estimatedCostUSD?: number
calls: number
sessions: number
inputTokens: number
@ -20,7 +24,7 @@ export type PeriodData = {
/// non-menubar PeriodData producers don't have to compute it.
codexCredits?: number
categories: Array<{ name: string; cost: number; savingsUSD: number; turns: number; editTurns: number; oneShotTurns: number }>
models: Array<{ name: string; cost: number; savingsUSD: number; calls: number }>
models: Array<{ name: string; cost: number; savingsUSD: number; calls: number; estimatedCostUSD?: number }>
/// Models with usage in the period whose pricing lookup fails against the
/// current tables (#638): their calls contribute $0 to `cost`. Optional so
/// PeriodData producers that predate the field keep compiling.
@ -146,6 +150,11 @@ export type MenubarPayload = {
cacheHitPercent: number
/// Codex credits consumed in the period; 0 when there is no Codex usage.
codexCredits: number
/// Portion of `cost` priced from estimated tokens (see ParsedApiCall.isEstimated).
/// Machine-readable signal that distinguishes guessed spend from metered spend;
/// display/metadata only, never summed into `cost`. Optional for compatibility
/// with payloads produced before the field existed.
estimatedCostUSD?: number
topActivities: Array<{
name: string
cost: number
@ -159,6 +168,9 @@ export type MenubarPayload = {
savingsUSD: number
savingsBaselineModel: string
calls: number
/// Estimated portion of this model's `cost`; > 0 marks the row as priced
/// from estimated tokens. Optional for payload back-compat.
estimatedCostUSD?: number
}>
/// See PeriodData.unpricedModels: usage priced at $0 for lack of pricing
/// data. Empty when every model in the period resolved a price. Optional
@ -291,7 +303,7 @@ function buildTopModels(models: PeriodData['models']): MenubarPayload['current']
return models
.filter(m => m.name !== SYNTHETIC_MODEL_NAME)
.slice(0, TOP_MODELS_LIMIT)
.map(m => ({ name: m.name, cost: m.cost, calls: m.calls, savingsUSD: m.savingsUSD, savingsBaselineModel: '' }))
.map(m => ({ name: m.name, cost: m.cost, calls: m.calls, savingsUSD: m.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: m.estimatedCostUSD ?? 0 }))
}
function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] {
@ -410,6 +422,7 @@ export function buildMenubarPayload(
cacheWriteTokens: current.cacheWriteTokens,
cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens),
codexCredits: current.codexCredits ?? 0,
estimatedCostUSD: current.estimatedCostUSD ?? 0,
topActivities: buildTopActivities(current.categories),
topModels: buildTopModels(current.models),
unpricedModels: current.unpricedModels ?? [],

View file

@ -5,6 +5,7 @@ import { homedir } from 'os'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
import { formatCost as baseCost } from './currency.js'
import { findUnpricedModels, getShortModelName } from './models.js'
import { markEstimated } from './format.js'
import { dateKey } from './day-aggregator.js'
// Display-only helpers. The shared formatters omit thousands separators and
@ -91,7 +92,7 @@ export function renderOverview(
let cost = 0, savings = 0, calls = 0, sessions = 0
let inTok = 0, outTok = 0, cacheR = 0, cacheW = 0
const byProvider = new Map<string, { cost: number; tokens: number }>()
const byModel = new Map<string, { cost: number; calls: number; tokens: number }>()
const byModel = new Map<string, { cost: number; calls: number; tokens: number; estimatedCost: number }>()
const byCat = new Map<string, { cost: number; turns: number }>()
const byTool = new Map<string, number>()
const byDay = new Map<string, { cost: number; tokens: number; providers: Set<string> }>()
@ -113,9 +114,10 @@ export function renderOverview(
cacheR += s.totalCacheReadTokens
cacheW += s.totalCacheWriteTokens
for (const [m, d] of Object.entries(s.modelBreakdown)) {
const e = byModel.get(m) ?? { cost: 0, calls: 0, tokens: 0 }
const e = byModel.get(m) ?? { cost: 0, calls: 0, tokens: 0, estimatedCost: 0 }
e.cost += d.costUSD
e.calls += d.calls
e.estimatedCost += d.estimatedCostUSD ?? 0
e.tokens += d.tokens.inputTokens + d.tokens.outputTokens + d.tokens.cacheReadInputTokens + d.tokens.cacheCreationInputTokens
byModel.set(m, e)
}
@ -209,8 +211,11 @@ export function renderOverview(
out.push(heading('Top models'))
out.push(renderTable(c,
[{ header: 'Model' }, { header: 'Cost', right: true }, { header: 'Calls', right: true }, { header: 'Tokens', right: true }],
modelRows.map(([m, v]) => [getShortModelName(m), formatCost(v.cost), formatCount(v.calls), formatTokens(v.tokens)]),
modelRows.map(([m, v]) => [getShortModelName(m), markEstimated(formatCost(v.cost), v.estimatedCost > 0), formatCount(v.calls), formatTokens(v.tokens)]),
))
if (modelRows.some(([, v]) => v.estimatedCost > 0)) {
out.push(' ' + c.dim('~ estimated cost (priced from estimated tokens)'))
}
out.push('')
}

View file

@ -1413,6 +1413,7 @@ function buildSessionSummary(
let totalCost = 0
let totalSavings = 0
let totalEstimated = 0
let totalInput = 0
let totalOutput = 0
let totalReasoning = 0
@ -1454,8 +1455,10 @@ function buildSessionSummary(
for (const call of turn.assistantCalls) {
const callSavings = call.savingsUSD ?? 0
const callEstimated = call.isEstimated ? call.costUSD : 0
totalCost += call.costUSD
totalSavings += callSavings
totalEstimated += callEstimated
totalInput += call.usage.inputTokens
totalOutput += call.usage.outputTokens
totalReasoning += call.usage.reasoningTokens
@ -1469,12 +1472,14 @@ function buildSessionSummary(
calls: 0,
costUSD: 0,
savingsUSD: 0,
estimatedCostUSD: 0,
tokens: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 },
}
}
modelBreakdown[modelKey].calls++
modelBreakdown[modelKey].costUSD += call.costUSD
modelBreakdown[modelKey].savingsUSD += callSavings
modelBreakdown[modelKey].estimatedCostUSD = (modelBreakdown[modelKey].estimatedCostUSD ?? 0) + callEstimated
modelBreakdown[modelKey].tokens.inputTokens += call.usage.inputTokens
modelBreakdown[modelKey].tokens.outputTokens += call.usage.outputTokens
modelBreakdown[modelKey].tokens.cacheReadInputTokens += call.usage.cacheReadInputTokens
@ -1513,6 +1518,7 @@ function buildSessionSummary(
lastTimestamp: lastTs || turns[turns.length - 1]?.timestamp || '',
totalCostUSD: totalCost,
totalSavingsUSD: totalSavings,
totalEstimatedCostUSD: totalEstimated,
totalInputTokens: totalInput,
totalOutputTokens: totalOutput,
totalReasoningTokens: totalReasoning,
@ -1822,6 +1828,7 @@ function summarizeProject(project: string, projectPath: string, sessions: Sessio
sessions,
totalCostUSD,
totalSavingsUSD: sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0),
totalEstimatedCostUSD: sessions.reduce((s, sess) => s + (sess.totalEstimatedCostUSD ?? 0), 0),
totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0),
totalProxiedCostUSD: isProxiedPath(projectPath) ? totalCostUSD : 0,
}
@ -1854,6 +1861,7 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn {
timestamp: call.timestamp,
bashCommands: call.bashCommands,
deduplicationKey: call.deduplicationKey,
isEstimated: call.costIsEstimated,
})
return {
@ -1881,6 +1889,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
cacheCreationOneHourTokens: 0,
},
costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes' || call.provider === 'kiro' || call.provider === 'codewhale') ? call.costUSD : undefined,
isEstimated: call.costIsEstimated || undefined,
speed: call.speed,
timestamp: call.timestamp,
tools: call.tools,
@ -1912,6 +1921,7 @@ function apiCallToCachedCall(call: ParsedApiCall): CachedCall {
provider: call.provider,
model: call.model,
usage: { ...call.usage, cacheCreationOneHourTokens: call.cacheCreationOneHourTokens ?? 0 },
isEstimated: call.isEstimated || undefined,
speed: call.speed,
timestamp: call.timestamp,
tools: call.tools,
@ -1992,6 +2002,7 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
webSearchRequests: u.webSearchRequests,
},
costUSD: call.costUSD ?? costUSD,
isEstimated: call.isEstimated,
tools: call.tools,
mcpTools: extractMcpTools(call.tools),
skills: call.skills,
@ -2528,6 +2539,34 @@ export function filterProjectsByDays(projects: ProjectSummary[], days: Set<strin
return filtered.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
}
// Merge projects that resolve to the same repository across providers (the
// same repo used with Claude Code + Codex, say). An additive total summed at
// the session level but forgotten here silently under-reports for exactly the
// multi-provider users (this bit totalEstimatedCostUSD once, caught in #639
// verification). Known gaps, deliberate: totalSavingsUSD is still not summed
// (pre-existing, tracked separately) and totalProxiedCostUSD is re-derived
// after the merge rather than summed here.
export function mergeProjectsByCrossProviderKey(projects: ProjectSummary[]): Map<string, ProjectSummary> {
const crossProviderKey = (p: ProjectSummary): string => {
const path = p.projectPath.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase()
return path.includes('/') ? path : p.project.toLowerCase()
}
const mergedMap = new Map<string, ProjectSummary>()
for (const p of projects) {
const key = crossProviderKey(p)
const existing = mergedMap.get(key)
if (existing) {
existing.sessions.push(...p.sessions)
existing.totalCostUSD += p.totalCostUSD
existing.totalEstimatedCostUSD = (existing.totalEstimatedCostUSD ?? 0) + (p.totalEstimatedCostUSD ?? 0)
existing.totalApiCalls += p.totalApiCalls
} else {
mergedMap.set(key, { ...p })
}
}
return mergedMap
}
export function filterProjectsByClaudeConfigSource(projects: ProjectSummary[], sourceId: string): ProjectSummary[] {
const filtered: ProjectSummary[] = []
for (const project of projects) {
@ -2641,22 +2680,7 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s
return { ...p, project: projectNameFromPath(canonical.path, p.project), projectPath: canonical.path }
}))
const crossProviderKey = (p: ProjectSummary): string => {
const path = p.projectPath.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase()
return path.includes('/') ? path : p.project.toLowerCase()
}
const mergedMap = new Map<string, ProjectSummary>()
for (const p of [...claudeProjects, ...resolvedOtherProjects]) {
const key = crossProviderKey(p)
const existing = mergedMap.get(key)
if (existing) {
existing.sessions.push(...p.sessions)
existing.totalCostUSD += p.totalCostUSD
existing.totalApiCalls += p.totalApiCalls
} else {
mergedMap.set(key, { ...p })
}
}
const mergedMap = mergeProjectsByCrossProviderKey([...claudeProjects, ...resolvedOtherProjects])
// Re-derive proxy attribution on the merged total: the merge above sums
// totalCostUSD across providers that share a canonical path but never

View file

@ -24,6 +24,9 @@ export type CachedCall = {
model: string
usage: CachedUsage
costUSD?: number
/// True when `costUSD` (or the tokens it is priced from) is estimated rather
/// than metered. Persisted so the estimated-cost marker survives the cache.
isEstimated?: boolean
speed: 'standard' | 'fast'
timestamp: string
tools: string[]
@ -113,25 +116,32 @@ export const PROVIDER_ENV_VARS: Record<string, string[]> = {
// disappear — they are preserved so month-to-date totals never drop.
export const DURABLE_PROVIDER_NAMES: ReadonlySet<string> = new Set(['copilot'])
// Estimated-cost surfacing (#639): providers that set `costIsEstimated` carry a
// `-est-cost` suffix (or a new entry) so their already-cached sessions reparse
// once and the flag lands, instead of silently reading as measured. Copilot
// needs no suffix: the cli-shutdown-cost-v1 bump below already forces its one
// re-parse, which lands the flag too, and durable orphans now survive
// fingerprint changes (the carry-forward in getOrCreateProviderSection).
export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
claude: 'advisor-usage-v1',
cline: 'worktree-project-grouping-v1',
codewhale: 'aggregate-session-v1',
codewhale: 'aggregate-session-v1-est-cost',
// Bump when the Codex parser changes attribution so unchanged, already-cached
// session files re-parse (session-cache.json serves them without invoking the
// provider parser otherwise). Covers native mcp_tool_call_end (#513) and
// CLI-wrapped `mcp-cli call` (#478) MCP attribution.
codex: 'mcp-attribution-v2',
cursor: 'composer-anchored-crediting-v1',
codex: 'mcp-attribution-v2-est-cost',
cursor: 'composer-anchored-crediting-v1-est-cost',
'cursor-agent': 'workspaceless-transcript-v1',
copilot: 'cli-shutdown-cost-v1',
hermes: 'reasoning-output-accounting-v1',
grok: 'estimated-cost-v1',
hermes: 'reasoning-output-accounting-v1-est-cost',
'lingtai-tui': 'token-ledger-registry-activity-v3',
'ibm-bob': 'worktree-project-grouping-v1',
kiro: 'ide-parsing-v1',
kiro: 'ide-parsing-v1-est-cost',
'kilo-code': 'worktree-project-grouping-v1',
'roo-code': 'worktree-project-grouping-v1',
warp: 'worktree-project-grouping-v1',
warp: 'worktree-project-grouping-v1-est-cost',
antigravity: 'worktree-project-grouping-v5',
}
@ -177,6 +187,10 @@ function isOptionalNum(v: unknown): boolean {
return v === undefined || isNum(v)
}
function isOptionalBool(v: unknown): boolean {
return v === undefined || typeof v === 'boolean'
}
function isToolCall(v: unknown): boolean {
if (!v || typeof v !== 'object') return false
const o = v as Record<string, unknown>
@ -213,6 +227,7 @@ function validateCall(c: unknown): c is CachedCall {
&& typeof o['timestamp'] === 'string'
&& (o['speed'] === 'standard' || o['speed'] === 'fast')
&& isOptionalNum(o['costUSD'])
&& isOptionalBool(o['isEstimated'])
&& isStringArray(o['tools'])
&& isStringArray(o['bashCommands'])
&& isStringArray(o['skills'])

View file

@ -116,6 +116,12 @@ export type ParsedApiCall = {
savingsUSD?: number
savingsBaselineModel?: string
isLocalSavings?: boolean
/// True when this call's `costUSD` is priced from estimated token counts or
/// otherwise synthesized by the provider (e.g. Warp/Kiro/Cursor derive tokens
/// from content length). Carried from `ParsedProviderCall.costIsEstimated`
/// across the parser/cache boundary. Aggregates roll the estimated portion up
/// as `estimatedCostUSD`; it is display/metadata only and never changes totals.
isEstimated?: boolean
}
export type ToolCall = {
@ -165,6 +171,10 @@ export type SessionSummary = {
lastTimestamp: string
totalCostUSD: number
totalSavingsUSD: number
/// Portion of `totalCostUSD` contributed by calls whose price is estimated
/// (see `ParsedApiCall.isEstimated`). Optional so SessionSummary fixtures and
/// producers predating the field keep compiling; the parser always sets it.
totalEstimatedCostUSD?: number
totalInputTokens: number
totalOutputTokens: number
totalReasoningTokens: number
@ -172,7 +182,7 @@ export type SessionSummary = {
totalCacheWriteTokens: number
apiCalls: number
turns: ClassifiedTurn[]
modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number }>
modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number; estimatedCostUSD?: number }>
toolBreakdown: Record<string, { calls: number }>
mcpBreakdown: Record<string, { calls: number }>
bashBreakdown: Record<string, { calls: number }>
@ -193,6 +203,9 @@ export type ProjectSummary = {
sessions: SessionSummary[]
totalCostUSD: number
totalSavingsUSD: number
/// Portion of `totalCostUSD` priced from estimated tokens (see
/// `SessionSummary.totalEstimatedCostUSD`). Optional for the same reason.
totalEstimatedCostUSD?: number
totalApiCalls: number
// Portion of `totalCostUSD` served through a subscription-backed proxy
// (config `proxyPaths`). `totalCostUSD` is left at the full API rate (the

View file

@ -16,7 +16,7 @@ import { buildGranularHistory } from './granular-history.js'
export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
const sessions = projects.flatMap(p => p.sessions)
const catTotals: Record<string, { turns: number; cost: number; savingsUSD: number; editTurns: number; oneShotTurns: number }> = {}
const modelTotals: Record<string, { calls: number; cost: number; savingsUSD: number; tokens: number }> = {}
const modelTotals: Record<string, { calls: number; cost: number; savingsUSD: number; estimatedCostUSD: number; tokens: number }> = {}
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0
for (const sess of sessions) {
@ -33,10 +33,11 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri
catTotals[cat].oneShotTurns += d.oneShotTurns
}
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, savingsUSD: 0, tokens: 0 }
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, savingsUSD: 0, estimatedCostUSD: 0, tokens: 0 }
modelTotals[model].calls += d.calls
modelTotals[model].cost += d.costUSD
modelTotals[model].savingsUSD += d.savingsUSD
modelTotals[model].estimatedCostUSD += d.estimatedCostUSD ?? 0
modelTotals[model].tokens += d.tokens.inputTokens + d.tokens.outputTokens + d.tokens.cacheReadInputTokens + d.tokens.cacheCreationInputTokens
}
}
@ -45,6 +46,7 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri
label,
cost: projects.reduce((s, p) => s + p.totalCostUSD, 0),
savingsUSD: projects.reduce((s, p) => s + p.totalSavingsUSD, 0),
estimatedCostUSD: projects.reduce((s, p) => s + (p.totalEstimatedCostUSD ?? 0), 0),
calls: projects.reduce((s, p) => s + p.totalApiCalls, 0),
sessions: projects.reduce((s, p) => s + p.sessions.length, 0),
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
@ -53,7 +55,7 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri
.map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })),
models: Object.entries(modelTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([name, d]) => ({ name, calls: d.calls, cost: d.cost, savingsUSD: d.savingsUSD })),
.map(([name, d]) => ({ name, calls: d.calls, cost: d.cost, savingsUSD: d.savingsUSD, estimatedCostUSD: d.estimatedCostUSD })),
unpricedModels: findUnpricedModels(Object.entries(modelTotals)
.map(([model, d]) => ({ model, calls: d.calls, cost: d.cost, tokens: d.tokens }))),
}
@ -260,6 +262,10 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
}
}
if (isAllProviders) {
// Load-bearing overwrite: the daily-cache path above never carries
// estimatedCostUSD (DailyEntry has no such field), so this fresh-parse
// rebuild is what keeps the estimated marker alive on cached periods.
// Removing it as redundant silently drops the flag.
currentData = buildPeriodData(periodInfo.label, scanProjects)
}
claudeConfigs = claudeConfigs ?? await claudeConfigSelector(scanProjects, null)

View file

@ -0,0 +1,13 @@
import { describe, it, expect } from 'vitest'
import { markEstimated } from '../src/format.js'
describe('markEstimated', () => {
it('prefixes the estimated marker when the figure is estimated', () => {
expect(markEstimated('$1.23', true)).toBe('~$1.23')
})
it('leaves a measured figure untouched', () => {
expect(markEstimated('$1.23', false)).toBe('$1.23')
})
})

View file

@ -11,6 +11,7 @@ function makeProject(opts: {
model: string
provider: string
tokens: { input: number; output: number; cacheR: number; cacheW: number }
estimated?: boolean
}): ProjectSummary {
const usage = {
inputTokens: opts.tokens.input,
@ -33,7 +34,7 @@ function makeProject(opts: {
totalCacheReadTokens: opts.tokens.cacheR,
totalCacheWriteTokens: opts.tokens.cacheW,
apiCalls: opts.calls,
modelBreakdown: { [opts.model]: { calls: opts.calls, costUSD: opts.cost, savingsUSD: 0, tokens: usage } },
modelBreakdown: { [opts.model]: { calls: opts.calls, costUSD: opts.cost, savingsUSD: 0, estimatedCostUSD: opts.estimated ? opts.cost : 0, tokens: usage } },
categoryBreakdown: { coding: { turns: 1, costUSD: opts.cost, savingsUSD: 0, retries: 0, editTurns: 1, oneShotTurns: 1 } },
toolBreakdown: { Bash: { calls: 5 }, Read: { calls: 2 } },
mcpBreakdown: {},
@ -96,6 +97,40 @@ describe('renderOverview', () => {
expect(out).not.toMatch(/\[/)
})
it('marks an estimated model row with a tilde and prints the legend once', () => {
const out = renderOverview([makeProject({
project: 'warpish',
projectPath: '/Users/test/warpish',
cost: 4.2,
calls: 2,
model: 'kiro-auto',
provider: 'kiro',
tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 },
estimated: true,
})], { label: 'June 2026', color: false })
// The estimated cost carries the tilde marker in the Top models table...
expect(out).toContain('~$4.20')
// ...and the legend explains it exactly once.
expect(out).toContain('~ estimated cost (priced from estimated tokens)')
})
it('does not mark a measured model row', () => {
const out = renderOverview([makeProject({
project: 'metered',
projectPath: '/Users/test/metered',
cost: 4.2,
calls: 2,
model: 'claude-opus-4-8',
provider: 'claude',
tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 },
})], { label: 'June 2026', color: false })
expect(out).toContain('$4.20')
expect(out).not.toContain('~$4.20')
expect(out).not.toContain('estimated cost (priced from estimated tokens)')
})
it('reports no usage for an empty range', () => {
const out = renderOverview([], { label: 'June 2026', color: false })
expect(out).toContain('No usage found for June 2026')

View file

@ -0,0 +1,165 @@
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { loadPricing } from '../src/models.js'
import { parseAllSessions, clearSessionCache } from '../src/parser.js'
import type { DateRange, ProjectSummary } from '../src/types.js'
// End-to-end proof for issue #639: a provider that sets `costIsEstimated` on its
// parsed calls must carry that truth all the way onto ParsedApiCall and into the
// session/model aggregates, surviving the session-cache round trip. CodeWhale is
// used because the same provider yields a measured call (metadata carries a real
// `cost`) or an estimated one (no `cost`, so tokens are priced) purely from the
// fixture, which keeps the measured-vs-estimated contrast in one code path.
const FIXTURE_DAY = Date.UTC(2026, 6, 14)
const RANGE: DateRange = {
start: new Date(FIXTURE_DAY - 24 * 60 * 60 * 1000),
end: new Date(FIXTURE_DAY + 24 * 60 * 60 * 1000),
}
let home: string
let cacheDir: string
let prevHome: string | undefined
let prevCache: string | undefined
beforeAll(async () => {
await loadPricing()
})
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'codeburn-est-home-'))
cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-est-cache-'))
prevHome = process.env['CODEWHALE_HOME']
prevCache = process.env['CODEBURN_CACHE_DIR']
process.env['CODEWHALE_HOME'] = home
process.env['CODEBURN_CACHE_DIR'] = cacheDir
clearSessionCache()
})
afterEach(async () => {
clearSessionCache()
if (prevHome === undefined) delete process.env['CODEWHALE_HOME']
else process.env['CODEWHALE_HOME'] = prevHome
if (prevCache === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = prevCache
await rm(home, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
})
async function writeSession(id: string, opts: { cost?: Record<string, number> | null; model?: string }): Promise<void> {
const sessions = join(home, 'sessions')
await mkdir(sessions, { recursive: true })
const metadata: Record<string, unknown> = {
id,
title: 'estimated-cost fixture',
created_at: '2026-07-14T10:00:00.000Z',
updated_at: '2026-07-14T11:00:00.000Z',
message_count: 0,
total_tokens: 200_000,
model: opts.model ?? 'gpt-4o',
model_provider: 'openai',
workspace: '/repos/est-fixture',
mode: 'agent',
}
if (opts.cost !== null && opts.cost !== undefined) metadata.cost = opts.cost
await writeFile(join(sessions, `${id}.json`), JSON.stringify({ schema_version: 1, metadata, messages: [] }))
}
function onlyCall(projects: ProjectSummary[]) {
const calls = projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls)))
expect(calls).toHaveLength(1)
return calls[0]!
}
function onlySession(projects: ProjectSummary[]) {
const sessions = projects.flatMap(p => p.sessions)
expect(sessions).toHaveLength(1)
return sessions[0]!
}
describe('estimated cost propagation (#639)', () => {
it('flags an estimated call and rolls the estimated dollars up through session/model/project', async () => {
await writeSession('estimated', { cost: null })
const projects = await parseAllSessions(RANGE, 'codewhale')
const call = onlyCall(projects)
expect(call.costUSD).toBeGreaterThan(0)
expect(call.isEstimated).toBe(true)
const session = onlySession(projects)
expect(session.totalEstimatedCostUSD).toBeCloseTo(call.costUSD)
const modelEntry = Object.values(session.modelBreakdown)[0]!
expect(modelEntry.estimatedCostUSD).toBeCloseTo(call.costUSD)
expect(projects[0]!.totalEstimatedCostUSD).toBeCloseTo(call.costUSD)
// Metadata only: the estimated portion is never subtracted from cost.
expect(session.totalCostUSD).toBeCloseTo(call.costUSD)
})
it('does not flag a measured call (real cost in metadata)', async () => {
await writeSession('measured', { cost: { session_cost_usd: 0.25 } })
const projects = await parseAllSessions(RANGE, 'codewhale')
const call = onlyCall(projects)
expect(call.costUSD).toBeCloseTo(0.25)
expect(call.isEstimated).toBeFalsy()
const session = onlySession(projects)
expect(session.totalEstimatedCostUSD ?? 0).toBe(0)
expect(Object.values(session.modelBreakdown)[0]!.estimatedCostUSD ?? 0).toBe(0)
expect(projects[0]!.totalEstimatedCostUSD ?? 0).toBe(0)
})
it('keeps the flag after the session-cache round trip (second parse is cache-served)', async () => {
await writeSession('estimated', { cost: null })
const first = await parseAllSessions(RANGE, 'codewhale')
const firstCost = onlyCall(first).costUSD
// Do NOT clear the cache: the second parse hydrates the call from
// session-cache.json, exercising CachedCall.isEstimated persistence.
const second = await parseAllSessions(RANGE, 'codewhale')
const call = onlyCall(second)
expect(call.isEstimated).toBe(true)
expect(call.costUSD).toBeCloseTo(firstCost)
expect(onlySession(second).totalEstimatedCostUSD).toBeCloseTo(firstCost)
})
})
describe('cross-provider project merge (#639 regression)', () => {
function summaryFor(path: string, opts: { cost: number; estimated?: number }): ProjectSummary {
return {
project: path.split('/').pop()!,
projectPath: path,
sessions: [],
totalCostUSD: opts.cost,
totalSavingsUSD: 0,
totalApiCalls: 1,
totalProxiedCostUSD: 0,
...(opts.estimated !== undefined ? { totalEstimatedCostUSD: opts.estimated } : {}),
} as ProjectSummary
}
it('keeps merged-in estimated dollars when two providers share a repo', async () => {
const { mergeProjectsByCrossProviderKey } = await import('../src/parser.js')
const merged = mergeProjectsByCrossProviderKey([
summaryFor('/repos/shared', { cost: 10 }), // measured provider, no estimate
summaryFor('/repos/shared', { cost: 5, estimated: 5 }), // estimated provider
])
expect(merged.size).toBe(1)
const project = [...merged.values()][0]!
expect(project.totalCostUSD).toBe(15)
expect(project.totalEstimatedCostUSD).toBe(5)
})
it('sums estimated dollars when both merged sides carry them', async () => {
const { mergeProjectsByCrossProviderKey } = await import('../src/parser.js')
const merged = mergeProjectsByCrossProviderKey([
summaryFor('/repos/shared', { cost: 3, estimated: 3 }),
summaryFor('/repos/shared', { cost: 4, estimated: 4 }),
])
expect([...merged.values()][0]!.totalEstimatedCostUSD).toBe(7)
})
})

View file

@ -132,6 +132,32 @@ describe('loadCache / saveCache', () => {
expect(loaded.providers['pi']?.files['/path/to/bad.jsonl']?.turns).toEqual([])
})
it('preserves the estimated-cost flag through save/load; a measured call stays unflagged', async () => {
const cache: SessionCache = {
version: CACHE_VERSION,
providers: {
warp: {
envFingerprint: 'abc123',
files: {
'/path/to/warp.sqlite': makeCachedFile({
turns: [makeTurn({ calls: [
makeCall({ deduplicationKey: 'est', costUSD: 0.5, isEstimated: true }),
makeCall({ deduplicationKey: 'measured', costUSD: 0.5 }),
] })],
}),
},
},
},
}
await saveCache(cache)
const loaded = await loadCache()
const calls = loaded.providers['warp']?.files['/path/to/warp.sqlite']?.turns[0]?.calls
expect(calls?.[0]?.isEstimated).toBe(true)
// A call with no flag round-trips as undefined, not silently coerced to true.
expect(calls?.[1]?.isEstimated).toBeUndefined()
})
it('returns empty cache on version mismatch', async () => {
const bad: SessionCache = { version: 999, providers: { claude: { envFingerprint: 'x', files: {} } } }
await mkdir(TMP_DIR, { recursive: true })