diff --git a/app/renderer/sections/Overview.test.tsx b/app/renderer/sections/Overview.test.tsx index 8ec61af80..48540d3d7 100644 --- a/app/renderer/sections/Overview.test.tsx +++ b/app/renderer/sections/Overview.test.tsx @@ -339,6 +339,34 @@ describe('Overview', () => { await waitFor(() => expect(container.querySelector('.ov-hero-num')).toHaveTextContent(new RegExp(`^${shown.replace(/[$.]/g, '\\$&')}$`))) }) + it.each(['poll', 'period switch'] as const)('survives estimate-marker changes during a %s (#1760)', (change) => { + const payload = makePayload(new Date()) + const estimated = { + ...payload, + current: { + ...payload.current, + cost: 320, + providerDetails: [{ id: 'claude', label: 'Claude', cost: 320, estimatedCostUSD: 20 }], + }, + } + const { container, rerender } = render( + , + ) + expect(container.querySelector('.ov-hero-num')).toHaveTextContent(/^\$312\.40$/) + + const period = change === 'period switch' ? 'week' : '30days' + rerender() + const hero = container.querySelector('.ov-hero-num')! + expect(hero).toHaveTextContent(/^~\$320\.00$/) + // The original outer trigger still anchors the token/estimate popover. + fireEvent.focus(hero) + expect(screen.getByRole('tooltip')).toHaveTextContent(/estimated/i) + fireEvent.blur(hero) + + rerender() + expect(container.querySelector('.ov-hero-num')).toHaveTextContent(/^\$312\.40$/) + }) + it('keeps weekday labels, and month labels on one row at least three columns apart', async () => { const now = new Date() getOverview.mockResolvedValue(makePayload(now)) @@ -1106,6 +1134,18 @@ describe('Overview', () => { expect(screen.queryByLabelText('Coaching signals')).not.toBeInTheDocument() }) + it('updates the hero currency even when its raw cost and filter stay the same', () => { + const payload = makePayload(new Date()) + const { container, rerender } = render( + , + ) + expect(container.querySelector('.ov-hero-num')).toHaveTextContent(/^\$312\.40$/) + + setActiveCurrency({ code: 'EUR', symbol: '€', rate: 0.9 }) + rerender() + expect(container.querySelector('.ov-hero-num')).toHaveTextContent(/^€281\.16$/) + }) + it('renders section costs in the active non-USD currency (rate applied once, symbol swapped)', async () => { setActiveCurrency({ code: 'EUR', symbol: '€', rate: 0.9 }) const now = new Date() diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index 2a9374438..de3bddc9b 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -601,7 +601,11 @@ function streakDays(daily: DailyHistoryEntry[], now: Date): number { function CountUp({ value, tokens, animateKey, animate = true, estimated = false }: { value: number; tokens?: ReturnType; animateKey: string; animate?: boolean; estimated?: boolean }) { const pop = useUsdPop(tokens, false, estimated) const mark = estimated ? '~' : '' - const ref = pop.ref + const formattedValue = formatUsd(value) + // React owns the marker and wrapper; GSAP owns only the empty number span. + // Replacing React's text children made React insert the estimate marker + // before a detached node (#1760). + const ref = useRef(null) const keyRef = useRef(null) useEffect(() => { @@ -610,7 +614,7 @@ function CountUp({ value, tokens, animateKey, animate = true, estimated = false const keyChanged = keyRef.current !== animateKey keyRef.current = animateKey if (!animate || !keyChanged || !motionEnabled()) { - element.textContent = mark + formatUsd(value) + element.textContent = formattedValue return } const counter = { n: 0 } @@ -618,14 +622,14 @@ function CountUp({ value, tokens, animateKey, animate = true, estimated = false n: value, duration: 0.7, ease: 'power2.out', - onUpdate: () => { element.textContent = mark + formatUsd(counter.n) }, + onUpdate: () => { element.textContent = formatUsd(counter.n) }, }) return () => { tween.kill() } - }, [value, animateKey, animate, mark]) + }, [value, animateKey, animate, formattedValue]) return ( <> -
{mark}{formatUsd(value)}
+
{mark}
{pop.pop} ) diff --git a/src/main.ts b/src/main.ts index a8ec40fd8..2c37edc5f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -645,7 +645,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: const sessionCountBasis = durable.data.sessionCountBasis // Same durable day set as the headline, so a project's row is what selecting // it reports (expired transcripts included), one row per repository. - const projectList = buildPayloadProjects(projects, durable.days, homedir()).map(p => ({ + const projectList = buildPayloadProjects(projects, durable.days, homedir(), durable.sessionCountCacheDays).map(p => ({ name: p.name, path: p.path ?? p.id ?? p.name, cost: convertCost(p.cost), diff --git a/src/status-snapshot-semantic.ts b/src/status-snapshot-semantic.ts index d3bb7ca33..a32daf58a 100644 --- a/src/status-snapshot-semantic.ts +++ b/src/status-snapshot-semantic.ts @@ -39,7 +39,8 @@ import { DAILY_CACHE_VERSION } from './daily-cache.js' // turns and cost for otherwise identical period queries. // v12: pullRequests rows carry carriedUSD, runList, state and followUpAt, and // push evidence moves spend between rows; an older snapshot shows the old card. -export const STATUS_SNAPSHOT_RENDER_VERSION = 12 +// v13: today's live session identities are exact, including project/provider counts. +export const STATUS_SNAPSHOT_RENDER_VERSION = 13 /// The semantic key recorded on every status snapshot. A snapshot whose stored /// key differs (an older render revision, or a different daily-cache version) diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 9ac5f7816..fae776a00 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -824,7 +824,8 @@ export function buildDurableOverviewFromNormalizedIndex( // Fields whose durable day rows cannot project under a project filter come // from the same normalized period slice that feeds the visible detail panels. const scan = buildPeriodData(periodInfo.label, scanProjects) - const sessionCount = uniquePeriodSessionCount(scanProjects, days) + // Today is always sourced from the normalized index, with session IDs. + const sessionCount = uniquePeriodSessionCount(scanProjects, days.filter(day => day.date !== todayStr)) data.sessions = sessionCount.sessions data.sessionCountBasis = sessionCount.basis if (hasProjectFilter) { @@ -882,6 +883,9 @@ export type DurablePeriod = { /// `includeGatewayInTotals` on, so a surface renders the labelled row /// whenever `costUSD` is non-zero. excludedGateway: ExcludedGatewayTotals + /// Days whose accounting may contain sessions without live identities. + /// Today is entirely live; historical reconciliations remain conservative. + sessionCountCacheDays: DailyEntry[] /// Fresh per-period parse (provider + name filtered) for detail views that /// still need surviving session files. liveProjects: ProjectSummary[] @@ -1032,7 +1036,10 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate // Daily occupancy ticks are not unique across a period (one session file // active on two days is two ticks). Prefer canonical provider+sessionId // from the live parse; occupancy remains only for source-dead days. - const sessionCount = uniquePeriodSessionCount(liveProjects, days) + // The union always takes today from this parse, never from the cache. + // Keep every day for money, but only historical rows can lack identities. + const sessionCountCacheDays = days.filter(day => day.date !== todayStr) + const sessionCount = uniquePeriodSessionCount(liveProjects, sessionCountCacheDays) data.sessions = sessionCount.sessions data.sessionCountBasis = sessionCount.basis // Tokens/models/categories have no per-project split in the day cache, so @@ -1089,7 +1096,7 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate }] })) as PeriodTotals : undefined - return { data, days, carriedCostUSD, unattributedCostUSD, excludedGateway: excludedGatewayTotals(days, pf), liveProjects, knownProjects, cache, todayAllDays, scanRange, periodTotals } + return { data, days, sessionCountCacheDays, carriedCostUSD, unattributedCostUSD, excludedGateway: excludedGatewayTotals(days, pf), liveProjects, knownProjects, cache, todayAllDays, scanRange, periodTotals } } type PayloadProject = NonNullable[number] @@ -1310,15 +1317,19 @@ export function buildPayloadProjects( scanProjects: ProjectSummary[], cacheDays: DailyEntry[] | null, home: string, + sessionCountCacheDays: DailyEntry[] | null = cacheDays, ): PayloadProject[] { + // Money still comes from the complete day set. Only rows actually eligible + // for cache-only accounting can make the corresponding project count partial. + const sessionCountCacheDaySet = new Set(sessionCountCacheDays ?? []) type Contrib = { cacheCost: number cacheSavings: number - cacheSessions: number cacheSessionDays: number cacheMaxDaySessions: number cacheCalls: number hasCache: boolean + unknownSessions: boolean liveCost: number liveSavings: number liveSessions: number @@ -1360,11 +1371,11 @@ export function buildPayloadProjects( c = { cacheCost: 0, cacheSavings: 0, - cacheSessions: 0, cacheSessionDays: 0, cacheMaxDaySessions: 0, cacheCalls: 0, hasCache: false, + unknownSessions: false, liveCost: 0, liveSavings: 0, liveSessions: 0, @@ -1384,16 +1395,16 @@ export function buildPayloadProjects( path: string | undefined, cost: number, savingsUSD: number, - occupancy: { sessions: number, sessionDays: number, maxDaySessions: number, calls: number, originKey?: string }, + occupancy: { sessions: number, sessionDays: number, maxDaySessions: number, calls: number, unknownSessions: boolean, originKey?: string }, ): Acc => { const acc = take(slug, path, slug) acc.originKey ??= occupancy.originKey const c = contribOf(acc, slug) c.hasCache = true + c.unknownSessions ||= occupancy.unknownSessions c.cacheCost += cost c.cacheSavings += savingsUSD c.cacheCalls += occupancy.calls - c.cacheSessions += occupancy.sessions c.cacheSessionDays += occupancy.sessionDays if (occupancy.maxDaySessions > c.cacheMaxDaySessions) c.cacheMaxDaySessions = occupancy.maxDaySessions return acc @@ -1415,16 +1426,17 @@ export function buildPayloadProjects( for (const s of p.sessions) rememberLiveSlug(s.project, p.projectPath) } - type Totals = { cost: number; savingsUSD: number; sessions: number; sessionDays: number; maxDaySessions: number; calls: number; originKey?: string } + type Totals = { cost: number; savingsUSD: number; sessions: number; sessionDays: number; maxDaySessions: number; calls: number; unknownSessions: boolean; originKey?: string } const knownBySlug = new Map>() const pathlessBySlug = new Map() - const emptyTotals = (): Totals => ({ cost: 0, savingsUSD: 0, sessions: 0, sessionDays: 0, maxDaySessions: 0, calls: 0 }) - const addTotals = (into: Totals, cost: number, savingsUSD: number, sessions: number, calls: number): void => { + const emptyTotals = (): Totals => ({ cost: 0, savingsUSD: 0, sessions: 0, sessionDays: 0, maxDaySessions: 0, calls: 0, unknownSessions: false }) + const addTotals = (into: Totals, cost: number, savingsUSD: number, sessions: number, calls: number, unknownSessions: boolean): void => { + into.unknownSessions ||= unknownSessions into.cost += cost into.savingsUSD += savingsUSD into.calls += calls into.sessions += sessions - if (sessions > 0) { + if (unknownSessions && sessions > 0) { into.sessionDays += 1 if (sessions > into.maxDaySessions) into.maxDaySessions = sessions } @@ -1433,6 +1445,8 @@ export function buildPayloadProjects( for (const d of cacheDays) { for (const [key, p] of Object.entries(d.projects ?? {})) { const name = projectDayIdentity(key, p).project + const unknownSessions = sessionCountCacheDaySet.has(d) + && (d.carried === true || hasUnknownCacheAccounting(p)) if (p.path) { const id = spendProjectIdentity({ project: name, projectPath: p.path }).id let byId = knownBySlug.get(name) @@ -1441,12 +1455,12 @@ export function buildPayloadProjects( knownBySlug.set(name, byId) } const acc = byId.get(id) ?? emptyTotals() - addTotals(acc, p.cost, p.savingsUSD, p.sessions, p.calls) + addTotals(acc, p.cost, p.savingsUSD, p.sessions, p.calls, unknownSessions) acc.originKey ??= p.originKey byId.set(id, acc) } else { const acc = pathlessBySlug.get(name) ?? emptyTotals() - addTotals(acc, p.cost, p.savingsUSD, p.sessions, p.calls) + addTotals(acc, p.cost, p.savingsUSD, p.sessions, p.calls, unknownSessions) pathlessBySlug.set(name, acc) } } @@ -1540,15 +1554,7 @@ export function buildPayloadProjects( let unknownCache = false for (const c of acc.contribs.values()) { if (!c.hasCache) continue - if ( - c.cacheCost > 0 - || c.cacheSavings > 0 - || c.cacheCalls > 0 - || c.cacheSessions > 0 - || c.cacheMaxDaySessions > 0 - ) { - unknownCache = true - } + unknownCache ||= c.unknownSessions if (c.cacheMaxDaySessions > cacheDayBound) cacheDayBound = c.cacheMaxDaySessions } const counted = boundSessionCount(liveUnique, cacheDayBound, unknownCache) @@ -1675,6 +1681,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: /// unscoped all-provider path; it is the authority the projects view merges /// from, so carried days count even after their session files are gone. let cacheDaysForPeriod: DailyEntry[] | null = null + let sessionCountCacheDays: DailyEntry[] | null = null let claudeConfigs: ClaudeConfigSelector | undefined const requestedClaudeConfigSourceId = opts.claudeConfigSourceId?.trim() || null const isClaudeConfigScoped = requestedClaudeConfigSourceId !== null @@ -1721,6 +1728,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: scanProjects = durable.liveProjects scanRange = durable.scanRange cacheDaysForPeriod = durable.days + sessionCountCacheDays = durable.sessionCountCacheDays cache = durable.cache todayAllDays = durable.todayAllDays durablePeriodTotals = durable.periodTotals @@ -1818,7 +1826,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: providers, scanProjects, !(isClaudeConfigScoped && effectivelyScoped) - && (cacheDaysForPeriod ?? []).some(dayLacksSessionIdentities), + && (sessionCountCacheDays ?? []).some(dayLacksSessionIdentities), ) } // Day slices carry no estimated split, so it comes from the live parse, the @@ -1885,7 +1893,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: // (cache ?? live) otherwise. Days recorded before the projects rollup existed // have totals but no project split, so this list can sum to less than the // headline — an honest gap. - currentData.projects = buildPayloadProjects(scanProjects, cacheDaysForPeriod, home) + currentData.projects = buildPayloadProjects(scanProjects, cacheDaysForPeriod, home, sessionCountCacheDays) const effMap = aggregateModelEfficiency(scanProjects) currentData.modelEfficiency = [...effMap.entries()].map(([name, eff]) => ({ diff --git a/tests/cli-status-menubar.test.ts b/tests/cli-status-menubar.test.ts index 7c28f69b3..679c55ef5 100644 --- a/tests/cli-status-menubar.test.ts +++ b/tests/cli-status-menubar.test.ts @@ -1073,7 +1073,7 @@ describe('codeburn status --format menubar-json', () => { } }) - it('rejects a pre-count-basis render-5 snapshot and preserves nonempty costs', async () => { + it.each([5, 12])('rejects a render-%i snapshot and preserves nonempty Today identities and costs', async renderVersion => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-legacy-basis-live-')) try { @@ -1101,12 +1101,24 @@ describe('codeburn status --format menubar-json', () => { } expect(original.current.calls).toBe(1) expect(original.current.sessions).toBe(1) - expect(original.current.sessionCountBasis).toBe('partial') + expect(original.current.sessionCountBasis).toBe('identity') expect(original.current.cost).toBeGreaterThan(0) const snapshotFiles = findSnapshotFiles(join(home, '.cache', 'codeburn')) expect(snapshotFiles).toHaveLength(1) - await plantRender5MissingBasis(snapshotFiles[0]!) + if (renderVersion === 5) { + await plantRender5MissingBasis(snapshotFiles[0]!) + } else { + // v12 already knows the field but wrongly qualifies live Today as partial. + // A v13 rendering bump must reject this otherwise-valid warm snapshot. + const record = JSON.parse(await readFile(snapshotFiles[0]!, 'utf-8')) as { + semanticKey: string + payload: { current: { sessionCountBasis: string } } + } + record.semanticKey = record.semanticKey.replace(/:render-\d+:/, ':render-12:') + record.payload.current.sessionCountBasis = 'partial' + await writeFile(snapshotFiles[0]!, JSON.stringify(record)) + } const second = runCli(args, home) expect(second.status, `stderr: ${second.stderr}`).toBe(0) @@ -1156,7 +1168,8 @@ describe('codeburn status --format menubar-json', () => { const first = runCli(args, home) expect(first.status, `stderr: ${first.stderr}`).toBe(0) const original = JSON.parse(first.stdout) as { current: { cost: number, calls: number, sessions: number, sessionCountBasis?: string } } - expect(original.current.sessionCountBasis).toBe('partial') + // Before adding retained history, this period contains only today's live session. + expect(original.current.sessionCountBasis).toBe('identity') expect(original.current.sessions).toBe(1) const cacheDir = join(home, '.cache', 'codeburn') diff --git a/tests/live-session-count.test.ts b/tests/live-session-count.test.ts new file mode 100644 index 000000000..d1420a454 --- /dev/null +++ b/tests/live-session-count.test.ts @@ -0,0 +1,120 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { getDateRange } from '../src/cli-date.js' +import { currentTzKey, DAILY_CACHE_VERSION, emptyCache, toDateString, type DailyCache, type DailyEntry } from '../src/daily-cache.js' +import { loadPricing } from '../src/models.js' +import { clearSessionCache, parseAllSessions } from '../src/parser.js' +import { buildDurableOverviewFromNormalizedIndex, buildMenubarPayloadForRange, getDailyCacheConfigHash } from '../src/usage-aggregator.js' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'codeburn-live-count-')) + process.env['CLAUDE_CONFIG_DIR'] = join(root, 'claude') + process.env['CODEBURN_CACHE_DIR'] = join(root, 'cache') + process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(root, 'desktop') + clearSessionCache() + await loadPricing() + const now = new Date() + const timestamp = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString() + for (const name of ['alpha', 'beta']) { + const dir = join(root, 'claude', 'projects', name) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, `${name}.jsonl`), JSON.stringify({ + type: 'assistant', sessionId: name, timestamp, cwd: `/projects/${name}`, + message: { + id: name, role: 'assistant', type: 'message', model: 'claude-sonnet-4-6', content: [], + usage: { input_tokens: 1000, output_tokens: 100 }, + }, + }) + '\n') + } +}) + +afterEach(async () => { + clearSessionCache() + await rm(root, { recursive: true, force: true }) +}) + +async function seedHistory(): Promise { + const now = new Date() + const date = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) + const projects = { alpha: { path: '/projects/alpha', cost: 9, calls: 9, sessions: 3, savingsUSD: 0 } } + const day: DailyEntry = { + date, cost: 9, calls: 9, sessions: 3, savingsUSD: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + editTurns: 0, oneShotTurns: 0, models: {}, categories: {}, projects, + providers: { claude: { cost: 9, calls: 9, sessions: 3, savingsUSD: 0, projects } }, + carried: true, + } + const cache: DailyCache = { + ...emptyCache(), version: DAILY_CACHE_VERSION, days: [day], lastComputedDate: date, + savingsConfigHash: getDailyCacheConfigHash(), tzKey: currentTzKey(), complete: true, watermarkTrusted: true, + } + await mkdir(join(root, 'cache'), { recursive: true }) + await writeFile(join(root, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`), JSON.stringify(cache)) + return day +} + +const options = { optimize: false, timeline: false } + +describe('live Today session counts (#1785)', () => { + it.each([ + ['all', undefined, 2], + ['claude', undefined, 2], + ['all', ['alpha'], 1], + ['claude', ['alpha'], 1], + ] as const)('keeps Today exact for %s with project filter %j', async (provider, project, count) => { + // Retained history exists but is outside the selected Today range. + await seedHistory() + const payload = await buildMenubarPayloadForRange(getDateRange('today'), { + ...options, provider, project: project ? [...project] : undefined, + }) + expect(payload.current).toMatchObject({ sessions: count, sessionCountBasis: 'identity', calls: count }) + expect(payload.current.cost).toBeGreaterThan(0) + expect(payload.current.providerDetails.find(p => p.id === 'claude')) + .toMatchObject({ sessions: count, sessionCountBasis: 'identity' }) + expect(payload.current.topProjects).toHaveLength(count) + for (const row of payload.current.topProjects) { + expect(row).toMatchObject({ sessions: 1, sessionCountBasis: 'identity' }) + expect(row.avgCostPerSession).toBeCloseTo(row.cost) + } + expect(payload.current.topProjects.reduce((sum, p) => sum + p.cost, 0)).toBeCloseTo(payload.current.cost) + }) + + it.each(['all', 'claude'])('preserves historical lower bounds and money for %s', async provider => { + await seedHistory() + const today = await buildMenubarPayloadForRange(getDateRange('today'), { ...options, provider }) + const week = await buildMenubarPayloadForRange(getDateRange('week'), { ...options, provider }) + expect(week.current).toMatchObject({ sessions: 3, sessionCountBasis: 'partial', calls: 11 }) + expect(week.current.cost).toBeCloseTo(9 + today.current.cost) + expect(week.current.providerDetails.find(p => p.id === 'claude')) + .toMatchObject({ sessions: 3, sessionCountBasis: 'partial' }) + const historical = week.current.topProjects.find(p => p.name === 'alpha')! + expect(historical).toMatchObject({ sessions: 3, sessionCountBasis: 'partial' }) + // Kept numeric for older menubar decoders; current clients gate on basis. + expect(historical.avgCostPerSession).toBeCloseTo(historical.cost / 3) + // A different project backed only by today's live row stays exact. + expect(week.current.topProjects.find(p => p.name === 'beta')) + .toMatchObject({ sessions: 1, sessionCountBasis: 'identity' }) + expect(week.current.topProjects.reduce((sum, p) => sum + p.cost, 0)).toBeCloseTo(week.current.cost) + }) + + it.each(['all', 'claude'])('keeps the normalized-index Today headline exact for %s', async provider => { + const period = getDateRange('today') + const projects = await parseAllSessions(period.range, provider) + const result = buildDurableOverviewFromNormalizedIndex(period, projects, emptyCache(), { provider }) + expect(result).toMatchObject({ sessions: 2, sessionCountBasis: 'identity', calls: 2 }) + expect(result.cost).toBeGreaterThan(0) + }) + + it('keeps an empty Today exact', async () => { + await rm(join(root, 'claude'), { recursive: true, force: true }) + await seedHistory() + const payload = await buildMenubarPayloadForRange(getDateRange('today'), { ...options, provider: 'claude' }) + expect(payload.current).toMatchObject({ sessions: 0, sessionCountBasis: 'identity', cost: 0 }) + expect(payload.current.topProjects).toEqual([]) + }) +}) diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index 2ad2862a2..5eab8e632 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -310,6 +310,19 @@ describe('buildMenubarPayload', () => { expect(payload.current.topActivities).toHaveLength(20) }) + it('preserves exact live session counts across the headline, provider and project (#1785)', () => { + const period: PeriodData = { + ...emptyPeriod('Today'), cost: 4, calls: 2, sessions: 2, sessionCountBasis: 'identity', + projects: [{ name: 'project', cost: 4, savingsUSD: 0, sessions: 2, sessionCountBasis: 'identity' }], + } + const payload = buildMenubarPayload(period, [{ + name: 'claude', displayName: 'Claude', cost: 4, calls: 2, sessions: 2, sessionCountBasis: 'identity', + }], null) + expect(payload.current).toMatchObject({ sessions: 2, sessionCountBasis: 'identity' }) + expect(payload.current.providerDetails[0]).toMatchObject({ sessions: 2, sessionCountBasis: 'identity' }) + expect(payload.current.topProjects[0]).toMatchObject({ sessions: 2, sessionCountBasis: 'identity', avgCostPerSession: 2 }) + }) + it('computes cacheHitPercent from cache reads over input plus cache reads', () => { const period: PeriodData = { label: 'Today',