mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-29 18:33:01 +00:00
The Copilot CLI and the GitHub Copilot desktop app both write ~/.copilot/session-store.db unconditionally; its assistant_usage_events table holds one row per API request. Until now input/cache tokens for these surfaces came only from the session.shutdown rollups in events.jsonl, which are written only on clean shutdown (a crash loses the whole leg's input/cache accounting) and lump each session leg into one per-model total. The rollup also RESETS its counters at in-session compaction (traced on a clean single-process 107-request session whose sole rollup covered exactly its five post-compaction requests), so even cleanly-closed long sessions were truncated; on a long-history machine the store recovered ~35% of real Copilot spend lost to crashes and compaction resets. The DB rows are per-request, crash-proof, and carry real timestamps. The store's input_tokens is cache-INCLUSIVE (input + cache_read + cache_write), the same convention as the shutdown rollups — verified against each row's token_details_json and by reconciling per-session sums against the CLI's own footers and rollups across two machines (1,380+ rows, 8 models, CLI 1.0.70–1.0.79, schema_version 6): every divergence was a rollup gap. Emitted calls mirror the shutdown-call contract: input/cache/reasoning only, output 0 — per-turn output stays owned by the events.jsonl assistant.message calls. Rollup-vs-store precedence is RECONCILED at serve time, per (session, model), and only there. Both representations always parse and cache; parseProviderSources aggregates the cached calls and, wherever store rows exist for a (session, model), drops the rollup calls and serves the rows plus per-leg RESIDUAL calls: each rollup leg subtracts only the rows in its own interval — rows commit strictly before their leg's shutdown line, so a leg at time T covers exactly the rows in (previous leg's T, T] — and any remainder (per token component, floored at zero) serves once at that leg's own timestamp. A store missing requests a leg covered — adopted mid-session, rows pruned before ever being read — therefore still serves that tail exactly once ON THAT LEG'S DAY, a crash-tail row the rollup never saw can never cancel it, and a complete store serves pure per-request granularity with every residual retired to zero. The decision reads only cached contents, never discovery: deleting or resetting the store changes nothing served, so finalized daily history can never flip on an absence epoch; cached rows of a deleted store remain the record until the 90-day orphan age-out (which exempts still-discovered paths). The serve set is the one coherent snapshot — nothing a writer does between discovery and a parse can change what one pass sees — and read-time precedence heals persisted duplication (stale epochs, runtimes without node:sqlite, restored files) instead of preserving it, following the buildDurablePeriod pattern. Store rows and rollups carry supplementary accounting weight. A rollup (or its residual) is aggregate accounting, never a request: zero api-call/model-call/turn weight, tokens and cost fully retained. A store row is one real request, but when it pairs with a served per-turn call it is supplementary too; rows pair with same-model per-turn calls by timestamp adjacency (monotone matching, tight 2-minute window — the two are written at the same completion moment, and a wide window would let a crash-only row pair against a neighbor whose own row is missing), computed once over the FULL serve set so a date-range boundary that separates a row from its call cannot double the request across adjacent day queries. Only the unpaired rows — store-only requests, exactly where crash-lost requests sit — count. Supplementary-only turns fold into the nearest behavioral turn within 30 minutes; with no behavioral turn to fold into they stay separate weightless turns, each on its own day, with apiCalls 0 — and the session emission gate admits usage-bearing zero-call sessions. The weight propagates into the daily cache: aggregateProjectsIntoDays applies the same rule to every calls counter and category-turn count it seals, so v19 history and live summaries can never disagree about what was a request. A changed source whose read defers on the busy shape (locked, EACCES, corrupt mid-replace — discovery still emits the source; only true absence or a schema mismatch reads as absent) now marks session hydration incomplete, so the daily backfill holds its watermark instead of finalizing a day the deferred rows never reached; an unchanged unreadable store defers nothing. The verdict travels with its result — the 180s memo and the serve burst-reuse restore the hydration verdict their cached data was parsed under, so a memoized partial parse cannot inherit a later parse's complete — and a discovered source whose FINGERPRINT cannot be read (EACCES on a present file) defers instead of silently skipping, while a genuinely deleted file stays a silent skip. Copilot reasoning tokens are no longer double-billed at the report layer: they are a subset of the output the per-turn calls already price, and copilot joins claude in the reasoning-inside-output case of the query-time cost recompute. Store dedup keys are content-discriminated — copilot-store:<sid>:<rowId>:<fnv1a64(created_at|tokens|model)> — because AUTOINCREMENT prevents id reuse only within one database lifetime: a same-path DB reset reusing row ids now mints new keys instead of the durable union swallowing the new usage, while a byte-identical re-insert still collapses (64-bit: 32-bit FNV collisions between plausible token tuples are constructible). Every call of a session serves under one project label resolved at serve time — the session-state-derived label when the serve set knows it, else the store rows' own — so neither rows cached before events.jsonl existed nor an events.jsonl orphaned by a session-state prune can split the session across two grouping keys. CODEBURN_COPILOT_SESSION_STORE_DB is read but deliberately NOT fingerprinted, per the #927 ruling (any copilot fingerprint change drops cached entries whose path still exists, destroying pruned history only the cache holds); the read is allowlisted in the #927 guard, and serve-time reconciliation makes repointing safe without a fingerprint — the new store's rows parse on sight and the old path's entries persist as durable orphans. The copilot parse version appends session-store-v2 and the daily cache bumps v17 → v19: per-day attribution, call counts and costs all change against pre-store builds. 19, not 18: an earlier pushed head of this PR already claimed v18 under different accounting, and the carry-forward would adopt those days as finalized without re-deriving them. Verified by A/B on snapshots of two real stores, a live SIGKILL crash test (row present, no rollup, tokens recovered exactly), live resumes whose warm-cache deltas matched new rows to the token, upgrade-healing at 4,800-session scale, and serve-level regressions pinning every maintainer finding from six review rounds: the rows-then-shutdown race, stale-cache healing, age-out exemption, absence-epoch identity, progressive row landing with residual retirement, behavioral weight across all four pinned scenarios, the hydration fence, project unification in both directions, the same-path reset, mixed coverage (crash tail vs covered-leg gap), multi-leg residual day attribution, range-invariant pairing, memo-scoped hydration verdicts, and the fingerprint-failure fence.
683 lines
27 KiB
TypeScript
683 lines
27 KiB
TypeScript
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { tmpdir } from 'os'
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { aggregateModelStats, buildCompareJson, computeComparison, computeCategoryComparison, computeWorkingStyle, findModelStat, renderCompareJson, scanSelfCorrections, type ModelStats } from '../src/compare-stats.js'
|
|
import type { ProjectSummary, SessionSummary, ClassifiedTurn } from '../src/types.js'
|
|
|
|
function makeTurn(model: string, cost: number, opts: { hasEdits?: boolean; retries?: number; outputTokens?: number; inputTokens?: number; cacheRead?: number; cacheWrite?: number; timestamp?: string; category?: string; hasAgentSpawn?: boolean; hasPlanMode?: boolean; speed?: 'standard' | 'fast'; tools?: string[] } = {}): ClassifiedTurn {
|
|
const defaultTools = opts.tools ?? (opts.hasEdits ? ['Edit'] : ['Read'])
|
|
return {
|
|
timestamp: opts.timestamp ?? '2026-04-15T10:00:00Z',
|
|
category: (opts.category ?? 'coding') as ClassifiedTurn['category'],
|
|
retries: opts.retries ?? 0,
|
|
hasEdits: opts.hasEdits ?? false,
|
|
userMessage: '',
|
|
assistantCalls: [{
|
|
provider: 'claude',
|
|
model,
|
|
usage: {
|
|
inputTokens: opts.inputTokens ?? 100,
|
|
outputTokens: opts.outputTokens ?? 200,
|
|
cacheCreationInputTokens: opts.cacheWrite ?? 500,
|
|
cacheReadInputTokens: opts.cacheRead ?? 5000,
|
|
cachedInputTokens: 0,
|
|
reasoningTokens: 0,
|
|
webSearchRequests: 0,
|
|
},
|
|
costUSD: cost,
|
|
tools: defaultTools,
|
|
mcpTools: [],
|
|
skills: [],
|
|
hasAgentSpawn: opts.hasAgentSpawn ?? false,
|
|
hasPlanMode: opts.hasPlanMode ?? false,
|
|
speed: opts.speed ?? 'standard' as const,
|
|
timestamp: opts.timestamp ?? '2026-04-15T10:00:00Z',
|
|
bashCommands: [],
|
|
deduplicationKey: `key-${Math.random()}`,
|
|
}],
|
|
}
|
|
}
|
|
|
|
function makeProject(turns: ClassifiedTurn[]): ProjectSummary {
|
|
const session: SessionSummary = {
|
|
sessionId: 'test-session',
|
|
project: 'test-project',
|
|
firstTimestamp: turns[0]?.timestamp ?? '',
|
|
lastTimestamp: turns[turns.length - 1]?.timestamp ?? '',
|
|
totalCostUSD: turns.reduce((s, t) => s + t.assistantCalls.reduce((s2, c) => s2 + c.costUSD, 0), 0),
|
|
totalInputTokens: 0,
|
|
totalOutputTokens: 0,
|
|
totalCacheReadTokens: 0,
|
|
totalCacheWriteTokens: 0,
|
|
apiCalls: turns.reduce((s, t) => s + t.assistantCalls.length, 0),
|
|
turns,
|
|
modelBreakdown: {},
|
|
toolBreakdown: {},
|
|
mcpBreakdown: {},
|
|
bashBreakdown: {},
|
|
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
|
|
skillBreakdown: {} as SessionSummary['skillBreakdown'],
|
|
}
|
|
return {
|
|
project: 'test-project',
|
|
projectPath: '/test',
|
|
sessions: [session],
|
|
totalCostUSD: session.totalCostUSD,
|
|
totalApiCalls: session.apiCalls,
|
|
}
|
|
}
|
|
|
|
describe('aggregateModelStats', () => {
|
|
it('aggregates calls, cost, and tokens per model', () => {
|
|
const project = makeProject([
|
|
makeTurn('opus-4-6', 0.10, { outputTokens: 200, inputTokens: 50, cacheRead: 5000, cacheWrite: 500 }),
|
|
makeTurn('opus-4-6', 0.15, { outputTokens: 300, inputTokens: 80, cacheRead: 6000, cacheWrite: 600 }),
|
|
makeTurn('opus-4-7', 0.25, { outputTokens: 800, inputTokens: 100, cacheRead: 7000, cacheWrite: 700 }),
|
|
])
|
|
const stats = aggregateModelStats([project])
|
|
const m6 = stats.find(s => s.model === 'opus-4-6')!
|
|
const m7 = stats.find(s => s.model === 'opus-4-7')!
|
|
|
|
expect(m6.calls).toBe(2)
|
|
expect(m6.cost).toBeCloseTo(0.25)
|
|
expect(m6.outputTokens).toBe(500)
|
|
expect(m7.calls).toBe(1)
|
|
expect(m7.cost).toBeCloseTo(0.25)
|
|
expect(m7.outputTokens).toBe(800)
|
|
})
|
|
|
|
it('attributes turn-level metrics to the primary model', () => {
|
|
const project = makeProject([
|
|
makeTurn('opus-4-6', 0.10, { hasEdits: true, retries: 0 }),
|
|
makeTurn('opus-4-6', 0.10, { hasEdits: true, retries: 2 }),
|
|
makeTurn('opus-4-7', 0.20, { hasEdits: true, retries: 0 }),
|
|
makeTurn('opus-4-7', 0.20, { hasEdits: false }),
|
|
])
|
|
const stats = aggregateModelStats([project])
|
|
const m6 = stats.find(s => s.model === 'opus-4-6')!
|
|
const m7 = stats.find(s => s.model === 'opus-4-7')!
|
|
|
|
expect(m6.editTurns).toBe(2)
|
|
expect(m6.oneShotTurns).toBe(1)
|
|
expect(m6.retries).toBe(2)
|
|
expect(m7.editTurns).toBe(1)
|
|
expect(m7.oneShotTurns).toBe(1)
|
|
expect(m7.totalTurns).toBe(2)
|
|
})
|
|
|
|
it('tracks firstSeen and lastSeen timestamps', () => {
|
|
const project = makeProject([
|
|
makeTurn('opus-4-6', 0.10, { timestamp: '2026-04-10T08:00:00Z' }),
|
|
makeTurn('opus-4-6', 0.10, { timestamp: '2026-04-15T20:00:00Z' }),
|
|
])
|
|
const stats = aggregateModelStats([project])
|
|
const m = stats.find(s => s.model === 'opus-4-6')!
|
|
expect(m.firstSeen).toBe('2026-04-10T08:00:00Z')
|
|
expect(m.lastSeen).toBe('2026-04-15T20:00:00Z')
|
|
})
|
|
|
|
it('filters out <synthetic> model entries', () => {
|
|
const project = makeProject([
|
|
makeTurn('<synthetic>', 0, {}),
|
|
makeTurn('opus-4-6', 0.10, {}),
|
|
])
|
|
const stats = aggregateModelStats([project])
|
|
expect(stats.find(s => s.model === '<synthetic>')).toBeUndefined()
|
|
expect(stats).toHaveLength(1)
|
|
})
|
|
|
|
it('returns empty array for no projects', () => {
|
|
expect(aggregateModelStats([])).toEqual([])
|
|
})
|
|
|
|
it('tracks editCost for edit turns', () => {
|
|
const project = makeProject([
|
|
makeTurn('opus-4-6', 0.10, { hasEdits: true }),
|
|
makeTurn('opus-4-6', 0.20, { hasEdits: true }),
|
|
makeTurn('opus-4-6', 0.50, { hasEdits: false }),
|
|
])
|
|
const stats = aggregateModelStats([project])
|
|
const m = stats.find(s => s.model === 'opus-4-6')!
|
|
expect(m.editCost).toBeCloseTo(0.30)
|
|
})
|
|
|
|
it('sorts by cost descending', () => {
|
|
const project = makeProject([
|
|
makeTurn('cheap-model', 0.01),
|
|
makeTurn('expensive-model', 5.00),
|
|
])
|
|
const stats = aggregateModelStats([project])
|
|
expect(stats[0].model).toBe('expensive-model')
|
|
expect(stats[1].model).toBe('cheap-model')
|
|
})
|
|
})
|
|
|
|
function makeStats(overrides: Partial<ModelStats> = {}): ModelStats {
|
|
return {
|
|
model: 'test-model',
|
|
calls: 100,
|
|
cost: 10,
|
|
outputTokens: 50000,
|
|
inputTokens: 10000,
|
|
cacheReadTokens: 20000,
|
|
cacheWriteTokens: 5000,
|
|
totalTurns: 200,
|
|
editTurns: 80,
|
|
oneShotTurns: 60,
|
|
retries: 20,
|
|
selfCorrections: 10,
|
|
editCost: 8,
|
|
firstSeen: '2026-04-01T00:00:00Z',
|
|
lastSeen: '2026-04-15T00:00:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('computeComparison', () => {
|
|
it('computes normalized metrics and picks winners correctly', () => {
|
|
const a = makeStats({ calls: 100, cost: 10, outputTokens: 50000, inputTokens: 10000, cacheReadTokens: 20000, cacheWriteTokens: 5000, editTurns: 80, oneShotTurns: 60, retries: 20, selfCorrections: 10, totalTurns: 200 })
|
|
const b = makeStats({ calls: 100, cost: 8, outputTokens: 40000, inputTokens: 10000, cacheReadTokens: 20000, cacheWriteTokens: 5000, editTurns: 80, oneShotTurns: 60, retries: 20, selfCorrections: 10, totalTurns: 200 })
|
|
const rows = computeComparison(a, b)
|
|
|
|
const costRow = rows.find(r => r.label === 'Cost / call')!
|
|
expect(costRow.valueA).toBeCloseTo(0.1)
|
|
expect(costRow.valueB).toBeCloseTo(0.08)
|
|
expect(costRow.winner).toBe('b')
|
|
|
|
const outputRow = rows.find(r => r.label === 'Output tok / call')!
|
|
expect(outputRow.valueA).toBe(500)
|
|
expect(outputRow.valueB).toBe(400)
|
|
expect(outputRow.winner).toBe('b')
|
|
})
|
|
|
|
it('returns null values for one-shot rate and retry rate when editTurns is zero', () => {
|
|
const a = makeStats({ editTurns: 0, oneShotTurns: 0, retries: 0 })
|
|
const b = makeStats({ editTurns: 80, oneShotTurns: 60, retries: 20 })
|
|
const rows = computeComparison(a, b)
|
|
|
|
const oneShotRow = rows.find(r => r.label === 'One-shot rate')!
|
|
expect(oneShotRow.valueA).toBeNull()
|
|
expect(oneShotRow.winner).toBe('none')
|
|
|
|
const retryRow = rows.find(r => r.label === 'Retry rate')!
|
|
expect(retryRow.valueA).toBeNull()
|
|
expect(retryRow.winner).toBe('none')
|
|
})
|
|
|
|
it('returns tie when values are equal', () => {
|
|
const a = makeStats({ calls: 100, cost: 10 })
|
|
const b = makeStats({ calls: 100, cost: 10 })
|
|
const rows = computeComparison(a, b)
|
|
|
|
const costRow = rows.find(r => r.label === 'Cost / call')!
|
|
expect(costRow.winner).toBe('tie')
|
|
})
|
|
|
|
it('computes cost per edit correctly', () => {
|
|
const a = makeStats({ editTurns: 40, editCost: 4 })
|
|
const b = makeStats({ editTurns: 80, editCost: 4 })
|
|
const rows = computeComparison(a, b)
|
|
const editRow = rows.find(r => r.label === 'Cost / edit')!
|
|
expect(editRow.valueA).toBeCloseTo(0.10)
|
|
expect(editRow.valueB).toBeCloseTo(0.05)
|
|
expect(editRow.winner).toBe('b')
|
|
})
|
|
|
|
it('picks higher value as winner for cache hit rate', () => {
|
|
const a = makeStats({ inputTokens: 5000, cacheReadTokens: 30000, cacheWriteTokens: 5000 })
|
|
const b = makeStats({ inputTokens: 10000, cacheReadTokens: 10000, cacheWriteTokens: 5000 })
|
|
const rows = computeComparison(a, b)
|
|
|
|
const cacheRow = rows.find(r => r.label === 'Cache hit rate')!
|
|
// Cache writes are excluded from the denominator (reads / reads + fresh
|
|
// input), matching src/menubar-json.ts and the desktop app.
|
|
const totalA = 5000 + 30000
|
|
const totalB = 10000 + 10000
|
|
expect(cacheRow.valueA).toBeCloseTo(30000 / totalA * 100)
|
|
expect(cacheRow.valueB).toBeCloseTo(10000 / totalB * 100)
|
|
expect(cacheRow.winner).toBe('a')
|
|
})
|
|
})
|
|
|
|
describe('compare JSON emitter', () => {
|
|
it('builds and renders the full comparison shape', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, { hasEdits: true, retries: 0 }),
|
|
makeTurn('model-b', 0.20, { hasEdits: true, retries: 1 }),
|
|
])
|
|
const models = aggregateModelStats([project])
|
|
const modelA = models.find(model => model.model === 'model-a')!
|
|
const modelB = models.find(model => model.model === 'model-b')!
|
|
modelA.selfCorrections = 2
|
|
modelB.selfCorrections = 1
|
|
|
|
const parsed = JSON.parse(renderCompareJson(
|
|
buildCompareJson([project], modelA, modelB, 'All Time', 'all'),
|
|
))
|
|
|
|
expect(Object.keys(parsed)).toEqual(['period', 'modelA', 'modelB', 'metrics', 'categories', 'workingStyle'])
|
|
expect(parsed.period).toEqual({ label: 'All Time', provider: 'all' })
|
|
expect(parsed.modelA.selfCorrections).toBe(2)
|
|
expect(parsed.metrics.some((row: { section: string }) => row.section === 'Performance')).toBe(true)
|
|
expect(parsed.metrics.some((row: { section: string }) => row.section === 'Efficiency')).toBe(true)
|
|
expect(parsed.categories).toHaveLength(1)
|
|
expect(parsed.workingStyle).toHaveLength(4)
|
|
})
|
|
})
|
|
|
|
function jsonlLine(type: string, model: string, text: string, timestamp = '2026-04-15T10:00:00Z'): string {
|
|
if (type === 'assistant') {
|
|
return JSON.stringify({
|
|
type: 'assistant', timestamp,
|
|
message: { model, content: [{ type: 'text', text }], id: `msg-${Math.random()}`, usage: { input_tokens: 0, output_tokens: 0 } },
|
|
})
|
|
}
|
|
return JSON.stringify({ type: 'user', timestamp, message: { role: 'user', content: text } })
|
|
}
|
|
|
|
describe('scanSelfCorrections', () => {
|
|
let tmpDir: string
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-test-'))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
it('counts apology patterns per model', async () => {
|
|
const sessionDir = join(tmpDir, 'session-abc')
|
|
await mkdir(sessionDir)
|
|
const lines = [
|
|
jsonlLine('assistant', 'opus-4-6', 'I apologize for the confusion.'),
|
|
jsonlLine('assistant', 'opus-4-6', 'Here is the result.'),
|
|
jsonlLine('assistant', 'sonnet-4-6', 'I was wrong about that.'),
|
|
jsonlLine('user', '', 'Do this'),
|
|
]
|
|
await writeFile(join(sessionDir, 'session.jsonl'), lines.join('\n') + '\n')
|
|
|
|
const result = await scanSelfCorrections([tmpDir])
|
|
expect(result.get('opus-4-6')).toBe(1)
|
|
expect(result.get('sonnet-4-6')).toBe(1)
|
|
})
|
|
|
|
it('does not count non-apology text', async () => {
|
|
const sessionDir = join(tmpDir, 'session-xyz')
|
|
await mkdir(sessionDir)
|
|
const lines = [
|
|
jsonlLine('assistant', 'opus-4-6', 'Here is the updated code.'),
|
|
jsonlLine('assistant', 'opus-4-6', 'Let me fix that for you.'),
|
|
]
|
|
await writeFile(join(sessionDir, 'session.jsonl'), lines.join('\n') + '\n')
|
|
|
|
const result = await scanSelfCorrections([tmpDir])
|
|
expect(result.get('opus-4-6')).toBeUndefined()
|
|
expect(result.size).toBe(0)
|
|
})
|
|
|
|
it('returns empty map for missing directory', async () => {
|
|
const result = await scanSelfCorrections([join(tmpDir, 'nonexistent')])
|
|
expect(result.size).toBe(0)
|
|
})
|
|
|
|
it('returns empty map for empty directory', async () => {
|
|
const result = await scanSelfCorrections([tmpDir])
|
|
expect(result.size).toBe(0)
|
|
})
|
|
|
|
it('scans subagent directories', async () => {
|
|
const sessionDir = join(tmpDir, 'session-sub')
|
|
const subagentsDir = join(sessionDir, 'subagents')
|
|
await mkdir(subagentsDir, { recursive: true })
|
|
const lines = [
|
|
jsonlLine('assistant', 'haiku-4-6', 'My mistake, let me redo that.'),
|
|
]
|
|
await writeFile(join(subagentsDir, 'sub.jsonl'), lines.join('\n') + '\n')
|
|
|
|
const result = await scanSelfCorrections([tmpDir])
|
|
expect(result.get('haiku-4-6')).toBe(1)
|
|
})
|
|
|
|
it('skips <synthetic> models', async () => {
|
|
const sessionDir = join(tmpDir, 'session-synth')
|
|
await mkdir(sessionDir)
|
|
const lines = [
|
|
jsonlLine('assistant', '<synthetic>', 'I apologize for the error.'),
|
|
]
|
|
await writeFile(join(sessionDir, 'session.jsonl'), lines.join('\n') + '\n')
|
|
|
|
const result = await scanSelfCorrections([tmpDir])
|
|
expect(result.get('<synthetic>')).toBeUndefined()
|
|
expect(result.size).toBe(0)
|
|
})
|
|
|
|
it('accumulates counts across multiple sessions and directories', async () => {
|
|
const sessionA = join(tmpDir, 'session-a')
|
|
const sessionB = join(tmpDir, 'session-b')
|
|
await mkdir(sessionA)
|
|
await mkdir(sessionB)
|
|
|
|
await writeFile(join(sessionA, 'a.jsonl'), [
|
|
jsonlLine('assistant', 'opus-4-6', 'I was wrong.', '2026-04-15T10:00:00Z'),
|
|
jsonlLine('assistant', 'opus-4-6', 'My bad!', '2026-04-15T10:01:00Z'),
|
|
].join('\n') + '\n')
|
|
|
|
await writeFile(join(sessionB, 'b.jsonl'), [
|
|
jsonlLine('assistant', 'opus-4-6', 'I apologize.', '2026-04-15T10:02:00Z'),
|
|
].join('\n') + '\n')
|
|
|
|
const result = await scanSelfCorrections([tmpDir])
|
|
expect(result.get('opus-4-6')).toBe(3)
|
|
})
|
|
|
|
it('handles malformed JSON lines gracefully', async () => {
|
|
const sessionDir = join(tmpDir, 'session-bad')
|
|
await mkdir(sessionDir)
|
|
await writeFile(join(sessionDir, 'bad.jsonl'), [
|
|
'not valid json',
|
|
jsonlLine('assistant', 'opus-4-6', 'I apologize.'),
|
|
].join('\n') + '\n')
|
|
|
|
const result = await scanSelfCorrections([tmpDir])
|
|
expect(result.get('opus-4-6')).toBe(1)
|
|
})
|
|
|
|
it('accepts multiple sessionDirs and merges counts', async () => {
|
|
const dir2 = await mkdtemp(join(tmpdir(), 'codeburn-test2-'))
|
|
try {
|
|
const sessionA = join(tmpDir, 'session-a')
|
|
const sessionB = join(dir2, 'session-b')
|
|
await mkdir(sessionA)
|
|
await mkdir(sessionB)
|
|
|
|
await writeFile(join(sessionA, 'a.jsonl'), [
|
|
jsonlLine('assistant', 'sonnet-4-6', 'My mistake.', '2026-04-15T10:00:00Z'),
|
|
].join('\n') + '\n')
|
|
|
|
await writeFile(join(sessionB, 'b.jsonl'), [
|
|
jsonlLine('assistant', 'sonnet-4-6', 'I was wrong.', '2026-04-15T10:01:00Z'),
|
|
].join('\n') + '\n')
|
|
|
|
const result = await scanSelfCorrections([tmpDir, dir2])
|
|
expect(result.get('sonnet-4-6')).toBe(2)
|
|
} finally {
|
|
await rm(dir2, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('computeCategoryComparison', () => {
|
|
it('returns per-category one-shot rates for both models', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, { hasEdits: true, retries: 0, category: 'coding' }),
|
|
makeTurn('model-a', 0.10, { hasEdits: true, retries: 1, category: 'coding' }),
|
|
makeTurn('model-b', 0.10, { hasEdits: true, retries: 0, category: 'coding' }),
|
|
makeTurn('model-b', 0.10, { hasEdits: true, retries: 0, category: 'coding' }),
|
|
makeTurn('model-a', 0.10, { hasEdits: true, retries: 0, category: 'debugging' }),
|
|
makeTurn('model-b', 0.10, { hasEdits: true, retries: 1, category: 'debugging' }),
|
|
])
|
|
const result = computeCategoryComparison([project], 'model-a', 'model-b')
|
|
|
|
const coding = result.find(r => r.category === 'coding')!
|
|
expect(coding.editTurnsA).toBe(2)
|
|
expect(coding.oneShotRateA).toBeCloseTo(50)
|
|
expect(coding.editTurnsB).toBe(2)
|
|
expect(coding.oneShotRateB).toBeCloseTo(100)
|
|
expect(coding.winner).toBe('b')
|
|
|
|
const debugging = result.find(r => r.category === 'debugging')!
|
|
expect(debugging.oneShotRateA).toBeCloseTo(100)
|
|
expect(debugging.oneShotRateB).toBeCloseTo(0)
|
|
expect(debugging.winner).toBe('a')
|
|
})
|
|
|
|
it('skips categories with no edit turns', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, { hasEdits: false, category: 'conversation' }),
|
|
makeTurn('model-b', 0.10, { hasEdits: false, category: 'conversation' }),
|
|
makeTurn('model-a', 0.10, { hasEdits: true, category: 'coding' }),
|
|
])
|
|
const result = computeCategoryComparison([project], 'model-a', 'model-b')
|
|
expect(result.find(r => r.category === 'conversation')).toBeUndefined()
|
|
expect(result).toHaveLength(1)
|
|
})
|
|
|
|
it('sorts by total turns descending', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, { hasEdits: true, category: 'coding' }),
|
|
makeTurn('model-a', 0.10, { hasEdits: true, category: 'coding' }),
|
|
makeTurn('model-a', 0.10, { hasEdits: true, category: 'coding' }),
|
|
makeTurn('model-b', 0.10, { hasEdits: true, category: 'coding' }),
|
|
makeTurn('model-a', 0.10, { hasEdits: true, category: 'debugging' }),
|
|
])
|
|
const result = computeCategoryComparison([project], 'model-a', 'model-b')
|
|
expect(result[0].category).toBe('coding')
|
|
})
|
|
|
|
it('returns null one-shot rate when model has no edits in category', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, { hasEdits: true, category: 'coding' }),
|
|
makeTurn('model-b', 0.10, { hasEdits: false, category: 'coding' }),
|
|
])
|
|
const result = computeCategoryComparison([project], 'model-a', 'model-b')
|
|
const coding = result.find(r => r.category === 'coding')!
|
|
expect(coding.oneShotRateA).toBeCloseTo(100)
|
|
expect(coding.oneShotRateB).toBeNull()
|
|
expect(coding.winner).toBe('none')
|
|
})
|
|
})
|
|
|
|
describe('computeWorkingStyle', () => {
|
|
it('computes delegation and planning rates', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, { hasAgentSpawn: true }),
|
|
makeTurn('model-a', 0.10, {}),
|
|
makeTurn('model-a', 0.10, { hasPlanMode: true }),
|
|
makeTurn('model-b', 0.10, {}),
|
|
makeTurn('model-b', 0.10, {}),
|
|
])
|
|
const result = computeWorkingStyle([project], 'model-a', 'model-b')
|
|
|
|
const delegation = result.find(r => r.label === 'Delegation rate')!
|
|
expect(delegation.valueA).toBeCloseTo(100 / 3)
|
|
expect(delegation.valueB).toBeCloseTo(0)
|
|
|
|
const planning = result.find(r => r.label === 'Planning rate')!
|
|
expect(planning.valueA).toBeCloseTo(100 / 3)
|
|
expect(planning.valueB).toBeCloseTo(0)
|
|
})
|
|
|
|
it('computes avg tools per turn', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, { hasEdits: true }),
|
|
makeTurn('model-a', 0.10, {}),
|
|
makeTurn('model-b', 0.10, { hasEdits: true }),
|
|
])
|
|
const result = computeWorkingStyle([project], 'model-a', 'model-b')
|
|
const tools = result.find(r => r.label === 'Avg tools / turn')!
|
|
expect(tools.valueA).toBeCloseTo(1)
|
|
expect(tools.valueB).toBeCloseTo(1)
|
|
})
|
|
|
|
it('computes fast mode usage', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, { speed: 'fast' }),
|
|
makeTurn('model-a', 0.10, {}),
|
|
makeTurn('model-b', 0.10, { speed: 'fast' }),
|
|
makeTurn('model-b', 0.10, { speed: 'fast' }),
|
|
])
|
|
const result = computeWorkingStyle([project], 'model-a', 'model-b')
|
|
const fast = result.find(r => r.label === 'Fast mode usage')!
|
|
expect(fast.valueA).toBeCloseTo(50)
|
|
expect(fast.valueB).toBeCloseTo(100)
|
|
})
|
|
|
|
it('returns null for models with no turns', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, {}),
|
|
])
|
|
const result = computeWorkingStyle([project], 'model-a', 'model-b')
|
|
const delegation = result.find(r => r.label === 'Delegation rate')!
|
|
expect(delegation.valueA).toBeCloseTo(0)
|
|
expect(delegation.valueB).toBeNull()
|
|
})
|
|
|
|
it('counts TaskCreate as planning', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, { tools: ['TaskCreate'] }),
|
|
makeTurn('model-a', 0.10, { tools: ['Read'] }),
|
|
makeTurn('model-a', 0.10, { tools: ['Edit'] }),
|
|
])
|
|
const result = computeWorkingStyle([project], 'model-a', 'model-b')
|
|
const planning = result.find(r => r.label === 'Planning rate')!
|
|
expect(planning.valueA).toBeCloseTo(100 / 3)
|
|
})
|
|
|
|
it('counts TaskUpdate as planning', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, { tools: ['TaskUpdate'] }),
|
|
makeTurn('model-a', 0.10, { tools: ['Read'] }),
|
|
])
|
|
const result = computeWorkingStyle([project], 'model-a', 'model-b')
|
|
const planning = result.find(r => r.label === 'Planning rate')!
|
|
expect(planning.valueA).toBeCloseTo(50)
|
|
})
|
|
|
|
it('counts TodoWrite as planning', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, { tools: ['TodoWrite', 'Read'] }),
|
|
makeTurn('model-a', 0.10, { tools: ['Bash'] }),
|
|
])
|
|
const result = computeWorkingStyle([project], 'model-a', 'model-b')
|
|
const planning = result.find(r => r.label === 'Planning rate')!
|
|
expect(planning.valueA).toBeCloseTo(50)
|
|
})
|
|
|
|
it('counts turn with planning tool + edits as planning', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, { tools: ['TaskCreate', 'Edit', 'Read'] }),
|
|
makeTurn('model-a', 0.10, { tools: ['Edit'] }),
|
|
])
|
|
const result = computeWorkingStyle([project], 'model-a', 'model-b')
|
|
const planning = result.find(r => r.label === 'Planning rate')!
|
|
expect(planning.valueA).toBeCloseTo(50)
|
|
})
|
|
|
|
it('does not count regular tools as planning', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, { tools: ['Read', 'Grep', 'Glob'] }),
|
|
makeTurn('model-a', 0.10, { tools: ['Edit', 'Bash'] }),
|
|
makeTurn('model-a', 0.10, { tools: ['Agent'] }),
|
|
])
|
|
const result = computeWorkingStyle([project], 'model-a', 'model-b')
|
|
const planning = result.find(r => r.label === 'Planning rate')!
|
|
expect(planning.valueA).toBeCloseTo(0)
|
|
})
|
|
|
|
it('counts planning once per turn even with multiple planning tools', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, { tools: ['TaskCreate', 'TaskUpdate', 'TaskCreate'] }),
|
|
makeTurn('model-a', 0.10, { tools: ['Read'] }),
|
|
])
|
|
const result = computeWorkingStyle([project], 'model-a', 'model-b')
|
|
const planning = result.find(r => r.label === 'Planning rate')!
|
|
expect(planning.valueA).toBeCloseTo(50)
|
|
})
|
|
|
|
it('hasPlanMode still triggers planning rate', () => {
|
|
const project = makeProject([
|
|
makeTurn('model-a', 0.10, { hasPlanMode: true, tools: ['Read'] }),
|
|
makeTurn('model-a', 0.10, { tools: ['Read'] }),
|
|
])
|
|
const result = computeWorkingStyle([project], 'model-a', 'model-b')
|
|
const planning = result.find(r => r.label === 'Planning rate')!
|
|
expect(planning.valueA).toBeCloseTo(50)
|
|
})
|
|
})
|
|
|
|
// Issue #767 item 1: `codeburn compare --model-a/--model-b` (both the JSON
|
|
// path and the TUI picker) required the canonical model id (e.g.
|
|
// claude-opus-4-8) with no way to pass the display name shown in the picker
|
|
// itself (e.g. "Opus 4.8"). findModelStat is the shared lookup both surfaces
|
|
// use, reusing getShortModelName (the existing canonical -> display mapping)
|
|
// instead of a new alias table.
|
|
describe('findModelStat', () => {
|
|
it('matches the exact canonical model id', () => {
|
|
const project = makeProject([makeTurn('claude-opus-4-6', 0.10)])
|
|
const stats = aggregateModelStats([project])
|
|
expect(findModelStat(stats, 'claude-opus-4-6')?.model).toBe('claude-opus-4-6')
|
|
})
|
|
|
|
it('matches the display name, case-insensitively', () => {
|
|
const project = makeProject([makeTurn('claude-opus-4-6', 0.10)])
|
|
const stats = aggregateModelStats([project])
|
|
expect(findModelStat(stats, 'opus 4.6')?.model).toBe('claude-opus-4-6')
|
|
expect(findModelStat(stats, 'Opus 4.6')?.model).toBe('claude-opus-4-6')
|
|
})
|
|
|
|
it('returns undefined for an unknown model', () => {
|
|
const project = makeProject([makeTurn('claude-opus-4-6', 0.10)])
|
|
const stats = aggregateModelStats([project])
|
|
expect(findModelStat(stats, 'claude-opus-9-9')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
// Copilot serve sets carry supplementary accounting calls (shutdown rollups,
|
|
// residuals, store rows paired with an already-counted per-turn call). They hold
|
|
// real tokens/cost but no behavioral evidence, so the compare report — which is
|
|
// entirely per-call/per-turn ratios — must not weigh them.
|
|
function supplement(turn: ClassifiedTurn, model: string, cost: number): ClassifiedTurn {
|
|
turn.assistantCalls.unshift({
|
|
...turn.assistantCalls[0]!,
|
|
model,
|
|
costUSD: cost,
|
|
supplementaryAccounting: true,
|
|
deduplicationKey: `supp-${Math.random()}`,
|
|
})
|
|
return turn
|
|
}
|
|
|
|
describe('supplementary accounting weight', () => {
|
|
it('takes the primary model from the first behavioral call, not a leading supplementary one', () => {
|
|
const project = makeProject([
|
|
supplement(makeTurn('opus-4-6', 0.10, { hasEdits: true }), 'rollup-model', 0.01),
|
|
])
|
|
const stats = aggregateModelStats([project])
|
|
|
|
expect(stats.find(s => s.model === 'opus-4-6')!.totalTurns).toBe(1)
|
|
expect(stats.find(s => s.model === 'rollup-model')?.totalTurns ?? 0).toBe(0)
|
|
})
|
|
|
|
it('keeps supplementary cost and tokens but does not count them as calls', () => {
|
|
const project = makeProject([
|
|
supplement(makeTurn('opus-4-6', 0.10), 'opus-4-6', 0.04),
|
|
])
|
|
const m = aggregateModelStats([project]).find(s => s.model === 'opus-4-6')!
|
|
|
|
expect(m.calls).toBe(1)
|
|
expect(m.cost).toBeCloseTo(0.14)
|
|
expect(m.outputTokens).toBe(400)
|
|
})
|
|
|
|
it('excludes an accounting-only turn from every efficiency surface', () => {
|
|
const accountingOnly = makeTurn('opus-4-6', 0.09, { hasEdits: true, category: 'debugging', speed: 'fast', hasAgentSpawn: true })
|
|
accountingOnly.assistantCalls[0]!.supplementaryAccounting = true
|
|
const project = makeProject([
|
|
makeTurn('opus-4-6', 0.10, { hasEdits: true, category: 'coding' }),
|
|
accountingOnly,
|
|
])
|
|
|
|
const m = aggregateModelStats([project]).find(s => s.model === 'opus-4-6')!
|
|
expect(m.totalTurns).toBe(1)
|
|
expect(m.editTurns).toBe(1)
|
|
|
|
const categories = computeCategoryComparison([project], 'opus-4-6', 'other-model')
|
|
expect(categories.map(c => c.category)).toEqual(['coding'])
|
|
|
|
const style = computeWorkingStyle([project], 'opus-4-6', 'other-model')
|
|
expect(style.find(r => r.label === 'Delegation rate')!.valueA).toBeCloseTo(0)
|
|
expect(style.find(r => r.label === 'Fast mode usage')!.valueA).toBeCloseTo(0)
|
|
})
|
|
})
|