codeburn/packages/cli/tests/sessions-by-branch.test.ts

129 lines
5.3 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { aggregateByBranch } from '../src/sessions-report.js'
import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary, TokenUsage } from '../src/types.js'
const ZERO_USAGE: TokenUsage = {
inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0,
cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
}
let keySeq = 0
function call(cost: number): ParsedApiCall {
return {
provider: 'claude', model: 'claude', usage: ZERO_USAGE, costUSD: cost,
tools: [], mcpTools: [], skills: [], subagentTypes: [],
hasAgentSpawn: false, hasPlanMode: false, speed: 'standard',
timestamp: '2026-07-01T10:00:00Z', bashCommands: [], deduplicationKey: `k${keySeq++}`,
}
}
// A turn whose total cost `cost` is split across `calls` API calls. `gitBranch`
// is set only when supplied — the cache stores it only on the turn where the
// branch CHANGES, so omitting it here is exactly what the carry-forward under
// test must reconstruct.
function turn(cost: number, calls = 1, gitBranch?: string): ClassifiedTurn {
return {
userMessage: '',
assistantCalls: Array.from({ length: calls }, () => call(cost / calls)),
timestamp: '2026-07-01T10:00:00Z', sessionId: 's',
category: 'coding', retries: 0, hasEdits: false,
...(gitBranch ? { gitBranch } : {}),
}
}
function session(id: string, turns: ClassifiedTurn[], everHadBranch?: boolean): SessionSummary {
return {
sessionId: id, project: 'p',
firstTimestamp: '2026-07-01T10:00:00Z', lastTimestamp: '2026-07-01T11:00:00Z',
totalCostUSD: 0, totalSavingsUSD: 0, totalEstimatedCostUSD: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalReasoningTokens: 0,
totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
apiCalls: 0, turns,
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
skillBreakdown: {} as SessionSummary['skillBreakdown'],
subagentBreakdown: {} as SessionSummary['subagentBreakdown'],
...(everHadBranch ? { everHadBranch } : {}),
}
}
function project(sessions: SessionSummary[]): ProjectSummary {
return { project: 'p', projectPath: '/p', sessions, totalCostUSD: 0, totalSavingsUSD: 0, totalApiCalls: 0, totalProxiedCostUSD: 0 }
}
describe('aggregateByBranch', () => {
it('carries the last-seen branch forward across turns that omit it', () => {
// The cache stored `main` on turn 1 (its first appearance) and nothing on
// turn 2 (unchanged); turn 3 changed to `feature`. The report must attribute
// turn 2 to `main` by carry-forward.
const rows = aggregateByBranch([project([
session('s', [
turn(100, 2, 'main'),
turn(50, 1),
turn(30, 1, 'feature'),
]),
])])
expect(rows.map(r => r.branch)).toEqual(['main', 'feature'])
const main = rows[0]!
expect(main.cost).toBeCloseTo(150, 6)
expect(main.calls).toBe(3)
expect(main.sessions).toBe(1)
const feature = rows[1]!
expect(feature.cost).toBeCloseTo(30, 6)
expect(feature.calls).toBe(1)
})
it('attributes spend before the first observed branch to an explicit null row', () => {
const rows = aggregateByBranch([project([
session('s', [
turn(20, 1), // unbranched prefix
turn(80, 1, 'main'),
]),
])])
expect(rows.map(r => r.branch)).toEqual(['main', null])
expect(rows.find(r => r.branch === null)!.cost).toBeCloseTo(20, 6)
expect(rows.find(r => r.branch === 'main')!.cost).toBeCloseTo(80, 6)
})
it('keeps in-range unbranched spend as null when the branch anchor was filtered out (everHadBranch)', () => {
// A branch-bearing session whose only in-range turns predate its first
// branch: the date slice dropped the anchor, but everHadBranch (captured
// pre-filter) still lets its $20 land in the explicit null row.
const rows = aggregateByBranch([project([
session('s', [turn(20, 1)], true),
])])
expect(rows).toEqual([{ branch: null, cost: 20, calls: 1, sessions: 1 }])
})
it('drops sessions that never observed a branch (no null bucket that dwarfs the rest)', () => {
const rows = aggregateByBranch([project([
session('branched', [turn(10, 1, 'main')]),
session('unbranched', [turn(999, 3), turn(999, 3)]),
])])
expect(rows).toEqual([{ branch: 'main', cost: 10, calls: 1, sessions: 1 }])
})
it('counts a session toward each branch it touched, once', () => {
const rows = aggregateByBranch([project([
session('a', [turn(40, 1, 'main'), turn(10, 1, 'feature')]),
session('b', [turn(30, 1, 'main')]),
])])
const main = rows.find(r => r.branch === 'main')!
expect(main.sessions).toBe(2)
expect(main.cost).toBeCloseTo(70, 6)
const feature = rows.find(r => r.branch === 'feature')!
expect(feature.sessions).toBe(1)
})
it('sorts rows by cost, descending', () => {
const rows = aggregateByBranch([project([
session('a', [turn(5, 1, 'small'), turn(200, 1, 'big'), turn(50, 1, 'mid')]),
])])
expect(rows.map(r => r.branch)).toEqual(['big', 'mid', 'small'])
})
it('returns nothing when no session carries branch data', () => {
expect(aggregateByBranch([project([session('a', [turn(100, 1)])])])).toEqual([])
})
})