Merge remote-tracking branch 'origin/main' into feat/windows-capacity-dock

This commit is contained in:
iamtoruk 2026-09-03 05:16:32 -07:00
commit 378062ebc2
46 changed files with 2165 additions and 308 deletions

View file

@ -28,8 +28,8 @@ There is no separate build step required to run the dev CLI. `npm run dev` runs
| `npm run test:locks` | Runs the three parallelism-sensitive `cache-refresh-lock` suites serially. |
| `npm run test:watch` | Same scope as `npm test`, in watch mode. |
| `npm run dev -- status` | Runs the CLI in dev mode against your real data. |
| `npm run build` | Bundles the litellm pricing snapshot, then runs `tsup` to produce `dist/cli.js`. |
| `npm run bundle-litellm` | Refreshes `src/data/litellm-snapshot.json` from the upstream litellm repo. |
| `npm run build` | Builds the CLI and dashboard from the checked-in pricing catalogs without mutating tracked source files. |
| `npm run bundle-litellm` | Explicitly refreshes the checked-in pricing catalogs from their upstream sources. Review and commit the resulting data changes separately. |
To test a specific suite, run vitest directly with a path:

View file

@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { App, overviewMemoKey, refreshedLabel, selectedReportMemoKeys, topCategoryByModel, usageSnapshotProps } from './App'
@ -500,9 +500,28 @@ describe('App shortcuts', () => {
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'grok'))
})
it('lists a detected provider with no spend this period and sorts it last', async () => {
// Hermes has usage only outside the current period: the CLI still emits it
// as a detected provider (cost 0), so the picker must show it, at the bottom.
it('hides idle providers while preserving explicit zero-cost activity', async () => {
const payload = overviewPayload()
payload.current.providers = { claude: 10, hermes: 0, cursor: 0 }
payload.current.providerDetails = [
{ id: 'claude', label: 'Claude', cost: 10, hasUsage: true },
{ id: 'hermes', label: 'Hermes', cost: 0, hasUsage: false },
{ id: 'cursor', label: 'Cursor', cost: 0, hasUsage: true },
]
mocks.getOverview.mockResolvedValue(payload)
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.click(screen.getByText('All providers'))
expect(await screen.findByRole('option', { name: 'Claude' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Cursor' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: 'Hermes' })).not.toBeInTheDocument()
})
it('keeps zero-cost providers in the picker when the CLI omits hasUsage', async () => {
// Every released CLI omits the field. Falling back to cost > 0 there hid
// subscription-backed providers whose period spend is $0.
const payload = overviewPayload()
payload.current.providers = { claude: 10, hermes: 0 }
payload.current.providerDetails = [
@ -515,14 +534,36 @@ describe('App shortcuts', () => {
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.click(screen.getByText('All providers'))
const claudeOption = await screen.findByRole('option', { name: 'Claude' })
const hermesOption = screen.getByRole('option', { name: 'Hermes' })
const options = screen.getAllByRole('option')
// Zero-cost Hermes appears, and sorts after the provider that has spend.
expect(options.indexOf(hermesOption)).toBeGreaterThan(options.indexOf(claudeOption))
expect(await screen.findByRole('option', { name: 'Claude' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Hermes' })).toBeInTheDocument()
})
fireEvent.click(hermesOption)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'hermes'))
it('does not carry another period provider catalog into a scoped period view', async () => {
const payload = overviewPayload()
payload.current.providerDetails = [
{ id: 'claude', label: 'Claude', cost: 10, hasUsage: true },
{ id: 'hermes', label: 'Hermes', cost: 5, hasUsage: true },
]
mocks.getOverview.mockResolvedValue(payload)
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Providers'))
fireEvent.click(await screen.findByRole('option', { name: 'Claude' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'claude'))
fireEvent.click(screen.getByText('Today'))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('today', 'claude'))
fireEvent.click(screen.getByLabelText('Providers'))
expect(screen.getByRole('option', { name: 'Claude' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: 'Hermes' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /^Sessions/ }))
const sessionFilters = await screen.findByRole('group', { name: 'Filter sessions by provider' })
expect(within(sessionFilters).getByRole('button', { name: 'Claude' })).toBeInTheDocument()
expect(within(sessionFilters).queryByRole('button', { name: 'Hermes' })).not.toBeInTheDocument()
})
it('hides the Claude config picker when the payload carries no claudeConfigs', async () => {
@ -1195,7 +1236,8 @@ describe('usage_snapshot telemetry props', () => {
return {
provider: 'claude', providerDisplayName: 'Claude', category: null,
inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, totalTokens: 0,
costUSD: 0, savingsUSD: 0, savingsBaselineModel: '', calls: 0, credits: null, ...over,
costUSD: 0, savingsUSD: 0, savingsBaselineModel: '', calls: 0, credits: null,
rawModels: [over.model], ...over,
}
}

View file

@ -267,7 +267,11 @@ function AppMain() {
const [settingsPane, setSettingsPane] = useState<SettingsPane>('general')
const [period, setPeriod] = useState<Period>(initialPeriod)
const [provider, setProvider] = useState<string>('all')
const [detectedProviders, setDetectedProviders] = useState<Array<{ id: string; label: string }>>([])
const [providerCatalog, setProviderCatalog] = useState<{
key: string | null
entries: Array<{ id: string; label: string }>
}>({ key: null, entries: [] })
const detectedProviders = providerCatalog.entries
const [customRange, setCustomRange] = useState<DateRange | null>(null)
const [claudeConfigSource, setClaudeConfigSource] = useState<string | null>(initialConfigSource)
const [scope, setScopeState] = useState<Scope>(initialScope)
@ -284,6 +288,10 @@ function AppMain() {
// the config scope before this poll runs. Passing scope='local' produces the
// same flag-free argv as before, so local users are unaffected.
const activeOverviewKey = overviewMemoKey(provider, period, customRange, claudeConfigSource, scope, new Date(now))
// Provider membership is period/range-specific. Keep the catalog tied to the
// exact unscoped local overview that produced it so a scoped view cannot leak
// providers from a different time horizon while its own payload is loading.
const allProviderOverviewKey = overviewMemoKey('all', period, customRange, null, 'local', new Date(now))
const overview = usePolled<MenubarPayload>(
() => scope === 'combined'
? codeburn.getOverview(period, 'all', customRange ?? undefined, undefined, undefined, 'combined')
@ -396,30 +404,41 @@ function AppMain() {
}, [])
useEffect(() => {
if (!overview.data) return
// Only the all-provider payload is authoritative for the picker. A scoped
// payload contains just the selected provider; merging it forever also
// leaked idle providers across period changes.
if (!overview.data || overview.switching || provider !== 'all' || claudeConfigSource || scope !== 'local') return
const details = overview.data.current.providerDetails
// Prefer providerDetails (internal id + display label); fall back to the
// providers map keys (lowercased display names) for older CLIs. The CLI
// only emits detected providers, so keep every entry (including ones with
// no spend this period) and sort by cost so zero-cost ones sit at the
// bottom of the picker.
// providers map keys (lowercased display names) for older CLIs. `hasUsage`
// keeps idle discovery rows out of the picker, but only when the CLI
// actually emits it: every released CLI omits it, and falling back to cost
// there hid subscription-backed providers whose period spend is $0.
const found = details
? [...details]
.filter(entry => entry.hasUsage ?? true)
.sort((a, b) => b.cost - a.cost)
.map(entry => ({ id: entry.id, label: entry.label }))
: Object.entries(overview.data.current.providers)
// Fallback map keys are lowercased display names; ones with spaces
// ("grok build") cannot round-trip as --provider, so exclude them
// rather than offer a filter that is guaranteed to error.
.filter(([key]) => /^[a-z0-9-]+$/.test(key))
.filter(([key, cost]) => cost > 0 && /^[a-z0-9-]+$/.test(key))
.sort(([, a], [, b]) => b - a)
.map(([key]) => ({ id: key, label: providerName(key) }))
setDetectedProviders(current => {
const next = [...current]
for (const item of found) if (!next.some(entry => entry.id === item.id)) next.push(item)
return next.length === current.length ? current : next
})
}, [overview.data])
setProviderCatalog({ key: allProviderOverviewKey, entries: found })
}, [allProviderOverviewKey, claudeConfigSource, overview.data, overview.switching, provider, scope])
const selectedProviderEntry = useMemo(() => provider === 'all'
? null
: detectedProviders.find(entry => entry.id === provider) ?? { id: provider, label: providerName(provider) },
[detectedProviders, provider])
const visibleProviderEntries = useMemo(() => providerCatalog.key === allProviderOverviewKey
? detectedProviders
: selectedProviderEntry
? [selectedProviderEntry]
: [],
[allProviderOverviewKey, detectedProviders, providerCatalog.key, selectedProviderEntry])
useEffect(() => {
const currency = overview.data?.currency
@ -540,7 +559,7 @@ function AppMain() {
// Keep the current-main provider-switch contract while the shared Core
// provider snapshot work is still held: warm the visible period for each
// detected provider only after the higher-value period/report queue.
for (const targetProvider of detectedProviders.map(entry => entry.id)) {
for (const targetProvider of visibleProviderEntries.map(entry => entry.id)) {
if (cancelled || targetProvider === provider) continue
const key = overviewMemoKey(targetProvider, period, null, null)
if (warmedKeys.current.has(key) || hasPolledMemo(key)) continue
@ -566,7 +585,7 @@ function AppMain() {
// `overview.data == null` (a boolean) gates on first-resolution without
// re-running every poll; the data content itself is intentionally not a dep.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, period, provider, detectedProviders, customRange, claudeConfigSource, scope, snapshotRevision, overview.data == null])
}, [ready, period, provider, visibleProviderEntries, customRange, claudeConfigSource, scope, snapshotRevision, overview.data == null])
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000)
@ -670,9 +689,9 @@ function AppMain() {
const claudeConfigs = overview.data?.claudeConfigs
const providerOptions = [
{ value: 'all', label: 'All providers' },
...detectedProviders.map(entry => ({ value: entry.id, label: entry.label })),
...visibleProviderEntries.map(entry => ({ value: entry.id, label: entry.label })),
]
const providerLabel = detectedProviders.find(entry => entry.id === provider)?.label ?? providerName(provider)
const providerLabel = selectedProviderEntry?.label ?? providerName(provider)
const activeConfigLabel = claudeConfigSource
? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? null
: null
@ -727,7 +746,7 @@ function AppMain() {
{section === 'overview' ? (
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} scope={scope} headlineSnapshot={headlineSnapshot} />
) : section === 'sessions' ? (
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} ready={ready} />
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={visibleProviderEntries} onProviderChange={onProviderSelect} ready={ready} />
) : section === 'pullRequests' ? (
<PullRequestsContent overview={overview} period={period} provider={provider} range={customRange} />
) : section === 'spend' ? (

View file

@ -75,4 +75,34 @@ describe('StackedBars', () => {
expect(legend.querySelectorAll('span')).toHaveLength(1)
expect(legend).toHaveTextContent('Claude')
})
it('renders one segment for a model merged from two raw routes and names both routes in the tooltip (#1239)', () => {
const daily = [{
...entry(26),
topModels: [{
name: 'MiniMax M3',
cost: 6.99,
savingsUSD: 0,
calls: 415,
inputTokens: 700,
outputTokens: 150,
rawModels: ['minimax/MiniMax-M3', 'MiniMaxAI/MiniMax-M3'],
}],
}]
const { container } = render(<StackedBars daily={daily} />)
const segments = container.querySelectorAll('.sbars .c .s')
expect(segments).toHaveLength(1)
expect(segments[0]).toHaveAttribute('title', 'MiniMax M3 (minimax/MiniMax-M3, MiniMaxAI/MiniMax-M3) · $6.99')
})
it('omits the route list from the tooltip when only one raw model fed the row', () => {
const daily = [{
...entry(26),
topModels: [{ name: 'GPT-5', cost: 2, savingsUSD: 0, calls: 3, inputTokens: 10, outputTokens: 5 }],
}]
const { container } = render(<StackedBars daily={daily} />)
expect(container.querySelector('.sbars .c .s')).toHaveAttribute('title', 'GPT-5 · $2.00')
})
})

View file

@ -59,12 +59,13 @@ export function StackedBars({ daily, fallbackLabel = 'All models', animateKey =
(a, b) => SERIES_ORDER.indexOf(seriesKeyForModel(a.name)) - SERIES_ORDER.indexOf(seriesKeyForModel(b.name)),
).map(model => {
const pct = Math.max(1, (Math.max(0, model.cost) / maxTotal) * 100)
const routes = model.rawModels && model.rawModels.length > 1 ? ` (${model.rawModels.join(', ')})` : ''
return (
<span
key={`${day.date}-${model.name}`}
className={`s ${seriesClassForModel(model.name)}`}
style={{ height: `${pct}%` }}
title={`${model.name} · ${formatUsd(model.cost)}`}
title={`${model.name}${routes} · ${formatUsd(model.cost)}`}
/>
)
})

View file

@ -56,6 +56,9 @@ export type DailyModelBreakdown = {
calls: number
inputTokens: number
outputTokens: number
// Raw provider/model ids that collapsed into this display name. Present
// only when more than one folded in; absent for an older CLI (#1239).
rawModels?: string[]
}
export type DailyHistoryEntry = {
@ -329,6 +332,9 @@ export type ModelReportRow = {
providerDisplayName: string
model: string
modelDisplayName: string
// Raw provider/model ids folded into this row (e.g. two routes of the same
// model). Length 1 when nothing merged.
rawModels: string[]
category: TaskCategory | null
inputTokens: number
outputTokens: number

View file

@ -20,6 +20,7 @@ const rows: ModelReportRow[] = [
providerDisplayName: 'Anthropic',
model: 'claude-opus-4.8',
modelDisplayName: 'Claude Opus 4.8',
rawModels: ['claude-opus-4.8'],
category: null,
topCategory: 'coding',
topCategoryShare: 0.71,
@ -39,6 +40,7 @@ const rows: ModelReportRow[] = [
providerDisplayName: 'Codex',
model: 'gpt-5.5-codex',
modelDisplayName: 'GPT-5.5 Codex',
rawModels: ['gpt-5.5-codex'],
category: null,
topCategory: 'debugging',
topCategoryShare: 0.42,
@ -58,6 +60,7 @@ const rows: ModelReportRow[] = [
providerDisplayName: 'Local',
model: 'llama-local',
modelDisplayName: 'Llama Local',
rawModels: ['llama-local'],
category: null,
inputTokens: 750_000,
outputTokens: 400_000,
@ -75,6 +78,7 @@ const rows: ModelReportRow[] = [
providerDisplayName: 'Custom',
model: 'my-proxy-model',
modelDisplayName: 'my-proxy-model',
rawModels: ['my-proxy-model'],
category: null,
inputTokens: 4_800_000,
outputTokens: 400_000,

View file

@ -272,7 +272,7 @@ function ProvidersPane({ period, refreshToken }: { period: Period; refreshToken:
// the internal id. Fall back to the providers map keys (lowercased display
// names) for older CLIs that omit providerDetails.
const providers = details
? details.filter(entry => entry.hasUsage ?? entry.cost > 0).map(entry => ({ id: entry.id, label: entry.label, cost: entry.cost }))
? details.filter(entry => entry.hasUsage ?? true).map(entry => ({ id: entry.id, label: entry.label, cost: entry.cost }))
: Object.entries(overview.data?.current.providers ?? {}).map(([id, cost]) => ({ id, label: id.charAt(0).toUpperCase() + id.slice(1), cost }))
return <section className="set-p on">
<div><h3 className="set-h">Providers</h3><p className="set-sub">codeburn auto-detects coding tools from local session files. No setup needed.</p></div>

View file

@ -8,6 +8,10 @@ private let menubarPeriodDefaultsKey = "CodeBurnMenubarPeriod"
struct CachedPayload {
let payload: MenubarPayload
let fetchedAt: Date
/// This scoped payload still disagreed with its all-provider slice after a
/// resident-bypassing one-shot re-parse. It is the best answer the CLI can
/// give, so it is served rather than discarded, with the UI saying so.
var contradictsAll: Bool = false
var isFresh: Bool { Date().timeIntervalSince(fetchedAt) < cacheTTLSeconds }
}
@ -281,10 +285,21 @@ final class AppStore {
)
}
/// Capture the keys that must describe one selection before any suspension
/// point. Reading `periodAllKey` before an await and `currentKey` afterward
/// can otherwise pair evidence from two different user selections.
private func selectionRefreshKeys() -> (
scoped: PayloadCacheKey,
local: PayloadCacheKey,
all: PayloadCacheKey
) {
(scoped: currentKey, local: localCurrentKey, all: periodAllKey)
}
var payload: MenubarPayload {
if effectiveSelectedScope == .combined {
let combinedPayload = cache[currentKey]?.payload
if let localPayload = cache[localCurrentKey]?.payload {
if let localPayload = consistentCachedPayload(for: localCurrentKey) {
if let combined = combinedPayload?.combined {
return MenubarPayload(
generated: combinedPayload?.generated ?? localPayload.generated,
@ -301,7 +316,11 @@ final class AppStore {
return combinedPayload
}
}
return cache[currentKey]?.payload ?? .empty
// No fallback to the PREVIOUS selection's payload. It belonged to a
// different provider, so serving it under this tab printed another
// provider's dollar figure as if it were this one's. Empty here means
// `hasCachedData` is false and the popover renders its loading state.
return consistentCachedPayload(for: currentKey) ?? .empty
}
/// Today (across all providers) backs day-specific views in the popover.
@ -403,7 +422,8 @@ final class AppStore {
}
var hasCachedData: Bool {
cache[currentKey] != nil || (effectiveSelectedScope == .combined && cache[localCurrentKey] != nil)
consistentCachedPayload(for: currentKey) != nil
|| (effectiveSelectedScope == .combined && consistentCachedPayload(for: localCurrentKey) != nil)
}
var hasStaleLoading: Bool {
@ -445,7 +465,10 @@ final class AppStore {
if effectiveSelectedScope == .combined {
requiredKeys.insert(localCurrentKey)
}
return requiredKeys.contains { cache[$0]?.isFresh != true } || hasStaleLoading
return requiredKeys.contains { key in
guard let cached = cache[key], cached.isFresh else { return true }
return providerPayloadContradictsAll(cached.payload, for: key)
} || hasStaleLoading
}
/// True if any cached payload reports at least one provider. Used to keep the
@ -477,6 +500,26 @@ final class AppStore {
cache[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days, claudeConfigSourceId: claudeConfigSourceId)]?.payload
}
func providerPayloadContradictsAllForTesting(_ payload: MenubarPayload,
scope: MenubarScope = .local,
period: Period,
provider: ProviderFilter,
day: String? = nil,
days: Set<String> = [],
claudeConfigSourceId: String? = nil) -> Bool {
providerPayloadContradictsAll(
payload,
for: PayloadCacheKey(
scope: scope,
period: period,
provider: provider,
day: day,
days: days,
claudeConfigSourceId: claudeConfigSourceId
)
)
}
func setLastErrorForTesting(_ error: String,
scope: MenubarScope = .local,
period: Period,
@ -491,13 +534,47 @@ final class AppStore {
provider: ProviderFilter,
day: String? = nil,
insertedAt: Date) {
inFlightKeys[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)] = insertedAt
inFlightKeys[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)] =
InFlightSlot(startedAt: insertedAt, token: UUID())
}
func setCacheDateToTodayForTesting() {
cacheDate = currentCacheDate()
}
func isInFlightForTesting(scope: MenubarScope = .local, period: Period, provider: ProviderFilter, day: String? = nil) -> Bool {
inFlightKeys[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)] != nil
}
func refreshQuietlyForTesting(scope: MenubarScope = .local,
period: Period,
provider: ProviderFilter,
day: String? = nil) async -> Bool {
await refreshQuietly(
key: PayloadCacheKey(scope: scope, period: period, provider: provider, day: day),
includeOptimize: false
)
}
func inFlightWaiterCountForTesting(scope: MenubarScope = .local,
period: Period,
provider: ProviderFilter,
day: String? = nil) -> Int {
inFlightWaiters[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)]?.count ?? 0
}
func finishInFlightForTesting(scope: MenubarScope = .local,
period: Period,
provider: ProviderFilter,
day: String? = nil) {
forceFinishInFlight(for: PayloadCacheKey(scope: scope, period: period, provider: provider, day: day))
}
func selectionRefreshKeysForTesting() -> (scoped: PayloadCacheKey, all: PayloadCacheKey) {
let snapshot = selectionRefreshKeys()
return (snapshot.scoped, snapshot.all)
}
func suppressRefreshesForTesting() {
refreshSuppressedForTesting = true
}
@ -626,33 +703,107 @@ final class AppStore {
let key = PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days, claudeConfigSourceId: claudeConfigSourceId)
let localKey = PayloadCacheKey(scope: .local, period: period, provider: provider, day: day, days: days, claudeConfigSourceId: claudeConfigSourceId)
let allKey = PayloadCacheKey(scope: .local, period: period, provider: .all, day: day, days: days, claudeConfigSourceId: claudeConfigSourceId)
switchTask = Task {
switchTask = Task { [weak self] in
guard let self else { return }
// The all-provider slice is the evidence that lets a scoped false
// zero be rejected, so it must be present BEFORE the scoped result
// is accepted. It does not have to be present before the scoped
// fetch STARTS: both run together and only the acceptance is
// ordered, via the gate passed as acceptAfter.
let allTask: Task<Bool, Never>? = provider == .all
? nil
: self.startAllProviderEvidence(key: allKey, force: false)
let gate: (@Sendable () async -> Void)? = allTask.map { task in { @Sendable in _ = await task.value } }
if scope == .combined {
async let local = refresh(key: localKey, includeOptimize: false, force: false, showLoading: false)
async let combined = refresh(key: key, includeOptimize: false, force: false, showLoading: false)
if provider == .all {
_ = await (local, combined)
} else {
async let all = refreshQuietly(key: allKey, includeOptimize: false, force: false)
_ = await (local, combined, all)
}
} else if provider == .all {
await refresh(key: key, includeOptimize: false, force: false, showLoading: false)
async let local = self.refresh(key: localKey, includeOptimize: false, force: false, showLoading: false, acceptAfter: gate)
async let combined = self.refresh(key: key, includeOptimize: false, force: false, showLoading: false, acceptAfter: gate)
_ = await (local, combined)
} else {
async let main = refresh(key: key, includeOptimize: false, force: false, showLoading: false)
async let all = refreshQuietly(key: allKey, includeOptimize: false, force: false)
_ = await (main, all)
await self.refresh(key: key, includeOptimize: false, force: false, showLoading: false, acceptAfter: gate)
}
_ = await allTask?.value
}
}
private var inFlightKeys: [PayloadCacheKey: Date] = [:]
/// Start the all-provider fetch immediately and hand back its task, so a
/// scoped fetch can run alongside it and merely AWAIT it before accepting.
private func startAllProviderEvidence(
key: PayloadCacheKey,
force: Bool,
qualityOfService: QualityOfService = .userInitiated
) -> Task<Bool, Never> {
Task { [weak self] in
guard let self else { return false }
return await self.refreshQuietly(
key: key,
includeOptimize: false,
force: force,
qualityOfService: qualityOfService
)
}
}
/// One occupancy of a key's in-flight slot. The token identifies WHICH
/// fetch owns the slot right now: the watchdog and the display-sleep reset
/// can evict an occupant, after which a later fetch legitimately claims the
/// same key. Without the token the evicted fetch's `defer` would then clear
/// the NEW owner's slot and resume its waiters, so a task parked on the
/// live fetch woke to an empty cache and reported failure.
private struct InFlightSlot {
let startedAt: Date
let token: UUID
}
private var inFlightKeys: [PayloadCacheKey: InFlightSlot] = [:]
private var inFlightWaiters: [PayloadCacheKey: [CheckedContinuation<Void, Never>]] = [:]
private func waitForInFlight(_ key: PayloadCacheKey) async {
guard inFlightKeys[key] != nil else { return }
await withCheckedContinuation { continuation in
guard inFlightKeys[key] != nil else {
continuation.resume()
return
}
inFlightWaiters[key, default: []].append(continuation)
}
}
private func claimInFlight(_ key: PayloadCacheKey) -> UUID {
let token = UUID()
inFlightKeys[key] = InFlightSlot(startedAt: Date(), token: token)
return token
}
/// Release the slot only if this fetch still owns it. A fetch whose slot was
/// evicted has nothing left to release and no waiters of its own.
private func finishInFlight(for key: PayloadCacheKey, token: UUID) {
guard inFlightKeys[key]?.token == token else { return }
forceFinishInFlight(for: key)
}
/// Evict whoever holds the slot and wake everyone parked on it. Only the
/// watchdog and the pipeline resets use this: they are declaring the slot
/// dead, not reporting a fetch that ended.
private func forceFinishInFlight(for key: PayloadCacheKey) {
inFlightKeys[key] = nil
let waiters = inFlightWaiters.removeValue(forKey: key) ?? []
for waiter in waiters {
waiter.resume()
}
}
private func finishAllInFlight() {
let keys = Set(inFlightKeys.keys).union(inFlightWaiters.keys)
for key in keys {
forceFinishInFlight(for: key)
}
}
func resetLoadingState() {
payloadRefreshGeneration &+= 1
loadingCountsByKey.removeAll()
loadingStartedAtByKey.removeAll()
inFlightKeys.removeAll()
finishAllInFlight()
attemptedKeys.removeAll()
}
@ -675,8 +826,8 @@ final class AppStore {
let staleLoading = loadingStartedAtByKey.filter {
now.timeIntervalSince($0.value) > loadingWatchdogSeconds
}
let staleInFlight = inFlightKeys.filter { (key, insertedAt) in
now.timeIntervalSince(insertedAt) > loadingWatchdogSeconds &&
let staleInFlight = inFlightKeys.filter { (key, slot) in
now.timeIntervalSince(slot.startedAt) > loadingWatchdogSeconds &&
loadingStartedAtByKey[key] == nil
}
guard !staleLoading.isEmpty || !staleInFlight.isEmpty else { return false }
@ -686,15 +837,15 @@ final class AppStore {
Int(now.timeIntervalSince(started)), key.label, key.provider.rawValue)
loadingCountsByKey[key] = nil
loadingStartedAtByKey[key] = nil
inFlightKeys[key] = nil
forceFinishInFlight(for: key)
if cache[key] == nil {
lastErrorByKey[key] = "Refresh took longer than expected. CodeBurn will keep retrying in the background."
}
}
for (key, insertedAt) in staleInFlight {
for (key, slot) in staleInFlight {
NSLog("CodeBurn: orphaned in-flight key stuck for %ds on %@/%@ — clearing",
Int(now.timeIntervalSince(insertedAt)), key.label, key.provider.rawValue)
inFlightKeys[key] = nil
Int(now.timeIntervalSince(slot.startedAt)), key.label, key.provider.rawValue)
forceFinishInFlight(for: key)
}
return true
}
@ -729,7 +880,7 @@ final class AppStore {
cache.removeAll()
loadingCountsByKey.removeAll()
loadingStartedAtByKey.removeAll()
inFlightKeys.removeAll()
finishAllInFlight()
attemptedKeys.removeAll()
lastErrorByKey.removeAll()
cacheDate = today
@ -750,6 +901,128 @@ final class AppStore {
}
}
/// A cached payload the refresh paths may keep instead of refetching. A
/// contradicting one is refetched; one already VERIFIED as contradicting is
/// not, or every poll would re-run the expensive one-shot parse forever.
private func cachedPayloadIsUsable(_ cached: CachedPayload, for key: PayloadCacheKey) -> Bool {
cached.contradictsAll || !providerPayloadContradictsAll(cached.payload, for: key)
}
private func consistentCachedPayload(for key: PayloadCacheKey) -> MenubarPayload? {
guard let cached = cache[key] else { return nil }
// A payload already verified by a resident-bypassing re-parse is served
// even though it still disagrees: it is the CLI's real answer, and
// `selectedPayloadMayBeIncomplete` is what tells the user so.
if cached.contradictsAll { return cached.payload }
guard !providerPayloadContradictsAll(cached.payload, for: key) else { return nil }
return cached.payload
}
/// The visible payload was verified against its all-provider slice and
/// still disagreed. The popover shows one subdued line rather than an error.
var selectedPayloadMayBeIncomplete: Bool {
if cache[currentKey]?.contradictsAll == true { return true }
return effectiveSelectedScope == .combined && cache[localCurrentKey]?.contradictsAll == true
}
/// An all-provider payload and its scoped sibling describe the same period,
/// day selection, scope, and Claude config. If the all-provider slice has a
/// positive contribution for the selected provider, a scoped 0/0 payload is
/// stale or incomplete rather than an honest answer. This is the exact
/// contract that prevents a provider tab advertising spend and then opening
/// onto a fabricated zero.
private func providerPayloadContradictsAll(_ payload: MenubarPayload, for key: PayloadCacheKey) -> Bool {
guard key.scope == .local,
key.provider != .all else { return false }
let allKey = PayloadCacheKey(
scope: .local,
period: key.period,
provider: .all,
day: key.day,
days: key.days,
claudeConfigSourceId: key.claudeConfigSourceId
)
guard let all = cache[allKey]?.payload else { return false }
let providerKeys = Set(key.provider.providerKeys + [key.provider.cliArg])
let detail = all.current.providerDetails.first(where: {
providerKeys.contains($0.id.lowercased()) || providerKeys.contains($0.label.lowercased())
})
let allCost = detail?.cost ?? key.provider.providerKeys.reduce(0.0) { sum, providerKey in
sum + (all.current.providers[providerKey] ?? 0)
}
let allCalls = detail?.calls
let allHasUsage = detail?.hasUsage ?? (allCost > 0)
// Cost and call dimensions are independent. A scoped result that keeps
// its calls but loses the spend (or vice versa) is still stale.
if allCost > 0, payload.current.cost == 0 { return true }
if let allCalls, allCalls > 0, payload.current.calls == 0 { return true }
// `hasUsage` is authoritative for subscription/token-only providers,
// where both cost and behavioral calls may legitimately be zero.
let scopedDetail = payload.current.providerDetails.first(where: {
providerKeys.contains($0.id.lowercased()) || providerKeys.contains($0.label.lowercased())
})
let scopedHasUsage = scopedDetail?.hasUsage ?? (
payload.current.cost > 0
|| payload.current.calls > 0
|| payload.current.sessions > 0
|| payload.current.inputTokens > 0
|| payload.current.outputTokens > 0
)
return allHasUsage && !scopedHasUsage
}
private func fetchPayload(
for key: PayloadCacheKey,
includeOptimize: Bool,
qualityOfService: QualityOfService,
acceptAfter: (@Sendable () async -> Void)? = nil
) async throws -> (payload: MenubarPayload, contradictsAll: Bool) {
let fresh = try await DataClient.fetch(
period: key.period,
day: key.day,
days: key.days,
provider: key.provider,
includeOptimize: includeOptimize,
scope: key.scope,
claudeConfigSourceId: key.claudeConfigSourceId,
qualityOfService: qualityOfService
)
// The all-provider slice is the evidence providerPayloadContradictsAll
// reads. It is awaited HERE, after this fetch has already run, so the
// two parses overlap and only the ACCEPTANCE is ordered. Waiting for it
// before starting cost a full serialized parse on every tab click.
await acceptAfter?()
guard providerPayloadContradictsAll(fresh, for: key) else { return (fresh, false) }
NSLog("CodeBurn: resident %@ payload contradicted its all-provider slice; verifying with a one-shot parse",
key.provider.rawValue)
let verified = try await DataClient.fetch(
period: key.period,
day: key.day,
days: key.days,
provider: key.provider,
includeOptimize: includeOptimize,
scope: key.scope,
claudeConfigSourceId: key.claudeConfigSourceId,
bypassResident: true,
qualityOfService: qualityOfService
)
// A second contradiction is not an error to show the user. The one-shot
// parse bypassed the resident child, so this IS what the CLI reports for
// this provider right now; throwing here replaced a real number with an
// error banner and left the tab with nothing at all. Serve it, flagged,
// and let the popover add one subdued line saying it may be incomplete.
let stillContradicts = providerPayloadContradictsAll(verified, for: key)
if stillContradicts {
NSLog("CodeBurn: verified %@ payload still contradicted its all-provider slice; serving it as possibly incomplete",
key.provider.rawValue)
}
return (verified, stillContradicts)
}
@discardableResult
func recoverFromStuckLoading() async -> Bool {
guard prepareStuckLoadingRecovery() else { return false }
@ -785,29 +1058,39 @@ final class AppStore {
showLoading: Bool = false,
qualityOfService: QualityOfService = .userInitiated
) async -> Bool {
if effectiveSelectedScope == .combined {
let keys = selectionRefreshKeys()
// Runs concurrently with the scoped fetch below; only the acceptance is
// ordered behind it (see fetchPayload's acceptAfter).
let allTask: Task<Bool, Never>? = keys.scoped.provider == .all
? nil
: startAllProviderEvidence(key: keys.all, force: force, qualityOfService: qualityOfService)
let gate: (@Sendable () async -> Void)? = allTask.map { task in { @Sendable in _ = await task.value } }
if keys.scoped.scope == .combined {
async let local = refreshQuietly(
key: localCurrentKey,
key: keys.local,
includeOptimize: includeOptimize,
force: force,
qualityOfService: qualityOfService
qualityOfService: qualityOfService,
acceptAfter: gate
)
async let combined = refresh(
key: currentKey,
key: keys.scoped,
includeOptimize: includeOptimize,
force: force,
showLoading: showLoading,
qualityOfService: qualityOfService
qualityOfService: qualityOfService,
acceptAfter: gate
)
let (localSucceeded, combinedSucceeded) = await (local, combined)
return localSucceeded && combinedSucceeded
} else {
return await refresh(
key: currentKey,
key: keys.scoped,
includeOptimize: includeOptimize,
force: force,
showLoading: showLoading,
qualityOfService: qualityOfService
qualityOfService: qualityOfService,
acceptAfter: gate
)
}
}
@ -821,13 +1104,34 @@ final class AppStore {
days: selectedDays,
claudeConfigSourceId: selectedClaudeConfigSourceId
)
let localKey = PayloadCacheKey(
scope: .local,
period: scopedKey.period,
provider: scopedKey.provider,
day: scopedKey.day,
days: scopedKey.days,
claudeConfigSourceId: scopedKey.claudeConfigSourceId
)
let allKey = PayloadCacheKey(
scope: .local,
period: scopedKey.period,
provider: .all,
day: scopedKey.day,
days: scopedKey.days,
claudeConfigSourceId: scopedKey.claudeConfigSourceId
)
let allTask: Task<Bool, Never>? = scopedKey.provider == .all
? nil
: startAllProviderEvidence(key: allKey, force: force)
let gate: (@Sendable () async -> Void)? = allTask.map { task in { @Sendable in _ = await task.value } }
if scope == .combined {
async let local = refreshQuietly(key: localCurrentKey, includeOptimize: false, force: false)
async let combined = refreshQuietly(key: scopedKey, includeOptimize: false, force: force)
async let local = refreshQuietly(key: localKey, includeOptimize: false, force: false, acceptAfter: gate)
async let combined = refreshQuietly(key: scopedKey, includeOptimize: false, force: force, acceptAfter: gate)
_ = await (local, combined)
} else {
await refreshQuietly(key: scopedKey, includeOptimize: false, force: force)
await refreshQuietly(key: scopedKey, includeOptimize: false, force: force, acceptAfter: gate)
}
_ = await allTask?.value
}
@discardableResult
@ -836,15 +1140,26 @@ final class AppStore {
includeOptimize: Bool,
force: Bool = false,
showLoading: Bool = false,
qualityOfService: QualityOfService = .userInitiated
qualityOfService: QualityOfService = .userInitiated,
acceptAfter: (@Sendable () async -> Void)? = nil
) async -> Bool {
invalidateStaleDayCache()
let cacheDateAtStart = cacheDate
let generationAtStart = payloadRefreshGeneration
if Task.isCancelled { return false }
if !force, cache[key]?.isFresh == true { return true }
if inFlightKeys[key] != nil { return false }
inFlightKeys[key] = Date()
if !force,
let cached = cache[key],
cached.isFresh,
cachedPayloadIsUsable(cached, for: key) { return true }
// Join an in-flight fetch instead of reporting failure. Returning false
// here made recoverFromStuckLoading() announce a failed recovery while a
// perfectly healthy fetch was still running, which is what the quiet
// path already avoided by waiting.
if inFlightKeys[key] != nil {
await waitForInFlight(key)
return consistentCachedPayload(for: key) != nil
}
let inFlightToken = claimInFlight(key)
attemptedKeys.insert(key)
lastErrorByKey[key] = nil
let didShowLoading = showLoading || cache[key] == nil
@ -864,7 +1179,7 @@ final class AppStore {
}
defer {
let abandonedAttempt = Task.isCancelled || generationAtStart != payloadRefreshGeneration
inFlightKeys[key] = nil
finishInFlight(for: key, token: inFlightToken)
if didShowLoading {
finishLoading(for: key)
}
@ -874,15 +1189,11 @@ final class AppStore {
}
var succeeded = false
do {
let fresh = try await DataClient.fetch(
period: key.period,
day: key.day,
days: key.days,
provider: key.provider,
let fresh = try await fetchPayload(
for: key,
includeOptimize: includeOptimize,
scope: key.scope,
claudeConfigSourceId: key.claudeConfigSourceId,
qualityOfService: qualityOfService
qualityOfService: qualityOfService,
acceptAfter: acceptAfter
)
if generationAtStart != payloadRefreshGeneration {
NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch")
@ -905,8 +1216,8 @@ final class AppStore {
NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — calendar rolled mid-fetch")
return false
}
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
reconcileClaudeConfigSelection(from: fresh, for: key)
cache[key] = CachedPayload(payload: fresh.payload, fetchedAt: Date(), contradictsAll: fresh.contradictsAll)
reconcileClaudeConfigSelection(from: fresh.payload, for: key)
lastSuccessByKey[key] = Date()
lastErrorByKey[key] = nil
succeeded = true
@ -915,14 +1226,9 @@ final class AppStore {
NSLog("CodeBurn: fetch failed for \(key.label)/\(key.provider.rawValue): \(error)")
if includeOptimize, cache[key] == nil {
do {
let fallback = try await DataClient.fetch(
period: key.period,
day: key.day,
days: key.days,
provider: key.provider,
let fallback = try await fetchPayload(
for: key,
includeOptimize: false,
scope: key.scope,
claudeConfigSourceId: key.claudeConfigSourceId,
qualityOfService: qualityOfService
)
guard !Task.isCancelled else { return false }
@ -931,8 +1237,8 @@ final class AppStore {
invalidateStaleDayCache()
return false
}
cache[key] = CachedPayload(payload: fallback, fetchedAt: Date())
reconcileClaudeConfigSelection(from: fallback, for: key)
cache[key] = CachedPayload(payload: fallback.payload, fetchedAt: Date(), contradictsAll: fallback.contradictsAll)
reconcileClaudeConfigSelection(from: fallback.payload, for: key)
lastSuccessByKey[key] = Date()
lastErrorByKey[key] = nil
return true
@ -990,12 +1296,19 @@ final class AppStore {
key: PayloadCacheKey,
includeOptimize: Bool,
force: Bool = false,
qualityOfService: QualityOfService = .userInitiated
qualityOfService: QualityOfService = .userInitiated,
acceptAfter: (@Sendable () async -> Void)? = nil
) async -> Bool {
invalidateStaleDayCache()
if !force, cache[key]?.isFresh == true { return true }
if inFlightKeys[key] != nil { return false }
inFlightKeys[key] = Date()
if !force,
let cached = cache[key],
cached.isFresh,
cachedPayloadIsUsable(cached, for: key) { return true }
if inFlightKeys[key] != nil {
await waitForInFlight(key)
return consistentCachedPayload(for: key) != nil
}
let inFlightToken = claimInFlight(key)
attemptedKeys.insert(key)
let cacheDateAtStart = cacheDate
let generationAtStart = payloadRefreshGeneration
@ -1003,18 +1316,14 @@ final class AppStore {
NSLog("CodeBurn: refreshing stale today status payload after %ds", age)
}
defer {
inFlightKeys[key] = nil
finishInFlight(for: key, token: inFlightToken)
}
do {
let fresh = try await DataClient.fetch(
period: key.period,
day: key.day,
days: key.days,
provider: key.provider,
let fresh = try await fetchPayload(
for: key,
includeOptimize: includeOptimize,
scope: key.scope,
claudeConfigSourceId: key.claudeConfigSourceId,
qualityOfService: qualityOfService
qualityOfService: qualityOfService,
acceptAfter: acceptAfter
)
if generationAtStart != payloadRefreshGeneration {
NSLog("CodeBurn: dropping quiet fetch result for \(key.label) — refresh pipeline reset mid-fetch")
@ -1026,8 +1335,8 @@ final class AppStore {
invalidateStaleDayCache()
return false
}
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
reconcileClaudeConfigSelection(from: fresh, for: key)
cache[key] = CachedPayload(payload: fresh.payload, fetchedAt: Date(), contradictsAll: fresh.contradictsAll)
reconcileClaudeConfigSelection(from: fresh.payload, for: key)
lastSuccessByKey[key] = Date()
lastErrorByKey[key] = nil
} catch {

View file

@ -47,6 +47,14 @@ struct CLIDecodeFailure: Error, CustomStringConvertible {
/// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route
/// commands through `/bin/zsh -c` anymore.
struct DataClient {
#if DEBUG
/// Test seam. The in-flight slot and the all-provider acceptance ordering
/// are only observable when two REAL fetches overlap, which needs a fetch
/// whose completion the test controls rather than a spawned CLI.
@MainActor
static var fetchHookForTesting: (@Sendable (ProviderFilter, Bool) async throws -> MenubarPayload)?
#endif
static func fetch(period: Period,
day: String? = nil,
days: Set<String> = [],
@ -54,7 +62,13 @@ struct DataClient {
includeOptimize: Bool,
scope: MenubarScope = .local,
claudeConfigSourceId: String? = nil,
bypassResident: Bool = false,
qualityOfService: QualityOfService = .userInitiated) async throws -> MenubarPayload {
#if DEBUG
if let hook = await MainActor.run(body: { fetchHookForTesting }) {
return try await hook(provider, bypassResident)
}
#endif
let subcommand = statusSubcommand(
period: period,
day: day,
@ -64,7 +78,11 @@ struct DataClient {
scope: scope,
claudeConfigSourceId: claudeConfigSourceId
)
let result = try await runCLI(subcommand: subcommand, qualityOfService: qualityOfService)
let result = try await runCLI(
subcommand: subcommand,
bypassResident: bypassResident,
qualityOfService: qualityOfService
)
guard result.exitCode == 0 else {
throw DataClientError.nonZeroExit(code: result.exitCode, stderr: result.stderr)
}
@ -133,10 +151,12 @@ struct DataClient {
private static func runCLI(
subcommand: [String],
bypassResident: Bool = false,
qualityOfService: QualityOfService = .userInitiated
) async throws -> ProcessResult {
try await runCLI(
subcommand: subcommand,
bypassResident: bypassResident,
serveRequest: { args in
try await ServeConnection.shared.request(args: args)
},
@ -164,6 +184,7 @@ struct DataClient {
/// the shared resident and globally limited one-shot closures above.
static func runCLI(
subcommand: [String],
bypassResident: Bool = false,
serveRequest: ([String]) async throws -> Data,
spawnFallback: () async throws -> ProcessResult
) async throws -> ProcessResult {
@ -172,7 +193,7 @@ struct DataClient {
// Transport/protocol failures fall back to the spawn path below, so
// the resident remains an optimization. Resource-policy failures stay
// terminal and cannot bypass the resident output ceiling.
if ServeConnection.isEligible(subcommand) {
if !bypassResident, ServeConnection.isEligible(subcommand) {
do {
let stdout = try await serveRequest(subcommand)
return ProcessResult(stdout: stdout, stderr: "", exitCode: 0)

View file

@ -389,18 +389,14 @@ struct ProviderDetail: Codable, Sendable {
id = try c.decode(String.self, forKey: .id)
label = try c.decode(String.self, forKey: .label)
cost = try c.decode(Double.self, forKey: .cost)
// Older CLIs emitted providerDetails without calls/hasUsage. When calls
// exists, derive activity from calls/cost; with neither signal, keep the
// detected provider visible rather than hiding valid $0 subscriptions.
let decodedCalls = try c.decodeIfPresent(Int.self, forKey: .calls)
calls = decodedCalls ?? 0
if let decodedUsage = try c.decodeIfPresent(Bool.self, forKey: .hasUsage) {
hasUsage = decodedUsage
} else if decodedCalls != nil {
hasUsage = cost > 0 || calls > 0
} else {
hasUsage = true
}
calls = try c.decodeIfPresent(Int.self, forKey: .calls) ?? 0
// EVERY released CLI omits hasUsage, so the absent case is the common
// one, not a legacy edge. Deriving activity from cost there hid every
// subscription-backed provider whose period spend is $0 (Hermes, Kimi,
// Copilot on an included plan). A provider the payload bothered to list
// is one the user has, so absent means visible; the strict signal
// applies only when the field is actually present.
hasUsage = try c.decodeIfPresent(Bool.self, forKey: .hasUsage) ?? true
}
}
@ -414,7 +410,9 @@ enum ProviderVisibility {
.filter(\.hasUsage)
.flatMap { [$0.id.lowercased(), $0.label.lowercased()] })
}
return Set(legacyProviders.keys.map { $0.lowercased() })
return Set(legacyProviders.compactMap { key, cost in
cost > 0 ? key.lowercased() : nil
})
}
}

View file

@ -0,0 +1,34 @@
import Foundation
struct ProviderReconnectPresentation: Sendable, Equatable {
let title: String
let defaultReason: String
let instruction: String
init(provider: ProviderFilter) {
title = "Reconnect \(provider.rawValue)"
switch provider {
case .claude:
defaultReason = "Claude Code credentials need to be refreshed."
instruction = "Open Claude Code in your terminal and type `/login`, then click Reconnect."
case .codex:
defaultReason = "Codex credentials need to be refreshed."
instruction = "Run `codex login` in your terminal, then click Reconnect."
case .kimiCode:
defaultReason = "Kimi Code credentials need to be refreshed."
instruction = "Run the Kimi CLI once to refresh your login, then click Reconnect."
case .gemini:
defaultReason = "Gemini credentials need to be refreshed."
instruction = "Run the Gemini CLI once to refresh your login, then click Reconnect."
case .copilot:
defaultReason = "Copilot credentials need to be refreshed."
instruction = "Sign in with the Copilot CLI, an editor plugin, or `gh auth login`, then click Reconnect."
case .antigravity:
defaultReason = "The local Antigravity service is unavailable."
instruction = "Start the Antigravity app, then click Reconnect."
default:
defaultReason = "\(provider.rawValue) credentials need to be refreshed."
instruction = "Sign in to \(provider.rawValue) again, then retry."
}
}
}

View file

@ -0,0 +1,51 @@
import Foundation
enum ProviderStripPagingDirection: Sendable {
case backward
case forward
var offset: Int {
switch self {
case .backward: -1
case .forward: 1
}
}
}
struct ProviderStripPagingState: Sendable, Equatable {
let filters: [ProviderFilter]
let selectedProvider: ProviderFilter
private(set) var viewportAnchor: ProviderFilter?
init(
filters: [ProviderFilter],
selectedProvider: ProviderFilter,
viewportAnchor: ProviderFilter?
) {
self.filters = filters
self.selectedProvider = selectedProvider
if let viewportAnchor, filters.contains(viewportAnchor) {
self.viewportAnchor = viewportAnchor
} else if filters.contains(selectedProvider) {
self.viewportAnchor = selectedProvider
} else {
self.viewportAnchor = filters.first
}
}
var canMoveBackward: Bool { anchorIndex > 0 }
var canMoveForward: Bool { anchorIndex >= 0 && anchorIndex < filters.count - 1 }
@discardableResult
mutating func move(direction: ProviderStripPagingDirection) -> ProviderFilter? {
guard !filters.isEmpty else { return viewportAnchor }
let targetIndex = min(max(anchorIndex + direction.offset, 0), filters.count - 1)
viewportAnchor = filters[targetIndex]
return viewportAnchor
}
private var anchorIndex: Int {
guard let viewportAnchor else { return -1 }
return filters.firstIndex(of: viewportAnchor) ?? -1
}
}

View file

@ -16,6 +16,7 @@ struct AgentTabStrip: View {
@State private var stripViewportWidth: CGFloat = 0
@State private var stripContentWidth: CGFloat = 0
@State private var scrollWheelMonitor: Any?
@State private var viewportAnchor: ProviderFilter?
var body: some View {
GeometryReader { viewportGeo in
@ -23,7 +24,7 @@ struct AgentTabStrip: View {
HStack(spacing: 4) {
if isOverflowing {
Button {
selectAdjacentProvider(direction: -1, proxy: proxy)
pageProviderStrip(direction: .backward, proxy: proxy)
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 10, weight: .semibold))
@ -44,6 +45,7 @@ struct AgentTabStrip: View {
isActive: store.selectedProvider == filter,
quota: store.quotaSummary(for: filter)
) {
viewportAnchor = filter
store.switchTo(provider: filter)
withAnimation(.easeInOut(duration: 0.18)) {
proxy.scrollTo(filter.id, anchor: .center)
@ -73,7 +75,7 @@ struct AgentTabStrip: View {
if isOverflowing {
Button {
selectAdjacentProvider(direction: 1, proxy: proxy)
pageProviderStrip(direction: .forward, proxy: proxy)
} label: {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
@ -88,17 +90,19 @@ struct AgentTabStrip: View {
.onAppear {
stripViewportWidth = viewportGeo.size.width
installScrollWheelMonitorIfNeeded()
withAnimation(.easeInOut(duration: 0.18)) {
proxy.scrollTo(store.selectedProvider.id, anchor: .center)
}
viewportAnchor = pagingState.viewportAnchor
scrollToViewportAnchor(proxy: proxy)
}
.onChange(of: viewportGeo.size.width) { _, newWidth in
stripViewportWidth = newWidth
}
.onChange(of: store.selectedProvider) { _, newProvider in
withAnimation(.easeInOut(duration: 0.18)) {
proxy.scrollTo(newProvider.id, anchor: .center)
}
viewportAnchor = visibleFilters.contains(newProvider) ? newProvider : pagingState.viewportAnchor
scrollToViewportAnchor(proxy: proxy)
}
.onChange(of: visibleFilters) { _, _ in
viewportAnchor = pagingState.viewportAnchor
scrollToViewportAnchor(proxy: proxy)
}
.onDisappear {
removeScrollWheelMonitorIfNeeded()
@ -109,14 +113,18 @@ struct AgentTabStrip: View {
}
private var periodAll: MenubarPayload {
store.periodAllPayload ?? store.payload
// `store.payload` is the SCOPED selection. Falling back to it while the
// selection has no cached data of its own used to surface the previous
// provider's totals here; the same hasCachedData gate the popover body
// uses keeps that out of the tab strip.
store.periodAllPayload ?? (store.hasCachedData ? store.payload : .empty)
}
private var visibleFilters: [ProviderFilter] {
// Tabs reflect the selected range. The payload's activity signal keeps
// token-only and subscription/flat-rate providers visible while hiding
// providers merely discovered on disk. Older payloads lack the activity
// signal, so they preserve detected-provider visibility for compatibility.
// signal, so their zero-cost discovery rows fail closed.
let activeKeys = ProviderVisibility.activeKeys(
providerDetails: periodAll.current.providerDetails,
legacyProviders: periodAll.current.providers
@ -149,21 +157,29 @@ struct AgentTabStrip: View {
}
}
private var currentFilterIndex: Int {
visibleFilters.firstIndex(of: store.selectedProvider) ?? 0
private var pagingState: ProviderStripPagingState {
ProviderStripPagingState(
filters: visibleFilters,
selectedProvider: store.selectedProvider,
viewportAnchor: viewportAnchor
)
}
private var canMoveBackward: Bool { currentFilterIndex > 0 }
private var canMoveForward: Bool { currentFilterIndex < visibleFilters.count - 1 }
private var canMoveBackward: Bool { pagingState.canMoveBackward }
private var canMoveForward: Bool { pagingState.canMoveForward }
private var isOverflowing: Bool { stripContentWidth > (stripViewportWidth - 30) }
private func selectAdjacentProvider(direction: Int, proxy: ScrollViewProxy) {
guard !visibleFilters.isEmpty else { return }
let targetIndex = min(max(currentFilterIndex + direction, 0), visibleFilters.count - 1)
let target = visibleFilters[targetIndex]
store.switchTo(provider: target)
private func pageProviderStrip(direction: ProviderStripPagingDirection, proxy: ScrollViewProxy) {
var state = pagingState
guard let target = state.move(direction: direction) else { return }
viewportAnchor = target
scrollToViewportAnchor(proxy: proxy)
}
private func scrollToViewportAnchor(proxy: ScrollViewProxy) {
guard let viewportAnchor else { return }
withAnimation(.easeInOut(duration: 0.18)) {
proxy.scrollTo(target.id, anchor: .center)
proxy.scrollTo(viewportAnchor.id, anchor: .center)
}
}
@ -410,38 +426,21 @@ private struct QuotaDetailPopover: View {
private func terminalFailureCard(reason: String?) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(reconnectTitle)
Text(reconnectPresentation.title)
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(.red)
Text(reason ?? defaultReconnectReason)
Text(reason ?? reconnectPresentation.defaultReason)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(2)
Text(reconnectInstruction)
Text(reconnectPresentation.instruction)
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
private var reconnectTitle: String {
switch quota.providerFilter {
case .codex: return "Reconnect Codex"
default: return "Reconnect Claude"
}
}
private var defaultReconnectReason: String {
switch quota.providerFilter {
case .codex: return "Refresh token rejected by OpenAI."
default: return "Refresh token rejected by Anthropic."
}
}
private var reconnectInstruction: String {
switch quota.providerFilter {
case .codex: return "Run `codex login` in your terminal, then click Reconnect."
default: return "Open Claude Code in your terminal and type `/login`, then click Reconnect."
}
private var reconnectPresentation: ProviderReconnectPresentation {
ProviderReconnectPresentation(provider: quota.providerFilter)
}
}

View file

@ -10,7 +10,7 @@ struct FlameWordmark: View {
// `flameShift 3s ease infinite` (keyframes 0%/100% at position 0%, 50% at
// 100%). Here that's a 3x-wide gradient sweeping from its left edge aligned
// with the text to its right edge aligned with the text (a 2x-width shift)
// and back — a 1.5s ease-in-out each way, autoreversing.
// and back, slowed to a 4s ease-in-out each way, autoreversing.
private static let flameColors: [Color] = [
Color(red: 0xFF / 255.0, green: 0x6A / 255.0, blue: 0x00 / 255.0), // #ff6a00
Color(red: 0xFF / 255.0, green: 0xDA / 255.0, blue: 0x44 / 255.0), // #ffda44
@ -27,8 +27,12 @@ struct FlameWordmark: View {
}
var body: some View {
// The letters keep a static flame fill of their own, so the sweep can only
// ever brighten them; a mask that is a frame late no longer blanks the name.
wordmark
.hidden()
.foregroundStyle(
LinearGradient(colors: Self.flameColors, startPoint: .leading, endPoint: .trailing)
)
.overlay {
GeometryReader { geo in
LinearGradient(
@ -41,7 +45,7 @@ struct FlameWordmark: View {
.animation(
reduceMotion || !store.menuPopoverVisible
? nil
: .easeInOut(duration: 1.5).repeatForever(autoreverses: true),
: .easeInOut(duration: 4).repeatForever(autoreverses: true),
value: sweeping
)
}

View file

@ -20,6 +20,14 @@ struct MenuBarContent: View {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
HeroSection()
if store.selectedPayloadMayBeIncomplete {
Text("This total may be incomplete.")
.font(.system(size: 11))
.foregroundStyle(Color.secondary.opacity(0.75))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.bottom, 6)
}
Divider().opacity(0.5)
PeriodSegmentedControl()
ScopeSegmentedControl()
@ -119,7 +127,7 @@ struct MenuBarContent: View {
// visible. Without this, the strip disappears for one frame on a period
// switch when the new key's payload is still empty.
if store.hasAnyProvidersInCache { return true }
let payload = store.todayPayload ?? store.payload
let payload = store.todayPayload ?? (store.hasCachedData ? store.payload : .empty)
return !payload.current.providers.isEmpty
}

View file

@ -22,28 +22,38 @@ struct AgentTabProviderVisibilityTests {
#expect(!keys.contains("claude"))
}
@Test("legacy payloads keep detected providers visible when activity is unknowable")
func legacyDetectedProviderFallback() {
@Test("legacy payloads show only providers with period spend")
func legacyProviderCostFallback() {
let keys = ProviderVisibility.activeKeys(
providerDetails: [],
legacyProviders: ["codex": 3.25, "hermes agent": 0]
)
#expect(keys == ["codex", "hermes agent"])
#expect(keys == ["codex"])
}
@Test("legacy provider details without activity fields fail open, while calls remain authoritative")
func legacyProviderDetailDecoding() throws {
let noSignal = try JSONDecoder().decode(
ProviderDetail.self,
from: Data(#"{"id":"hermes","label":"Hermes Agent","cost":0}"#.utf8)
)
let explicitIdleCalls = try JSONDecoder().decode(
ProviderDetail.self,
from: Data(#"{"id":"hermes","label":"Hermes Agent","cost":0,"calls":0}"#.utf8)
)
@Test("provider details without hasUsage stay visible")
func absentHasUsageDefaultsToVisible() throws {
func decode(_ json: String) throws -> ProviderDetail {
try JSONDecoder().decode(ProviderDetail.self, from: Data(json.utf8))
}
// Every RELEASED CLI omits hasUsage. Deriving it from cost there hid
// every subscription-backed provider whose period spend is $0.
let noHasUsage = try decode(#"{"id":"hermes","label":"Hermes Agent","cost":0}"#)
let noHasUsageWithCalls = try decode(#"{"id":"hermes","label":"Hermes Agent","cost":0,"calls":0}"#)
let explicitlyIdle = try decode(#"{"id":"hermes","label":"Hermes Agent","cost":0,"calls":0,"hasUsage":false}"#)
let explicitlyActive = try decode(#"{"id":"codex","label":"Codex","cost":0,"calls":0,"hasUsage":true}"#)
#expect(noSignal.hasUsage)
#expect(!explicitIdleCalls.hasUsage)
#expect(noHasUsage.hasUsage)
#expect(noHasUsageWithCalls.hasUsage)
// The strict signal still applies wherever the CLI actually emits it.
#expect(!explicitlyIdle.hasUsage)
#expect(explicitlyActive.hasUsage)
let keys = ProviderVisibility.activeKeys(
providerDetails: [noHasUsage, explicitlyIdle],
legacyProviders: [:]
)
#expect(keys.contains("hermes"))
}
}

View file

@ -0,0 +1,270 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
/// A fetch whose completion the test controls, so two real refresh tasks can be
/// held in flight at once. Every attempt records the provider it was issued for
/// and parks until the test releases it by index.
@MainActor
private final class ScriptedFetch {
private var parked: [Int: CheckedContinuation<Void, Never>] = [:]
private(set) var attempts: [(index: Int, provider: ProviderFilter)] = []
private(set) var released: [Int] = []
var payloadForProvider: (ProviderFilter) -> MenubarPayload = { _ in scriptedPayload(cost: 1) }
func install() {
DataClient.fetchHookForTesting = { [weak self] provider, _ in
guard let self else { return scriptedPayload(cost: 0) }
let index = await self.begin(provider)
await self.park(index)
return await self.payload(for: provider)
}
}
func uninstall() {
DataClient.fetchHookForTesting = nil
for (_, continuation) in parked { continuation.resume() }
parked.removeAll()
}
private func begin(_ provider: ProviderFilter) -> Int {
let index = attempts.count
attempts.append((index: index, provider: provider))
return index
}
private func park(_ index: Int) async {
await withCheckedContinuation { parked[index] = $0 }
}
private func payload(for provider: ProviderFilter) -> MenubarPayload {
payloadForProvider(provider)
}
func isParked(_ index: Int) -> Bool { parked[index] != nil }
func release(_ index: Int) {
guard let continuation = parked.removeValue(forKey: index) else { return }
released.append(index)
continuation.resume()
}
func indices(for provider: ProviderFilter) -> [Int] {
attempts.filter { $0.provider == provider }.map(\.index)
}
}
private func scriptedPayload(cost: Double,
calls: Int = 1,
providers: [String: Double]? = nil,
providerDetails: [ProviderDetail] = []) -> MenubarPayload {
MenubarPayload(
generated: "test",
current: CurrentBlock(
label: "Today",
cost: cost,
calls: calls,
sessions: 1,
oneShotRate: nil,
inputTokens: 1,
outputTokens: 1,
cacheHitPercent: 0,
codexCredits: nil,
topActivities: [],
topModels: [],
localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []),
providers: providers ?? ["claude": cost],
providerDetails: providerDetails,
topProjects: [],
modelEfficiency: [],
topSessions: [],
retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),
routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []),
tools: [],
skills: [],
subagents: [],
mcpServers: []
),
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: []),
combined: nil,
claudeConfigs: nil
)
}
/// Yield repeatedly until `condition` holds. The tasks under test suspend on
/// real continuations, so progress needs actual scheduler turns, not a sleep.
@MainActor
private func settle(until condition: () -> Bool, turns: Int = 500) async -> Bool {
for _ in 0..<turns {
if condition() { return true }
await Task.yield()
}
return condition()
}
// Serialized: the DataClient fetch hook is process-global, so two of these
// running at once would each see the other's attempts.
@Suite("AppStore refresh concurrency", .serialized)
@MainActor
struct AppStoreRefreshConcurrencyTests {
@Test("a fetch whose in-flight slot was evicted cannot release its successor's slot")
func evictedFetchCannotReleaseSuccessorSlot() async {
let script = ScriptedFetch()
script.install()
defer { script.uninstall() }
let store = AppStore()
store.setCacheDateToTodayForTesting()
// A claims the slot for (today, claude) and parks inside its fetch.
let taskA = Task { await store.refreshQuietlyForTesting(period: .today, provider: .claude) }
#expect(await settle(until: { script.attempts.count == 1 }))
#expect(store.isInFlightForTesting(period: .today, provider: .claude))
// The display-sleep reset evicts the slot out from under A.
store.resetLoadingState()
#expect(!store.isInFlightForTesting(period: .today, provider: .claude))
// B legitimately claims the same key afterwards.
let taskB = Task { await store.refreshQuietlyForTesting(period: .today, provider: .claude) }
#expect(await settle(until: { script.attempts.count == 2 }))
#expect(store.isInFlightForTesting(period: .today, provider: .claude))
// C finds the key busy and parks as a waiter on B.
var cFinished = false
let taskC = Task {
let result = await store.refreshQuietlyForTesting(period: .today, provider: .claude)
cFinished = true
return result
}
#expect(await settle(until: { store.inFlightWaiterCountForTesting(period: .today, provider: .claude) == 1 }))
// A finishes late. Its slot is gone, so its release must be a no-op:
// B keeps the slot and C stays parked. Untokened, A's defer cleared B's
// slot and woke C onto an empty cache.
script.release(0)
_ = await taskA.value
#expect(store.isInFlightForTesting(period: .today, provider: .claude))
#expect(store.inFlightWaiterCountForTesting(period: .today, provider: .claude) == 1)
#expect(!cFinished)
// B finishes and C resumes off B's result.
script.release(1)
#expect(await taskB.value)
#expect(await taskC.value)
#expect(cFinished)
#expect(!store.isInFlightForTesting(period: .today, provider: .claude))
#expect(store.inFlightWaiterCountForTesting(period: .today, provider: .claude) == 0)
}
@Test("the all-provider and scoped fetches run together and only acceptance is ordered")
func allAndScopedFetchesOverlap() async {
let script = ScriptedFetch()
script.payloadForProvider = { provider in
provider == .all
? scriptedPayload(cost: 12, providers: ["claude": 12],
providerDetails: [ProviderDetail(id: "claude", label: "Claude", cost: 12, calls: 4, hasUsage: true)])
: scriptedPayload(cost: 12, providers: ["claude": 12],
providerDetails: [ProviderDetail(id: "claude", label: "Claude", cost: 12, calls: 4, hasUsage: true)])
}
script.install()
defer { script.uninstall() }
let store = AppStore()
store.setCacheDateToTodayForTesting()
store.suppressRefreshesForTesting()
store.switchTo(provider: .claude)
let task = Task { await store.refresh(includeOptimize: false, force: true) }
// BOTH parses are in flight before either has produced a result. The
// serialized version could not reach two attempts until the first
// returned.
#expect(await settle(until: { script.attempts.count == 2 }))
#expect(Set(script.attempts.map(\.provider)) == Set([.all, .claude]))
#expect(script.isParked(0))
#expect(script.isParked(1))
// Acceptance is still ordered: release the scoped fetch first and it
// must not land until the all-provider evidence has.
let scopedIndex = script.indices(for: .claude)[0]
let allIndex = script.indices(for: .all)[0]
script.release(scopedIndex)
for _ in 0..<50 { await Task.yield() }
#expect(store.cachedPayloadForTesting(period: .today, provider: .claude) == nil)
script.release(allIndex)
#expect(await task.value)
#expect(store.cachedPayloadForTesting(period: .today, provider: .claude) != nil)
#expect(store.cachedPayloadForTesting(period: .today, provider: .all) != nil)
}
@Test("an interactive refresh joins an in-flight fetch instead of reporting failure")
func interactiveRefreshJoinsInFlightFetch() async {
let script = ScriptedFetch()
script.install()
defer { script.uninstall() }
let store = AppStore()
store.setCacheDateToTodayForTesting()
let first = Task { await store.refresh(includeOptimize: false, force: true) }
#expect(await settle(until: { script.attempts.count == 1 }))
// Second interactive refresh on the same key. It used to return false
// immediately, which is what made recoverFromStuckLoading() announce a
// failed recovery while a perfectly healthy fetch was still running.
var secondResult: Bool?
let second = Task {
let value = await store.refresh(includeOptimize: false, force: true)
secondResult = value
return value
}
#expect(await settle(until: { store.inFlightWaiterCountForTesting(period: .today, provider: .all) == 1 }))
#expect(secondResult == nil)
script.release(0)
#expect(await first.value)
#expect(await second.value)
}
@Test("a twice-contradicting payload is cached and flagged instead of raising an error")
func twiceContradictingPayloadIsCachedAndFlagged() async {
let script = ScriptedFetch()
// The scoped payload reports nothing for a provider the all-provider
// slice says is active, and says so again on the verifying re-parse.
script.payloadForProvider = { provider in
provider == .all
? scriptedPayload(cost: 12, providers: ["claude": 11.5, "hermes agent": 0.5],
providerDetails: [
ProviderDetail(id: "claude", label: "Claude", cost: 11.5, calls: 4, hasUsage: true),
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0.5, calls: 7, hasUsage: true),
])
: scriptedPayload(cost: 0, calls: 0, providers: ["hermes agent": 0],
providerDetails: [ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: false)])
}
script.install()
defer { script.uninstall() }
let store = AppStore()
store.setCacheDateToTodayForTesting()
store.suppressRefreshesForTesting()
store.switchTo(provider: .hermes)
let task = Task { await store.refresh(includeOptimize: false, force: true) }
// all + scoped + the resident-bypassing verification of the scoped one.
#expect(await settle(until: { script.attempts.count >= 2 }))
for index in 0..<script.attempts.count { script.release(index) }
#expect(await settle(until: { script.attempts.count == 3 }))
script.release(2)
_ = await task.value
// Cached and served, with the caveat flag set. It used to throw, which
// left the tab with an error banner and no number at all.
#expect(store.cachedPayloadForTesting(period: .today, provider: .hermes) != nil)
#expect(store.selectedPayloadMayBeIncomplete)
#expect(store.lastError == nil)
}
}

View file

@ -46,6 +46,12 @@ private func claudeConfigSelector(selectedId: String? = nil) -> ClaudeConfigSele
}
private func menubarPayload(cost: Double,
calls: Int = 1,
sessions: Int = 1,
inputTokens: Int = 1,
outputTokens: Int = 1,
providers: [String: Double]? = nil,
providerDetails: [ProviderDetail] = [],
combined: CombinedUsage? = nil,
claudeConfigs: ClaudeConfigSelector? = nil) -> MenubarPayload {
MenubarPayload(
@ -53,17 +59,18 @@ private func menubarPayload(cost: Double,
current: CurrentBlock(
label: "Today",
cost: cost,
calls: 1,
sessions: 1,
calls: calls,
sessions: sessions,
oneShotRate: nil,
inputTokens: 1,
outputTokens: 1,
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheHitPercent: 0,
codexCredits: nil,
topActivities: [],
topModels: [],
localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []),
providers: ["claude": cost],
providers: providers ?? ["claude": cost],
providerDetails: providerDetails,
topProjects: [],
modelEfficiency: [],
topSessions: [],
@ -84,6 +91,332 @@ private func menubarPayload(cost: Double,
@Suite("AppStore refresh recovery")
@MainActor
struct AppStoreRefreshRecoveryTests {
@Test("an active all-provider slice rejects a contradictory scoped zero")
func activeAllProviderSliceRejectsScopedZero() {
let store = AppStore()
let all = menubarPayload(
cost: 12,
providers: ["claude": 11.5, "hermes agent": 0.5],
providerDetails: [
ProviderDetail(id: "claude", label: "Claude", cost: 11.5, calls: 4, hasUsage: true),
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0.5, calls: 7, hasUsage: true),
]
)
let falseZero = menubarPayload(
cost: 0,
calls: 0,
sessions: 0,
providers: ["hermes agent": 0],
providerDetails: [
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: false),
]
)
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
store.suppressRefreshesForTesting()
store.switchTo(provider: .hermes)
store.setCachedPayloadForTesting(falseZero, period: .today, provider: .hermes, fetchedAt: Date())
#expect(store.providerPayloadContradictsAllForTesting(falseZero, period: .today, provider: .hermes))
// The Hermes tab must show NO dollar figure. It used to fall back to the
// previous selection's payload and print $12, which is Claude's spend
// plus Hermes', under a tab labelled Hermes.
#expect(!store.hasCachedData)
#expect(store.payload.current.cost == 0)
#expect(store.payload.current.calls == 0)
#expect(store.needsInteractivePayloadRefresh)
}
@Test("positive provider spend cannot disappear when scoped calls survive")
func positiveSpendCannotDisappearWhenScopedCallsSurvive() {
let store = AppStore()
let all = menubarPayload(
cost: 12,
providers: ["claude": 11.5, "hermes agent": 0.5],
providerDetails: [
ProviderDetail(id: "claude", label: "Claude", cost: 11.5, calls: 4, hasUsage: true),
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0.5, calls: 7, hasUsage: true),
]
)
let missingSpend = menubarPayload(
cost: 0,
calls: 7,
sessions: 7,
inputTokens: 100,
outputTokens: 20,
providers: ["hermes agent": 0],
providerDetails: [
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 7, hasUsage: true),
]
)
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
#expect(store.providerPayloadContradictsAllForTesting(missingSpend, period: .today, provider: .hermes))
}
@Test("flat-rate activity rejects a contradictory scoped zero")
func flatRateActivityRejectsScopedZero() {
let store = AppStore()
let all = menubarPayload(
cost: 12,
providers: ["claude": 12, "hermes agent": 0],
providerDetails: [
ProviderDetail(id: "claude", label: "Claude", cost: 12, calls: 4, hasUsage: true),
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 7, hasUsage: true),
]
)
let falseZero = menubarPayload(
cost: 0,
calls: 0,
sessions: 0,
providers: ["hermes agent": 0],
providerDetails: [
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: false),
]
)
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
#expect(store.providerPayloadContradictsAllForTesting(falseZero, period: .today, provider: .hermes))
}
@Test("token-only activity accepts a scoped payload with matching usage")
func tokenOnlyActivityAcceptsMatchingScopedUsage() {
let store = AppStore()
let all = menubarPayload(
cost: 12,
providers: ["claude": 12, "hermes agent": 0],
providerDetails: [
ProviderDetail(id: "claude", label: "Claude", cost: 12, calls: 4, hasUsage: true),
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: true),
]
)
let tokenOnly = menubarPayload(
cost: 0,
calls: 0,
sessions: 1,
inputTokens: 100,
outputTokens: 20,
providers: ["hermes agent": 0],
providerDetails: [
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: true),
]
)
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
#expect(!store.providerPayloadContradictsAllForTesting(tokenOnly, period: .today, provider: .hermes))
}
@Test("scoped providerDetails hasUsage remains authoritative with empty headline totals")
func scopedProviderDetailsUsageRemainsAuthoritative() {
let store = AppStore()
let all = menubarPayload(
cost: 12,
providers: ["claude": 12, "hermes agent": 0],
providerDetails: [
ProviderDetail(id: "claude", label: "Claude", cost: 12, calls: 4, hasUsage: true),
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: true),
]
)
let providerDetailOnly = menubarPayload(
cost: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
providers: ["hermes agent": 0],
providerDetails: [
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: true),
]
)
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
#expect(!store.providerPayloadContradictsAllForTesting(providerDetailOnly, period: .today, provider: .hermes))
}
@Test("token-only activity rejects a completely empty scoped payload")
func tokenOnlyActivityRejectsEmptyScopedPayload() {
let store = AppStore()
let all = menubarPayload(
cost: 12,
providers: ["claude": 12, "hermes agent": 0],
providerDetails: [
ProviderDetail(id: "claude", label: "Claude", cost: 12, calls: 4, hasUsage: true),
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: true),
]
)
let empty = menubarPayload(
cost: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
providers: ["hermes agent": 0],
providerDetails: [
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: false),
]
)
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
#expect(store.providerPayloadContradictsAllForTesting(empty, period: .today, provider: .hermes))
}
@Test("late all-provider evidence invalidates an already cached scoped zero")
func lateAllProviderEvidenceInvalidatesScopedZero() {
let store = AppStore()
let falseZero = menubarPayload(
cost: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
providers: ["hermes agent": 0],
providerDetails: [
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: false),
]
)
let all = menubarPayload(
cost: 12,
providers: ["claude": 11.5, "hermes agent": 0.5],
providerDetails: [
ProviderDetail(id: "claude", label: "Claude", cost: 11.5, calls: 4, hasUsage: true),
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0.5, calls: 7, hasUsage: true),
]
)
store.suppressRefreshesForTesting()
store.switchTo(provider: .hermes)
store.setCachedPayloadForTesting(falseZero, period: .today, provider: .hermes, fetchedAt: Date())
#expect(store.hasCachedData)
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
#expect(!store.hasCachedData)
#expect(store.needsInteractivePayloadRefresh)
}
@Test("quiet refresh joins matching in-flight evidence instead of racing ahead")
func quietRefreshJoinsMatchingInFlightEvidence() async {
let store = AppStore()
let all = menubarPayload(
cost: 12,
providers: ["claude": 11.5, "hermes agent": 0.5],
providerDetails: [
ProviderDetail(id: "claude", label: "Claude", cost: 11.5, calls: 4, hasUsage: true),
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0.5, calls: 7, hasUsage: true),
]
)
store.setCacheDateToTodayForTesting()
store.seedInFlightForTesting(period: .today, provider: .all, insertedAt: Date())
let joined = Task { @MainActor in
await store.refreshQuietlyForTesting(period: .today, provider: .all)
}
while store.inFlightWaiterCountForTesting(period: .today, provider: .all) == 0 {
await Task.yield()
}
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
store.finishInFlightForTesting(period: .today, provider: .all)
#expect(await joined.value)
}
@Test("refresh pipeline reset releases matching in-flight waiters")
func refreshPipelineResetReleasesInFlightWaiters() async {
let store = AppStore()
store.setCacheDateToTodayForTesting()
store.seedInFlightForTesting(period: .today, provider: .all, insertedAt: Date())
let joined = Task { @MainActor in
await store.refreshQuietlyForTesting(period: .today, provider: .all)
}
while store.inFlightWaiterCountForTesting(period: .today, provider: .all) == 0 {
await Task.yield()
}
store.resetLoadingState()
#expect(!(await joined.value))
#expect(!store.isInFlightForTesting(period: .today, provider: .all))
}
@Test("selection refresh snapshots scoped and all-provider keys together")
func selectionRefreshSnapshotsMatchingKeys() {
let store = AppStore()
store.suppressRefreshesForTesting()
store.switchTo(period: .month)
store.switchTo(provider: .hermes)
let snapshot = store.selectionRefreshKeysForTesting()
store.switchTo(period: .today)
store.switchTo(provider: .cursor)
#expect(snapshot.scoped.period == .month)
#expect(snapshot.scoped.provider == .hermes)
#expect(snapshot.all.period == .month)
#expect(snapshot.all.provider == .all)
#expect(snapshot.scoped.day == snapshot.all.day)
#expect(snapshot.scoped.days == snapshot.all.days)
#expect(snapshot.scoped.claudeConfigSourceId == snapshot.all.claudeConfigSourceId)
}
@Test("a genuine provider zero remains valid when the all-provider slice is also zero")
func genuineProviderZeroRemainsValid() {
let store = AppStore()
let all = menubarPayload(
cost: 12,
providers: ["claude": 12, "hermes agent": 0],
providerDetails: [
ProviderDetail(id: "claude", label: "Claude", cost: 12, calls: 4, hasUsage: true),
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: false),
]
)
let zero = menubarPayload(
cost: 0,
calls: 0,
sessions: 0,
providers: ["hermes agent": 0],
providerDetails: [
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: false),
]
)
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
#expect(!store.providerPayloadContradictsAllForTesting(zero, period: .today, provider: .hermes))
}
@Test("a provider switch shows no figure until the target provider loads")
func providerSwitchShowsNoStaleFigure() {
let store = AppStore()
store.suppressRefreshesForTesting()
store.setCachedPayloadForTesting(
menubarPayload(cost: 12.34),
period: .today,
provider: .all,
fetchedAt: Date()
)
store.switchTo(provider: .cursor)
// Keeping the all-provider payload mounted under the Cursor tab is not
// continuity, it is a wrong number: $12.34 is every provider's spend.
// Empty here is what puts the popover on its loading state instead.
#expect(store.selectedProvider == .cursor)
#expect(!store.hasCachedData)
#expect(store.payload.current.cost == 0)
// The all-provider selection still reads its own cached payload.
store.switchTo(provider: .all)
#expect(store.hasCachedData)
#expect(store.payload.current.cost == 12.34)
}
@Test("stale visible payload triggers hard recovery without clearing cache")
func stalePayloadTriggersHardRecoveryWithoutClearingCache() {
let store = AppStore()

View file

@ -0,0 +1,36 @@
import Testing
@testable import CodeBurnMenubar
@Suite("Provider reconnect presentation")
struct ProviderReconnectPresentationTests {
@Test("every quota provider gets provider-specific recovery copy", arguments: [
ProviderFilter.claude,
.codex,
.kimiCode,
.gemini,
.copilot,
.antigravity,
])
func providerSpecificRecoveryCopy(_ provider: ProviderFilter) {
let presentation = ProviderReconnectPresentation(provider: provider)
#expect(presentation.title == "Reconnect \(provider.rawValue)")
#expect(!presentation.defaultReason.isEmpty)
#expect(!presentation.instruction.isEmpty)
for other in ["Claude", "Codex", "Kimi", "Gemini", "Copilot", "Antigravity"]
where other != provider.rawValue && !(provider == .kimiCode && other == "Kimi") {
#expect(!presentation.title.contains(other))
#expect(!presentation.instruction.contains(other))
}
}
@Test("unsupported providers receive neutral recovery copy")
func genericRecoveryCopy() {
let presentation = ProviderReconnectPresentation(provider: .cursor)
#expect(presentation.title == "Reconnect Cursor")
#expect(presentation.defaultReason == "Cursor credentials need to be refreshed.")
#expect(presentation.instruction == "Sign in to Cursor again, then retry.")
}
}

View file

@ -0,0 +1,49 @@
import Testing
@testable import CodeBurnMenubar
@Suite("Provider strip paging")
struct ProviderStripPagingStateTests {
@Test("paging changes the viewport without selecting a provider")
func pagingDoesNotSelect() {
var state = ProviderStripPagingState(
filters: [.all, .claude, .cursor],
selectedProvider: .all,
viewportAnchor: .all
)
#expect(state.move(direction: .forward) == .claude)
#expect(state.viewportAnchor == .claude)
#expect(state.selectedProvider == .all)
#expect(state.move(direction: .forward) == .cursor)
#expect(state.viewportAnchor == .cursor)
#expect(state.selectedProvider == .all)
#expect(!state.canMoveForward)
}
@Test("paging clamps a missing anchor to the selected provider")
func missingAnchorUsesSelection() {
var state = ProviderStripPagingState(
filters: [.all, .claude, .cursor],
selectedProvider: .claude,
viewportAnchor: .grok
)
#expect(state.viewportAnchor == .claude)
#expect(state.move(direction: .backward) == .all)
#expect(state.selectedProvider == .claude)
}
@Test("an empty strip cannot page")
func emptyStrip() {
var state = ProviderStripPagingState(
filters: [],
selectedProvider: .all,
viewportAnchor: nil
)
#expect(state.move(direction: .forward) == nil)
#expect(!state.canMoveBackward)
#expect(!state.canMoveForward)
}
}

View file

@ -1057,6 +1057,29 @@ struct ServeConnectionTests {
#expect(await fallback.snapshot() == 1)
}
@Test("a verified-fresh request can bypass the resident worker")
func verifiedFreshRequestBypassesResident() async throws {
let resident = FallbackRecorder()
let fallback = FallbackRecorder()
let result = try await DataClient.runCLI(
subcommand: ["status", "--format", "menubar-json", "--provider", "hermes"],
bypassResident: true,
serveRequest: { _ in
await resident.record()
return Data("stale".utf8)
},
spawnFallback: {
await fallback.record()
return DataClient.ProcessResult(stdout: Data("fresh".utf8), stderr: "", exitCode: 0)
}
)
#expect(String(decoding: result.stdout, as: UTF8.self) == "fresh")
#expect(await resident.snapshot() == 0)
#expect(await fallback.snapshot() == 1)
}
@Test("the first real request is the only cold-start query")
func firstRequestIsTheWarmup() async throws {
let dir = NSTemporaryDirectory() + "serve-connection-test-" + UUID().uuidString

View file

@ -14,9 +14,9 @@
],
"scripts": {
"bundle-litellm": "node scripts/bundle-litellm.mjs",
"build": "node scripts/bundle-litellm.mjs && tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\" && npm run build:dash",
"build": "tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\" && npm run build:dash",
"build:cli": "tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\"",
"build:dash": "cd dash && npm install --no-audit --no-fund --silent && npm run build",
"build:dash": "cd dash && npm ci --no-audit --no-fund --silent && npm run build",
"dev": "NODE_OPTIONS=--no-deprecation tsx src/cli.ts",
"test": "vitest run tests --exclude \"tests/cache-refresh-lock*\"",
"test:locks": "vitest run tests/cache-refresh-lock.test.ts tests/cache-refresh-lock-corrupt-body.test.ts tests/cache-refresh-lock-process.test.ts tests/cache-refresh-lock-status-snapshot.test.ts --poolOptions.forks.singleFork=true",

View file

@ -33,7 +33,6 @@ const WORK = process.env['UPGRADE_PATH_WORK'] || join(tmpdir(), 'codeburn upgrad
const OLD_SESSION_CACHE = 'session-cache.v7.json'
const OLD_DAILY_CACHE = 'daily-cache.v17.json'
const NEW_SESSION_CACHE_DIR = 'session-cache.v9'
const NEW_DAILY_CACHE = 'daily-cache.v29.json'
const HOME = join(WORK, 'user home')
const PAYLOADS = join(WORK, 'payloads')
@ -48,6 +47,14 @@ const fail = msg => { failures++; console.log(` FAIL ${msg}`) }
const skip = msg => { skipped++; console.log(` skip ${msg}`) }
const check = (cond, msg) => (cond ? ok(msg) : fail(msg))
function newestDailyCacheAfter(dir, baselineName) {
const baseline = Number(baselineName.match(/^daily-cache\.v(\d+)\.json$/)?.[1] ?? -1)
return readdirSync(dir)
.map(name => ({ name, version: Number(name.match(/^daily-cache\.v(\d+)\.json$/)?.[1] ?? -1) }))
.filter(candidate => candidate.version > baseline)
.sort((a, b) => b.version - a.version)[0]?.name ?? null
}
function run(cmd, args, opts = {}) {
const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 1 << 29, shell: false, ...opts })
if (r.error) throw new Error(`${cmd} ${args.join(' ')}: ${r.error.message}`)
@ -207,10 +214,12 @@ const envelope = JSON.parse(readFileSync(join(upgradeCache, NEW_SESSION_CACHE_DI
check(envelope.version === 9 && Object.keys(envelope.providers ?? {}).length > 0,
`envelope at version ${envelope.version} with ${Object.keys(envelope.providers ?? {}).length} providers`)
check(readdirSync(join(upgradeCache, NEW_SESSION_CACHE_DIR)).some(n => n !== 'envelope.json'), 'shards published alongside the envelope')
check(existsSync(join(upgradeCache, NEW_DAILY_CACHE)), `${NEW_DAILY_CACHE} re-derived`)
const newDailyCache = newestDailyCacheAfter(upgradeCache, OLD_DAILY_CACHE)
check(newDailyCache !== null, `${newDailyCache ?? 'current daily cache'} re-derived`)
check(existsSync(join(upgradeCache, OLD_DAILY_CACHE)), `${OLD_DAILY_CACHE} kept as the carry-forward baseline`)
if (!newDailyCache) process.exit(1)
const oldDays = JSON.parse(readFileSync(join(upgradeCache, OLD_DAILY_CACHE), 'utf8')).days.length
const newDays = JSON.parse(readFileSync(join(upgradeCache, NEW_DAILY_CACHE), 'utf8')).days.length
const newDays = JSON.parse(readFileSync(join(upgradeCache, newDailyCache), 'utf8')).days.length
check(newDays >= oldDays, `daily history did not shrink: ${oldDays} -> ${newDays} days`)
// ── 4. payload parity ────────────────────────────────────────────────────────
@ -415,7 +424,10 @@ else {
for (const a of aged) ok(`${a.date}: ${a.kind} (removed ${a.removed} of ${a.removed + a.kept} transcripts)`)
capture(newBin, agingCache, join(PAYLOADS, 'aging-upgraded'))
const upDaily = JSON.parse(readFileSync(join(agingCache, NEW_DAILY_CACHE), 'utf8'))
const agingDailyCache = newestDailyCacheAfter(agingCache, OLD_DAILY_CACHE)
check(agingDailyCache !== null, `${agingDailyCache ?? 'current aging daily cache'} re-derived`)
if (!agingDailyCache) process.exit(1)
const upDaily = JSON.parse(readFileSync(join(agingCache, agingDailyCache), 'utf8'))
const usd = n => `$${n.toFixed(6)}`
for (const a of aged) {

View file

@ -27,7 +27,7 @@ import type { DateRange, ProjectSummary } from './types.js'
// again here. 26 is the first free number on main's ladder — and it is load
// bearing beyond the collision: the branch's own validators already hold
// daily-cache.v21.json files whose copilot slices were carried stale by the
// bug PENDING_REDERIVE_PROVIDERS fixes below, and only a number those files
// bug PENDING_REDERIVE_PROVIDER_VERSIONS fixes below, and only a number those files
// cannot claim gets them re-derived. isMigratableCache/adoptOlderDailyCaches
// carry a same-or-newer version forward as FINALIZED without re-deriving it,
// so a number can never mean two accountings. (feat/core-extraction also sits
@ -174,7 +174,11 @@ import type { DateRange, ProjectSummary } from './types.js'
// but optimize added reasoning again. Re-derive so report matches the live parse.
// v29: #1118 OrcaRouter route pricing (fusion aliases + peel; auto stays unpriced).
// v28 on main already shipped #1115.
export const DAILY_CACHE_VERSION = 29
// v31: Hermes cost provenance. Subscription-included sessions stay $0,
// explicit estimates retain their status, and surviving Hermes sources replace
// v29 slices produced by the old API-equivalent fallback.
// 31: #1234 Hermes cost contract; 30 is claimed by #1132.
export const DAILY_CACHE_VERSION = 31
const MIN_SUPPORTED_VERSION = 28
/// Providers whose per-day CALL COUNT means something different at
@ -197,7 +201,18 @@ const MIN_SUPPORTED_VERSION = 28
/// for the (day, provider). A day whose copilot sources are gone yields no
/// fresh slice at all, so it still carries forward whole — the #1033 bar is
/// untouched, in both directions, and every other provider keeps the guard.
const PENDING_REDERIVE_PROVIDERS: readonly string[] = ['copilot']
const PENDING_REDERIVE_PROVIDER_VERSIONS: Readonly<Record<string, number>> = {
copilot: 26,
// Tracks DAILY_CACHE_VERSION: a v30 file may have been written by #1132's
// accounting, which never carried the Hermes cost contract.
hermes: 31,
}
function providersPendingRederiveFrom(fromVersion: number): string[] {
return Object.entries(PENDING_REDERIVE_PROVIDER_VERSIONS)
.filter(([, contractVersion]) => fromVersion < contractVersion)
.map(([provider]) => provider)
}
// Version-suffixed so different binaries each own a distinct file and never
// clobber an incompatible schema. Bumping the version mints a fresh filename;
// adoptOlderDailyCaches then unions days out of every previous file (including
@ -299,7 +314,7 @@ export type DailyCache = {
watermarkTrusted?: boolean
/// Providers still owed the one re-derivation that a migration from a
/// pre-`DAILY_CACHE_VERSION` cache entitles them to, despite a shrinking
/// call count (see PENDING_REDERIVE_PROVIDERS). Set by the migration,
/// call count (see PENDING_REDERIVE_PROVIDER_VERSIONS). Set by the migration,
/// cleared by the first COMPLETE re-derive — persisted rather than computed
/// so a partial parse in between does not silently spend the entitlement.
pendingRederive?: string[]
@ -453,11 +468,14 @@ function migrateDays(days: Record<string, unknown>[]): DailyEntry[] {
/// an unspent entitlement out of the parsed file so a same-version reload does
/// not drop it.
function pendingRederiveFor(fromVersion: number, parsed: unknown): string[] | undefined {
if (fromVersion < DAILY_CACHE_VERSION) return [...PENDING_REDERIVE_PROVIDERS]
const raw = (parsed as { pendingRederive?: unknown } | null)?.pendingRederive
if (!Array.isArray(raw)) return undefined
const kept = raw.filter((p): p is string => typeof p === 'string')
return kept.length > 0 ? kept : undefined
const pending = new Set(
Array.isArray(raw) ? raw.filter((p): p is string => typeof p === 'string') : [],
)
if (fromVersion < DAILY_CACHE_VERSION) {
for (const provider of providersPendingRederiveFrom(fromVersion)) pending.add(provider)
}
return pending.size > 0 ? [...pending] : undefined
}
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean; watermarkTrusted?: boolean }): DailyCache {
@ -578,9 +596,17 @@ async function adoptOlderDailyCaches(): Promise<DailyCache> {
days,
// Anything adopted out of an OLDER file was derived under an older
// accounting, so the providers whose call counts changed meaning get their
// one guarded-shrink-exempt re-derivation (see PENDING_REDERIVE_PROVIDERS).
pendingRederive: base.pendingRederive
?? (rest.some(c => c.parsed.version < DAILY_CACHE_VERSION) ? [...PENDING_REDERIVE_PROVIDERS] : undefined),
// one guarded-shrink-exempt re-derivation (see
// PENDING_REDERIVE_PROVIDER_VERSIONS).
pendingRederive: (() => {
const pending = new Set(base.pendingRederive ?? [])
for (const candidate of rest) {
for (const provider of pendingRederiveFor(candidate.parsed.version, candidate.parsed) ?? []) {
pending.add(provider)
}
}
return pending.size > 0 ? [...pending] : undefined
})(),
// An untrusted base means nothing here was derived under the current
// accounting: leave complete unset so the next hydration re-derives every
// day whose sources survive (the merge keeps the rest).
@ -1091,7 +1117,7 @@ export function mergeDayEntries(
guardPartialSurvival = false,
/// Providers whose baseline slices were recorded under an accounting where a
/// call meant something else, so a shrink is not evidence of source loss for
/// this one re-derivation (see PENDING_REDERIVE_PROVIDERS). Only consulted
/// this one re-derivation (see PENDING_REDERIVE_PROVIDER_VERSIONS). Only consulted
/// where the fresh parse actually produced a slice for the (date, provider);
/// a slice it could not produce at all is carried by the branch above,
/// exactly as before.

View file

@ -1,5 +1,5 @@
import { open, rename, unlink, mkdir } from 'fs/promises'
import { existsSync, readFileSync } from 'fs'
import { existsSync, readFileSync, unlinkSync } from 'fs'
import { randomBytes } from 'crypto'
import { join } from 'path'
@ -22,10 +22,13 @@ import type { CachedCall, ProviderSection } from './session-cache.js'
// dropped: sealed days are not re-read. This slice does not bump
// DAILY_CACHE_VERSION (one cold re-parse for every user) to recover history.
export const HERMES_SESSION_LEDGER_FILENAME = 'hermes-session-ledger.v1.json'
export const HERMES_SESSION_LEDGER_VERSION = 1 as const
// v3 discards the local-only v2 migration candidate. Seeding v2 from the warm
// session cache could preserve lifetime totals while assigning historical
// observations to the migration day, inflating that day's scoped Hermes spend.
export const HERMES_SESSION_LEDGER_FILENAME = 'hermes-session-ledger.v3.json'
export const HERMES_SESSION_LEDGER_VERSION = 3 as const
export type HermesCostBasis = 'actual' | 'estimated' | 'calculated'
export type HermesCostBasis = 'actual' | 'estimated' | 'calculated' | 'included'
export type HermesTokenTotals = {
inputTokens: number
@ -299,7 +302,7 @@ function isNum(v: unknown): v is number {
}
function isCostBasis(v: unknown): v is HermesCostBasis {
return v === 'actual' || v === 'estimated' || v === 'calculated'
return v === 'actual' || v === 'estimated' || v === 'calculated' || v === 'included'
}
function validateTokens(o: Record<string, unknown>): o is Record<string, unknown> & HermesTokenTotals {
@ -346,7 +349,17 @@ function validateLedger(raw: unknown): raw is HermesSessionLedger {
function readLedgerFromDisk(): HermesSessionLedger {
const path = hermesSessionLedgerPath()
if (!existsSync(path)) return emptyHermesSessionLedger()
if (!existsSync(path)) {
// The v1 file is superseded and can never be read again: the parse-version
// bump forces a cold re-parse that rebuilds every cursor from source.
// Left behind it is dead bytes that only invite a future reader to trust
// observations recorded under an accounting this branch replaced.
const v1 = join(getCodeburnCacheDir(), 'hermes-session-ledger.v1.json')
if (existsSync(v1)) {
try { unlinkSync(v1) } catch { /* another process got there first */ }
}
return emptyHermesSessionLedger()
}
try {
const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'))
if (!validateLedger(parsed)) return emptyHermesSessionLedger()

View file

@ -55,7 +55,7 @@ const { version } = require('../package.json')
// Bump when the menubar payload's rendering semantics change without a package
// release or daily-cache version change. The envelope version in session-cache
// protects record shape; this protects the meaning of an otherwise valid one.
const STATUS_SNAPSHOT_RENDER_VERSION = 2
const STATUS_SNAPSHOT_RENDER_VERSION = 4
const STATUS_SNAPSHOT_SEMANTIC_KEY = `${version}:render-${STATUS_SNAPSHOT_RENDER_VERSION}:daily-${DAILY_CACHE_VERSION}`
import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
import { CodexThroughputReader, newestCodexSession, renderCodexThroughput } from './codex-throughput.js'

View file

@ -111,6 +111,11 @@ export type DailyModelBreakdown = {
calls: number
inputTokens: number
outputTokens: number
/// Raw provider/model ids that collapsed into this display name (e.g.
/// `minimax/MiniMax-M3` and `MiniMaxAI/MiniMax-M3` both showing as "MiniMax
/// M3"). Present only when more than one raw id folded in, so a cached vs
/// uncached route can still be told apart (#1239).
rawModels?: string[]
}
export type DailyHistoryEntry = {

View file

@ -28,6 +28,11 @@ export type ModelReportRow = {
savingsUSD: number
savingsBaselineModel: string
calls: number
/// Raw provider/model ids folded into this row by the canonical-id merge
/// above (e.g. `minimax/MiniMax-M3` and `MiniMaxAI/MiniMax-M3` both showing
/// as "MiniMax M3"). Length 1 when nothing merged. Lets a cached vs
/// uncached route stay visible even though the row is one model (#1239).
rawModels: string[]
/// Codex credit consumption (issues #408/#495). null for non-Codex models or
/// Codex models without a known credit rate. A merged row that mixed rated
/// and unrated buckets stores the partial sum of the rated ones.
@ -217,6 +222,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
existing.savingsUSD += bucket.savingsUSD
existing.calls += bucket.calls
existing.savingsBaselineModel = resolvedBaseline
if (!existing.rawModels.includes(bucket.model)) existing.rawModels.push(bucket.model)
const existingRated = existing.credits !== null
const incomingRated = bucketCredits !== null
if (incomingRated) existing.credits = (existing.credits ?? 0) + bucketCredits
@ -238,6 +244,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
savingsUSD: bucket.savingsUSD,
savingsBaselineModel: resolvedBaseline,
calls: bucket.calls,
rawModels: [bucket.model],
credits: bucketCredits,
})
}
@ -657,6 +664,7 @@ export function renderJson(rows: ModelReportRow[]): string {
providerDisplayName: r.providerDisplayName,
model: r.model,
modelDisplayName: r.modelDisplayName,
rawModels: r.rawModels,
category: r.category ?? r.topCategory ?? null,
agentType: r.agentType ?? null,
topCategory: r.topCategory ?? null,

View file

@ -1248,9 +1248,6 @@ const autoModelNames: Record<string, string> = {
}
const SHORT_NAMES: Record<string, string> = {
// claude-fable-5 and claude-mythos-5 are outside the opus/sonnet/haiku families deriveClaudeShortName covers.
'claude-fable-5': 'Fable 5',
'claude-mythos-5': 'Mythos 5',
// Modern claude-<family>-<major>-<minor> ids are derived in deriveClaudeShortName.
// Only the legacy 3.x ids (family-last) need explicit mapping.
'claude-3-7-sonnet': 'Sonnet 3.7',
@ -1363,9 +1360,15 @@ const SORTED_SHORT_NAMES: [string, string][] = Object.entries(SHORT_NAMES)
// Anthropic's id scheme is `claude-<family>-<major>[-<minor>]`, so every new
// version is derivable — no hand-maintained entry per release. (Legacy 3.x ids
// put the family last, e.g. `claude-3-5-sonnet`, and stay in SHORT_NAMES.)
const CLAUDE_FAMILY: Record<string, string> = { opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku' }
const CLAUDE_FAMILY: Record<string, string> = {
opus: 'Opus',
sonnet: 'Sonnet',
haiku: 'Haiku',
fable: 'Fable',
mythos: 'Mythos',
}
function deriveClaudeShortName(canonical: string): string | undefined {
const m = canonical.match(/^claude-(opus|sonnet|haiku)-(\d+)(?:-(\d+))?/)
const m = canonical.match(/^claude-(opus|sonnet|haiku|fable|mythos)-(\d+)(?:-(\d+))?/)
if (!m) return undefined
const [, family, major, minor] = m
return `${CLAUDE_FAMILY[family]} ${major}${minor ? `.${minor}` : ''}`

View file

@ -37,6 +37,8 @@ type HermesSessionRow = {
reasoning_tokens: number | null
estimated_cost_usd: number | null
actual_cost_usd: number | null
cost_status: string | null
cost_source: string | null
api_call_count: number | null
tool_call_count: number | null
started_at: number | null
@ -474,13 +476,23 @@ function resolveHermesCost(
if (row && row.actual_cost_usd != null) {
return { costUSD: row.actual_cost_usd, costIsEstimated: false, costBasis: 'actual' }
}
// estimated_cost_usd = 0.0 is not a measurement: Hermes writes it when cost_status
// is 'unknown' (no pricing data) or 'included' (flat subscription), so trusting it
// would zero out every subscription session. Only positive estimates are trusted;
// anything else falls through to the token-based calculation. A genuinely free
// model still lands on $0 either way, since litellm prices it at 0.
if (row && row.estimated_cost_usd != null && row.estimated_cost_usd > 0) {
return { costUSD: row.estimated_cost_usd, costIsEstimated: false, costBasis: 'estimated' }
const costStatus = row?.cost_status?.trim().toLowerCase()
const costSource = row?.cost_source?.trim().toLowerCase()
// Included subscription usage is real activity but not metered API spend.
// Its recorded zero must win over API-equivalent token pricing.
if (costStatus === 'included') {
return { costUSD: 0, costIsEstimated: false, costBasis: 'included' }
}
// An explicit Hermes estimate is authoritative even when it is zero (for
// example, a provider's free tier). Preserve that provenance in the UI.
if (costStatus === 'estimated' && row?.estimated_cost_usd != null) {
return { costUSD: row.estimated_cost_usd, costIsEstimated: true, costBasis: 'estimated' }
}
// Only rows predating BOTH provenance fields use the legacy positive-estimate
// fallback. A provenance-aware `unknown` row can retain a partial estimate
// from earlier priced calls; treating that as the session total undercounts.
if (!costStatus && !costSource && row && row.estimated_cost_usd != null && row.estimated_cost_usd > 0) {
return { costUSD: row.estimated_cost_usd, costIsEstimated: true, costBasis: 'estimated' }
}
return { costUSD: calculatedCost, costIsEstimated: true, costBasis: 'calculated' }
}
@ -514,9 +526,11 @@ function observationToCall(
reasoningTokens: observation.reasoningTokens,
webSearchRequests: 0,
costUSD: observation.costUSD,
// Later observations keep the stored basis: calculated stays estimated;
// provider-recorded actual/estimated keep main's measured behavior.
costIsEstimated: later ? observation.costBasis === 'calculated' : args.costIsEstimated,
// Later observations keep the stored basis. Included and actual are
// recorded facts; estimated and calculated remain estimates.
costIsEstimated: later
? observation.costBasis === 'calculated' || observation.costBasis === 'estimated'
: args.costIsEstimated,
tools: later ? [] : args.tools,
bashCommands: later ? [] : args.bashCommands,
timestamp: observation.timestamp,
@ -644,6 +658,8 @@ function createParser(source: SessionSource, seenKeys: Set<string>, hermesHome:
${numberColumn(columns, 'reasoning_tokens')},
${nullableColumn(columns, 'estimated_cost_usd')},
${nullableColumn(columns, 'actual_cost_usd')},
${nullableColumn(columns, 'cost_status')},
${nullableColumn(columns, 'cost_source')},
${numberColumn(columns, 'api_call_count')},
${numberColumn(columns, 'tool_call_count')},
${nullableColumn(columns, 'started_at')},

View file

@ -373,7 +373,11 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// replays (double-counted before), takes the model from the reporting
// assistant/message, and keeps agent-injected context out of the preview.
dsh: 'seed-aware-v1',
hermes: 'reasoning-output-accounting-v1-est-cost-routed-ids-workspace-pr-v5-zero-estimate-fallback-v1',
// cost-provenance-v3: preserve Hermes included/estimated/actual status and
// rebuild the provider section alongside the v3 lifetime ledger. The parse
// bump is required with the ledger bump: seeding a new ledger from a section
// produced under v2 can turn historical accounting deltas into today's use.
hermes: 'reasoning-output-accounting-v1-est-cost-routed-ids-workspace-pr-v5-cost-provenance-v3',
'lingtai-tui': 'token-ledger-registry-activity-v3',
'ibm-bob': 'worktree-project-grouping-v1',
// project-path-v1: the parser now records the session's full working

View file

@ -17,7 +17,7 @@ import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, compu
import { buildPrAttribution, aggregateByBranch } from './sessions-report.js'
import { scanAndDetect } from './optimize.js'
import { callBillableOutputTokens, sessionBillableOutputTokens } from './session-output.js'
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, mergeDayEntries, 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.
@ -221,19 +221,51 @@ async function claudeConfigSelector(projects: ProjectSummary[], selectedId?: str
return buildSelector(byId, selectedId)
}
/// `d.models` keys by the raw provider id (day-aggregator), same as
/// PeriodData.models — buildTopModels (menubar-json.ts) already merges those
/// into display names for `current.topModels`. Merge here too, so
/// `history.daily[].topModels` (the Spend/Trend charts) cannot disagree with
/// it or with `codeburn models` about what counts as one model (#1239). Raw
/// ids that collapse into one row are kept as `rawModels` so a cached vs
/// uncached route stays visible.
export function mergeDayModelsByDisplayName(models: DailyEntry['models']): Array<{
name: string
cost: number
savingsUSD: number
calls: number
inputTokens: number
outputTokens: number
rawModels: string[]
}> {
const merged = new Map<string, { cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; rawModels: string[] }>()
for (const [raw, m] of Object.entries(models)) {
if (raw === '<synthetic>') continue
const name = getShortModelName(raw)
const acc = merged.get(name) ?? { cost: 0, savingsUSD: 0, calls: 0, inputTokens: 0, outputTokens: 0, rawModels: [] }
acc.cost += m.cost
acc.savingsUSD += m.savingsUSD ?? 0
acc.calls += m.calls
acc.inputTokens += m.inputTokens
acc.outputTokens += m.outputTokens
if (!acc.rawModels.includes(raw)) acc.rawModels.push(raw)
merged.set(name, acc)
}
return [...merged.entries()].map(([name, d]) => ({ name, ...d }))
}
function dailyEntriesToHistory(days: ReturnType<typeof aggregateProjectsIntoDays>): MenubarPayload['history']['daily'] {
return days.map(d => {
const topModels = Object.entries(d.models)
.filter(([name]) => name !== '<synthetic>')
.sort(([, a], [, b]) => b.cost - a.cost)
const topModels = mergeDayModelsByDisplayName(d.models)
.sort((a, b) => b.cost - a.cost)
.slice(0, 5)
.map(([name, m]) => ({
name,
.map(m => ({
name: m.name,
cost: m.cost,
savingsUSD: m.savingsUSD,
calls: m.calls,
inputTokens: m.inputTokens,
outputTokens: m.outputTokens,
...(m.rawModels.length > 1 ? { rawModels: m.rawModels } : {}),
}))
return {
date: d.date,
@ -284,6 +316,36 @@ function sliceDayToProvider(day: DailyEntry, provider: string): DailyEntry {
}
}
/// Overlay surviving provider-scoped source data onto the durable all-provider
/// cache without touching unrelated providers. The result is provider-sliced so
/// headline and history consumers cannot accidentally count unrelated providers.
///
/// A settled day's sources age off disk continuously, so a fresh
/// provider-scoped parse of a historical date is a LOWER BOUND, not a
/// correction. Setting it unconditionally (what this did) replaced finalized
/// cache days with whatever the shrunken parse could still see, so the scoped
/// view reported a fraction of the day the all-provider view served off the
/// same cache.
///
/// The rule is therefore: fresh may FILL a (date, provider) the cache lacks,
/// and wins on any day still inside the settle window, but it may never SHRINK
/// a settled day the cache already holds. That is exactly mergeDayEntries with
/// guardPartialSurvival, whose argument order matters: `fresh` is primary and
/// the cache baseline is secondary, so the guard reads the shrink in the
/// direction it expects. Filling still has to work, because on a cold cache
/// the scoped parse is the only source a historical day has.
export function overlayProviderDaySlices(
baseline: DailyEntry[],
fresh: DailyEntry[],
provider: string,
): DailyEntry[] {
const freshSliced = fresh
.filter(day => Object.hasOwn(day.providers, provider))
.map(day => sliceDayToProvider(day, provider))
const sliced = baseline.map(day => sliceDayToProvider(day, provider))
return mergeDayEntries(freshSliced, sliced, false, undefined, true)
}
/// 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 —
@ -534,10 +596,10 @@ export type DurablePeriod = {
/// Fresh per-period parse (provider + name filtered) for detail views that
/// still need surviving session files.
liveProjects: ProjectSummary[]
/// Hydrated all-provider cache (reused by the menubar's provider list + daily
/// history sections).
/// Shared durable cache. All-provider requests hydrate it; provider-scoped
/// requests load it without triggering unrelated provider scans.
cache: DailyCache
/// Today-only slice, all providers, name-filtered (memo seed for the menubar).
/// Today-only, name-filtered slice for the active provider scope.
todayAllDays: DailyEntry[]
/// The scan range the live parse covered (today-only when the period is today).
scanRange: DateRange
@ -556,14 +618,18 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
const rangeEndStr = toDateString(periodInfo.range.end)
const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr
const cache = await hydrateCache()
// The shared daily cache is hydrated only by an all-provider request. A
// provider tab must not turn into a hidden all-provider scan on a cold or
// migrating cache; it loads the durable cache as-is and overlays the selected
// provider's surviving source data below.
const cache = pf === 'all' ? await hydrateCache() : await loadDailyCache()
// Today's live data always comes from an all-provider parse so the union (and
// any per-provider slice of it) sees every provider's today. `todayAllDays` is
// the today bucket only — the union filters the historical remainder out of the
// cache.
// The unscoped path parses all providers. A provider-scoped path parses only
// that provider and overlays its fresh slices onto the durable cache below.
// `todayAllDays` is the today bucket for whichever scope is active.
let liveProjects: ProjectSummary[]
let todayAllDays: DailyEntry[]
let freshProviderDays: DailyEntry[] = []
let scanRange: DateRange
if (pf === 'all') {
if (isTodayOnly) {
@ -587,10 +653,15 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
: aggregateProjectsIntoDays(fp(await parseAllSessions(todayRange, 'all'))).filter(d => d.date === todayStr)
}
} else {
// Provider-filtered: today's all-provider parse feeds the union (sliced
// below); the provider-scoped parse feeds the detail/enrichment fields.
todayAllDays = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRange, 'all'))).filter(d => d.date === todayStr)
// Provider-filtered: one provider-scoped parse feeds both today's union
// slice and the detail/enrichment fields. Scanning every unrelated provider
// here made a first hover deserialize the entire multi-gigabyte cache even
// though the returned payload contains only `pf`.
const rawProv = fp(await parseAllSessions(isTodayOnly ? todayRange : periodInfo.range, pf))
freshProviderDays = aggregateProjectsIntoDays(rawProv)
todayAllDays = rangeEndStr >= todayStr
? aggregateProjectsIntoDays(filterProjectsByDays(rawProv, new Set([todayStr]))).filter(d => d.date === todayStr)
: []
liveProjects = daysSelection && !isTodayOnly ? filterProjectsByDays(rawProv, daysSelection.days) : rawProv
scanRange = isTodayOnly ? todayRange : periodInfo.range
}
@ -622,7 +693,14 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
: undefined
const allDays = unionDaysForPeriod(cache, todayAllDays, periodInfo, daysSelection?.days ?? null, sliceHistorical)
const days = pf === 'all' ? allDays : allDays.map(d => sliceDayToProvider(d, pf))
const freshDaysInSelection = freshProviderDays.filter(day =>
day.date >= rangeStartStr
&& day.date <= rangeEndStr
&& (!daysSelection || daysSelection.days.has(day.date)),
)
const days = pf === 'all'
? allDays
: overlayProviderDaySlices(allDays, freshDaysInSelection, pf)
const data = buildPeriodDataFromDays(days, periodInfo.label)
// Enrich the cache-authoritative headline with fields DailyEntry cannot carry.
@ -745,11 +823,10 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
// still renders.
}
if (!effectivelyScoped) {
// Every non-scoped headline — all-provider AND provider-filtered — is built
// by the one shared durable-totals builder. It unions the carry-forward
// cache with today's live parse (slicing to the provider when filtered), so
// days whose session files have expired still count. The provider list and
// daily-history sections below reuse its cache + today slice.
// Every non-config-scoped headline is built by the shared durable-totals
// builder. The all-provider path hydrates the cache; a provider-filtered
// path overlays that provider's surviving source slices without scanning
// unrelated providers. Expired-source history remains durable in both.
const durable = await buildDurablePeriod(periodInfo, {
provider: pf,
project: opts.project,
@ -871,38 +948,9 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
const fullHistory = [...allCacheDays, ...todayDays]
dailyHistory = dailyEntriesToHistory(fullHistory)
} else {
const emptyModels = [] as { name: string; cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number }[]
const historyFromCache = allCacheDays.map(d => {
const prov = d.providers[pf] ?? { calls: 0, cost: 0, savingsUSD: 0 }
return {
date: d.date,
cost: prov.cost,
savingsUSD: prov.savingsUSD,
calls: prov.calls,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: emptyModels,
}
})
const todayFromParse = aggregateProjectsIntoDays(scanProjects)
.filter(d => d.date === todayStr)
.map(d => {
const prov = d.providers[pf] ?? { calls: 0, cost: 0, savingsUSD: 0 }
return {
date: d.date,
cost: prov.cost,
savingsUSD: prov.savingsUSD,
calls: prov.calls,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: emptyModels,
}
})
dailyHistory = [...historyFromCache, ...todayFromParse]
const freshHistory = [...aggregateProjectsIntoDays(scanProjects), ...(todayAllDays ?? [])]
.filter(day => day.date >= historyStartStr && day.date <= todayStr)
dailyHistory = dailyEntriesToHistory(overlayProviderDaySlices(allCacheDays, freshHistory, pf))
}
const home = homedir()

View file

@ -84,6 +84,26 @@ async function seedCarriedCache(): Promise<string> {
return day
}
/** Sources for the settled day still exist but explain only a fraction of what
* the cache finalized there, which is what source aging looks like in the
* window before a transcript is deleted outright. */
async function seedPartialSourcesFor(date: string): Promise<void> {
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p')
await mkdir(projectDir, { recursive: true })
const [y, m, d] = date.split('-').map(Number)
const line = JSON.stringify({
type: 'assistant',
timestamp: new Date(y!, m! - 1, d!, 12, 0, 0).toISOString(),
sessionId: 's-partial',
message: {
type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', id: 'm-partial',
content: [],
usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
},
})
await writeFile(join(projectDir, 's-partial.jsonl'), line + '\n', 'utf-8')
}
/** Seed a real, priced Claude session dated TODAY (the live, surviving half). */
async function seedLiveTodaySession(): Promise<void> {
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p')
@ -211,6 +231,25 @@ describe('CLI totals ↔ menubar parity through the durable daily cache', () =>
expect(codexDurable.carriedCostUSD).toBe(0)
})
it('keeps the scoped view equal to the all-provider slice when a settled day only partially survives', async () => {
const day = await seedCarriedCache()
await seedPartialSourcesFor(day)
await seedLiveTodaySession()
const range = getDateRange('all').range
clearSessionCache()
const all = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', optimize: false, timeline: false })
clearSessionCache()
const scoped = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'claude', optimize: false, timeline: false })
// The corpus is entirely Claude, so the scoped headline IS the all-provider
// Claude slice. The scoped path used to overlay the shrunken re-parse over
// the settled day and report a fraction of the all-provider total.
expect(all.current.calls).toBeGreaterThan(40)
expect(scoped.current.calls).toBe(all.current.calls)
expect(scoped.current.cost).toBeCloseTo(all.current.cost, 6)
})
it('holds the equality in the plain live regime with no carried days', async () => {
// No cache seeded: the only data is today's surviving session.
await seedLiveTodaySession()

View file

@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { existsSync, readdirSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter as pathDelimiter, join } from 'node:path'
@ -842,6 +842,52 @@ describe('codeburn status --format menubar-json', () => {
}
})
it('rejects a provider payload cached under the previous render contract', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-provider-render-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000))
const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1')].join('\n'),
)
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize']
const first = runCli(args, home)
expect(first.status, `stderr: ${first.stderr}`).toBe(0)
const snapshotFiles = findSnapshotFiles(join(home, '.cache', 'codeburn'))
expect(snapshotFiles).toHaveLength(1)
const record = JSON.parse(await readFile(snapshotFiles[0]!, 'utf-8')) as {
semanticKey: string
payload: { current: { providerDetails: unknown[] } }
}
record.semanticKey = record.semanticKey.replace(/:render-\d+:/, ':render-2:')
record.payload.current.providerDetails = [
{ id: 'legacy-idle', label: 'Legacy Idle', cost: 0 },
]
await writeFile(snapshotFiles[0]!, JSON.stringify(record))
const second = runCli(args, home)
expect(second.status, `stderr: ${second.stderr}`).toBe(0)
const payload = JSON.parse(second.stdout) as {
current: { providerDetails: Array<{ id: string; calls: number; hasUsage: boolean }> }
}
expect(payload.current.providerDetails.map(provider => provider.id)).not.toContain('legacy-idle')
expect(payload.current.providerDetails.find(provider => provider.id === 'claude')).toMatchObject({
calls: 1,
hasUsage: true,
})
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('reprices from an updated live LiteLLM cache instead of serving a stale snapshot', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-pricing-gen-'))

View file

@ -736,11 +736,16 @@ describe('#946: a migration re-derives copilot instead of carrying it', () => {
/// Seeds an older-version daily cache (what a 0.9.20 install leaves behind)
/// so `loadDailyCache` takes the adoption path.
/// Pinned, not derived from DAILY_CACHE_VERSION: this fixture is the
/// pre-copilot-contract era, which is a fixed point in history.
/// `DAILY_CACHE_VERSION - 5` drifted across the copilot boundary (26) on the
/// next bump and silently stopped seeding the entitlement these tests check.
const PRE_COPILOT_CONTRACT_VERSION = 25
const seedOlderCache = async (days: DailyEntry[]) => {
await writeFile(
join(TMP_CACHE_ROOT, `daily-cache.v${DAILY_CACHE_VERSION - 4}.json`),
join(TMP_CACHE_ROOT, `daily-cache.v${PRE_COPILOT_CONTRACT_VERSION}.json`),
JSON.stringify({
version: DAILY_CACHE_VERSION - 4,
version: PRE_COPILOT_CONTRACT_VERSION,
savingsConfigHash: 'cfg-A',
tzKey: currentTzKey(),
lastComputedDate: daysAgoStr(1),
@ -763,6 +768,61 @@ describe('#946: a migration re-derives copilot instead of carrying it', () => {
expect(out.pendingRederive).toBeUndefined()
})
it('re-derives a Hermes slice after the cost-provenance contract changes', async () => {
const corrupt = slice(4603, 6764, { sessions: 6764 })
const corrected = slice(0.438, 6, { sessions: 6 })
await seedOlderCache([day(settled, { hermes: corrupt })])
const out = await ensureCacheHydrated(
noSessions,
() => [day(settled, { hermes: corrected })],
'cfg-A',
)
expect(out.days.find(d => d.date === settled)!.providers['hermes'])
.toMatchObject({ cost: 0.438, calls: 6, sessions: 6 })
expect(out.pendingRederive).toBeUndefined()
})
it('does not re-open Copilot re-derivation for a cache already past its contract change', async () => {
await writeFile(
join(TMP_CACHE_ROOT, `daily-cache.v${DAILY_CACHE_VERSION - 1}.json`),
JSON.stringify({
version: DAILY_CACHE_VERSION - 1,
savingsConfigHash: 'cfg-A',
tzKey: currentTzKey(),
lastComputedDate: daysAgoStr(1),
days: [day(settled, { copilot: PRE_STORE, hermes: slice(4603, 6764) })],
complete: true,
watermarkTrusted: true,
}),
'utf-8',
)
const loaded = await loadDailyCache()
expect(loaded.pendingRederive).toEqual(['hermes'])
})
it('preserves an older cache pending repair while adding a newer provider repair', async () => {
await writeFile(
join(TMP_CACHE_ROOT, `daily-cache.v${DAILY_CACHE_VERSION - 1}.json`),
JSON.stringify({
version: DAILY_CACHE_VERSION - 1,
savingsConfigHash: 'cfg-A',
tzKey: currentTzKey(),
lastComputedDate: daysAgoStr(1),
days: [day(settled, { copilot: PRE_STORE, hermes: slice(4603, 6764) })],
complete: true,
watermarkTrusted: true,
pendingRederive: ['copilot'],
}),
'utf-8',
)
const loaded = await loadDailyCache()
expect(loaded.pendingRederive).toEqual(['copilot', 'hermes'])
})
it('still carries the slice whole when the sources are gone (never-lose, #1033)', async () => {
await seedOlderCache([day(settled, { copilot: PRE_STORE })])
// The re-derive finds nothing at all for that day: the store and the
@ -794,7 +854,7 @@ describe('#946: a migration re-derives copilot instead of carrying it', () => {
it('a PARTIAL parse does not spend the entitlement', async () => {
await seedOlderCache([day(settled, { copilot: PRE_STORE })])
const partial = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
expect(partial.pendingRederive).toEqual(['copilot'])
expect(partial.pendingRederive).toEqual(['copilot', 'hermes'])
// The next COMPLETE run still gets to re-derive.
const out = await ensureCacheHydrated(noSessions, () => [day(settled, { copilot: STORE_BACKED })], 'cfg-A')
expect(out.days.find(d => d.date === settled)!.providers['copilot'])

View file

@ -4,8 +4,10 @@ import { join } from 'path'
import { tmpdir } from 'os'
import {
DAILY_CACHE_VERSION,
currentTzKey,
ensureCacheHydrated,
loadDailyCache,
toDateString,
type DailyEntry,
} from '../src/daily-cache.js'
@ -107,3 +109,50 @@ describe('daily-cache re-derivation on a DAILY_CACHE_VERSION bump', () => {
expect(JSON.parse(await readFile(oldPath, 'utf8'))).toEqual(oldCache)
})
})
// v30 is claimed by two open branches at once: this one and #1132. A v30 file
// on disk therefore carries UNKNOWN accounting, so adopting it as the
// finalized base would serve #1132's numbers under this branch's contract.
describe('daily-cache adoption of a v30 file written under a different accounting', () => {
it('carries its days forward but never adopts it as the finalized base', async () => {
const date = toDateString(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
const yesterday = toDateString(new Date(Date.now() - 24 * 60 * 60 * 1000))
const foreign = day(date, 42)
foreign.providers = {
hermes: {
calls: 1,
cost: 42,
savingsUSD: 0,
sessions: 1,
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 30,
cacheWriteTokens: 0,
},
}
await writeFile(
join(cacheRoot, 'daily-cache.v30.json'),
JSON.stringify({
version: 30,
savingsConfigHash: 'cfg',
tzKey: currentTzKey(),
lastComputedDate: yesterday,
days: [foreign],
complete: true,
watermarkTrusted: true,
}),
)
const loaded = await loadDailyCache()
expect(DAILY_CACHE_VERSION).toBe(31)
expect(loaded.version).toBe(31)
// A v30 candidate must not satisfy the same-version adoption at
// adoptOlderDailyCaches, so its trust markers cannot survive.
expect(loaded.complete).toBe(false)
expect(loaded.watermarkTrusted).toBe(false)
const carried = loaded.days.find(entry => entry.date === date)
expect(carried?.carried).toBe(true)
expect(loaded.pendingRederive).toContain('hermes')
})
})

View file

@ -28,6 +28,7 @@ function seedDb(): void {
input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0,
cache_read_tokens INTEGER DEFAULT 0, cache_write_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0, estimated_cost_usd REAL, actual_cost_usd REAL,
cost_status TEXT, cost_source TEXT,
api_call_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0,
started_at REAL, title TEXT
)
@ -41,18 +42,24 @@ function seedDb(): void {
const startedAt = Date.now() / 1000 - 3600
const insert = db.prepare(
`INSERT INTO sessions (id, source, model, input_tokens, output_tokens,
estimated_cost_usd, actual_cost_usd, api_call_count, started_at)
VALUES (?, 'cli', ?, ?, ?, ?, ?, 1, ?)`,
estimated_cost_usd, actual_cost_usd, cost_status, cost_source, api_call_count, started_at)
VALUES (?, 'cli', ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
)
// Hermes writes estimated 0.0 when cost_status is 'unknown' or 'included'
// (subscription). CodeBurn must not trust that $0: the tokens are real, so
// cost falls through to the token-based calculation.
insert.run('zero-estimate', 'claude-opus-4-6', 100000, 10000, 0.0, null, startedAt)
// Subscription-included usage is recorded $0 spend even though it carries
// real tokens. API-equivalent pricing must not overwrite that provenance.
insert.run('included-zero', 'gpt-5.6-sol', 100000, 10000, 0.0, null, 'included', 'none', startedAt)
// Unknown cost with no usable estimate falls back to token pricing.
insert.run('unknown-zero', 'claude-opus-4-6', 100000, 10000, 0.0, null, 'unknown', 'none', startedAt)
// An unknown row may retain a partial estimate from earlier priced calls.
// It is not a complete session total and must still be recalculated.
insert.run('unknown-partial', 'claude-opus-4-6', 100000, 10000, 0.25, null, 'unknown', 'official_docs_snapshot', startedAt)
// A positive recorded estimate stays authoritative.
insert.run('positive-estimate', 'claude-opus-4-6', 100000, 10000, 0.5, null, startedAt)
insert.run('positive-estimate', 'claude-opus-4-6', 100000, 10000, 0.5, null, 'estimated', 'official_docs_snapshot', startedAt)
// An explicitly estimated free value remains zero.
insert.run('estimated-zero', 'free-model', 100000, 10000, 0.0, null, 'estimated', 'provider_models_api', startedAt)
// An explicit $0 *actual* invoice amount is recorded fact and stays $0.
insert.run('zero-actual', 'claude-opus-4-6', 100000, 10000, null, 0.0, startedAt)
for (const id of ['zero-estimate', 'positive-estimate', 'zero-actual']) {
insert.run('zero-actual', 'claude-opus-4-6', 100000, 10000, null, 0.0, 'actual', 'invoice', startedAt)
for (const id of ['included-zero', 'unknown-zero', 'unknown-partial', 'positive-estimate', 'estimated-zero', 'zero-actual']) {
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run(id, 'user', `session ${id}`, startedAt)
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
@ -77,18 +84,28 @@ afterEach(async () => {
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
skipUnlessSqlite('hermes recorded-cost fallback', () => {
it('ignores a $0 estimated cost and calculates from tokens', async () => {
it('preserves included and recorded zeroes while estimating unknown cost', async () => {
seedDb()
const projects = await parseAllSessions(undefined, 'hermes')
const sessions = projects.flatMap(project => project.sessions)
const byId = new Map(sessions.map(s => [s.sessionId, s]))
const zeroEstimate = byId.get('zero-estimate')!
expect(zeroEstimate.totalCostUSD).toBeGreaterThan(0)
const included = byId.get('included-zero')!
expect(included.totalCostUSD).toBe(0)
const unknown = byId.get('unknown-zero')!
expect(unknown.totalCostUSD).toBeGreaterThan(0)
const unknownPartial = byId.get('unknown-partial')!
expect(unknownPartial.totalCostUSD).toBeGreaterThan(0)
expect(unknownPartial.totalCostUSD).not.toBe(0.25)
const positive = byId.get('positive-estimate')!
expect(positive.totalCostUSD).toBe(0.5)
const estimatedZero = byId.get('estimated-zero')!
expect(estimatedZero.totalCostUSD).toBe(0)
const zeroActual = byId.get('zero-actual')!
expect(zeroActual.totalCostUSD).toBe(0)
})

View file

@ -1,10 +1,12 @@
import { mkdir, mkdtemp, rm } from 'fs/promises'
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
applyHermesSnapshot,
HERMES_SESSION_LEDGER_VERSION,
hermesBaselineKey,
hermesObservationKey,
hermesSessionLedgerPath,
@ -237,10 +239,41 @@ describe('hermes session ledger unit', () => {
await mkdir(hermesSessionLedgerPath())
let caught: unknown
try {
await persistHermesSessionLedger({ version: 1, cursors: {} })
await persistHermesSessionLedger({ version: HERMES_SESSION_LEDGER_VERSION, cursors: {} })
} catch (err) {
caught = err
}
expect(isHermesLedgerPublicationError(caught)).toBe(true)
})
it('ignores a superseded v1 ledger and removes it from the cache dir', async () => {
const v1Path = join(cacheDir, 'hermes-session-ledger.v1.json')
await writeFile(v1Path, JSON.stringify({
version: 1,
cursors: {
default: {
historical: {
profile: 'default',
sessionId: 'historical',
lastSeen: {
inputTokens: 1,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
costUSD: 4603,
costBasis: 'calculated',
},
observations: [],
},
},
},
}))
expect(existsSync(v1Path)).toBe(true)
// No v3 file: the loader starts empty, and the v1 file it can never read
// again must not survive the load.
expect(loadHermesSessionLedger().cursors).toEqual({})
expect(existsSync(v1Path)).toBe(false)
})
})

View file

@ -295,7 +295,7 @@ describe('buildMenubarPayload', () => {
])
})
it('keeps zero-cost detected providers in the legacy dict for compatibility', () => {
it('keeps the legacy provider dict additive while providerDetails remains the activity authority', () => {
const providers: ProviderCost[] = [
{ name: 'claude', displayName: 'Claude', cost: 76.45 },
{ name: 'codex', displayName: 'Codex', cost: 0 },
@ -303,6 +303,11 @@ describe('buildMenubarPayload', () => {
]
const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
expect(payload.current.providers).toEqual({ claude: 76.45, codex: 0, cursor: 2.18 })
expect(payload.current.providerDetails).toEqual([
{ id: 'claude', label: 'Claude', cost: 76.45, calls: 0, hasUsage: true },
{ id: 'codex', label: 'Codex', cost: 0, calls: 0, hasUsage: false },
{ id: 'cursor', label: 'Cursor', cost: 2.18, calls: 0, hasUsage: true },
])
})
it('includes up to 365 daily history entries sorted ascending by date', () => {

View file

@ -0,0 +1,62 @@
import { readFileSync } from 'fs'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
import { allProviderNames } from '../src/providers/index.js'
// The menubar's ProviderFilter.cliArg is what gets passed to `--provider`, and
// it is also the id the payload-consistency guard matches against
// providerDetails. A case whose cliArg is not a real registry name silently
// disables that guard for its tab: the lookup finds no detail row, so no
// contradiction is ever detected. Nothing in either language can see both
// sides, so the contract is checked here, where the registry is importable and
// the Swift source is readable.
const APP_STORE = join(process.cwd(), 'mac/Sources/CodeBurnMenubar/AppStore.swift')
function providerFilterSource(): string {
const source = readFileSync(APP_STORE, 'utf-8')
const start = source.indexOf('enum ProviderFilter')
expect(start).toBeGreaterThan(-1)
return source.slice(start)
}
/** Every `case foo = "Bar"` declared on the enum. */
function enumCases(source: string): string[] {
const body = source.slice(0, source.indexOf('var id: String'))
return [...body.matchAll(/^\s*case (\w+) = "/gm)].map(m => m[1]!)
}
/** The `case .foo: "bar"` table inside `var cliArg`. */
function cliArgTable(source: string): Record<string, string> {
const start = source.indexOf('var cliArg: String {')
expect(start).toBeGreaterThan(-1)
const body = source.slice(start, source.indexOf('\n }', start))
return Object.fromEntries(
[...body.matchAll(/case \.(\w+): "([^"]+)"/g)].map(m => [m[1]!, m[2]!]),
)
}
describe('menubar ProviderFilter to CLI provider registry', () => {
const source = providerFilterSource()
const cases = enumCases(source)
const cliArgs = cliArgTable(source)
const registry = new Set(allProviderNames())
it('declares a cliArg for every enum case', () => {
expect(cases.length).toBeGreaterThan(20)
expect(cases.filter(name => !(name in cliArgs))).toEqual([])
})
it.each(Object.entries(cliArgs).filter(([name]) => name !== 'all'))(
'maps .%s to a provider the CLI registry knows',
(_name, cliArg) => {
expect(registry.has(cliArg)).toBe(true)
},
)
it('keeps .all outside the registry, since it is the no-filter sentinel', () => {
expect(cliArgs['all']).toBe('all')
expect(registry.has('all')).toBe(false)
})
})

View file

@ -315,6 +315,20 @@ describe('aggregateModels', () => {
expect(rows[0]!.calls).toBe(3)
})
it('collapses two raw MiniMax routes into one row and keeps both visible as rawModels (#1239)', async () => {
const rows = await aggregateModels([makeProject([
makeTurn('feature', [makeCall({ provider: 'opencode', model: 'minimax/MiniMax-M3', input: 100, output: 50, costUSD: 0.05 })]),
makeTurn('feature', [makeCall({ provider: 'opencode', model: 'MiniMaxAI/MiniMax-M3', input: 200, output: 100, costUSD: 6.94 })]),
])])
expect(rows).toHaveLength(1)
expect(rows[0]!.modelDisplayName).toBe('MiniMax M3')
expect(rows[0]!.rawModels).toEqual(['minimax/MiniMax-M3', 'MiniMaxAI/MiniMax-M3'])
expect(rows[0]!.costUSD).toBeCloseTo(6.99, 6)
const parsed = JSON.parse(renderJson(rows))
expect(parsed[0].rawModels).toEqual(['minimax/MiniMax-M3', 'MiniMaxAI/MiniMax-M3'])
})
it('does not merge the same display name across providers', async () => {
const rows = await aggregateModels([makeProject([
makeTurn('feature', [makeCall({ provider: 'cline-cli', model: 'glm-5p2', costUSD: 1 })]),

View file

@ -298,6 +298,11 @@ describe('getShortModelName', () => {
expect(getShortModelName('claude-opus-9-9-20300101')).toBe('Opus 9.9')
})
it('derives versioned Fable and Mythos labels from their model ids', () => {
expect(getShortModelName('claude-fable-5-1')).toBe('Fable 5.1')
expect(getShortModelName('claude-mythos-5-2-20300101')).toBe('Mythos 5.2')
})
it('shows the real model name for pricing-sibling aliases, not the internal key', () => {
// GLM-5.2 (and its lowercase Hermes spelling) price via the glm-5p1 sibling;
// reports must show GLM-5.2, not the pricing key.

View file

@ -0,0 +1,18 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
scripts: Record<string, string>
}
describe('release build contract', () => {
it('builds from checked-in pricing catalogs instead of mutating them from the network', () => {
expect(packageJson.scripts['bundle-litellm']).toBe('node scripts/bundle-litellm.mjs')
expect(packageJson.scripts.build).not.toContain('bundle-litellm')
})
it('installs dashboard dependencies without rewriting the lockfile', () => {
expect(packageJson.scripts['build:dash']).toContain('npm ci')
expect(packageJson.scripts['build:dash']).not.toContain('npm install')
})
})

View file

@ -0,0 +1,96 @@
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { buildMenubarPayloadForRange, overlayProviderDaySlices } from '../src/usage-aggregator.js'
import { getDateRange } from '../src/cli-date.js'
import { loadPricing } from '../src/models.js'
import type { DailyEntry, ProviderDaySlice } from '../src/daily-cache.js'
const parseAllSessions = vi.hoisted(() => vi.fn(async () => []))
vi.mock('../src/parser.js', async (importOriginal) => {
const mod = await importOriginal<typeof import('../src/parser.js')>()
return {
...mod,
parseAllSessions,
isSessionHydrationComplete: vi.fn(() => true),
sessionHydrationSnapshot: vi.fn(() => ({
complete: true,
deferredForFirstPaint: false,
indexedFiles: 0,
pendingFiles: 0,
})),
}
})
vi.mock('../src/daily-cache.js', async (importOriginal) => {
const mod = await importOriginal<typeof import('../src/daily-cache.js')>()
return {
...mod,
ensureCacheHydrated: vi.fn(async (parseSessions: (range: { start: Date; end: Date }) => Promise<unknown>) => {
await parseSessions({ start: new Date(0), end: new Date() })
return mod.emptyCache()
}),
loadDailyCache: vi.fn(async () => mod.emptyCache()),
}
})
describe('provider-scoped menubar aggregation', () => {
beforeAll(async () => {
await loadPricing()
})
it('never scans unrelated providers to render one selected provider', async () => {
parseAllSessions.mockClear()
await buildMenubarPayloadForRange(getDateRange('today'), {
provider: 'hermes',
optimize: false,
timeline: false,
})
expect(parseAllSessions.mock.calls.map(([, provider]) => provider))
.toEqual(['hermes'])
})
it('overlays today without letting a shrunken parse rewrite settled history', () => {
const slice = (cost: number, calls: number): ProviderDaySlice => ({ cost, calls, savingsUSD: 0, sessions: calls })
const day = (date: string, providers: Record<string, ProviderDaySlice>): DailyEntry => ({
date,
cost: Object.values(providers).reduce((sum, provider) => sum + provider.cost, 0),
calls: Object.values(providers).reduce((sum, provider) => sum + provider.calls, 0),
sessions: Object.values(providers).reduce((sum, provider) => sum + (provider.sessions ?? 0), 0),
savingsUSD: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 0,
oneShotTurns: 0,
models: {},
categories: {},
providers,
})
// 2026-08-20 is settled and its sources have partially aged off disk: the
// fresh parse sees 3 of the 100 calls the cache finalized, so the cache
// stays authoritative. 2026-08-21 is a settled day the cache never held
// (cold cache), where the fresh parse is the only source there is.
const result = overlayProviderDaySlices(
[
day('2026-08-20', { hermes: slice(10, 100), claude: slice(2, 3) }),
],
[
day('2026-08-20', { hermes: slice(0.3, 3) }),
day('2026-08-21', { hermes: slice(0.438, 6) }),
],
'hermes',
)
expect(result.map(entry => ({ date: entry.date, cost: entry.cost, calls: entry.calls }))).toEqual([
{ date: '2026-08-20', cost: 10, calls: 100 },
{ date: '2026-08-21', cost: 0.438, calls: 6 },
])
expect(result[0]!.providers).toEqual({ hermes: slice(10, 100) })
expect(result[1]!.providers).toEqual({ hermes: slice(0.438, 6) })
})
})

View file

@ -1,7 +1,12 @@
import { describe, expect, it, beforeAll } from 'vitest'
import { buildMenubarPayloadForRange, providerSliceHasUsage } from '../src/usage-aggregator.js'
import { buildMenubarPayloadForRange, mergeDayModelsByDisplayName, providerSliceHasUsage } from '../src/usage-aggregator.js'
import { getDateRange } from '../src/cli-date.js'
import { loadPricing } from '../src/models.js'
import type { ModelDayStats } from '../src/daily-cache.js'
function modelStats(overrides: Partial<ModelDayStats>): ModelDayStats {
return { calls: 0, cost: 0, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, ...overrides }
}
describe('buildMenubarPayloadForRange', () => {
beforeAll(async () => {
@ -36,3 +41,29 @@ describe('buildMenubarPayloadForRange', () => {
expect(payload.stale).toBeUndefined()
})
})
describe('mergeDayModelsByDisplayName', () => {
it('collapses two raw MiniMax routes into one display-name row and keeps both raw ids (#1239)', () => {
const merged = mergeDayModelsByDisplayName({
'minimax/MiniMax-M3': modelStats({ calls: 355, cost: 6.99, inputTokens: 500, outputTokens: 100 }),
'MiniMaxAI/MiniMax-M3': modelStats({ calls: 60, cost: 0.37, inputTokens: 200, outputTokens: 50 }),
})
expect(merged).toHaveLength(1)
expect(merged[0]!.name).toBe('MiniMax M3')
expect(merged[0]!.rawModels).toEqual(['minimax/MiniMax-M3', 'MiniMaxAI/MiniMax-M3'])
expect(merged[0]!.calls).toBe(415)
expect(merged[0]!.cost).toBeCloseTo(7.36, 6)
expect(merged[0]!.inputTokens).toBe(700)
expect(merged[0]!.outputTokens).toBe(150)
})
it('keeps distinct models on separate rows with a single-entry rawModels', () => {
const merged = mergeDayModelsByDisplayName({
'claude-sonnet-4-6': modelStats({ calls: 1, cost: 1 }),
'gpt-5': modelStats({ calls: 2, cost: 2 }),
})
expect(merged.map(m => m.name).sort()).toEqual(['GPT-5', 'Sonnet 4.6'])
for (const row of merged) expect(row.rawModels).toHaveLength(1)
})
})