Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 9 additions & 16 deletions src/usage-aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,8 +755,9 @@ export type IndexedDurableOverview = {
* Project a dashboard headline from one normalized lifetime index. Existing
* durable cache days remain authoritative (so expired transcripts do not
* disappear); normalized days fill only dates the durable cache does not yet
* contain, which is the cold first-index case. Today always comes from the
* live normalized index. No parser is called here.
* contain, which is the cold first-index case. A provider-scoped index
* overlays its days the way buildDurablePeriod does. Today always comes from
* the live normalized index. No parser is called here.
*/
export function buildDurableOverviewFromNormalizedIndex(
periodInfo: PeriodInfo,
Expand Down Expand Up @@ -805,20 +806,12 @@ export function buildDurableOverviewFromNormalizedIndex(
)
: []
const allDays = [...cachedAllDays, ...normalizedHistoricalDays].sort((a, b) => a.date.localeCompare(b.date))
const normalizedByDate = new Map(normalizedDays.map(day => [day.date, day]))
const days = excludeGatewayFromDays(pf === 'all' ? allDays : allDays.map(day => {
if (Object.hasOwn(day.providers, pf)) return sliceDayToProvider(day, pf)
const normalized = normalizedByDate.get(day.date)
// The shared cache can be complete for a date while lacking this selected
// provider's slice (for example, Claude was cached before Codex appeared).
// Fill only that absent slice from the provider-scoped normalized index.
// An existing slice is authoritative unless the live parse explains more of
// it (the reconcile above), which retains carried/expired money and prevents
// the surviving source from being counted twice.
return normalized && Object.hasOwn(normalized.providers, pf)
? sliceDayToProvider(normalized, pf)
: sliceDayToProvider(day, pf)
}), pf)
// The shared cache can be complete yet predate this provider's sources, so a
// provider-scoped index applies the same overlay as buildDurablePeriod: the
// headline then matches report and the first paint after every refresh.
const days = excludeGatewayFromDays(pf === 'all'
? allDays
: overlayProviderDaySlices(allDays, normalizedDays.filter(day => day.date >= rangeStartStr && day.date <= rangeEndStr), pf), pf)
const data = buildPeriodDataFromDays(days, periodInfo.label)

// Fields whose durable day rows cannot project under a project filter come
Expand Down
26 changes: 25 additions & 1 deletion tests/dashboard-period-truth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,36 @@ describe('interactive period truth', () => {
}
expect(selectDashboardHistoryIndex({
provider: 'codex', normalizedProjects: [codex], cache: cacheWithCodex, planUsages: [], readyThrough: 'lifetime',
}, 'lifetime').durable).toMatchObject({ cost: 4, calls: 1, sessions: 1 })
}, 'lifetime').durable).toMatchObject({ cost: 7, calls: 1, sessions: 1 })
expect(selectDashboardHistoryIndex({
provider: 'codex', normalizedProjects: [], cache: cacheWithCodex, planUsages: [], readyThrough: 'lifetime',
}, 'lifetime').durable).toMatchObject({ cost: 4, calls: 1, sessions: 1 })
})

it.each(['copilot', 'codex'])('keeps %s days absent from a complete shared cache and counts a cached day once', provider => {
const at = (age: number) => new Date(Date.now() - age * 24 * 60 * 60 * 1000).toISOString()
const cached = historicalProviderProject(provider, 4, at(5))
const unrelated = historicalProviderProject('claude', 100, at(4))
const surviving = historicalProviderProject(provider, 1, at(5))
const missingDay = historicalProviderProject(provider, 7, at(3))
const outsideWeek = historicalProviderProject(provider, 50, at(12))
const sharedCache: DailyCache = {
...emptyDailyCache(), days: aggregateProjectsIntoDays([cached, unrelated]),
}
const before = structuredClone(sharedCache)
const history: DashboardHistoryIndex = {
provider, normalizedProjects: [surviving, missingDay, outsideWeek],
cache: sharedCache, planUsages: [], readyThrough: 'lifetime',
}

expect(selectDashboardHistoryIndex(history, 'week').durable).toMatchObject({
cost: 8, calls: 2, inputTokens: 20, outputTokens: 10,
})
expect(selectDashboardHistoryIndex(history, 'today').durable).toMatchObject({ cost: 0, calls: 0 })
expect(selectDashboardHistoryIndex(history, 'lifetime').durable).toMatchObject({ cost: 58, calls: 3 })
expect(sharedCache).toEqual(before)
})

it('names the visible explicit Month while a cold shared index widens in the background', async () => {
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
Expand Down
123 changes: 123 additions & 0 deletions tests/dashboard-provider-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { PassThrough } from 'node:stream'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import React from 'react'
import { render } from 'ink'
import stripAnsi from 'strip-ansi'
import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest'

import { assembleDashboardFirstPaint, buildDashboardHistoryIndex, InteractiveDashboard, selectDashboardHistoryIndex } from '../src/dashboard.js'
import { DAILY_CACHE_VERSION, emptyCache, saveDailyCache, toDateString } from '../src/daily-cache.js'
import { aggregateProjectsIntoDays } from '../src/day-aggregator.js'
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
import { clearLoadCacheMemo } from '../src/session-cache.js'
import { getDailyCacheConfigHash } from '../src/usage-aggregator.js'

let root: string

beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'dashboard-provider-refresh-'))
for (const [key, value] of Object.entries({
HOME: root, USERPROFILE: root, CLAUDE_CONFIG_DIR: join(root, 'claude'),
CODEBURN_CACHE_DIR: join(root, 'cache'), CODEBURN_COPILOT_DISABLE_OTEL: '1',
CODEBURN_COPILOT_SESSION_STATE_DIR: join(root, 'no-cli'),
CODEBURN_COPILOT_SESSION_STORE_DB: join(root, 'no-store'),
CODEBURN_COPILOT_WS_STORAGE_DIR: join(root, 'workspace'),
CODEBURN_COPILOT_GLOBAL_STORAGE_DIR: join(root, 'no-global'),
CODEBURN_COPILOT_JETBRAINS_DIR: join(root, 'no-jb'),
})) vi.stubEnv(key, value)
clearSessionCache()
clearLoadCacheMemo()
})

