diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 9ac5f7816..c9041d345 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -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, @@ -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 diff --git a/tests/dashboard-period-truth.test.ts b/tests/dashboard-period-truth.test.ts index 8b27f721b..f79da8076 100644 --- a/tests/dashboard-period-truth.test.ts +++ b/tests/dashboard-period-truth.test.ts @@ -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 diff --git a/tests/dashboard-provider-refresh.test.ts b/tests/dashboard-provider-refresh.test.ts new file mode 100644 index 000000000..a34355269 --- /dev/null +++ b/tests/dashboard-provider-refresh.test.ts @@ -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(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) +})