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
12 changes: 8 additions & 4 deletions src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,10 +623,14 @@ export function withDailyCacheLock<T>(fn: () => Promise<T>): Promise<T> {

export const MS_PER_DAY = 24 * 60 * 60 * 1000
export const BACKFILL_DAYS = 365
// Keep 2 years of history so the longest bounded UI period (6 months
// via `all`) and the uncapped `lifetime` period both have headroom to
// read from cache while old entries get pruned.
export const DAILY_CACHE_RETENTION_DAYS = 730
// Ten years. This cache is the ONLY durable record of carried days (their
// session files are long deleted), and the uncapped `lifetime` period reads
// from it via buildDurablePeriod, so pruning at the old 2-year mark would
// have replayed the lost-history bug in slow motion at that horizon.
// Measured envelope keeps this honest: ~2.3 MB / ~11 ms JSON parse per 730
// days of fully dense data, so even a decade of daily use stays ~11 MB and
// well under 100 ms on the polling path.
export const DAILY_CACHE_RETENTION_DAYS = 3650

export function toDateString(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
Expand Down
34 changes: 23 additions & 11 deletions src/day-aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,27 +102,39 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
cat.oneShotTurns += oneShotTurns
turnDay.categories[turn.category] = cat

// Slice-level turn stats are attributed per provider actually present
// in the turn, each with only ITS calls' cost — a slice's category
// totals must never contain another provider's spend, or a later
// carry-forward of that slice would overstate the day.
const providersInTurn = new Map<string, { cost: number; savingsUSD: number }>()
// Cost stays attributed to every provider actually present in the turn,
// but turn counts belong to exactly one slice. Otherwise carrying one
// missing provider later re-adds a turn already present in the fresh
// provider's slice. Highest call count wins; when the first call's
// provider is tied for highest, replacing only on a strict increase
// leaves that provider selected.
const providersInTurn = new Map<string, { calls: number; cost: number; savingsUSD: number }>()
for (const call of turn.assistantCalls) {
const acc = providersInTurn.get(call.provider) ?? { cost: 0, savingsUSD: 0 }
const acc = providersInTurn.get(call.provider) ?? { calls: 0, cost: 0, savingsUSD: 0 }
acc.calls += 1
acc.cost += call.costUSD
acc.savingsUSD += call.savingsUSD ?? 0
providersInTurn.set(call.provider, acc)
}
let primaryProvider = turn.assistantCalls[0]!.provider
let primaryCalls = providersInTurn.get(primaryProvider)!.calls
for (const [provider, totals] of providersInTurn) {
if (totals.calls > primaryCalls) {
primaryProvider = provider
primaryCalls = totals.calls
}
}
for (const [prov, totals] of providersInTurn) {
const turnSlice = ensureSlice(turnDay, prov)
turnSlice.editTurns! += editTurns
turnSlice.oneShotTurns! += oneShotTurns
const ownsTurn = prov === primaryProvider
turnSlice.editTurns! += ownsTurn ? editTurns : 0
turnSlice.oneShotTurns! += ownsTurn ? oneShotTurns : 0
const sliceCat = turnSlice.categories![turn.category] ?? { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 }
sliceCat.turns += 1
sliceCat.turns += ownsTurn ? 1 : 0
sliceCat.cost += totals.cost
sliceCat.savingsUSD += totals.savingsUSD
sliceCat.editTurns += editTurns
sliceCat.oneShotTurns += oneShotTurns
sliceCat.editTurns += ownsTurn ? editTurns : 0
sliceCat.oneShotTurns += ownsTurn ? oneShotTurns : 0
turnSlice.categories![turn.category] = sliceCat
}

Expand Down
4 changes: 2 additions & 2 deletions tests/daily-cache-carry-forward.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ describe('never-lose invariant: invalidations with vanished sources', () => {
})

it('retention still prunes ancient carried days after a rebuild', async () => {
await seed({ days: [seededDay(), day('2020-01-01', { claude: slice(1, 1) })], complete: false })
await seed({ days: [seededDay(), day('2010-01-01', { claude: slice(1, 1) })], complete: false })
const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A')
expect(out.days.map(d => d.date)).toEqual([daysAgoStr(30)])
})
Expand Down Expand Up @@ -483,7 +483,7 @@ describe('adoption union across older cache files', () => {
const old = {
version: 13,
days: [
day('2023-01-01', { claude: slice(1, 1) }),
day('2010-01-01', { claude: slice(1, 1) }),
day(daysAgoStr(15), { claude: slice(5, 2) }),
day(daysAgoStr(0), { claude: slice(99, 9) }),
],
Expand Down
4 changes: 2 additions & 2 deletions tests/daily-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ describe('addNewDays', () => {
})

it('still prunes when newestDate is valid', () => {
const old = '2020-01-01'
const old = '2010-01-01'
const recent = '2026-04-10'
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
Expand All @@ -269,7 +269,7 @@ describe('addNewDays', () => {
days: [emptyDay(old, 1), emptyDay(recent, 2)],
}
const updated = addNewDays(base, [], recent)
// 730-day retention from 2026-04-10 → cutoff ~2024-04-11; 2020-01-01 must be gone.
// 3650-day retention from 2026-04-10 puts the cutoff in 2016; 2010-01-01 must be gone.
expect(updated.days.find(d => d.date === old)).toBeUndefined()
expect(updated.days.find(d => d.date === recent)).toBeDefined()
})
Expand Down
123 changes: 120 additions & 3 deletions tests/day-aggregator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,40 @@ function makeCall(timestamp: string, costUSD: number, model = 'Opus 4.7', provid
}
}

function makeSingleTurnProject(
assistantCalls: ReturnType<typeof makeCall>[],
{ retries = 0, hasEdits = true }: { retries?: number; hasEdits?: boolean } = {},
): ProjectSummary {
const timestamp = assistantCalls[0]!.timestamp
const totalCostUSD = assistantCalls.reduce((sum, call) => sum + call.costUSD, 0)
return makeProject({
sessions: [{
sessionId: 'multi-provider-session',
project: 'p',
firstTimestamp: timestamp,
lastTimestamp: assistantCalls.at(-1)!.timestamp,
totalCostUSD,
totalInputTokens: assistantCalls.reduce((sum, call) => sum + call.usage.inputTokens, 0),
totalOutputTokens: assistantCalls.reduce((sum, call) => sum + call.usage.outputTokens, 0),
totalCacheReadTokens: assistantCalls.reduce((sum, call) => sum + call.usage.cacheReadInputTokens, 0),
totalCacheWriteTokens: assistantCalls.reduce((sum, call) => sum + call.usage.cacheCreationInputTokens, 0),
apiCalls: assistantCalls.length,
turns: [{
userMessage: 'compare providers',
timestamp,
sessionId: 'multi-provider-session',
category: 'coding',
retries,
hasEdits,
assistantCalls,
}],
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown: {} as never,
skillBreakdown: {} as never,
}],
})
}

describe('aggregateProjectsIntoDays', () => {
it('buckets a whole turn (all its calls) on the turn user-message date', () => {
// Turn-anchored bucketing: a turn whose calls straddle midnight lands wholly
Expand Down Expand Up @@ -134,6 +168,48 @@ describe('aggregateProjectsIntoDays', () => {
})
})

it('skips a timestamped empty-call turn without throwing or counting it at day level', () => {
const projects: ProjectSummary[] = [
makeProject({
sessions: [{
sessionId: 'empty-call-turn',
project: 'p',
firstTimestamp: '2026-04-09T10:00:00',
lastTimestamp: '2026-04-09T10:00:00',
totalCostUSD: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 0,
turns: [{
userMessage: 'no assistant response',
timestamp: '2026-04-09T10:00:00',
sessionId: 'empty-call-turn',
category: 'coding',
retries: 0,
hasEdits: true,
assistantCalls: [],
}],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as never,
skillBreakdown: {} as never,
}],
}),
]

let days: ReturnType<typeof aggregateProjectsIntoDays> = []
expect(() => { days = aggregateProjectsIntoDays(projects) }).not.toThrow()

expect(days).toHaveLength(1)
expect(days[0]).toMatchObject({ cost: 0, calls: 0, editTurns: 0, oneShotTurns: 0 })
expect(days[0]!.categories).toEqual({})
expect(days[0]!.providers).toEqual({})
})

it('counts a session under its firstTimestamp date', () => {
const projects: ProjectSummary[] = [
makeProject({
Expand Down Expand Up @@ -213,10 +289,10 @@ describe('aggregateProjectsIntoDays', () => {
expect(day.providers['codex']!.models).toEqual({
'gpt-5': { calls: 1, cost: 3, savingsUSD: 0, inputTokens: 100, outputTokens: 200, cacheReadTokens: 50, cacheWriteTokens: 0 },
})
// Slice categories hold only that provider's share of the turn — a slice
// must never carry another provider's spend into a later merge.
// Slice categories hold only that provider's share of cost; the primary
// provider (the first call in this tie) owns the turn count.
expect(day.providers['claude']!.categories!['coding']).toMatchObject({ turns: 1, cost: 7 })
expect(day.providers['codex']!.categories!['coding']).toMatchObject({ turns: 1, cost: 3 })
expect(day.providers['codex']!.categories!['coding']).toMatchObject({ turns: 0, cost: 3 })
// Day-level category still counts the whole turn once.
expect(day.categories['coding']).toMatchObject({ turns: 1, cost: 10 })
// Per-project rollup at day level and inside each provider slice; path is
Expand All @@ -225,6 +301,47 @@ describe('aggregateProjectsIntoDays', () => {
expect(day.providers['claude']!.projects!['p']).toMatchObject({ cost: 7, calls: 1 })
expect(day.providers['codex']!.projects!['p']).toMatchObject({ cost: 3, calls: 1 })
})

it('attributes a multi-provider turn to the majority provider exactly once', () => {
const timestamp = '2026-04-10T10:00:00'
const projects = [makeSingleTurnProject([
makeCall(timestamp, 2, 'gpt-5', 'codex'),
makeCall(timestamp, 3, 'Opus 4.7', 'claude'),
makeCall(timestamp, 4, 'Opus 4.7', 'claude'),
])]

const day = aggregateProjectsIntoDays(projects)[0]!
const slices = Object.values(day.providers)

expect(day.editTurns).toBe(1)
expect(day.oneShotTurns).toBe(1)
expect(day.categories['coding']!.turns).toBe(1)
expect(slices.reduce((sum, provider) => sum + (provider.editTurns ?? 0), 0)).toBe(1)
expect(slices.reduce((sum, provider) => sum + (provider.oneShotTurns ?? 0), 0)).toBe(1)
expect(slices.reduce((sum, provider) => sum + (provider.categories?.['coding']?.turns ?? 0), 0)).toBe(1)
expect(day.providers['claude']).toMatchObject({ cost: 7, editTurns: 1, oneShotTurns: 1 })
expect(day.providers['claude']!.categories!['coding']).toMatchObject({ turns: 1, cost: 7, editTurns: 1, oneShotTurns: 1 })
expect(day.providers['codex']).toMatchObject({ cost: 2, editTurns: 0, oneShotTurns: 0 })
expect(day.providers['codex']!.categories!['coding']).toMatchObject({ turns: 0, cost: 2, editTurns: 0, oneShotTurns: 0 })
})

it("breaks a provider call-count tie with the turn's first call", () => {
const timestamp = '2026-04-10T10:00:00'
const projects = [makeSingleTurnProject([
makeCall(timestamp, 2, 'gpt-5', 'codex'),
makeCall(timestamp, 7, 'Opus 4.7', 'claude'),
])]

const day = aggregateProjectsIntoDays(projects)[0]!
const slices = Object.values(day.providers)

expect(slices.reduce((sum, provider) => sum + (provider.editTurns ?? 0), 0)).toBe(1)
expect(slices.reduce((sum, provider) => sum + (provider.categories?.['coding']?.turns ?? 0), 0)).toBe(1)
expect(day.providers['codex']).toMatchObject({ editTurns: 1, oneShotTurns: 1 })
expect(day.providers['codex']!.categories!['coding']).toMatchObject({ turns: 1, editTurns: 1, oneShotTurns: 1 })
expect(day.providers['claude']).toMatchObject({ editTurns: 0, oneShotTurns: 0 })
expect(day.providers['claude']!.categories!['coding']).toMatchObject({ turns: 0, editTurns: 0, oneShotTurns: 0 })
})
})

describe('buildPeriodDataFromDays', () => {
Expand Down