afterEach(async () => {
clearSessionCache()
clearLoadCacheMemo()
vi.unstubAllEnvs()
await rm(root, { recursive: true, force: true })
})

it('keeps the Copilot headline through cached startup and source refresh when its day is absent from a complete cache', async () => {
const at = (age: number) => new Date(Date.now() - age * 86400000).toISOString()
const oldDir = join(root, 'claude', 'projects', 'old')
await mkdir(oldDir, { recursive: true })
await writeFile(join(oldDir, 'old.jsonl'), JSON.stringify({
type: 'assistant', sessionId: 'old', timestamp: at(12), cwd: '/fixture/old',
message: { id: 'old', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
content: [], usage: { input_tokens: 100, output_tokens: 50 } },
}) + '\n')
// The all-provider cache was completed before these Copilot journals appeared.
// Its non-empty history is on a different day from both new sessions.
const oldProjects = await parseAllSessions(undefined, 'claude')
await saveDailyCache({
...emptyCache(getDailyCacheConfigHash()), days: aggregateProjectsIntoDays(oldProjects),
complete: true, watermarkTrusted: true, lastComputedDate: toDateString(new Date(at(1))),
})
const cachePath = join(root, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`)
const cacheBefore = await readFile(cachePath, 'utf8')

const timestamp = Date.parse(at(3))
for (const [id, credits] of [['alpha', 40], ['beta', 60]] as const) {
const dir = join(root, 'workspace', id, 'chatSessions')
await mkdir(dir, { recursive: true })
await writeFile(join(root, 'workspace', id, 'workspace.json'), JSON.stringify({ folder: `file:///fixture/${id}` }))
await writeFile(join(dir, `${id}.jsonl`), JSON.stringify({ kind: 0, v: {
sessionId: id, creationDate: timestamp, requests: [{
requestId: `${id}-request`, modelId: 'copilot/gpt-4o', timestamp,
modelTotals: [{ model: 'gpt-4o', inputTokens: 3500, cachedTokens: 2500, outputTokens: 500 }],
copilotCredits: credits,
}],
} }) + '\n')
}
await parseAllSessions(undefined, 'copilot')
clearSessionCache()
clearLoadCacheMemo()

const first = await assembleDashboardFirstPaint('week', 'copilot', undefined, undefined, undefined, null)
const expected = { cost: 1, calls: 2, sessions: 2, inputTokens: 2000, outputTokens: 1000, cacheReadTokens: 5000 }
expect(first.result.initialDurable).toMatchObject(expected)

const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
stdin.isTTY = true
stdin.setRawMode = () => stdin
stdin.ref = () => stdin
stdin.unref = () => stdin
stdout.isTTY = true
stdout.columns = 120
stdout.rows = 50
const frames: string[] = []
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
let indexed!: () => void
const settled = new Promise<void>(resolve => { indexed = resolve })
const app = render(React.createElement(InteractiveDashboard, {
initialProjects: first.result.filteredProjects, initialPeriod: 'week', initialProvider: 'copilot',
initialDurable: first.result.initialDurable, refreshSeconds: 0, windowColumns: 120,
initialHistoryIndexing: true, initialCacheWasCold: false, onHistoryIndexed: indexed,
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
onTestFinished(() => app.unmount())
await app.waitUntilRenderFlush()
expect(frames.filter(value => value.trim()).at(-1)).toContain('$1.00 cost 2 calls')
await settled
await app.waitUntilRenderFlush()
const refreshedFrame = frames.filter(value => value.trim()).at(-1) ?? ''
expect(refreshedFrame).toContain('$1.00 cost 2 calls')
expect(refreshedFrame).toContain('2.0K in 1.0K out 5.0K cached')
expect(refreshedFrame).not.toContain('source refresh pending')
app.unmount()

// Both index paths, repeated refreshes, and project filters must agree with
// the first paint without advancing the shared all-provider cache.
for (const preferCompleteSnapshot of [true, false]) {
const index = await buildDashboardHistoryIndex('copilot', undefined, undefined, { preferCompleteSnapshot })
expect(selectDashboardHistoryIndex(index, 'week').durable).toMatchObject(expected)
expect(selectDashboardHistoryIndex(index, 'lifetime').durable).toMatchObject(expected)
expect(selectDashboardHistoryIndex({ ...index, projectFilter: ['alpha'] }, 'week').durable)
.toMatchObject({ cost: 0.4, calls: 1, inputTokens: 1000, outputTokens: 500, cacheReadTokens: 2500 })
expect(selectDashboardHistoryIndex({ ...index, excludeFilter: ['alpha'] }, 'week').durable)
.toMatchObject({ cost: 0.6, calls: 1, inputTokens: 1000, outputTokens: 500, cacheReadTokens: 2500 })
}
expect(await readFile(cachePath, 'utf8')).toBe(cacheBefore)
})
Loading