chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:26:02 +08:00
commit a0a48b5e45
437 changed files with 100767 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
import { afterAll, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { runAction } from '../src/act/apply.js'
import { journalPath, readRecords } from '../src/act/journal.js'
import { sha256 } from '../src/act/backup.js'
import type { ActionRecord } from '../src/act/types.js'
const roots: string[] = []
async function makeRoot(): Promise<{ actionsDir: string; files: string }> {
const root = await mkdtemp(join(tmpdir(), 'codeburn-act-journal-'))
roots.push(root)
const files = join(root, 'files')
await mkdir(files, { recursive: true })
return { actionsDir: join(root, 'actions'), files }
}
afterAll(async () => {
for (const root of roots) await rm(root, { recursive: true, force: true })
})
describe('runAction journaling', () => {
it('journals backups and afterHash for edit, create, and move ops', async () => {
const { actionsDir, files } = await makeRoot()
const editPath = join(files, 'edit.txt')
const createPath = join(files, 'new.txt')
const movePath = join(files, 'move.txt')
const moveDest = join(files, 'archive', 'move.txt')
await writeFile(editPath, 'old-edit')
await writeFile(movePath, 'move-body')
const rec = await runAction({
kind: 'claude-md-rule',
description: 'test action',
changes: [
{ op: 'edit', path: editPath, content: 'new-edit' },
{ op: 'create', path: createPath, content: 'created' },
{ op: 'move', path: movePath, movedTo: moveDest },
],
}, actionsDir)
expect(rec.status).toBe('applied')
expect(rec.findingId).toBeNull()
expect(rec.changes).toHaveLength(3)
const [edit, create, move] = rec.changes
expect(edit!.backup).not.toBeNull()
expect(await readFile(join(actionsDir, edit!.backup!), 'utf-8')).toBe('old-edit')
expect(edit!.afterHash).toBe(sha256(Buffer.from('new-edit')))
expect(await readFile(editPath, 'utf-8')).toBe('new-edit')
expect(create!.backup).toBeNull()
expect(create!.afterHash).toBe(sha256(Buffer.from('created')))
expect(await readFile(createPath, 'utf-8')).toBe('created')
expect(move!.backup).not.toBeNull()
expect(await readFile(join(actionsDir, move!.backup!), 'utf-8')).toBe('move-body')
expect(move!.movedTo).toBe(moveDest)
expect(move!.afterHash).toBe(sha256(Buffer.from('move-body')))
expect(await readFile(moveDest, 'utf-8')).toBe('move-body')
expect(existsSync(movePath)).toBe(false)
const records = await readRecords(actionsDir)
expect(records).toHaveLength(1)
expect(records[0]!.id).toBe(rec.id)
})
it('rolls back completed mutations and writes no record when a mutation fails', async () => {
const { actionsDir, files } = await makeRoot()
const editPath = join(files, 'edit.txt')
await writeFile(editPath, 'original')
const missingSrc = join(files, 'does-not-exist.txt')
await expect(runAction({
kind: 'shell-config',
description: 'failing action',
changes: [
{ op: 'edit', path: editPath, content: 'changed' },
{ op: 'move', path: missingSrc, movedTo: join(files, 'dest.txt') },
],
}, actionsDir)).rejects.toThrow()
expect(await readFile(editPath, 'utf-8')).toBe('original')
expect(existsSync(journalPath(actionsDir))).toBe(false)
})
it('skips corrupt journal lines and still loads valid records', async () => {
const { actionsDir } = await makeRoot()
await mkdir(actionsDir, { recursive: true })
const recA = sampleRecord('11111111-1111-1111-1111-111111111111', 'first')
const recB = sampleRecord('22222222-2222-2222-2222-222222222222', 'second')
await writeFile(
journalPath(actionsDir),
JSON.stringify(recA) + '\n' + '{ this is not json\n' + JSON.stringify(recB) + '\n',
)
const records = await readRecords(actionsDir)
expect(records.map(r => r.id)).toEqual([recA.id, recB.id])
expect(records.map(r => r.description)).toEqual(['first', 'second'])
})
it('round-trips a record through JSON (act list --json shape)', async () => {
const { actionsDir, files } = await makeRoot()
const p = join(files, 'f.txt')
await writeFile(p, 'before')
const rec = await runAction({
kind: 'model-default',
description: 'json shape',
changes: [{ op: 'edit', path: p, content: 'after' }],
}, actionsDir)
const records = await readRecords(actionsDir)
const roundTripped = JSON.parse(JSON.stringify(records)) as ActionRecord[]
expect(roundTripped).toEqual(records)
const only = roundTripped[0]!
expect(only).toMatchObject({ id: rec.id, kind: 'model-default', description: 'json shape', status: 'applied' })
expect(typeof only.at).toBe('string')
expect(only.findingId).toBeNull()
expect(only.changes[0]).toMatchObject({ path: p, op: 'edit' })
expect(only.changes[0]!.backup).not.toBeNull()
expect(typeof only.changes[0]!.afterHash).toBe('string')
})
})
describe('action lock', () => {
it('refuses to run while a fresh foreign lock is held', async () => {
const { actionsDir, files } = await makeRoot()
await mkdir(actionsDir, { recursive: true })
await writeFile(join(actionsDir, '.lock'), '')
const target = join(files, 'blocked.txt')
await expect(runAction({
kind: 'shell-config',
description: 'blocked by lock',
changes: [{ op: 'create', path: target, content: 'x' }],
}, actionsDir)).rejects.toThrow(/in progress/)
expect(existsSync(target)).toBe(false)
})
it('takes over a lock whose mtime is older than 60s', async () => {
const { actionsDir, files } = await makeRoot()
await mkdir(actionsDir, { recursive: true })
const lock = join(actionsDir, '.lock')
await writeFile(lock, JSON.stringify({ pid: 1, at: 0 }))
const past = new Date(Date.now() - 61_000)
await utimes(lock, past, past)
const target = join(files, 'takeover.txt')
const rec = await runAction({
kind: 'shell-config',
description: 'stale takeover',
changes: [{ op: 'create', path: target, content: 'x' }],
}, actionsDir)
expect(rec.status).toBe('applied')
expect(await readFile(target, 'utf-8')).toBe('x')
expect(existsSync(lock)).toBe(false)
})
})
describe('act list --json (CLI)', () => {
it('prints full records as JSON, newest first', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-act-cli-'))
roots.push(home)
const actionsDir = join(home, '.config', 'codeburn', 'actions')
const files = join(home, 'work')
await mkdir(files, { recursive: true })
const p1 = join(files, 'a.txt')
const p2 = join(files, 'b.txt')
await writeFile(p1, 'a')
await writeFile(p2, 'b')
const recOlder = await runAction({
kind: 'mcp-remove', description: 'older', changes: [{ op: 'edit', path: p1, content: 'a2' }],
}, actionsDir)
const recNewer = await runAction({
kind: 'mcp-remove', description: 'newer', changes: [{ op: 'edit', path: p2, content: 'b2' }],
}, actionsDir)
// Anchor the spawn to this checkout so running vitest from another cwd
// cannot execute a different checkout's cli.ts.
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
const res = spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', 'act', 'list', '--json'], {
cwd: repoRoot,
env: { ...process.env, HOME: home, USERPROFILE: home, HOMEPATH: home, HOMEDRIVE: '' },
encoding: 'utf-8',
})
expect(res.status).toBe(0)
const parsed = JSON.parse(res.stdout) as ActionRecord[]
expect(parsed.map(r => r.id)).toEqual([recNewer.id, recOlder.id])
expect(parsed[0]).toMatchObject({ kind: 'mcp-remove', description: 'newer', status: 'applied', findingId: null })
expect(parsed[0]!.changes[0]).toMatchObject({ path: p2, op: 'edit' })
expect(typeof parsed[0]!.changes[0]!.afterHash).toBe('string')
expect(parsed[0]!.changes[0]!.backup).not.toBeNull()
}, 20_000)
})
function sampleRecord(id: string, description: string): ActionRecord {
return {
id,
at: new Date().toISOString(),
kind: 'mcp-remove',
findingId: null,
description,
changes: [],
status: 'applied',
}
}
+435
View File
@@ -0,0 +1,435 @@
import { afterAll, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { journalPath } from '../src/act/journal.js'
import {
buildActReportJson,
buildOptimizeAppliedHeader,
computeActReport,
renderActReport,
} from '../src/act/report.js'
import type { ActionRecord } from '../src/act/types.js'
import type { ProjectSummary } from '../src/types.js'
type Session = ProjectSummary['sessions'][number]
const roots: string[] = []
afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) })
const NOW = new Date('2026-07-01T00:00:00Z')
function daysAgo(n: number): string {
return new Date(NOW.getTime() - n * 86_400_000).toISOString()
}
async function writeJournal(records: unknown[]): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'codeburn-act-report-'))
roots.push(root)
const actionsDir = join(root, 'actions')
await mkdir(actionsDir, { recursive: true })
await writeFile(journalPath(actionsDir), records.map(r => JSON.stringify(r)).join('\n') + (records.length ? '\n' : ''))
return actionsDir
}
function makeSession(id: string, firstTimestamp: string, over: Partial<Session> = {}): Session {
return {
sessionId: id,
project: 'app',
firstTimestamp,
lastTimestamp: firstTimestamp,
totalCostUSD: 1,
totalSavingsUSD: 0,
totalInputTokens: 1000,
totalOutputTokens: 0,
totalReasoningTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as Session['categoryBreakdown'],
skillBreakdown: {},
subagentBreakdown: {},
...over,
}
}
function projectOf(sessions: Session[]): ProjectSummary {
return {
project: 'app',
projectPath: '/tmp/app',
sessions,
totalCostUSD: sessions.reduce((s, x) => s + x.totalCostUSD, 0),
totalSavingsUSD: 0,
totalApiCalls: sessions.length,
totalProxiedCostUSD: 0,
}
}
function sessionsAt(count: number, ts: string, over: Partial<Session> = {}): Session[] {
return Array.from({ length: count }, (_, i) => makeSession(`s${i}`, ts, over))
}
function mcpRecord(over: Partial<ActionRecord> = {}): ActionRecord {
const at = daysAgo(10)
return {
id: 'a1',
at,
kind: 'mcp-remove',
findingId: 'unused-mcp',
description: 'Remove an MCP server from config',
changes: [],
status: 'applied',
baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 56_000, sessions: 28, metrics: { 'brave-search': 2000 } },
...over,
}
}
const load = (projects: ProjectSummary[]) => async () => projects
describe('mcp realized delta', () => {
it('multiplies baseline tokens-per-session by post-window sessions (exact)', async () => {
const actionsDir = await writeJournal([mcpRecord()])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
expect(report.rows).toHaveLength(1)
const row = report.rows[0]!
expect(row.status).toBe('measured')
expect(row.realizedTokens).toBe(40_000) // 2000 tokens/session * 20 sessions
expect(row.estimatedAtApply).toBe(56_000)
expect(row.estimatedForWindow).toBe(40_000) // window-scaled: 2000 * 20
expect(row.confidence).toBe('normal')
expect(report.totalRealizedTokens).toBe(40_000)
})
it('subtracts keeper sessions that still load the server for project-scope, not a revert', async () => {
const rec = mcpRecord({ kind: 'mcp-project-scope', findingId: 'mcp-project-scope', description: 'Project-scope an MCP server' })
const actionsDir = await writeJournal([rec])
const keepers = sessionsAt(5, daysAgo(4), { mcpInventory: ['mcp__brave-search__search'] })
const cold = sessionsAt(15, daysAgo(5))
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf([...cold, ...keepers])]) })
const row = report.rows[0]!
expect(row.status).toBe('measured')
expect(row.realizedTokens).toBe(30_000) // 2000 x (20 - 5 still loading)
expect(row.estimatedForWindow).toBe(40_000) // 2000 x all 20 window sessions
})
it('reports "reverted" with zero savings when the server reappears in the window', async () => {
const actionsDir = await writeJournal([mcpRecord()])
const back = sessionsAt(20, daysAgo(5), { mcpInventory: ['mcp__brave-search__search'] })
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(back)]) })
const row = report.rows[0]!
expect(row.status).toBe('reverted')
expect(row.realizedTokens).toBe(0)
expect(row.note).toMatch(/reverted by user/)
expect(report.totalRealizedTokens).toBe(0)
})
})
describe('confidence markers', () => {
it('marks low when fewer than 20 post-window sessions', async () => {
const actionsDir = await writeJournal([mcpRecord()])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(10, daysAgo(5)))]) })
const row = report.rows[0]!
expect(row.status).toBe('measured')
expect(row.realizedTokens).toBe(20_000) // 2000 * 10
expect(row.confidence).toBe('low')
})
it('marks low when volume shifts more than 2x versus baseline', async () => {
// 25 post-window sessions (>= 20, so not the count rule) over 10 days is
// 2.5/day against a 1/day baseline (14 sessions / 14 days) -> 2.5x shift.
const rec = mcpRecord({ baseline: { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 56_000, sessions: 14, metrics: { 'brave-search': 2000 } } })
const actionsDir = await writeJournal([rec])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(25, daysAgo(5)))]) })
const row = report.rows[0]!
expect(row.status).toBe('measured')
expect(row.confidence).toBe('low')
})
it('stays normal when volume is comparable to baseline', async () => {
const actionsDir = await writeJournal([mcpRecord()]) // baseline 28/14 = 2/day
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) // 20/10 = 2/day
expect(report.rows[0]!.confidence).toBe('normal')
})
})
describe('eligibility', () => {
it('excludes undone actions and actions younger than 3 days', async () => {
const records = [
mcpRecord({ id: 'old', at: daysAgo(10) }),
mcpRecord({ id: 'young', at: daysAgo(1) }),
mcpRecord({ id: 'undone', at: daysAgo(20), status: 'undone' }),
]
const actionsDir = await writeJournal(records)
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
expect(report.rows).toHaveLength(1)
expect(report.rows[0]!.id).toBe('old')
expect(report.activeCount).toBe(2) // old + young are applied; undone is not
})
})
describe('read-edit realized delta', () => {
it('credits the reduction in the read deficit using the detector estimate math', async () => {
// Baseline ratio 1:1 (deficit 3 reads/edit). After window: 120 reads / 40
// edits = 3:1 (deficit 1). Credit (3 - 1) * 40 edits * 600 = 48000.
const at = daysAgo(10)
const rec: ActionRecord = {
id: 'r1', at, kind: 'claude-md-rule', findingId: 'read-edit-ratio',
description: 'Add the read-edit-ratio rule block', changes: [], status: 'applied',
baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 12_000, sessions: 28, metrics: { reads: 10, edits: 10 } },
}
const actionsDir = await writeJournal([rec])
const session = makeSession('s0', daysAgo(5), { toolBreakdown: { Read: { calls: 120 }, Edit: { calls: 40 } } })
const filler = sessionsAt(19, daysAgo(4))
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf([session, ...filler])]) })
const row = report.rows[0]!
expect(row.status).toBe('measured')
expect(row.realizedTokens).toBe(48_000)
expect(row.estimatedAtApply).toBe(12_000)
expect(row.estimatedForWindow).toBe(72_000) // deficitThen 3 x 40 edits x 600, same denominator as realized
expect(row.realizedTokens).toBeLessThanOrEqual(row.estimatedForWindow)
expect(row.note).toMatch(/1\.0:1 -> 3\.0:1/)
})
})
describe('round-down discipline', () => {
it('floors non-integer mcp products, never rounds up', async () => {
const rec = mcpRecord({ baseline: { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 5000, sessions: 3, metrics: { 'brave-search': 700.7 } } })
const actionsDir = await writeJournal([rec])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(3, daysAgo(5)))]) })
const row = report.rows[0]!
expect(row.status).toBe('measured')
expect(row.realizedTokens).toBe(2102) // floor(700.7 x 3 = 2102.1); ceil would be 2103
expect(row.estimatedForWindow).toBe(2102)
})
it('floors non-integer read-edit products, never rounds up', async () => {
// deficitThen = 4 - 10/7 = 18/7; deficitNow = 4 - 20/10 = 2.
// realized = floor((18/7 - 2) x 10 x 600) = floor(3428.57...) = 3428.
// window estimate = floor(18/7 x 10 x 600) = floor(15428.57...) = 15428.
const at = daysAgo(10)
const rec: ActionRecord = {
id: 'rf1', at, kind: 'claude-md-rule', findingId: 'read-edit-ratio',
description: 'Add the read-edit-ratio rule block', changes: [], status: 'applied',
baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 9000, sessions: 28, metrics: { reads: 10, edits: 7 } },
}
const actionsDir = await writeJournal([rec])
const session = makeSession('s0', daysAgo(5), { toolBreakdown: { Read: { calls: 20 }, Edit: { calls: 10 } } })
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf([session])]) })
const row = report.rows[0]!
expect(row.status).toBe('measured')
expect(row.realizedTokens).toBe(3428) // ceil would be 3429
expect(row.estimatedForWindow).toBe(15_428) // ceil would be 15429
})
})
describe('archive realized delta', () => {
it('measures per-item definition tokens times sessions and detects un-archive', async () => {
const at = daysAgo(10)
const kept = join(tmpdir(), 'codeburn-act-report-absent-skill-xyz') // absent -> not reverted
const base = { windowDays: 14, capturedAt: at, estimatedTokens: 160, sessions: 28, metrics: { 'skill-a': 80, 'skill-b': 80 } }
const rec: ActionRecord = {
id: 'ar1', at, kind: 'archive-skill', findingId: 'unused-skills',
description: 'Archive 2 unused skills', status: 'applied',
changes: [{ path: kept, backup: null, op: 'move', movedTo: kept + '.archived', afterHash: '' }],
baseline: base,
}
const actionsDir = await writeJournal([rec])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
expect(report.rows[0]!.status).toBe('measured')
expect(report.rows[0]!.realizedTokens).toBe(3200) // 160 tokens/session * 20
// Tautology expected: estimate and realized share the formula when nothing
// reverted; the measured signal is the session count and the revert check.
expect(report.rows[0]!.estimatedForWindow).toBe(report.rows[0]!.realizedTokens)
// Now the original path exists again -> reverted, zero savings.
const restoredPath = join(actionsDir, 'restored-skill')
await writeFile(restoredPath, 'x')
const rec2: ActionRecord = { ...rec, changes: [{ path: restoredPath, backup: null, op: 'move', movedTo: restoredPath + '.archived', afterHash: '' }] }
const dir2 = await writeJournal([rec2])
const report2 = await computeActReport({ actionsDir: dir2, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
expect(report2.rows[0]!.status).toBe('reverted')
expect(report2.rows[0]!.realizedTokens).toBe(0)
})
})
describe('unmeasured kinds', () => {
it('marks bash cap not measurable but keeps the estimate visible', async () => {
const at = daysAgo(10)
const rec: ActionRecord = {
id: 'b1', at, kind: 'shell-config', findingId: 'bash-output-cap',
description: 'Set the bash output cap', changes: [], status: 'applied',
baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 3750, sessions: 28, metrics: { calls: 200 } },
}
const actionsDir = await writeJournal([rec])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
expect(report.rows[0]!.status).toBe('not-measurable')
expect(report.rows[0]!.estimatedAtApply).toBe(3750)
expect(report.rows[0]!.estimatedForWindow).toBe(3750) // no window scaling for unmeasured kinds
expect(report.measuredCount).toBe(0)
})
it('marks mcp and archive not measurable when the window has no sessions yet', async () => {
const at = daysAgo(10)
const arch: ActionRecord = {
id: 'z2', at, kind: 'archive-skill', findingId: 'unused-skills',
description: 'Archive 1 unused skill', changes: [], status: 'applied',
baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 80, sessions: 28, metrics: { 'skill-a': 80 } },
}
const actionsDir = await writeJournal([mcpRecord(), arch])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([]) })
expect(report.rows).toHaveLength(2)
for (const row of report.rows) {
expect(row.status).toBe('not-measurable')
expect(row.note).toMatch(/no sessions in the window yet/)
expect(row.realizedTokens).toBe(0)
}
expect(report.measuredCount).toBe(0)
})
})
describe('journal robustness', () => {
const missingAt = { id: 'm1', kind: 'mcp-remove', status: 'applied', description: 'missing at', changes: [] }
const numericAt = { id: 'm2', at: 12345, kind: 'mcp-remove', status: 'applied', description: 'numeric at', changes: [] }
it('skips malformed records with a note instead of crashing, and drops the optimize header', async () => {
const actionsDir = await writeJournal([missingAt, numericAt])
const report = await computeActReport({
actionsDir, now: NOW,
loadProjects: async () => { throw new Error('should not scan when no eligible records remain') },
})
expect(report.malformedRecords).toBe(2)
expect(report.rows).toHaveLength(0)
expect(report.activeCount).toBe(0)
expect(buildOptimizeAppliedHeader(report)).toBeNull()
expect(renderActReport(report)).toMatch(/2 malformed records skipped/)
})
it('still measures valid records alongside malformed ones', async () => {
const actionsDir = await writeJournal([missingAt, mcpRecord()])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
expect(report.malformedRecords).toBe(1)
expect(report.rows).toHaveLength(1)
expect(report.rows[0]!.realizedTokens).toBe(40_000)
expect(renderActReport(report)).toMatch(/1 malformed record skipped/)
})
})
describe('optimize header', () => {
it('appears only when a normal-confidence measured token action exists', async () => {
const actionsDir = await writeJournal([mcpRecord()])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
const header = buildOptimizeAppliedHeader(report)
expect(header).toMatch(/^Applied fixes: 1 active, realized ~40\.0K tokens.*over 10 days\. Details: codeburn act report$/)
})
it('renders no header when every measured row is low confidence (under-claim)', async () => {
const actionsDir = await writeJournal([mcpRecord()])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(10, daysAgo(5)))]) })
expect(report.rows[0]!.status).toBe('measured')
expect(report.rows[0]!.confidence).toBe('low')
expect(report.rows[0]!.realizedTokens).toBe(20_000) // still visible in act report
expect(report.totalRealizedTokens).toBe(20_000)
expect(buildOptimizeAppliedHeader(report)).toBeNull()
})
it('sums only normal-confidence rows into the header total', async () => {
// r1: baseline 28/14d = 2/day vs post 20/10d = 2/day -> normal.
// r2: baseline 100/14d = 7.1/day vs 2/day -> >2x shift -> low.
const r1 = mcpRecord({ id: 'n1' })
const r2 = mcpRecord({
id: 'l1',
baseline: { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 56_000, sessions: 100, metrics: { 'other-server': 2000 } },
})
const actionsDir = await writeJournal([r1, r2])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
expect(report.totalRealizedTokens).toBe(80_000) // both stay visible in act report
const header = buildOptimizeAppliedHeader(report)
expect(header).toMatch(/^Applied fixes: 2 active, realized ~40\.0K tokens/)
})
it('returns null and never scans when the journal has no eligible actions', async () => {
const emptyDir = await writeJournal([])
const report = await computeActReport({
actionsDir: emptyDir, now: NOW,
loadProjects: async () => { throw new Error('should not scan for an empty journal') },
})
expect(report.rows).toHaveLength(0)
expect(report.activeCount).toBe(0)
expect(buildOptimizeAppliedHeader(report)).toBeNull()
})
it('records the earliest apply date per finding for re-flagging', async () => {
const records = [
mcpRecord({ id: 'x1', at: daysAgo(9), findingId: 'unused-mcp' }),
mcpRecord({ id: 'x2', at: daysAgo(4), findingId: 'unused-mcp' }),
]
const actionsDir = await writeJournal(records)
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(3)))]) })
expect(report.appliedByFinding['unused-mcp']).toBe(daysAgo(9).slice(0, 10))
})
})
describe('json + render shape', () => {
it('mirrors the rows and totals in --json', async () => {
const actionsDir = await writeJournal([mcpRecord()])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
const json = buildActReportJson(report) as {
actions: Array<Record<string, unknown>>
totals: Record<string, unknown>
footer: string
windowCapDays: number
}
expect(Array.isArray(json.actions)).toBe(true)
expect(json.actions[0]).toMatchObject({
kind: 'mcp-remove',
estimatedAtApply: 56_000,
estimatedForWindow: 40_000,
realizedTokens: 40_000,
status: 'measured',
confidence: 'normal',
})
expect(json.totals).toMatchObject({ realizedTokens: 40_000, measuredActions: 1, activeActions: 1 })
expect(json.windowCapDays).toBe(30)
expect((json as { malformedRecords?: number }).malformedRecords).toBe(0)
expect(typeof json.footer).toBe('string')
expect(json.footer).toMatch(/correlation/)
})
it('renders an empty state without a table when nothing is measurable', async () => {
const emptyDir = await writeJournal([])
const report = await computeActReport({ actionsDir: emptyDir, now: NOW, loadProjects: async () => [] })
const out = renderActReport(report)
expect(out).toMatch(/No applied actions to measure yet/)
expect(out).not.toMatch(/Total realized/)
})
it('renders a table with a total row when measurements exist', async () => {
const actionsDir = await writeJournal([mcpRecord()])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
const out = renderActReport(report)
expect(out).toMatch(/Total realized/)
expect(out).toMatch(/40\.0K/)
expect(out).toMatch(/measures only its own metric/)
expect(out).toMatch(/scaled to the measured window/)
})
})
+307
View File
@@ -0,0 +1,307 @@
import { afterAll, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runAction } from '../src/act/apply.js'
import { appendRecord, readRecords } from '../src/act/journal.js'
import { DriftError, undoAction } from '../src/act/undo.js'
import type { ActionRecord } from '../src/act/types.js'
const roots: string[] = []
async function makeRoot(): Promise<{ actionsDir: string; files: string }> {
const root = await mkdtemp(join(tmpdir(), 'codeburn-act-undo-'))
roots.push(root)
const files = join(root, 'files')
await mkdir(files, { recursive: true })
return { actionsDir: join(root, 'actions'), files }
}
afterAll(async () => {
for (const root of roots) await rm(root, { recursive: true, force: true })
})
describe('undoAction', () => {
it('restores byte-identical content for edit, create, and move, then refuses a second undo', async () => {
const { actionsDir, files } = await makeRoot()
const editPath = join(files, 'edit.bin')
const createPath = join(files, 'created.bin')
const movePath = join(files, 'move.bin')
const moveDest = join(files, 'sub', 'move.bin')
const editOriginal = Buffer.from([0, 1, 2, 3, 255, 254])
const moveOriginal = Buffer.from([10, 20, 30, 40])
await writeFile(editPath, editOriginal)
await writeFile(movePath, moveOriginal)
const rec = await runAction({
kind: 'archive-skill',
description: 'undo test',
changes: [
{ op: 'edit', path: editPath, content: Buffer.from([9, 9, 9]) },
{ op: 'create', path: createPath, content: Buffer.from([7, 7]) },
{ op: 'move', path: movePath, movedTo: moveDest },
],
}, actionsDir)
// 8-char prefix is accepted
await undoAction({ id: rec.id.slice(0, 8) }, { actionsDir })
expect(Buffer.compare(await readFile(editPath), editOriginal)).toBe(0)
expect(existsSync(createPath)).toBe(false)
expect(existsSync(moveDest)).toBe(false)
expect(Buffer.compare(await readFile(movePath), moveOriginal)).toBe(0)
const records = await readRecords(actionsDir)
expect(records).toHaveLength(1)
expect(records[0]!.status).toBe('undone')
expect(records[0]!.undoneAt).toBeTruthy()
await expect(undoAction({ id: rec.id }, { actionsDir })).rejects.toThrow(/already undone/)
})
it('undoes the most recent action with --last and leaves earlier actions applied', async () => {
const { actionsDir, files } = await makeRoot()
const first = join(files, 'first.txt')
const second = join(files, 'second.txt')
await writeFile(first, 'first-old')
await writeFile(second, 'second-old')
const recFirst = await runAction({
kind: 'claude-md-rule', description: 'first', changes: [{ op: 'edit', path: first, content: 'first-new' }],
}, actionsDir)
await runAction({
kind: 'claude-md-rule', description: 'second', changes: [{ op: 'edit', path: second, content: 'second-new' }],
}, actionsDir)
const undone = await undoAction({ last: true }, { actionsDir })
expect(undone.description).toBe('second')
expect(await readFile(second, 'utf-8')).toBe('second-old')
expect(await readFile(first, 'utf-8')).toBe('first-new')
const records = await readRecords(actionsDir)
expect(records.find(r => r.id === recFirst.id)!.status).toBe('applied')
})
it('refuses to undo a drifted file, but --force proceeds', async () => {
const { actionsDir, files } = await makeRoot()
const p = join(files, 'drift.txt')
await writeFile(p, 'original')
const rec = await runAction({
kind: 'claude-md-rule', description: 'drift', changes: [{ op: 'edit', path: p, content: 'applied' }],
}, actionsDir)
await writeFile(p, 'user-modified')
await expect(undoAction({ id: rec.id }, { actionsDir })).rejects.toBeInstanceOf(DriftError)
expect((await readRecords(actionsDir))[0]!.status).toBe('applied')
expect(await readFile(p, 'utf-8')).toBe('user-modified')
await undoAction({ id: rec.id }, { actionsDir, force: true })
expect(await readFile(p, 'utf-8')).toBe('original')
expect((await readRecords(actionsDir))[0]!.status).toBe('undone')
})
it('reverts changes newest-first so overlapping changes restore the original state', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'a.txt')
const dest = join(files, 'b.txt')
await writeFile(src, 'orig')
const rec = await runAction({
kind: 'shell-config',
description: 'move then edit',
changes: [
{ op: 'move', path: src, movedTo: dest },
{ op: 'edit', path: dest, content: 'edited' },
],
}, actionsDir)
await undoAction({ id: rec.id }, { actionsDir })
expect(await readFile(src, 'utf-8')).toBe('orig')
expect(existsSync(dest)).toBe(false)
})
it('refuses to undo a move when the original path is occupied, then --force overwrites', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'a.txt')
const dest = join(files, 'b.txt')
await writeFile(src, 'moved-bytes')
const rec = await runAction({
kind: 'archive-skill', description: 'occupied', changes: [{ op: 'move', path: src, movedTo: dest }],
}, actionsDir)
await writeFile(src, 'squatter')
const err = await undoAction({ id: rec.id }, { actionsDir }).catch(e => e)
expect(err).toBeInstanceOf(DriftError)
expect((err as DriftError).drifted.some(d => d.includes(src))).toBe(true)
await undoAction({ id: rec.id }, { actionsDir, force: true })
expect(await readFile(src, 'utf-8')).toBe('moved-bytes')
expect(existsSync(dest)).toBe(false)
})
it('snapshots an existing move destination and restores both files on undo', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'a.txt')
const dest = join(files, 'b.txt')
await writeFile(src, 'src-bytes')
await writeFile(dest, 'dest-bytes')
const rec = await runAction({
kind: 'archive-agent', description: 'move onto dest', changes: [{ op: 'move', path: src, movedTo: dest }],
}, actionsDir)
expect(rec.changes[0]!.destBackup).not.toBeNull()
expect(await readFile(dest, 'utf-8')).toBe('src-bytes')
expect(await readFile(join(actionsDir, rec.changes[0]!.destBackup!), 'utf-8')).toBe('dest-bytes')
await undoAction({ id: rec.id }, { actionsDir })
expect(await readFile(src, 'utf-8')).toBe('src-bytes')
expect(await readFile(dest, 'utf-8')).toBe('dest-bytes')
})
it('force-undo of a move whose moved file is gone restores from backup and flips status', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'a.txt')
const dest = join(files, 'b.txt')
await writeFile(src, 'precious')
const rec = await runAction({
kind: 'archive-command', description: 'gone', changes: [{ op: 'move', path: src, movedTo: dest }],
}, actionsDir)
await rm(dest)
await expect(undoAction({ id: rec.id }, { actionsDir })).rejects.toBeInstanceOf(DriftError)
const undone = await undoAction({ id: rec.id }, { actionsDir, force: true })
expect(undone.status).toBe('undone')
expect(await readFile(src, 'utf-8')).toBe('precious')
})
it('undoing a create that overwrote an existing file restores the prior bytes', async () => {
const { actionsDir, files } = await makeRoot()
const p = join(files, 'exists.txt')
await writeFile(p, 'prior')
const rec = await runAction({
kind: 'guard-install', description: 'create over existing', changes: [{ op: 'create', path: p, content: 'new' }],
}, actionsDir)
expect(rec.changes[0]!.backup).not.toBeNull()
await undoAction({ id: rec.id }, { actionsDir })
expect(await readFile(p, 'utf-8')).toBe('prior')
})
it('undoes a plan that touches the same path twice back to the original bytes', async () => {
const { actionsDir, files } = await makeRoot()
const p = join(files, 'twice.txt')
await writeFile(p, 'v0')
const rec = await runAction({
kind: 'claude-md-rule',
description: 'same path twice',
changes: [
{ op: 'edit', path: p, content: 'v1' },
{ op: 'edit', path: p, content: 'v2' },
],
}, actionsDir)
expect(rec.changes[0]!.afterHash).toBe(rec.changes[1]!.afterHash)
await undoAction({ id: rec.id }, { actionsDir })
expect(await readFile(p, 'utf-8')).toBe('v0')
})
it('rejects an ambiguous id prefix with the match count', async () => {
const { actionsDir } = await makeRoot()
await appendRecord(actionsDir, bareRecord('aaaaaaaa-1111-4111-8111-111111111111', 'one'))
await appendRecord(actionsDir, bareRecord('aaaaaaaa-2222-4222-8222-222222222222', 'two'))
await expect(undoAction({ id: 'aaaaaaaa' }, { actionsDir })).rejects.toThrow(/matches 2 actions/)
})
it('archives a directory and undo restores the tree with nested content byte-identical', async () => {
const { actionsDir, files } = await makeRoot()
const dir = join(files, 'skill')
const dest = join(files, '.archived', 'skill')
const nestedBytes = Buffer.from([1, 2, 3, 250, 0])
await mkdir(join(dir, 'nested'), { recursive: true })
await writeFile(join(dir, 'SKILL.md'), 'skill body')
await writeFile(join(dir, 'nested', 'data.bin'), nestedBytes)
const rec = await runAction({
kind: 'archive-skill',
description: 'archive dir',
changes: [{ op: 'move', path: dir, movedTo: dest }],
}, actionsDir)
expect(rec.changes[0]!.afterHash).toBe('')
expect(rec.changes[0]!.backup).not.toBeNull()
expect(existsSync(dir)).toBe(false)
expect(await readFile(join(dest, 'SKILL.md'), 'utf-8')).toBe('skill body')
await undoAction({ id: rec.id }, { actionsDir })
expect(existsSync(dest)).toBe(false)
expect(await readFile(join(dir, 'SKILL.md'), 'utf-8')).toBe('skill body')
expect(Buffer.compare(await readFile(join(dir, 'nested', 'data.bin')), nestedBytes)).toBe(0)
})
it('moves a directory onto an existing destination directory and undo restores both trees', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'agent')
const dest = join(files, 'agent-archived')
await mkdir(src, { recursive: true })
await mkdir(dest, { recursive: true })
await writeFile(join(src, 'agent.md'), 'src tree')
await writeFile(join(dest, 'old.md'), 'dest tree')
const rec = await runAction({
kind: 'archive-agent',
description: 'dir onto dir',
changes: [{ op: 'move', path: src, movedTo: dest }],
}, actionsDir)
expect(rec.changes[0]!.destBackup).not.toBeNull()
expect(await readFile(join(dest, 'agent.md'), 'utf-8')).toBe('src tree')
expect(existsSync(join(dest, 'old.md'))).toBe(false)
await undoAction({ id: rec.id }, { actionsDir })
expect(await readFile(join(src, 'agent.md'), 'utf-8')).toBe('src tree')
expect(await readFile(join(dest, 'old.md'), 'utf-8')).toBe('dest tree')
expect(existsSync(join(dest, 'agent.md'))).toBe(false)
})
it('refuses dir-move undo when the original path is occupied, then --force overwrites', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'cmd')
const dest = join(files, 'cmd-archived')
await mkdir(src, { recursive: true })
await writeFile(join(src, 'cmd.md'), 'body')
const rec = await runAction({
kind: 'archive-command',
description: 'occupied dir',
changes: [{ op: 'move', path: src, movedTo: dest }],
}, actionsDir)
await mkdir(src, { recursive: true })
await writeFile(join(src, 'squatter.md'), 'squatter')
const err = await undoAction({ id: rec.id }, { actionsDir }).catch(e => e)
expect(err).toBeInstanceOf(DriftError)
expect((err as DriftError).drifted.some(d => d.includes(src))).toBe(true)
await undoAction({ id: rec.id }, { actionsDir, force: true })
expect(await readFile(join(src, 'cmd.md'), 'utf-8')).toBe('body')
expect(existsSync(join(src, 'squatter.md'))).toBe(false)
expect(existsSync(dest)).toBe(false)
})
})
function bareRecord(id: string, description: string): ActionRecord {
return {
id,
at: new Date().toISOString(),
kind: 'mcp-remove',
findingId: null,
description,
changes: [],
status: 'applied',
}
}
+149
View File
@@ -0,0 +1,149 @@
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { delimiter, join } from 'path'
import { describe, expect, it } from 'vitest'
import {
buildAntigravityHookLookupPath,
installAntigravityStatusLineHook,
resolvePersistentCodeburnPathFromPath,
uninstallAntigravityStatusLineHook,
} from '../src/antigravity-statusline.js'
describe('Antigravity CLI statusLine hook installer', () => {
async function withTempSettings(run: (dir: string, settingsPath: string) => Promise<void>) {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-agy-hook-'))
const settingsPath = join(dir, 'settings.json')
const binDir = join(dir, 'bin')
const codeburnPath = join(binDir, process.platform === 'win32' ? 'codeburn.cmd' : 'codeburn')
await mkdir(binDir, { recursive: true })
await writeFile(codeburnPath, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n')
await chmod(codeburnPath, 0o755)
process.env['CODEBURN_ANTIGRAVITY_SETTINGS_PATH'] = settingsPath
process.env['CODEBURN_CACHE_DIR'] = join(dir, 'cache')
process.env.PATH = binDir
try {
await run(dir, settingsPath)
} finally {
await rm(dir, { recursive: true, force: true })
}
}
it('builds a lookup PATH with user paths before fallbacks', () => {
const lookupPath = buildAntigravityHookLookupPath(['/Users/me/.nvm/versions/node/v22.13.0/bin', '/usr/bin'].join(delimiter))
expect(lookupPath.split(delimiter)).toContain('/Users/me/.nvm/versions/node/v22.13.0/bin')
if (process.platform !== 'win32') expect(lookupPath.split(delimiter)).toContain('/opt/homebrew/bin')
})
it('skips transient npx codeburn shims when resolving the hook command', async () => {
await withTempSettings(async (dir) => {
const npxBin = join(dir, '.npm', '_npx', 'abcd', 'node_modules', '.bin')
const persistentBin = join(dir, 'persistent-bin')
const npxCodeburn = join(npxBin, process.platform === 'win32' ? 'codeburn.cmd' : 'codeburn')
const persistentCodeburn = join(persistentBin, process.platform === 'win32' ? 'codeburn.cmd' : 'codeburn')
await mkdir(npxBin, { recursive: true })
await mkdir(persistentBin, { recursive: true })
await writeFile(npxCodeburn, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n')
await writeFile(persistentCodeburn, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n')
await chmod(npxCodeburn, 0o755)
await chmod(persistentCodeburn, 0o755)
const resolved = await resolvePersistentCodeburnPathFromPath([npxBin, persistentBin].join(delimiter))
expect(resolved).toBe(persistentCodeburn)
})
})
it('backs up and restores an existing custom statusLine when forced', async () => {
await withTempSettings(async (dir, settingsPath) => {
const customStatusLine = {
type: 'command',
command: 'custom-statusline',
padding: 1,
}
await writeFile(settingsPath, `${JSON.stringify({ statusLine: customStatusLine }, null, 2)}\n`)
await expect(installAntigravityStatusLineHook(false)).rejects.toThrow('already has a custom statusLine')
expect(await installAntigravityStatusLineHook(true)).toBe('installed')
const installed = JSON.parse(await readFile(settingsPath, 'utf-8'))
expect(installed.statusLine.command).toContain('agy-statusline-hook')
expect(installed.statusLine.command).not.toContain('custom-statusline')
const backupPath = join(dir, 'cache', 'antigravity-statusline-previous.json')
const backup = JSON.parse(await readFile(backupPath, 'utf-8'))
expect(backup.statusLine).toEqual(customStatusLine)
expect(await uninstallAntigravityStatusLineHook()).toBe('restored')
const restored = JSON.parse(await readFile(settingsPath, 'utf-8'))
expect(restored.statusLine).toEqual(customStatusLine)
})
})
it('installs CodeBurn statusLine when no statusLine exists', async () => {
await withTempSettings(async (_dir, settingsPath) => {
expect(await installAntigravityStatusLineHook(false)).toBe('installed')
expect(await installAntigravityStatusLineHook(false)).toBe('already-installed')
const settings = JSON.parse(await readFile(settingsPath, 'utf-8'))
expect(settings.statusLine).toMatchObject({
type: 'command',
padding: 0,
})
expect(settings.statusLine.command).toContain('agy-statusline-hook')
expect(settings.statusLine.command).toContain(join(_dir, 'bin'))
expect(settings.statusLine.command).not.toContain('dist/cli.js')
})
})
it('repairs an existing stale CodeBurn statusLine command without force', async () => {
await withTempSettings(async (dir, settingsPath) => {
await writeFile(settingsPath, JSON.stringify({
statusLine: {
type: 'command',
command: "'/usr/local/bin/node' '/Users/me/codeburn-agy-statusline/dist/cli.js' agy-statusline-hook",
padding: 0,
},
}))
expect(await installAntigravityStatusLineHook(false)).toBe('installed')
const settings = JSON.parse(await readFile(settingsPath, 'utf-8'))
expect(settings.statusLine.command).toContain(join(dir, 'bin'))
expect(settings.statusLine.command).toContain('agy-statusline-hook')
expect(settings.statusLine.command).not.toContain('codeburn-agy-statusline/dist/cli.js')
})
})
it('treats a custom statusLine that only mentions the hook token as custom, not CodeBurn-owned', async () => {
await withTempSettings(async (_dir, settingsPath) => {
const custom = 'mybar --note "runs agy-statusline-hook nightly"'
await writeFile(settingsPath, JSON.stringify({
statusLine: { type: 'command', command: custom, padding: 0 },
}))
await expect(installAntigravityStatusLineHook(false)).rejects.toThrow(/custom statusLine/)
const settings = JSON.parse(await readFile(settingsPath, 'utf-8'))
expect(settings.statusLine.command).toBe(custom)
})
})
it('removes CodeBurn statusLine when there is no previous hook backup', async () => {
await withTempSettings(async (_dir, settingsPath) => {
await writeFile(settingsPath, JSON.stringify({
statusLine: {
type: 'command',
command: 'codeburn agy-statusline-hook',
padding: 0,
},
}))
expect(await uninstallAntigravityStatusLineHook()).toBe('removed')
const settings = JSON.parse(await readFile(settingsPath, 'utf-8'))
expect(settings).not.toHaveProperty('statusLine')
})
})
})
+112
View File
@@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest'
import { aggregateAudit } from '../src/audit-report.js'
import type {
ProjectSummary,
SessionSummary,
ClassifiedTurn,
ParsedApiCall,
TokenUsage,
TaskCategory,
} from '../src/types.js'
function emptyTokens(): TokenUsage {
return {
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
}
}
function makeCall(usage: Partial<TokenUsage>, costUSD: number, model = 'unknown-model-xyz', provider = 'claude'): ParsedApiCall {
return {
provider,
model,
usage: { ...emptyTokens(), ...usage },
costUSD,
tools: [],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-05-09T00:00:00.000Z',
bashCommands: [],
deduplicationKey: `${provider}-${model}-${costUSD}-${usage.inputTokens ?? 0}-${usage.cachedInputTokens ?? 0}`,
}
}
function makeProject(calls: ParsedApiCall[]): ProjectSummary {
const turn: ClassifiedTurn = {
userMessage: 't',
assistantCalls: calls,
timestamp: '2026-05-09T00:00:00.000Z',
sessionId: 's1',
category: 'feature' as TaskCategory,
retries: 0,
hasEdits: false,
}
const session: SessionSummary = {
sessionId: 's1',
project: 'p',
firstTimestamp: '2026-05-09T00:00:00.000Z',
lastTimestamp: '2026-05-09T00:00:00.000Z',
totalCostUSD: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 0,
turns: [turn],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
skillBreakdown: {},
}
return { project: 'p', projectPath: 'p', sessions: [session], totalCostUSD: 0, totalApiCalls: 0 }
}
describe('aggregateAudit', () => {
it('keeps raw fields and exposes codeburn normalizations', async () => {
const anthropicCall = makeCall({ inputTokens: 100, outputTokens: 50, reasoningTokens: 10, cacheReadInputTokens: 200 }, 0.5)
const openaiCall = makeCall({ inputTokens: 100, outputTokens: 50, cachedInputTokens: 300 }, 0.5)
const rows = await aggregateAudit([makeProject([anthropicCall, openaiCall])])
expect(rows).toHaveLength(1)
const r = rows[0]!
// raw fields are summed untouched
expect(r.raw.inputTokens).toBe(200)
expect(r.raw.outputTokens).toBe(100)
expect(r.raw.reasoningTokens).toBe(10)
expect(r.raw.cacheReadInputTokens).toBe(200)
expect(r.raw.cachedInputTokens).toBe(300)
// reasoning folds into output for pricing
expect(r.displayed.outputTokens).toBe(110)
// cache read is the SUM of per-call max(anthropic, openai), not max of sums
expect(r.displayed.cacheReadTokens).toBe(500)
// attributed cost is preserved exactly
expect(r.attributedCostUSD).toBeCloseTo(1.0)
})
it('returns null rates and zero component cost for an unpriced model', async () => {
const rows = await aggregateAudit([makeProject([makeCall({ inputTokens: 1000 }, 0, 'definitely-not-a-real-model-zzz')])])
expect(rows).toHaveLength(1)
expect(rows[0]!.rates).toBeNull()
expect(rows[0]!.cost.recomputedTotalUSD).toBe(0)
})
it('splits buckets by (provider, model)', async () => {
const rows = await aggregateAudit([makeProject([
makeCall({ inputTokens: 10 }, 0.1, 'model-a', 'claude'),
makeCall({ inputTokens: 20 }, 0.2, 'model-b', 'claude'),
makeCall({ inputTokens: 30 }, 0.3, 'model-a', 'codex'),
])])
expect(rows).toHaveLength(3)
})
})
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest'
import { extractBashCommands } from '../src/bash-utils.js'
import { BASH_TOOLS } from '../src/classifier.js'
describe('extractBashCommands', () => {
it('extracts single command', () => {
expect(extractBashCommands('git status')).toEqual(['git'])
})
it('extracts chained commands with &&', () => {
expect(extractBashCommands('git add . && git commit -m "x"')).toEqual(['git', 'git'])
})
it('extracts chained commands with ;', () => {
expect(extractBashCommands('ls; pwd')).toEqual(['ls', 'pwd'])
})
it('extracts piped commands', () => {
expect(extractBashCommands('cat file | grep pattern')).toEqual(['cat', 'grep'])
})
it('filters out cd', () => {
expect(extractBashCommands('cd /path && git status')).toEqual(['git'])
})
it('returns empty for cd only', () => {
expect(extractBashCommands('cd /path')).toEqual([])
})
it('returns empty for empty string', () => {
expect(extractBashCommands('')).toEqual([])
})
it('returns empty for whitespace only', () => {
expect(extractBashCommands(' ')).toEqual([])
})
it('extracts basename from full path binary', () => {
expect(extractBashCommands('/usr/bin/git status')).toEqual(['git'])
})
it('handles mixed separators', () => {
expect(extractBashCommands('cd /x && npm install; npm run build | tee log')).toEqual(['npm', 'npm', 'tee'])
})
it('handles extra whitespace', () => {
expect(extractBashCommands(' git status ')).toEqual(['git'])
})
it('handles command with quotes containing separators', () => {
expect(extractBashCommands('echo "hello && world"')).toEqual(['echo'])
})
it('handles quoted separators followed by real separator', () => {
expect(extractBashCommands('echo "hello && world" && git status')).toEqual(['echo', 'git'])
})
it('handles single-quoted separators', () => {
expect(extractBashCommands("echo 'hello && world'")).toEqual(['echo'])
})
it('skips leading env var assignments', () => {
expect(extractBashCommands('NODE_ENV=prod npm test')).toEqual(['npm'])
expect(extractBashCommands('FOO=bar BAZ=qux ls -la')).toEqual(['ls'])
})
it('skips standalone true/false', () => {
expect(extractBashCommands('true && git status')).toEqual(['git'])
expect(extractBashCommands('false || echo done')).toEqual(['echo'])
expect(extractBashCommands('true')).toEqual([])
})
it('handles env vars combined with chained commands', () => {
expect(extractBashCommands('NODE_ENV=test npm test && git push')).toEqual(['npm', 'git'])
})
it('skips command wrapper prefixes', () => {
expect(extractBashCommands('rtk git status')).toEqual(['git'])
expect(extractBashCommands('sudo npm install')).toEqual(['npm'])
expect(extractBashCommands('npx vitest --run')).toEqual(['vitest'])
})
it('skips prefix combined with env var assignment', () => {
expect(extractBashCommands('DEBUG=1 rtk git status')).toEqual(['git'])
})
it('skips nested wrapper prefixes', () => {
expect(extractBashCommands('sudo npx vitest --run')).toEqual(['vitest'])
})
it('skips prefix across chained commands', () => {
expect(extractBashCommands('rtk git add . && rtk git commit -m "msg"')).toEqual(['git', 'git'])
})
it('keeps a standalone prefix with no following command', () => {
expect(extractBashCommands('rtk')).toEqual(['rtk'])
expect(extractBashCommands('sudo')).toEqual(['sudo'])
})
it('keeps prefix when the next token is a flag', () => {
expect(extractBashCommands('nice -n 10 git push')).toEqual(['nice'])
})
it('skips env assignment that follows a wrapper prefix', () => {
expect(extractBashCommands('sudo NODE_ENV=production node server.js')).toEqual(['node'])
expect(extractBashCommands('time FOO=1 make build')).toEqual(['make'])
})
it('keeps prefix when the next token is quoted', () => {
expect(extractBashCommands('npx "@angular/cli" new app')).toEqual(['npx'])
expect(extractBashCommands("npx 'ts-node' script.ts")).toEqual(['npx'])
})
})
describe('BASH_TOOLS', () => {
it('recognizes Bash', () => { expect(BASH_TOOLS.has('Bash')).toBe(true) })
it('recognizes BashTool', () => { expect(BASH_TOOLS.has('BashTool')).toBe(true) })
it('rejects unknown tools', () => { expect(BASH_TOOLS.has('Read')).toBe(false) })
})
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { blobToText, isSqliteBusyError } from '../src/sqlite.js'
describe('blobToText', () => {
it('returns empty string for null', () => {
expect(blobToText(null)).toBe('')
})
it('returns empty string for undefined', () => {
expect(blobToText(undefined)).toBe('')
})
it('passes through strings unchanged', () => {
expect(blobToText('hello world')).toBe('hello world')
})
it('decodes valid UTF-8 Uint8Array', () => {
const buf = new TextEncoder().encode('café ☕')
expect(blobToText(buf)).toBe('café ☕')
})
it('replaces invalid UTF-8 bytes with U+FFFD instead of crashing', () => {
const buf = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x80, 0xfe])
const result = blobToText(buf)
expect(result).toContain('Hello')
expect(result).toContain('')
})
it('handles truncated multi-byte sequence', () => {
// é in UTF-8 is [0xc3, 0xa9]. Truncate to just [0xc3].
const buf = new Uint8Array([0x63, 0x61, 0x66, 0xc3])
const result = blobToText(buf)
expect(result).toBe('caf')
})
it('handles empty Uint8Array', () => {
expect(blobToText(new Uint8Array(0))).toBe('')
})
})
describe('isSqliteBusyError', () => {
it('detects node:sqlite busy errors by errcode', () => {
expect(isSqliteBusyError({ code: 'ERR_SQLITE_ERROR', errcode: 5, errstr: 'database is locked' })).toBe(true)
})
it('detects sqlite locked messages', () => {
expect(isSqliteBusyError(new Error('SQLITE_LOCKED: database table is locked'))).toBe(true)
})
it('ignores unrelated sqlite errors', () => {
expect(isSqliteBusyError(new Error('no such table: session'))).toBe(false)
})
})
+196
View File
@@ -0,0 +1,196 @@
import { describe, it, expect } from 'vitest'
import { classifyTurn } from '../src/classifier.js'
import type { ParsedApiCall, ParsedTurn } from '../src/types.js'
function makeCall(opts: Partial<ParsedApiCall> & { tools?: string[]; skills?: string[] }): ParsedApiCall {
const tools = opts.tools ?? []
return {
provider: 'claude',
model: 'Opus 4.7',
usage: {
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: 0,
tools,
mcpTools: tools.filter(t => t.startsWith('mcp__')),
skills: opts.skills ?? [],
hasAgentSpawn: tools.includes('Agent'),
hasPlanMode: tools.includes('EnterPlanMode'),
speed: 'standard',
timestamp: '2026-05-04T00:00:00Z',
bashCommands: [],
deduplicationKey: 'k',
...opts,
}
}
function makeTurn(calls: ParsedApiCall[], userMessage = ''): ParsedTurn {
return {
userMessage,
assistantCalls: calls,
timestamp: '2026-05-04T00:00:00Z',
sessionId: 's1',
}
}
describe('classifyTurn — Skill subCategory', () => {
it('attaches subCategory when a Skill tool fires alone (input.skill)', () => {
const turn = makeTurn([makeCall({ tools: ['Skill'], skills: ['init'] })])
const c = classifyTurn(turn)
expect(c.category).toBe('general')
expect(c.subCategory).toBe('init')
})
it('attaches subCategory when skill identifier comes via input.name (extracted upstream)', () => {
const turn = makeTurn([makeCall({ tools: ['Skill'], skills: ['atelier'] })])
const c = classifyTurn(turn)
expect(c.category).toBe('general')
expect(c.subCategory).toBe('atelier')
})
it('uses the first skill identifier when a single turn invokes multiple skills', () => {
const turn = makeTurn([makeCall({ tools: ['Skill', 'Skill'], skills: ['review', 'security-review'] })])
const c = classifyTurn(turn)
expect(c.category).toBe('general')
expect(c.subCategory).toBe('review')
})
it('aggregates skills across multiple assistant calls in the same turn', () => {
const turn = makeTurn([
makeCall({ tools: ['Skill'], skills: ['claude-api'] }),
makeCall({ tools: ['Skill'], skills: ['init'] }),
])
const c = classifyTurn(turn)
expect(c.category).toBe('general')
expect(c.subCategory).toBe('claude-api')
})
it('does not attach subCategory when the Skill tool fires but no skill name was extracted', () => {
const turn = makeTurn([makeCall({ tools: ['Skill'], skills: [] })])
const c = classifyTurn(turn)
expect(c.category).toBe('general')
expect(c.subCategory).toBeUndefined()
})
it('does not attach subCategory when category is not general (e.g. Skill alongside Edit promotes to coding)', () => {
const turn = makeTurn([makeCall({ tools: ['Skill', 'Edit'], skills: ['init'] })])
const c = classifyTurn(turn)
expect(c.category).toBe('coding')
expect(c.subCategory).toBeUndefined()
})
it('does not attach subCategory for non-Skill general turns', () => {
const turn = makeTurn([makeCall({ tools: [] })], 'just chatting')
const c = classifyTurn(turn)
expect(c.subCategory).toBeUndefined()
})
it('tolerates missing skills field on legacy ParsedApiCall shape', () => {
const baseCall = makeCall({ tools: ['Skill'], skills: ['init'] })
const legacyCall = { ...baseCall } as unknown as ParsedApiCall & { skills?: string[] }
delete (legacyCall as { skills?: string[] }).skills
const c = classifyTurn(makeTurn([legacyCall]))
expect(c.category).toBe('general')
expect(c.subCategory).toBeUndefined()
})
})
// Regression coverage for issue #196: feature verbs that lead a message
// were previously hijacked into 'debugging' just because the message contained
// an incidental "error" / "fix" / "issue" word later in the same sentence.
// Now whichever keyword pattern matches earliest wins.
describe('classifyTurn — feature vs debugging precedence (#196)', () => {
function codingTurn(userMessage: string): ParsedTurn {
return makeTurn([makeCall({ tools: ['Edit'] })], userMessage)
}
it('classifies "add error handling" as feature, not debugging', () => {
const c = classifyTurn(codingTurn('add error handling to the auth module'))
expect(c.category).toBe('feature')
})
it('classifies "create an issue tracker" as feature, not debugging', () => {
const c = classifyTurn(codingTurn('create an issue tracker page in the dashboard'))
expect(c.category).toBe('feature')
})
it('classifies "implement the 404 page" as feature, not debugging', () => {
const c = classifyTurn(codingTurn('implement the 404 page with a friendly redirect'))
expect(c.category).toBe('feature')
})
it('still classifies "fix the layout for the new feature" as debugging', () => {
const c = classifyTurn(codingTurn('fix the layout for the new feature'))
expect(c.category).toBe('debugging')
})
it('still classifies a plain bug report as debugging', () => {
const c = classifyTurn(codingTurn('login is broken, traceback below'))
expect(c.category).toBe('debugging')
})
it('classifies "refactor the error handling" as refactoring', () => {
const c = classifyTurn(codingTurn('refactor the error handling so it is cleaner'))
expect(c.category).toBe('refactoring')
})
it('chat-only message starting with "add" stays feature even with "fix" later', () => {
const c = classifyTurn(makeTurn([], 'add a setting page; we will fix the styles after'))
expect(c.category).toBe('feature')
})
it('chat-only message starting with "fix" stays debugging even with "add" later', () => {
const c = classifyTurn(makeTurn([], 'fix the bug introduced when we added the new flag'))
expect(c.category).toBe('debugging')
})
})
describe('classifyTurn — retry detection via toolSequence', () => {
it('detects retries from multi-call turns (Claude-style)', () => {
const turn = makeTurn([
makeCall({ tools: ['Edit'] }),
makeCall({ tools: ['Bash'] }),
makeCall({ tools: ['Edit'] }),
], 'fix the build')
const c = classifyTurn(turn)
expect(c.retries).toBe(1)
})
it('detects retries from toolSequence on a single call (Kiro/Goose-style)', () => {
const call = makeCall({ tools: ['Edit', 'Bash'] })
call.toolSequence = [[{ tool: 'Edit' }], [{ tool: 'Bash' }], [{ tool: 'Edit' }]]
const turn = makeTurn([call], 'fix the build')
const c = classifyTurn(turn)
expect(c.retries).toBe(1)
})
it('returns 0 retries for single call without toolSequence', () => {
const call = makeCall({ tools: ['Edit', 'Bash'] })
const turn = makeTurn([call], 'fix the build')
const c = classifyTurn(turn)
expect(c.retries).toBe(0)
})
it('counts multiple retries from toolSequence', () => {
const call = makeCall({ tools: ['Edit', 'Bash'] })
call.toolSequence = [[{ tool: 'Edit' }], [{ tool: 'Bash' }], [{ tool: 'Edit' }], [{ tool: 'Bash' }], [{ tool: 'Edit' }]]
const turn = makeTurn([call], 'fix the build')
const c = classifyTurn(turn)
expect(c.retries).toBe(2)
})
it('ignores toolSequence with only one step', () => {
const call = makeCall({ tools: ['Edit', 'Bash'] })
call.toolSequence = [[{ tool: 'Edit' }, { tool: 'Bash' }]]
const turn = makeTurn([call], 'fix the build')
const c = classifyTurn(turn)
expect(c.retries).toBe(0)
})
})
+169
View File
@@ -0,0 +1,169 @@
import { afterEach, describe, it, expect, vi } from 'vitest'
import {
getDateRange,
PERIODS,
PERIOD_LABELS,
parsePeriodOrThrow,
periodInfoFromQuery,
toPeriod,
type Period,
} from '../src/cli-date.js'
afterEach(() => {
vi.useRealTimers()
})
describe('getDateRange', () => {
it('"all" is bounded to the last 6 months, not epoch', () => {
const { range, label } = getDateRange('all')
const now = new Date()
expect(label).toBe('Last 6 months')
// Regression guard: must never silently fall back to epoch (the old
// dashboard bug) or any pre-2000 date.
expect(range.start.getFullYear()).toBeGreaterThan(2000)
const monthsDiff =
(now.getFullYear() - range.start.getFullYear()) * 12 +
(now.getMonth() - range.start.getMonth())
expect(monthsDiff).toBe(6)
expect(range.start.getDate()).toBe(1)
// End is today, end of day.
expect(range.end.getHours()).toBe(23)
expect(range.end.getMinutes()).toBe(59)
})
it('"all" does not overflow past the target month at end-of-month', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 7, 31, 12, 0, 0))
const { range } = getDateRange('all')
expect(range.start.getFullYear()).toBe(2026)
expect(range.start.getMonth()).toBe(1)
expect(range.start.getDate()).toBe(1)
})
it('"week" returns the last 7 days', () => {
const { range, label } = getDateRange('week')
expect(label).toBe('Last 7 Days')
// start = midnight 7 days ago, end = today 23:59:59.999 -> ~8 days span.
const diffDays = (range.end.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24)
expect(diffDays).toBeGreaterThanOrEqual(7)
expect(diffDays).toBeLessThanOrEqual(8)
})
it('"month" starts on day 1 of the current month', () => {
const { range } = getDateRange('month')
expect(range.start.getDate()).toBe(1)
expect(range.start.getHours()).toBe(0)
})
it('"30days" returns 30 days back', () => {
const { range, label } = getDateRange('30days')
expect(label).toBe('Last 30 Days')
const diffDays = (range.end.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24)
expect(diffDays).toBeGreaterThanOrEqual(30)
expect(diffDays).toBeLessThanOrEqual(31)
})
it('"today" starts at local midnight', () => {
const { range } = getDateRange('today')
expect(range.start.getHours()).toBe(0)
expect(range.start.getMinutes()).toBe(0)
expect(range.end.getHours()).toBe(23)
})
it('"yesterday" is supported (CLI-only convenience)', () => {
const { range, label } = getDateRange('yesterday')
expect(label).toMatch(/^Yesterday/)
expect(range.start.getHours()).toBe(0)
expect(range.end.getHours()).toBe(23)
})
it('unknown period exits with an error instead of silently falling back', () => {
expect(() => getDateRange('not-a-period')).toThrow()
})
})
describe('PERIODS / PERIOD_LABELS', () => {
it('exposes the expected period set', () => {
expect(PERIODS).toEqual(['today', 'week', '30days', 'month', 'all'])
})
it('has a label for every period', () => {
for (const p of PERIODS) {
expect(PERIOD_LABELS[p]).toBeTruthy()
}
})
it('"all" tab label reflects the 6-month bound', () => {
// Short label used in the dashboard tab strip. The long-form label
// ("Last 6 months") comes from getDateRange().label.
expect(PERIOD_LABELS.all).toBe('6 Months')
})
})
describe('parsePeriodOrThrow', () => {
it('round-trips known periods', () => {
const known: Period[] = ['today', 'week', '30days', 'month', 'all']
for (const p of known) {
expect(parsePeriodOrThrow(p)).toBe(p)
}
})
it('throws on unknown input without calling process.exit', () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
try {
expect(() => parsePeriodOrThrow('garbage')).toThrow(/Unknown period "garbage"/)
expect(exitSpy).not.toHaveBeenCalled()
} finally {
exitSpy.mockRestore()
}
})
})
describe('periodInfoFromQuery', () => {
it('resolves a named period', () => {
const info = periodInfoFromQuery({ period: 'week' }, 'month')
expect(info.label).toBe('Last 7 Days')
})
it('throws for an invalid period without calling process.exit', () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
try {
expect(() => periodInfoFromQuery({ period: 'garbage' }, 'month')).toThrow(/Unknown period "garbage"/)
expect(exitSpy).not.toHaveBeenCalled()
} finally {
exitSpy.mockRestore()
}
})
})
describe('toPeriod', () => {
it('round-trips known periods', () => {
const known: Period[] = ['today', 'week', '30days', 'month', 'all']
for (const p of known) {
expect(toPeriod(p)).toBe(p)
}
})
it('exits with an error on unknown input instead of silently falling back', () => {
// Previously toPeriod silently fell back to 'week' for any unrecognized
// value, which let typos like `-p mounth` produce a quiet 7-day report
// while the user thought they were viewing the month. The new behavior
// is to fail loudly via process.exit(1) after writing to stderr.
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') }) as unknown as ReturnType<typeof vi.spyOn>
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
try {
expect(() => toPeriod('garbage')).toThrow('exit')
expect(exitSpy).toHaveBeenCalledWith(1)
expect(stderrSpy).toHaveBeenCalled()
} finally {
exitSpy.mockRestore()
stderrSpy.mockRestore()
}
})
})
+133
View File
@@ -0,0 +1,133 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
HOME: home,
TZ: 'UTC',
},
encoding: 'utf-8',
timeout: 30_000,
})
}
function userLine(content: string, timestamp: string): string {
return JSON.stringify({
type: 'user',
sessionId: 'deepseek-v4-session',
timestamp,
cwd: '/tmp/deepseek-v4-validation',
message: { role: 'user', content },
})
}
function assistantLine(model: string, timestamp: string, messageId: string, usage: Record<string, number>): string {
return JSON.stringify({
type: 'assistant',
sessionId: 'deepseek-v4-session',
timestamp,
cwd: '/tmp/deepseek-v4-validation',
message: {
id: messageId,
type: 'message',
role: 'assistant',
model,
content: [
{ type: 'text', text: 'updated pricing code' },
{ type: 'tool_use', id: `tu-${messageId}`, name: 'Edit', input: { file_path: '/tmp/deepseek-v4-validation/pricing.ts', old_string: 'old', new_string: 'new' } },
],
usage,
},
})
}
describe('CLI DeepSeek v4 Claude pricing regression', () => {
it('prices DeepSeek v4 Claude sessions even when the runtime LiteLLM cache lacks those models', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-deepseek-v4-cli-'))
try {
const projectDir = join(home, '.claude', 'projects', 'deepseek-v4-validation')
const cacheDir = join(home, '.cache', 'codeburn')
await mkdir(projectDir, { recursive: true })
await mkdir(cacheDir, { recursive: true })
await writeFile(join(cacheDir, 'litellm-pricing.json'), JSON.stringify({
timestamp: Date.now(),
data: {
'gpt-4o-mini': {
inputCostPerToken: 1.5e-7,
outputCostPerToken: 6e-7,
cacheWriteCostPerToken: 0,
cacheReadCostPerToken: 7.5e-8,
webSearchCostPerRequest: 0.01,
fastMultiplier: 1,
},
},
}))
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('Use DeepSeek v4 through the Claude-compatible endpoint.', '2026-05-20T10:00:00.000Z'),
assistantLine('deepseek-v4-pro', '2026-05-20T10:01:00.000Z', 'deepseek-v4-pro', {
input_tokens: 2_477_914,
output_tokens: 762_994,
cache_read_input_tokens: 258_556_928,
cache_creation_input_tokens: 0,
}),
userLine('Validate the flash model path too.', '2026-05-20T10:02:00.000Z'),
assistantLine('deepseek-v4-flash', '2026-05-20T10:03:00.000Z', 'deepseek-v4-flash', {
input_tokens: 1_552_573,
output_tokens: 353_914,
cache_read_input_tokens: 48_388_608,
cache_creation_input_tokens: 0,
}),
].join('\n') + '\n',
)
const result = runCli([
'--format', 'json',
'--from', '2026-05-20',
'--to', '2026-05-20',
'--provider', 'claude',
], home)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const report = JSON.parse(result.stdout) as {
overview: { cost: number; calls: number; tokens: { cacheRead: number } }
models: Array<{ name: string; cost: number; calls: number; inputTokens: number; outputTokens: number; cacheReadTokens: number }>
}
const pro = report.models.find(m => m.name === 'DeepSeek v4 Pro')
const flash = report.models.find(m => m.name === 'DeepSeek v4 Flash')
expect(report.overview.calls).toBe(2)
expect(report.overview.tokens.cacheRead).toBe(306_945_536)
expect(report.overview.cost).toBeCloseTo(3.13091, 5)
expect(pro).toBeDefined()
expect(pro!.calls).toBe(1)
expect(pro!.inputTokens).toBe(2_477_914)
expect(pro!.outputTokens).toBe(762_994)
expect(pro!.cacheReadTokens).toBe(258_556_928)
expect(pro!.cost).toBeCloseTo(2.678966, 6)
expect(flash).toBeDefined()
expect(flash!.calls).toBe(1)
expect(flash!.inputTokens).toBe(1_552_573)
expect(flash!.outputTokens).toBe(353_914)
expect(flash!.cacheReadTokens).toBe(48_388_608)
expect(flash!.cost).toBeCloseTo(0.451944, 6)
} finally {
await rm(home, { recursive: true, force: true })
}
})
})
+98
View File
@@ -0,0 +1,98 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
HOME: home,
USERPROFILE: home,
HOMEPATH: home,
HOMEDRIVE: '',
CLAUDE_CONFIG_DIR: join(home, '.claude'),
CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'),
TZ: 'UTC',
},
encoding: 'utf-8',
timeout: 30_000,
})
}
describe('codeburn report Devin model variants', () => {
it('keeps friendly Devin effort-tier names in JSON model rows and efficiency rows', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-devin-models-'))
try {
await mkdir(join(home, '.config', 'codeburn'), { recursive: true })
await writeFile(join(home, '.config', 'codeburn', 'config.json'), JSON.stringify({
devin: { acuUsdRate: 1 },
}))
const transcriptsDir = join(home, '.local', 'share', 'devin', 'cli', 'transcripts')
await mkdir(transcriptsDir, { recursive: true })
await writeFile(join(transcriptsDir, 'session-487.json'), JSON.stringify({
schema_version: '1.4',
session_id: 'session-487',
agent: { model_name: 'GPT-5.4' },
steps: [
{
step_id: 1,
message: 'fix the model row',
metadata: { is_user_input: true, created_at: '2026-04-10T09:00:00.000Z' },
},
{
step_id: 2,
message: 'editing',
tool_calls: [{ function_name: 'Edit' }],
metadata: {
created_at: '2026-04-10T09:01:00.000Z',
committed_acu_cost: 0.25,
generation_model: 'gpt-5-3-codex-xhigh',
metrics: { input_tokens: 100, output_tokens: 25 },
},
},
],
}))
const result = runCli([
'report',
'--format',
'json',
'--from',
'2026-04-10',
'--to',
'2026-04-10',
'--provider',
'devin',
], home)
expect(result.status, result.stderr).toBe(0)
const report = JSON.parse(result.stdout) as {
models: Array<{
name: string
calls: number
cost: number
editTurns: number
oneShotTurns: number
costPerEdit: number | null
}>
}
expect(report.models).toHaveLength(1)
expect(report.models[0]).toMatchObject({
name: 'GPT-5.3 Codex (xhigh)',
calls: 1,
cost: 0.25,
editTurns: 1,
oneShotTurns: 1,
costPerEdit: 0.25,
})
} finally {
await rm(home, { recursive: true, force: true })
}
})
})
+96
View File
@@ -0,0 +1,96 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
HOME: home,
TZ: 'UTC',
},
encoding: 'utf-8',
})
}
function userLine(sessionId: string, timestamp: string): string {
return JSON.stringify({
type: 'user',
sessionId,
timestamp,
message: { role: 'user', content: 'add feature' },
})
}
function assistantLine(sessionId: string, timestamp: string, messageId: string): string {
return JSON.stringify({
type: 'assistant',
sessionId,
timestamp,
message: {
id: messageId,
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [{ type: 'text', text: 'done' }],
usage: {
input_tokens: 1000,
output_tokens: 100,
},
},
})
}
describe('codeburn export custom date range', () => {
it('exports a single custom period filtered by --from/--to', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-export-'))
try {
const projectDir = join(home, '.claude', 'projects', 'app')
await mkdir(projectDir, { recursive: true })
await writeFile(
join(projectDir, 'in-range.jsonl'),
[
userLine('in-range', '2026-04-10T09:00:00Z'),
assistantLine('in-range', '2026-04-10T09:01:00Z', 'msg-in-range'),
].join('\n'),
)
await writeFile(
join(projectDir, 'out-of-range.jsonl'),
[
userLine('out-of-range', '2026-04-11T09:00:00Z'),
assistantLine('out-of-range', '2026-04-11T09:01:00Z', 'msg-out-of-range'),
].join('\n'),
)
const outputPath = join(home, 'custom-export.json')
const result = runCli([
'export',
'--format', 'json',
'--from', '2026-04-10',
'--to', '2026-04-10',
'--provider', 'claude',
'--output', outputPath,
], home)
expect(result.status).toBe(0)
expect(result.stdout).toContain('Exported (2026-04-10 to 2026-04-10)')
const exported = JSON.parse(await readFile(outputPath, 'utf-8')) as {
summary: Array<{ Period: string; Sessions: number }>
sessions: Array<{ 'Session ID': string }>
}
expect(exported.summary).toHaveLength(1)
expect(exported.summary[0]?.Period).toBe('2026-04-10 to 2026-04-10')
expect(exported.summary[0]?.Sessions).toBe(1)
expect(exported.sessions.map(s => s['Session ID'])).toEqual(['in-range'])
} finally {
await rm(home, { recursive: true, force: true })
}
})
})
+237
View File
@@ -0,0 +1,237 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
HOME: home,
TZ: 'UTC',
},
encoding: 'utf-8',
})
}
function userLine(sessionId: string, timestamp: string): string {
return JSON.stringify({
type: 'user',
sessionId,
timestamp,
message: { role: 'user', content: 'do the thing' },
})
}
function assistantEditLine(sessionId: string, timestamp: string, messageId: string): string {
// Includes a tool_use of `Edit` so the parser flags this turn as hasEdits=true.
// Single edit-turn with no retry (one assistant message in the turn) → counts
// as one oneShotTurn.
return JSON.stringify({
type: 'assistant',
sessionId,
timestamp,
message: {
id: messageId,
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [
{ type: 'text', text: 'editing' },
{ type: 'tool_use', id: 'tu-1', name: 'Edit', input: { file_path: '/tmp/x', old_string: 'a', new_string: 'b' } },
],
usage: { input_tokens: 1000, output_tokens: 100 },
},
})
}
function assistantNoEditLine(sessionId: string, timestamp: string, messageId: string): string {
// No edit tool — this turn does not count toward editTurns/oneShotTurns,
// but does count toward `turns` and `calls`.
return JSON.stringify({
type: 'assistant',
sessionId,
timestamp,
message: {
id: messageId,
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [{ type: 'text', text: 'just chatting' }],
usage: { input_tokens: 200, output_tokens: 30 },
},
})
}
describe('codeburn report --format json daily[] one-shot fields (issue #279)', () => {
it('exposes per-day turns / editTurns / oneShotTurns / oneShotRate', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-daily-'))
try {
const projectDir = join(home, '.claude', 'projects', 'app')
await mkdir(projectDir, { recursive: true })
// Day 1 (2026-04-10): one edit-turn (one-shot), one chat-turn
// Day 2 (2026-04-11): one edit-turn (one-shot)
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('s1', '2026-04-10T09:00:00Z'),
assistantEditLine('s1', '2026-04-10T09:01:00Z', 'm-d1-edit'),
userLine('s1', '2026-04-10T10:00:00Z'),
assistantNoEditLine('s1', '2026-04-10T10:01:00Z', 'm-d1-chat'),
userLine('s1', '2026-04-11T09:00:00Z'),
assistantEditLine('s1', '2026-04-11T09:01:00Z', 'm-d2-edit'),
].join('\n'),
)
const result = runCli([
'--format', 'json',
'--from', '2026-04-10',
'--to', '2026-04-11',
'--provider', 'claude',
], home)
expect(result.status).toBe(0)
const report = JSON.parse(result.stdout) as {
daily: Array<{
date: string
cost: number
calls: number
turns: number
editTurns: number
oneShotTurns: number
oneShotRate: number | null
}>
}
expect(report.daily).toHaveLength(2)
const day1 = report.daily.find(d => d.date === '2026-04-10')
expect(day1).toBeDefined()
expect(day1!.turns).toBe(2)
expect(day1!.editTurns).toBe(1)
expect(day1!.oneShotTurns).toBe(1)
expect(day1!.oneShotRate).toBe(100)
const day2 = report.daily.find(d => d.date === '2026-04-11')
expect(day2).toBeDefined()
expect(day2!.turns).toBe(1)
expect(day2!.editTurns).toBe(1)
expect(day2!.oneShotTurns).toBe(1)
expect(day2!.oneShotRate).toBe(100)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('reports null oneShotRate when the day has no edit turns', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-daily-'))
try {
const projectDir = join(home, '.claude', 'projects', 'app')
await mkdir(projectDir, { recursive: true })
await writeFile(
join(projectDir, 'chat-only.jsonl'),
[
userLine('s2', '2026-04-10T09:00:00Z'),
assistantNoEditLine('s2', '2026-04-10T09:01:00Z', 'm-chat-1'),
userLine('s2', '2026-04-10T09:30:00Z'),
assistantNoEditLine('s2', '2026-04-10T09:31:00Z', 'm-chat-2'),
].join('\n'),
)
const result = runCli([
'--format', 'json',
'--from', '2026-04-10',
'--to', '2026-04-10',
'--provider', 'claude',
], home)
expect(result.status).toBe(0)
const report = JSON.parse(result.stdout) as {
daily: Array<{ date: string; turns: number; editTurns: number; oneShotTurns: number; oneShotRate: number | null }>
}
const day = report.daily.find(d => d.date === '2026-04-10')!
expect(day.turns).toBe(2)
expect(day.editTurns).toBe(0)
expect(day.oneShotTurns).toBe(0)
// null, not 0 — the rate is undefined when no edits happened, and a
// chat-only day would otherwise read as 0% one-shot which is misleading.
expect(day.oneShotRate).toBeNull()
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('filters a single review day with --day', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-day-'))
try {
const projectDir = join(home, '.claude', 'projects', 'app')
await mkdir(projectDir, { recursive: true })
// runCli pins TZ=UTC, so these exact boundaries exercise the inclusive
// start/end of the selected calendar day without local-offset drift.
await writeFile(
join(projectDir, 'day-selector.jsonl'),
[
userLine('day-before', '2026-04-09T23:59:00Z'),
assistantNoEditLine('day-before', '2026-04-09T23:59:30Z', 'm-before'),
userLine('day-selected', '2026-04-10T00:00:00Z'),
assistantEditLine('day-selected', '2026-04-10T23:59:59Z', 'm-selected'),
userLine('day-after', '2026-04-11T00:00:00Z'),
assistantNoEditLine('day-after', '2026-04-11T00:00:30Z', 'm-after'),
].join('\n'),
)
const result = runCli([
'--format', 'json',
'--day', '2026-04-10',
'--provider', 'claude',
], home)
expect(result.status).toBe(0)
const report = JSON.parse(result.stdout) as {
period: string
periodKey: string
daily: Array<{ date: string; calls: number; editTurns: number }>
projects: Array<{ sessions: number; calls: number }>
}
expect(report.period).toBe('Day (2026-04-10)')
expect(report.periodKey).toBe('day')
expect(report.daily.map(d => d.date)).toEqual(['2026-04-10'])
expect(report.daily[0]?.calls).toBe(1)
expect(report.daily[0]?.editTurns).toBe(1)
expect(report.projects[0]?.sessions).toBe(1)
expect(report.projects[0]?.calls).toBe(1)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('rejects --day combined with --from/--to', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-day-'))
try {
const result = runCli([
'--format', 'json',
'--day', '2026-04-10',
'--from', '2026-04-10',
'--provider', 'claude',
], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('--day cannot be combined with --from or --to')
} finally {
await rm(home, { recursive: true, force: true })
}
})
})
+76
View File
@@ -0,0 +1,76 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, it, expect } from 'vitest'
const CLI_TIMEOUT_MS = 10_000
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
HOME: home,
USERPROFILE: home,
HOMEPATH: home,
HOMEDRIVE: '',
},
encoding: 'utf-8',
})
}
function readConfig(home: string): Promise<Record<string, unknown>> {
return readFile(join(home, '.config', 'codeburn', 'config.json'), 'utf-8')
.then(raw => JSON.parse(raw) as Record<string, unknown>)
}
describe('codeburn model-savings command', () => {
it('saves, lists, and removes a local-model savings mapping', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-savings-'))
try {
const set = runCli(['model-savings', 'llama3.1:8b', 'gpt-4o'], home)
expect(set.status).toBe(0)
expect(set.stdout).toContain('llama3.1:8b -> gpt-4o')
const saved = await readConfig(home)
expect(saved.localModelSavings).toEqual({ 'llama3.1:8b': 'gpt-4o' })
const list = runCli(['model-savings', '--list'], home)
expect(list.status).toBe(0)
expect(list.stdout).toContain('llama3.1:8b -> gpt-4o')
const remove = runCli(['model-savings', '--remove', 'llama3.1:8b'], home)
expect(remove.status).toBe(0)
const after = await readConfig(home)
expect(after.localModelSavings).toBeUndefined()
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_TIMEOUT_MS)
it('warns when the same model is also configured in modelAliases', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-savings-'))
try {
expect(runCli(['model-alias', 'llama3.1:8b', 'gpt-4o'], home).status).toBe(0)
const set = runCli(['model-savings', 'llama3.1:8b', 'gpt-4o'], home)
expect(set.status).toBe(0)
expect(set.stdout).toContain('savings take precedence')
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_TIMEOUT_MS)
it('rejects a remove for an unknown mapping', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-savings-'))
try {
const result = runCli(['model-savings', '--remove', 'unknown:1b'], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('No savings mapping found')
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_TIMEOUT_MS)
})
+150
View File
@@ -0,0 +1,150 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, it, expect } from 'vitest'
const CLI_PLAN_TIMEOUT_MS = 10_000
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
HOME: home,
USERPROFILE: home, // os.homedir() uses USERPROFILE on Windows
HOMEPATH: home,
HOMEDRIVE: '',
},
encoding: 'utf-8',
})
}
describe('codeburn plan command', () => {
it('persists provider-keyed plans and clears on reset', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
const setResult = runCli(['plan', 'set', 'claude-max'], home)
expect(setResult.status).toBe(0)
const setCodexResult = runCli(['plan', 'set', 'custom', '--monthly-usd', '200', '--provider', 'codex'], home)
expect(setCodexResult.status).toBe(0)
const configPath = join(home, '.config', 'codeburn', 'config.json')
const configRaw = await readFile(configPath, 'utf-8')
const config = JSON.parse(configRaw) as { plans?: { claude?: { id?: string; monthlyUsd?: number }; codex?: { id?: string; monthlyUsd?: number } } }
expect(config.plans?.claude?.id).toBe('claude-max')
expect(config.plans?.claude?.monthlyUsd).toBe(200)
expect(config.plans?.codex?.id).toBe('custom')
expect(config.plans?.codex?.monthlyUsd).toBe(200)
const resetResult = runCli(['plan', 'reset'], home)
expect(resetResult.status).toBe(0)
const afterResetRaw = await readFile(configPath, 'utf-8')
const afterReset = JSON.parse(afterResetRaw) as { plan?: unknown; plans?: unknown }
expect(afterReset.plan).toBeUndefined()
expect(afterReset.plans).toBeUndefined()
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_PLAN_TIMEOUT_MS)
it('resets one provider without removing other plans', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
expect(runCli(['plan', 'set', 'claude-max'], home).status).toBe(0)
expect(runCli(['plan', 'set', 'custom', '--monthly-usd', '200', '--provider', 'codex'], home).status).toBe(0)
expect(runCli(['plan', 'reset', '--provider', 'codex'], home).status).toBe(0)
const configPath = join(home, '.config', 'codeburn', 'config.json')
const configRaw = await readFile(configPath, 'utf-8')
const config = JSON.parse(configRaw) as { plans?: { claude?: { id?: string }; codex?: unknown } }
expect(config.plans?.claude?.id).toBe('claude-max')
expect(config.plans?.codex).toBeUndefined()
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_PLAN_TIMEOUT_MS)
it('resets the all-provider plan without removing provider-specific plans', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
expect(runCli(['plan', 'set', 'claude-max'], home).status).toBe(0)
expect(runCli(['plan', 'reset', '--provider', 'all'], home).status).toBe(0)
const configPath = join(home, '.config', 'codeburn', 'config.json')
const configRaw = await readFile(configPath, 'utf-8')
const config = JSON.parse(configRaw) as { plans?: { claude?: { id?: string }; all?: unknown } }
expect(config.plans?.claude?.id).toBe('claude-max')
expect(config.plans?.all).toBeUndefined()
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_PLAN_TIMEOUT_MS)
it('shows all configured plans as json', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
expect(runCli(['plan', 'set', 'claude-max'], home).status).toBe(0)
expect(runCli(['plan', 'set', 'custom', '--monthly-usd', '200', '--provider', 'codex'], home).status).toBe(0)
const result = runCli(['plan', '--format', 'json'], home)
expect(result.status).toBe(0)
const payload = JSON.parse(result.stdout) as { id?: string; provider?: string; plans?: { claude?: { id?: string }; codex?: { id?: string } } }
expect(payload.id).toBe('claude-max')
expect(payload.provider).toBe('claude')
expect(payload.plans?.claude?.id).toBe('claude-max')
expect(payload.plans?.codex?.id).toBe('custom')
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_PLAN_TIMEOUT_MS)
it('filters shown plans by provider', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
expect(runCli(['plan', 'set', 'claude-max'], home).status).toBe(0)
expect(runCli(['plan', 'set', 'custom', '--monthly-usd', '200', '--provider', 'codex'], home).status).toBe(0)
const result = runCli(['plan', '--format', 'json', '--provider', 'codex'], home)
expect(result.status).toBe(0)
const payload = JSON.parse(result.stdout) as { id?: string; provider?: string; plans?: unknown }
expect(payload.id).toBe('custom')
expect(payload.provider).toBe('codex')
expect(payload.plans).toMatchObject({ codex: { id: 'custom' } })
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_PLAN_TIMEOUT_MS)
it('rejects all-provider scope for preset plans', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
const result = runCli(['plan', 'set', 'claude-max', '--provider', 'all'], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('omit --provider or use --provider claude')
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_PLAN_TIMEOUT_MS)
it('shows invalid reset-day value in error output', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
const result = runCli(['plan', 'set', 'claude-max', '--reset-day', '99'], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('--reset-day must be an integer from 1 to 28; got 99.')
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_PLAN_TIMEOUT_MS)
})
+85
View File
@@ -0,0 +1,85 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, it, expect } from 'vitest'
const CLI_TIMEOUT_MS = 10_000
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
HOME: home,
USERPROFILE: home,
HOMEPATH: home,
HOMEDRIVE: '',
},
encoding: 'utf-8',
})
}
function readConfig(home: string): Promise<Record<string, unknown>> {
return readFile(join(home, '.config', 'codeburn', 'config.json'), 'utf-8')
.then(raw => JSON.parse(raw) as Record<string, unknown>)
}
describe('codeburn price-override command', () => {
it('saves, lists, and removes a model price override', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-price-override-'))
try {
const set = runCli([
'price-override',
'unpriced-provider/test-model',
'--input',
'0.27',
'--output',
'1.10',
'--cache-read',
'0.03',
'--cache-creation',
'0.42',
], home)
expect(set.status).toBe(0)
expect(set.stdout).toContain('unpriced-provider/test-model')
expect(set.stdout).toContain('USD per 1,000,000 tokens')
const saved = await readConfig(home)
expect(saved.priceOverrides).toEqual({
'unpriced-provider/test-model': {
input: 0.27,
output: 1.1,
cacheRead: 0.03,
cacheCreation: 0.42,
},
})
const list = runCli(['price-override', '--list'], home)
expect(list.status).toBe(0)
expect(list.stdout).toContain('unpriced-provider/test-model')
expect(list.stdout).toContain('input 0.27')
expect(list.stdout).toContain('output 1.1')
const remove = runCli(['price-override', '--remove', 'unpriced-provider/test-model'], home)
expect(remove.status).toBe(0)
const after = await readConfig(home)
expect(after.priceOverrides).toBeUndefined()
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_TIMEOUT_MS)
it('rejects an invalid rate', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-price-override-'))
try {
const result = runCli(['price-override', 'bad-rate-model', '--input', 'abc', '--output', '1'], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('Invalid --input')
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_TIMEOUT_MS)
})
+72
View File
@@ -0,0 +1,72 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest'
import { allProviderNames, getAllProviders } from '../src/providers/index.js'
let homes: string[] = []
afterEach(async () => {
while (homes.length > 0) {
const h = homes.pop()
if (h) await rm(h, { recursive: true, force: true })
}
})
async function makeHome(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'codeburn-provider-cli-'))
homes.push(home)
return home
}
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' },
encoding: 'utf-8',
timeout: 30_000,
})
}
describe('allProviderNames', () => {
it('is non-empty, sorted, and excludes the "all" sentinel', () => {
const names = allProviderNames()
expect(names.length).toBeGreaterThan(0)
expect([...names]).toEqual([...names].sort())
expect(names).not.toContain('all')
})
it('covers every loadable provider (guards against lazy-list drift)', async () => {
const loaded = (await getAllProviders()).map(p => p.name)
const names = allProviderNames()
for (const name of loaded) {
expect(names).toContain(name)
}
})
})
describe('codeburn --provider validation', () => {
it('rejects an unknown provider with a clear error and exit 1', async () => {
const home = await makeHome()
const res = runCli(['report', '--provider', 'claud', '-p', 'today'], home)
expect(res.status).toBe(1)
expect(res.stderr).toContain('unknown provider "claud"')
expect(res.stderr).toContain('Valid values: all,')
})
it('accepts a valid provider', async () => {
const home = await makeHome()
const res = runCli(['status', '--provider', 'claude', '--format', 'json'], home)
expect(res.status).toBe(0)
expect(res.stderr).not.toContain('unknown provider')
})
it('accepts the "all" sentinel', async () => {
const home = await makeHome()
const res = runCli(['status', '--provider', 'all', '--format', 'json'], home)
expect(res.status).toBe(0)
})
})
+154
View File
@@ -0,0 +1,154 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { afterEach, describe, expect, it, vi } from 'vitest'
// Each test spawns `tsx src/cli.ts` several times, which re-transpiles the CLI
// on every spawn. Under full-suite parallel load those spawns contend for CPU
// and can exceed the 5s default, so give this spawn-based file a timeout that
// matches the per-spawn cap below. The slowest test runs ~1.7s in isolation.
vi.setConfig({ testTimeout: 30_000 })
let homes: string[] = []
afterEach(async () => {
while (homes.length > 0) {
const h = homes.pop()
if (h) await rm(h, { recursive: true, force: true })
}
})
async function makeHome(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'codeburn-proxy-cli-'))
homes.push(home)
return home
}
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' },
encoding: 'utf-8',
timeout: 30_000,
})
}
const configPath = (home: string) => join(home, '.config', 'codeburn', 'config.json')
async function readConfig(home: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(configPath(home), 'utf-8'))
}
async function writeConfig(home: string, obj: unknown): Promise<void> {
await mkdir(join(home, '.config', 'codeburn'), { recursive: true })
await writeFile(configPath(home), JSON.stringify(obj), 'utf-8')
}
describe('codeburn proxy-path CLI', () => {
it('adds, lists, dedupes, and removes a proxy path', async () => {
const home = await makeHome()
expect(runCli(['proxy-path', '--list'], home).stdout).toContain('No proxy paths configured')
const add = runCli(['proxy-path', '/work/copilot-repo'], home)
expect(add.status).toBe(0)
expect(add.stdout).toContain('Proxy path saved')
expect((await readConfig(home)).proxyPaths).toEqual(['/work/copilot-repo'])
// Trailing-slash variant is the same path -> deduped, not a second entry.
const dup = runCli(['proxy-path', '/work/copilot-repo/'], home)
expect(dup.stdout).toContain('already configured')
expect((await readConfig(home)).proxyPaths).toEqual(['/work/copilot-repo'])
expect(runCli(['proxy-path', '--list'], home).stdout).toContain('/work/copilot-repo')
const rm = runCli(['proxy-path', '--remove', '/work/copilot-repo'], home)
expect(rm.status).toBe(0)
expect((await readConfig(home)).proxyPaths).toBeUndefined()
})
it('rejects a relative path and the filesystem root', async () => {
const home = await makeHome()
const rel = runCli(['proxy-path', './rel'], home)
expect(rel.status).toBe(1)
expect(rel.stderr).toContain('absolute')
const root = runCli(['proxy-path', '/'], home)
expect(root.status).toBe(1)
})
it('errors (exit 1) when removing a path that is not configured', async () => {
const home = await makeHome()
const res = runCli(['proxy-path', '--remove', '/not/configured'], home)
expect(res.status).toBe(1)
expect(res.stderr).toContain('No proxy path found')
})
it('does not crash on a malformed config (proxyPaths as a non-array)', async () => {
const home = await makeHome()
await writeConfig(home, { proxyPaths: 42 })
const list = runCli(['proxy-path', '--list'], home)
expect(list.status).toBe(0)
expect(list.stderr).not.toMatch(/TypeError|is not iterable|is not a function/)
expect(list.stdout).toContain('No proxy paths configured')
const add = runCli(['proxy-path', '/work/repo'], home)
expect(add.status).toBe(0)
expect(add.stderr).not.toMatch(/TypeError/)
// The garbage was discarded; only the valid absolute path persists.
expect((await readConfig(home)).proxyPaths).toEqual(['/work/repo'])
})
it('does not crash on a malformed config (proxyPaths array with non-string entries)', async () => {
const home = await makeHome()
await writeConfig(home, { proxyPaths: [42, null, '/work/keep'] })
const add = runCli(['proxy-path', '/work/new'], home)
expect(add.status).toBe(0)
expect(add.stderr).not.toMatch(/TypeError|is not a function/)
expect((await readConfig(home)).proxyPaths).toEqual(['/work/keep', '/work/new'])
})
})
describe('codeburn report --format json: proxy overview', () => {
async function writeClaudeSession(home: string, cwd: string): Promise<void> {
const dir = join(home, '.claude', 'projects', 'proxied')
await mkdir(dir, { recursive: true })
const ts = new Date().toISOString()
const line = JSON.stringify({
type: 'assistant', sessionId: 's1', timestamp: ts, cwd,
message: {
id: 'm1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-6',
content: [{ type: 'text', text: 'hi' }],
usage: { input_tokens: 10000, output_tokens: 2000, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
},
})
await writeFile(join(dir, 's1.jsonl'), line + '\n', 'utf-8')
}
function overview(home: string): { cost: number; proxiedCost: number; netCost: number } {
const res = runCli(['report', '--period', 'all', '--format', 'json'], home)
expect(res.status).toBe(0)
return JSON.parse(res.stdout).overview
}
it('reports proxiedCost == cost and netCost == 0 under a proxy path, and netCost == cost without one', async () => {
const home = await makeHome()
const cwd = '/proj/proxiedrepo'
await writeClaudeSession(home, cwd)
// Baseline: no proxy config -> nothing attributed, net == cost.
const base = overview(home)
expect(base.cost).toBeGreaterThan(0)
expect(base.proxiedCost).toBe(0)
expect(base.netCost).toBeCloseTo(base.cost, 10)
// With the proxy path -> full cost preserved, fully subscription-covered.
await writeConfig(home, { proxyPaths: [cwd] })
const proxied = overview(home)
expect(proxied.cost).toBeCloseTo(base.cost, 10) // cost is never modified
expect(proxied.proxiedCost).toBeCloseTo(proxied.cost, 10)
expect(proxied.netCost).toBeCloseTo(0, 10)
expect(proxied.netCost).toBeCloseTo(proxied.cost - proxied.proxiedCost, 10)
})
})
+643
View File
@@ -0,0 +1,643 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter as pathDelimiter, join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
function runCli(args: string[], home: string, extraEnv: Record<string, string | undefined> = {}) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'),
HOME: home,
TZ: 'UTC',
...extraEnv,
},
encoding: 'utf-8',
timeout: 30_000,
})
}
function userLine(sessionId: string, timestamp: string): string {
return JSON.stringify({
type: 'user',
sessionId,
timestamp,
message: { role: 'user', content: 'do the thing' },
})
}
function assistantLine(sessionId: string, timestamp: string, messageId: string): string {
return JSON.stringify({
type: 'assistant',
sessionId,
timestamp,
message: {
id: messageId,
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [
{ type: 'text', text: 'done' },
{ type: 'tool_use', id: 'tu-1', name: 'Edit', input: { file_path: '/tmp/x', old_string: 'a', new_string: 'b' } },
],
usage: { input_tokens: 500, output_tokens: 50 },
},
})
}
describe('codeburn status --format menubar-json', () => {
it('returns valid MenubarPayload with expected top-level fields', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const h = now.getUTCHours()
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z')
const ts4 = new Date(base.getTime() + 180_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('s1', ts1),
assistantLine('s1', ts2, 'msg-1'),
userLine('s1', ts3),
assistantLine('s1', ts4, 'msg-2'),
].join('\n'),
)
const result = runCli([
'status',
'--format', 'menubar-json',
'--period', 'today',
'--provider', 'all',
'--no-optimize',
], home)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as Record<string, unknown>
expect(payload).toHaveProperty('generated')
expect(payload).toHaveProperty('current')
expect(payload).toHaveProperty('optimize')
expect(payload).toHaveProperty('history')
const current = payload['current'] as Record<string, unknown>
expect(current['cost']).toBeGreaterThan(0)
expect(current['calls']).toBe(2)
expect(current['sessions']).toBe(1)
expect(current).toHaveProperty('oneShotRate')
expect(current).toHaveProperty('topActivities')
expect(current).toHaveProperty('topModels')
expect(current).toHaveProperty('providers')
const history = payload['history'] as { daily: unknown[] }
expect(Array.isArray(history.daily)).toBe(true)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('filters menubar payloads to a selected review day with --day', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-day-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('before', '2026-04-09T23:58:00Z'),
assistantLine('before', '2026-04-09T23:59:00Z', 'msg-before'),
userLine('selected', '2026-04-10T11:59:00Z'),
assistantLine('selected', '2026-04-10T12:00:00Z', 'msg-selected'),
userLine('after', '2026-04-11T00:00:00Z'),
assistantLine('after', '2026-04-11T00:01:00Z', 'msg-after'),
].join('\n'),
)
const result = runCli([
'status',
'--format', 'menubar-json',
'--day', '2026-04-10',
'--provider', 'all',
'--no-optimize',
], home)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: {
label: string
calls: number
sessions: number
topProjects: Array<{ sessions: number; sessionDetails: Array<{ date: string }> }>
}
}
expect(payload.current.label).toBe('Day (2026-04-10)')
expect(payload.current.calls).toBe(1)
expect(payload.current.sessions).toBe(1)
expect(payload.current.topProjects).toHaveLength(1)
expect(payload.current.topProjects[0]?.sessions).toBe(1)
expect(payload.current.topProjects[0]?.sessionDetails.map(s => s.date)).toEqual(['2026-04-10'])
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('filters the whole menubar payload to a selected Claude config source', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-claude-config-'))
try {
const work = join(home, 'claude-work')
const personal = join(home, 'claude-personal')
const slug = 'shared-app'
await mkdir(join(work, 'projects', slug), { recursive: true })
await mkdir(join(personal, 'projects', slug), { recursive: true })
await writeFile(
join(work, 'projects', slug, 'work.jsonl'),
[
userLine('work', '2026-04-10T11:59:00Z'),
assistantLine('work', '2026-04-10T12:00:00Z', 'msg-work'),
].join('\n'),
)
await writeFile(
join(personal, 'projects', slug, 'personal.jsonl'),
[
userLine('personal', '2026-04-10T12:59:00Z'),
assistantLine('personal', '2026-04-10T13:00:00Z', 'msg-personal'),
].join('\n'),
)
const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter) }
const allResult = runCli([
'status',
'--format', 'menubar-json',
'--period', 'all',
'--provider', 'all',
'--no-optimize',
], home, env)
expect(allResult.status, `stderr: ${allResult.stderr}`).toBe(0)
const allPayload = JSON.parse(allResult.stdout) as {
current: { calls: number; sessions: number }
claudeConfigs?: { selectedId: string | null; options: Array<{ id: string; label: string; path: string }> }
}
expect(allPayload.current.calls).toBe(2)
expect(allPayload.current.sessions).toBe(2)
expect(allPayload.claudeConfigs?.selectedId).toBeNull()
expect(allPayload.claudeConfigs?.options.map(o => o.label).sort()).toEqual(['claude-personal', 'claude-work'])
const workSourceId = allPayload.claudeConfigs!.options.find(o => o.label === 'claude-work')!.id
const workResult = runCli([
'status',
'--format', 'menubar-json',
'--period', 'all',
'--provider', 'all',
'--claude-config-source', workSourceId,
'--no-optimize',
], home, env)
expect(workResult.status, `stderr: ${workResult.stderr}`).toBe(0)
const workPayload = JSON.parse(workResult.stdout) as {
current: { calls: number; sessions: number; providers: Record<string, number> }
history: { daily: Array<{ calls: number }> }
claudeConfigs?: { selectedId: string | null }
}
expect(workPayload.claudeConfigs?.selectedId).toBe(workSourceId)
expect(workPayload.current.calls).toBe(1)
expect(workPayload.current.sessions).toBe(1)
expect(Object.keys(workPayload.current.providers)).toEqual(['claude'])
expect(workPayload.history.daily.reduce((sum, d) => sum + d.calls, 0)).toBe(1)
const invalidResult = runCli([
'status',
'--format', 'menubar-json',
'--period', 'all',
'--provider', 'all',
'--claude-config-source', 'claude-config:missing',
'--no-optimize',
], home, env)
expect(invalidResult.status, `stderr: ${invalidResult.stderr}`).toBe(0)
const invalidPayload = JSON.parse(invalidResult.stdout) as {
current: { calls: number }
claudeConfigs?: { selectedId: string | null }
}
expect(invalidPayload.current.calls).toBe(2)
expect(invalidPayload.claudeConfigs?.selectedId).toBeNull()
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('keeps idle Claude config options visible for the selected period', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-claude-config-idle-'))
try {
const work = join(home, 'claude-work')
const personal = join(home, 'claude-personal')
await mkdir(join(work, 'projects', 'app'), { recursive: true })
await mkdir(join(personal, 'projects', 'app'), { recursive: true })
await writeFile(
join(work, 'projects', 'app', 'work.jsonl'),
[
userLine('work', '2026-04-10T11:59:00Z'),
assistantLine('work', '2026-04-10T12:00:00Z', 'msg-work'),
].join('\n'),
)
await writeFile(
join(personal, 'projects', 'app', 'personal.jsonl'),
[
userLine('personal', '2026-04-09T11:59:00Z'),
assistantLine('personal', '2026-04-09T12:00:00Z', 'msg-personal'),
].join('\n'),
)
const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter) }
const allResult = runCli([
'status',
'--format', 'menubar-json',
'--day', '2026-04-10',
'--provider', 'all',
'--no-optimize',
], home, env)
expect(allResult.status, `stderr: ${allResult.stderr}`).toBe(0)
const allPayload = JSON.parse(allResult.stdout) as {
current: { calls: number }
claudeConfigs?: { options: Array<{ id: string; label: string }> }
}
expect(allPayload.current.calls).toBe(1)
expect(allPayload.claudeConfigs?.options.map(o => o.label).sort()).toEqual(['claude-personal', 'claude-work'])
const personalSourceId = allPayload.claudeConfigs!.options.find(o => o.label === 'claude-personal')!.id
const personalResult = runCli([
'status',
'--format', 'menubar-json',
'--day', '2026-04-10',
'--provider', 'all',
'--claude-config-source', personalSourceId,
'--no-optimize',
], home, env)
expect(personalResult.status, `stderr: ${personalResult.stderr}`).toBe(0)
const personalPayload = JSON.parse(personalResult.stdout) as {
current: { calls: number; sessions: number; providers: Record<string, number> }
claudeConfigs?: { selectedId: string | null }
}
expect(personalPayload.claudeConfigs?.selectedId).toBe(personalSourceId)
expect(personalPayload.current.calls).toBe(0)
expect(personalPayload.current.sessions).toBe(0)
expect(personalPayload.current.providers).toEqual({ claude: 0 })
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('rejects --claude-config-source combined with a non-Claude --provider', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-cfg-provider-'))
try {
const work = join(home, 'claude-work')
const personal = join(home, 'claude-personal')
await mkdir(join(work, 'projects', 'app'), { recursive: true })
await mkdir(join(personal, 'projects', 'app'), { recursive: true })
const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter) }
const result = runCli([
'status', '--format', 'menubar-json', '--period', 'all',
'--provider', 'codex', '--claude-config-source', 'claude-config:whatever',
'--no-optimize',
], home, env)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('--claude-config-source cannot be combined with --provider codex')
// 'claude' is allowed (a config scopes Claude usage).
const okClaude = runCli([
'status', '--format', 'menubar-json', '--period', 'all',
'--provider', 'claude', '--no-optimize',
], home, env)
expect(okClaude.status, `stderr: ${okClaude.stderr}`).toBe(0)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('surfaces Claude Desktop sessions as their own bucket so config sum equals All', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-desktop-'))
try {
const work = join(home, 'claude-work')
const personal = join(home, 'claude-personal')
await mkdir(join(work, 'projects', 'app'), { recursive: true })
await mkdir(join(personal, 'projects', 'app'), { recursive: true })
await writeFile(join(work, 'projects', 'app', 'w.jsonl'),
[userLine('w', '2026-04-10T11:59:00Z'), assistantLine('w', '2026-04-10T12:00:00Z', 'mw')].join('\n'))
await writeFile(join(personal, 'projects', 'app', 'p.jsonl'),
[userLine('p', '2026-04-10T12:59:00Z'), assistantLine('p', '2026-04-10T13:00:00Z', 'mp')].join('\n'))
// A fake Claude Desktop sessions tree.
const desktop = join(home, 'desktop-sessions')
const dProj = join(desktop, 'appid', 'ws', 'local_s1', '.claude', 'projects', 'space')
await mkdir(dProj, { recursive: true })
await writeFile(join(dProj, 'd.jsonl'),
[userLine('d', '2026-04-10T13:59:00Z'), assistantLine('d', '2026-04-10T14:00:00Z', 'md')].join('\n'))
const env = {
CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter),
CODEBURN_DESKTOP_SESSIONS_DIR: desktop,
}
const result = runCli([
'status', '--format', 'menubar-json', '--period', 'all', '--provider', 'all', '--no-optimize',
], home, env)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: { calls: number }
claudeConfigs?: { options: Array<{ id: string; label: string }> }
}
// 3 sessions total: work + personal + desktop.
expect(payload.current.calls).toBe(3)
// The selector lists all three, including a Claude Desktop bucket.
const labels = payload.claudeConfigs?.options.map(o => o.label).sort()
expect(labels).toContain('Claude Desktop')
expect(labels).toHaveLength(3)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('includes LingTai TUI usage and activity categories in menubar payloads', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-lingtai-'))
try {
const lingtaiHome = join(home, '.lingtai')
const agentDir = join(lingtaiHome, 'agent')
await mkdir(join(agentDir, 'logs'), { recursive: true })
await writeFile(join(agentDir, '.agent.json'), JSON.stringify({
agent_id: 'agent-1',
agent_name: 'LingTai Agent',
llm: { model: 'gpt-4o' },
}))
const now = new Date()
const h = now.getUTCHours()
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(agentDir, 'logs', 'token_ledger.jsonl'),
[
JSON.stringify({ source: 'main', ts: ts1, input: 1000, cached: 100, output: 100, model: 'gpt-4o' }),
JSON.stringify({ source: 'tc_wake', ts: ts2, input: 2000, cached: 500, output: 200, model: 'gpt-4o' }),
JSON.stringify({ source: 'summarize_apriori', ts: ts3, input: 1500, cached: 300, output: 150, model: 'gpt-4o' }),
].join('\n') + '\n',
)
const result = runCli([
'status',
'--format', 'menubar-json',
'--period', 'today',
'--provider', 'lingtai-tui',
'--no-optimize',
], home, {
LINGTAI_HOME: lingtaiHome,
LINGTAI_TUI_GLOBAL_DIR: join(home, '.lingtai-tui'),
})
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: {
cost: number
calls: number
providers: Record<string, number>
topActivities: Array<{ name: string; turns: number }>
}
}
expect(payload.current.cost).toBeGreaterThan(0)
expect(payload.current.calls).toBe(3)
expect(payload.current.providers['lingtai tui']).toBeGreaterThan(0)
expect(payload.current.topActivities.map(a => a.name).sort()).toEqual([
'Conversation',
'Delegation',
'Planning',
])
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('attaches combined local-only usage for --scope combined and omits it for local scope', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const h = now.getUTCHours()
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('s1', ts1),
assistantLine('s1', ts2, 'msg-1'),
].join('\n'),
)
const combinedResult = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'combined',
'--period', 'today',
'--provider', 'all',
'--no-optimize',
], home)
expect(combinedResult.status, `stderr: ${combinedResult.stderr}`).toBe(0)
const payload = JSON.parse(combinedResult.stdout) as {
current: {
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
}
history: {
daily: Array<{ cacheWriteTokens?: number; cacheReadTokens?: number }>
}
combined?: {
perDevice: Array<{
id: string
local: boolean
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheCreateTokens: number
cacheReadTokens: number
totalTokens: number
}>
combined: {
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheCreateTokens: number
cacheReadTokens: number
totalTokens: number
deviceCount: number
reachableCount: number
}
}
}
expect(payload.combined).toBeDefined()
expect(payload.combined!.perDevice).toHaveLength(1)
const local = payload.combined!.perDevice[0]!
const cacheCreateTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheWriteTokens ?? 0), 0)
const cacheReadTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheReadTokens ?? 0), 0)
const totalTokens = payload.current.inputTokens + payload.current.outputTokens + cacheCreateTokens + cacheReadTokens
expect(local).toMatchObject({
id: 'local',
local: true,
cost: payload.current.cost,
calls: payload.current.calls,
sessions: payload.current.sessions,
inputTokens: payload.current.inputTokens,
outputTokens: payload.current.outputTokens,
cacheCreateTokens,
cacheReadTokens,
totalTokens,
})
expect(payload.combined!.combined).toEqual({
cost: payload.current.cost,
calls: payload.current.calls,
sessions: payload.current.sessions,
inputTokens: payload.current.inputTokens,
outputTokens: payload.current.outputTokens,
cacheCreateTokens,
cacheReadTokens,
totalTokens,
deviceCount: 1,
reachableCount: 1,
})
const localResult = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'local',
'--period', 'today',
'--provider', 'all',
'--no-optimize',
], home)
expect(localResult.status, `stderr: ${localResult.stderr}`).toBe(0)
expect(JSON.parse(localResult.stdout)).not.toHaveProperty('combined')
} finally {
await rm(home, { recursive: true, force: true })
}
})
it.each([
['--provider', ['--provider', 'claude']],
['--project', ['--project', 'x']],
['--exclude', ['--exclude', 'y']],
])('rejects combined scope with filtered local payloads from %s', async (_name, filterArgs) => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-filter-'))
try {
const result = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'combined',
...filterArgs,
'--no-optimize',
], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('error: --scope combined cannot be combined with --provider, --project, or --exclude (paired devices report unfiltered usage)')
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('rejects invalid menubar-json scope values', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-scope-'))
try {
const result = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'remote',
'--no-optimize',
], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('unknown scope "remote"')
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('still emits a valid combined menubar payload when the remotes store is corrupt', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-corrupt-remotes-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const h = now.getUTCHours()
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(join(projectDir, 'session.jsonl'), [userLine('s1', ts1), assistantLine('s1', ts2, 'msg-1')].join('\n'))
// Corrupt the remotes store the combined path reads. The menubar must
// still get a valid payload (combined degrades to local-only).
const sharingDir = join(home, '.config', 'codeburn', 'sharing')
await mkdir(sharingDir, { recursive: true })
await writeFile(join(sharingDir, 'remote-devices.json'), '{ this is : not valid json ]')
const result = runCli([
'status', '--format', 'menubar-json', '--scope', 'combined',
'--period', 'today', '--no-optimize',
], home)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: { cost: number }
combined?: { perDevice: unknown[]; combined: { deviceCount: number; reachableCount: number } }
}
expect(payload.combined).toBeDefined()
expect(payload.combined!.perDevice).toHaveLength(1)
expect(payload.combined!.combined.deviceCount).toBe(1)
expect(payload.combined!.combined.reachableCount).toBe(1)
} finally {
await rm(home, { recursive: true, force: true })
}
})
})
+108
View File
@@ -0,0 +1,108 @@
// Regression for the codex stale-cache path (#478 follow-up, same class as
// the kiro bug #618/#619). session-cache.json serves unchanged session files
// without invoking the provider parser, so bumping CODEX_CACHE_VERSION alone
// does NOT re-attribute already-cached sessions. Registering `codex` in
// PROVIDER_PARSE_VERSIONS changes the provider envFingerprint, which discards
// the stale section and forces a re-parse. This exercises the full
// parseAllSessions pipeline against a cache seeded with the PRE-fix
// fingerprint and asserts the mcp-cli MCP attribution is recovered.
import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'
import { mkdir, rm, readFile, writeFile } from 'fs/promises'
import { createHash } from 'crypto'
import { join } from 'path'
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
const testRoot = vi.hoisted(() => {
const root = `${process.env['TMPDIR'] || '/tmp'}/codex-stale-repro-${process.pid}-${Date.now()}`
process.env['HOME'] = `${root}/home`
process.env['USERPROFILE'] = `${root}/home`
process.env['CODEX_HOME'] = `${root}/codex`
return root
})
const CODEX_HOME = join(testRoot, 'codex')
const CACHE_DIR = join(testRoot, 'cache')
// computeEnvFingerprint('codex') as staged (no PROVIDER_PARSE_VERSIONS entry):
// hash of just CODEX_HOME. This is what sits in every existing user cache.
function preFixFingerprint(): string {
return createHash('sha256').update(`CODEX_HOME=${CODEX_HOME}`).digest('hex').slice(0, 16)
}
beforeEach(() => {
process.env['HOME'] = join(testRoot, 'home')
process.env['USERPROFILE'] = join(testRoot, 'home')
process.env['CODEX_HOME'] = CODEX_HOME
process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR
})
afterAll(async () => {
await rm(testRoot, { recursive: true, force: true })
})
function allMcpServers(projects: Awaited<ReturnType<typeof parseAllSessions>>): string[] {
const servers: string[] = []
for (const p of projects) {
for (const s of p.sessions) {
servers.push(...Object.keys(s.mcpBreakdown))
}
}
return servers
}
describe('codex parser change invalidates stale session-cache (#478/#513)', () => {
it('re-parses unchanged codex files after a parser attribution change', async () => {
const sessionDir = join(CODEX_HOME, 'sessions', '2026', '04', '14')
await mkdir(sessionDir, { recursive: true })
await mkdir(CACHE_DIR, { recursive: true })
const lines = [
JSON.stringify({ type: 'session_meta', timestamp: '2026-04-14T10:00:00Z', payload: { session_id: 'sess-stale', model: 'gpt-5.5', cwd: '/Users/test/proj', originator: 'codex_cli_rs' } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'call mcp via cli' }] } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:30Z', payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command: "bash -lc \"mcp-cli call github get_issue '{}'\"" }) } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:01:00Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 300, output_tokens: 100 }, total_token_usage: { total_tokens: 400 } } } }),
]
await writeFile(join(sessionDir, 'rollout-stale.jsonl'), lines.join('\n') + '\n')
// Run 1: cold cache, fixed parser. MCP attribution present (sanity).
clearSessionCache()
const fresh = await parseAllSessions(undefined, 'codex')
expect(allMcpServers(fresh)).toContain('github')
// Simulate a user whose session-cache.json was written by the PRE-fix
// release: pre-fix envFingerprint, unchanged file fingerprint, cached
// turns lack the mcp__ tool. Also reset codex-results.json to v4 so the
// provider (if it runs at all) must genuinely re-parse.
const cachePath = join(CACHE_DIR, 'session-cache.json')
const cache = JSON.parse(await readFile(cachePath, 'utf8'))
cache.providers.codex.envFingerprint = preFixFingerprint()
for (const f of Object.values(cache.providers.codex.files) as any[]) {
for (const turn of f.turns) {
for (const call of turn.calls) {
call.tools = call.tools.filter((t: string) => !t.startsWith('mcp__'))
if (call.toolSequence) {
call.toolSequence = call.toolSequence.filter((step: any[]) => !step.some(c => c.tool.startsWith('mcp__')))
}
}
}
}
await writeFile(cachePath, JSON.stringify(cache))
const codexCachePath = join(CACHE_DIR, 'codex-results.json')
const codexCache = JSON.parse(await readFile(codexCachePath, 'utf8'))
codexCache.version = 4
for (const f of Object.values(codexCache.files) as any[]) {
for (const call of f.calls ?? []) {
call.tools = (call.tools ?? []).filter((t: string) => !t.startsWith('mcp__'))
}
}
await writeFile(codexCachePath, JSON.stringify(codexCache))
clearSessionCache()
const second = await parseAllSessions(undefined, 'codex')
// FIXED: `codex` is now in PROVIDER_PARSE_VERSIONS, so the pre-fix
// envFingerprint no longer matches, the stale section is discarded, the
// unchanged file re-parses, and the mcp-cli attribution reappears.
expect(allMcpServers(second)).toContain('github')
})
})
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { codexCredits, codexCreditRate } from '../src/codex-credits.js'
describe('codexCreditRate', () => {
it('resolves the documented per-model rates', () => {
expect(codexCreditRate('gpt-5.5')).toEqual({ input: 125, cachedInput: 12.5, output: 750 })
expect(codexCreditRate('gpt-5.4')).toEqual({ input: 62.5, cachedInput: 6.25, output: 375 })
expect(codexCreditRate('gpt-5.4-mini')).toEqual({ input: 18.75, cachedInput: 1.875, output: 113 })
})
it('tolerates codex suffix variants and casing', () => {
expect(codexCreditRate('GPT-5.5-codex')?.input).toBe(125)
expect(codexCreditRate('gpt-5.4-codex-mini')?.input).toBe(18.75)
})
it('returns null for models with no known credit rate', () => {
expect(codexCreditRate('gpt-4o')).toBeNull()
expect(codexCreditRate('claude-opus-4-8')).toBeNull()
})
})
describe('codexCredits', () => {
it('charges 1M input tokens at the input rate', () => {
expect(codexCredits('gpt-5.5', { inputTokens: 1_000_000, cachedReadTokens: 0, outputTokens: 0 })).toBe(125)
})
it('charges 1M output tokens at the output rate', () => {
expect(codexCredits('gpt-5.5', { inputTokens: 0, cachedReadTokens: 0, outputTokens: 1_000_000 })).toBe(750)
})
it('charges cache-read tokens at the cheaper cached rate', () => {
expect(codexCredits('gpt-5.5', { inputTokens: 0, cachedReadTokens: 1_000_000, outputTokens: 0 })).toBe(12.5)
})
it('folds reasoning tokens into the output rate', () => {
// 500k output + 500k reasoning = 1M output-billed => 750 credits.
expect(codexCredits('gpt-5.5', { inputTokens: 0, cachedReadTokens: 0, outputTokens: 500_000, reasoningTokens: 500_000 })).toBe(750)
})
it('sums a mixed record (gpt-5.4)', () => {
// 2M input (125) + 1M cached (6.25) + 0.5M output (187.5) = 318.75
const credits = codexCredits('gpt-5.4', { inputTokens: 2_000_000, cachedReadTokens: 1_000_000, outputTokens: 500_000 })
expect(credits).toBeCloseTo(125 + 6.25 + 187.5, 6)
})
it('clamps negative / non-finite token counts to 0', () => {
expect(codexCredits('gpt-5.5', { inputTokens: -100, cachedReadTokens: NaN, outputTokens: 1_000_000 })).toBe(750)
})
it('returns null for an unknown model', () => {
expect(codexCredits('gpt-4o', { inputTokens: 1_000_000, cachedReadTokens: 0, outputTokens: 0 })).toBeNull()
})
})
+570
View File
@@ -0,0 +1,570 @@
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, computeComparison, computeCategoryComparison, computeWorkingStyle, 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')!
const totalA = 5000 + 30000 + 5000
const totalB = 10000 + 10000 + 5000
expect(cacheRow.valueA).toBeCloseTo(30000 / totalA * 100)
expect(cacheRow.valueB).toBeCloseTo(10000 / totalB * 100)
expect(cacheRow.winner).toBe('a')
})
})
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)
})
})
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { normalizeContentBlocks } from '../src/content-utils.js'
describe('normalizeContentBlocks', () => {
it('passes an array of blocks through unchanged', () => {
const blocks = [{ type: 'text', text: 'hi' }, { type: 'tool_use', text: '' }]
expect(normalizeContentBlocks(blocks)).toBe(blocks)
})
it('wraps a string as a single text block (the issue #441 case)', () => {
expect(normalizeContentBlocks('hello world')).toEqual([{ type: 'text', text: 'hello world' }])
})
it('returns an empty array for null / undefined', () => {
expect(normalizeContentBlocks(null)).toEqual([])
expect(normalizeContentBlocks(undefined)).toEqual([])
})
it('returns an empty array for other non-array values', () => {
// Defensive against corrupt records: a number/object content must not throw downstream.
expect(normalizeContentBlocks(42 as unknown as string)).toEqual([])
expect(normalizeContentBlocks({ type: 'text' } as unknown as string)).toEqual([])
})
it('drops null/undefined elements inside an array (avoids the same crash one level down)', () => {
const dirty = [{ type: 'text', text: 'ok' }, null, undefined, { type: 'tool_use' }] as unknown as Array<{ type?: string }>
const out = normalizeContentBlocks(dirty)
expect(out).toEqual([{ type: 'text', text: 'ok' }, { type: 'tool_use' }])
expect(() => out.filter(b => b.type === 'text')).not.toThrow()
})
it('returns the same reference for a clean array (no copy)', () => {
const clean = [{ type: 'text', text: 'a' }, { type: 'tool_use' }]
expect(normalizeContentBlocks(clean)).toBe(clean)
})
it('the result is always safe to .filter/.some over', () => {
const inputs = ['a string', null, undefined, [{ type: 'text' }], [{ type: 'text' }, null]] as const
for (const input of inputs) {
expect(() => normalizeContentBlocks(input as never).filter(b => b.type === 'text')).not.toThrow()
}
})
})
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { convertCost, roundForActiveCurrency, getFractionDigits } from '../src/currency.js'
import { CurrencyState } from '../src/currency.js'
import * as currencyMod from '../src/currency.js'
// We poke the module-level state directly via switchCurrency for these tests.
// Each test restores USD afterwards so it doesn't bleed.
async function setActive(code: string, rate: number): Promise<void> {
// switchCurrency does network + persistence; for unit tests we set the
// active state directly via the module's internal state. Since the module
// doesn't expose a setter, we go through getCurrency()'s state and patch.
// Instead use the public switchCurrency only when offline: nope, just
// exploit the fact that the module exports `getCurrency` which returns a
// ref. We can't easily mock fetch. So we test only convertCost (which uses
// active.rate) and rounding helpers — both pure functions of the state.
const state = currencyMod.getCurrency()
// @ts-expect-error — directly mutating for test
state.code = code
// @ts-expect-error
state.rate = rate
// @ts-expect-error
state.symbol = code
}
beforeEach(async () => {
await setActive('USD', 1)
})
afterEach(async () => {
await setActive('USD', 1)
})
describe('convertCost — no rounding contract', () => {
it('returns unrounded float for USD (rate=1)', () => {
expect(convertCost(1.234567)).toBe(1.234567)
expect(convertCost(0.001)).toBe(0.001)
})
it('returns unrounded float for non-USD currencies', async () => {
await setActive('JPY', 150)
// 1 USD * 150 = 150, but a fractional input must NOT be rounded by convertCost.
expect(convertCost(0.123456)).toBeCloseTo(18.5184, 4)
expect(convertCost(1.5)).toBe(225)
})
it('rounding is the caller\'s responsibility (display vs export)', async () => {
// Regression guard: previously convertCost did its own rounding which
// produced ¥412.37 in CSV exports while the dashboard rendered ¥412.
// Confirm we now return the raw value and the caller decides.
await setActive('JPY', 150)
const raw = convertCost(2.7491)
expect(raw).toBe(412.365) // unrounded
expect(roundForActiveCurrency(raw)).toBe(412) // currency-aware rounding for export
})
})
describe('roundForActiveCurrency', () => {
it('USD rounds to 2 decimals', async () => {
await setActive('USD', 1)
expect(roundForActiveCurrency(1.2345)).toBe(1.23)
expect(roundForActiveCurrency(1.235)).toBeCloseTo(1.24, 2)
expect(roundForActiveCurrency(0.005)).toBe(0.01)
})
it('JPY rounds to whole numbers', async () => {
await setActive('JPY', 150)
expect(roundForActiveCurrency(412.37)).toBe(412)
expect(roundForActiveCurrency(412.5)).toBe(413)
expect(roundForActiveCurrency(0.4)).toBe(0)
})
it('KRW rounds to whole numbers', async () => {
await setActive('KRW', 1300)
expect(roundForActiveCurrency(15999.7)).toBe(16000)
})
it('EUR rounds to 2 decimals like USD', async () => {
await setActive('EUR', 0.92)
expect(roundForActiveCurrency(1.2345)).toBe(1.23)
})
it('matches the display contract: roundForActiveCurrency(convertCost(x)) is what users see', async () => {
await setActive('JPY', 150)
// Dashboard displays via formatCost which uses getFractionDigits=0 for JPY.
// CSV exports must produce the same integer value, not a 2-decimal float.
expect(roundForActiveCurrency(convertCost(2.75))).toBe(413)
expect(roundForActiveCurrency(convertCost(2.745))).toBe(412)
})
})
describe('getFractionDigits', () => {
it('returns 0 for zero-fraction currencies', () => {
expect(getFractionDigits('JPY')).toBe(0)
expect(getFractionDigits('KRW')).toBe(0)
expect(getFractionDigits('CLP')).toBe(0)
})
it('returns 2 for typical currencies', () => {
expect(getFractionDigits('USD')).toBe(2)
expect(getFractionDigits('EUR')).toBe(2)
expect(getFractionDigits('GBP')).toBe(2)
expect(getFractionDigits('INR')).toBe(2)
expect(getFractionDigits('CNY')).toBe(2)
expect(getFractionDigits('RON')).toBe(2)
})
})
+378
View File
@@ -0,0 +1,378 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readFile, rm } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import type { ProjectSummary } from '../src/types.js'
import {
addNewDays,
DAILY_CACHE_VERSION,
type DailyCache,
type DailyEntry,
getDaysInRange,
ensureCacheHydrated,
loadDailyCache,
saveDailyCache,
withDailyCacheLock,
} from '../src/daily-cache.js'
function emptyDay(date: string, cost = 0, calls = 0): DailyEntry {
return {
date,
cost,
savingsUSD: 0,
calls,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 0,
oneShotTurns: 0,
models: {},
categories: {},
providers: {},
}
}
const TMP_CACHE_ROOT = join(tmpdir(), `codeburn-cache-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
beforeEach(() => {
process.env['CODEBURN_CACHE_DIR'] = TMP_CACHE_ROOT
})
afterEach(async () => {
vi.useRealTimers()
if (existsSync(TMP_CACHE_ROOT)) {
await rm(TMP_CACHE_ROOT, { recursive: true, force: true })
}
})
describe('loadDailyCache', () => {
it('returns an empty cache when the file does not exist', async () => {
const cache = await loadDailyCache()
expect(cache.version).toBe(DAILY_CACHE_VERSION)
expect(cache.lastComputedDate).toBeNull()
expect(cache.days).toEqual([])
})
it('returns an empty cache when the file contains invalid JSON', async () => {
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), 'not valid json{{', 'utf-8')
const cache = await loadDailyCache()
expect(cache.days).toEqual([])
})
it('returns an empty cache and backs up when version is too old to migrate', async () => {
const saved = {
version: 1,
lastComputedDate: '2026-04-10',
days: [{ date: '2026-04-10', cost: 10, calls: 5 }],
}
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8')
const cache = await loadDailyCache()
expect(cache.days).toEqual([])
expect(cache.lastComputedDate).toBeNull()
expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v1.bak'))).toBe(true)
})
it('discards a v2 cache and starts fresh (provider rollups would be stale)', async () => {
// MIN_SUPPORTED_VERSION was raised to DAILY_CACHE_VERSION because the
// migration path cannot recompute the providers / categories / models
// rollups from session data (the cache does not retain raw sessions),
// so a migrated old cache would carry forward stale provider totals
// for the full retention window. Older caches now get discarded and
// recomputed from scratch on next run.
const saved = {
version: 2,
lastComputedDate: '2026-04-10',
days: [{
date: '2026-04-10', cost: 10, calls: 5, sessions: 2,
inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, cacheWriteTokens: 100,
models: { 'claude-opus-4-6': { calls: 5, cost: 10, inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, cacheWriteTokens: 100 } },
}],
}
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8')
const cache = await loadDailyCache()
expect(cache.version).toBe(DAILY_CACHE_VERSION)
expect(cache.days).toEqual([])
expect(cache.lastComputedDate).toBeNull()
// Old cache is renamed to .v2.bak rather than deleted.
expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v2.bak'))).toBe(true)
})
it('discards a v5 cache because cached Claude costs predate 1-hour cache pricing', async () => {
const saved = {
version: 5,
lastComputedDate: '2026-05-01',
days: [{
date: '2026-05-01',
cost: 0.37575,
calls: 1,
sessions: 1,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 60_120,
editTurns: 0,
oneShotTurns: 0,
models: { 'Opus 4.7': { calls: 1, cost: 0.37575, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 60_120 } },
categories: {},
providers: { claude: { calls: 1, cost: 0.37575 } },
}],
}
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8')
const cache = await loadDailyCache()
expect(cache.version).toBe(DAILY_CACHE_VERSION)
expect(cache.days).toEqual([])
expect(cache.lastComputedDate).toBeNull()
expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v5.bak'))).toBe(true)
})
it('round-trips a valid cache through save and load', async () => {
const saved: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'cfg-hash-1',
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-09', 12.5, 40), emptyDay('2026-04-10', 7.25, 28)],
}
await saveDailyCache(saved)
const loaded = await loadDailyCache()
expect(loaded).toEqual(saved)
})
})
describe('saveDailyCache', () => {
it('writes atomically so no temp file is left after a successful save', async () => {
const saved: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'cfg-hash-1',
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-10', 5)],
}
await saveDailyCache(saved)
const { readdir } = await import('fs/promises')
const files = await readdir(TMP_CACHE_ROOT)
const tempLeftovers = files.filter(f => f.endsWith('.tmp'))
expect(tempLeftovers).toEqual([])
const finalFile = await readFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), 'utf-8')
expect(JSON.parse(finalFile)).toEqual(saved)
})
})
describe('addNewDays', () => {
it('returns a new cache with the added days sorted ascending by date', () => {
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-04-08',
days: [emptyDay('2026-04-07', 3), emptyDay('2026-04-08', 5)],
}
const updated = addNewDays(base, [emptyDay('2026-04-10', 9), emptyDay('2026-04-09', 7)], '2026-04-10')
expect(updated.days.map(d => d.date)).toEqual(['2026-04-07', '2026-04-08', '2026-04-09', '2026-04-10'])
expect(updated.lastComputedDate).toBe('2026-04-10')
})
it('replaces existing days with incoming data (last write wins)', () => {
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-04-08',
days: [emptyDay('2026-04-08', 5)],
}
const updated = addNewDays(base, [emptyDay('2026-04-08', 99)], '2026-04-08')
const aprilEight = updated.days.find(d => d.date === '2026-04-08')!
expect(aprilEight.cost).toBe(99)
})
it('does not regress lastComputedDate if incoming newestDate is older', () => {
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-10', 5)],
}
const updated = addNewDays(base, [emptyDay('2026-04-05', 3)], '2026-04-05')
expect(updated.lastComputedDate).toBe('2026-04-10')
})
it('skips prune when newestDate is malformed (does not silently drop all days)', () => {
// Regression guard: a corrupt newestDate string used to produce a NaN
// cutoff, which made `d.date >= "Invalid Date"` always false and
// wiped every cached day on the next merge. The guard now leaves
// the entries untouched so the next valid run can prune normally.
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-08', 1), emptyDay('2026-04-09', 2), emptyDay('2026-04-10', 3)],
}
const updated = addNewDays(base, [], 'not-a-date')
expect(updated.days.map(d => d.date)).toEqual(['2026-04-08', '2026-04-09', '2026-04-10'])
})
it('still prunes when newestDate is valid', () => {
const old = '2020-01-01'
const recent = '2026-04-10'
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: recent,
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.
expect(updated.days.find(d => d.date === old)).toBeUndefined()
expect(updated.days.find(d => d.date === recent)).toBeDefined()
})
})
describe('getDaysInRange', () => {
const cache: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-04-10',
days: [
emptyDay('2026-04-05', 1),
emptyDay('2026-04-06', 2),
emptyDay('2026-04-07', 3),
emptyDay('2026-04-08', 4),
emptyDay('2026-04-09', 5),
emptyDay('2026-04-10', 6),
],
}
it('returns inclusive start and end range', () => {
const days = getDaysInRange(cache, '2026-04-07', '2026-04-09')
expect(days.map(d => d.date)).toEqual(['2026-04-07', '2026-04-08', '2026-04-09'])
})
it('returns empty when range is entirely outside cache', () => {
expect(getDaysInRange(cache, '2026-03-01', '2026-03-10')).toEqual([])
expect(getDaysInRange(cache, '2026-05-01', '2026-05-10')).toEqual([])
})
it('clips to available cache days when range extends beyond', () => {
const days = getDaysInRange(cache, '2026-04-09', '2026-04-20')
expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
})
})
describe('ensureCacheHydrated', () => {
it('does not recompute yesterday after it has already been cached', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-12T12:00:00.000Z'))
const saved: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-06-11',
days: [emptyDay('2026-06-11', 5, 10)],
}
await saveDailyCache(saved)
let parseCalls = 0
const hydrated = await ensureCacheHydrated(
async () => {
parseCalls += 1
return []
},
() => [],
)
expect(parseCalls).toBe(0)
expect(hydrated).toEqual(saved)
})
it('drops a cached today/future entry so it is recomputed live, keeping yesterday cached', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-12T12:00:00.000Z'))
// A "today" entry can only exist via a backward clock change or a stale
// cache; it must be purged so today is served live, not from a frozen entry.
const saved: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-06-12',
days: [emptyDay('2026-06-11', 5, 10), emptyDay('2026-06-12', 9, 20)],
}
await saveDailyCache(saved)
let parseCalls = 0
const hydrated = await ensureCacheHydrated(
async () => {
parseCalls += 1
return []
},
() => [],
)
expect(parseCalls).toBe(0)
expect(hydrated.days.map(d => d.date)).toEqual(['2026-06-11'])
expect(hydrated.lastComputedDate).toBe('2026-06-11')
})
})
describe('withDailyCacheLock', () => {
it('serializes concurrent operations', async () => {
const sequence: string[] = []
const op = async (tag: string): Promise<void> => {
await withDailyCacheLock(async () => {
sequence.push(`start-${tag}`)
await new Promise(r => setTimeout(r, 20))
sequence.push(`end-${tag}`)
})
}
await Promise.all([op('a'), op('b'), op('c')])
for (let i = 0; i < sequence.length; i += 2) {
expect(sequence[i]?.startsWith('start-')).toBe(true)
expect(sequence[i + 1]?.startsWith('end-')).toBe(true)
expect(sequence[i]!.slice(6)).toBe(sequence[i + 1]!.slice(4))
}
})
})
describe('ensureCacheHydrated: savings config invalidation', () => {
it('discards cached days when the savingsConfigHash changes between calls', async () => {
// Seed a cache with a day OLDER than yesterday so the hydration window
// (which keeps `d.date < yesterdayStr`) actually retains it.
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000)
const twoDaysAgoStr = `${twoDaysAgo.getFullYear()}-${String(twoDaysAgo.getMonth() + 1).padStart(2, '0')}-${String(twoDaysAgo.getDate()).padStart(2, '0')}`
const seeded: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'cfg-A',
lastComputedDate: twoDaysAgoStr,
days: [emptyDay(twoDaysAgoStr, 1.5, 3)],
}
await saveDailyCache(seeded)
const parseSessions = async (): Promise<ProjectSummary[]> => []
const aggregateDays = (): DailyEntry[] => []
// Hash mismatch → ensureCacheHydrated must drop the stale day and start fresh.
const rehydrated = await ensureCacheHydrated(parseSessions, aggregateDays, 'cfg-B')
expect(rehydrated.savingsConfigHash).toBe('cfg-B')
expect(rehydrated.days).toEqual([])
// Same hash → cached days survive.
const seeded2: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'cfg-C',
lastComputedDate: twoDaysAgoStr,
days: [emptyDay(twoDaysAgoStr, 1.5, 3)],
}
await saveDailyCache(seeded2)
const preserved = await ensureCacheHydrated(parseSessions, aggregateDays, 'cfg-C')
expect(preserved.days).toHaveLength(1)
expect(preserved.days[0]!.date).toBe(twoDaysAgoStr)
})
})
+146
View File
@@ -0,0 +1,146 @@
import { homedir } from 'os'
import { describe, it, expect } from 'vitest'
import { shortProject } from '../src/dashboard.js'
import { formatCost } from '../src/format.js'
import type { ProjectSummary, SessionSummary } from '../src/types.js'
const EMPTY_CATEGORY_BREAKDOWN = {
coding: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
debugging: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
feature: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
refactoring: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
testing: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
exploration: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
planning: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
delegation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
git: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
'build/deploy': { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
conversation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
brainstorming: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
general: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
} satisfies SessionSummary['categoryBreakdown']
function makeSession(id: string, cost: number, timestamp = '2026-04-14T10:00:00Z'): SessionSummary {
return {
sessionId: id,
project: 'test-project',
firstTimestamp: timestamp,
lastTimestamp: timestamp,
totalCostUSD: cost,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: { ...EMPTY_CATEGORY_BREAKDOWN },
skillBreakdown: {},
}
}
function makeProject(name: string, sessions: SessionSummary[]): ProjectSummary {
return {
project: name,
projectPath: name,
sessions,
totalCostUSD: sessions.reduce((s, x) => s + x.totalCostUSD, 0),
totalApiCalls: sessions.reduce((s, x) => s + x.apiCalls, 0),
}
}
// Logic replicated from TopSessions component
function getTopSessions(projects: ProjectSummary[], n = 5) {
const all = projects.flatMap(p => p.sessions.map(s => ({ ...s, projectPath: p.projectPath })))
return [...all].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, n)
}
// Logic replicated from ProjectBreakdown component
function avgCostLabel(project: ProjectSummary): string {
return project.sessions.length > 0
? formatCost(project.totalCostUSD / project.sessions.length)
: '-'
}
describe('TopSessions - top-5 selection', () => {
it('returns all sessions when fewer than 5 exist', () => {
const project = makeProject('proj', [
makeSession('s1', 1.0),
makeSession('s2', 2.0),
])
const top = getTopSessions([project])
expect(top).toHaveLength(2)
expect(top[0].totalCostUSD).toBe(2.0)
expect(top[1].totalCostUSD).toBe(1.0)
})
it('returns exactly 5 when more than 5 sessions exist', () => {
const sessions = [0.1, 0.5, 3.0, 1.0, 0.8, 2.0].map((cost, i) =>
makeSession(`s${i}`, cost)
)
const project = makeProject('proj', sessions)
const top = getTopSessions([project])
expect(top).toHaveLength(5)
expect(top[0].totalCostUSD).toBe(3.0)
expect(top[4].totalCostUSD).toBe(0.5)
})
it('is stable on tied costs - preserves input order for equal values', () => {
const sessions = [
makeSession('s1', 1.0),
makeSession('s2', 1.0),
makeSession('s3', 1.0),
]
const project = makeProject('proj', sessions)
const top = getTopSessions([project])
expect(top.map(s => s.sessionId)).toEqual(['s1', 's2', 's3'])
})
})
describe('shortProject - path shortening', () => {
const home = homedir()
it('preserves directory names containing dashes', () => {
expect(shortProject(`${home}/work/my-project`)).toBe('work/my-project')
})
it('preserves directory names containing dots', () => {
expect(shortProject(`${home}/work/my.app.io`)).toBe('work/my.app.io')
})
it('returns "home" for the home dir itself', () => {
expect(shortProject(home)).toBe('home')
})
it('does not strip a sibling whose name shares the home prefix', () => {
const sibling = `${home}-backup/proj`
expect(shortProject(sibling).endsWith('proj')).toBe(true)
expect(shortProject(sibling)).not.toMatch(/^-/)
})
it('keeps only the last 3 segments for deeply nested paths', () => {
expect(shortProject(`${home}/a/b/c/d/e/f`)).toBe('d/e/f')
})
it('handles paths outside the home dir', () => {
expect(shortProject('/opt/myproject')).toBe('opt/myproject')
})
})
describe('avg/s in ProjectBreakdown', () => {
it('returns dash for a project with no sessions', () => {
const project = makeProject('proj', [])
expect(avgCostLabel(project)).toBe('-')
})
it('returns formatted average cost across sessions', () => {
const sessions = [makeSession('s1', 2.0), makeSession('s2', 4.0)]
const project = makeProject('proj', sessions)
expect(avgCostLabel(project)).toBe(formatCost(3.0))
})
})
+141
View File
@@ -0,0 +1,141 @@
import { afterEach, describe, it, expect, vi } from 'vitest'
import { formatDateRangeLabel, formatDayRangeLabel, parseDateRangeFlags, parseDayFlag, shiftDay } from '../src/cli-date.js'
afterEach(() => {
vi.useRealTimers()
})
describe('parseDateRangeFlags', () => {
it('returns null when neither flag is provided', () => {
expect(parseDateRangeFlags(undefined, undefined)).toBeNull()
})
it('parses a symmetric range in local time', () => {
const range = parseDateRangeFlags('2026-04-07', '2026-04-10')
expect(range).not.toBeNull()
expect(range!.start.getFullYear()).toBe(2026)
expect(range!.start.getMonth()).toBe(3)
expect(range!.start.getDate()).toBe(7)
expect(range!.start.getHours()).toBe(0)
expect(range!.end.getDate()).toBe(10)
expect(range!.end.getHours()).toBe(23)
expect(range!.end.getMinutes()).toBe(59)
expect(range!.end.getSeconds()).toBe(59)
})
it('accepts --from alone (open-ended to today 23:59:59)', () => {
const range = parseDateRangeFlags('2026-04-01', undefined)
expect(range).not.toBeNull()
expect(range!.start.getDate()).toBe(1)
expect(range!.end.getHours()).toBe(23)
})
it('accepts --to alone with a 6-month default start', () => {
// Previously the missing --from defaulted to epoch (1970), opening a
// 55-year scan window that was almost never what the user meant. The
// default is now 6 months back from now, matching the dashboard's
// "6 Months" period boundary.
const range = parseDateRangeFlags(undefined, '2026-04-10')
expect(range).not.toBeNull()
expect(range!.start.getTime()).toBeGreaterThan(new Date(0).getTime())
const sixMonthsMs = 6 * 31 * 24 * 60 * 60 * 1000
const ageMs = Date.now() - range!.start.getTime()
expect(ageMs).toBeLessThanOrEqual(sixMonthsMs + 1000)
expect(ageMs).toBeGreaterThanOrEqual(sixMonthsMs - 1000)
expect(range!.end.getDate()).toBe(10)
})
it('throws when --from > --to', () => {
expect(() => parseDateRangeFlags('2026-04-10', '2026-04-07'))
.toThrow('--from must not be after --to')
})
it('throws on a non-ISO string', () => {
expect(() => parseDateRangeFlags('April 7', undefined))
.toThrow('Invalid date format')
})
it('throws on wrong digit count', () => {
expect(() => parseDateRangeFlags('26-4-7', undefined))
.toThrow('Invalid date format')
})
it('rejects month/day overflow instead of silently rolling forward', () => {
// Without overflow validation, JS Date silently turns Feb 31 into Mar 3
// and 13/32 into 02/01 of the following year. That made `--from
// 2026-02-31 --to 2026-03-15` quietly drop sessions on Feb 28 - Mar 2.
expect(() => parseDateRangeFlags('2026-02-31', '2026-03-15'))
.toThrow('Invalid date "2026-02-31"')
expect(() => parseDateRangeFlags('2026-13-01', undefined))
.toThrow('Invalid date "2026-13-01"')
expect(() => parseDateRangeFlags('2026-04-31', undefined))
.toThrow('Invalid date "2026-04-31"')
expect(() => parseDateRangeFlags(undefined, '2026-02-30'))
.toThrow('Invalid date "2026-02-30"')
// Leap-day check: 2024 is a leap year, 2025 is not.
expect(parseDateRangeFlags('2024-02-29', '2024-03-01')).not.toBeNull()
expect(() => parseDateRangeFlags('2025-02-29', undefined))
.toThrow('Invalid date "2025-02-29"')
})
it('same day is valid (start midnight, end 23:59:59)', () => {
const range = parseDateRangeFlags('2026-04-10', '2026-04-10')
expect(range).not.toBeNull()
expect(range!.start.getDate()).toBe(10)
expect(range!.end.getDate()).toBe(10)
})
it('formats custom range labels consistently', () => {
expect(formatDateRangeLabel('2026-04-07', '2026-04-10')).toBe('2026-04-07 to 2026-04-10')
expect(formatDateRangeLabel(undefined, '2026-04-10')).toBe('all to 2026-04-10')
expect(formatDateRangeLabel('2026-04-07', undefined)).toBe('2026-04-07 to today')
})
})
describe('parseDayFlag', () => {
it('returns null when no day is provided', () => {
expect(parseDayFlag(undefined)).toBeNull()
})
it('parses an explicit day as local midnight through end of day', () => {
const selected = parseDayFlag('2026-04-10')
expect(selected).not.toBeNull()
expect(selected!.day).toBe('2026-04-10')
expect(selected!.label).toBe('Day (2026-04-10)')
expect(selected!.range.start.getFullYear()).toBe(2026)
expect(selected!.range.start.getMonth()).toBe(3)
expect(selected!.range.start.getDate()).toBe(10)
expect(selected!.range.start.getHours()).toBe(0)
expect(selected!.range.end.getDate()).toBe(10)
expect(selected!.range.end.getHours()).toBe(23)
expect(selected!.range.end.getMinutes()).toBe(59)
expect(selected!.range.end.getSeconds()).toBe(59)
})
it('resolves yesterday as the previous local calendar day after midnight', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 23, 0, 5, 0))
const selected = parseDayFlag('yesterday')
expect(selected!.day).toBe('2026-05-22')
expect(selected!.range.start.getDate()).toBe(22)
expect(selected!.range.end.getDate()).toBe(22)
})
it('supports today and day shifting', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 23, 12, 0, 0))
expect(parseDayFlag('today')!.day).toBe('2026-05-23')
expect(formatDayRangeLabel('2026-05-22')).toBe('Day (2026-05-22)')
expect(shiftDay('2026-05-22', -1)).toBe('2026-05-21')
expect(shiftDay('2026-05-22', 1)).toBe('2026-05-23')
})
it('rejects malformed or overflowing day values', () => {
expect(() => parseDayFlag('May 22')).toThrow('Invalid date format')
expect(() => parseDayFlag('2026-02-31')).toThrow('Invalid date "2026-02-31"')
expect(() => shiftDay('2026-13-01', 1)).toThrow('Invalid date "2026-13-01"')
})
})
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from '../src/day-aggregator.js'
import type { ParsedApiCall, ProjectSummary, SessionSummary, Turn } from '../src/types.js'
function makeCall(timestamp: string, opts: { costUSD: number; savingsUSD?: number; savingsBaselineModel?: string; model?: string }): ParsedApiCall {
return {
provider: 'claude',
model: opts.model ?? 'local-model',
usage: {
inputTokens: 100,
outputTokens: 200,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 50,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: opts.costUSD,
savingsUSD: opts.savingsUSD,
savingsBaselineModel: opts.savingsBaselineModel,
tools: [],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp,
bashCommands: [],
deduplicationKey: `dk-${timestamp}-${opts.costUSD}-${opts.savingsUSD ?? 0}`,
}
}
function makeTurn(timestamp: string, calls: ParsedApiCall[], category: string = 'coding'): Turn {
return {
userMessage: 'u',
timestamp,
sessionId: 's',
category: category as Turn['category'],
retries: 0,
hasEdits: false,
assistantCalls: calls,
} as Turn
}
function makeSession(sessions: SessionSummary[]): ProjectSummary {
const totalCostUSD = sessions.reduce((s, sess) => s + sess.totalCostUSD, 0)
const totalSavingsUSD = sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0)
const totalApiCalls = sessions.reduce((s, sess) => s + sess.apiCalls, 0)
return {
project: 'p',
projectPath: '/p',
sessions,
totalCostUSD,
totalSavingsUSD,
totalApiCalls,
}
}
describe('aggregateProjectsIntoDays: savings totals', () => {
it('rolls up day, model, category, and provider savings separately from cost', () => {
const turn = makeTurn('2026-04-10T10:00:00', [
makeCall('2026-04-10T10:00:00', { costUSD: 0, savingsUSD: 5, savingsBaselineModel: 'gpt-4o' }),
])
const turn2 = makeTurn('2026-04-10T10:01:00', [
makeCall('2026-04-10T10:01:00', { costUSD: 2, savingsUSD: 0, model: 'gpt-4o' }),
])
const project: ProjectSummary = {
project: 'p',
projectPath: '/p',
sessions: [{
sessionId: 's1',
project: 'p',
firstTimestamp: '2026-04-10T10:00:00',
lastTimestamp: '2026-04-10T10:01:00',
totalCostUSD: 2,
totalSavingsUSD: 5,
totalInputTokens: 200,
totalOutputTokens: 400,
totalCacheReadTokens: 100,
totalCacheWriteTokens: 0,
apiCalls: 2,
turns: [turn, turn2],
modelBreakdown: { 'Local Model': { calls: 1, costUSD: 0, savingsUSD: 5, tokens: { inputTokens: 100, outputTokens: 200, cacheCreationInputTokens: 0, cacheReadInputTokens: 50, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 } }, 'gpt-4o': { calls: 1, costUSD: 2, savingsUSD: 0, tokens: { inputTokens: 100, outputTokens: 200, cacheCreationInputTokens: 0, cacheReadInputTokens: 50, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 } } },
toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown: { coding: { turns: 1, costUSD: 2, savingsUSD: 5, retries: 0, editTurns: 0, oneShotTurns: 0 } },
skillBreakdown: {}, subagentBreakdown: {},
}],
totalCostUSD: 2,
totalSavingsUSD: 5,
totalApiCalls: 2,
}
const days = aggregateProjectsIntoDays([project])
expect(days).toHaveLength(1)
const day = days[0]!
expect(day.cost).toBe(2)
expect(day.savingsUSD).toBe(5)
expect(day.models['local-model']).toMatchObject({ calls: 1, cost: 0, savingsUSD: 5 })
expect(day.models['gpt-4o']).toMatchObject({ calls: 1, cost: 2, savingsUSD: 0 })
expect(day.providers['claude']).toMatchObject({ calls: 2, cost: 2, savingsUSD: 5 })
expect(day.categories.coding).toMatchObject({ turns: 2, cost: 2, savingsUSD: 5 })
})
})
describe('buildPeriodDataFromDays: savings totals', () => {
it('threads savings through to model and category rollups', () => {
const days = [
{
date: '2026-04-09',
cost: 2,
savingsUSD: 5,
calls: 1,
sessions: 1,
inputTokens: 100,
outputTokens: 200,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 0,
oneShotTurns: 0,
models: { 'local-model': { calls: 1, cost: 0, savingsUSD: 5, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } },
categories: { coding: { turns: 1, cost: 0, savingsUSD: 5, editTurns: 0, oneShotTurns: 0 } },
providers: { claude: { calls: 1, cost: 0, savingsUSD: 5 } },
},
{
date: '2026-04-10',
cost: 3,
savingsUSD: 0,
calls: 1,
sessions: 1,
inputTokens: 100,
outputTokens: 200,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 0,
oneShotTurns: 0,
models: { 'gpt-4o': { calls: 1, cost: 3, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } },
categories: { coding: { turns: 1, cost: 3, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } },
providers: { claude: { calls: 1, cost: 3, savingsUSD: 0 } },
},
]
const pd = buildPeriodDataFromDays(days, '7 Days')
expect(pd.savingsUSD).toBe(5)
const coding = pd.categories.find(c => c.name === 'Coding')!
expect(coding.savingsUSD).toBe(5)
const local = pd.models.find(m => m.name === 'local-model')!
expect(local.savingsUSD).toBe(5)
expect(local.cost).toBe(0)
})
})
+310
View File
@@ -0,0 +1,310 @@
import { describe, expect, it } from 'vitest'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from '../src/day-aggregator.js'
import type { ProjectSummary } from '../src/types.js'
function makeProject(overrides: Partial<ProjectSummary> & { sessions: ProjectSummary['sessions'] }): ProjectSummary {
return {
project: 'p',
projectPath: '/p',
totalCostUSD: overrides.sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
totalApiCalls: overrides.sessions.reduce((s, sess) => s + sess.apiCalls, 0),
...overrides,
}
}
function makeCall(timestamp: string, costUSD: number, model = 'Opus 4.7', provider = 'claude') {
return {
provider,
model,
usage: {
inputTokens: 100,
outputTokens: 200,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 50,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD,
tools: [],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard' as const,
timestamp,
bashCommands: [],
deduplicationKey: `dk-${timestamp}-${costUSD}`,
}
}
describe('aggregateProjectsIntoDays', () => {
it('buckets api calls by calendar date derived from timestamp', () => {
const projects: ProjectSummary[] = [
makeProject({
sessions: [{
sessionId: 's1',
project: 'p',
firstTimestamp: '2026-04-09T10:00:00',
lastTimestamp: '2026-04-10T08:00:00',
totalCostUSD: 10,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 2,
turns: [
{
userMessage: 'hi',
timestamp: '2026-04-09T10:00:00',
sessionId: 's1',
category: 'coding',
retries: 0,
hasEdits: true,
assistantCalls: [
makeCall('2026-04-09T10:00:00', 4),
makeCall('2026-04-10T08:00:00', 6),
],
},
],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as never,
skillBreakdown: {} as never,
}],
}),
]
const days = aggregateProjectsIntoDays(projects)
expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
expect(days[0]!.cost).toBe(4)
expect(days[0]!.calls).toBe(1)
expect(days[1]!.cost).toBe(6)
expect(days[1]!.calls).toBe(1)
})
it('attributes category turns + editTurns + oneShotTurns to the first call date of the turn', () => {
const projects: ProjectSummary[] = [
makeProject({
sessions: [{
sessionId: 's1',
project: 'p',
firstTimestamp: '2026-04-09T10:00:00',
lastTimestamp: '2026-04-09T10:05:00',
totalCostUSD: 3,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [
{
userMessage: 'hi',
timestamp: '2026-04-09T10:00:00',
sessionId: 's1',
category: 'coding',
retries: 0,
hasEdits: true,
assistantCalls: [makeCall('2026-04-09T10:00:00', 3)],
},
],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as never,
skillBreakdown: {} as never,
}],
}),
]
const days = aggregateProjectsIntoDays(projects)
const day = days[0]!
expect(day.editTurns).toBe(1)
expect(day.oneShotTurns).toBe(1)
expect(day.categories['coding']).toEqual({
turns: 1,
cost: 3,
savingsUSD: 0,
editTurns: 1,
oneShotTurns: 1,
})
})
it('counts a session under its firstTimestamp date', () => {
const projects: ProjectSummary[] = [
makeProject({
sessions: [{
sessionId: 's1',
project: 'p',
firstTimestamp: '2026-04-09T23:59:00',
lastTimestamp: '2026-04-10T00:10:00',
totalCostUSD: 1,
totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
apiCalls: 0,
turns: [],
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown: {} as never,
skillBreakdown: {} as never,
}],
}),
]
const days = aggregateProjectsIntoDays(projects)
const expectedDate = dateKey('2026-04-09T23:59:00')
expect(days[0]!.date).toBe(expectedDate)
expect(days[0]!.sessions).toBe(1)
})
it('aggregates per-model and per-provider totals inside each day', () => {
const projects: ProjectSummary[] = [
makeProject({
sessions: [{
sessionId: 's1',
project: 'p',
firstTimestamp: '2026-04-10T10:00:00',
lastTimestamp: '2026-04-10T10:00:00',
totalCostUSD: 10,
totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
apiCalls: 2,
turns: [
{
userMessage: 'x', timestamp: '2026-04-10T10:00:00', sessionId: 's1',
category: 'coding', retries: 0, hasEdits: false,
assistantCalls: [
makeCall('2026-04-10T10:00:00', 7, 'Opus 4.7', 'claude'),
makeCall('2026-04-10T10:00:00', 3, 'gpt-5', 'codex'),
],
},
],
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown: {} as never,
skillBreakdown: {} as never,
}],
}),
]
const days = aggregateProjectsIntoDays(projects)
const day = days[0]!
expect(day.models['Opus 4.7']).toEqual({
calls: 1, cost: 7, savingsUSD: 0,
inputTokens: 100, outputTokens: 200,
cacheReadTokens: 50, cacheWriteTokens: 0,
})
expect(day.models['gpt-5']).toEqual({
calls: 1, cost: 3, savingsUSD: 0,
inputTokens: 100, outputTokens: 200,
cacheReadTokens: 50, cacheWriteTokens: 0,
})
expect(day.providers['claude']).toEqual({ calls: 1, cost: 7, savingsUSD: 0 })
expect(day.providers['codex']).toEqual({ calls: 1, cost: 3, savingsUSD: 0 })
})
})
describe('buildPeriodDataFromDays', () => {
function makeDay(date: string, cost: number) {
return {
date,
cost,
calls: 10,
sessions: 2,
inputTokens: 100,
outputTokens: 200,
cacheReadTokens: 300,
cacheWriteTokens: 0,
editTurns: 3,
oneShotTurns: 2,
models: {
'Opus 4.7': { calls: 8, cost: cost * 0.8, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
'Haiku 4.5': { calls: 2, cost: cost * 0.2, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
},
categories: { 'coding': { turns: 2, cost: cost * 0.5, savingsUSD: 0, editTurns: 2, oneShotTurns: 1 } },
providers: { 'claude': { calls: 10, cost, savingsUSD: 0 } },
}
}
it('sums cost, calls, sessions, tokens across days', () => {
const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)]
const pd = buildPeriodDataFromDays(days, '7 Days')
expect(pd.label).toBe('7 Days')
expect(pd.cost).toBe(30)
expect(pd.calls).toBe(20)
expect(pd.sessions).toBe(4)
expect(pd.inputTokens).toBe(200)
expect(pd.outputTokens).toBe(400)
expect(pd.cacheReadTokens).toBe(600)
})
it('merges per-model totals across days and sorts by cost desc', () => {
const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)]
const pd = buildPeriodDataFromDays(days, 'Today')
expect(pd.models[0]!.name).toBe('Opus 4.7')
expect(pd.models[0]!.cost).toBeCloseTo(24)
expect(pd.models[1]!.name).toBe('Haiku 4.5')
expect(pd.models[1]!.cost).toBeCloseTo(6)
})
it('merges per-category totals and keeps editTurns + oneShotTurns per category', () => {
const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)]
const pd = buildPeriodDataFromDays(days, 'Today')
const coding = pd.categories.find(c => c.name === 'Coding')!
expect(coding.turns).toBe(4)
expect(coding.editTurns).toBe(4)
expect(coding.oneShotTurns).toBe(2)
expect(coding.cost).toBeCloseTo(15)
})
it('returns empty period totals when no days supplied', () => {
const pd = buildPeriodDataFromDays([], 'Today')
expect(pd.cost).toBe(0)
expect(pd.calls).toBe(0)
expect(pd.sessions).toBe(0)
expect(pd.categories).toEqual([])
expect(pd.models).toEqual([])
})
it('attributes a midnight-straddling turn to the first assistant call date, not the user message date', () => {
// Regression for the bug that shipped in 0.8.2-0.8.4: when a user message
// sat on one side of midnight and the assistant response landed on the other,
// day-aggregator.ts bucketed by assistant time but renderStatusBar bucketed
// by user time, so the menubar and `codeburn status` disagreed on Today.
// The invariant for both surfaces: a turn is counted on the day its first
// assistant call actually ran.
const userTs = '2026-04-20T23:58:00Z'
const assistantTs = '2026-04-21T00:30:00Z'
const assistantLocal = new Date(assistantTs)
const expectedDate = `${assistantLocal.getFullYear()}-${String(assistantLocal.getMonth() + 1).padStart(2, '0')}-${String(assistantLocal.getDate()).padStart(2, '0')}`
const projects: ProjectSummary[] = [
makeProject({
sessions: [{
sessionId: 's1',
project: 'p',
firstTimestamp: userTs,
lastTimestamp: assistantTs,
totalCostUSD: 5,
totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [{
userMessage: 'ask',
timestamp: userTs,
sessionId: 's1',
category: 'coding',
retries: 0,
hasEdits: false,
assistantCalls: [makeCall(assistantTs, 5)],
}],
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown: {} as never,
skillBreakdown: {} as never,
}],
}),
]
const days = aggregateProjectsIntoDays(projects)
const costDay = days.find(d => d.cost === 5)
expect(costDay, 'turn cost must be bucketed somewhere').toBeDefined()
expect(costDay!.date).toBe(expectedDate)
expect(costDay!.calls).toBe(1)
})
})
+226
View File
@@ -0,0 +1,226 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, readFile, readdir, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { exportCsv, exportJson, type PeriodExport } from '../src/export.js'
import type { ProjectSummary } from '../src/types.js'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'export-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
function makeProject(projectPath: string): ProjectSummary {
return {
project: projectPath,
projectPath,
sessions: [
{
sessionId: 'sess-001',
project: projectPath,
firstTimestamp: '2026-04-14T10:00:00Z',
lastTimestamp: '2026-04-14T10:01:00Z',
totalCostUSD: 1.23,
totalInputTokens: 100,
totalOutputTokens: 50,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [
{
userMessage: '=SUM(1,2)',
timestamp: '2026-04-14T10:00:00Z',
sessionId: 'sess-001',
category: 'coding',
retries: 0,
hasEdits: true,
assistantCalls: [
{
provider: 'claude',
model: '+danger-model',
usage: {
inputTokens: 100,
outputTokens: 50,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: 1.23,
tools: ['Read'],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-04-14T10:00:00Z',
bashCommands: ['@malicious'],
deduplicationKey: 'dedup-1',
},
],
},
],
modelBreakdown: {
'+danger-model': {
calls: 1,
costUSD: 1.23,
tokens: {
inputTokens: 100,
outputTokens: 50,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
},
},
toolBreakdown: {
Read: { calls: 1 },
},
mcpBreakdown: {},
bashBreakdown: {
'@malicious': { calls: 1 },
},
categoryBreakdown: {
coding: { turns: 1, costUSD: 1.23, retries: 0, editTurns: 1, oneShotTurns: 1 },
debugging: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
feature: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
refactoring: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
testing: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
exploration: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
planning: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
delegation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
git: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
'build/deploy': { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
conversation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
brainstorming: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
general: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
},
skillBreakdown: {},
},
],
totalCostUSD: 1.23,
totalApiCalls: 1,
}
}
describe('exportCsv', () => {
it('prefixes formula-like cells to prevent CSV injection', async () => {
const periods: PeriodExport[] = [
{
label: '30 Days',
projects: [makeProject('=cmd,calc')],
},
]
const outputPath = join(tmpDir, 'report.csv')
const folder = await exportCsv(periods, outputPath)
// exportCsv now writes a folder of clean one-table-per-file CSVs, so the formula-prefix
// guard is scattered across files. Concatenate them for the assertion surface.
const [projects, models, shell] = await Promise.all([
readFile(join(folder, 'projects.csv'), 'utf-8'),
readFile(join(folder, 'models.csv'), 'utf-8'),
readFile(join(folder, 'shell-commands.csv'), 'utf-8'),
])
const content = projects + models + shell
expect(content).toContain("\"'=cmd,calc\"")
expect(content).toContain("'+danger-model")
expect(content).toContain("'@malicious")
})
it('escapes tab and carriage-return prefixes in CSV cells', async () => {
const periods: PeriodExport[] = [
{
label: '30 Days',
projects: [makeProject('\tcmd'), makeProject('\rcmd')],
},
]
const outputPath = join(tmpDir, 'tab-cr.csv')
const folder = await exportCsv(periods, outputPath)
const projects = await readFile(join(folder, 'projects.csv'), 'utf-8')
expect(projects).toContain("'\tcmd")
expect(projects).toContain("'\rcmd")
})
it('includes per-model efficiency metrics', async () => {
const periods: PeriodExport[] = [
{
label: '30 Days',
projects: [makeProject('app')],
},
]
const outputPath = join(tmpDir, 'models.csv')
const folder = await exportCsv(periods, outputPath)
const models = await readFile(join(folder, 'models.csv'), 'utf-8')
expect(models).toContain('Edit Turns')
expect(models).toContain('One-shot Rate (%)')
expect(models).toContain('Retries/Edit')
expect(models).toContain('Cost/Edit')
expect(models).toContain(',1,100,0,')
})
it('does not crash when periods array is empty', async () => {
const outputPath = join(tmpDir, 'empty.csv')
const folder = await exportCsv([], outputPath)
const entries = await readdir(folder)
expect(entries.length).toBeGreaterThanOrEqual(0)
})
it('describes detail files without hardcoding a 30-day window', async () => {
const periods: PeriodExport[] = [
{
label: '2026-04-07 to 2026-04-10',
projects: [makeProject('app')],
},
]
const outputPath = join(tmpDir, 'custom.csv')
const folder = await exportCsv(periods, outputPath)
const readme = await readFile(join(folder, 'README.txt'), 'utf-8')
expect(readme).toContain('selected detail period')
expect(readme).not.toContain('30-day window')
})
it('writes MCP server usage to mcp.csv', async () => {
const project = makeProject('app')
project.sessions[0]!.mcpBreakdown = { node_repl: { calls: 5 } }
const periods: PeriodExport[] = [{ label: '30 Days', projects: [project] }]
const folder = await exportCsv(periods, join(tmpDir, 'mcp.csv'))
const mcp = await readFile(join(folder, 'mcp.csv'), 'utf-8')
expect(mcp).toContain('Server,Calls,Share (%)')
expect(mcp).toContain('node_repl,5,100')
})
})
describe('exportJson', () => {
it('includes an mcp section with per-server usage', async () => {
const project = makeProject('app')
project.sessions[0]!.mcpBreakdown = { node_repl: { calls: 3 }, github: { calls: 1 } }
const periods: PeriodExport[] = [{ label: '30 Days', projects: [project] }]
const outputPath = join(tmpDir, 'export.json')
const saved = await exportJson(periods, outputPath)
const data = JSON.parse(await readFile(saved, 'utf-8'))
expect(Array.isArray(data.mcp)).toBe(true)
expect(data.mcp).toEqual([
{ Server: 'node_repl', Calls: 3, 'Share (%)': 75 },
{ Server: 'github', Calls: 1, 'Share (%)': 25 },
])
})
})
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect, afterEach } from 'vitest'
import { createServer, type Server } from 'node:http'
import { type AddressInfo } from 'node:net'
import { fetchWithTimeout } from '../src/fetch-utils.js'
let server: Server
afterEach(async () => {
await new Promise<void>(resolve => server?.close(() => resolve()))
})
function listen(handler: (respond: () => void) => void): Promise<string> {
return new Promise(resolve => {
server = createServer((_req, res) => handler(() => res.end('{"ok":true}')))
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as AddressInfo
resolve(`http://127.0.0.1:${port}/`)
})
})
}
describe('fetchWithTimeout', () => {
it('aborts when the server never responds, within the timeout window', async () => {
// Accept the request but never reply — the half-open-network case.
const url = await listen(() => { /* never respond */ })
const start = Date.now()
await expect(fetchWithTimeout(url, {}, 150)).rejects.toMatchObject({ name: 'TimeoutError' })
const elapsed = Date.now() - start
// Fails fast at ~the timeout, not hanging indefinitely.
expect(elapsed).toBeLessThan(2000)
})
it('returns the response when the server replies before the timeout', async () => {
const url = await listen(respond => respond())
const res = await fetchWithTimeout(url, {}, 2000)
expect(res.ok).toBe(true)
expect(await res.json()).toEqual({ ok: true })
})
it('still aborts on timeout when the caller also passes a signal', async () => {
const url = await listen(() => { /* never respond */ })
const controller = new AbortController()
await expect(fetchWithTimeout(url, { signal: controller.signal }, 150))
.rejects.toMatchObject({ name: 'TimeoutError' })
})
})
@@ -0,0 +1,2 @@
{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-06-21T13:32:13Z","content":"<USER_REQUEST>sanitized fixture prompt</USER_REQUEST>"}
{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-06-21T13:32:14Z","content":"sanitized fixture response","thinking":"sanitized fixture reasoning"}
@@ -0,0 +1,10 @@
{
"conversationId": "fixture-current-cli",
"layout": "~/.gemini/antigravity-cli/conversations/<conversation-id>.db with sibling brain/<conversation-id>/.system_generated/logs/transcript.jsonl",
"rows": [
{
"idx": 0,
"hex": "120202032213666978747572652d63757272656e742d636c690ace01222508f80710b9ec0118da05301848930550475a12666978747572652d726573706f6e73652d319a011267656d696e692d70726f2d64656661756c74a201230a0d7472616a6563746f72795f69641212666978747572652d7472616a6563746f7279a201140a0b757365645f636c61756465120566616c7365a201140a0f6c6173745f737465705f696e646578120132a201230a0a6d6f64656c5f656e756d12154d4f44454c5f504c414345484f4c4445525f4d3136aa011547656d696e6920332e312050726f20284869676829"
}
]
}
@@ -0,0 +1,2 @@
{"role":"user","message":{"content":[{"type":"text","text":"<user_query>\nRun a quick smoke test\n</user_query>"}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"Smoke test passed."}]}}
@@ -0,0 +1,5 @@
{"id":"evt-start","event":"start","timestamp":"2026-06-22T10:00:00.000Z","data":{"model":"openai-codex:gpt-5.5"}}
{"id":"evt-codex-usage","event":"agent","timestamp":1782122405000,"data":{"type":"usage","usage":{"input_tokens":1000,"output_tokens":200,"cached_read_tokens":50,"thought_tokens":25,"total_tokens":1200},"costUsd":null}}
{"id":"evt-status-glm","event":"agent","timestamp":"2026-06-22T10:00:10.000Z","data":{"type":"status","model":"glm-5.2"}}
{"id":"evt-glm-usage","event":"agent","timestamp":"2026-06-22T10:00:15.000Z","data":{"type":"usage","usage":{"input_tokens":3000,"output_tokens":400,"cached_read_tokens":100,"thought_tokens":60,"total_tokens":3560},"costUsd":null}}
{"id":"evt-glm-usage","event":"agent","timestamp":"2026-06-22T10:00:16.000Z","data":{"type":"usage","usage":{"input_tokens":9999,"output_tokens":9999,"cached_read_tokens":9999,"thought_tokens":9999,"total_tokens":39996},"costUsd":null}}
@@ -0,0 +1,3 @@
{"id":"evt-start","event":"start","timestamp":"2026-06-22T11:00:00.000Z","data":{"model":"glm-5.2"}}
{"id":"evt-status","event":"agent","timestamp":"2026-06-22T11:00:05.000Z","data":{"type":"status","model":"openai-codex:gpt-5.5"}}
{"id":"evt-message","event":"agent","timestamp":"2026-06-22T11:00:10.000Z","data":{"type":"message","text":"no usage was emitted"}}
@@ -0,0 +1,3 @@
{"id":"evt-start","event":"start","timestamp":"2026-06-22T12:00:00.000Z","data":{"model":"glm-5.2"}}
{"id":"evt-before-transition","event":"agent","timestamp":"2026-06-22T12:00:05.000Z","data":{"type":"usage","usage":{"input_tokens":777,"output_tokens":33,"cached_read_tokens":7,"thought_tokens":3,"total_tokens":820},"costUsd":null}}
{"id":"evt-status","event":"agent","timestamp":"2026-06-22T12:00:10.000Z","data":{"type":"status","model":"openai-codex:gpt-5.5"}}
+1
View File
@@ -0,0 +1 @@
{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-bash","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"b1","name":"Bash","input":{"command":"/x/__proto__"}}],"usage":{"input_tokens":1,"output_tokens":1}}}
+1
View File
@@ -0,0 +1 @@
{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-model","type":"message","role":"assistant","model":"__proto__","content":[{"type":"text","text":"x"}],"usage":{"input_tokens":1,"output_tokens":1}}}
+1
View File
@@ -0,0 +1 @@
{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-tool","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"t1","name":"__proto__","input":{}}],"usage":{"input_tokens":1,"output_tokens":1}}}
+195
View File
@@ -0,0 +1,195 @@
import { describe, it, expect, afterEach, vi } from 'vitest'
import { mkdtemp, writeFile, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import {
MAX_SESSION_FILE_BYTES,
MAX_STREAM_SESSION_FILE_BYTES,
readSessionFile,
readSessionLines,
} from '../src/fs-utils.js'
describe('readSessionFile', () => {
const tmpDirs: string[] = []
afterEach(async () => {
delete process.env.CODEBURN_VERBOSE
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function tmpPath(content: string | Buffer): Promise<string> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-fs-'))
tmpDirs.push(base)
const p = join(base, 'x.jsonl')
await writeFile(p, content)
return p
}
it('returns content for small files via readFile fast path', async () => {
const p = await tmpPath('hello\nworld\n')
expect(await readSessionFile(p)).toBe('hello\nworld\n')
})
it('returns content for large files under the full-file cap', async () => {
const size = 8 * 1024 * 1024
const p = await tmpPath(Buffer.alloc(size, 'a'))
const got = await readSessionFile(p)
expect(got).not.toBeNull()
expect(got!.length).toBe(size)
})
it('returns null and skips files over the cap', async () => {
const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'b'))
expect(await readSessionFile(p)).toBeNull()
})
it('emits stderr warning under CODEBURN_VERBOSE=1 for skipped file', async () => {
process.env.CODEBURN_VERBOSE = '1'
const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'c'))
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
await readSessionFile(p)
expect(spy).toHaveBeenCalled()
const msg = (spy.mock.calls[0][0] as string)
expect(msg).toContain('codeburn')
expect(msg).toContain('oversize')
spy.mockRestore()
})
it('returns null on stat failure without throwing', async () => {
expect(await readSessionFile('/nonexistent/path/x.jsonl')).toBeNull()
})
})
describe('readSessionLines', () => {
const tmpDirs: string[] = []
afterEach(async () => {
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function tmpPath(content: string): Promise<string> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-lines-'))
tmpDirs.push(base)
const p = join(base, 'session.jsonl')
await writeFile(p, content)
return p
}
it('yields all lines from a file', async () => {
const p = await tmpPath('line1\nline2\nline3\n')
const lines: string[] = []
for await (const line of readSessionLines(p)) lines.push(line)
expect(lines).toEqual(['line1', 'line2', 'line3'])
})
it('skips old large lines before materializing the full line', async () => {
const oldLine = `{"type":"assistant","timestamp":"2026-01-01T00:00:00Z","payload":"${'x'.repeat(100_000)}"}`
const newLine = '{"type":"assistant","timestamp":"2026-05-01T00:00:00Z"}'
const p = await tmpPath(`${oldLine}\n${newLine}\n`)
const lines: string[] = []
for await (const line of readSessionLines(p, head => head.includes('2026-01-01'))) {
lines.push(line)
}
expect(lines).toEqual([newLine])
})
it('yields large lines as Buffers when requested', async () => {
const largeLine = `{"type":"assistant","timestamp":"2026-05-01T00:00:00Z","payload":"${'x'.repeat(100_000)}"}`
const p = await tmpPath(`${largeLine}\nsmall\n`)
const lines: Array<string | Buffer> = []
for await (const line of readSessionLines(p, undefined, { largeLineAsBuffer: true })) {
lines.push(line)
}
expect(Buffer.isBuffer(lines[0])).toBe(true)
expect(lines[1]).toBe('small')
})
it('does not leak file descriptors when generator is abandoned early', async () => {
const content = Array.from({ length: 1000 }, (_, i) => `line-${i}`).join('\n')
const p = await tmpPath(content)
const gen = readSessionLines(p)
await gen.next()
await gen.return(undefined)
})
it('reads from startByteOffset, yielding only lines after the offset', async () => {
const content = 'line1\nline2\nline3\n'
const p = await tmpPath(content)
const offset = Buffer.byteLength('line1\n')
const lines: string[] = []
for await (const line of readSessionLines(p, undefined, { startByteOffset: offset })) {
lines.push(line)
}
expect(lines).toEqual(['line2', 'line3'])
})
it('byteOffsetTracker tracks position after last complete newline', async () => {
const content = 'aaa\nbbb\nccc\n'
const p = await tmpPath(content)
const tracker = { lastCompleteLineOffset: 0 }
const lines: string[] = []
for await (const line of readSessionLines(p, undefined, { byteOffsetTracker: tracker })) {
lines.push(line)
}
expect(lines).toEqual(['aaa', 'bbb', 'ccc'])
expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength(content))
})
it('byteOffsetTracker accounts for startByteOffset', async () => {
const content = 'line1\nline2\nline3\n'
const p = await tmpPath(content)
const offset = Buffer.byteLength('line1\n')
const tracker = { lastCompleteLineOffset: 0 }
for await (const _line of readSessionLines(p, undefined, { startByteOffset: offset, byteOffsetTracker: tracker })) {}
expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength(content))
})
it('byteOffsetTracker excludes trailing partial line (no final newline)', async () => {
const content = 'line1\nline2\npartial'
const p = await tmpPath(content)
const tracker = { lastCompleteLineOffset: 0 }
for await (const _line of readSessionLines(p, undefined, { byteOffsetTracker: tracker })) {}
expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength('line1\nline2\n'))
})
it('byteOffsetTracker updates for skipped lines too', async () => {
const content = 'skip-me\nkeep-me\n'
const p = await tmpPath(content)
const tracker = { lastCompleteLineOffset: 0 }
const lines: string[] = []
for await (const line of readSessionLines(p, head => head.includes('skip-me'), { byteOffsetTracker: tracker })) {
lines.push(line)
}
expect(lines).toEqual(['keep-me'])
expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength(content))
})
it('reads files at or under the stream cap', async () => {
const p = await tmpPath('a\nb\nc\n')
const lines: string[] = []
for await (const line of readSessionLines(p, undefined, { maxBytes: 1024 })) lines.push(line)
expect(lines).toEqual(['a', 'b', 'c'])
})
it('skips files over the stream cap and surfaces a notice without CODEBURN_VERBOSE', async () => {
const p = await tmpPath('a\nb\nc\n')
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const lines: string[] = []
for await (const line of readSessionLines(p, undefined, { maxBytes: 1 })) lines.push(line)
expect(lines).toEqual([])
expect(spy).toHaveBeenCalled()
expect(spy.mock.calls[0][0] as string).toContain('skipped oversize session')
spy.mockRestore()
})
it('stream cap is generous enough for multi-GB Codex sessions', () => {
expect(MAX_STREAM_SESSION_FILE_BYTES).toBe(4 * 1024 * 1024 * 1024)
})
})
+371
View File
@@ -0,0 +1,371 @@
import { afterAll, describe, expect, it } from 'vitest'
import { appendFile, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dedupeStreamingMessageIds, parseApiCall, parseJsonlLine } from '../src/parser.js'
import { computeSessionUsage, emptyCache, readCache, writeAllow, writeCache } from '../src/guard/usage.js'
import { runGuardHook, runGuardStatusline } from '../src/guard/hooks.js'
import { writeGuardConfig, DEFAULT_GUARD_CONFIG } from '../src/guard/store.js'
import { buildFlags, matchFlag, writeFlags, type GuardFlags } from '../src/guard/flags.js'
import type { ProjectSummary } from '../src/types.js'
const roots: string[] = []
async function tmp(): Promise<string> {
const d = await mkdtemp(join(tmpdir(), 'codeburn-guard-hooks-'))
roots.push(d)
return d
}
afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) })
type Tool = { name: string; input?: Record<string, unknown> }
function assistantLine(id: string, o: { inTok?: number; outTok?: number; tools?: Tool[]; ts?: string } = {}): string {
const content: Record<string, unknown>[] = [{ type: 'text', text: 'ok' }]
for (const t of o.tools ?? []) content.push({ type: 'tool_use', id: `${t.name}-${id}`, name: t.name, input: t.input ?? {} })
return JSON.stringify({
type: 'assistant',
timestamp: o.ts ?? '2026-07-01T00:00:00.000Z',
message: {
model: 'claude-sonnet-4-20250514',
id,
type: 'message',
role: 'assistant',
usage: { input_tokens: o.inTok ?? 1_000_000, output_tokens: o.outTok ?? 200_000, cache_read_input_tokens: 0 },
content,
},
}) + '\n'
}
async function transcript(lines: string[]): Promise<string> {
const dir = await tmp()
const path = join(dir, 'session.jsonl')
await writeFile(path, lines.join(''), 'utf-8')
return path
}
const SID = 'sess-1'
function hookInput(path: string): string {
return JSON.stringify({ session_id: SID, transcript_path: path, hook_event_name: 'PreToolUse', tool_name: 'Bash' })
}
// The shipped pipeline's cost for a transcript: parse every line, dedupe
// streaming copies last-wins exactly like parseSessionFile does, sum call
// costs. The guard's incremental fold must match this to the cent.
async function shippedDedupedCost(path: string): Promise<number> {
const entries = (await readFile(path, 'utf-8'))
.split('\n')
.filter(l => l.trim())
.map(l => parseJsonlLine(l))
.filter((e): e is NonNullable<ReturnType<typeof parseJsonlLine>> => e !== null)
return dedupeStreamingMessageIds(entries).reduce((s, e) => s + (parseApiCall(e)?.costUSD ?? 0), 0)
}
describe('incremental session cache', () => {
it('parses only the appended tail and totals match a cold parse', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a'), assistantLine('b')])
const r1 = await computeSessionUsage(emptyCache(SID), path)
await writeCache(r1.cache, base)
expect(r1.resumedFrom).toBe(0)
expect(r1.cache.costUSD).toBeGreaterThan(0)
const offset1 = r1.cache.byteOffset
await appendFile(path, assistantLine('c') + assistantLine('d'), 'utf-8')
const size = (await stat(path)).size
const prev = await readCache(SID, base)
const r2 = await computeSessionUsage(prev, path)
// Bytes-read assertion: the second pass resumed exactly where the first
// stopped and consumed only the appended region (not the whole file).
expect(r2.resumedFrom).toBe(offset1)
expect(r2.cache.byteOffset).toBe(size)
expect(r2.cache.byteOffset - r2.resumedFrom).toBeGreaterThan(0)
expect(r2.resumedFrom).toBeLessThan(size)
const cold = await computeSessionUsage(emptyCache(SID), path)
expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10)
expect(r2.cache.costUSD).toBeGreaterThan(r1.cache.costUSD)
})
it('resets to a cold parse when the transcript shrinks (rotation)', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a'), assistantLine('b')])
const r1 = await computeSessionUsage(emptyCache(SID), path)
await writeCache(r1.cache, base)
await writeFile(path, assistantLine('z'), 'utf-8') // smaller file, new content
const r2 = await computeSessionUsage(await readCache(SID, base), path)
expect(r2.resumedFrom).toBe(0)
const cold = await computeSessionUsage(emptyCache(SID), path)
expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10)
})
})
describe('streaming duplicates (per-message-id replace)', () => {
it('counts a message written 3x with identical usage once, matching the shipped dedup', async () => {
const dup = assistantLine('msg-dup', { inTok: 500_000, outTok: 100_000 })
const path = await transcript([dup, dup, dup, assistantLine('msg-other')])
const singlePath = await transcript([dup, assistantLine('msg-other')])
const { cache } = await computeSessionUsage(emptyCache(SID), path)
expect(cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10)
const single = await computeSessionUsage(emptyCache(SID), singlePath)
expect(cache.costUSD).toBeCloseTo(single.cache.costUSD, 10)
})
it('keeps the last copy when streamed usage grows', async () => {
const copies = [10_000, 50_000, 120_000].map(outTok => assistantLine('msg-grow', { outTok }))
const path = await transcript(copies)
const lastOnly = await transcript([copies[2]!])
const { cache } = await computeSessionUsage(emptyCache(SID), path)
expect(cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10)
const last = await computeSessionUsage(emptyCache(SID), lastOnly)
expect(cache.costUSD).toBeCloseTo(last.cache.costUSD, 10)
})
it('replaces across incremental invocations when a later copy arrives', async () => {
const base = await tmp()
const path = await transcript([assistantLine('msg-x', { outTok: 20_000 })])
const r1 = await computeSessionUsage(emptyCache(SID), path)
await writeCache(r1.cache, base)
await appendFile(path, assistantLine('msg-x', { outTok: 90_000 }) + assistantLine('msg-y'), 'utf-8')
const r2 = await computeSessionUsage(await readCache(SID, base), path)
expect(r2.resumedFrom).toBe(r1.cache.byteOffset)
expect(r2.cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10)
})
})
describe('trailing complete line without newline', () => {
it('does not double count the line once it completes (A,B,C then D)', async () => {
const base = await tmp()
const a = assistantLine('msg-a')
const b = assistantLine('msg-b')
const c = assistantLine('msg-c')
const d = assistantLine('msg-d')
const dir = await tmp()
const path = join(dir, 'session.jsonl')
await writeFile(path, a + b + c.slice(0, -1), 'utf-8') // C complete but unterminated
const r1 = await computeSessionUsage(emptyCache(SID), path)
await writeCache(r1.cache, base)
// C was folded, but the offset stops after B's newline.
expect(r1.cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10)
expect(r1.cache.byteOffset).toBe((a + b).length)
await appendFile(path, '\n' + d, 'utf-8')
const r2 = await computeSessionUsage(await readCache(SID, base), path)
// C is re-read as a replace, not a second add: totals equal a cold parse.
const cold = await computeSessionUsage(emptyCache(SID), path)
expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10)
expect(r2.cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10)
})
})
describe('git commit detection', () => {
it('requires commit to be the git subcommand', async () => {
const cases: [string, boolean][] = [
['git commit -m x', true],
['git -c user.name=x commit', true],
['npm test && git commit -am done', true],
['git add src\ngit commit -m "step"', true], // newline-separated multi-command Bash call
['git log --grep commit', false],
['git diff && echo commit', false],
['echo git then commit', false],
['git diff\ncommit notes to self', false], // commit on the next line is not a git subcommand
]
for (const [command, expected] of cases) {
const path = await transcript([assistantLine('msg-1', { tools: [{ name: 'Bash', input: { command } }] })])
const { cache } = await computeSessionUsage(emptyCache(SID), path)
expect(cache.sawGitCommit, command).toBe(expected)
}
})
})
describe('budget hook (PreToolUse)', () => {
async function costOf(path: string): Promise<number> {
return (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD
}
it('stays silent below the soft cap', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a')])
const c = await costOf(path)
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 2, hardUSD: c * 4 }, base)
expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('')
})
it('warns once on the soft cap, then suppresses the repeat', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a'), assistantLine('b')])
const c = await costOf(path)
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 0.5, hardUSD: c * 10 }, base)
const first = await runGuardHook('pretooluse', hookInput(path), { base })
expect(JSON.parse(first).systemMessage).toContain('soft cap')
const second = await runGuardHook('pretooluse', hookInput(path), { base })
expect(second).toBe('')
})
it('blocks on the hard cap with a deny decision, and allow lifts it', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a'), assistantLine('b')])
const c = await costOf(path)
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 0.2, hardUSD: c * 0.5 }, base)
const blocked = JSON.parse(await runGuardHook('pretooluse', hookInput(path), { base }))
expect(blocked.hookSpecificOutput.hookEventName).toBe('PreToolUse')
expect(blocked.hookSpecificOutput.permissionDecision).toBe('deny')
expect(blocked.hookSpecificOutput.permissionDecisionReason).toContain('guard allow')
await writeAllow(SID, base)
expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('')
})
it('disables the cap when the threshold is null', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a'), assistantLine('b')])
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: null, hardUSD: null }, base)
expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('')
})
})
describe('yield checkpoint (Stop)', () => {
function stopInput(path: string): string {
return JSON.stringify({ session_id: SID, transcript_path: path, hook_event_name: 'Stop' })
}
async function withCheckpoint(base: string, path: string): Promise<void> {
const c = (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, checkpointUSD: c * 0.5 }, base)
}
it('fires once for an expensive no-edit no-commit session', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a'), assistantLine('b')])
await withCheckpoint(base, path)
const first = JSON.parse(await runGuardHook('stop', stopInput(path), { base }))
expect(first.systemMessage).toContain('no edits or commits')
expect(await runGuardHook('stop', stopInput(path), { base })).toBe('') // once per session
})
it('stays silent when cost is below the checkpoint', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a')])
const c = (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, checkpointUSD: c * 5 }, base)
expect(await runGuardHook('stop', stopInput(path), { base })).toBe('')
})
it('stays silent when the session made an edit', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a', { tools: [{ name: 'Edit', input: { file_path: '/x' } }] }), assistantLine('b')])
await withCheckpoint(base, path)
expect(await runGuardHook('stop', stopInput(path), { base })).toBe('')
})
it('stays silent when the session ran git commit', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a', { tools: [{ name: 'Bash', input: { command: 'git commit -m wip' } }] }), assistantLine('b')])
await withCheckpoint(base, path)
expect(await runGuardHook('stop', stopInput(path), { base })).toBe('')
})
})
describe('session opener (SessionStart)', () => {
it('emits the matching opener text for a flagged project and nothing when stale', async () => {
const base = await tmp()
const projectPath = '/tmp/flagged-project'
const flags: GuardFlags = { generatedAt: new Date().toISOString(), projects: [{ path: projectPath, openers: ['DELIVERABLE OPENER'] }] }
await writeFlags(flags, base)
const input = JSON.stringify({ session_id: SID, cwd: projectPath, hook_event_name: 'SessionStart', source: 'startup' })
const out = JSON.parse(await runGuardHook('sessionstart', input, { base }))
expect(out.hookSpecificOutput.hookEventName).toBe('SessionStart')
expect(out.hookSpecificOutput.additionalContext).toBe('DELIVERABLE OPENER')
// Unflagged cwd -> nothing.
const other = JSON.stringify({ session_id: SID, cwd: '/tmp/other', hook_event_name: 'SessionStart' })
expect(await runGuardHook('sessionstart', other, { base })).toBe('')
// Stale flag list (> 7 days) -> nothing.
const stale: GuardFlags = { generatedAt: new Date(Date.now() - 8 * 86_400_000).toISOString(), projects: flags.projects }
await writeFlags(stale, base)
expect(await runGuardHook('sessionstart', input, { base })).toBe('')
})
it('builds flags from optimize candidate detectors and matches by project path', async () => {
// A project whose only session has real spend but zero edit turns is a
// low-worth candidate; buildFlags should flag its projectPath.
const projects = [lowWorthProject()]
const flags = await buildFlags(projects as unknown as ProjectSummary[])
expect(flags.projects.length).toBeGreaterThan(0)
expect(matchFlag(flags, '/repo/alpha').length).toBeGreaterThan(0)
expect(matchFlag(flags, '/repo/alpha/src')).toEqual(matchFlag(flags, '/repo/alpha')) // subdir matches
expect(matchFlag(flags, '/repo/beta')).toEqual([])
})
})
describe('fail-open: malformed stdin', () => {
it('every handler exits with empty output on garbage input', async () => {
const base = await tmp()
for (const ev of ['pretooluse', 'sessionstart', 'stop']) {
expect(await runGuardHook(ev, 'not json {', { base })).toBe('')
expect(await runGuardHook(ev, '', { base })).toBe('')
}
expect(await runGuardHook('unknownevent', JSON.stringify({ session_id: SID }), { base })).toBe('')
expect(await runGuardStatusline('not json {', { base })).toBe('')
})
it('handlers stay silent when the transcript path is missing', async () => {
const base = await tmp()
const noPath = JSON.stringify({ session_id: SID, hook_event_name: 'PreToolUse' })
expect(await runGuardHook('pretooluse', noPath, { base })).toBe('')
expect(await runGuardHook('stop', noPath, { base })).toBe('')
})
})
describe('statusline', () => {
it('prints one line with the session cost', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a')])
const out = await runGuardStatusline(JSON.stringify({ session_id: SID, transcript_path: path }), { base })
expect(out.startsWith('codeburn guard $')).toBe(true)
expect(out).not.toContain('\n')
})
})
// A minimal ProjectSummary shaped just enough for findLowWorthCandidates:
// meaningful cost, no delivery command, and no edit turns.
function lowWorthProject(): Record<string, unknown> {
const turn = {
userMessage: 'do a thing',
assistantCalls: [{ costUSD: 40, tools: [], bashCommands: [], timestamp: '2026-07-01T00:00:00Z' }],
timestamp: '2026-07-01T00:00:00Z',
sessionId: 's-alpha',
category: 'exploration',
retries: 0,
hasEdits: false,
}
const session = {
sessionId: 's-alpha',
project: 'alpha',
firstTimestamp: '2026-07-01T00:00:00Z',
lastTimestamp: '2026-07-01T01:00:00Z',
totalCostUSD: 40,
totalSavingsUSD: 0,
totalInputTokens: 100, totalOutputTokens: 100, totalReasoningTokens: 0,
totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [turn],
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown: {}, skillBreakdown: {}, subagentBreakdown: {},
}
return {
project: 'alpha',
projectPath: '/repo/alpha',
sessions: [session],
totalCostUSD: 40, totalSavingsUSD: 0, totalApiCalls: 1, totalProxiedCostUSD: 0,
}
}
+158
View File
@@ -0,0 +1,158 @@
import { afterAll, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runAction } from '../src/act/apply.js'
import { readRecords } from '../src/act/journal.js'
import {
buildInstall, buildUninstall, inspectInstall, settingsPathFor,
GUARD_HOOK_PREFIX, GUARD_STATUSLINE_COMMAND,
} from '../src/guard/settings.js'
const roots: string[] = []
async function makeRoot(): Promise<{ settings: string; actionsDir: string }> {
const root = await mkdtemp(join(tmpdir(), 'codeburn-guard-install-'))
roots.push(root)
await mkdir(join(root, '.claude'), { recursive: true })
return { settings: join(root, '.claude', 'settings.json'), actionsDir: join(root, 'actions') }
}
afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) })
function canonical(obj: unknown): string {
return JSON.stringify(obj, null, 2) + '\n'
}
async function readJson(path: string): Promise<any> {
return JSON.parse(await readFile(path, 'utf-8'))
}
describe('settingsPathFor', () => {
it('resolves project (default) and global scopes', () => {
expect(settingsPathFor({ cwd: '/repo' })).toBe(join('/repo', '.claude', 'settings.json'))
expect(settingsPathFor({ project: '/x' })).toBe(join('/x', '.claude', 'settings.json'))
expect(settingsPathFor({ global: true })).toContain(join('.claude', 'settings.json'))
})
})
describe('guard install', () => {
it('creates settings with all three hook events when none exists', async () => {
const { settings, actionsDir } = await makeRoot()
await rm(settings, { force: true })
const built = buildInstall(settings)
expect(built.plan).not.toBeNull()
expect(built.plan!.kind).toBe('guard-install')
await runAction(built.plan!, actionsDir)
const doc = await readJson(settings)
expect(Object.keys(doc.hooks).sort()).toEqual(['PreToolUse', 'SessionStart', 'Stop'])
expect(doc.hooks.PreToolUse[0].hooks[0].command).toBe(`${GUARD_HOOK_PREFIX} pretooluse`)
expect(doc.hooks.SessionStart[0].matcher).toBe('startup')
expect(doc.hooks.Stop[0].matcher).toBeUndefined() // Stop takes no matcher
// journaled + undoable
const records = await readRecords(actionsDir)
expect(records[0]!.kind).toBe('guard-install')
})
it('appends to pre-existing user hooks without disturbing them', async () => {
const { settings, actionsDir } = await makeRoot()
const original = canonical({
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] },
})
await writeFile(settings, original)
await runAction(buildInstall(settings).plan!, actionsDir)
const doc = await readJson(settings)
const commands = doc.hooks.PreToolUse.flatMap((g: any) => g.hooks.map((h: any) => h.command))
expect(commands).toContain('my-own-hook.sh')
expect(commands).toContain(`${GUARD_HOOK_PREFIX} pretooluse`)
})
it('is idempotent: re-install adds nothing', async () => {
const { settings, actionsDir } = await makeRoot()
await rm(settings, { force: true })
await runAction(buildInstall(settings).plan!, actionsDir)
const again = buildInstall(settings)
expect(again.plan).toBeNull()
expect(again.notes.join(' ')).toContain('already present')
})
it('configures the statusline only when none exists', async () => {
const { settings, actionsDir } = await makeRoot()
await rm(settings, { force: true })
await runAction(buildInstall(settings, { statusline: true }).plan!, actionsDir)
expect((await readJson(settings)).statusLine.command).toBe(GUARD_STATUSLINE_COMMAND)
// A pre-existing statusline is refused, not overwritten.
const { settings: s2, actionsDir: a2 } = await makeRoot()
await writeFile(s2, canonical({ statusLine: { type: 'command', command: 'my-statusline.sh' } }))
const built = buildInstall(s2, { statusline: true })
expect(built.notes.join(' ')).toContain('statusline is already configured')
await runAction(built.plan!, a2)
expect((await readJson(s2)).statusLine.command).toBe('my-statusline.sh')
})
it('refuses to apply a plan when the settings file changed after it was built', async () => {
const { settings, actionsDir } = await makeRoot()
await writeFile(settings, canonical({ permissions: { allow: [] } }))
const built = buildInstall(settings)
expect(built.plan).not.toBeNull()
const concurrent = canonical({ permissions: { allow: ['Bash(ls:*)'] } })
await writeFile(settings, concurrent)
await expect(runAction(built.plan!, actionsDir)).rejects.toThrow(/changed since the plan was built/)
expect(await readFile(settings, 'utf-8')).toBe(concurrent) // the concurrent edit survives
expect(await readRecords(actionsDir)).toEqual([]) // nothing journaled
})
})
describe('guard uninstall', () => {
it('removes exactly our entries and restores byte-identical settings', async () => {
const { settings, actionsDir } = await makeRoot()
const original = canonical({
permissions: { allow: [] },
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] },
})
await writeFile(settings, original)
await runAction(buildInstall(settings, { statusline: true }).plan!, actionsDir)
// ours are present now
const mid = inspectInstall(settings)
expect(mid.hooks.sort()).toEqual(['PreToolUse', 'SessionStart', 'Stop'])
expect(mid.statusline).toBe(true)
await runAction(buildUninstall(settings).plan!, actionsDir)
expect(await readFile(settings, 'utf-8')).toBe(original) // byte-identical
const after = inspectInstall(settings)
expect(after.hooks).toEqual([])
expect(after.statusline).toBe(false)
})
it('preserves a user hook that shares the PreToolUse array', async () => {
const { settings, actionsDir } = await makeRoot()
await writeFile(settings, canonical({
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] },
}))
await runAction(buildInstall(settings).plan!, actionsDir)
await runAction(buildUninstall(settings).plan!, actionsDir)
const doc = await readJson(settings)
const commands = doc.hooks.PreToolUse.flatMap((g: any) => g.hooks.map((h: any) => h.command))
expect(commands).toEqual(['my-own-hook.sh'])
})
it('reports nothing to do on a settings file without our hooks', async () => {
const { settings } = await makeRoot()
await writeFile(settings, canonical({ hooks: {} }))
const built = buildUninstall(settings)
expect(built.plan).toBeNull()
expect(built.notes.join(' ')).toContain('no codeburn guard hooks')
})
it('reports nothing to do when the settings file is absent', async () => {
const { settings } = await makeRoot()
await rm(settings, { force: true })
expect(existsSync(settings)).toBe(false)
const built = buildUninstall(settings)
expect(built.plan).toBeNull()
})
})
+152
View File
@@ -0,0 +1,152 @@
// Regression test for the Kiro stale-cache path (#618, #619).
//
// Before this fix the Kiro parser returned 0 turns for every IDE execution
// file that stores content under `context.messages[].entries`. Those empty
// results were cached in session-cache.json keyed by file fingerprint, so
// shipping a fixed parser alone is not enough: unchanged files would keep
// their cached `turns: []` forever. The fix registers kiro in
// PROVIDER_PARSE_VERSIONS, which changes the provider envFingerprint and
// makes `parseProviderSources` discard the stale section on first run.
//
// This test exercises the full `parseAllSessions` pipeline against a seeded
// session-cache.json, in both directions:
// - a cache seeded with the CURRENT fingerprint is honored (zero-turn entry
// stays, proving the seed is structurally valid and actually trusted)
// - a cache seeded with the PRE-FIX fingerprint is discarded and the file
// is re-parsed, recovering the calls the broken parser missed
import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'
import { mkdir, rm, writeFile } from 'fs/promises'
import { createHash } from 'crypto'
import { join } from 'path'
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
import {
CACHE_VERSION,
computeEnvFingerprint,
fingerprintFile,
type SessionCache,
} from '../src/session-cache.js'
// The kiro provider singleton captures homedir() when its module is first
// imported, so HOME must point at the test root before ../src/parser.js is
// evaluated. vi.hoisted runs ahead of the static imports above (but after
// tests/setup/env-isolation.ts, whose per-test beforeEach re-sandboxes env
// vars — anything read at *call* time, like CODEBURN_CACHE_DIR, must be
// re-asserted in this file's own beforeEach).
const testRoot = vi.hoisted(() => {
const root = `${process.env['TMPDIR'] || '/tmp'}/kiro-cache-inv-${process.pid}-${Date.now()}`
process.env['HOME'] = `${root}/home`
process.env['USERPROFILE'] = `${root}/home`
return root
})
const HOME = join(testRoot, 'home')
const CACHE_DIR = join(testRoot, 'cache')
function kiroAgentDir(): string {
if (process.platform === 'darwin') {
return join(HOME, 'Library', 'Application Support', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent')
}
if (process.platform === 'win32') {
return join(HOME, 'AppData', 'Roaming', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent')
}
return join(HOME, '.config', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent')
}
// What computeEnvFingerprint('kiro') returned before kiro had an entry in
// PROVIDER_PARSE_VERSIONS: no env vars, no parser version, i.e. a hash of
// zero parts. This is the fingerprint sitting in every pre-fix cache.
function preFixFingerprint(): string {
return createHash('sha256').update([].join('\0')).digest('hex').slice(0, 16)
}
// Writes one IDE execution file in the context.messages[].entries format that
// the pre-fix parser turned into 0 turns, and returns its path.
async function seedExecutionFile(): Promise<string> {
const dir = join(kiroAgentDir(), 'a'.repeat(32), 'b'.repeat(32))
await mkdir(dir, { recursive: true })
const path = join(dir, 'exec-stale-001')
await writeFile(path, JSON.stringify({
executionId: 'exec-stale-001',
workflowType: 'chat-agent',
status: 'succeed',
startTime: 1780000000000,
chatSessionId: 'session-stale-001',
context: {
messages: [
{ role: 'human', entries: [{ type: 'text', text: 'What is TypeScript?' }] },
{ role: 'bot', entries: [{ type: 'text', text: 'TypeScript is a typed superset of JavaScript.' }] },
],
},
}))
return path
}
async function seedCache(execPath: string, envFingerprint: string): Promise<void> {
const fp = await fingerprintFile(execPath)
if (!fp) throw new Error('failed to fingerprint seeded execution file')
const cache: SessionCache = {
version: CACHE_VERSION,
providers: {
kiro: {
envFingerprint,
files: {
[execPath]: { fingerprint: fp, mcpInventory: [], turns: [] },
},
},
},
}
await mkdir(CACHE_DIR, { recursive: true })
await writeFile(join(CACHE_DIR, 'session-cache.json'), JSON.stringify(cache))
}
async function parseKiroCalls() {
const projects = await parseAllSessions(undefined, 'kiro')
return projects
.flatMap(p => p.sessions)
.flatMap(s => s.turns)
.flatMap(t => t.assistantCalls)
}
beforeEach(async () => {
// Runs after env-isolation's global beforeEach, which cleared this var.
process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR
clearSessionCache()
await rm(testRoot, { recursive: true, force: true })
})
afterAll(async () => {
clearSessionCache()
await rm(testRoot, { recursive: true, force: true })
})
describe('Kiro session cache invalidation', () => {
it('registers a kiro parser version in the env fingerprint', () => {
expect(computeEnvFingerprint('kiro')).not.toBe(preFixFingerprint())
})
it('control: a zero-turn cache entry at the current fingerprint is honored', async () => {
const execPath = await seedExecutionFile()
await seedCache(execPath, computeEnvFingerprint('kiro'))
const calls = await parseKiroCalls()
// The seeded cache is structurally valid and trusted: the unchanged file
// is not re-parsed, so the stale zero-turn entry yields no calls. This
// guards the regression test below against passing for the wrong reason
// (an invalid or unread seed being silently ignored).
expect(calls).toHaveLength(0)
})
it('regression: a pre-fix cache fingerprint forces a re-parse that recovers the calls', async () => {
const execPath = await seedExecutionFile()
await seedCache(execPath, preFixFingerprint())
const calls = await parseKiroCalls()
expect(calls).toHaveLength(1)
expect(calls[0]!.usage.inputTokens).toBeGreaterThan(0)
expect(calls[0]!.usage.outputTokens).toBeGreaterThan(0)
})
})
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, afterEach } from 'vitest'
import {
calculateCost,
calculateLocalModelSavings,
getLocalModelSavingsConfigHash,
getLocalSavingsBaseline,
loadPricing,
setLocalModelSavings,
} from '../src/models.js'
afterEach(() => setLocalModelSavings({}))
describe('setLocalModelSavings / getLocalSavingsBaseline', () => {
it('returns undefined when no mapping is configured', () => {
setLocalModelSavings({})
expect(getLocalSavingsBaseline('llama3.1:8b')).toBeUndefined()
})
it('returns the baseline name for a configured source model', () => {
setLocalModelSavings({ 'llama3.1:8b': 'gpt-4o' })
expect(getLocalSavingsBaseline('llama3.1:8b')).toBe('gpt-4o')
})
it('uses Object.hasOwn so __proto__ cannot be coerced via the prototype chain', () => {
// Regression for the prototype-pollution test: a hostile model name
// like `__proto__` used to resolve to Object.prototype because plain
// object bracket lookup walks the prototype chain.
setLocalModelSavings({})
expect(getLocalSavingsBaseline('__proto__')).toBeUndefined()
expect(getLocalSavingsBaseline('constructor')).toBeUndefined()
expect(getLocalSavingsBaseline('toString')).toBeUndefined()
})
it('refuses non-string keys defensively', () => {
setLocalModelSavings({})
expect(getLocalSavingsBaseline('' as unknown as string)).toBeUndefined()
expect(getLocalSavingsBaseline(undefined as unknown as string)).toBeUndefined()
})
it('getLocalModelSavingsConfigHash is stable across sort order and empty for no mappings', () => {
setLocalModelSavings({})
expect(getLocalModelSavingsConfigHash()).toBe('')
setLocalModelSavings({ a: 'gpt-4o', b: 'claude-opus-4-6' })
const h1 = getLocalModelSavingsConfigHash()
setLocalModelSavings({ b: 'claude-opus-4-6', a: 'gpt-4o' })
const h2 = getLocalModelSavingsConfigHash()
expect(h1).toBe(h2)
expect(h1).not.toBe('')
})
it('changes the hash when the baseline mapping changes', () => {
setLocalModelSavings({ 'llama3.1:8b': 'gpt-4o' })
const h1 = getLocalModelSavingsConfigHash()
setLocalModelSavings({ 'llama3.1:8b': 'gpt-5' })
const h2 = getLocalModelSavingsConfigHash()
expect(h1).not.toBe(h2)
})
})
describe('calculateLocalModelSavings', () => {
it('returns null when no mapping is configured for the model', () => {
setLocalModelSavings({})
const out = calculateLocalModelSavings('llama3.1:8b', 1_000_000, 200_000, 0, 0, 0)
expect(out).toBeNull()
})
it('returns null when the baseline model is unknown to the pricing snapshot', () => {
setLocalModelSavings({ 'llama3.1:8b': 'unknown-paid-model-xyz' })
const out = calculateLocalModelSavings('llama3.1:8b', 1_000, 1_000, 0, 0, 0)
expect(out).toBeNull()
})
it('returns the baseline cost as savings for a configured mapping', async () => {
await loadPricing()
setLocalModelSavings({ 'llama3.1:8b': 'gpt-4o' })
const expected = calculateCost('gpt-4o', 1_000_000, 200_000, 50_000, 800_000, 0)
const out = calculateLocalModelSavings('llama3.1:8b', 1_000_000, 200_000, 50_000, 800_000, 0)
expect(out).not.toBeNull()
expect(out!.savingsUSD).toBeCloseTo(expected)
expect(out!.baselineModel).toBe('gpt-4o')
})
it('respects speed and web-search inputs in the baseline calculation', async () => {
await loadPricing()
setLocalModelSavings({ local: 'gpt-4o' })
const standard = calculateLocalModelSavings('local', 1_000, 500, 0, 0, 2, 'standard')
expect(standard).not.toBeNull()
// Web search is a flat $0.01 per request, so the standard path with 2
// web search requests should include 2 cents of counterfactual spend.
expect(standard!.savingsUSD).toBeGreaterThan(0.02)
})
})
+656
View File
@@ -0,0 +1,656 @@
import { describe, it, expect } from 'vitest'
import {
aggregateMcpCoverage,
detectMcpProfileAdvisor,
detectMcpToolCoverage,
estimateMcpSchemaCost,
} from '../src/optimize.js'
import type {
ClassifiedTurn,
ParsedApiCall,
ProjectSummary,
SessionSummary,
TaskCategory,
TokenUsage,
} from '../src/types.js'
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
const ZERO_USAGE: TokenUsage = {
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
}
function makeCall(opts: {
tools?: string[]
cacheCreation?: number
cacheRead?: number
cost?: number
} = {}): ParsedApiCall {
const tools = opts.tools ?? []
return {
provider: 'claude',
model: 'Opus 4.7',
usage: {
...ZERO_USAGE,
cacheCreationInputTokens: opts.cacheCreation ?? 0,
cacheReadInputTokens: opts.cacheRead ?? 0,
},
costUSD: opts.cost ?? 0,
tools,
mcpTools: tools.filter(t => t.startsWith('mcp__')),
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-05-04T00:00:00Z',
bashCommands: [],
deduplicationKey: 'k',
}
}
function makeTurn(calls: ParsedApiCall[]): ClassifiedTurn {
return {
userMessage: '',
assistantCalls: calls,
timestamp: '2026-05-04T00:00:00Z',
sessionId: 's1',
category: 'coding',
retries: 0,
hasEdits: false,
}
}
function makeSession(opts: {
sessionId?: string
inventory?: string[]
turns?: ClassifiedTurn[]
mcpBreakdown?: Record<string, { calls: number }>
}): SessionSummary {
const turns = opts.turns ?? []
const apiCalls = turns.reduce((s, t) => s + t.assistantCalls.length, 0)
const emptyCategoryBreakdown = {} as Record<TaskCategory, { turns: number; costUSD: number; retries: number; editTurns: number; oneShotTurns: number }>
return {
sessionId: opts.sessionId ?? 's1',
project: 'p',
firstTimestamp: '2026-05-04T00:00:00Z',
lastTimestamp: '2026-05-04T00:00:00Z',
totalCostUSD: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls,
turns,
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: opts.mcpBreakdown ?? {},
bashBreakdown: {},
categoryBreakdown: emptyCategoryBreakdown,
skillBreakdown: {},
...(opts.inventory ? { mcpInventory: opts.inventory } : {}),
}
}
function project(sessions: SessionSummary[]): ProjectSummary {
return projectNamed('p', sessions)
}
function projectNamed(name: string, sessions: SessionSummary[]): ProjectSummary {
return {
project: name,
projectPath: `/tmp/${name}`,
sessions,
totalCostUSD: 0,
totalApiCalls: sessions.reduce((s, ses) => s + ses.apiCalls, 0),
}
}
// ---------------------------------------------------------------------------
// aggregateMcpCoverage
// ---------------------------------------------------------------------------
describe('aggregateMcpCoverage', () => {
it('returns empty list when no session has MCP inventory', () => {
const projects = [project([makeSession({})])]
expect(aggregateMcpCoverage(projects)).toEqual([])
})
it('reports per-server tools available, invoked, and unused', () => {
const inventory = [
'mcp__hf__hub_repo_search',
'mcp__hf__paper_search',
'mcp__hf__hf_doc_search',
]
const turns = [
makeTurn([makeCall({ tools: ['mcp__hf__hub_repo_search'] })]),
]
const sessions = [
makeSession({ inventory, turns, mcpBreakdown: { hf: { calls: 1 } } }),
]
const result = aggregateMcpCoverage([project(sessions)])
expect(result).toHaveLength(1)
expect(result[0]!.server).toBe('hf')
expect(result[0]!.toolsAvailable).toBe(3)
expect(result[0]!.toolsInvoked).toBe(1)
expect(result[0]!.unusedTools).toEqual([
'mcp__hf__hf_doc_search',
'mcp__hf__paper_search',
])
expect(result[0]!.coverageRatio).toBeCloseTo(1 / 3, 5)
expect(result[0]!.invocations).toBe(1)
expect(result[0]!.loadedSessions).toBe(1)
})
it('unions inventory across multiple sessions for the same server', () => {
const sessions = [
makeSession({ sessionId: 'a', inventory: ['mcp__x__a', 'mcp__x__b'] }),
makeSession({ sessionId: 'b', inventory: ['mcp__x__b', 'mcp__x__c'] }),
]
const result = aggregateMcpCoverage([project(sessions)])
expect(result[0]!.toolsAvailable).toBe(3)
expect(result[0]!.loadedSessions).toBe(2)
})
it('separates servers with similar names', () => {
const sessions = [
makeSession({ inventory: ['mcp__hf__a', 'mcp__hugface__a'] }),
]
const result = aggregateMcpCoverage([project(sessions)])
expect(result.map(r => r.server).sort()).toEqual(['hf', 'hugface'])
})
it('skips invocations without inventory (foreign server, no inventory observed)', () => {
// A server can show up only via a call. We still report it so the
// operator knows it was invoked, but coverage is 0/0 and it is not a
// candidate for the unused-coverage finding.
const turns = [makeTurn([makeCall({ tools: ['mcp__ghost__t1'] })])]
const sessions = [
makeSession({ turns, mcpBreakdown: { ghost: { calls: 1 } } }),
]
const result = aggregateMcpCoverage([project(sessions)])
// No inventory entry -> aggregator drops the server from the report
// because we cannot reason about coverage without an inventory baseline.
expect(result).toEqual([])
})
})
// ---------------------------------------------------------------------------
// estimateMcpSchemaCost — cache-aware accounting
// ---------------------------------------------------------------------------
describe('estimateMcpSchemaCost', () => {
it('charges first cacheCreation turn at full price, subsequent turns at cache-read', () => {
const turns = [
makeTurn([makeCall({ cacheCreation: 50_000 })]), // first turn: write
makeTurn([makeCall({ cacheRead: 60_000 })]), // ongoing: read
makeTurn([makeCall({ cacheRead: 60_000 })]),
]
const sessions = [makeSession({
inventory: Array.from({ length: 30 }, (_, i) => `mcp__svc__t${i}`),
turns,
mcpBreakdown: { svc: { calls: 0 } },
})]
// 30 unused tools * 400 token estimate = 12_000 schema tokens
// cap by call cache buckets so we never overclaim
const cost = estimateMcpSchemaCost(30, [project(sessions)], 'svc')
expect(cost.cacheWriteTokens).toBe(12_000) // capped by 50k creation, 12k schema fits
expect(cost.cacheReadTokens).toBe(24_000) // 12k + 12k across two ongoing turns
// effective = write * 1.25 + read * 0.10 (cache pricing)
expect(cost.effectiveInputTokens).toBeCloseTo(12_000 * 1.25 + 24_000 * 0.10, 5)
})
it('caps by available cache bucket so we never overclaim', () => {
const turns = [makeTurn([makeCall({ cacheCreation: 1_000 })])]
const sessions = [makeSession({
inventory: Array.from({ length: 30 }, (_, i) => `mcp__svc__t${i}`),
turns,
mcpBreakdown: { svc: { calls: 0 } },
})]
// 30*400 = 12k schema tokens, but the call only had 1k cache-creation,
// so we should not claim more than 1k of overhead for that turn.
const cost = estimateMcpSchemaCost(30, [project(sessions)], 'svc')
expect(cost.cacheWriteTokens).toBe(1_000)
})
it('returns zero when no unused tools', () => {
const sessions = [makeSession({
inventory: ['mcp__svc__t1'],
turns: [makeTurn([makeCall({ cacheCreation: 5000 })])],
})]
const cost = estimateMcpSchemaCost(0, [project(sessions)], 'svc')
expect(cost).toEqual({ cacheWriteTokens: 0, cacheReadTokens: 0, effectiveInputTokens: 0 })
})
it('counts cache write AND cache read on the same call', () => {
// A long session can have a cache rebuild mid-stream where one call
// reports both buckets. The estimator must charge both, not skip the
// read because of the write.
const turns = [makeTurn([
makeCall({ cacheCreation: 50_000, cacheRead: 30_000 }),
])]
const sessions = [makeSession({
inventory: Array.from({ length: 30 }, (_, i) => `mcp__svc__t${i}`),
turns,
mcpBreakdown: { svc: { calls: 0 } },
})]
const cost = estimateMcpSchemaCost(30, [project(sessions)], 'svc')
expect(cost.cacheWriteTokens).toBe(12_000) // capped at 50k creation
expect(cost.cacheReadTokens).toBe(12_000) // capped at 30k read
})
it('counts every cache rebuild, not just the first one', () => {
// Sessions that span more than 5 minutes can rebuild the cache
// multiple times. The estimator should treat every cacheCreation
// bucket as another write.
const turns = [makeTurn([
makeCall({ cacheCreation: 50_000 }),
makeCall({ cacheCreation: 50_000 }), // rebuild after cache TTL
makeCall({ cacheRead: 60_000 }),
])]
const sessions = [makeSession({
inventory: Array.from({ length: 30 }, (_, i) => `mcp__svc__t${i}`),
turns,
mcpBreakdown: { svc: { calls: 0 } },
})]
const cost = estimateMcpSchemaCost(30, [project(sessions)], 'svc')
expect(cost.cacheWriteTokens).toBe(24_000) // both rebuilds counted
expect(cost.cacheReadTokens).toBe(12_000)
})
it('skips sessions where the server was never loaded', () => {
const turns = [makeTurn([makeCall({ cacheCreation: 100_000 })])]
const sessions = [makeSession({
inventory: ['mcp__other__t1'],
turns,
})]
const cost = estimateMcpSchemaCost(10, [project(sessions)], 'svc')
expect(cost.cacheWriteTokens).toBe(0)
})
it('requires observed inventory for the server, not just invocations', () => {
// Session invoked the server (mcpBreakdown set, mcpTools called) but
// never reported a deferred_tools_delta for it. Cost should be 0 to
// stay consistent with aggregateMcpCoverage's loadedSessions rule.
const turns = [makeTurn([
makeCall({ tools: ['mcp__svc__t1'], cacheCreation: 100_000 }),
])]
const sessions = [makeSession({
// No inventory at all
turns,
mcpBreakdown: { svc: { calls: 1 } },
})]
const cost = estimateMcpSchemaCost(10, [project(sessions)], 'svc')
expect(cost.cacheWriteTokens).toBe(0)
expect(cost.cacheReadTokens).toBe(0)
})
it('caps combined unused-schema budget across multiple flagged servers', () => {
// Two flagged servers, each with 30 unused tools (12k schema each =
// 24k combined). One call has a 50k cache-creation bucket. The
// combined cap means total write tokens reported is min(24k, 50k) =
// 24k, not 24k + 24k = 48k.
const inventory = [
...Array.from({ length: 30 }, (_, i) => `mcp__a__t${i}`),
...Array.from({ length: 30 }, (_, i) => `mcp__b__t${i}`),
]
const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])]
const sessions = [makeSession({ inventory, turns })]
const cost = estimateMcpSchemaCost(
{ a: 30, b: 30 },
[project(sessions)],
['a', 'b'],
)
expect(cost.cacheWriteTokens).toBe(24_000)
})
it('still works with the single-server signature (backward compat)', () => {
const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])]
const sessions = [makeSession({
inventory: Array.from({ length: 30 }, (_, i) => `mcp__svc__t${i}`),
turns,
})]
const cost = estimateMcpSchemaCost(30, [project(sessions)], 'svc')
expect(cost.cacheWriteTokens).toBe(12_000)
})
})
// ---------------------------------------------------------------------------
// detectMcpToolCoverage — finding emission with thresholds
// ---------------------------------------------------------------------------
describe('detectMcpToolCoverage', () => {
it('returns null when no inventory exists at all', () => {
expect(detectMcpToolCoverage([project([makeSession({})])])).toBeNull()
})
it('does not flag a server with healthy coverage', () => {
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`)
const turns = [makeTurn(
Array.from({ length: 8 }, (_, i) => makeCall({ tools: [`mcp__svc__t${i}`] })),
)]
const sessions = [
makeSession({ sessionId: 'a', inventory, turns }),
makeSession({ sessionId: 'b', inventory, turns }),
]
// 8/20 = 40% coverage, above the 20% threshold -> no finding
expect(detectMcpToolCoverage([project(sessions)])).toBeNull()
})
it('does not flag a server with too few tools (signal too noisy)', () => {
// Below MCP_COVERAGE_MIN_TOOLS=10
const inventory = ['mcp__svc__a', 'mcp__svc__b']
const sessions = [
makeSession({ sessionId: 'a', inventory }),
makeSession({ sessionId: 'b', inventory }),
]
expect(detectMcpToolCoverage([project(sessions)])).toBeNull()
})
it('does not flag if seen in only one session (insufficient evidence)', () => {
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`)
const sessions = [makeSession({ inventory })]
expect(detectMcpToolCoverage([project(sessions)])).toBeNull()
})
it('flags a large server with low coverage across multiple sessions', () => {
const inventory = Array.from({ length: 30 }, (_, i) => `mcp__hf__t${i}`)
const turns = [makeTurn([
makeCall({ tools: ['mcp__hf__t0'], cacheCreation: 100_000 }),
])]
const sessions = [
makeSession({ sessionId: 'a', inventory, turns, mcpBreakdown: { hf: { calls: 1 } } }),
makeSession({ sessionId: 'b', inventory, turns, mcpBreakdown: { hf: { calls: 1 } } }),
]
const finding = detectMcpToolCoverage([project(sessions)])
expect(finding).not.toBeNull()
expect(finding!.title).toContain('1 MCP server')
expect(finding!.title).toContain('low tool coverage')
expect(finding!.explanation).toContain('hf')
expect(finding!.explanation).toContain('1/30')
expect(finding!.fix.type).toBe('command')
expect((finding!.fix as { text: string }).text).toContain("claude mcp remove 'hf'")
expect(finding!.tokensSaved).toBeGreaterThan(0)
})
it('escalates impact to high when token waste crosses the threshold', () => {
const inventory = Array.from({ length: 60 }, (_, i) => `mcp__big__t${i}`)
// 60 tools * 400 tokens = 24k schema. With many sessions and large
// cache-creation buckets, total effective tokens easily clear 200k.
const turns = [makeTurn([
makeCall({ tools: ['mcp__big__t0'], cacheCreation: 50_000 }),
makeCall({ cacheRead: 60_000 }),
makeCall({ cacheRead: 60_000 }),
])]
// Need enough sessions so the per-session ~28.8k effective tokens
// (24k write + 48k read × 0.10) sum past the 200k high-impact threshold.
const sessions = Array.from({ length: 8 }, (_, i) =>
makeSession({ sessionId: `s${i}`, inventory, turns, mcpBreakdown: { big: { calls: 1 } } }),
)
const finding = detectMcpToolCoverage([project(sessions)])
expect(finding).not.toBeNull()
expect(finding!.impact).toBe('high')
})
it('does not count invocation-only sessions toward loadedSessions', () => {
// Server `svc` has inventory in only one session, but is invoked in
// a second session that never observed the schema. Pre-fix this
// would have satisfied the >=2 session threshold; it must not now.
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`)
const turns = [makeTurn([
makeCall({ tools: ['mcp__svc__t0'], cacheCreation: 50_000 }),
])]
const sessions = [
makeSession({ sessionId: 'a', inventory, turns, mcpBreakdown: { svc: { calls: 1 } } }),
// No inventory — this shouldn't be considered a "loaded" session.
makeSession({ sessionId: 'b', turns, mcpBreakdown: { svc: { calls: 1 } } }),
]
expect(detectMcpToolCoverage([project(sessions)])).toBeNull()
})
it('does not let invocations of un-inventoried tools inflate coverage', () => {
// Inventory has 20 tools, none invoked. Calls hit a 21st tool that
// never appeared in any deferred_tools_delta (could be a renamed/
// removed tool from an older session config). Coverage must stay 0%
// and unusedCount must not go negative.
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`)
const turns = [makeTurn([makeCall({ tools: ['mcp__svc__ghost'] })])]
const sessions = [
makeSession({ sessionId: 'a', inventory, turns, mcpBreakdown: { svc: { calls: 1 } } }),
makeSession({ sessionId: 'b', inventory, turns, mcpBreakdown: { svc: { calls: 1 } } }),
]
const result = aggregateMcpCoverage([project(sessions)])
expect(result[0]!.toolsAvailable).toBe(20)
expect(result[0]!.toolsInvoked).toBe(0)
expect(result[0]!.coverageRatio).toBe(0)
expect(result[0]!.unusedTools).toHaveLength(20)
})
it('handles multiple flagged servers and pluralises the title', () => {
const sessions: SessionSummary[] = []
for (const server of ['svc1', 'svc2']) {
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`)
const turns = [makeTurn([
makeCall({ tools: [`mcp__${server}__t0`], cacheCreation: 50_000 }),
])]
sessions.push(
makeSession({ sessionId: `${server}-a`, inventory, turns, mcpBreakdown: { [server]: { calls: 1 } } }),
makeSession({ sessionId: `${server}-b`, inventory, turns, mcpBreakdown: { [server]: { calls: 1 } } }),
)
}
const finding = detectMcpToolCoverage([project(sessions)])
expect(finding).not.toBeNull()
expect(finding!.title).toContain('2 MCP servers')
expect((finding!.fix as { text: string }).text.split('\n')).toHaveLength(2)
})
})
// ---------------------------------------------------------------------------
// detectMcpProfileAdvisor — project-scoping recommendations
// ---------------------------------------------------------------------------
describe('detectMcpProfileAdvisor', () => {
const smallInventory = Array.from({ length: 4 }, (_, i) => `mcp__github__t${i}`)
it('flags a server loaded across projects but invoked only in one hot project', () => {
const hotTurns = [makeTurn([
makeCall({ tools: ['mcp__github__t0'], cacheCreation: 10_000 }),
makeCall({ tools: ['mcp__github__t1'], cacheCreation: 10_000 }),
])]
const coldTurns = [makeTurn([makeCall({ cacheCreation: 10_000 })])]
const projects = [
projectNamed('api', [
makeSession({ inventory: smallInventory, turns: hotTurns, mcpBreakdown: { github: { calls: 2 } } }),
]),
projectNamed('web', [
makeSession({ inventory: smallInventory, turns: coldTurns, mcpBreakdown: { github: { calls: 0 } } }),
]),
projectNamed('docs', [
makeSession({ inventory: smallInventory, turns: coldTurns, mcpBreakdown: { github: { calls: 0 } } }),
]),
]
const finding = detectMcpProfileAdvisor(projects)
expect(finding).not.toBeNull()
expect(finding!.title).toContain('project-scoped')
expect(finding!.explanation).toContain('github')
expect(finding!.explanation).toContain('/tmp/api')
expect(finding!.explanation).toContain('/tmp/web')
expect(finding!.explanation).toContain('/tmp/docs')
expect(finding!.tokensSaved).toBe(4000)
expect(finding!.fix.type).toBe('paste')
if (finding!.fix.type === 'paste') {
expect(finding!.fix.destination).toBe('prompt')
expect(finding!.fix.text).toContain('Keep github available for /tmp/api')
expect(finding!.fix.text).toContain('/tmp/web')
expect(finding!.fix.text).toContain('/tmp/docs')
}
})
it('does not flag servers used evenly across loaded projects', () => {
const projects = ['api', 'web', 'docs'].map(name => projectNamed(name, [
makeSession({
inventory: smallInventory,
turns: [makeTurn([makeCall({ tools: ['mcp__github__t0'], cacheCreation: 10_000 })])],
mcpBreakdown: { github: { calls: 2 } },
}),
]))
expect(detectMcpProfileAdvisor(projects)).toBeNull()
})
it('allows a hot profile shared by two projects', () => {
const projects = [
projectNamed('api', [
makeSession({
inventory: smallInventory,
turns: [makeTurn([
makeCall({ tools: ['mcp__github__t0'], cacheCreation: 10_000 }),
makeCall({ tools: ['mcp__github__t1'], cacheCreation: 10_000 }),
])],
mcpBreakdown: { github: { calls: 2 } },
}),
]),
projectNamed('web', [
makeSession({
inventory: smallInventory,
turns: [makeTurn([
makeCall({ tools: ['mcp__github__t0'], cacheCreation: 10_000 }),
makeCall({ tools: ['mcp__github__t1'], cacheCreation: 10_000 }),
])],
mcpBreakdown: { github: { calls: 2 } },
}),
]),
projectNamed('docs', [
makeSession({
inventory: smallInventory,
turns: [makeTurn([makeCall({ cacheCreation: 10_000 })])],
mcpBreakdown: { github: { calls: 0 } },
}),
]),
projectNamed('playground', [
makeSession({
inventory: smallInventory,
turns: [makeTurn([makeCall({ cacheCreation: 10_000 })])],
mcpBreakdown: { github: { calls: 0 } },
}),
]),
]
const finding = detectMcpProfileAdvisor(projects)
expect(finding).not.toBeNull()
expect(finding!.explanation).toContain('/tmp/api')
expect(finding!.explanation).toContain('/tmp/web')
expect(finding!.explanation).toContain('/tmp/docs')
expect(finding!.explanation).toContain('/tmp/playground')
})
it('caps profile savings once when multiple candidate servers share cold sessions', () => {
const githubInventory = Array.from({ length: 4 }, (_, i) => `mcp__github__t${i}`)
const slackInventory = Array.from({ length: 4 }, (_, i) => `mcp__slack__t${i}`)
const inventory = [...githubInventory, ...slackInventory]
const projects = [
projectNamed('api', [
makeSession({
inventory,
turns: [makeTurn([
makeCall({ tools: ['mcp__github__t0'] }),
makeCall({ tools: ['mcp__github__t1'] }),
makeCall({ tools: ['mcp__slack__t0'] }),
makeCall({ tools: ['mcp__slack__t1'] }),
])],
mcpBreakdown: { github: { calls: 2 }, slack: { calls: 2 } },
}),
]),
projectNamed('web', [
makeSession({
inventory,
turns: [makeTurn([makeCall({ cacheCreation: 2_000 })])],
mcpBreakdown: { github: { calls: 0 }, slack: { calls: 0 } },
}),
]),
projectNamed('docs', [
makeSession({
inventory,
turns: [makeTurn([makeCall({ cacheCreation: 2_000 })])],
mcpBreakdown: { github: { calls: 0 }, slack: { calls: 0 } },
}),
]),
]
const finding = detectMcpProfileAdvisor(projects)
expect(finding).not.toBeNull()
expect(finding!.title).toContain('2 MCP servers')
expect(finding!.explanation).toContain('github')
expect(finding!.explanation).toContain('slack')
expect(finding!.tokensSaved).toBe(5000)
})
it('requires at least three loaded projects before recommending a profile', () => {
const projects = [
projectNamed('api', [
makeSession({
inventory: smallInventory,
turns: [makeTurn([makeCall({ tools: ['mcp__github__t0'], cacheCreation: 10_000 })])],
mcpBreakdown: { github: { calls: 2 } },
}),
]),
projectNamed('web', [
makeSession({
inventory: smallInventory,
turns: [makeTurn([makeCall({ cacheCreation: 10_000 })])],
mcpBreakdown: { github: { calls: 0 } },
}),
]),
]
expect(detectMcpProfileAdvisor(projects)).toBeNull()
})
it('does not duplicate low tool coverage findings for the same server', () => {
const inventory = Array.from({ length: 12 }, (_, i) => `mcp__huge__t${i}`)
const projects = [
projectNamed('api', [
makeSession({
inventory,
turns: [makeTurn([makeCall({ tools: ['mcp__huge__t0'], cacheCreation: 20_000 })])],
mcpBreakdown: { huge: { calls: 3 } },
}),
]),
projectNamed('web', [
makeSession({
inventory,
turns: [makeTurn([makeCall({ cacheCreation: 20_000 })])],
mcpBreakdown: { huge: { calls: 0 } },
}),
]),
projectNamed('docs', [
makeSession({
inventory,
turns: [makeTurn([makeCall({ cacheCreation: 20_000 })])],
mcpBreakdown: { huge: { calls: 0 } },
}),
]),
]
const coverage = [{
server: 'huge',
toolsAvailable: 12,
toolsInvoked: 1,
unusedTools: Array.from({ length: 11 }, (_, i) => `mcp__huge__t${i + 1}`),
invocations: 3,
loadedSessions: 3,
coverageRatio: 1 / 12,
}]
expect(detectMcpProfileAdvisor(projects, coverage)).toBeNull()
})
})
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { pseudonym, redactProjectNames } from '../src/mcp/redact.js'
import type { MenubarPayload } from '../src/menubar-json.js'
function payload(): MenubarPayload {
const base = {
name: 'secret-client-repo', cost: 5, sessions: 2, avgCostPerSession: 2.5,
sessionDetails: [{ cost: 3, calls: 5, inputTokens: 100, outputTokens: 50, date: '2026-06-01', models: [{ name: 'Opus', cost: 3 }] }],
}
return {
generated: '', optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] }, history: { daily: [] },
current: {
label: 'Today', cost: 5, calls: 10, sessions: 2, oneShotRate: null, inputTokens: 0, outputTokens: 0,
cacheHitPercent: 0, topActivities: [], topModels: [], providers: {},
topProjects: [base], modelEfficiency: [],
topSessions: [{ project: 'secret-client-repo', cost: 5, calls: 10, date: '2026-06-01' }],
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
tools: [], skills: [], subagents: [], mcpServers: [],
},
} as MenubarPayload
}
describe('redact', () => {
it('pseudonym is stable and path-free', () => {
expect(pseudonym('a')).toBe(pseudonym('a'))
expect(pseudonym('secret-client-repo')).toMatch(/^project-[0-9a-f]{6}$/)
expect(pseudonym('a/b/c')).not.toContain('/')
})
it('hashes project names by default, preserves numbers', () => {
const out = redactProjectNames(payload(), false)
expect(out.current.topProjects[0]!.name).toMatch(/^project-[0-9a-f]{6}$/)
expect(out.current.topSessions[0]!.project).toMatch(/^project-[0-9a-f]{6}$/)
expect(out.current.topProjects[0]!.cost).toBe(5)
})
it('redacts session details when hashing', () => {
const out = redactProjectNames(payload(), false)
const details = out.current.topProjects[0]!.sessionDetails!
expect(details).toHaveLength(1)
expect(details[0]!.date).toBe('')
expect(details[0]!.models).toEqual([])
expect(details[0]!.cost).toBe(3)
})
it('same project name gets same pseudonym in topProjects and topSessions', () => {
const out = redactProjectNames(payload(), false)
expect(out.current.topProjects[0]!.name).toBe(out.current.topSessions[0]!.project)
})
it('keeps real names and session details when include=true', () => {
const out = redactProjectNames(payload(), true)
expect(out.current.topProjects[0]!.name).toBe('secret-client-repo')
expect(out.current.topProjects[0]!.sessionDetails![0]!.date).toBe('2026-06-01')
})
})
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { createServer } from '../src/mcp/server.js'
import type { MenubarPayload } from '../src/menubar-json.js'
function fakePayload(calls = 100): MenubarPayload {
return {
generated: '', optimize: { findingCount: 1, savingsUSD: 2, topFindings: [{ title: 'X', impact: 'high', savingsUSD: 2 }] }, history: { daily: [] },
current: {
label: 'Today', cost: 9, calls, sessions: 1, oneShotRate: 0.5, inputTokens: 10, outputTokens: 5, cacheHitPercent: 50,
topActivities: [{ name: 'feature', cost: 9, turns: 5, oneShotRate: 0.5 }], topModels: [{ name: 'Opus 4.8', cost: 9, calls }],
providers: { 'claude code': 9 }, topProjects: [{ name: 'real-repo', cost: 9, sessions: 1, avgCostPerSession: 9, sessionDetails: [] }],
modelEfficiency: [], topSessions: [{ project: 'real-repo', cost: 9, calls, date: '2026-06-01' }],
retryTax: { totalUSD: 1, retries: 2, editTurns: 5, byModel: [{ name: 'Opus 4.8', taxUSD: 1, retries: 2, retriesPerEdit: 0.4 }] },
routingWaste: { totalSavingsUSD: 1, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [] },
tools: [], skills: [], subagents: [], mcpServers: [],
},
} as MenubarPayload
}
async function connect(aggregate: (p: unknown, o: unknown) => Promise<MenubarPayload>) {
const server = createServer({ version: 'test', aggregate: aggregate as never })
const [a, b] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: 'test', version: '1' })
await Promise.all([server.connect(a), client.connect(b)])
return client
}
describe('mcp server', () => {
it('exposes exactly two read-only tools', async () => {
const client = await connect(async () => fakePayload())
const { tools } = await client.listTools()
expect(tools.map(t => t.name).sort()).toEqual(['get_savings', 'get_usage'])
expect(tools.find(t => t.name === 'get_usage')!.annotations?.readOnlyHint).toBe(true)
})
it('get_usage hashes project names by default', async () => {
const client = await connect(async () => fakePayload())
const res = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project' } })
expect(JSON.stringify(res)).not.toContain('real-repo')
expect(JSON.stringify(res)).toMatch(/project-[0-9a-f]{6}/)
expect(res.isError).toBeFalsy()
})
it('get_usage reveals names when opted in', async () => {
const client = await connect(async () => fakePayload())
const res = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project', include_project_names: true } })
expect(JSON.stringify(res)).toContain('real-repo')
})
it('empty data returns a friendly message, not a zero table', async () => {
const client = await connect(async () => fakePayload(0))
const res = await client.callTool({ name: 'get_usage', arguments: { period: 'today' } })
expect(String((res.content as Array<{ text: string }>)[0].text).toLowerCase()).toContain('no usage')
})
it('aggregator failure surfaces as isError', async () => {
const client = await connect(async () => { throw new Error('boom') })
const res = await client.callTool({ name: 'get_savings', arguments: {} })
expect(res.isError).toBe(true)
expect(JSON.stringify(res)).toContain('boom')
})
})
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { renderSummaryTable, renderBreakdownTable, renderSavingsTable } from '../src/mcp/tables.js'
import type { MenubarPayload } from '../src/menubar-json.js'
function payload(): MenubarPayload {
return {
generated: '', optimize: { findingCount: 1, savingsUSD: 2.5, topFindings: [{ title: 'Trim system prompt', impact: 'high', savingsUSD: 2.5 }] }, history: { daily: [] },
current: {
label: 'Last 7 Days', cost: 12.5, calls: 100, sessions: 4, oneShotRate: 0.5, inputTokens: 1000, outputTokens: 500,
cacheHitPercent: 80, topActivities: [{ name: 'feature', cost: 8, turns: 30, oneShotRate: 0.6 }],
topModels: [{ name: 'Opus 4.8', cost: 10, calls: 60 }], providers: { 'claude code': 12.5 },
topProjects: [{ name: 'project-abc123', cost: 12.5, sessions: 4, avgCostPerSession: 3.125, sessionDetails: [] }],
modelEfficiency: [], topSessions: [],
retryTax: { totalUSD: 1.2, retries: 4, editTurns: 20, byModel: [{ name: 'Opus 4.8', taxUSD: 1.2, retries: 4, retriesPerEdit: 0.2 }] },
routingWaste: { totalSavingsUSD: 3, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [{ name: 'Opus 4.8', costPerEdit: 0.05, editTurns: 20, actualUSD: 1, counterfactualUSD: 0.2, savingsUSD: 0.8 }] },
tools: [], skills: [], subagents: [], mcpServers: [],
},
} as MenubarPayload
}
describe('tables', () => {
it('summary shows headline cost and top models', () => {
const t = renderSummaryTable(payload())
expect(t).toContain('Last 7 Days')
expect(t).toContain('Opus 4.8')
expect(t).toContain('| Model | Cost | Calls |')
})
it('breakdown by provider lists providers', () => {
expect(renderBreakdownTable(payload(), 'provider', 20)).toContain('claude code')
})
it('breakdown handles empty dimension gracefully', () => {
const p = payload(); p.current.topActivities = []
expect(renderBreakdownTable(p, 'task', 20)).toContain('no data')
})
it('savings shows retry tax and routing waste', () => {
const t = renderSavingsTable(payload())
expect(t).toContain('Retry tax')
expect(t).toContain('Routing waste')
expect(t).toContain('Trim system prompt')
})
})
+163
View File
@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest'
import {
buildPersistentCodeburnLookupPath,
formatGitHubReleaseLookupError,
resolveLatestMenubarReleaseAssets,
resolveMenubarReleaseAssets,
resolvePersistentCodeburnPathFromWhichOutput,
resolveProxyUrlForUrl,
resolveVersionedMenubarReleaseAssets,
shouldFallbackToReleaseApi,
type ReleaseResponse,
} from '../src/menubar-installer.js'
function asset(name: string) {
return { name, browser_download_url: `https://example.test/${name}` }
}
describe('resolveMenubarReleaseAssets', () => {
it('ignores dev zips and pairs the checksum with the versioned zip', () => {
const release: ReleaseResponse = {
tag_name: 'mac-v0.9.8',
assets: [
asset('CodeBurnMenubar-dev.zip'),
asset('CodeBurnMenubar-dev.zip.sha256'),
asset('CodeBurnMenubar-v0.9.8.zip'),
asset('CodeBurnMenubar-v0.9.8.zip.sha256'),
],
}
const resolved = resolveMenubarReleaseAssets(release)
expect(resolved.zip.name).toBe('CodeBurnMenubar-v0.9.8.zip')
expect(resolved.checksum?.name).toBe('CodeBurnMenubar-v0.9.8.zip.sha256')
})
it('fails when a release only contains dev assets', () => {
const release: ReleaseResponse = {
tag_name: 'mac-v0.9.8',
assets: [
asset('CodeBurnMenubar-dev.zip'),
asset('CodeBurnMenubar-dev.zip.sha256'),
],
}
expect(() => resolveMenubarReleaseAssets(release)).toThrow(/versioned zip/)
})
it('fails when the versioned checksum is missing', () => {
const release: ReleaseResponse = {
tag_name: 'mac-v0.9.8',
assets: [
asset('CodeBurnMenubar-v0.9.8.zip'),
],
}
expect(() => resolveMenubarReleaseAssets(release)).toThrow(/Missing checksum/)
})
it('selects the newest mac release instead of the newest repo release', () => {
const releases: ReleaseResponse[] = [
{
tag_name: 'v0.9.9',
assets: [
asset('codeburn-0.9.9.tgz'),
],
},
{
tag_name: 'mac-v0.9.8',
assets: [
asset('CodeBurnMenubar-v0.9.8.zip'),
asset('CodeBurnMenubar-v0.9.8.zip.sha256'),
],
},
]
const resolved = resolveLatestMenubarReleaseAssets(releases)
expect(resolved.release.tag_name).toBe('mac-v0.9.8')
expect(resolved.zip.name).toBe('CodeBurnMenubar-v0.9.8.zip')
})
it('builds direct release asset URLs from the CLI version', () => {
const resolved = resolveVersionedMenubarReleaseAssets('0.9.15')
expect(resolved.release.tag_name).toBe('mac-v0.9.15')
expect(resolved.zip.name).toBe('CodeBurnMenubar-v0.9.15.zip')
expect(resolved.zip.browser_download_url).toBe(
'https://github.com/getagentseal/codeburn/releases/download/mac-v0.9.15/CodeBurnMenubar-v0.9.15.zip'
)
expect(resolved.checksum.name).toBe('CodeBurnMenubar-v0.9.15.zip.sha256')
expect(resolved.checksum.browser_download_url).toBe(
'https://github.com/getagentseal/codeburn/releases/download/mac-v0.9.15/CodeBurnMenubar-v0.9.15.zip.sha256'
)
})
it('normalizes a leading v when building direct release URLs', () => {
const resolved = resolveVersionedMenubarReleaseAssets('v0.9.15')
expect(resolved.release.tag_name).toBe('mac-v0.9.15')
expect(resolved.zip.name).toBe('CodeBurnMenubar-v0.9.15.zip')
})
it('falls back to the release API only for missing direct assets', () => {
expect(shouldFallbackToReleaseApi(404)).toBe(true)
expect(shouldFallbackToReleaseApi(410)).toBe(true)
expect(shouldFallbackToReleaseApi(403)).toBe(false)
expect(shouldFallbackToReleaseApi(429)).toBe(false)
expect(shouldFallbackToReleaseApi(500)).toBe(false)
})
it('explains likely rate limiting for GitHub API 403 and 429 errors', () => {
const headerValues: Record<string, string> = {
'retry-after': '120',
'x-ratelimit-reset': '1783539204',
}
const headers = { get: (name: string) => headerValues[name] ?? null }
expect(formatGitHubReleaseLookupError(403, headers)).toContain(
'GitHub may be rate limiting unauthenticated release API requests'
)
expect(formatGitHubReleaseLookupError(403, headers)).toContain('retry-after=120')
expect(formatGitHubReleaseLookupError(429, headers)).toContain('x-ratelimit-reset=1783539204')
})
it('preserves the caller PATH when building the persistent CLI lookup PATH', () => {
const lookupPath = buildPersistentCodeburnLookupPath('/Users/me/.nvm/versions/node/v22.13.0/bin:/usr/bin')
expect(lookupPath.split(':')).toContain('/Users/me/.nvm/versions/node/v22.13.0/bin')
expect(lookupPath.split(':')).toContain('/opt/homebrew/bin')
})
it('selects a persistent codeburn binary when npx is first in which output', () => {
const resolved = resolvePersistentCodeburnPathFromWhichOutput([
'/Users/me/.npm/_npx/abcd/node_modules/.bin/codeburn',
'/Users/me/.nvm/versions/node/v22.13.0/bin/codeburn',
].join('\n'))
expect(resolved).toBe('/Users/me/.nvm/versions/node/v22.13.0/bin/codeburn')
})
it('shows the install guidance instead of a raw env failure when only npx is available', () => {
expect(() => resolvePersistentCodeburnPathFromWhichOutput(
'/Users/me/.npm/_npx/abcd/node_modules/.bin/codeburn'
)).toThrow(/Install CodeBurn globally first/)
})
it('uses HTTPS proxy for GitHub HTTPS downloads', () => {
const proxyUrl = resolveProxyUrlForUrl('https://api.github.com/repos/getagentseal/codeburn/releases', {
HTTPS_PROXY: 'http://proxy.company.test:8080',
})
expect(proxyUrl).toBe('http://proxy.company.test:8080')
})
it('bypasses proxy when NO_PROXY matches the download host', () => {
const proxyUrl = resolveProxyUrlForUrl('https://api.github.com/repos/getagentseal/codeburn/releases', {
HTTPS_PROXY: 'http://proxy.company.test:8080',
NO_PROXY: '.github.com',
})
expect(proxyUrl).toBeUndefined()
})
})
+325
View File
@@ -0,0 +1,325 @@
import { describe, expect, it } from 'vitest'
import { buildMenubarPayload, type CombinedUsage, type PeriodData, type ProviderCost } from '../src/menubar-json.js'
import type { OptimizeResult } from '../src/optimize.js'
function emptyPeriod(label: string): PeriodData {
return {
label,
cost: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
categories: [],
models: [],
}
}
describe('buildMenubarPayload', () => {
it('emits the full schema with current-period metrics and iso timestamp', () => {
const period: PeriodData = {
label: '7 Days',
cost: 1248.01,
calls: 11231,
sessions: 97,
inputTokens: 19100,
outputTokens: 675600,
cacheReadTokens: 0,
cacheWriteTokens: 0,
categories: [],
models: [],
}
const payload = buildMenubarPayload(period, [], null)
expect(payload.generated).toMatch(/^\d{4}-\d{2}-\d{2}T/)
expect(payload.current.label).toBe('7 Days')
expect(payload.current.cost).toBe(1248.01)
expect(payload.current.calls).toBe(11231)
expect(payload.current.sessions).toBe(97)
expect(payload.current.inputTokens).toBe(19100)
expect(payload.current.outputTokens).toBe(675600)
})
it('exposes period-scoped cache tokens on current, decoupled from the 365-day history backfill (#583)', () => {
const period: PeriodData = {
label: '30 Days',
cost: 5, calls: 10, sessions: 2,
inputTokens: 1000, outputTokens: 2000,
// What `report -p 30days` shows in the terminal for the same window.
cacheReadTokens: 1_391_100_000,
cacheWriteTokens: 42_000_000,
categories: [], models: [],
}
// history.daily is the full BACKFILL_DAYS (365) trend, whose cache totals are
// far larger than the selected period. The web cards used to sum these, which
// is the bug in #583. current must mirror the period, not the backfill.
const dailyHistory = [
{ date: '2025-07-15', cost: 0, savingsUSD: 0, calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 1_800_000_000, cacheWriteTokens: 60_000_000, topModels: [] },
{ date: '2026-06-30', cost: 0, savingsUSD: 0, calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 270_000_000, cacheWriteTokens: 12_000_000, topModels: [] },
]
const payload = buildMenubarPayload(period, [], null, dailyHistory)
// current carries the period totals verbatim ...
expect(payload.current.cacheReadTokens).toBe(1_391_100_000)
expect(payload.current.cacheWriteTokens).toBe(42_000_000)
// ... and is independent of the larger history-backfill sum the cards used before.
const historyReadSum = dailyHistory.reduce((s, d) => s + d.cacheReadTokens, 0)
const historyWriteSum = dailyHistory.reduce((s, d) => s + d.cacheWriteTokens, 0)
expect(historyReadSum).toBeGreaterThan(payload.current.cacheReadTokens)
expect(historyWriteSum).toBeGreaterThan(payload.current.cacheWriteTokens)
})
it('computes per-category oneShotRate from editTurns and skips categories without edits', () => {
const period: PeriodData = {
label: 'Today',
cost: 0, calls: 0, sessions: 0,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
categories: [
{ name: 'Coding', cost: 15.83, turns: 7, editTurns: 7, oneShotTurns: 6 },
{ name: 'Conversation', cost: 16.69, turns: 47, editTurns: 0, oneShotTurns: 0 },
],
models: [],
}
const payload = buildMenubarPayload(period, [], null)
const coding = payload.current.topActivities.find(a => a.name === 'Coding')!
expect(coding.oneShotRate).toBeCloseTo(6 / 7)
const conv = payload.current.topActivities.find(a => a.name === 'Conversation')!
expect(conv.oneShotRate).toBeNull()
})
it('computes aggregate oneShotRate across categories with edits', () => {
const period: PeriodData = {
label: 'Today',
cost: 0, calls: 0, sessions: 0,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
categories: [
{ name: 'Coding', cost: 1, turns: 7, editTurns: 10, oneShotTurns: 8 },
{ name: 'Debugging', cost: 1, turns: 5, editTurns: 10, oneShotTurns: 6 },
{ name: 'Conversation', cost: 1, turns: 40, editTurns: 0, oneShotTurns: 0 },
],
models: [],
}
const payload = buildMenubarPayload(period, [], null)
expect(payload.current.oneShotRate).toBeCloseTo((8 + 6) / (10 + 10))
})
it('returns null aggregate oneShotRate when no categories have editTurns', () => {
const period: PeriodData = {
label: 'Today',
cost: 0, calls: 0, sessions: 0,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
categories: [{ name: 'Conversation', cost: 1, turns: 5, editTurns: 0, oneShotTurns: 0 }],
models: [],
}
const payload = buildMenubarPayload(period, [], null)
expect(payload.current.oneShotRate).toBeNull()
})
it('filters out the synthetic model and caps topModels at 20 so multi-model users see all their models', () => {
const models = Array.from({ length: 30 }, (_, i) => ({
name: `Model${i}`, cost: 30 - i, calls: 100,
}))
const period: PeriodData = {
label: 'Today',
cost: 0, calls: 0, sessions: 0,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
categories: [],
models: [{ name: '<synthetic>', cost: 99, calls: 0 }, ...models],
}
const payload = buildMenubarPayload(period, [], null)
expect(payload.current.topModels.find(m => m.name === '<synthetic>')).toBeUndefined()
expect(payload.current.topModels).toHaveLength(20)
expect(payload.current.topModels[0].name).toBe('Model0')
})
it('caps topActivities at 20 so all task categories can surface', () => {
const period: PeriodData = {
label: 'Today',
cost: 0, calls: 0, sessions: 0,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
categories: Array.from({ length: 25 }, (_, i) => ({
name: `Cat${i}`, cost: 1, turns: 1, editTurns: 1, oneShotTurns: 1,
})),
models: [],
}
const payload = buildMenubarPayload(period, [], null)
expect(payload.current.topActivities).toHaveLength(20)
})
it('computes cacheHitPercent from cache reads over input plus cache reads', () => {
const period: PeriodData = {
label: 'Today',
cost: 0, calls: 0, sessions: 0,
inputTokens: 100,
outputTokens: 200,
cacheReadTokens: 900,
cacheWriteTokens: 0,
categories: [],
models: [],
}
const payload = buildMenubarPayload(period, [], null)
expect(payload.current.cacheHitPercent).toBeCloseTo(90)
})
it('returns zero cacheHitPercent when there is no input or cache traffic', () => {
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
expect(payload.current.cacheHitPercent).toBe(0)
})
it('handles null optimize as empty findings block', () => {
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
expect(payload.optimize).toEqual({ findingCount: 0, savingsUSD: 0, topFindings: [] })
})
it('converts tokensSaved to savingsUSD via costRate and caps topFindings at 10', () => {
const findings = Array.from({ length: 15 }, (_, i) => ({
title: `F${i}`, explanation: '', impact: 'low' as const, tokensSaved: 1000,
fix: { type: 'paste' as const, label: '', text: '' },
}))
const optimize: OptimizeResult = {
findings,
costRate: 0.00002,
healthScore: 60,
healthGrade: 'C',
}
const payload = buildMenubarPayload(emptyPeriod('Today'), [], optimize)
expect(payload.optimize.findingCount).toBe(15)
expect(payload.optimize.topFindings).toHaveLength(10)
expect(payload.optimize.topFindings[0].title).toBe('F0')
expect(payload.optimize.topFindings[0].savingsUSD).toBeCloseTo(1000 * 0.00002)
expect(payload.optimize.savingsUSD).toBeCloseTo(15 * 1000 * 0.00002)
})
it('maps providers into a lowercased dict inside the current-period block', () => {
const providers: ProviderCost[] = [
{ name: 'Claude Code', cost: 76.45 },
{ name: 'Cursor', cost: 2.18 },
{ name: 'Codex', cost: 1.5 },
]
const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
expect(payload.current.providers).toEqual({ 'claude code': 76.45, cursor: 2.18, codex: 1.5 })
})
it('keeps zero-cost providers in the dict so installed-but-unused providers still render as tabs', () => {
const providers: ProviderCost[] = [
{ name: 'Claude', cost: 76.45 },
{ name: 'Codex', cost: 0 },
{ name: 'Cursor', cost: 2.18 },
]
const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
expect(payload.current.providers).toEqual({ claude: 76.45, codex: 0, cursor: 2.18 })
})
it('includes up to 365 daily history entries sorted ascending by date', () => {
const history = Array.from({ length: 400 }, (_, i) => {
const d = new Date(2025, 0, 1)
d.setDate(d.getDate() + i)
return {
date: d.toISOString().slice(0, 10),
cost: i,
calls: i * 10,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
}
})
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null, history)
expect(payload.history.daily).toHaveLength(365)
expect(payload.history.daily[0]!.date < payload.history.daily[364]!.date).toBe(true)
expect(payload.history.daily[364]!.date).toBe(history[399]!.date)
})
it('preserves token fields in dailyHistory entries', () => {
const history = [
{ date: '2026-04-15', cost: 10, calls: 50, inputTokens: 100, outputTokens: 200, cacheReadTokens: 5000, cacheWriteTokens: 800, topModels: [{ name: 'Opus 4.7', cost: 8, calls: 40, inputTokens: 80, outputTokens: 160 }] },
{ date: '2026-04-16', cost: 20, calls: 75, inputTokens: 150, outputTokens: 350, cacheReadTokens: 8000, cacheWriteTokens: 1200, topModels: [] },
]
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null, history)
expect(payload.history.daily[0]).toEqual(history[0])
expect(payload.history.daily[1]).toEqual(history[1])
})
it('returns empty history when none supplied', () => {
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
expect(payload.history.daily).toEqual([])
})
it('drops providers with negative cost defensively', () => {
const providers: ProviderCost[] = [
{ name: 'Claude', cost: 76.45 },
{ name: 'Broken', cost: -1 },
]
const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
expect(payload.current.providers).toEqual({ claude: 76.45 })
})
it('omits combined usage by default and accepts the documented combined shape when attached', () => {
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
expect(payload).not.toHaveProperty('combined')
const combined: CombinedUsage = {
perDevice: [
{
id: 'local',
name: 'Mac Studio',
local: true,
cost: 1,
calls: 2,
sessions: 1,
inputTokens: 100,
outputTokens: 50,
cacheCreateTokens: 10,
cacheReadTokens: 20,
totalTokens: 180,
},
],
combined: {
cost: 1,
calls: 2,
sessions: 1,
inputTokens: 100,
outputTokens: 50,
cacheCreateTokens: 10,
cacheReadTokens: 20,
totalTokens: 180,
deviceCount: 1,
reachableCount: 1,
},
}
payload.combined = combined
expect(payload.combined).toEqual(combined)
})
it('emits Claude config selector metadata only when multiple configs are available', () => {
const oneConfig = buildMenubarPayload(emptyPeriod('Today'), [], null, undefined, undefined, undefined, undefined, {
selectedId: null,
options: [{ id: 'claude-config:a', label: 'claude-work', path: '/tmp/claude-work' }],
})
expect(oneConfig).not.toHaveProperty('claudeConfigs')
const twoConfigs = buildMenubarPayload(emptyPeriod('Today'), [], null, undefined, undefined, undefined, undefined, {
selectedId: 'claude-config:b',
options: [
{ id: 'claude-config:a', label: 'claude-work', path: '/tmp/claude-work' },
{ id: 'claude-config:b', label: 'claude-personal', path: '/tmp/claude-personal' },
],
})
expect(twoConfigs.claudeConfigs).toEqual({
selectedId: 'claude-config:b',
options: [
{ id: 'claude-config:a', label: 'claude-work', path: '/tmp/claude-work' },
{ id: 'claude-config:b', label: 'claude-personal', path: '/tmp/claude-personal' },
],
})
})
})
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { buildMenubarPayload, type LocalModelSavings, type PeriodData } from '../src/menubar-json.js'
function basePeriod(overrides: Partial<PeriodData> = {}): PeriodData {
return {
label: '7 Days',
cost: 0,
savingsUSD: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
categories: [],
models: [],
...overrides,
}
}
describe('buildMenubarPayload: local-model savings', () => {
it('defaults localModelSavings to an empty block when no breakdown is provided', () => {
const payload = buildMenubarPayload(basePeriod(), [], null)
expect(payload.current.localModelSavings).toEqual({ totalUSD: 0, calls: 0, byModel: [], byProvider: [] })
})
it('threads the localModelSavings breakdown into the payload when supplied', () => {
const breakdown: LocalModelSavings = {
totalUSD: 12.34,
calls: 7,
byModel: [
{ name: 'llama3.1:8b', calls: 4, actualUSD: 0, savingsUSD: 7.21, baselineModel: 'gpt-4o', inputTokens: 1234, outputTokens: 567 },
{ name: 'qwen2.5:32b', calls: 3, actualUSD: 0, savingsUSD: 5.13, baselineModel: 'claude-opus-4-6', inputTokens: 4321, outputTokens: 876 },
],
byProvider: [
{ name: 'ollama', calls: 7, savingsUSD: 12.34 },
],
}
const payload = buildMenubarPayload(basePeriod(), [], null, undefined, undefined, undefined, {
localModelSavings: breakdown,
})
expect(payload.current.localModelSavings).toEqual(breakdown)
})
it('exposes savingsUSD and savingsBaselineModel on top models', () => {
const payload = buildMenubarPayload(basePeriod({
models: [
{ name: 'Local Model', cost: 0, savingsUSD: 5, calls: 1 },
{ name: 'gpt-4o', cost: 2, savingsUSD: 0, calls: 1 },
],
}), [], null)
const local = payload.current.topModels.find(m => m.name === 'Local Model')!
expect(local.savingsUSD).toBe(5)
expect(local.cost).toBe(0)
const paid = payload.current.topModels.find(m => m.name === 'gpt-4o')!
expect(paid.savingsUSD).toBe(0)
})
it('surfaces savingsUSD on top projects and top sessions', () => {
const payload = buildMenubarPayload(basePeriod({
cost: 2,
savingsUSD: 5,
projects: [
{ name: 'p', cost: 2, savingsUSD: 5, sessions: 1, sessionDetails: [{ cost: 2, savingsUSD: 5, calls: 1, inputTokens: 0, outputTokens: 0, date: '2026-04-10', models: [{ name: 'Local Model', cost: 0, savingsUSD: 5 }] }] },
],
topSessions: [{ project: 'p', cost: 2, savingsUSD: 5, calls: 1, date: '2026-04-10' }],
}), [], null)
const proj = payload.current.topProjects[0]!
expect(proj.savingsUSD).toBe(5)
expect(proj.sessionDetails[0]!.savingsUSD).toBe(5)
expect(proj.sessionDetails[0]!.models[0]!.savingsUSD).toBe(5)
const session = payload.current.topSessions[0]!
expect(session.savingsUSD).toBe(5)
})
it('emits savingsUSD and per-model breakdown in history entries', () => {
const payload = buildMenubarPayload(basePeriod(), [], null, [
{ date: '2026-04-10', cost: 2, savingsUSD: 5, calls: 1, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, topModels: [{ name: 'Local Model', cost: 0, savingsUSD: 5, calls: 1, inputTokens: 0, outputTokens: 0 }] },
])
expect(payload.history.daily[0]!.savingsUSD).toBe(5)
expect(payload.history.daily[0]!.topModels[0]!.savingsUSD).toBe(5)
})
it('keeps topActivities savingsUSD aligned with category rollups', () => {
const payload = buildMenubarPayload(basePeriod({
categories: [{ name: 'Coding', cost: 0, savingsUSD: 5, turns: 1, editTurns: 0, oneShotTurns: 0 }],
}), [], null)
expect(payload.current.topActivities[0]!.savingsUSD).toBe(5)
})
})
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { getModelCosts, getShortModelName } from '../src/models.js'
// Verifies MiniMax pricing loaded from FALLBACK_PRICING (no network call).
// pricingCache stays null until loadPricing() runs, so getModelCosts falls
// through to FALLBACK_PRICING which is what we want to validate here.
describe('MiniMax model pricing', () => {
it('returns pricing for MiniMax-M2.7', () => {
const costs = getModelCosts('MiniMax-M2.7')
expect(costs).not.toBeNull()
expect(costs!.inputCostPerToken).toBe(0.3e-6)
expect(costs!.outputCostPerToken).toBe(1.2e-6)
expect(costs!.cacheReadCostPerToken).toBe(0.06e-6)
expect(costs!.cacheWriteCostPerToken).toBe(0.375e-6)
expect(costs!.fastMultiplier).toBe(1)
})
it('returns pricing for MiniMax-M2.7-highspeed', () => {
const costs = getModelCosts('MiniMax-M2.7-highspeed')
expect(costs).not.toBeNull()
expect(costs!.inputCostPerToken).toBe(0.6e-6)
expect(costs!.outputCostPerToken).toBe(2.4e-6)
expect(costs!.cacheReadCostPerToken).toBe(0.06e-6)
expect(costs!.cacheWriteCostPerToken).toBe(0.375e-6)
expect(costs!.fastMultiplier).toBe(1)
})
it('returns official pricing for MiniMax-M3', () => {
// MiniMax moved M3 to tiered pricing (platform.minimax.io pay-as-you-go):
// $0.3/$1.2 per M is the official standard tier (inputs up to 512K), with
// $0.6/$2.4 above 512K. The snapshot carries the standard tier, matching
// how other tiered models are priced here.
const costs = getModelCosts('MiniMax-M3')
expect(costs).not.toBeNull()
expect(costs!.inputCostPerToken).toBe(0.3e-6)
expect(costs!.outputCostPerToken).toBe(1.2e-6)
})
it('highspeed pricing is distinct from base model pricing', () => {
const base = getModelCosts('MiniMax-M2.7')
const fast = getModelCosts('MiniMax-M2.7-highspeed')
expect(fast!.inputCostPerToken).toBeGreaterThan(base!.inputCostPerToken)
expect(fast!.outputCostPerToken).toBeGreaterThan(base!.outputCostPerToken)
})
it('returns short name for MiniMax-M2.7', () => {
expect(getShortModelName('MiniMax-M2.7')).toBe('MiniMax M2.7')
})
it('returns short name for MiniMax-M2.7-highspeed', () => {
expect(getShortModelName('MiniMax-M2.7-highspeed')).toBe('MiniMax M2.7 Highspeed')
})
it('handles MiniMax model ID with date suffix', () => {
expect(getShortModelName('MiniMax-M2.7-20260101')).toBe('MiniMax M2.7')
})
})
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest'
import { aggregateModelEfficiency } from '../src/model-efficiency.js'
import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary } from '../src/types.js'
function call(model: string, costUSD = 1): ParsedApiCall {
return {
provider: 'claude',
model,
usage: {
inputTokens: 100,
outputTokens: 50,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD,
tools: ['Edit'],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-05-05T00:00:00Z',
bashCommands: [],
deduplicationKey: `${model}-${costUSD}`,
}
}
function turn(model: string, opts: { hasEdits?: boolean; retries?: number; costUSD?: number } = {}): ClassifiedTurn {
return {
userMessage: '',
assistantCalls: [call(model, opts.costUSD ?? 1)],
timestamp: '2026-05-05T00:00:00Z',
sessionId: 's1',
category: 'coding',
retries: opts.retries ?? 0,
hasEdits: opts.hasEdits ?? true,
}
}
function multiModelTurn(calls: ParsedApiCall[], opts: { retries?: number; hasEdits?: boolean } = {}): ClassifiedTurn {
return {
userMessage: '',
assistantCalls: calls,
timestamp: '2026-05-05T00:00:00Z',
sessionId: 's1',
category: 'coding',
retries: opts.retries ?? 0,
hasEdits: opts.hasEdits ?? true,
}
}
function project(turns: ClassifiedTurn[]): ProjectSummary {
const session: SessionSummary = {
sessionId: 's1',
project: 'app',
firstTimestamp: '2026-05-05T00:00:00Z',
lastTimestamp: '2026-05-05T00:00:00Z',
totalCostUSD: turns.reduce((sum, t) => sum + t.assistantCalls.reduce((s, c) => s + c.costUSD, 0), 0),
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: turns.reduce((sum, t) => sum + t.assistantCalls.length, 0),
turns,
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
skillBreakdown: {},
}
return {
project: 'app',
projectPath: '/app',
sessions: [session],
totalCostUSD: session.totalCostUSD,
totalApiCalls: session.apiCalls,
}
}
describe('aggregateModelEfficiency', () => {
it('computes one-shot, retry, and cost-per-edit metrics by display model', () => {
const stats = aggregateModelEfficiency([project([
turn('claude-sonnet-4-5', { hasEdits: true, retries: 0, costUSD: 2 }),
turn('claude-sonnet-4-5', { hasEdits: true, retries: 2, costUSD: 4 }),
turn('claude-opus-4-6', { hasEdits: true, retries: 0, costUSD: 10 }),
turn('claude-sonnet-4-5', { hasEdits: false, retries: 0, costUSD: 3 }),
])])
const sonnet = stats.get('Sonnet 4.5')
expect(sonnet?.editTurns).toBe(2)
expect(sonnet?.oneShotTurns).toBe(1)
expect(sonnet?.oneShotRate).toBe(50)
expect(sonnet?.retriesPerEdit).toBe(1)
expect(sonnet?.costPerEditUSD).toBe(3)
const opus = stats.get('Opus 4.6')
expect(opus?.oneShotRate).toBe(100)
})
it('returns no stats for non-edit turns', () => {
const stats = aggregateModelEfficiency([project([
turn('claude-sonnet-4-5', { hasEdits: false }),
])])
expect(stats.size).toBe(0)
})
it('attributes a multi-model turn to the first non-synthetic model', () => {
const stats = aggregateModelEfficiency([project([
multiModelTurn([
call('<synthetic>', 0),
call('claude-opus-4-6', 2),
call('claude-sonnet-4-5', 1),
], { retries: 0, hasEdits: true }),
])])
expect(stats.has('Opus 4.6')).toBe(true)
expect(stats.has('Sonnet 4.5')).toBe(false)
expect(stats.has('<synthetic>')).toBe(false)
const opus = stats.get('Opus 4.6')!
expect(opus.editTurns).toBe(1)
expect(opus.oneShotTurns).toBe(1)
expect(opus.costPerEditUSD).toBe(3)
})
it('skips a turn whose calls are all synthetic', () => {
const stats = aggregateModelEfficiency([project([
multiModelTurn([
call('<synthetic>', 0),
call('<synthetic>', 0),
], { retries: 0, hasEdits: true }),
])])
expect(stats.size).toBe(0)
})
})
+132
View File
@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest'
import { calculateCost, getModelCosts, getShortModelName } from '../src/models.js'
// Lock down the post-hoist refactor: every model name a real user has
// emitted in the last year should resolve to the same display name and
// the same costs as before. If this list grows or shrinks, the refactor
// is fine — it's the per-name resolution that must stay stable.
const KNOWN_NAMES = [
'claude-opus-4-7',
'claude-opus-4-6',
'claude-opus-4-5',
'claude-sonnet-4-6',
'claude-sonnet-4-5',
'claude-haiku-4-5',
'claude-3-5-sonnet',
'claude-3-5-haiku',
'claude-opus-4-7-20250101',
'claude-sonnet-4-6-20250929',
'anthropic/claude-opus-4-7',
'anthropic--claude-4.6-opus',
'anthropic--claude-4.6-sonnet',
'claude-4.6-sonnet',
'gpt-5',
'gpt-5-mini',
'gpt-5-nano',
'gpt-5-pro',
'gpt-5.1',
'gpt-5.1-codex',
'gpt-5.1-codex-mini',
'gpt-5.2',
'gpt-5.2-low',
'gpt-5.3-codex',
'gpt-5.4',
'gpt-5.4-mini',
'gpt-4o',
'gpt-4o-mini',
'gpt-4.1',
'gpt-4.1-mini',
'gpt-4.1-nano',
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-3.1-pro-preview',
'gemini-3-flash-preview',
'gemini-3.1-pro',
'gemini-3-flash',
'cursor-auto',
'cursor-agent-auto',
'copilot-auto',
'copilot-openai-auto',
'kiro-auto',
'cline-auto',
'qwen-auto',
'kimi-auto',
'kimi-for-coding',
'kimi-k2-thinking-turbo',
'kimi-k2.6',
'o3',
'o4-mini',
'deepseek-coder',
'deepseek-coder-max',
'deepseek-r1',
'MiniMax-M2.7',
'MiniMax-M2.7-highspeed',
]
describe('post-hoist resolution stability', () => {
it('every known model resolves to a non-empty short name', () => {
for (const name of KNOWN_NAMES) {
const short = getShortModelName(name)
expect(short, `short name for ${name}`).toBeTruthy()
expect(typeof short, `short name for ${name}`).toBe('string')
}
})
it('gpt-5-mini does NOT collide with gpt-5 (longest-prefix wins)', () => {
expect(getShortModelName('gpt-5-mini')).toBe('GPT-5 Mini')
expect(getShortModelName('gpt-5')).toBe('GPT-5')
expect(getShortModelName('gpt-5-nano')).toBe('GPT-5 Nano')
expect(getShortModelName('gpt-5-pro')).toBe('GPT-5 Pro')
})
it('gpt-5.1-codex-mini does NOT collapse to gpt-5.1-codex or gpt-5', () => {
expect(getShortModelName('gpt-5.1-codex-mini')).toBe('GPT-5.1 Codex Mini')
expect(getShortModelName('gpt-5.1-codex')).toBe('GPT-5.1 Codex')
expect(getShortModelName('gpt-5.1')).toBe('GPT-5.1')
})
it('claude-haiku-4-5 does NOT collapse to claude-haiku-4 or claude-3-5-haiku', () => {
expect(getShortModelName('claude-haiku-4-5')).toBe('Haiku 4.5')
expect(getShortModelName('claude-3-5-haiku')).toBe('Haiku 3.5')
})
it('kimi managed aliases resolve to priced Kimi models', () => {
expect(getShortModelName('kimi-auto')).toBe('Kimi (auto)')
expect(getShortModelName('kimi-for-coding')).toBe('Kimi K2 Thinking')
expect(getShortModelName('kimi-k2-thinking-turbo')).toBe('Kimi K2 Thinking Turbo')
expect(getShortModelName('kimi-k2.6')).toBe('Kimi K2.6')
expect(getModelCosts('kimi-auto')?.inputCostPerToken).toBeGreaterThan(0)
})
it('getModelCosts returns positive token costs for every known name', () => {
for (const name of KNOWN_NAMES) {
const c = getModelCosts(name)
expect(c, `costs for ${name}`).not.toBeNull()
expect(c!.inputCostPerToken).toBeGreaterThan(0)
expect(c!.outputCostPerToken).toBeGreaterThan(0)
}
})
it('calculateCost is stable for a typical Sonnet 4.6 turn', () => {
// 1k input, 2k output, 50k cache read — common Claude Code shape.
const cost = calculateCost('claude-sonnet-4-6', 1000, 2000, 0, 50_000, 0)
expect(cost).toBeGreaterThan(0)
expect(Number.isFinite(cost)).toBe(true)
})
it('calculateCost clamps NaN/negative inputs to 0', () => {
const c1 = calculateCost('claude-sonnet-4-6', NaN, 1000, 0, 0, 0)
const c2 = calculateCost('claude-sonnet-4-6', 0, 1000, 0, 0, 0)
expect(c1).toBe(c2)
const c3 = calculateCost('claude-sonnet-4-6', -1000, 1000, 0, 0, 0)
expect(c3).toBe(c2)
})
it('repeated calls return the same cost (memoized sort cache is consistent)', () => {
const a = getModelCosts('gpt-5-mini')
const b = getModelCosts('gpt-5-mini')
const c = getModelCosts('gpt-5-mini')
expect(a).toEqual(b)
expect(b).toEqual(c)
})
})
+513
View File
@@ -0,0 +1,513 @@
import { describe, it, expect } from 'vitest'
import chalk from 'chalk'
import stripAnsi from 'strip-ansi'
import { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv, type ModelReportRow } from '../src/models-report.js'
import type {
ProjectSummary,
SessionSummary,
ClassifiedTurn,
ParsedApiCall,
TokenUsage,
TaskCategory,
} from '../src/types.js'
function emptyTokens(): TokenUsage {
return {
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
}
}
function makeCall(opts: {
provider: string
model: string
costUSD: number
input?: number
output?: number
cacheWrite?: number
cacheRead?: number
}): ParsedApiCall {
return {
provider: opts.provider,
model: opts.model,
usage: {
...emptyTokens(),
inputTokens: opts.input ?? 0,
outputTokens: opts.output ?? 0,
cacheCreationInputTokens: opts.cacheWrite ?? 0,
cacheReadInputTokens: opts.cacheRead ?? 0,
},
costUSD: opts.costUSD,
tools: [],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-05-09T00:00:00.000Z',
bashCommands: [],
deduplicationKey: `${opts.provider}-${opts.model}-${opts.costUSD}`,
}
}
function makeTurn(category: TaskCategory, calls: ParsedApiCall[]): ClassifiedTurn {
return {
userMessage: 'test',
assistantCalls: calls,
timestamp: '2026-05-09T00:00:00.000Z',
sessionId: 's1',
category,
retries: 0,
hasEdits: false,
}
}
function makeSession(turns: ClassifiedTurn[]): SessionSummary {
return {
sessionId: 's1',
project: 'p',
firstTimestamp: '2026-05-09T00:00:00.000Z',
lastTimestamp: '2026-05-09T00:00:00.000Z',
totalCostUSD: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 0,
turns,
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
skillBreakdown: {},
}
}
function makeProject(turns: ClassifiedTurn[]): ProjectSummary {
return {
project: 'p',
projectPath: '/tmp/p',
sessions: [makeSession(turns)],
totalCostUSD: 0,
totalApiCalls: 0,
}
}
describe('aggregateModels', () => {
it('groups by (provider, model) and sorts by cost descending in default mode', async () => {
const project = makeProject([
makeTurn('feature', [
makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', input: 1000, output: 200, cacheWrite: 500, cacheRead: 8000, costUSD: 5.0 }),
]),
makeTurn('debugging', [
makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', input: 800, output: 100, cacheWrite: 300, cacheRead: 5000, costUSD: 3.5 }),
]),
makeTurn('feature', [
makeCall({ provider: 'codex', model: 'gpt-5', input: 600, output: 80, costUSD: 1.2 }),
]),
])
const rows = await aggregateModels([project])
expect(rows.map(r => `${r.provider}:${r.model}`)).toEqual(['claude:claude-sonnet-4-6', 'codex:gpt-5'])
const claudeRow = rows[0]!
expect(claudeRow.inputTokens).toBe(1800)
expect(claudeRow.outputTokens).toBe(300)
expect(claudeRow.cacheWriteTokens).toBe(800)
expect(claudeRow.cacheReadTokens).toBe(13000)
expect(claudeRow.costUSD).toBeCloseTo(8.5, 6)
expect(claudeRow.calls).toBe(2)
expect(claudeRow.totalTokens).toBe(1800 + 300 + 800 + 13000)
})
it('computes Codex credits per model and leaves non-Codex / unknown models null', async () => {
const rows = await aggregateModels([makeProject([
// gpt-5.5: 1M non-cached input (125) + 1M cached read (12.5) + 1M output (750) = 887.5 credits
makeTurn('feature', [
makeCall({ provider: 'codex', model: 'gpt-5.5', input: 1_000_000, output: 1_000_000, cacheRead: 1_000_000, costUSD: 9 }),
]),
// codex but no known credit rate -> null
makeTurn('feature', [
makeCall({ provider: 'codex', model: 'gpt-5', input: 1000, output: 80, costUSD: 1.2 }),
]),
// non-codex provider -> null even if tokens present
makeTurn('feature', [
makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', input: 1000, output: 200, costUSD: 5 }),
]),
])])
const byKey = Object.fromEntries(rows.map(r => [`${r.provider}:${r.model}`, r]))
expect(byKey['codex:gpt-5.5']!.credits).toBeCloseTo(887.5, 6)
expect(byKey['codex:gpt-5']!.credits).toBeNull()
expect(byKey['claude:claude-sonnet-4-6']!.credits).toBeNull()
})
it('includes credits in the JSON output', async () => {
const rows = await aggregateModels([makeProject([
makeTurn('feature', [
makeCall({ provider: 'codex', model: 'gpt-5.5', input: 0, output: 1_000_000, cacheRead: 0, costUSD: 9 }),
]),
])])
const parsed = JSON.parse(renderJson(rows))
expect(parsed[0].credits).toBeCloseTo(750, 6)
})
it('does not double-count cache reads when a provider sets both cache fields', async () => {
// Providers like codex/mux/codebuff populate cacheReadInputTokens AND
// cachedInputTokens with the same value (Anthropic vs OpenAI vocabulary for
// the same tokens). The report must count them once, not sum them.
const call = makeCall({ provider: 'mux', model: 'claude-opus-4-8', input: 100, output: 50, cacheRead: 4000, costUSD: 2.0 })
call.usage.cachedInputTokens = 4000 // mirrors cacheReadInputTokens, as those providers do
const rows = await aggregateModels([makeProject([makeTurn('feature', [call])])])
expect(rows[0]!.cacheReadTokens).toBe(4000) // not 8000
})
it('reports the dominant task type with its cost share in default mode', async () => {
const project = makeProject([
makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 6.0, input: 100, output: 20 })]),
makeTurn('debugging', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]),
makeTurn('refactoring', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]),
])
const rows = await aggregateModels([project])
expect(rows[0]!.topCategory).toBe('feature')
expect(rows[0]!.topCategoryShare).toBeCloseTo(0.6, 3)
})
it('explodes rows by task in byTask mode and groups them so renderer can blank repeats', async () => {
const project = makeProject([
makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 6.0, input: 100, output: 20 })]),
makeTurn('debugging', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]),
makeTurn('feature', [makeCall({ provider: 'codex', model: 'gpt-5', costUSD: 1.0, input: 60, output: 10 })]),
])
const rows = await aggregateModels([project], { byTask: true })
expect(rows).toHaveLength(3)
// Group order: claude (8.0) before codex (1.0); within claude, feature (6.0) before debugging (2.0).
expect(rows.map(r => `${r.provider}:${r.model}:${r.category}`)).toEqual([
'claude:claude-sonnet-4-6:feature',
'claude:claude-sonnet-4-6:debugging',
'codex:gpt-5:feature',
])
})
it('respects taskFilter by excluding non-matching turns from every bucket', async () => {
const project = makeProject([
makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 5.0, input: 100, output: 20 })]),
makeTurn('debugging', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]),
])
const rows = await aggregateModels([project], { taskFilter: 'feature' })
expect(rows).toHaveLength(1)
expect(rows[0]!.costUSD).toBeCloseTo(5.0, 6)
})
it('applies topN and minCost filters', async () => {
const project = makeProject([
makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 5.0, input: 100, output: 20 })]),
makeTurn('feature', [makeCall({ provider: 'codex', model: 'gpt-5', costUSD: 0.5, input: 50, output: 10 })]),
makeTurn('feature', [makeCall({ provider: 'cursor', model: 'auto', costUSD: 0.001, input: 10, output: 1 })]),
])
const top = await aggregateModels([project], { topN: 1 })
expect(top).toHaveLength(1)
const above = await aggregateModels([project], { minCost: 0.01 })
expect(above.find(r => r.provider === 'cursor')).toBeUndefined()
})
it('counts reasoning tokens as output tokens', async () => {
const project = makeProject([
makeTurn('feature', [
{
provider: 'codex',
model: 'gpt-5',
usage: { ...emptyTokens(), inputTokens: 100, outputTokens: 50, reasoningTokens: 200 },
costUSD: 1.0,
tools: [],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-05-09T00:00:00.000Z',
bashCommands: [],
deduplicationKey: 'k',
},
]),
])
const rows = await aggregateModels([project])
expect(rows[0]!.outputTokens).toBe(250)
})
})
describe('renderTable', () => {
function visibleWidth(line: string): number {
return stripAnsi(line).length
}
function row(partial: Partial<ModelReportRow>): ModelReportRow {
return {
provider: 'claude',
providerDisplayName: 'Claude',
model: 'claude-sonnet-4-6',
modelDisplayName: 'Sonnet 4.6',
category: null,
inputTokens: 0,
outputTokens: 0,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
costUSD: 0,
savingsUSD: 0,
savingsBaselineModel: '',
calls: 0,
credits: null,
...partial,
}
}
it('blanks repeated provider/model cells in byTask mode but keeps them in default mode', () => {
const rows: ModelReportRow[] = [
row({ category: 'feature', costUSD: 7.78, inputTokens: 512_000, outputTokens: 98_000, cacheWriteTokens: 1_400_000, cacheReadTokens: 6_200_000, totalTokens: 8_210_000 }),
row({ category: 'debugging', costUSD: 5.31, inputTokens: 380_000, outputTokens: 71_000, cacheWriteTokens: 920_000, cacheReadTokens: 4_100_000, totalTokens: 5_471_000 }),
]
const out = renderTable(rows, { byTask: true, showTotals: false, terminalWidth: 200 })
const lines = out.split('\n')
// Layout: top border, header, header-separator, data..., bottom border.
const dataLines = lines.slice(3, -1)
expect(dataLines[0]).toContain('Sonnet 4.6')
expect(dataLines[0]).toContain('Feature Dev')
expect(dataLines[1]).not.toContain('Sonnet 4.6')
expect(dataLines[1]).not.toContain('Claude')
expect(dataLines[1]).toContain('Debugging')
})
it('keeps provider/model cells on every row in default mode', () => {
const rows: ModelReportRow[] = [
row({ topCategory: 'feature', topCategoryShare: 0.6, costUSD: 5.0 }),
row({ provider: 'codex', providerDisplayName: 'Codex', model: 'gpt-5', modelDisplayName: 'GPT-5', topCategory: 'debugging', topCategoryShare: 0.4, costUSD: 1.2 }),
]
const out = renderTable(rows, { byTask: false, showTotals: false, terminalWidth: 200 })
const dataLines = out.split('\n').slice(3, -1)
expect(dataLines[0]).toContain('Sonnet 4.6')
expect(dataLines[1]).toContain('GPT-5')
})
it('drops cache columns when terminal is narrow', () => {
const rows: ModelReportRow[] = [row({ topCategory: 'feature', topCategoryShare: 1, costUSD: 1 })]
const wide = renderTable(rows, { showTotals: false, terminalWidth: 200 })
const narrow = renderTable(rows, { showTotals: false, terminalWidth: 80 })
expect(wide).toContain('Cache Write')
expect(narrow).not.toContain('Cache Write')
expect(narrow).not.toContain('Cache Read')
})
it('expands table borders to the available terminal width by default', () => {
const rows: ModelReportRow[] = [
row({ category: 'coding', costUSD: 1.0, inputTokens: 46_300, outputTokens: 3_700_000, cacheWriteTokens: 16_300_000, cacheReadTokens: 1_569_800_000, totalTokens: 1_589_800_000 }),
row({ category: 'delegation', costUSD: 0.5, inputTokens: 44_200, outputTokens: 1_900_000, cacheWriteTokens: 9_400_000, cacheReadTokens: 499_600_000, totalTokens: 511_000_000 }),
]
const out = renderTable(rows, { byTask: true, showTotals: false, terminalWidth: 132 })
const lines = out.split('\n')
expect(visibleWidth(lines[0]!)).toBe(132)
expect(visibleWidth(lines[1]!)).toBe(132)
expect(visibleWidth(lines.at(-1)!)).toBe(132)
})
it('keeps every colored table row aligned to the same visible width', () => {
const originalLevel = chalk.level
chalk.level = 1
try {
const rows: ModelReportRow[] = [
row({ category: 'coding', costUSD: 978.89, inputTokens: 46_300, outputTokens: 3_700_000, cacheWriteTokens: 16_300_000, cacheReadTokens: 1_569_800_000, totalTokens: 1_589_800_000 }),
row({ category: 'delegation', costUSD: 357.0, inputTokens: 44_200, outputTokens: 1_900_000, cacheWriteTokens: 9_400_000, cacheReadTokens: 499_600_000, totalTokens: 511_000_000 }),
row({ category: 'exploration', costUSD: 324.86, inputTokens: 96_800, outputTokens: 1_600_000, cacheWriteTokens: 16_600_000, cacheReadTokens: 359_400_000, totalTokens: 377_800_000 }),
]
const out = renderTable(rows, { byTask: true, terminalWidth: 160 })
const widths = out.split('\n').map(visibleWidth)
expect(new Set(widths)).toEqual(new Set([160]))
} finally {
chalk.level = originalLevel
}
})
it('can render compact tables when fullWidth is disabled', () => {
const rows: ModelReportRow[] = [
row({ category: 'coding', costUSD: 1.0, inputTokens: 46_300, outputTokens: 3_700_000, totalTokens: 1_589_800_000 }),
]
const out = renderTable(rows, { byTask: true, showTotals: false, terminalWidth: 160, fullWidth: false })
expect(visibleWidth(out.split('\n')[0]!)).toBeLessThan(160)
})
it('emits a footer totals row by default and suppresses it under showTotals=false', () => {
const rows: ModelReportRow[] = [row({ costUSD: 1.0, inputTokens: 100, totalTokens: 100 })]
expect(renderTable(rows, { showTotals: true })).toContain('Total')
expect(renderTable(rows, { showTotals: false })).not.toMatch(/^\s*Total/m)
})
})
describe('renderMarkdown', () => {
it('produces a GitHub-flavored markdown table with right-aligned numeric columns', () => {
const rows: ModelReportRow[] = [
{
provider: 'claude',
providerDisplayName: 'Claude',
model: 'claude-sonnet-4-6',
modelDisplayName: 'Sonnet 4.6',
category: null,
topCategory: 'feature',
topCategoryShare: 0.6,
inputTokens: 100,
outputTokens: 50,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalTokens: 150,
costUSD: 1.5,
calls: 1,
},
]
const md = renderMarkdown(rows, { showTotals: false })
const lines = md.split('\n')
expect(lines[0]).toBe('| Provider | Model | Top Task | Input | Output | Cache Write | Cache Read | Total | Cost | Saved |')
expect(lines[1]).toBe('| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |')
expect(lines[2]).toContain('| Claude |')
expect(lines[2]).toContain('`Sonnet 4.6`')
expect(lines[2]).toContain('Feature Dev (60%)')
})
it('escapes pipe characters in provider/model names', () => {
const rows: ModelReportRow[] = [
{
provider: 'odd',
providerDisplayName: 'A|B',
model: 'm|n',
modelDisplayName: 'M|N',
category: null,
topCategory: 'feature',
topCategoryShare: 1,
inputTokens: 0,
outputTokens: 0,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
costUSD: 0,
calls: 0,
},
]
const md = renderMarkdown(rows, { showTotals: false })
expect(md).toContain('A\\|B')
expect(md).toContain('M\\|N')
})
it('emits a bold totals row when showTotals is true', () => {
const rows: ModelReportRow[] = [
{
provider: 'p',
providerDisplayName: 'P',
model: 'm',
modelDisplayName: 'M',
category: null,
topCategory: 'feature',
topCategoryShare: 1,
inputTokens: 100,
outputTokens: 50,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalTokens: 150,
costUSD: 1.5,
calls: 1,
},
]
const md = renderMarkdown(rows)
expect(md).toContain('**Total**')
})
})
describe('renderJson', () => {
it('emits a JSON array with the documented field shape', () => {
const rows: ModelReportRow[] = [
{
provider: 'claude',
providerDisplayName: 'Claude',
model: 'claude-sonnet-4-6',
modelDisplayName: 'Sonnet 4.6',
category: null,
topCategory: 'feature',
topCategoryCost: 6.0,
topCategoryShare: 0.6,
inputTokens: 100,
outputTokens: 50,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalTokens: 150,
costUSD: 1.5,
calls: 1,
},
]
const parsed = JSON.parse(renderJson(rows)) as Array<Record<string, unknown>>
expect(parsed).toHaveLength(1)
expect(parsed[0]).toMatchObject({
provider: 'claude',
model: 'claude-sonnet-4-6',
modelDisplayName: 'Sonnet 4.6',
topCategory: 'feature',
inputTokens: 100,
outputTokens: 50,
totalTokens: 150,
calls: 1,
})
})
})
describe('renderCsv', () => {
it('produces a header row followed by one row per ModelReportRow', () => {
const rows: ModelReportRow[] = [
{
provider: 'claude',
providerDisplayName: 'Claude',
model: 'claude-sonnet-4-6',
modelDisplayName: 'Sonnet 4.6',
category: null,
topCategory: 'feature',
topCategoryShare: 0.6,
inputTokens: 100,
outputTokens: 50,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalTokens: 150,
costUSD: 1.5,
savingsUSD: 0,
calls: 1,
},
]
const csv = renderCsv(rows)
const lines = csv.split('\n')
expect(lines[0]).toBe('provider,model,top_task,top_task_share,input_tokens,output_tokens,cache_write_tokens,cache_read_tokens,total_tokens,calls,cost_usd,savings_usd,savings_baseline_model')
expect(lines[1]).toBe('Claude,Sonnet 4.6,Feature Dev,0.6000,100,50,0,0,150,1,1.500000,0.000000,')
})
it('escapes commas in provider/model cells', () => {
const rows: ModelReportRow[] = [
{
provider: 'weird',
providerDisplayName: 'Weird, Co.',
model: 'm',
modelDisplayName: 'M',
category: null,
topCategory: 'feature',
topCategoryShare: 1.0,
inputTokens: 0,
outputTokens: 0,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
costUSD: 0,
savingsUSD: 0,
calls: 0,
},
]
const csv = renderCsv(rows)
expect(csv.split('\n')[1]).toContain('"Weird, Co."')
})
})
+790
View File
@@ -0,0 +1,790 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, it, expect, beforeAll, afterEach } from 'vitest'
import {
findUnpricedModels,
getModelCosts,
getShortModelName,
calculateCost,
loadPricing,
setModelAliases,
setPriceOverrides,
setLocalModelSavings,
getLocalModelSavingsConfigHash,
getPriceOverridesConfigHash,
} from '../src/models.js'
import { getDailyCacheConfigHash } from '../src/usage-aggregator.js'
beforeAll(async () => {
await loadPricing()
})
afterEach(() => {
setModelAliases({})
setPriceOverrides({})
setLocalModelSavings({})
})
describe('getModelCosts', () => {
it('does not match short canonical against longer pricing key', () => {
const costs = getModelCosts('gpt-4')
if (costs) {
expect(costs.inputCostPerToken).not.toBe(2.5e-6)
}
})
it('returns correct pricing for gpt-4o vs gpt-4o-mini', () => {
const mini = getModelCosts('gpt-4o-mini')
const full = getModelCosts('gpt-4o')
expect(mini).not.toBeNull()
expect(full).not.toBeNull()
expect(mini!.inputCostPerToken).toBeLessThan(full!.inputCostPerToken)
})
it('returns fallback pricing for known Claude models', () => {
const costs = getModelCosts('claude-opus-4-6-20260205')
expect(costs).not.toBeNull()
expect(costs!.inputCostPerToken).toBe(5e-6)
})
it('prices lowercase glm-5.2 (Hermes spelling) the same as capitalized GLM-5.2', () => {
const lower = getModelCosts('glm-5.2')
const upper = getModelCosts('GLM-5.2')
expect(lower).not.toBeNull()
expect(upper).not.toBeNull()
expect(lower!.inputCostPerToken).toBe(upper!.inputCostPerToken)
expect(lower!.outputCostPerToken).toBe(upper!.outputCostPerToken)
})
})
describe('getShortModelName', () => {
it('maps gpt-4o-mini correctly (not gpt-4o)', () => {
expect(getShortModelName('gpt-4o-mini-2024-07-18')).toBe('GPT-4o Mini')
})
it('maps gpt-4o correctly', () => {
expect(getShortModelName('gpt-4o-2024-08-06')).toBe('GPT-4o')
})
it('maps gpt-4.1-mini correctly (not gpt-4.1)', () => {
expect(getShortModelName('gpt-4.1-mini-2025-04-14')).toBe('GPT-4.1 Mini')
})
it('maps gpt-5.4-mini correctly (not gpt-5.4)', () => {
expect(getShortModelName('gpt-5.4-mini')).toBe('GPT-5.4 Mini')
})
// Regression for #461: spark is a distinct variant, not a reasoning suffix.
it('maps gpt-5.3-codex-spark to its own label (not GPT-5.3 Codex)', () => {
const name = getShortModelName('gpt-5.3-codex-spark')
expect(name).not.toBe('GPT-5.3 Codex')
expect(name).toBe('GPT-5.3 Codex Spark')
})
it('maps gpt-5.3-codex reasoning suffixes to the base label', () => {
expect(getShortModelName('gpt-5.3-codex-high')).toBe('GPT-5.3 Codex')
expect(getShortModelName('gpt-5.3-codex-low')).toBe('GPT-5.3 Codex')
})
it('maps claude-opus-4-6 with date suffix', () => {
expect(getShortModelName('claude-opus-4-6-20260205')).toBe('Opus 4.6')
})
// Regression for #420: claude-opus-4-8 must get its own line, not collapse
// into the generic "Opus 4" bucket via the shorter claude-opus-4 prefix.
it('maps claude-opus-4-8 to its own line (not Opus 4)', () => {
expect(getShortModelName('claude-opus-4-8')).toBe('Opus 4.8')
})
// A future version is derived from the id with no hand-maintained entry.
it('derives an unreleased claude version with no SHORT_NAMES entry', () => {
expect(getShortModelName('claude-sonnet-5-2')).toBe('Sonnet 5.2')
expect(getShortModelName('claude-haiku-5')).toBe('Haiku 5')
expect(getShortModelName('claude-opus-9-9-20300101')).toBe('Opus 9.9')
})
it('shows the real model name for pricing-sibling aliases, not the internal key', () => {
// GLM-5.2 (and its lowercase Hermes spelling) price via the glm-5p1 sibling;
// reports must show GLM-5.2, not the pricing key.
expect(getShortModelName('GLM-5.2')).toBe('GLM-5.2')
expect(getShortModelName('glm-5.2')).toBe('GLM-5.2')
expect(getShortModelName('glm-5p1')).toBe('GLM-5.2')
// Grok Build prices via the grok-build-0.1 sibling.
expect(getShortModelName('grok-build')).toBe('Grok Build')
expect(getShortModelName('grok-build-0.1')).toBe('Grok Build')
// grok-composer has no alias, just a missing display entry.
expect(getShortModelName('grok-composer-2.5-fast')).toBe('Grok Composer 2.5 Fast')
})
})
describe('claude-fable-5 pricing + name', () => {
it('prices at $10/M input, $50/M output via models.dev/OpenRouter gap-fill', () => {
expect(calculateCost('claude-fable-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo(10, 6)
expect(calculateCost('claude-fable-5', 0, 1_000_000, 0, 0, 0)).toBeCloseTo(50, 6)
})
it('shows its own display name', () => {
expect(getShortModelName('claude-fable-5')).toBe('Fable 5')
})
})
describe('builtin aliases - getModelCosts', () => {
it('resolves anthropic--claude-4.6-opus', () => {
expect(getModelCosts('anthropic--claude-4.6-opus')).not.toBeNull()
})
it('resolves anthropic--claude-4.6-sonnet', () => {
expect(getModelCosts('anthropic--claude-4.6-sonnet')).not.toBeNull()
})
it('resolves anthropic--claude-4.5-opus', () => {
expect(getModelCosts('anthropic--claude-4.5-opus')).not.toBeNull()
})
it('resolves anthropic--claude-4.5-sonnet', () => {
expect(getModelCosts('anthropic--claude-4.5-sonnet')).not.toBeNull()
})
it('resolves anthropic--claude-4.5-haiku', () => {
expect(getModelCosts('anthropic--claude-4.5-haiku')).not.toBeNull()
})
it('resolves double-wrapped anthropic/anthropic--claude-4.6-opus', () => {
expect(getModelCosts('anthropic/anthropic--claude-4.6-opus')).not.toBeNull()
})
it('resolves double-wrapped anthropic/anthropic--claude-4.6-sonnet', () => {
expect(getModelCosts('anthropic/anthropic--claude-4.6-sonnet')).not.toBeNull()
})
it('resolves double-wrapped anthropic/anthropic--claude-4.5-haiku', () => {
expect(getModelCosts('anthropic/anthropic--claude-4.5-haiku')).not.toBeNull()
})
it('OMP opus resolves to same pricing as canonical claude-opus-4-6', () => {
expect(getModelCosts('anthropic--claude-4.6-opus')).toEqual(getModelCosts('claude-opus-4-6'))
})
it('OMP sonnet resolves to same pricing as canonical claude-sonnet-4-6', () => {
expect(getModelCosts('anthropic--claude-4.6-sonnet')).toEqual(getModelCosts('claude-sonnet-4-6'))
})
it('OMP haiku resolves to same pricing as canonical claude-haiku-4-5', () => {
expect(getModelCosts('anthropic--claude-4.5-haiku')).toEqual(getModelCosts('claude-haiku-4-5'))
})
})
describe('builtin aliases - getShortModelName', () => {
it('anthropic--claude-4.6-opus -> Opus 4.6', () => {
expect(getShortModelName('anthropic--claude-4.6-opus')).toBe('Opus 4.6')
})
it('anthropic--claude-4.6-sonnet -> Sonnet 4.6', () => {
expect(getShortModelName('anthropic--claude-4.6-sonnet')).toBe('Sonnet 4.6')
})
it('anthropic--claude-4.5-opus -> Opus 4.5', () => {
expect(getShortModelName('anthropic--claude-4.5-opus')).toBe('Opus 4.5')
})
it('anthropic--claude-4.5-sonnet -> Sonnet 4.5', () => {
expect(getShortModelName('anthropic--claude-4.5-sonnet')).toBe('Sonnet 4.5')
})
it('anthropic--claude-4.5-haiku -> Haiku 4.5', () => {
expect(getShortModelName('anthropic--claude-4.5-haiku')).toBe('Haiku 4.5')
})
it('anthropic/anthropic--claude-4.6-opus -> Opus 4.6', () => {
expect(getShortModelName('anthropic/anthropic--claude-4.6-opus')).toBe('Opus 4.6')
})
})
describe('Antigravity Gemini 3.5 Flash variants resolve to pricing', () => {
const variants = [
'gemini-3.5-flash',
'gemini-3.5-flash-high',
'gemini-3.5-flash-medium',
'gemini-3.5-flash-low',
'Gemini 3.5 Flash (High)',
]
for (const variant of variants) {
it(`${variant} resolves to Gemini 3.5 Flash`, () => {
expect(getModelCosts(variant)).toEqual(getModelCosts('gemini-3.5-flash'))
expect(getShortModelName(variant)).toBe('Gemini 3.5 Flash')
})
}
it('calculates non-zero cost for high thinking labels', () => {
expect(calculateCost('gemini-3.5-flash-high', 1000, 100, 0, 0, 0)).toBeGreaterThan(0)
})
})
describe('user aliases via setModelAliases', () => {
it('user alias resolves for getModelCosts', () => {
setModelAliases({ 'my-internal-model': 'claude-sonnet-4-6' })
expect(getModelCosts('my-internal-model')).toEqual(getModelCosts('claude-sonnet-4-6'))
})
it('user alias resolves for getShortModelName', () => {
setModelAliases({ 'my-internal-model': 'claude-opus-4-6' })
expect(getShortModelName('my-internal-model')).toBe('Opus 4.6')
})
it('user alias overrides builtin', () => {
setModelAliases({ 'anthropic--claude-4.6-opus': 'claude-sonnet-4-5' })
expect(getModelCosts('anthropic--claude-4.6-opus')).toEqual(getModelCosts('claude-sonnet-4-5'))
})
it('resetting aliases restores builtins', () => {
setModelAliases({ 'anthropic--claude-4.6-opus': 'claude-sonnet-4-5' })
setModelAliases({})
expect(getModelCosts('anthropic--claude-4.6-opus')).toEqual(getModelCosts('claude-opus-4-6'))
})
})
describe('user price overrides', () => {
it('prices a model missing from the pricing snapshot', () => {
const model = 'zz-price-override-missing-model-390'
expect(getModelCosts(model)).toBeNull()
setPriceOverrides({
[model]: { input: 1.25, output: 2.5 },
})
const costs = getModelCosts(model)
expect(costs).not.toBeNull()
expect(costs!.inputCostPerToken).toBe(1.25e-6)
expect(costs!.outputCostPerToken).toBe(2.5e-6)
expect(calculateCost(model, 1_000_000, 1_000_000, 0, 0, 0)).toBe(3.75)
})
it('wins over snapshot pricing and configured aliases', () => {
setModelAliases({
'price-override-aliased-model': 'claude-opus-4-6',
'price-override-canonical-source': 'price-override-canonical-target',
})
setPriceOverrides({
'gpt-4o': { input: 7, output: 8 },
'claude-opus-4-6': { input: 4, output: 5 },
'price-override-aliased-model': { input: 2, output: 3 },
'price-override-canonical-target': { input: 6, output: 7 },
})
expect(getModelCosts('gpt-4o')!.inputCostPerToken).toBe(7e-6)
expect(getModelCosts('price-override-aliased-model')!.inputCostPerToken).toBe(2e-6)
expect(getModelCosts('price-override-canonical-source')!.inputCostPerToken).toBe(6e-6)
})
it('converts USD per 1,000,000 tokens to per-token ModelCosts exactly', () => {
const model = 'price-override-unit-conversion'
setPriceOverrides({
[model]: { input: 1, output: 0 },
})
expect(getModelCosts(model)!.inputCostPerToken).toBe(1e-6)
expect(calculateCost(model, 1_000_000, 0, 0, 0, 0)).toBe(1)
})
it('defaults cache rates from input pricing when omitted', () => {
const model = 'price-override-cache-defaults'
setPriceOverrides({
[model]: { input: 10, output: 20 },
})
const costs = getModelCosts(model)
expect(costs).not.toBeNull()
expect(costs!.cacheWriteCostPerToken).toBeCloseTo(12.5e-6, 12)
expect(costs!.cacheReadCostPerToken).toBeCloseTo(1e-6, 12)
})
it('wins for case-insensitive and prefix matches without shadowing a more-specific exact snapshot entry', () => {
const miniSnapshot = getModelCosts('gpt-5-mini')
expect(miniSnapshot).not.toBeNull()
setPriceOverrides({
'gpt-5': { input: 91, output: 92 },
})
expect(getModelCosts('GPT-5')!.inputCostPerToken).toBe(91e-6)
expect(getModelCosts('gpt-5-foo')!.inputCostPerToken).toBe(91e-6)
const mini = getModelCosts('gpt-5-mini')
expect(mini).not.toBeNull()
expect(mini!.inputCostPerToken).toBe(miniSnapshot!.inputCostPerToken)
expect(mini!.outputCostPerToken).toBe(miniSnapshot!.outputCostPerToken)
})
it('includes builtin and user price overrides in the daily cache config hash', () => {
setLocalModelSavings({ local: 'gpt-4o' })
setPriceOverrides({})
// The builtin overrides always participate, so a release that edits them
// invalidates cached daily costs even with no user overrides configured.
const builtinOnly = getPriceOverridesConfigHash()
expect(builtinOnly).toContain('builtin:')
expect(getPriceOverridesConfigHash()).toBe(builtinOnly)
const baseline = getDailyCacheConfigHash()
setPriceOverrides({ 'price-hash-model': { input: 1, output: 2 } })
const firstCombined = getDailyCacheConfigHash()
setPriceOverrides({ 'price-hash-model': { input: 3, output: 2 } })
const secondCombined = getDailyCacheConfigHash()
expect(firstCombined).not.toBe(baseline)
expect(secondCombined).not.toBe(baseline)
expect(secondCombined).not.toBe(firstCombined)
})
})
describe('calculateCost - OMP names produce non-zero cost', () => {
it('calculates cost for anthropic--claude-4.6-opus', () => {
expect(calculateCost('anthropic--claude-4.6-opus', 1000, 200, 0, 0, 0)).toBeGreaterThan(0)
})
it('calculates cost for anthropic/anthropic--claude-4.6-sonnet', () => {
expect(calculateCost('anthropic/anthropic--claude-4.6-sonnet', 1000, 200, 0, 0, 0)).toBeGreaterThan(0)
})
})
describe('Warp Claude variants resolve to pricing', () => {
const cases: Array<[string, string]> = [
['claude-4-6-sonnet-high', 'claude-sonnet-4-6'],
['claude-4-6-sonnet-low', 'claude-sonnet-4-6'],
['claude-4-6-sonnet-medium', 'claude-sonnet-4-6'],
['claude-4-6-sonnet-high-fast', 'claude-sonnet-4-6'],
['claude-4-7-opus-xhigh', 'claude-opus-4-7'],
['claude-4-7-opus-xhigh-fast', 'claude-opus-4-7'],
]
for (const [input, expectedAlias] of cases) {
it(`${input} resolves to ${expectedAlias} pricing`, () => {
const costs = getModelCosts(input)
expect(costs).not.toBeNull()
expect(costs!.inputCostPerToken).toBeGreaterThan(0)
const expected = getModelCosts(expectedAlias)
expect(expected).not.toBeNull()
expect(costs!.inputCostPerToken).toBe(expected!.inputCostPerToken)
expect(costs!.outputCostPerToken).toBe(expected!.outputCostPerToken)
})
it(`${input} calculates non-zero cost`, () => {
expect(calculateCost(input, 1000, 200, 0, 0, 0)).toBeGreaterThan(0)
})
}
})
describe('calculateCost - Claude cache write durations', () => {
it('prices 1-hour cache writes at 1.6x the 5-minute cache write rate', () => {
const fiveMinute = calculateCost('claude-opus-4-7', 0, 0, 1_000_000, 0, 0)
const oneHour = calculateCost('claude-opus-4-7', 0, 0, 1_000_000, 0, 0, 'standard', 1_000_000)
const mixed = calculateCost('claude-opus-4-7', 0, 0, 100_000, 0, 0, 'standard', 60_000)
expect(fiveMinute).toBeCloseTo(6.25, 6)
expect(oneHour).toBeCloseTo(10, 6)
expect(mixed).toBeCloseTo(0.85, 6)
})
})
describe('existing model names still resolve', () => {
it('canonical claude-opus-4-6', () => {
expect(getModelCosts('claude-opus-4-6')).not.toBeNull()
})
it('canonical claude-sonnet-4-5', () => {
expect(getModelCosts('claude-sonnet-4-5')).not.toBeNull()
})
it('date-stamped claude-sonnet-4-20250514', () => {
expect(getModelCosts('claude-sonnet-4-20250514')).not.toBeNull()
})
it('pinned claude-sonnet-4-6@20250929', () => {
expect(getModelCosts('claude-sonnet-4-6@20250929')).not.toBeNull()
})
it('anthropic/-prefixed anthropic/claude-opus-4-6', () => {
expect(getModelCosts('anthropic/claude-opus-4-6')).not.toBeNull()
})
// #420: 4.8 has its own LiteLLM pricing tier ($5/$25), so it must not fall
// through the prefix match to the older, 3x-pricier claude-opus-4 ($15/$75).
it('claude-opus-4-8 prices at its own tier, not original claude-opus-4', () => {
const v48 = getModelCosts('claude-opus-4-8')
expect(v48).not.toBeNull()
// $5/$25 per M tokens — the 4.6/4.7 tier, not the original opus-4 $15/$75.
expect(v48!.inputCostPerToken).toBeCloseTo(0.000005, 12)
expect(v48!.outputCostPerToken).toBeCloseTo(0.000025, 12)
expect(v48!.inputCostPerToken).not.toEqual(getModelCosts('claude-opus-4')!.inputCostPerToken)
})
})
// Issue #159: every model name Cursor emits in its SQLite database must
// resolve to a non-zero pricing entry, otherwise the dashboard shows $0 for
// that model. Each case asserts the resolved pricing identity matches the
// pricing of the expected canonical key, so an accidental alias swap (e.g.
// `claude-4.6-opus` aliased to a haiku entry) fails the test even though
// haiku also has positive pricing.
describe('Cursor model variants resolve to pricing', () => {
const cases: Array<[string, string]> = [
// Sonnet family
['claude-4-sonnet', 'claude-sonnet-4'],
['claude-4-sonnet-1m', 'claude-sonnet-4'],
['claude-4-sonnet-thinking', 'claude-sonnet-4-5'],
['claude-4.5-sonnet', 'claude-sonnet-4-5'],
['claude-4.5-sonnet-thinking', 'claude-sonnet-4-5'],
['claude-4.6-sonnet', 'claude-sonnet-4-6'],
['claude-4.6-sonnet-high', 'claude-sonnet-4-6'],
['claude-4.6-sonnet-low', 'claude-sonnet-4-6'],
['claude-4.6-sonnet-thinking', 'claude-sonnet-4-6'],
['claude-4.6-sonnet-high-thinking', 'claude-sonnet-4-6'],
// Opus family
['claude-4-opus', 'claude-opus-4'],
['claude-4.5-opus', 'claude-opus-4-5'],
['claude-4.5-opus-high', 'claude-opus-4-5'],
['claude-4.5-opus-low', 'claude-opus-4-5'],
['claude-4.5-opus-medium', 'claude-opus-4-5'],
['claude-4.5-opus-high-thinking', 'claude-opus-4-5'],
['claude-4.6-opus', 'claude-opus-4-6'],
['claude-4.6-opus-fast-mode', 'claude-opus-4-6'],
['claude-4.6-opus-high', 'claude-opus-4-6'],
['claude-4.6-opus-low', 'claude-opus-4-6'],
['claude-4.6-opus-medium', 'claude-opus-4-6'],
['claude-4.6-opus-high-thinking', 'claude-opus-4-6'],
['claude-4.7-opus', 'claude-opus-4-7'],
['claude-opus-4-7-thinking-high', 'claude-opus-4-7'],
// Haiku family
['claude-4.5-haiku', 'claude-haiku-4-5'],
['claude-4.6-haiku', 'claude-haiku-4-5'],
// Cursor auto proxy
['cursor-auto', 'claude-sonnet-4-5'],
// OpenAI variants Cursor emits
['gpt-5', 'gpt-5'],
['gpt-5-fast', 'gpt-5'],
['gpt-5.2', 'gpt-5.2'],
['gpt-5.2-low', 'gpt-5'],
// Direct LiteLLM hits where no alias is required
['grok-code-fast-1', 'grok-code-fast-1'],
['gemini-3-pro', 'gemini-3-pro-preview'],
]
for (const [input, expectedAlias] of cases) {
it(`${input} resolves to ${expectedAlias} pricing`, () => {
const costs = getModelCosts(input)
expect(costs, `${input} should resolve to pricing (and not produce $0 in the dashboard)`).not.toBeNull()
expect(costs!.inputCostPerToken).toBeGreaterThan(0)
expect(costs!.outputCostPerToken).toBeGreaterThan(0)
const expected = getModelCosts(expectedAlias)
expect(expected, `expected target ${expectedAlias} should itself resolve`).not.toBeNull()
// Identity check: the alias must produce the SAME pricing object as
// the canonical key, not just any non-zero pricing. Catches drift
// where a future edit re-points an alias at a wrong-but-positive entry.
expect(costs!.inputCostPerToken).toBe(expected!.inputCostPerToken)
expect(costs!.outputCostPerToken).toBe(expected!.outputCostPerToken)
})
}
})
describe('Cursor house model pricing', () => {
const cases: Array<[string, { input: number; output: number; cacheWrite: number; cacheRead: number }]> = [
['composer-2.5', { input: 0.5, output: 2.5, cacheWrite: 0.5, cacheRead: 0.2 }],
['composer-2', { input: 0.5, output: 2.5, cacheWrite: 0.5, cacheRead: 0.2 }],
['composer-1.5', { input: 3.5, output: 17.5, cacheWrite: 3.5, cacheRead: 0.35 }],
['composer-1', { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 }],
]
for (const [model, rates] of cases) {
it(`${model} uses Cursor-published rates instead of Claude Sonnet proxy pricing`, () => {
const costs = getModelCosts(model)
expect(costs).not.toBeNull()
expect(costs!.inputCostPerToken).toBeCloseTo(rates.input * 1e-6, 12)
expect(costs!.outputCostPerToken).toBeCloseTo(rates.output * 1e-6, 12)
expect(costs!.cacheWriteCostPerToken).toBeCloseTo(rates.cacheWrite * 1e-6, 12)
expect(costs!.cacheReadCostPerToken).toBeCloseTo(rates.cacheRead * 1e-6, 12)
})
}
})
// Regression: LiteLLM ships `snowflake/claude-4-opus` ($5/M, a gateway rate),
// which the bundler strips to a bare `claude-4-opus` snapshot key. Without the
// alias-precedence guard in getModelCosts, that bare reseller key shadows the
// curated alias `claude-4-opus -> claude-opus-4` and mis-prices Opus 4 at a
// third of its official list price. Pin the official number so a re-shadowing
// fails loudly rather than silently under-reporting spend.
describe('alias precedence over stripped reseller keys', () => {
it('claude-4-opus resolves to the official Opus 4 list price, not a gateway discount', () => {
const aliased = getModelCosts('claude-4-opus')
const canonical = getModelCosts('claude-opus-4')
expect(aliased).not.toBeNull()
expect(canonical).not.toBeNull()
expect(aliased!.inputCostPerToken).toBe(canonical!.inputCostPerToken)
expect(aliased!.outputCostPerToken).toBe(canonical!.outputCostPerToken)
expect(aliased!.inputCostPerToken).toBe(15e-6)
expect(aliased!.outputCostPerToken).toBe(75e-6)
})
it('the explicit provider prefix is still honored for the gateway rate', () => {
// The guard fires only for the bare name; a fully-qualified gateway id must
// still return that gateway's own price when LiteLLM publishes one.
const gateway = getModelCosts('snowflake/claude-4-opus')
const bare = getModelCosts('claude-4-opus')
expect(gateway).not.toBeNull()
expect(gateway!.inputCostPerToken).toBeLessThan(bare!.inputCostPerToken)
})
})
// The case-insensitive index that lets `MiniMax-M3` reach a lowercase
// `minimax-m3` slug must NOT let a case-mismatched query resolve to one of
// LiteLLM's [0,0] price stubs (e.g. `GigaChat-2-Max`). Doing so would flip an
// honest null (which fires the "no pricing data, will show $0" warning) into a
// silent $0 and hide real spend. A case-EXACT query still finds the stub.
describe('zero-priced stubs do not satisfy case-insensitive lookup', () => {
it('a case-mismatched query to a [0,0] stub stays null', () => {
expect(getModelCosts('gigachat-2-max')).toBeNull()
})
it('the case-exact stub still resolves (just at zero cost)', () => {
const exact = getModelCosts('GigaChat-2-Max')
expect(exact).not.toBeNull()
expect(exact!.inputCostPerToken).toBe(0)
})
})
describe('DeepSeek v4 models resolve to pricing', () => {
it('deepseek-v4-pro has current official discounted pricing', () => {
const costs = getModelCosts('deepseek-v4-pro')
expect(costs).not.toBeNull()
expect(costs!.inputCostPerToken).toBe(4.35e-7)
expect(costs!.outputCostPerToken).toBe(8.7e-7)
expect(costs!.cacheReadCostPerToken).toBe(3.625e-9)
expect(costs!.cacheWriteCostPerToken).toBe(0)
})
it('deepseek-v4-flash has current official pricing', () => {
const costs = getModelCosts('deepseek-v4-flash')
expect(costs).not.toBeNull()
expect(costs!.inputCostPerToken).toBe(1.4e-7)
expect(costs!.outputCostPerToken).toBe(2.8e-7)
expect(costs!.cacheReadCostPerToken).toBe(2.8e-9)
expect(costs!.cacheWriteCostPerToken).toBe(0)
})
it('provider-prefixed DeepSeek v4 names resolve to the same pricing', () => {
expect(getModelCosts('deepseek/deepseek-v4-pro')).toEqual(getModelCosts('deepseek-v4-pro'))
expect(getModelCosts('deepseek/deepseek-v4-flash')).toEqual(getModelCosts('deepseek-v4-flash'))
})
it('calculates non-zero costs for observed DeepSeek v4 Claude usage', () => {
const pro = calculateCost('deepseek-v4-pro', 2_477_914, 762_994, 0, 258_556_928, 0)
const flash = calculateCost('deepseek-v4-flash', 1_552_573, 353_914, 0, 48_388_608, 0)
expect(pro).toBeCloseTo(2.68, 2)
expect(flash).toBeCloseTo(0.45, 2)
})
it('uses DeepSeek v4 display names', () => {
expect(getShortModelName('deepseek-v4-pro')).toBe('DeepSeek v4 Pro')
expect(getShortModelName('deepseek-v4-flash')).toBe('DeepSeek v4 Flash')
})
it('keeps bundled DeepSeek v4 fallback entries when runtime pricing cache is stale', async () => {
const cacheRoot = await mkdtemp(join(tmpdir(), 'codeburn-pricing-cache-'))
try {
process.env['CODEBURN_CACHE_DIR'] = cacheRoot
await mkdir(cacheRoot, { recursive: true })
await writeFile(join(cacheRoot, 'litellm-pricing.json'), JSON.stringify({
timestamp: Date.now(),
data: {
'gpt-4o-mini': {
inputCostPerToken: 9e-7,
outputCostPerToken: 1.8e-6,
cacheWriteCostPerToken: 0,
cacheReadCostPerToken: 9e-8,
webSearchCostPerRequest: 0.01,
fastMultiplier: 1,
},
},
}), 'utf-8')
await loadPricing()
expect(getModelCosts('gpt-4o-mini')!.inputCostPerToken).toBe(9e-7)
expect(getModelCosts('deepseek-v4-pro')!.inputCostPerToken).toBe(4.35e-7)
expect(getModelCosts('deepseek-v4-flash')!.inputCostPerToken).toBe(1.4e-7)
} finally {
await rm(cacheRoot, { recursive: true, force: true })
await loadPricing()
}
})
})
describe('provider pricing suffix variants', () => {
const cases: Array<[string, string]> = [
['GLM-4.7-TEE', 'glm-4.7'],
['glm-4.7:thinking', 'glm-4.7'],
['Kimi-K2.5-TEE', 'kimi-k2.5'],
['deepseek-v4-pro:cloud', 'deepseek-v4-pro'],
['glm-5:thinking', 'glm-5'],
['kimi-k2.6:thinking', 'kimi-k2.6'],
['deepseek-v4-flash:thinking', 'deepseek-v4-flash'],
['minimax-m3:cloud', 'minimax-m3'],
]
for (const [input, expectedBase] of cases) {
it(`${input} resolves through ${expectedBase}`, () => {
const costs = getModelCosts(input)
const expected = getModelCosts(expectedBase)
expect(costs).not.toBeNull()
expect(expected).not.toBeNull()
expect(costs!.inputCostPerToken).toBe(expected!.inputCostPerToken)
expect(costs!.outputCostPerToken).toBe(expected!.outputCostPerToken)
})
}
it('does not strip arbitrary local runtime tags', () => {
expect(getModelCosts('qwen3.6:35b-a3b-bf16')).toBeNull()
})
it('does not strip free-tier markers into paid pricing', () => {
expect(getModelCosts('mimo-v2-flash:free')).toBeNull()
})
})
describe('observed provider model aliases', () => {
const cases: Array<[string, string]> = [
['MiMo-V2-Flash', 'xiaomi/mimo-v2-flash'],
['KAT-Coder-Pro-V1', 'kwaipilot/kat-coder-pro'],
]
for (const [input, expectedModel] of cases) {
it(`${input} resolves through ${expectedModel}`, () => {
const costs = getModelCosts(input)
const expected = getModelCosts(expectedModel)
expect(costs).not.toBeNull()
expect(expected).not.toBeNull()
expect(costs).toEqual(expected)
expect(calculateCost(input, 1_000_000, 1_000_000, 0, 0, 0)).toBeGreaterThan(0)
})
}
it('does not map dated Qwen3 Max to a reseller price without provider context', () => {
expect(getModelCosts('qwen3-max-2026-01-23')).toBeNull()
expect(calculateCost('qwen3-max-2026-01-23', 1_000_000, 1_000_000, 0, 0, 0)).toBe(0)
})
})
describe('findUnpricedModels', () => {
it('flags an unknown paid-looking model with $0 cost and skips priced ones', () => {
const rows = [
{ model: 'claude-opus-4-6', calls: 10, cost: 2.5, tokens: 5000 },
{ model: 'zz-mystery-paid-model-999', calls: 3, cost: 0, tokens: 1200 },
]
const unpriced = findUnpricedModels(rows)
expect(unpriced).toEqual([{ model: 'zz-mystery-paid-model-999', calls: 3, tokens: 1200 }])
})
it('never flags a row that carries real cost, even when the lookup misses', () => {
// Aggregation keys rows by display name; the lookup misses but the row was
// priced at parse time, so it must not be reported as unpriced.
const unpriced = findUnpricedModels([
{ model: 'Opus 4.8', calls: 100, cost: 42.5, tokens: 1_000_000 },
{ model: 'zz-unknown-but-priced-elsewhere', calls: 5, cost: 0.01, tokens: 500 },
])
expect(unpriced).toEqual([])
})
it('flags $0 display-name rows even when the raw id would price today', () => {
// Droid prices the lowercased display name ("claude sonnet 4.6" -> no
// pricing -> $0) and the parser keys the row by display name. Those
// tokens really entered the report at $0, so the row must be flagged
// even though claude-sonnet-4-6 itself is priced.
const unpriced = findUnpricedModels([
{ model: 'Sonnet 4.6', calls: 12, cost: 0, tokens: 500_000 },
])
expect(unpriced).toEqual([{ model: 'Sonnet 4.6', calls: 12, tokens: 500_000 }])
})
it('flags zero-rate pricing stubs but not explicit zero-rate user overrides', async () => {
// LiteLLM ships [0,0] stubs for models it lists but has no price for;
// a stub hit means "unknown price", not "free".
const cacheRoot = await mkdtemp(join(tmpdir(), 'codeburn-pricing-cache-'))
try {
process.env['CODEBURN_CACHE_DIR'] = cacheRoot
await writeFile(join(cacheRoot, 'litellm-pricing.json'), JSON.stringify({
timestamp: Date.now(),
data: {
'zz-zero-stub-model': {
inputCostPerToken: 0,
outputCostPerToken: 0,
cacheWriteCostPerToken: 0,
cacheReadCostPerToken: 0,
webSearchCostPerRequest: 0,
fastMultiplier: 1,
},
},
}), 'utf-8')
await loadPricing()
expect(getModelCosts('zz-zero-stub-model')).not.toBeNull()
const rows = [{ model: 'zz-zero-stub-model', calls: 3, cost: 0, tokens: 1100 }]
expect(findUnpricedModels(rows)).toHaveLength(1)
// An explicit user override at zero rates means "this model is free".
setPriceOverrides({ 'zz-zero-stub-model': { input: 0, output: 0 } })
expect(findUnpricedModels(rows)).toEqual([])
// A prefix override cannot prove intent: getModelCosts resolves table
// hits before prefix overrides, so the $0 came from the stub, not the
// user. Still flagged.
setPriceOverrides({ 'zz-zero-stub': { input: 0, output: 0 } })
expect(findUnpricedModels(rows)).toHaveLength(1)
} finally {
delete process.env['CODEBURN_CACHE_DIR']
await rm(cacheRoot, { recursive: true, force: true })
setPriceOverrides({})
await loadPricing()
}
})
it('skips synthetic, empty, local-looking, and zero-usage rows', () => {
const unpriced = findUnpricedModels([
{ model: '<synthetic>', calls: 5, cost: 0, tokens: 100 },
{ model: '', calls: 5, cost: 0, tokens: 100 },
{ model: 'llama3.1:8b', calls: 5, cost: 0, tokens: 100 },
{ model: 'zz-quantized-model-bf16', calls: 5, cost: 0, tokens: 100 },
{ model: 'zz-no-usage-model', calls: 0, cost: 0, tokens: 0 },
])
expect(unpriced).toEqual([])
})
it('heals when the user configures an alias or a price override', () => {
const model = 'zz-proxy-renamed-model-x1'
expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toHaveLength(1)
setModelAliases({ [model]: 'claude-opus-4-6' })
expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toEqual([])
setModelAliases({})
setPriceOverrides({ [model]: { input: 1, output: 2 } })
expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toEqual([])
})
it('skips models mapped via model-savings (intentionally $0)', () => {
const model = 'zz-my-local-runner'
expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toHaveLength(1)
setLocalModelSavings({ [model]: 'gpt-4o' })
expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toEqual([])
})
it('sorts by tokens, then calls', () => {
const unpriced = findUnpricedModels([
{ model: 'zz-small', calls: 9, cost: 0, tokens: 10 },
{ model: 'zz-big', calls: 1, cost: 0, tokens: 9999 },
])
expect(unpriced.map(u => u.model)).toEqual(['zz-big', 'zz-small'])
})
})
+655
View File
@@ -0,0 +1,655 @@
import { afterAll, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createHash } from 'node:crypto'
import { PassThrough, Writable } from 'node:stream'
import { planFor, planFindings, type PlanContext } from '../src/act/plans.js'
import { renderApplyList, runOptimizeApply, type ApplyOptions } from '../src/act/optimize-apply.js'
import { runAction } from '../src/act/apply.js'
import { undoAction } from '../src/act/undo.js'
import { readRecords, shortId } from '../src/act/journal.js'
import {
detectBloatedClaudeMd,
detectDuplicateReads,
detectJunkReads,
detectLowReadEditRatio,
detectMcpToolCoverage,
} from '../src/optimize.js'
import type {
FindingApply,
FindingId,
McpServerCoverage,
ToolCall,
WasteAction,
WasteFinding,
} from '../src/optimize.js'
const roots: string[] = []
type Fixture = { root: string; home: string; project: string; actionsDir: string }
async function makeFixture(): Promise<Fixture> {
const root = await mkdtemp(join(tmpdir(), 'codeburn-optimize-apply-'))
roots.push(root)
const home = join(root, 'home')
const project = join(root, 'project')
await mkdir(home, { recursive: true })
await mkdir(project, { recursive: true })
return { root, home, project, actionsDir: join(root, 'actions') }
}
afterAll(async () => {
for (const root of roots) await rm(root, { recursive: true, force: true })
})
function makeFinding(id: FindingId, fix: WasteAction, apply?: FindingApply): WasteFinding {
return { id, title: id, explanation: '', impact: 'medium', tokensSaved: 1000, fix, ...(apply ? { apply } : {}) }
}
const CMD_FIX: WasteAction = { type: 'command', label: '', text: '' }
async function hashTree(dir: string): Promise<string> {
const h = createHash('sha256')
async function walk(d: string): Promise<void> {
const entries = (await readdir(d, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))
for (const entry of entries) {
const full = join(d, entry.name)
if (entry.isDirectory()) {
h.update('D:' + full + '\n')
await walk(full)
} else {
h.update('F:' + full + '\n')
h.update(await readFile(full))
}
}
}
await walk(dir)
return h.digest('hex')
}
describe('mcp-remove plan', () => {
it('deletes exactly the named server, leaves other keys untouched, and undo restores byte-identical', async () => {
const fx = await makeFixture()
const claudeJson = join(fx.home, '.claude.json')
const original = JSON.stringify({
mcpServers: { alpha: { command: 'a' }, beta: { command: 'b', args: ['x'] } },
numFoo: 3,
nested: { keep: true },
}, null, 2) + '\n'
await writeFile(claudeJson, original)
await writeFile(join(fx.project, '.mcp.json'), JSON.stringify({ mcpServers: { gamma: {} } }, null, 2) + '\n')
const finding = makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['beta'] })
const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project })
expect(plan).not.toBeNull()
expect(plan!.changes).toHaveLength(1)
expect(plan!.changes[0]!.path).toBe(claudeJson)
const rec = await runAction(plan!, fx.actionsDir)
const after = JSON.parse(await readFile(claudeJson, 'utf-8'))
expect(after.mcpServers).toEqual({ alpha: { command: 'a' } })
expect(after.numFoo).toBe(3)
expect(after.nested).toEqual({ keep: true })
// Untouched sibling config file.
expect(JSON.parse(await readFile(join(fx.project, '.mcp.json'), 'utf-8')).mcpServers).toEqual({ gamma: {} })
// 2-space indent + trailing newline contract.
expect(await readFile(claudeJson, 'utf-8')).toBe(JSON.stringify(after, null, 2) + '\n')
await undoAction({ id: rec.id }, { actionsDir: fx.actionsDir })
expect(await readFile(claudeJson, 'utf-8')).toBe(original)
})
})
describe('mcp-project-scope plan', () => {
it('moves the entry from the global config into the keeper project .mcp.json, creating it when missing', async () => {
const fx = await makeFixture()
const claudeJson = join(fx.home, '.claude.json')
const serverValue = { command: 'srv', args: ['--flag'], env: { A: '1' } }
const original = JSON.stringify({ mcpServers: { srv: serverValue }, other: 1 }, null, 2) + '\n'
await writeFile(claudeJson, original)
const keeper = join(fx.root, 'keeper')
await mkdir(keeper, { recursive: true })
const keeperMcp = join(keeper, '.mcp.json')
expect(existsSync(keeperMcp)).toBe(false)
const finding = makeFinding('mcp-project-scope', { type: 'paste', destination: 'prompt', label: '', text: '' }, {
kind: 'mcp-project-scope',
servers: [{ server: 'srv', keepProjects: [keeper], removeProjects: [] }],
})
const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project })
expect(plan).not.toBeNull()
const rec = await runAction(plan!, fx.actionsDir)
expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({})
expect(existsSync(keeperMcp)).toBe(true)
expect(JSON.parse(await readFile(keeperMcp, 'utf-8')).mcpServers).toEqual({ srv: serverValue })
await undoAction({ id: rec.id }, { actionsDir: fx.actionsDir })
expect(await readFile(claudeJson, 'utf-8')).toBe(original)
expect(existsSync(keeperMcp)).toBe(false)
})
})
describe('unparseable config file', () => {
it('reports the parse error, skips that server, and still applies the servers it can read', async () => {
const fx = await makeFixture()
const claudeJson = join(fx.home, '.claude.json')
await writeFile(claudeJson, JSON.stringify({ mcpServers: { good: { command: 'g' } } }, null, 2) + '\n')
const brokenMcp = join(fx.project, '.mcp.json')
await writeFile(brokenMcp, '{ this is not valid json,,, ')
const finding = makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['good', 'bad'] })
const { plan, notes } = planFindings([finding], { homeDir: fx.home, cwd: fx.project })[0]!
expect(notes.some(n => /could not parse/.test(n) && n.includes('.mcp.json'))).toBe(true)
expect(notes.some(n => n.includes('bad'))).toBe(true)
expect(plan).not.toBeNull()
expect(plan!.changes.map(c => c.path)).toEqual([claudeJson])
await runAction(plan!, fx.actionsDir)
expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({})
// The broken file is left exactly as-is.
expect(await readFile(brokenMcp, 'utf-8')).toBe('{ this is not valid json,,, ')
})
})
describe('archive plan', () => {
it('archives a skill dir and an agent file, round-trips undo, and suffixes a colliding name with -2', async () => {
const fx = await makeFixture()
const skillsDir = join(fx.home, '.claude', 'skills')
const agentsDir = join(fx.home, '.claude', 'agents')
await mkdir(join(skillsDir, 'foo'), { recursive: true })
await writeFile(join(skillsDir, 'foo', 'SKILL.md'), 'skill body')
// Pre-existing archive with the same name forces the -2 suffix.
await mkdir(join(skillsDir, '.archived', 'foo'), { recursive: true })
await writeFile(join(skillsDir, '.archived', 'foo', 'SKILL.md'), 'old archived')
await mkdir(agentsDir, { recursive: true })
await writeFile(join(agentsDir, 'bar.md'), 'agent body')
const skillFinding = makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['foo'] })
const skillPlan = planFor(skillFinding, { homeDir: fx.home, cwd: fx.project })
expect(skillPlan!.changes[0]).toMatchObject({
op: 'move',
path: join(skillsDir, 'foo'),
movedTo: join(skillsDir, '.archived', 'foo-2'),
})
const skillRec = await runAction(skillPlan!, fx.actionsDir)
expect(existsSync(join(skillsDir, 'foo'))).toBe(false)
expect(await readFile(join(skillsDir, '.archived', 'foo-2', 'SKILL.md'), 'utf-8')).toBe('skill body')
// The pre-existing archive is preserved.
expect(await readFile(join(skillsDir, '.archived', 'foo', 'SKILL.md'), 'utf-8')).toBe('old archived')
const agentFinding = makeFinding('unused-agents', CMD_FIX, { kind: 'archive', names: ['bar'] })
const agentPlan = planFor(agentFinding, { homeDir: fx.home, cwd: fx.project })
expect(agentPlan!.changes[0]).toMatchObject({
op: 'move',
path: join(agentsDir, 'bar.md'),
movedTo: join(agentsDir, '.archived', 'bar.md'),
})
const agentRec = await runAction(agentPlan!, fx.actionsDir)
expect(existsSync(join(agentsDir, 'bar.md'))).toBe(false)
await undoAction({ id: agentRec.id }, { actionsDir: fx.actionsDir })
await undoAction({ id: skillRec.id }, { actionsDir: fx.actionsDir })
expect(await readFile(join(agentsDir, 'bar.md'), 'utf-8')).toBe('agent body')
expect(await readFile(join(skillsDir, 'foo', 'SKILL.md'), 'utf-8')).toBe('skill body')
expect(existsSync(join(skillsDir, '.archived', 'foo-2'))).toBe(false)
})
})
describe('claude-md rule plan', () => {
it('appends a fresh marker block, replaces it in place on re-apply, and undo removes it', async () => {
const fx = await makeFixture()
const claudeMd = join(fx.project, 'CLAUDE.md')
const original = '# Project\n\nExisting rules.\n'
await writeFile(claudeMd, original)
const first = makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'Read before editing.' })
const firstPlan = planFor(first, { homeDir: fx.home, cwd: fx.project })
const firstRec = await runAction(firstPlan!, fx.actionsDir)
let body = await readFile(claudeMd, 'utf-8')
expect(body).toContain('# Project')
expect(body).toContain('<!-- codeburn:begin read-edit-ratio -->')
expect(body).toContain('Read before editing.')
expect(body).toContain('<!-- codeburn:end read-edit-ratio -->')
// Second apply with the same id replaces the block instead of duplicating.
const second = makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'Read first, then edit.' })
const secondPlan = planFor(second, { homeDir: fx.home, cwd: fx.project })
const secondRec = await runAction(secondPlan!, fx.actionsDir)
body = await readFile(claudeMd, 'utf-8')
expect(body.match(/codeburn:begin read-edit-ratio/g)).toHaveLength(1)
expect(body).toContain('Read first, then edit.')
expect(body).not.toContain('Read before editing.')
await undoAction({ id: secondRec.id }, { actionsDir: fx.actionsDir })
await undoAction({ id: firstRec.id }, { actionsDir: fx.actionsDir })
expect(await readFile(claudeMd, 'utf-8')).toBe(original)
})
})
describe('shell-config plan', () => {
it('writes the bash cap inside # markers to the rc chosen from $SHELL', async () => {
const fx = await makeFixture()
const finding = makeFinding('bash-output-cap', { type: 'paste', destination: 'shell-config', label: '', text: 'export BASH_MAX_OUTPUT_LENGTH=15000' })
const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project, shell: '/bin/zsh' })
expect(plan!.changes[0]!.path).toBe(join(fx.home, '.zshrc'))
await runAction(plan!, fx.actionsDir)
const body = await readFile(join(fx.home, '.zshrc'), 'utf-8')
expect(body).toBe('# codeburn:begin bash-output-cap\nexport BASH_MAX_OUTPUT_LENGTH=15000\n# codeburn:end bash-output-cap\n')
})
})
describe('dry-run', () => {
it('leaves the fixture tree byte-identical when only planning', async () => {
const fx = await makeFixture()
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ mcpServers: { s: { command: 'c' } } }, null, 2) + '\n')
await mkdir(join(fx.home, '.claude', 'skills', 'ghost'), { recursive: true })
await writeFile(join(fx.home, '.claude', 'skills', 'ghost', 'SKILL.md'), 'x')
await writeFile(join(fx.project, 'CLAUDE.md'), '# rules\n')
const findings: WasteFinding[] = [
makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['s'] }),
makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['ghost'] }),
makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'rule' }),
makeFinding('bash-output-cap', { type: 'paste', destination: 'shell-config', label: '', text: 'export BASH_MAX_OUTPUT_LENGTH=15000' }),
]
const before = await hashTree(fx.root)
const plans = planFindings(findings, { homeDir: fx.home, cwd: fx.project, shell: '/bin/zsh' })
// Exercise the exact rendering the dry-run path prints.
renderApplyList(plans.filter(p => p.plan !== null), plans.filter(p => p.plan === null), 0.000002)
const after = await hashTree(fx.root)
expect(plans.every(p => p.plan !== null)).toBe(true)
expect(after).toBe(before)
})
})
describe('finding-id regression guard', () => {
const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/
const KNOWN: ReadonlySet<FindingId> = new Set<FindingId>([
'read-edit-ratio', 'build-folder-reads', 'redundant-rereads', 'warmup-heavy',
'unused-mcp', 'mcp-low-coverage', 'mcp-project-scope', 'retry-heavy-capabilities',
'low-worth-sessions', 'context-heavy-sessions', 'cost-outliers', 'claude-md-too-long',
'bash-output-cap', 'unused-agents', 'unused-skills', 'unused-commands',
])
it('every finding produced by a detector run carries a stable, known, non-empty id', async () => {
const fx = await makeFixture()
const bigClaudeMd = '# Rules\n' + Array.from({ length: 260 }, (_, i) => `- rule ${i}`).join('\n') + '\n'
await writeFile(join(fx.project, 'CLAUDE.md'), bigClaudeMd)
function read(file: string, session = 's1'): ToolCall {
return { name: 'Read', input: { file_path: file }, sessionId: session, project: 'p' }
}
const calls: ToolCall[] = [
read('/p/node_modules/a.js'), read('/p/node_modules/b.js'), read('/p/dist/c.js'),
...Array.from({ length: 6 }, () => read('/p/src/app.ts')),
...Array.from({ length: 10 }, (): ToolCall => ({ name: 'Edit', input: {}, sessionId: 's1', project: 'p' })),
]
const coverage: McpServerCoverage[] = [{
server: 'x', toolsAvailable: 20, toolsInvoked: 1,
unusedTools: Array.from({ length: 19 }, (_, i) => `mcp__x__t${i}`),
invocations: 1, loadedSessions: 3, coverageRatio: 0.05,
}]
const findings = [
detectLowReadEditRatio(calls),
detectJunkReads(calls),
detectDuplicateReads(calls),
detectBloatedClaudeMd(new Set([fx.project])),
detectMcpToolCoverage([], coverage),
].filter((f): f is WasteFinding => f !== null)
expect(findings.length).toBeGreaterThanOrEqual(5)
for (const f of findings) {
expect(f.id).toBeTruthy()
expect(f.id).toMatch(KEBAB)
expect(KNOWN.has(f.id)).toBe(true)
}
const ids = findings.map(f => f.id)
expect(new Set(ids).size).toBe(ids.length)
})
})
describe('unused-mcp plan', () => {
it('builds a remove-everywhere plan, including other projects[*] entries', async () => {
const fx = await makeFixture()
const claudeJson = join(fx.home, '.claude.json')
await writeFile(claudeJson, JSON.stringify({
mcpServers: { u: { command: 'u' } },
projects: { '/some/other': { mcpServers: { u: { command: 'u' }, keepme: {} } } },
}, null, 2) + '\n')
const finding = makeFinding('unused-mcp', CMD_FIX, { kind: 'mcp-remove', servers: ['u'] })
const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project })
expect(plan).not.toBeNull()
expect(plan!.kind).toBe('mcp-remove')
await runAction(plan!, fx.actionsDir)
const after = JSON.parse(await readFile(claudeJson, 'utf-8'))
expect(after.mcpServers).toEqual({})
expect(after.projects['/some/other'].mcpServers).toEqual({ keepme: {} })
})
})
describe('BOM handling', () => {
it('parses a config file with a UTF-8 BOM', async () => {
const fx = await makeFixture()
const claudeJson = join(fx.home, '.claude.json')
await writeFile(claudeJson, '' + JSON.stringify({ mcpServers: { b: {} }, keep: 1 }, null, 2) + '\n')
const { plan, notes } = planFindings(
[makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['b'] })],
{ homeDir: fx.home, cwd: fx.project },
)[0]!
expect(notes).toEqual([])
expect(plan).not.toBeNull()
const written = JSON.parse(String(plan!.changes[0]!.content))
expect(written).toEqual({ mcpServers: {}, keep: 1 })
})
})
// ---------------------------------------------------------------------------
// runOptimizeApply end-to-end (injected stdio, crafted findings, fixture home)
// ---------------------------------------------------------------------------
const ANSI = /\[[0-9;]*m/g
type Io = {
input: PassThrough
output: Writable
errorOutput: Writable
stdout(): string
stderr(): string
}
function makeIo(answer?: string): Io {
const input = new PassThrough()
input.end(answer ?? '')
const outChunks: Buffer[] = []
const errChunks: Buffer[] = []
const output = new Writable({ write(c, _e, cb) { outChunks.push(Buffer.from(c)); cb() } })
const errorOutput = new Writable({ write(c, _e, cb) { errChunks.push(Buffer.from(c)); cb() } })
return {
input,
output,
errorOutput,
stdout: () => Buffer.concat(outChunks).toString('utf-8').replace(ANSI, ''),
stderr: () => Buffer.concat(errChunks).toString('utf-8').replace(ANSI, ''),
}
}
function applyOpts(fx: Fixture, io: Io, extra: Partial<ApplyOptions> & { findings: WasteFinding[] }): ApplyOptions {
const ctx: PlanContext = { homeDir: fx.home, cwd: fx.project, shell: '/bin/zsh' }
return {
ctx,
actionsDir: fx.actionsDir,
input: io.input,
output: io.output,
errorOutput: io.errorOutput,
...extra,
}
}
// One mcp server, one skill, one shell cap: three appliable findings in a
// stable 1/2/3 order for the picker tests.
async function threeFindingFixture(): Promise<{ fx: Fixture; findings: WasteFinding[] }> {
const fx = await makeFixture()
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ mcpServers: { a: { command: 'a' } } }, null, 2) + '\n')
await mkdir(join(fx.home, '.claude', 'skills', 'foo'), { recursive: true })
await writeFile(join(fx.home, '.claude', 'skills', 'foo', 'SKILL.md'), 'x')
const findings: WasteFinding[] = [
makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['a'] }),
makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['foo'] }),
makeFinding('bash-output-cap', { type: 'paste', destination: 'shell-config', label: '', text: 'export BASH_MAX_OUTPUT_LENGTH=15000' }),
]
return { fx, findings }
}
describe('runOptimizeApply end-to-end', () => {
it('--yes applies every plan and prints journal short ids with the undo hint', async () => {
const { fx, findings } = await threeFindingFixture()
const io = makeIo()
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, yes: true }))
const records = await readRecords(fx.actionsDir)
expect(records).toHaveLength(3)
const out = io.stdout()
for (const rec of records) {
expect(out).toContain(`Applied ${shortId(rec.id)}`)
expect(out).toContain(`Undo anytime: codeburn act undo ${shortId(rec.id)}`)
}
expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({})
expect(existsSync(join(fx.home, '.claude', 'skills', '.archived', 'foo'))).toBe(true)
expect(existsSync(join(fx.home, '.zshrc'))).toBe(true)
})
it('interactive pick "2" applies only the second plan', async () => {
const { fx, findings } = await threeFindingFixture()
const io = makeIo('2\n')
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings }))
const records = await readRecords(fx.actionsDir)
expect(records).toHaveLength(1)
expect(records[0]!.kind).toBe('archive-skill')
expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({ a: { command: 'a' } })
expect(existsSync(join(fx.home, '.zshrc'))).toBe(false)
})
it('interactive pick "1,3" applies the first and third plans', async () => {
const { fx, findings } = await threeFindingFixture()
const io = makeIo('1,3\n')
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings }))
const records = await readRecords(fx.actionsDir)
expect(records.map(r => r.kind).sort()).toEqual(['mcp-remove', 'shell-config'])
expect(existsSync(join(fx.home, '.claude', 'skills', 'foo'))).toBe(true)
})
it('a garbage answer applies nothing and prints the empty outcome', async () => {
const { fx, findings } = await threeFindingFixture()
const io = makeIo('wat\n')
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings }))
expect(await readRecords(fx.actionsDir)).toHaveLength(0)
expect(io.stdout()).toContain('Nothing applied.')
})
it('EOF at the prompt prints "Nothing applied." and leaves the exit code untouched', async () => {
const { fx, findings } = await threeFindingFixture()
const io = makeIo()
const prevExit = process.exitCode
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings }))
expect(process.exitCode).toBe(prevExit)
expect(io.stdout()).toContain('Nothing applied.')
expect(await readRecords(fx.actionsDir)).toHaveLength(0)
})
it('--only restricts the applied set', async () => {
const { fx, findings } = await threeFindingFixture()
const io = makeIo()
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, yes: true, only: 'unused-skills' }))
const records = await readRecords(fx.actionsDir)
expect(records).toHaveLength(1)
expect(records[0]!.kind).toBe('archive-skill')
expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({ a: { command: 'a' } })
})
it('--only with an unknown or not-appliable id errors with the valid ids and exit code 2', async () => {
const { fx, findings } = await threeFindingFixture()
const io = makeIo()
const prevExit = process.exitCode
try {
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, yes: true, only: 'read-edit-ratio' }))
expect(process.exitCode).toBe(2)
const err = io.stderr()
expect(err).toContain('read-edit-ratio')
expect(err).toContain('Appliable ids for this run:')
expect(err).toContain('mcp-low-coverage')
expect(await readRecords(fx.actionsDir)).toHaveLength(0)
expect(io.stdout()).not.toContain('No appliable config-class fixes')
} finally {
process.exitCode = prevExit
}
})
it('--yes skips claude-md plans with a reason unless explicitly selected via --only', async () => {
const { fx, findings } = await threeFindingFixture()
const claudeMdFinding = makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'Read first.' })
const io = makeIo()
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [claudeMdFinding, ...findings], yes: true }))
expect(io.stdout()).toContain('Skipped read-edit-ratio: CLAUDE.md edits are not applied with --yes')
expect(existsSync(join(fx.project, 'CLAUDE.md'))).toBe(false)
expect((await readRecords(fx.actionsDir)).map(r => r.kind)).not.toContain('claude-md-rule')
const fx2 = await makeFixture()
const io2 = makeIo()
await runOptimizeApply([], undefined, applyOpts(fx2, io2, { findings: [claudeMdFinding], yes: true, only: 'read-edit-ratio' }))
expect((await readRecords(fx2.actionsDir)).map(r => r.kind)).toEqual(['claude-md-rule'])
expect(await readFile(join(fx2.project, 'CLAUDE.md'), 'utf-8')).toContain('<!-- codeburn:begin read-edit-ratio -->')
})
it('project-scope leaves unrelated projects[*] entries untouched and previews the cold removals', async () => {
const fx = await makeFixture()
const claudeJson = join(fx.home, '.claude.json')
const serverValue = { command: 'srv' }
await writeFile(claudeJson, JSON.stringify({
mcpServers: { srv: serverValue },
projects: {
'/cold/one': { mcpServers: { srv: serverValue } },
'/unrelated/proj': { mcpServers: { srv: serverValue, keepme: {} } },
},
}, null, 2) + '\n')
const keeper = join(fx.root, 'keeper')
await mkdir(keeper, { recursive: true })
const finding = makeFinding('mcp-project-scope', CMD_FIX, {
kind: 'mcp-project-scope',
servers: [{ server: 'srv', keepProjects: [keeper], removeProjects: ['/cold/one'] }],
})
const io = makeIo()
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true }))
expect(io.stdout()).toContain('(removes srv from 1 project entry: /cold/one)')
const after = JSON.parse(await readFile(claudeJson, 'utf-8'))
expect(after.mcpServers).toEqual({})
expect(after.projects['/cold/one'].mcpServers).toEqual({})
expect(after.projects['/unrelated/proj'].mcpServers).toEqual({ srv: serverValue, keepme: {} })
expect(JSON.parse(await readFile(join(keeper, '.mcp.json'), 'utf-8')).mcpServers).toEqual({ srv: serverValue })
})
it('surfaces parse-error notes when every plan resolves to null', async () => {
const fx = await makeFixture()
await writeFile(join(fx.home, '.claude.json'), 'not json{{{')
const finding = makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['ghost'] })
const io = makeIo()
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true }))
const out = io.stdout()
expect(out).toContain('No appliable config-class fixes')
expect(out).toContain('could not parse')
expect(out).toContain('.claude.json')
expect(await readRecords(fx.actionsDir)).toHaveLength(0)
})
it('renders notes under manual findings alongside appliable ones', async () => {
const fx = await makeFixture()
await writeFile(join(fx.home, '.claude.json'), 'not json{{{')
await mkdir(join(fx.home, '.claude', 'skills', 'foo'), { recursive: true })
await writeFile(join(fx.home, '.claude', 'skills', 'foo', 'SKILL.md'), 'x')
const findings: WasteFinding[] = [
makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['ghost'] }),
makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['foo'] }),
]
const io = makeIo()
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, dryRun: true }))
const out = io.stdout()
expect(out).toContain('[mcp-low-coverage] manual')
expect(out).toContain('could not parse')
})
})
describe('stale-plan detection', () => {
it('rejects when the target changed after the plan was built, leaving nothing behind', async () => {
const fx = await makeFixture()
const claudeJson = join(fx.home, '.claude.json')
await writeFile(claudeJson, JSON.stringify({ mcpServers: { s: {} } }, null, 2) + '\n')
const plan = planFor(
makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['s'] }),
{ homeDir: fx.home, cwd: fx.project },
)
expect(plan).not.toBeNull()
const interim = JSON.stringify({ mcpServers: { s: {}, addedMeanwhile: {} } }, null, 2) + '\n'
await writeFile(claudeJson, interim)
await expect(runAction(plan!, fx.actionsDir)).rejects.toThrow(/changed since the plan was built; re-run codeburn optimize --apply/)
expect(await readFile(claudeJson, 'utf-8')).toBe(interim)
expect(await readRecords(fx.actionsDir)).toHaveLength(0)
const backups = await readdir(join(fx.actionsDir, 'backups')).catch(() => [])
expect(backups).toEqual([])
})
it('rejects a plan expecting an absent file when the file appeared', async () => {
const fx = await makeFixture()
const claudeMd = join(fx.project, 'CLAUDE.md')
const plan = planFor(
makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'rule' }),
{ homeDir: fx.home, cwd: fx.project },
)
expect(plan!.changes[0]).toMatchObject({ op: 'create', expectedHash: null })
await writeFile(claudeMd, '# appeared meanwhile\n')
await expect(runAction(plan!, fx.actionsDir)).rejects.toThrow(/changed since the plan was built/)
expect(await readFile(claudeMd, 'utf-8')).toBe('# appeared meanwhile\n')
expect(await readRecords(fx.actionsDir)).toHaveLength(0)
})
it('applies when the target still matches the expected hash', async () => {
const fx = await makeFixture()
const claudeJson = join(fx.home, '.claude.json')
await writeFile(claudeJson, JSON.stringify({ mcpServers: { s: {} } }, null, 2) + '\n')
const plan = planFor(
makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['s'] }),
{ homeDir: fx.home, cwd: fx.project },
)
expect(plan!.changes[0]!.op === 'move' ? undefined : plan!.changes[0]!.expectedHash).toMatch(/^[0-9a-f]{64}$/)
const rec = await runAction(plan!, fx.actionsDir)
expect(rec.status).toBe('applied')
expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({})
})
it('a change without expectedHash skips validation (framework back-compat)', async () => {
const fx = await makeFixture()
const p = join(fx.home, 'free.txt')
await writeFile(p, 'anything at all')
const rec = await runAction({
kind: 'claude-md-rule',
description: 'no expected hash',
changes: [{ op: 'edit', path: p, content: 'overwritten' }],
}, fx.actionsDir)
expect(rec.status).toBe('applied')
expect(await readFile(p, 'utf-8')).toBe('overwritten')
})
})
+425
View File
@@ -0,0 +1,425 @@
import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import * as fsUtils from '../src/fs-utils.js'
vi.mock('os', async () => {
const actual = await vi.importActual<typeof import('os')>('os')
const fs = await vi.importActual<typeof import('fs')>('fs')
const fakeHome = fs.mkdtempSync(actual.tmpdir() + '/codeburn-home-')
fs.mkdirSync(fakeHome + '/.claude', { recursive: true })
process.env['CODEBURN_TEST_FAKE_HOME'] = fakeHome
return { ...actual, homedir: () => fakeHome }
})
const FAKE_HOME_FOR_MOCK = process.env['CODEBURN_TEST_FAKE_HOME']!
import {
detectBloatedClaudeMd,
detectUnusedMcp,
detectBashBloat,
detectGhostCommands,
loadMcpConfigs,
scanJsonlFile,
scanAndDetect,
type ToolCall,
} from '../src/optimize.js'
import {
estimateContextBudget,
discoverProjectCwd,
} from '../src/context-budget.js'
// ============================================================================
// Helpers for filesystem fixtures
// ============================================================================
const FIXTURE_ROOTS: string[] = [FAKE_HOME_FOR_MOCK]
function makeFixtureRoot(): string {
const dir = mkdtempSync(join(tmpdir(), 'codeburn-test-'))
FIXTURE_ROOTS.push(dir)
return dir
}
function writeFile(path: string, content: string): void {
mkdirSync(join(path, '..'), { recursive: true })
writeFileSync(path, content)
}
function touchOld(path: string, daysAgo: number): void {
const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000)
utimesSync(path, past, past)
}
afterAll(() => {
for (const dir of FIXTURE_ROOTS) {
rmSync(dir, { recursive: true, force: true })
}
})
// ============================================================================
// detectBloatedClaudeMd (including @-import expansion)
// ============================================================================
describe('detectBloatedClaudeMd', () => {
it('flags a CLAUDE.md with more than 200 lines', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
const content = Array.from({ length: 300 }, (_, i) => `line ${i}`).join('\n')
writeFile(join(projectDir, 'CLAUDE.md'), content)
const finding = detectBloatedClaudeMd(new Set([projectDir]))
expect(finding).not.toBeNull()
})
it('expands @-imports and counts transitive load', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(
join(projectDir, 'CLAUDE.md'),
'line 1\nline 2\n@./rules.md\n@./conventions.md\n',
)
writeFile(join(projectDir, 'rules.md'), Array.from({ length: 120 }, (_, i) => `rule ${i}`).join('\n'))
writeFile(join(projectDir, 'conventions.md'), Array.from({ length: 120 }, (_, i) => `conv ${i}`).join('\n'))
const finding = detectBloatedClaudeMd(new Set([projectDir]))
expect(finding).not.toBeNull()
expect(finding!.explanation).toContain('2 @-imports')
})
it('does not flag a lean CLAUDE.md under 200 lines with no imports', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, 'CLAUDE.md'), 'just a few\nlines\nhere\n')
expect(detectBloatedClaudeMd(new Set([projectDir]))).toBeNull()
})
it('does not recurse infinitely on circular @-imports', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, 'CLAUDE.md'), '@./a.md\n')
writeFile(join(projectDir, 'a.md'), '@./b.md\n')
writeFile(join(projectDir, 'b.md'), '@./a.md\n')
expect(() => detectBloatedClaudeMd(new Set([projectDir]))).not.toThrow()
})
it('ignores @ tokens that are not paths (emails, npm scopes)', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(
join(projectDir, 'CLAUDE.md'),
Array.from({ length: 250 }, (_, i) =>
i === 10 ? '@user@example.com' :
i === 20 ? '@org/package' :
`line ${i}`
).join('\n'),
)
const finding = detectBloatedClaudeMd(new Set([projectDir]))
expect(finding).not.toBeNull()
// "with N @-imports" suffix appears only when non-zero imports were resolved
expect(finding!.explanation).not.toMatch(/with \d+ @-import/)
})
})
// ============================================================================
// loadMcpConfigs + detectUnusedMcp
// ============================================================================
describe('loadMcpConfigs', () => {
it('returns empty map when no configs exist', () => {
const root = makeFixtureRoot()
const servers = loadMcpConfigs([root])
expect(servers.size).toBe(0)
})
it('reads servers from project .mcp.json', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
mcpServers: { foo: { command: 'foo' }, bar: { command: 'bar' } },
}))
const servers = loadMcpConfigs([projectDir])
expect(servers.has('foo')).toBe(true)
expect(servers.has('bar')).toBe(true)
})
it('normalizes server names by replacing colons with underscores', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
mcpServers: { 'plugin:context7:context7': { command: 'ctx' } },
}))
const servers = loadMcpConfigs([projectDir])
expect(servers.has('plugin_context7_context7')).toBe(true)
expect(servers.get('plugin_context7_context7')!.original).toBe('plugin:context7:context7')
})
it('handles malformed JSON without crashing', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, '.mcp.json'), '{ not valid json')
expect(() => loadMcpConfigs([projectDir])).not.toThrow()
expect(loadMcpConfigs([projectDir]).size).toBe(0)
})
})
describe('detectUnusedMcp', () => {
it('flags servers configured but never called', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
mcpServers: { ghost: { command: 'x' } },
}))
const configFile = join(projectDir, '.mcp.json')
touchOld(configFile, 30)
const finding = detectUnusedMcp([], [], new Set([projectDir]))
expect(finding).not.toBeNull()
expect(finding!.explanation).toContain('ghost')
})
it('does not flag servers configured within 24 hours', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
mcpServers: { freshly_added: { command: 'x' } },
}))
expect(detectUnusedMcp([], [], new Set([projectDir]))).toBeNull()
})
it('does not flag servers that were called', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
mcpServers: { used: { command: 'x' } },
}))
touchOld(join(projectDir, '.mcp.json'), 30)
const calls: ToolCall[] = [
{ name: 'mcp__used__some_tool', input: {}, sessionId: 's1', project: 'p1' },
]
expect(detectUnusedMcp(calls, [], new Set([projectDir]))).toBeNull()
})
})
// ============================================================================
// detectBashBloat
// ============================================================================
describe('detectBashBloat', () => {
it('flags when env var is unset (uses default 30K)', () => {
const finding = detectBashBloat()
expect(finding).not.toBeNull()
expect(finding!.impact).toBe('medium')
})
it('does not flag when env var is at recommended 15K', () => {
process.env['BASH_MAX_OUTPUT_LENGTH'] = '15000'
expect(detectBashBloat()).toBeNull()
})
it('does not flag when env var is below recommended', () => {
process.env['BASH_MAX_OUTPUT_LENGTH'] = '10000'
expect(detectBashBloat()).toBeNull()
})
it('flags when env var is above 15K', () => {
process.env['BASH_MAX_OUTPUT_LENGTH'] = '50000'
const finding = detectBashBloat()
expect(finding).not.toBeNull()
})
})
// ============================================================================
// detectGhostCommands (the pure-function ghost detector)
// ============================================================================
describe('detectGhostCommands', () => {
it('returns null when no commands are defined', async () => {
expect(await detectGhostCommands([])).toBeNull()
})
it('does not match /tmp or /usr or other path prefixes as command usage', async () => {
const messages = [
'check /tmp/debug.log',
'look at /usr/local/bin',
'rm -rf /var/cache',
]
expect(await detectGhostCommands(messages)).toBeNull()
})
it('matches <command-name> tags in user messages', async () => {
const messages = ['<command-name>review</command-name>']
expect(await detectGhostCommands(messages)).toBeNull()
})
})
// ============================================================================
// scanJsonlFile
// ============================================================================
describe('scanJsonlFile', () => {
it('returns empty result for nonexistent file', async () => {
const result = await scanJsonlFile('/nonexistent/path.jsonl', 'p1', undefined)
expect(result.calls).toEqual([])
expect(result.cwds).toEqual([])
expect(result.apiCalls).toEqual([])
expect(result.userMessages).toEqual([])
})
it('parses tool_use blocks from assistant entries', async () => {
const root = makeFixtureRoot()
const filePath = join(root, 'session.jsonl')
const now = new Date().toISOString()
const lines = [
JSON.stringify({
type: 'assistant',
timestamp: now,
message: {
content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/x/foo.ts' } }],
},
}),
]
writeFile(filePath, lines.join('\n'))
const result = await scanJsonlFile(filePath, 'p1', undefined)
expect(result.calls).toHaveLength(1)
expect(result.calls[0].name).toBe('Read')
})
it('skips malformed JSONL lines without crashing', async () => {
const root = makeFixtureRoot()
const filePath = join(root, 'session.jsonl')
writeFile(filePath, 'this is not json\n{broken\n{"type":"assistant","message":{"content":[]}}\n')
const result = await scanJsonlFile(filePath, 'p1', undefined)
expect(result.calls).toEqual([])
})
it('uses readSessionLines (streaming) rather than readSessionFile (full-string load)', async () => {
const readSessionLinesSpy = vi.spyOn(fsUtils, 'readSessionLines')
const readSessionFileSpy = vi.spyOn(fsUtils, 'readSessionFile')
const root = makeFixtureRoot()
const filePath = join(root, 'session.jsonl')
const now = new Date().toISOString()
writeFile(filePath, JSON.stringify({
type: 'assistant', timestamp: now,
message: { content: [{ type: 'tool_use', name: 'Bash', input: {} }] },
}))
await scanJsonlFile(filePath, 'p1', undefined)
expect(readSessionLinesSpy).toHaveBeenCalledWith(filePath, undefined, { largeLineAsBuffer: true })
expect(readSessionFileSpy).not.toHaveBeenCalled()
readSessionLinesSpy.mockRestore()
readSessionFileSpy.mockRestore()
})
it('processes all entries in a large multi-line file without truncation', async () => {
const root = makeFixtureRoot()
const filePath = join(root, 'session.jsonl')
const now = new Date().toISOString()
const ENTRY_COUNT = 500
const lines = Array.from({ length: ENTRY_COUNT }, (_, i) =>
JSON.stringify({
type: 'assistant',
timestamp: now,
message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: `/file-${i}.ts` } }] },
}),
)
writeFile(filePath, lines.join('\n'))
const result = await scanJsonlFile(filePath, 'p1', undefined)
expect(result.calls).toHaveLength(ENTRY_COUNT)
})
it('respects date-range filter for assistant entries', async () => {
const root = makeFixtureRoot()
const filePath = join(root, 'session.jsonl')
const old = '2020-01-01T00:00:00Z'
const now = new Date().toISOString()
writeFile(filePath, [
JSON.stringify({
type: 'assistant', timestamp: old,
message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/old' } }] },
}),
JSON.stringify({
type: 'assistant', timestamp: now,
message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/new' } }] },
}),
].join('\n'))
const today = new Date()
const start = new Date(today.getFullYear(), today.getMonth(), today.getDate())
const result = await scanJsonlFile(filePath, 'p1', { start, end: today })
expect(result.calls).toHaveLength(1)
expect((result.calls[0].input as Record<string, unknown>).file_path).toBe('/new')
})
})
// ============================================================================
// scanAndDetect (top-level integration)
// ============================================================================
describe('scanAndDetect', () => {
it('returns healthy result for empty projects', async () => {
const result = await scanAndDetect([])
expect(result.findings).toEqual([])
expect(result.healthScore).toBe(100)
expect(result.healthGrade).toBe('A')
expect(result.costRate).toBe(0)
})
})
// ============================================================================
// context-budget
// ============================================================================
describe('estimateContextBudget', () => {
it('returns only system base when project has no config', async () => {
const root = makeFixtureRoot()
const budget = await estimateContextBudget(root)
expect(budget.total).toBeGreaterThan(0)
expect(budget.mcpTools.count).toBe(0)
expect(budget.skills.count).toBe(0)
})
it('includes MCP tools from project .mcp.json', async () => {
const root = makeFixtureRoot()
writeFile(join(root, '.mcp.json'), JSON.stringify({
mcpServers: { a: { command: 'x' }, b: { command: 'x' } },
}))
const budget = await estimateContextBudget(root)
expect(budget.mcpTools.count).toBeGreaterThan(0)
})
it('includes memory file tokens from CLAUDE.md', async () => {
const root = makeFixtureRoot()
writeFile(join(root, 'CLAUDE.md'), 'Project context for Claude.\n')
const budget = await estimateContextBudget(root)
expect(budget.memory.count).toBeGreaterThan(0)
expect(budget.memory.tokens).toBeGreaterThan(0)
})
})
describe('discoverProjectCwd', () => {
it('returns null for empty directory', async () => {
const root = makeFixtureRoot()
expect(await discoverProjectCwd(root)).toBeNull()
})
it('returns null for directory with no jsonl files', async () => {
const root = makeFixtureRoot()
writeFile(join(root, 'readme.txt'), 'hi')
expect(await discoverProjectCwd(root)).toBeNull()
})
it('extracts cwd from the first jsonl entry', async () => {
const root = makeFixtureRoot()
const entry = JSON.stringify({ type: 'assistant', cwd: '/Users/test/project', timestamp: new Date().toISOString() })
writeFile(join(root, 'session.jsonl'), entry + '\n')
expect(await discoverProjectCwd(root)).toBe('/Users/test/project')
})
})
File diff suppressed because it is too large Load Diff
+202
View File
@@ -0,0 +1,202 @@
// Regression test for the OTel multi-conversation cache overwrite bug.
//
// Root cause: `parseProviderSources` in parser.ts calls
// `delete section.files[source.path]`
// at the START of every loop iteration over changedSources. When multiple OTel
// conversations from the same agent-traces.db share the same path key, each
// iteration wiped the merged turns accumulated by all previous iterations, so
// only the LAST conversation's data survived — a ~434x cost undercount in
// practice (observed: $0.19 vs ~$85 ground truth from OTel DB).
//
// This test exercises the full `parseAllSessions` pipeline to catch any future
// regression at the aggregation layer, not just the provider-level parser.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { createRequire } from 'node:module'
import { isSqliteAvailable } from '../src/sqlite.js'
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
const requireForTest = createRequire(import.meta.url)
type TestDb = {
exec(sql: string): void
prepare(sql: string): { run(...p: unknown[]): void }
close(): void
}
function createOtelDb(dbPath: string): void {
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (path: string) => TestDb }
const db = new DatabaseSync(dbPath)
db.exec(`
CREATE TABLE spans (
span_id TEXT PRIMARY KEY NOT NULL,
trace_id TEXT NOT NULL,
operation_name TEXT,
start_time_ms INTEGER NOT NULL DEFAULT 0,
response_model TEXT
);
CREATE TABLE span_attributes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
span_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT
);
`)
db.close()
}
interface ConvSpec {
spanId: string
traceId: string
convId: string
model: string
input: number
output: number
cacheRead: number
cacheCreate: number
startTimeMs?: number
}
function insertConversation(dbPath: string, spec: ConvSpec): void {
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (path: string) => TestDb }
const db = new DatabaseSync(dbPath)
db.prepare(
`INSERT INTO spans (span_id, trace_id, operation_name, start_time_ms, response_model)
VALUES (?, ?, ?, ?, ?)`
).run(spec.spanId, spec.traceId, 'chat', spec.startTimeMs ?? Date.now(), spec.model)
const attrStmt = db.prepare(
`INSERT INTO span_attributes (span_id, key, value) VALUES (?, ?, ?)`
)
const attrs: Record<string, string | number> = {
'gen_ai.conversation.id': spec.convId,
'gen_ai.response.model': spec.model,
'gen_ai.usage.input_tokens': spec.input,
'gen_ai.usage.output_tokens': spec.output,
'gen_ai.usage.cache_read.input_tokens': spec.cacheRead,
'gen_ai.usage.cache_creation.input_tokens': spec.cacheCreate,
}
for (const [key, value] of Object.entries(attrs)) {
attrStmt.run(spec.spanId, key, String(value))
}
db.close()
}
describe.skipIf(!isSqliteAvailable())(
'OTel multi-conversation cache aggregation (regression for 434x undercount)',
() => {
let tmpHome: string
let tmpCache: string
let dbPath: string
beforeEach(async () => {
tmpHome = await mkdtemp(join(tmpdir(), 'cb-otel-agg-home-'))
tmpCache = await mkdtemp(join(tmpdir(), 'cb-otel-agg-cache-'))
dbPath = join(tmpHome, 'agent-traces.db')
process.env['HOME'] = tmpHome
process.env['CODEBURN_CACHE_DIR'] = tmpCache
vi.stubEnv('CODEBURN_COPILOT_OTEL_DB', dbPath)
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '')
// Redirect JSONL and transcript dirs to nonexistent paths so real
// developer session files don't contaminate the test results.
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', join(tmpHome, 'no-jsonl'))
vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpHome, 'no-ws'))
})
afterEach(async () => {
clearSessionCache()
vi.unstubAllEnvs()
await rm(tmpHome, { recursive: true, force: true })
await rm(tmpCache, { recursive: true, force: true })
})
it('preserves cache tokens from all conversations, not just the last one', async () => {
// Pricing for claude-haiku-4-5 (per litellm-snapshot.json):
// input: $1.00 / M → 1e-6
// output: $5.00 / M → 5e-6
// cache_read: $0.10 / M → 1e-7
// cache_write: $1.25 / M → 1.25e-6
createOtelDb(dbPath)
const conversations: ConvSpec[] = [
{ spanId: 'span-1', traceId: 'trace-1', convId: 'conv-1', model: 'claude-haiku-4-5-20251001',
input: 1_000, output: 100, cacheRead: 50_000, cacheCreate: 500 },
{ spanId: 'span-2', traceId: 'trace-2', convId: 'conv-2', model: 'claude-haiku-4-5-20251001',
input: 2_000, output: 200, cacheRead: 60_000, cacheCreate: 600 },
{ spanId: 'span-3', traceId: 'trace-3', convId: 'conv-3', model: 'claude-haiku-4-5-20251001',
input: 3_000, output: 300, cacheRead: 70_000, cacheCreate: 700 },
]
for (const c of conversations) insertConversation(dbPath, c)
const projects = await parseAllSessions(undefined, 'copilot')
const allCalls = projects
.flatMap(p => p.sessions)
.flatMap(s => s.turns)
.flatMap(t => t.assistantCalls)
// All three conversations must be present — before the fix only 1 survived.
expect(allCalls).toHaveLength(3)
const totalInput = allCalls.reduce((s, c) => s + c.usage.inputTokens, 0)
const totalOutput = allCalls.reduce((s, c) => s + c.usage.outputTokens, 0)
const totalCacheRead = allCalls.reduce((s, c) => s + c.usage.cacheReadInputTokens, 0)
const totalCacheCreate = allCalls.reduce((s, c) => s + c.usage.cacheCreationInputTokens, 0)
expect(totalInput).toBe(6_000) // 1 000 + 2 000 + 3 000
expect(totalOutput).toBe(600) // 100 + 200 + 300
expect(totalCacheRead).toBe(180_000) // 50k + 60k + 70k — all must survive
expect(totalCacheCreate).toBe(1_800) // 500 + 600 + 700
// Pre-fix regression check: if only the last conversation survived,
// totalCacheRead would be 70 000 (the last one). Assert it's 180 000.
expect(totalCacheRead).toBeGreaterThan(100_000)
// Cost sanity: input+output+cacheRead+cacheCreate with haiku-4-5 pricing
// 6000 * 1e-6 = 0.006
// 600 * 5e-6 = 0.003
// 180k * 1e-7 = 0.018
// 1800 * 1.25e-6 = 0.00225
// total ≈ $0.029
const totalCostUSD = allCalls.reduce((s, c) => s + c.costUSD, 0)
expect(totalCostUSD).toBeCloseTo(0.029, 2)
})
it('second run from disk cache also delivers all conversations', async () => {
// Ensures the merged result written to the session-cache.json survives
// a reload and yields the same aggregated data on repeat invocations.
createOtelDb(dbPath)
const conversations: ConvSpec[] = [
{ spanId: 'span-a', traceId: 'trace-a', convId: 'conv-a', model: 'claude-haiku-4-5-20251001',
input: 500, output: 50, cacheRead: 25_000, cacheCreate: 250 },
{ spanId: 'span-b', traceId: 'trace-b', convId: 'conv-b', model: 'claude-haiku-4-5-20251001',
input: 500, output: 50, cacheRead: 25_000, cacheCreate: 250 },
]
for (const c of conversations) insertConversation(dbPath, c)
// First run — parses and writes disk cache
await parseAllSessions(undefined, 'copilot')
// Clear in-memory cache only, leaving disk cache intact
clearSessionCache()
// Second run — should read from disk cache
const projects = await parseAllSessions(undefined, 'copilot')
const allCalls = projects
.flatMap(p => p.sessions)
.flatMap(s => s.turns)
.flatMap(t => t.assistantCalls)
expect(allCalls).toHaveLength(2)
const totalCacheRead = allCalls.reduce((s, c) => s + c.usage.cacheReadInputTokens, 0)
expect(totalCacheRead).toBe(50_000) // 25k + 25k from both conversations
})
}
)
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest'
import { renderOverview } from '../src/overview.js'
import type { ProjectSummary } from '../src/types.js'
function makeProject(opts: {
project: string
projectPath: string
cost: number
calls: number
model: string
provider: string
tokens: { input: number; output: number; cacheR: number; cacheW: number }
}): ProjectSummary {
const usage = {
inputTokens: opts.tokens.input,
outputTokens: opts.tokens.output,
cacheReadInputTokens: opts.tokens.cacheR,
cacheCreationInputTokens: opts.tokens.cacheW,
}
return {
project: opts.project,
projectPath: opts.projectPath,
totalCostUSD: opts.cost,
totalSavingsUSD: 0,
totalProxiedCostUSD: 0,
totalApiCalls: opts.calls,
sessions: [{
sessionId: 's1',
project: opts.project,
totalInputTokens: opts.tokens.input,
totalOutputTokens: opts.tokens.output,
totalCacheReadTokens: opts.tokens.cacheR,
totalCacheWriteTokens: opts.tokens.cacheW,
apiCalls: opts.calls,
modelBreakdown: { [opts.model]: { calls: opts.calls, costUSD: opts.cost, savingsUSD: 0, tokens: usage } },
categoryBreakdown: { coding: { turns: 1, costUSD: opts.cost, savingsUSD: 0, retries: 0, editTurns: 1, oneShotTurns: 1 } },
toolBreakdown: { Bash: { calls: 5 }, Read: { calls: 2 } },
mcpBreakdown: {},
bashBreakdown: {},
skillBreakdown: {},
subagentBreakdown: {},
turns: [{
userMessage: 'hi',
timestamp: '2026-06-15T10:00:00Z',
sessionId: 's1',
category: 'coding',
retries: 0,
hasEdits: true,
assistantCalls: [{ provider: opts.provider, model: opts.model, costUSD: opts.cost, usage }],
}],
}],
} as unknown as ProjectSummary
}
describe('renderOverview', () => {
it('renders the detailed sections from real aggregation', () => {
const out = renderOverview([makeProject({
project: 'myproject',
projectPath: '/Users/test/myproject',
cost: 12.5,
calls: 3,
model: 'claude-opus-4-8',
provider: 'claude',
tokens: { input: 1000, output: 200, cacheR: 5000, cacheW: 100 },
})], { label: 'June 2026', color: false })
for (const section of ['Totals', 'By tool', 'Top models', 'Highest-value days', 'Top projects', 'Daily', 'By activity', 'Tools']) {
expect(out).toContain(section)
}
expect(out).toContain('Opus 4.8') // model display name
expect(out).toContain('claude') // provider in By tool
expect(out).toContain('myproject') // clean project name from path basename
expect(out).toContain('$12.50')
expect(out).toContain('2026-06-15')
expect(out).toContain('Coding')
expect(out).toContain('Bash')
})
it('uses thousands separators and a B unit, and strips color in no-color mode', () => {
const out = renderOverview([makeProject({
project: 'big',
projectPath: '/Users/test/big',
cost: 1234.56,
calls: 10,
model: 'claude-opus-4-8',
provider: 'claude',
tokens: { input: 1_000_000, output: 1_000_000, cacheR: 2_000_000_000, cacheW: 0 },
})], { label: 'June 2026', color: false })
expect(out).toContain('$1,234.56')
// tokens render as full, comma-grouped numbers (not abbreviated)
expect(out).toContain('2,002,000,000')
// no-color mode must not emit ANSI escape codes
// eslint-disable-next-line no-control-regex
expect(out).not.toMatch(/\[/)
})
it('reports no usage for an empty range', () => {
const out = renderOverview([], { label: 'June 2026', color: false })
expect(out).toContain('No usage found for June 2026')
})
it('does not split a slug-only Claude project path into fake path segments', () => {
const out = renderOverview([makeProject({
project: 'Projects-Content-OS',
projectPath: 'Projects/Content/OS',
cost: 3.25,
calls: 1,
model: 'claude-sonnet-4-5',
provider: 'claude',
tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 },
})], { label: 'June 2026', color: false })
expect(out).toContain('Projects-Content-OS')
expect(out).not.toContain(' OS ')
})
})
describe('renderOverview unpriced models', () => {
it('warns when a model with usage has no pricing data', () => {
const out = renderOverview([makeProject({
project: 'mystery',
projectPath: '/Users/test/mystery',
cost: 0,
calls: 4,
model: 'zz-mystery-paid-model-999',
provider: 'claude',
tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 },
})], { label: 'June 2026', color: false })
expect(out).toContain('Unpriced')
expect(out).toContain('1 model at $0')
expect(out).toContain('zz-mystery-paid-model-999')
expect(out).toContain('codeburn model-alias')
})
it('stays silent when every model is priced', () => {
const out = renderOverview([makeProject({
project: 'priced',
projectPath: '/Users/test/priced',
cost: 5,
calls: 2,
model: 'claude-opus-4-8',
provider: 'claude',
tokens: { input: 100, output: 50, cacheR: 0, cacheW: 0 },
})], { label: 'June 2026', color: false })
expect(out).not.toContain('Unpriced')
})
})
+577
View File
@@ -0,0 +1,577 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm, utimes } from 'fs/promises'
import { join, relative } from 'path'
import { tmpdir, homedir } from 'os'
import { parseAllSessions } from '../src/parser.js'
import type { DateRange } from '../src/types.js'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'claude-cwd-test-'))
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
// Point desktop sessions at an empty subdir by default so real sessions
// on the developer's machine do not bleed into the unit tests.
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'desktop-sessions')
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
function dayRange(day: string): DateRange {
return {
start: new Date(`${day}T00:00:00.000Z`),
end: new Date(`${day}T23:59:59.999Z`),
}
}
async function writeClaudeSession(
projectSlug: string,
sessionId: string,
cwd: string,
timestamp: string,
usage: Record<string, unknown> = { input_tokens: 100, output_tokens: 50 },
model = 'claude-sonnet-4-5',
): Promise<void> {
const projectDir = join(tmpDir, 'projects', projectSlug)
await mkdir(projectDir, { recursive: true })
const filePath = join(projectDir, `${sessionId}.jsonl`)
await writeFile(filePath, JSON.stringify({
type: 'assistant',
sessionId,
timestamp,
cwd,
message: {
id: `msg-${sessionId}`,
type: 'message',
role: 'assistant',
model,
content: [],
usage,
},
}) + '\n')
const mtime = new Date(timestamp)
await utimes(filePath, mtime, mtime)
}
describe('Claude cwd project paths', () => {
it('uses the JSONL cwd as the canonical project path instead of the lossy directory slug', async () => {
await writeClaudeSession(
'c--AI-LAB-OPENCLAW',
'windows-session',
'C:\\AI_LAB\\OPENCLAW',
'2099-05-01T12:00:00.000Z',
)
const projects = await parseAllSessions(dayRange('2099-05-01'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.projectPath).toBe('C:\\AI_LAB\\OPENCLAW')
expect(projects[0]!.projectPath).not.toBe('c//AI/LAB/OPENCLAW')
expect(projects[0]!.totalApiCalls).toBe(1)
})
it('groups Windows cwd case and slash variants into one project', async () => {
await writeClaudeSession(
'windows-openclaw-a',
'upper-backslash',
'C:\\AI_LAB\\OPENCLAW',
'2099-05-02T10:00:00.000Z',
)
await writeClaudeSession(
'windows-openclaw-b',
'lower-forward-slash',
'c:/AI_LAB/OPENCLAW/',
'2099-05-02T11:00:00.000Z',
)
const projects = await parseAllSessions(dayRange('2099-05-02'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.sessions).toHaveLength(2)
expect(projects[0]!.totalApiCalls).toBe(2)
expect(projects[0]!.sessions.map(s => s.sessionId).sort()).toEqual([
'lower-forward-slash',
'upper-backslash',
])
})
it('prefers the canonical cwd path even when mixed with slug-only sessions in the same directory', async () => {
const slug = 'c--AI-LAB-OPENCLAW'
const projectDir = join(tmpDir, 'projects', slug)
await mkdir(projectDir, { recursive: true })
const noCwdPath = join(projectDir, 'a-no-cwd.jsonl')
await writeFile(noCwdPath, JSON.stringify({
type: 'assistant',
sessionId: 'no-cwd',
timestamp: '2099-05-03T10:00:00.000Z',
message: {
id: 'msg-no-cwd', type: 'message', role: 'assistant',
model: 'claude-sonnet-4-5', content: [],
usage: { input_tokens: 100, output_tokens: 50 },
},
}) + '\n')
await utimes(noCwdPath, new Date('2099-05-03T10:00:00.000Z'), new Date('2099-05-03T10:00:00.000Z'))
await writeClaudeSession(slug, 'b-with-cwd', 'C:\\AI_LAB\\OPENCLAW', '2099-05-03T11:00:00.000Z')
const projects = await parseAllSessions(dayRange('2099-05-03'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.sessions).toHaveLength(2)
expect(projects[0]!.projectPath).toBe('C:\\AI_LAB\\OPENCLAW')
expect(projects[0]!.projectPath).not.toBe('c//AI/LAB/OPENCLAW')
})
it('falls back to the slug-derived path when cwd is null, missing, or empty', async () => {
const slug = 'fallback-slug'
const projectDir = join(tmpDir, 'projects', slug)
await mkdir(projectDir, { recursive: true })
async function writeWith(name: string, sessionId: string, cwdField: unknown, ts: string) {
const filePath = join(projectDir, `${name}.jsonl`)
const obj: Record<string, unknown> = {
type: 'assistant', sessionId, timestamp: ts,
message: {
id: `msg-${sessionId}`, type: 'message', role: 'assistant',
model: 'claude-sonnet-4-5', content: [],
usage: { input_tokens: 100, output_tokens: 50 },
},
}
if (cwdField !== undefined) obj.cwd = cwdField
await writeFile(filePath, JSON.stringify(obj) + '\n')
await utimes(filePath, new Date(ts), new Date(ts))
}
await writeWith('null-cwd', 's-null', null, '2099-05-04T10:00:00.000Z')
await writeWith('empty-cwd', 's-empty', '', '2099-05-04T10:30:00.000Z')
await writeWith('whitespace-cwd', 's-ws', ' ', '2099-05-04T11:00:00.000Z')
await writeWith('missing-cwd', 's-miss', undefined, '2099-05-04T11:30:00.000Z')
const projects = await parseAllSessions(dayRange('2099-05-04'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.sessions).toHaveLength(4)
expect(projects[0]!.projectPath).toBe('fallback-slug')
})
it('keeps a hyphenated slug intact when cwd is absent', async () => {
const slug = 'Projects-Content-OS'
const projectDir = join(tmpDir, 'projects', slug)
await mkdir(projectDir, { recursive: true })
const filePath = join(projectDir, 'no-cwd.jsonl')
await writeFile(filePath, JSON.stringify({
type: 'assistant',
sessionId: 'no-cwd',
timestamp: '2099-05-09T10:00:00.000Z',
message: {
id: 'msg-no-cwd',
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [],
usage: { input_tokens: 100, output_tokens: 50 },
},
}) + '\n')
await utimes(filePath, new Date('2099-05-09T10:00:00.000Z'), new Date('2099-05-09T10:00:00.000Z'))
const projects = await parseAllSessions(dayRange('2099-05-09'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.project).toBe(slug)
expect(projects[0]!.projectPath).toBe(slug)
expect(projects[0]!.projectPath).not.toBe('Projects/Content/OS')
})
it('does not group sibling projects under a parent directory that merely contains .git', async () => {
const projectsRoot = join(tmpDir, 'Projects')
const swiftbar = join(projectsRoot, 'Swiftbar')
const contentOs = join(projectsRoot, 'Content-OS')
await mkdir(join(projectsRoot, '.git'), { recursive: true })
await mkdir(swiftbar, { recursive: true })
await mkdir(contentOs, { recursive: true })
await writeClaudeSession(
'tmp-Projects-Swiftbar',
'swiftbar-session',
swiftbar,
'2099-05-10T10:00:00.000Z',
)
await writeClaudeSession(
'tmp-Projects-Content-OS',
'content-os-session',
contentOs,
'2099-05-10T11:00:00.000Z',
)
const projects = await parseAllSessions(dayRange('2099-05-10'), 'claude')
const projectPaths = projects.map(project => project.projectPath).sort()
expect(projects).toHaveLength(2)
expect(projectPaths).toEqual([contentOs, swiftbar].sort())
expect(projectPaths).not.toContain(projectsRoot)
})
it('groups git worktrees under the main repository project', async () => {
const mainRepo = join(tmpDir, 'repos', 'codeburn')
const worktreeA = join(tmpDir, 'worktrees', 'codeburn-feature-a')
const worktreeB = join(tmpDir, 'worktrees', 'codeburn-feature-b')
await mkdir(join(mainRepo, '.git', 'worktrees', 'feature-a'), { recursive: true })
await mkdir(join(mainRepo, '.git', 'worktrees', 'feature-b'), { recursive: true })
await mkdir(worktreeA, { recursive: true })
await mkdir(worktreeB, { recursive: true })
await writeFile(join(worktreeA, '.git'), `gitdir: ${join(mainRepo, '.git', 'worktrees', 'feature-a')}\n`)
await writeFile(join(worktreeB, '.git'), `gitdir: ${relative(worktreeB, join(mainRepo, '.git', 'worktrees', 'feature-b'))}\n`)
await writeClaudeSession(
'tmp-worktrees-codeburn-feature-a',
'worktree-a-session',
worktreeA,
'2099-05-07T10:00:00.000Z',
)
await writeClaudeSession(
'tmp-worktrees-codeburn-feature-b',
'worktree-b-session',
worktreeB,
'2099-05-07T11:00:00.000Z',
)
const projects = await parseAllSessions(dayRange('2099-05-07'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.project).toBe('codeburn')
expect(projects[0]!.projectPath).toBe(mainRepo)
expect(projects[0]!.sessions).toHaveLength(2)
expect(projects[0]!.totalApiCalls).toBe(2)
expect(projects[0]!.sessions.map(s => s.sessionId).sort()).toEqual([
'worktree-a-session',
'worktree-b-session',
])
})
it('does not group separate-git-dir projects that are not git worktrees', async () => {
const externalGitDir = join(tmpDir, 'external-git-dirs', 'project.git')
const projectDir = join(tmpDir, 'standalone', 'separate-git-dir')
await mkdir(externalGitDir, { recursive: true })
await mkdir(projectDir, { recursive: true })
await writeFile(join(projectDir, '.git'), `gitdir: ${externalGitDir}\n`)
await writeClaudeSession(
'tmp-standalone-separate-git-dir',
'separate-git-dir-session',
projectDir,
'2099-05-08T10:00:00.000Z',
)
const projects = await parseAllSessions(dayRange('2099-05-08'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.projectPath).toBe(projectDir)
expect(projects[0]!.project).toBe('tmp-standalone-separate-git-dir')
})
})
describe('Claude cache creation pricing', () => {
it('prices 1-hour cache writes from usage.cache_creation at the 2x input rate', async () => {
await writeClaudeSession(
'cache-pricing',
'one-hour-cache',
'/tmp/cache-pricing',
'2099-05-05T10:00:00.000Z',
{
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 60_120,
cache_creation: {
ephemeral_5m_input_tokens: 0,
ephemeral_1h_input_tokens: 60_120,
},
},
'claude-opus-4-7',
)
const projects = await parseAllSessions(dayRange('2099-05-05'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.sessions[0]!.totalCacheWriteTokens).toBe(60_120)
expect(projects[0]!.totalCostUSD).toBeCloseTo(0.6012, 6)
})
it('falls back to the legacy 5-minute cache write rate when split fields are absent', async () => {
await writeClaudeSession(
'legacy-cache-pricing',
'legacy-cache',
'/tmp/legacy-cache-pricing',
'2099-05-06T10:00:00.000Z',
{
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 60_120,
},
'claude-opus-4-7',
)
const projects = await parseAllSessions(dayRange('2099-05-06'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.sessions[0]!.totalCacheWriteTokens).toBe(60_120)
expect(projects[0]!.totalCostUSD).toBeCloseTo(0.37575, 6)
})
})
// ── Helpers for Cowork local-agent-mode session fixtures ────────────────
async function writeCoworkSession(opts: {
desktopSessionsDir: string
appId: string
workspaceId: string
sessionId: string
spaceName?: string
spaceId?: string
userSelectedFolders?: string[]
title?: string
claudeSessionId: string
timestamp: string
usage?: Record<string, unknown>
model?: string
}): Promise<void> {
const {
desktopSessionsDir, appId, workspaceId, sessionId,
spaceName, spaceId, userSelectedFolders, title,
claudeSessionId, timestamp,
usage = { input_tokens: 100, output_tokens: 50 },
model = 'claude-sonnet-4-5',
} = opts
const workspaceDir = join(desktopSessionsDir, appId, workspaceId)
// spaces.json — written only when a space is defined
await mkdir(workspaceDir, { recursive: true })
if (spaceId && spaceName) {
await writeFile(join(workspaceDir, 'spaces.json'), JSON.stringify({
spaces: [{ id: spaceId, name: spaceName, folders: [], projects: [] }],
}))
}
// local_<sessionId>.json — session metadata
const outputsDir = join(workspaceDir, sessionId, 'outputs')
const sessionMeta: Record<string, unknown> = {
sessionId,
cwd: outputsDir,
title: title ?? (spaceName ? `Test session for ${spaceName}` : 'Untitled session'),
}
if (spaceId) sessionMeta['spaceId'] = spaceId
if (userSelectedFolders) sessionMeta['userSelectedFolders'] = userSelectedFolders
await writeFile(join(workspaceDir, `${sessionId}.json`), JSON.stringify(sessionMeta))
// .claude/projects/<slug>/session.jsonl — the actual token-bearing session
const projectSlug = outputsDir.replace(/[/\\]/g, '-').replace(/^-/, '')
const projectDir = join(workspaceDir, sessionId, '.claude', 'projects', projectSlug)
await mkdir(projectDir, { recursive: true })
const filePath = join(projectDir, `${claudeSessionId}.jsonl`)
await writeFile(filePath, JSON.stringify({
type: 'assistant',
sessionId: claudeSessionId,
timestamp,
cwd: outputsDir,
message: {
id: `msg-${claudeSessionId}`,
type: 'message',
role: 'assistant',
model,
content: [],
usage,
},
}) + '\n')
const mtime = new Date(timestamp)
await utimes(filePath, mtime, mtime)
}
describe('Claude Cowork local-agent-mode session grouping', () => {
it('groups multiple Cowork sessions from the same space under the space name', async () => {
const desktopSessionsDir = join(tmpDir, 'desktop-sessions')
const spaceId = 'space-001'
const spaceName = 'Project1'
await writeCoworkSession({
desktopSessionsDir,
appId: 'app-abc',
workspaceId: 'ws-001',
sessionId: 'local_aaaa',
spaceName,
spaceId,
claudeSessionId: 'session-a',
timestamp: '2099-06-01T10:00:00.000Z',
})
await writeCoworkSession({
desktopSessionsDir,
appId: 'app-abc',
workspaceId: 'ws-001',
sessionId: 'local_bbbb',
spaceName,
spaceId,
claudeSessionId: 'session-b',
timestamp: '2099-06-01T11:00:00.000Z',
})
const projects = await parseAllSessions(dayRange('2099-06-01'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.project).toBe(spaceName)
expect(projects[0]!.sessions).toHaveLength(2)
expect(projects[0]!.sessions.map(s => s.sessionId).sort()).toEqual([
'session-a',
'session-b',
])
})
it('keeps sessions from different spaces in separate projects', async () => {
const desktopSessionsDir = join(tmpDir, 'desktop-sessions')
// Each space gets its own workspace so their spaces.json files don't overwrite each other.
await writeCoworkSession({
desktopSessionsDir,
appId: 'app-abc',
workspaceId: 'ws-001',
sessionId: 'local_cccc',
spaceName: 'Project1',
spaceId: 'space-001',
claudeSessionId: 'session-c',
timestamp: '2099-06-02T10:00:00.000Z',
})
await writeCoworkSession({
desktopSessionsDir,
appId: 'app-abc',
workspaceId: 'ws-002',
sessionId: 'local_dddd',
spaceName: 'Project2',
spaceId: 'space-002',
claudeSessionId: 'session-d',
timestamp: '2099-06-02T11:00:00.000Z',
})
const projects = await parseAllSessions(dayRange('2099-06-02'), 'claude')
expect(projects).toHaveLength(2)
const names = projects.map(p => p.project).sort()
expect(names).toEqual(['Project1', 'Project2'])
})
it('falls back to userSelectedFolders[0] basename when no spaceId is set', async () => {
const desktopSessionsDir = join(tmpDir, 'desktop-sessions')
await writeCoworkSession({
desktopSessionsDir,
appId: 'app-abc',
workspaceId: 'ws-003',
sessionId: 'local_eeee',
userSelectedFolders: ['/home/user/projects/ParentFolder/SubFolder'],
title: 'Some session title',
claudeSessionId: 'session-e',
timestamp: '2099-06-03T10:00:00.000Z',
})
const projects = await parseAllSessions(dayRange('2099-06-03'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.project).toBe('SubFolder')
expect(projects[0]!.sessions).toHaveLength(1)
})
it('falls back to title when no spaceId and no userSelectedFolders', async () => {
const desktopSessionsDir = join(tmpDir, 'desktop-sessions')
await writeCoworkSession({
desktopSessionsDir,
appId: 'app-abc',
workspaceId: 'ws-004',
sessionId: 'local_ffff',
title: 'A standalone session task',
claudeSessionId: 'session-f',
timestamp: '2099-06-04T10:00:00.000Z',
})
const projects = await parseAllSessions(dayRange('2099-06-04'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.project).toBe('A standalone session task')
expect(projects[0]!.sessions).toHaveLength(1)
})
it('groups container-mode sessions (cwd=/sessions/<name>) under the space name', async () => {
const desktopSessionsDir = join(tmpDir, 'desktop-sessions')
const workspaceDir = join(desktopSessionsDir, 'app-abc', 'ws-006')
const sessionId = 'local_hhhh'
// Set up workspace metadata (spaces.json + session .json with spaceId)
await mkdir(workspaceDir, { recursive: true })
await writeFile(join(workspaceDir, 'spaces.json'), JSON.stringify({
spaces: [{ id: 'space-001', name: 'Project1', folders: [], projects: [] }],
}))
const containerCwd = '/sessions/trusting-inspiring-ritchie'
await writeFile(join(workspaceDir, `${sessionId}.json`), JSON.stringify({
sessionId, spaceId: 'space-001', cwd: containerCwd, title: 'Container session',
}))
// Container-mode: project slug is derived from the container cwd, not outputs/
const containerSlug = containerCwd.replace(/\//g, '-').replace(/^-/, '')
const projectDir = join(workspaceDir, sessionId, '.claude', 'projects', containerSlug)
await mkdir(projectDir, { recursive: true })
const filePath = join(projectDir, 'container-session.jsonl')
await writeFile(filePath, JSON.stringify({
type: 'assistant',
sessionId: 'container-session',
timestamp: '2099-06-06T10:00:00.000Z',
cwd: containerCwd,
message: {
id: 'msg-container', type: 'message', role: 'assistant',
model: 'claude-sonnet-4-5', content: [],
usage: { input_tokens: 100, output_tokens: 50 },
},
}) + '\n')
await utimes(filePath, new Date('2099-06-06T10:00:00.000Z'), new Date('2099-06-06T10:00:00.000Z'))
const projects = await parseAllSessions(dayRange('2099-06-06'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.project).toBe('Project1')
expect(projects[0]!.projectPath).toBe('Project1')
expect(projects[0]!.sessions).toHaveLength(1)
})
it('falls back to sanitized directory slug when no session metadata exists', async () => {
const desktopSessionsDir = join(tmpDir, 'desktop-sessions')
const workspaceDir = join(desktopSessionsDir, 'app-abc', 'ws-005')
const sessionId = 'local_gggg'
const outputsDir = join(workspaceDir, sessionId, 'outputs')
const projectSlug = outputsDir.replace(/[/\\]/g, '-').replace(/^-/, '')
const projectDir = join(workspaceDir, sessionId, '.claude', 'projects', projectSlug)
await mkdir(projectDir, { recursive: true })
// No spaces.json or session .json at all
const filePath = join(projectDir, 'no-meta-session.jsonl')
await writeFile(filePath, JSON.stringify({
type: 'assistant',
sessionId: 'no-meta-session',
timestamp: '2099-06-05T10:00:00.000Z',
cwd: outputsDir,
message: {
id: 'msg-no-meta', type: 'message', role: 'assistant',
model: 'claude-sonnet-4-5', content: [],
usage: { input_tokens: 100, output_tokens: 50 },
},
}) + '\n')
await utimes(filePath, new Date('2099-06-05T10:00:00.000Z'), new Date('2099-06-05T10:00:00.000Z'))
const projects = await parseAllSessions(dayRange('2099-06-05'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.project).toBe(projectSlug)
expect(projects[0]!.sessions).toHaveLength(1)
})
})
+434
View File
@@ -0,0 +1,434 @@
import { describe, it, expect } from 'vitest'
import { compactEntry } from '../src/parser.js'
import type { JournalEntry } from '../src/types.js'
function entry(overrides: Partial<JournalEntry> & Record<string, unknown>): JournalEntry {
return { type: 'user', ...overrides } as JournalEntry
}
describe('compactEntry', () => {
it('preserves type, timestamp, sessionId, cwd', () => {
const raw = entry({ type: 'user', timestamp: 't1', sessionId: 's1', cwd: '/foo' })
const c = compactEntry(raw)
expect(c.type).toBe('user')
expect(c.timestamp).toBe('t1')
expect(c.sessionId).toBe('s1')
expect(c.cwd).toBe('/foo')
})
it('strips unknown catch-all fields', () => {
const raw = entry({
type: 'assistant',
toolResult: { type: 'tool_result', content: 'x'.repeat(10_000) },
someHugeField: 'y'.repeat(10_000),
})
const c = compactEntry(raw)
expect((c as Record<string, unknown>)['toolResult']).toBeUndefined()
expect((c as Record<string, unknown>)['someHugeField']).toBeUndefined()
})
it('preserves deferred_tools_delta attachment with copied names', () => {
const raw = entry({
type: 'attachment',
attachment: {
type: 'deferred_tools_delta',
addedNames: ['mcp__svc__t1', 'Bash'],
extraData: 'should be dropped',
},
})
const c = compactEntry(raw)
const att = (c as Record<string, unknown>)['attachment'] as Record<string, unknown>
expect(att['type']).toBe('deferred_tools_delta')
expect(att['addedNames']).toEqual(['mcp__svc__t1', 'Bash'])
expect(att['extraData']).toBeUndefined()
})
it('copies addedNames into a new array (not by reference)', () => {
const originalNames = ['mcp__a__b', 'Bash']
const raw = entry({
type: 'attachment',
attachment: { type: 'deferred_tools_delta', addedNames: originalNames },
})
const c = compactEntry(raw)
const att = (c as Record<string, unknown>)['attachment'] as { addedNames: string[] }
expect(att.addedNames).not.toBe(originalNames)
expect(att.addedNames).toEqual(originalNames)
})
it('caps addedNames at 1000 entries', () => {
const names = Array.from({ length: 2000 }, (_, i) => `mcp__svc__t${i}`)
const raw = entry({
type: 'attachment',
attachment: { type: 'deferred_tools_delta', addedNames: names },
})
const c = compactEntry(raw)
const att = (c as Record<string, unknown>)['attachment'] as { addedNames: string[] }
expect(att.addedNames).toHaveLength(1000)
})
it('filters non-string entries from addedNames', () => {
const raw = entry({
type: 'attachment',
attachment: { type: 'deferred_tools_delta', addedNames: [42, null, 'mcp__a__b', undefined] },
})
const c = compactEntry(raw)
const att = (c as Record<string, unknown>)['attachment'] as { addedNames: string[] }
expect(att.addedNames).toEqual(['mcp__a__b'])
})
it('drops non-deferred_tools_delta attachments', () => {
const raw = entry({
type: 'attachment',
attachment: { type: 'other', data: 'x'.repeat(10_000) },
})
const c = compactEntry(raw)
expect((c as Record<string, unknown>)['attachment']).toBeUndefined()
})
it('caps user message string content at 2000', () => {
const longText = 'a'.repeat(5000)
const raw = entry({
type: 'user',
message: { role: 'user' as const, content: longText },
})
const c = compactEntry(raw)
expect(c.message!.role).toBe('user')
const content = (c.message as { content: string }).content
expect(content.length).toBe(2000)
})
it('caps total user text across all blocks at 2000', () => {
const raw = entry({
type: 'user',
message: {
role: 'user' as const,
content: [
{ type: 'text' as const, text: 'a'.repeat(1500) },
{ type: 'text' as const, text: 'b'.repeat(1500) },
{ type: 'text' as const, text: 'c'.repeat(1500) },
{ type: 'image' as const, source: 'big data' },
],
},
})
const c = compactEntry(raw)
const content = (c.message as { content: Array<{ type: string; text: string }> }).content
expect(content).toHaveLength(2)
expect(content[0]!.text.length).toBe(1500)
expect(content[1]!.text.length).toBe(500)
})
it('compacts assistant tool_use blocks, dropping text and thinking, preserving id', () => {
const raw = entry({
type: 'assistant',
timestamp: 't1',
message: {
type: 'message' as const,
role: 'assistant' as const,
model: 'claude-opus-4-6',
id: 'msg_123',
usage: { input_tokens: 100, output_tokens: 200 },
content: [
{ type: 'text', text: 'x'.repeat(50_000) },
{ type: 'thinking', thinking: 'y'.repeat(50_000) },
{ type: 'tool_use', id: 'tu1', name: 'Read', input: { file_path: '/foo', huge: 'z'.repeat(10_000) } },
{ type: 'tool_use', id: 'tu2', name: 'Edit', input: { old_string: 'a'.repeat(5000), new_string: 'b'.repeat(5000) } },
],
},
})
const c = compactEntry(raw)
const msg = c.message as { content: Array<{ type: string; id?: string; name?: string; input?: Record<string, unknown> }> }
expect(msg.content).toHaveLength(2)
expect(msg.content[0]!.name).toBe('Read')
expect(msg.content[0]!.id).toBe('tu1')
expect(msg.content[0]!.input).toEqual({ file_path: '/foo' })
expect(msg.content[1]!.name).toBe('Edit')
expect(msg.content[1]!.id).toBe('tu2')
expect(msg.content[1]!.input).toEqual({})
})
it('caps tool_use blocks at 500 per message', () => {
const blocks = Array.from({ length: 600 }, (_, i) => ({
type: 'tool_use' as const,
id: `tu${i}`,
name: `Tool${i}`,
input: {},
}))
const raw = entry({
type: 'assistant',
message: {
type: 'message' as const,
role: 'assistant' as const,
model: 'claude-opus-4-6',
usage: { input_tokens: 10, output_tokens: 10 },
content: blocks,
},
})
const c = compactEntry(raw)
const msg = c.message as { content: unknown[] }
expect(msg.content).toHaveLength(500)
})
it('preserves model, usage (destructured), and id on assistant messages', () => {
const raw = entry({
type: 'assistant',
message: {
type: 'message' as const,
role: 'assistant' as const,
model: 'claude-opus-4-6',
id: 'msg_abc',
usage: {
input_tokens: 50,
output_tokens: 100,
cache_read_input_tokens: 25,
extraGarbage: 'should not survive',
},
content: [],
},
})
const c = compactEntry(raw)
const msg = c.message as { model: string; id: string; usage: Record<string, unknown> }
expect(msg.model).toBe('claude-opus-4-6')
expect(msg.id).toBe('msg_abc')
expect(msg.usage['input_tokens']).toBe(50)
expect(msg.usage['output_tokens']).toBe(100)
expect(msg.usage['cache_read_input_tokens']).toBe(25)
expect(msg.usage['extraGarbage']).toBeUndefined()
})
it('deep-copies usage nested objects, stripping extra keys', () => {
const cacheCreation = { ephemeral_5m_input_tokens: 100, ephemeral_1h_input_tokens: 200, extraJunk: 'big' }
const serverToolUse = { web_search_requests: 3, web_fetch_requests: 1, extraJunk: 'big' }
const raw = entry({
type: 'assistant',
message: {
type: 'message' as const,
role: 'assistant' as const,
model: 'claude-opus-4-6',
usage: {
input_tokens: 10,
output_tokens: 10,
speed: 'fast',
cache_creation: cacheCreation,
server_tool_use: serverToolUse,
},
content: [],
},
})
const c = compactEntry(raw)
const msg = c.message as { usage: Record<string, unknown> }
expect(msg.usage['speed']).toBe('fast')
const cc = msg.usage['cache_creation'] as Record<string, unknown>
expect(cc['ephemeral_5m_input_tokens']).toBe(100)
expect(cc['ephemeral_1h_input_tokens']).toBe(200)
expect(cc['extraJunk']).toBeUndefined()
expect(cc).not.toBe(cacheCreation)
const stu = msg.usage['server_tool_use'] as Record<string, unknown>
expect(stu['web_search_requests']).toBe(3)
expect(stu['web_fetch_requests']).toBe(1)
expect(stu['extraJunk']).toBeUndefined()
expect(stu).not.toBe(serverToolUse)
})
it('keeps Skill input.skill and input.name, type-checked and capped', () => {
const raw = entry({
type: 'assistant',
message: {
type: 'message' as const,
role: 'assistant' as const,
model: 'claude-opus-4-6',
usage: { input_tokens: 10, output_tokens: 10 },
content: [
{ type: 'tool_use', id: 'tu', name: 'Skill', input: { skill: 'graphify', args: 'huge arg data' } },
],
},
})
const c = compactEntry(raw)
const msg = c.message as { content: Array<{ input: Record<string, unknown> }> }
expect(msg.content[0]!.input['skill']).toBe('graphify')
expect(msg.content[0]!.input['args']).toBeUndefined()
})
it('rejects non-string Skill input.skill and caps long names', () => {
const raw = entry({
type: 'assistant',
message: {
type: 'message' as const,
role: 'assistant' as const,
model: 'claude-opus-4-6',
usage: { input_tokens: 10, output_tokens: 10 },
content: [
{ type: 'tool_use', id: 'tu1', name: 'Skill', input: { skill: { malicious: 'x'.repeat(10_000) } } },
{ type: 'tool_use', id: 'tu2', name: 'Skill', input: { skill: 'a'.repeat(500) } },
],
},
})
const c = compactEntry(raw)
const msg = c.message as { content: Array<{ input: Record<string, unknown> }> }
expect(msg.content[0]!.input['skill']).toBeUndefined()
expect((msg.content[1]!.input['skill'] as string).length).toBe(200)
})
it('keeps Bash input.command capped at 2000 for bash command extraction', () => {
const longCmd = 'npm run build && '.repeat(200)
const raw = entry({
type: 'assistant',
message: {
type: 'message' as const,
role: 'assistant' as const,
model: 'claude-opus-4-6',
usage: { input_tokens: 10, output_tokens: 10 },
content: [
{ type: 'tool_use', id: 'tu', name: 'Bash', input: { command: longCmd, description: 'big desc' } },
],
},
})
const c = compactEntry(raw)
const msg = c.message as { content: Array<{ input: Record<string, unknown> }> }
const cmd = msg.content[0]!.input['command'] as string
expect(cmd.length).toBe(2000)
expect(msg.content[0]!.input['description']).toBeUndefined()
})
it('keeps Read file_path capped and drops unrelated input fields', () => {
const raw = entry({
type: 'assistant',
message: {
type: 'message' as const,
role: 'assistant' as const,
model: 'claude-opus-4-6',
usage: { input_tokens: 10, output_tokens: 10 },
content: [
{ type: 'tool_use', id: 'tu', name: 'Read', input: { file_path: '/tmp/' + 'x'.repeat(3000), content: 'big' } },
],
},
})
const c = compactEntry(raw)
const msg = c.message as { content: Array<{ input: Record<string, unknown> }> }
expect((msg.content[0]!.input['file_path'] as string).length).toBe(2000)
expect(msg.content[0]!.input['content']).toBeUndefined()
})
it('keeps Agent subagent_type capped and drops prompt text', () => {
const raw = entry({
type: 'assistant',
message: {
type: 'message' as const,
role: 'assistant' as const,
model: 'claude-opus-4-6',
usage: { input_tokens: 10, output_tokens: 10 },
content: [
{ type: 'tool_use', id: 'tu', name: 'Agent', input: { subagent_type: 'reviewer'.repeat(50), prompt: 'big' } },
],
},
})
const c = compactEntry(raw)
const msg = c.message as { content: Array<{ input: Record<string, unknown> }> }
expect((msg.content[0]!.input['subagent_type'] as string).length).toBe(200)
expect(msg.content[0]!.input['prompt']).toBeUndefined()
})
it('handles entry with no message field', () => {
const raw = entry({ type: 'system', timestamp: 't1', cwd: '/x' })
const c = compactEntry(raw)
expect(c.type).toBe('system')
expect(c.timestamp).toBe('t1')
expect(c.message).toBeUndefined()
})
it('handles assistant message with no usage (non-standard)', () => {
const raw = entry({
type: 'assistant',
message: {
type: 'message' as const,
role: 'assistant' as const,
model: 'claude-opus-4-6',
content: [{ type: 'text', text: 'response' }],
},
})
const c = compactEntry(raw)
expect(c.message).toBeUndefined()
})
it('handles unexpected message role (neither user nor assistant)', () => {
const raw = entry({
type: 'system',
message: { role: 'system' as never, content: 'sys prompt' },
})
const c = compactEntry(raw)
expect(c.message).toBeUndefined()
})
it('tolerates null elements in user content array', () => {
const raw = entry({
type: 'user',
message: {
role: 'user' as const,
content: [null, undefined, { type: 'text', text: 'ok' }, 42, { type: 'text' }] as never,
},
})
const c = compactEntry(raw)
const content = (c.message as { content: Array<{ text: string }> }).content
expect(content).toHaveLength(1)
expect(content[0]!.text).toBe('ok')
})
it('tolerates assistant content that is not an array', () => {
const raw = entry({
type: 'assistant',
message: {
type: 'message' as const,
role: 'assistant' as const,
model: 'claude-opus-4-6',
usage: { input_tokens: 10, output_tokens: 10 },
content: 'not an array' as never,
},
})
const c = compactEntry(raw)
const msg = c.message as { content: unknown[] }
expect(msg.content).toEqual([])
})
it('tolerates null elements in assistant content array', () => {
const raw = entry({
type: 'assistant',
message: {
type: 'message' as const,
role: 'assistant' as const,
model: 'claude-opus-4-6',
usage: { input_tokens: 10, output_tokens: 10 },
content: [null, { type: 'tool_use', id: 'tu1', name: 'Read', input: {} }, undefined] as never,
},
})
const c = compactEntry(raw)
const msg = c.message as { content: Array<{ name: string }> }
expect(msg.content).toHaveLength(1)
expect(msg.content[0]!.name).toBe('Read')
})
it('memory reduction: compacted entry is much smaller than raw', () => {
const hugeContent = Array.from({ length: 20 }, (_, i) => ({
type: i % 2 === 0 ? 'text' : 'tool_result',
text: 'x'.repeat(100_000),
content: 'y'.repeat(100_000),
}))
const raw = entry({
type: 'assistant',
timestamp: '2026-01-01T00:00:00',
message: {
type: 'message' as const,
role: 'assistant' as const,
model: 'claude-opus-4-6',
id: 'msg_1',
usage: { input_tokens: 1000, output_tokens: 500 },
content: hugeContent as never,
},
toolResult: { content: 'z'.repeat(500_000) },
})
const rawSize = JSON.stringify(raw).length
const compacted = compactEntry(raw)
const compactedSize = JSON.stringify(compacted).length
expect(rawSize).toBeGreaterThan(2_000_000)
expect(compactedSize).toBeLessThan(500)
})
})
+82
View File
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { filterProjectsByName } from '../src/parser.js'
import type { ProjectSummary } from '../src/types.js'
function makeProject(project: string, projectPath = project): ProjectSummary {
return {
project,
projectPath,
sessions: [],
totalCostUSD: 0,
totalApiCalls: 0,
}
}
describe('filterProjectsByName', () => {
const projects = [
makeProject('codeburn', '/Users/alice/codeburn'),
makeProject('AgentSeal', '/Users/alice/projects/AgentSeal'),
makeProject('dashboard', '/Users/alice/AgentSeal/dashboard'),
makeProject('sandbox', '/tmp/sandbox'),
]
it('returns all projects when no filters given', () => {
expect(filterProjectsByName(projects)).toEqual(projects)
expect(filterProjectsByName(projects, [], [])).toEqual(projects)
expect(filterProjectsByName(projects, undefined, undefined)).toEqual(projects)
})
it('include matches project name (case-insensitive substring)', () => {
const result = filterProjectsByName(projects, ['codeburn'])
expect(result.map(p => p.project)).toEqual(['codeburn'])
})
it('include is case-insensitive', () => {
const result = filterProjectsByName(projects, ['AGENTSEAL'])
expect(result.map(p => p.project).sort()).toEqual(['AgentSeal', 'dashboard'])
})
it('include matches substring in path when name does not match', () => {
const result = filterProjectsByName(projects, ['alice/projects'])
expect(result.map(p => p.project)).toEqual(['AgentSeal'])
})
it('include uses OR semantics across patterns', () => {
const result = filterProjectsByName(projects, ['codeburn', 'sandbox'])
expect(result.map(p => p.project).sort()).toEqual(['codeburn', 'sandbox'])
})
it('exclude removes matching projects (AND-negation across patterns)', () => {
const result = filterProjectsByName(projects, undefined, ['codeburn', 'sandbox'])
expect(result.map(p => p.project).sort()).toEqual(['AgentSeal', 'dashboard'])
})
it('exclude matches path substring', () => {
const result = filterProjectsByName(projects, undefined, ['/tmp'])
expect(result.map(p => p.project)).not.toContain('sandbox')
})
it('exclude is applied after include', () => {
const result = filterProjectsByName(projects, ['AgentSeal'], ['dashboard'])
expect(result.map(p => p.project)).toEqual(['AgentSeal'])
})
it('returns empty array when no project matches include', () => {
expect(filterProjectsByName(projects, ['does-not-exist'])).toEqual([])
})
it('empty-string pattern matches every project', () => {
const resultInclude = filterProjectsByName(projects, [''])
expect(resultInclude).toHaveLength(projects.length)
const resultExclude = filterProjectsByName(projects, undefined, [''])
expect(resultExclude).toEqual([])
})
it('does not mutate the input array', () => {
const input = [makeProject('a'), makeProject('b')]
const snapshot = [...input]
filterProjectsByName(input, ['a'], ['b'])
expect(input).toEqual(snapshot)
})
})
+126
View File
@@ -0,0 +1,126 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
import { CACHE_VERSION, computeEnvFingerprint } from '../src/session-cache.js'
import type { DateRange } from '../src/types.js'
let home: string
let cacheDir: string
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'codeburn-gemini-home-'))
cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-gemini-cache-'))
process.env['HOME'] = home
process.env['CODEBURN_CACHE_DIR'] = cacheDir
})
afterEach(async () => {
clearSessionCache()
await rm(home, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
})
describe('Gemini session cache migration', () => {
it('reparses cached legacy aggregate Gemini entries into granular calls', async () => {
const chatsDir = join(home, '.gemini', 'tmp', 'project-a', 'chats')
await mkdir(chatsDir, { recursive: true })
const sessionPath = join(chatsDir, 'session-2026-05-16.json')
await writeFile(sessionPath, JSON.stringify({
sessionId: 'gemini-session-1',
startTime: '2026-05-16T10:00:00.000Z',
messages: [
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
{
id: 'g1',
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'first',
model: 'gemini-3.1-pro-preview',
tokens: { input: 10, output: 5 },
},
{
id: 'g2',
timestamp: '2026-05-16T10:00:10.000Z',
type: 'gemini',
content: 'second',
model: 'gemini-3.1-pro-preview',
tokens: { input: 12, output: 6 },
},
],
}))
const fileStat = await stat(sessionPath)
await writeFile(join(cacheDir, 'session-cache.json'), JSON.stringify({
version: CACHE_VERSION,
providers: {
gemini: {
envFingerprint: computeEnvFingerprint('gemini'),
files: {
[sessionPath]: {
fingerprint: {
dev: fileStat.dev,
ino: fileStat.ino,
mtimeMs: fileStat.mtimeMs,
sizeBytes: fileStat.size,
},
mcpInventory: [],
turns: [{
timestamp: '2026-05-16T10:00:00.000Z',
sessionId: 'gemini-session-1',
userMessage: 'work',
calls: [{
provider: 'gemini',
model: 'gemini-3.1-pro-preview',
usage: {
inputTokens: 22,
outputTokens: 11,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
cacheCreationOneHourTokens: 0,
},
speed: 'standard',
timestamp: '2026-05-16T10:00:00.000Z',
tools: [],
bashCommands: [],
skills: [],
deduplicationKey: 'gemini:gemini-session-1',
}],
}],
},
},
},
},
}))
const range: DateRange = {
start: new Date('2026-05-16T00:00:00.000Z'),
end: new Date('2026-05-16T23:59:59.999Z'),
}
const projects = await parseAllSessions(range, 'gemini')
const keys = projects.flatMap(project =>
project.sessions.flatMap(session =>
session.turns.flatMap(turn => turn.assistantCalls.map(call => call.deduplicationKey)),
),
)
expect(projects[0]!.totalApiCalls).toBe(2)
expect(keys).toEqual([
'gemini:gemini-session-1:g1',
'gemini:gemini-session-1:g2',
])
const savedCache = JSON.parse(await readFile(join(cacheDir, 'session-cache.json'), 'utf-8'))
const savedKeys = savedCache.providers.gemini.files[sessionPath].turns.flatMap((turn: { calls: Array<{ deduplicationKey: string }> }) =>
turn.calls.map(call => call.deduplicationKey),
)
expect(savedKeys).toEqual(keys)
})
})
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { parseJsonlLine } from '../src/parser.js'
function largeUserLine(): string {
return JSON.stringify({
type: 'user',
sessionId: 's1',
timestamp: '2026-05-01T00:00:00Z',
cwd: '/repo',
message: {
role: 'user',
content: [
{ type: 'image', source: { data: 'x'.repeat(40_000) } },
{ type: 'text', text: 'hello ' + 'a'.repeat(3000) },
],
},
})
}
function largeAssistantLine(): string {
return JSON.stringify({
type: 'assistant',
sessionId: 's1',
timestamp: '2026-05-01T00:00:01Z',
cwd: '/repo',
message: {
id: 'm1',
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [
{ type: 'text', text: 'x'.repeat(40_000) },
{ type: 'tool_use', id: 'read1', name: 'Read', input: { file_path: '/tmp/file.ts', content: 'drop me' } },
{ type: 'tool_use', id: 'agent1', name: 'Agent', input: { subagent_type: 'reviewer', prompt: 'drop me' } },
],
usage: {
input_tokens: 100,
output_tokens: 20,
cache_read_input_tokens: 300,
},
},
})
}
describe('large JSONL compact scanner', () => {
it('extracts user text from array content without full JSON.parse', () => {
const parsed = parseJsonlLine(largeUserLine())
expect(parsed?.type).toBe('user')
const content = parsed?.message?.role === 'user' ? parsed.message.content : ''
expect(content).toBeTypeOf('string')
expect((content as string).startsWith('hello ')).toBe(true)
expect((content as string).length).toBe(2000)
})
it('extracts capped tool inputs needed by optimize', () => {
const parsed = parseJsonlLine(Buffer.from(largeAssistantLine()))
const msg = parsed?.message
expect(msg?.role).toBe('assistant')
if (msg?.role !== 'assistant') return
expect(msg.usage.input_tokens).toBe(100)
expect(msg.usage.output_tokens).toBe(20)
expect(msg.usage.cache_read_input_tokens).toBe(300)
expect(msg.content).toEqual([
{ type: 'tool_use', id: 'read1', name: 'Read', input: { file_path: '/tmp/file.ts' } },
{ type: 'tool_use', id: 'agent1', name: 'Agent', input: { subagent_type: 'reviewer' } },
])
})
it('extracts deferred MCP inventory from large attachment lines', () => {
const line = JSON.stringify({
type: 'attachment',
sessionId: 's1',
timestamp: '2026-05-01T00:00:02Z',
padding: 'x'.repeat(40_000),
attachment: {
type: 'deferred_tools_delta',
addedNames: ['Bash', 'mcp__svc__tool'],
},
})
const parsed = parseJsonlLine(Buffer.from(line)) as Record<string, unknown>
expect(parsed['attachment']).toEqual({
type: 'deferred_tools_delta',
addedNames: ['Bash', 'mcp__svc__tool'],
})
})
})
+236
View File
@@ -0,0 +1,236 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
import { parseAllSessions, clearSessionCache } from '../src/parser.js'
import type { DateRange } from '../src/types.js'
let home: string
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'codeburn-large-'))
process.env['CLAUDE_CONFIG_DIR'] = join(home, '.claude')
})
afterEach(async () => {
clearSessionCache()
await rm(home, { recursive: true, force: true })
})
function userLine(sessionId: string, timestamp: string, textSize = 100): string {
return JSON.stringify({
type: 'user',
sessionId,
timestamp,
cwd: '/projects/app',
message: { role: 'user', content: 'x'.repeat(textSize) },
})
}
function assistantLine(sessionId: string, timestamp: string, messageId: string, opts?: {
contentSize?: number
toolCount?: number
}): string {
const contentSize = opts?.contentSize ?? 0
const toolCount = opts?.toolCount ?? 1
const content: unknown[] = []
if (contentSize > 0) {
content.push({ type: 'text', text: 'y'.repeat(contentSize) })
content.push({ type: 'thinking', thinking: 'z'.repeat(contentSize) })
}
for (let i = 0; i < toolCount; i++) {
content.push({
type: 'tool_use',
id: `tu-${i}`,
name: i === 0 ? 'Edit' : 'Read',
input: { file_path: '/tmp/x', big: 'w'.repeat(contentSize) },
})
}
return JSON.stringify({
type: 'assistant',
sessionId,
timestamp,
message: {
id: messageId,
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content,
usage: { input_tokens: 1000, output_tokens: 100 },
},
})
}
function messageFirstLargeAssistantLine(sessionId: string, timestamp: string, messageId: string): string {
const hugeText = 'y'.repeat(3_000_000)
return `{"parentUuid":"u1","isSidechain":false,"message":{"model":"claude-sonnet-4-5","id":"${messageId}","type":"message","role":"assistant","content":[{"type":"text","text":"${hugeText}"},{"type":"tool_use","id":"tu-large","name":"Edit","input":{"file_path":"/tmp/x","old_string":"a","new_string":"b"}}],"usage":{"input_tokens":1000,"output_tokens":100,"cache_read_input_tokens":5000}},"uuid":"a1","timestamp":"${timestamp}","type":"assistant","sessionId":"${sessionId}","cwd":"/projects/app"}`
}
function attachmentLine(sessionId: string, timestamp: string): string {
return JSON.stringify({
type: 'attachment',
sessionId,
timestamp,
attachment: {
type: 'deferred_tools_delta',
addedNames: ['Bash', 'Edit', 'Read', 'mcp__hf__hub_search'],
},
})
}
describe('parseAllSessions with large Claude fixture', () => {
it('correctly parses sessions with bulky text/thinking/tool_result blocks', async () => {
const projectDir = join(home, '.claude', 'projects', 'bigapp')
await mkdir(projectDir, { recursive: true })
const lines: string[] = []
lines.push(attachmentLine('s1', '2026-04-10T09:00:00Z'))
for (let i = 0; i < 50; i++) {
const ts = `2026-04-10T${String(9 + Math.floor(i / 10)).padStart(2, '0')}:${String((i % 10) * 5).padStart(2, '0')}:00Z`
lines.push(userLine('s1', ts, 5000))
lines.push(assistantLine('s1', ts.replace(':00Z', ':30Z'), `msg-${i}`, {
contentSize: 50_000,
toolCount: 3,
}))
}
await writeFile(join(projectDir, 'session.jsonl'), lines.join('\n'))
const range: DateRange = {
start: new Date('2026-04-10T00:00:00Z'),
end: new Date('2026-04-10T23:59:59Z'),
}
const projects = await parseAllSessions(range, 'claude')
expect(projects.length).toBeGreaterThan(0)
const proj = projects[0]!
expect(proj.totalApiCalls).toBe(50)
expect(proj.totalCostUSD).toBeGreaterThan(0)
const sess = proj.sessions[0]!
expect(sess.turns.length).toBe(50)
for (const turn of sess.turns) {
expect(turn.userMessage.length).toBeLessThanOrEqual(2000)
expect(turn.assistantCalls.length).toBe(1)
const call = turn.assistantCalls[0]!
expect(call.tools).toContain('Edit')
expect(call.tools).toContain('Read')
expect(call.model).toBe('claude-sonnet-4-5')
}
expect(sess.mcpInventory).toContain('mcp__hf__hub_search')
})
it('handles malformed JSONL lines without crashing', async () => {
const projectDir = join(home, '.claude', 'projects', 'baddata')
await mkdir(projectDir, { recursive: true })
const lines = [
'not json at all',
'{"type": "user", "sessionId": "s1", "timestamp": "2026-04-10T10:00:00Z", "message": {"role": "user", "content": [null, {"type": "text", "text": "hello"}, 42]}}',
'{"type": "assistant", "sessionId": "s1", "timestamp": "2026-04-10T10:01:00Z", "message": {"id": "m1", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5", "content": "not-an-array", "usage": {"input_tokens": 100, "output_tokens": 50}}}',
'{"type": "assistant", "sessionId": "s1", "timestamp": "2026-04-10T10:02:00Z", "message": {"id": "m2", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5", "content": [null, {"type": "tool_use", "id": "t1", "name": "Read", "input": {}}], "usage": {"input_tokens": 100, "output_tokens": 50}}}',
]
await writeFile(join(projectDir, 'session.jsonl'), lines.join('\n'))
const range: DateRange = {
start: new Date('2026-04-10T00:00:00Z'),
end: new Date('2026-04-10T23:59:59Z'),
}
const projects = await parseAllSessions(range, 'claude')
expect(projects.length).toBeGreaterThan(0)
const sess = projects[0]!.sessions[0]!
expect(sess.apiCalls).toBeGreaterThanOrEqual(1)
})
it('discovers direct Claude subagent JSONL files under a project directory', async () => {
const projectDir = join(home, '.claude', 'projects', 'direct-subagents')
const subagentsDir = join(projectDir, 'subagents')
await mkdir(subagentsDir, { recursive: true })
const lines = [
userLine('subagent-session', '2026-04-10T10:00:00Z', 100),
assistantLine('subagent-session', '2026-04-10T10:01:00Z', 'subagent-msg', {
contentSize: 0,
toolCount: 2,
}),
]
await writeFile(join(subagentsDir, 'worker.jsonl'), lines.join('\n'))
const range: DateRange = {
start: new Date('2026-04-10T00:00:00Z'),
end: new Date('2026-04-10T23:59:59Z'),
}
const projects = await parseAllSessions(range, 'claude')
expect(projects).toHaveLength(1)
const session = projects[0]!.sessions[0]!
expect(session.sessionId).toBe('worker')
expect(session.apiCalls).toBe(1)
expect(session.toolBreakdown['Edit']?.calls).toBe(1)
expect(session.toolBreakdown['Read']?.calls).toBe(1)
})
it('discovers nested Claude subagent JSONL files under a direct subagents directory', async () => {
const projectDir = join(home, '.claude', 'projects', 'nested-subagents')
const nestedSubagentsDir = join(projectDir, 'subagents', 'subagents')
await mkdir(nestedSubagentsDir, { recursive: true })
const lines = [
userLine('nested-subagent-session', '2026-04-10T11:00:00Z', 100),
assistantLine('nested-subagent-session', '2026-04-10T11:01:00Z', 'nested-subagent-msg', {
contentSize: 0,
toolCount: 1,
}),
]
await writeFile(join(nestedSubagentsDir, 'worker.jsonl'), lines.join('\n'))
const range: DateRange = {
start: new Date('2026-04-10T00:00:00Z'),
end: new Date('2026-04-10T23:59:59Z'),
}
const projects = await parseAllSessions(range, 'claude')
expect(projects).toHaveLength(1)
const session = projects[0]!.sessions[0]!
expect(session.sessionId).toBe('worker')
expect(session.apiCalls).toBe(1)
expect(session.toolBreakdown['Edit']?.calls).toBe(1)
})
it('parses huge message-first assistant lines without full JSON.parse expansion', async () => {
const projectDir = join(home, '.claude', 'projects', 'messagefirst')
await mkdir(projectDir, { recursive: true })
const lines = [
userLine('s1', '2026-04-10T10:00:00Z', 100),
messageFirstLargeAssistantLine('s1', '2026-04-10T10:00:01Z', 'msg-large'),
]
await writeFile(join(projectDir, 'session.jsonl'), lines.join('\n'))
const range: DateRange = {
start: new Date('2026-04-10T00:00:00Z'),
end: new Date('2026-04-10T23:59:59Z'),
}
const projects = await parseAllSessions(range, 'claude')
expect(projects.length).toBeGreaterThan(0)
const sess = projects[0]!.sessions[0]!
expect(sess.apiCalls).toBe(1)
expect(sess.totalInputTokens).toBe(1000)
expect(sess.totalOutputTokens).toBe(100)
expect(sess.totalCacheReadTokens).toBe(5000)
expect(sess.toolBreakdown['Edit']?.calls).toBe(1)
})
})
+131
View File
@@ -0,0 +1,131 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import {
setLocalModelSavings,
setModelAliases,
loadPricing,
} from '../src/models.js'
import { parseAllSessions, clearSessionCache } from '../src/parser.js'
import type { DateRange } from '../src/types.js'
const FIXTURE_DAY = Date.UTC(2026, 3, 16)
const RANGE_START = new Date(FIXTURE_DAY - 24 * 60 * 60 * 1000)
const RANGE_END = new Date(FIXTURE_DAY + 24 * 60 * 60 * 1000)
function makeRange(): DateRange {
return { start: RANGE_START, end: RANGE_END }
}
let tmpDirs: string[] = []
beforeAll(async () => {
await loadPricing()
})
beforeEach(() => {
setLocalModelSavings({})
setModelAliases({})
})
afterEach(async () => {
delete (Object.prototype as Record<string, unknown>).calls
clearSessionCache()
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function setupLocalModelSession(modelName: string): Promise<string> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-savings-'))
tmpDirs.push(base)
const projectDir = join(base, 'projects', 'p')
await mkdir(projectDir, { recursive: true })
// Use a synthetic local-style model name and a small known token count.
const file = join(projectDir, 's1.jsonl')
const ts = '2026-04-16T10:00:00.000Z'
const line = JSON.stringify({
type: 'assistant',
timestamp: ts,
sessionId: 's1',
message: {
type: 'message',
role: 'assistant',
model: modelName,
id: 'msg-1',
content: [],
usage: {
input_tokens: 1000,
output_tokens: 200,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
})
await writeFile(file, line + '\n', 'utf-8')
process.env['CLAUDE_CONFIG_DIR'] = base
return base
}
describe('local-model savings: end-to-end', () => {
it('keeps an unconfigured local model at $0 with no savings recorded', async () => {
await setupLocalModelSession('llama3.1:8b')
const projects = await parseAllSessions(makeRange(), 'all')
const allCalls = projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls)))
expect(allCalls.length).toBeGreaterThan(0)
for (const c of allCalls) {
expect(c.costUSD).toBe(0)
expect(c.savingsUSD ?? 0).toBe(0)
expect(c.isLocalSavings).toBeFalsy()
}
})
it('records savings and forces cost to 0 when a local model has a savings mapping', async () => {
await setupLocalModelSession('llama3.1:8b')
setLocalModelSavings({ 'llama3.1:8b': 'gpt-4o' })
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const allCalls = projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls)))
expect(allCalls.length).toBeGreaterThan(0)
for (const c of allCalls) {
expect(c.costUSD).toBe(0)
expect(c.savingsUSD).toBeGreaterThan(0)
expect(c.savingsBaselineModel).toBe('gpt-4o')
expect(c.isLocalSavings).toBe(true)
}
// Session and project rollups surface the savings total.
const totalSavings = projects.reduce((s, p) => s + p.totalSavingsUSD, 0)
expect(totalSavings).toBeGreaterThan(0)
})
it('does not apply savings for a model that has no mapping', async () => {
await setupLocalModelSession('qwen2.5:32b')
setLocalModelSavings({ 'unrelated:1b': 'gpt-4o' })
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const allCalls = projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls)))
for (const c of allCalls) {
expect(c.savingsUSD ?? 0).toBe(0)
}
})
it('forces a $0 cost even when the same model is also in modelAliases', async () => {
// The local-savings path is meant to win for actual cost: spending
// config semantics say "this is local, track counterfactual", so
// even a stale modelAliases entry must not cause us to charge real
// dollars for a local call.
await setupLocalModelSession('llama3.1:8b')
setModelAliases({ 'llama3.1:8b': 'gpt-4o' })
setLocalModelSavings({ 'llama3.1:8b': 'gpt-4o' })
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const allCalls = projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls)))
for (const c of allCalls) {
expect(c.costUSD).toBe(0)
expect(c.savingsUSD).toBeGreaterThan(0)
}
})
})
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest'
import { extractMcpInventory } from '../src/parser.js'
import type { JournalEntry } from '../src/types.js'
function entry(overrides: Partial<JournalEntry> & Record<string, unknown>): JournalEntry {
return { type: 'attachment', ...overrides } as JournalEntry
}
describe('extractMcpInventory', () => {
it('returns empty array when no entries have an attachment', () => {
expect(extractMcpInventory([entry({ type: 'user' })])).toEqual([])
})
it('returns empty array when no deferred_tools_delta is present', () => {
expect(extractMcpInventory([
entry({ attachment: { type: 'something_else', addedNames: ['mcp__a__b'] } }),
])).toEqual([])
})
it('extracts mcp__server__tool names from a single delta', () => {
const result = extractMcpInventory([
entry({
attachment: {
type: 'deferred_tools_delta',
addedNames: ['Bash', 'Edit', 'mcp__hf__hub_repo_search', 'mcp__hf__paper_search'],
},
}),
])
expect(result).toEqual(['mcp__hf__hub_repo_search', 'mcp__hf__paper_search'])
})
it('filters out built-in tools (no mcp__ prefix)', () => {
const result = extractMcpInventory([
entry({
attachment: {
type: 'deferred_tools_delta',
addedNames: ['Bash', 'Edit', 'WebFetch', 'mcp__svc__t1'],
},
}),
])
expect(result).toEqual(['mcp__svc__t1'])
})
it('rejects malformed names: empty server segment', () => {
const result = extractMcpInventory([
entry({
attachment: {
type: 'deferred_tools_delta',
addedNames: ['mcp____tool', 'mcp__svc__t1'],
},
}),
])
expect(result).toEqual(['mcp__svc__t1'])
})
it('rejects malformed names: missing tool segment (no second `__`)', () => {
const result = extractMcpInventory([
entry({
attachment: {
type: 'deferred_tools_delta',
addedNames: ['mcp__server', 'mcp__svc__t1'],
},
}),
])
expect(result).toEqual(['mcp__svc__t1'])
})
it('rejects malformed names: empty tool segment (trailing `__`)', () => {
const result = extractMcpInventory([
entry({
attachment: {
type: 'deferred_tools_delta',
addedNames: ['mcp__server__', 'mcp__svc__t1'],
},
}),
])
expect(result).toEqual(['mcp__svc__t1'])
})
it('unions across multiple delta entries (incremental adds)', () => {
const result = extractMcpInventory([
entry({ attachment: { type: 'deferred_tools_delta', addedNames: ['mcp__a__t1'] } }),
entry({ attachment: { type: 'deferred_tools_delta', addedNames: ['mcp__a__t2', 'mcp__b__t1'] } }),
])
expect(result).toEqual(['mcp__a__t1', 'mcp__a__t2', 'mcp__b__t1'])
})
it('deduplicates names seen in multiple deltas', () => {
const result = extractMcpInventory([
entry({ attachment: { type: 'deferred_tools_delta', addedNames: ['mcp__a__t1', 'mcp__a__t1'] } }),
entry({ attachment: { type: 'deferred_tools_delta', addedNames: ['mcp__a__t1'] } }),
])
expect(result).toEqual(['mcp__a__t1'])
})
it('tolerates missing or non-string addedNames', () => {
const result = extractMcpInventory([
entry({ attachment: { type: 'deferred_tools_delta' } }),
entry({ attachment: { type: 'deferred_tools_delta', addedNames: 'not-an-array' } }),
entry({ attachment: { type: 'deferred_tools_delta', addedNames: [42, null, 'mcp__svc__t1', undefined] } }),
])
expect(result).toEqual(['mcp__svc__t1'])
})
it('tolerates malformed attachment object', () => {
const result = extractMcpInventory([
entry({ attachment: null }),
entry({ attachment: 'string-not-object' }),
entry({ attachment: { type: 'deferred_tools_delta', addedNames: ['mcp__svc__t1'] } }),
])
expect(result).toEqual(['mcp__svc__t1'])
})
it('returns names in sorted order', () => {
const result = extractMcpInventory([
entry({
attachment: {
type: 'deferred_tools_delta',
addedNames: ['mcp__zzz__a', 'mcp__aaa__z', 'mcp__mmm__m'],
},
}),
])
expect(result).toEqual(['mcp__aaa__z', 'mcp__mmm__m', 'mcp__zzz__a'])
})
})
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { isPositiveNumber, safeNumber } from '../src/parser.js'
describe('safeNumber', () => {
it('returns positive finite numbers unchanged', () => {
expect(safeNumber(1)).toBe(1)
expect(safeNumber(0.25)).toBe(0.25)
})
it('normalizes non-positive numbers to zero', () => {
expect(safeNumber(0)).toBe(0)
expect(safeNumber(-1)).toBe(0)
})
it('normalizes non-finite numbers to zero', () => {
expect(safeNumber(Number.NaN)).toBe(0)
expect(safeNumber(Number.POSITIVE_INFINITY)).toBe(0)
expect(safeNumber(Number.NEGATIVE_INFINITY)).toBe(0)
})
it('normalizes non-number values to zero', () => {
expect(safeNumber('12')).toBe(0)
expect(safeNumber(null)).toBe(0)
expect(safeNumber(undefined)).toBe(0)
expect(safeNumber({ value: 12 })).toBe(0)
})
})
describe('isPositiveNumber', () => {
it('returns true for positive finite numbers', () => {
expect(isPositiveNumber(1)).toBe(true)
expect(isPositiveNumber(0.25)).toBe(true)
})
it('returns false for zero and negative numbers', () => {
expect(isPositiveNumber(0)).toBe(false)
expect(isPositiveNumber(-1)).toBe(false)
})
it('returns false for non-finite numbers', () => {
expect(isPositiveNumber(Number.NaN)).toBe(false)
expect(isPositiveNumber(Number.POSITIVE_INFINITY)).toBe(false)
expect(isPositiveNumber(Number.NEGATIVE_INFINITY)).toBe(false)
})
it('returns false for non-number values', () => {
expect(isPositiveNumber('12')).toBe(false)
expect(isPositiveNumber(null)).toBe(false)
expect(isPositiveNumber(undefined)).toBe(false)
expect(isPositiveNumber({ value: 12 })).toBe(false)
})
})
+75
View File
@@ -0,0 +1,75 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, expect, it } from 'vitest'
// A non-Claude provider records its project path with the leading slash stripped
// ("Users/test/x"), while a configured proxy path keeps it ("/Users/test/x").
// Matching must be leading-slash agnostic, or a Codex-ONLY project under a proxy
// path is silently reported as out-of-pocket even though the SAME path is flagged
// when a Claude session happens to co-exist there (covered by the merge test) —
// i.e. attribution would depend on incidental provider co-location.
//
// This lives in its own file because the codex provider captures CODEX_HOME when
// its module is first evaluated; isolating it gives a fresh module graph that
// reads the env set below before the dynamic import.
const CWD = '/Users/test/codexonlyproxied'
let tmpDirs: string[] = []
afterEach(async () => {
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
it('flags a Codex-only project under a proxy path (leading-slash agnostic match)', async () => {
// Codex fixture (the only sessions present).
const home = await mkdtemp(join(tmpdir(), 'codeburn-codexonly-'))
tmpDirs.push(home)
const dir = join(home, 'sessions', '2026', '04', '16')
await mkdir(dir, { recursive: true })
const meta = JSON.stringify({
type: 'session_meta', timestamp: '2026-04-16T10:00:00Z',
payload: { cwd: CWD, originator: 'codex-cli', session_id: 'codex-1', model: 'gpt-5.3-codex' },
})
const tokens = JSON.stringify({
type: 'event_msg', timestamp: '2026-04-16T10:01:00Z',
payload: {
type: 'token_count',
info: {
model: 'gpt-5.3-codex',
last_token_usage: { input_tokens: 1000, cached_input_tokens: 0, output_tokens: 200, reasoning_output_tokens: 0, total_tokens: 1200 },
total_token_usage: { input_tokens: 1000, cached_input_tokens: 0, output_tokens: 200, reasoning_output_tokens: 0, total_tokens: 1200 },
},
},
})
await writeFile(join(dir, 'rollout-codex-1.jsonl'), meta + '\n' + tokens + '\n', 'utf-8')
process.env['CODEX_HOME'] = home
// Empty Claude dir so the only project is the Codex one.
const claudeEmpty = await mkdtemp(join(tmpdir(), 'codeburn-codexonly-claude-'))
tmpDirs.push(claudeEmpty)
await mkdir(join(claudeEmpty, 'projects'), { recursive: true })
process.env['CLAUDE_CONFIG_DIR'] = claudeEmpty
// Import AFTER env is set so the codex provider reads CODEX_HOME.
const { parseAllSessions, clearSessionCache } = await import('../src/parser.js')
const { setProxyPaths, loadPricing } = await import('../src/models.js')
await loadPricing()
setProxyPaths([CWD])
clearSessionCache()
const range = { start: new Date(Date.UTC(2026, 3, 15)), end: new Date(Date.UTC(2026, 3, 17)) }
const projects = await parseAllSessions(range, 'all')
const codex = projects.find(p => p.sessions.some(s => s.turns.some(t => t.assistantCalls.some(c => c.provider === 'codex'))))
expect(codex).toBeDefined()
expect(codex!.totalCostUSD).toBeGreaterThan(0)
// The fix: leading-slash agnostic matching flags the Codex-only project.
expect(codex!.totalProxiedCostUSD).toBeCloseTo(codex!.totalCostUSD, 10)
setProxyPaths([])
clearSessionCache()
})
+104
View File
@@ -0,0 +1,104 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
// Regression guard for the cross-provider merge in parseAllSessions: when the
// same repo is used with Claude Code AND another tool (Codex), the two
// ProjectSummaries merge by canonical path and totalCostUSD is summed. The
// merge must RE-DERIVE totalProxiedCostUSD from the final path+cost, or only
// the first-seen provider's proxied amount survives and net out-of-pocket is
// overstated — silently reintroducing the exact bug issue #417 fixes.
//
// The codex provider captures its sessions dir (CODEX_HOME) when its module is
// first evaluated, so this file sets the env and creates fixtures BEFORE a
// dynamic import of the parser. A hyphen-free cwd is used so codex's
// sanitize/unsanitize path round-trips to the same merge key as Claude.
const MERGE_CWD = '/Users/test/proxiedmerge'
let tmpDirs: string[] = []
afterEach(async () => {
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function writeClaudeFixture(cwd: string): Promise<void> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-merge-claude-'))
tmpDirs.push(base)
const dir = join(base, 'projects', 'p')
await mkdir(dir, { recursive: true })
const line = JSON.stringify({
type: 'assistant',
timestamp: '2026-04-16T10:00:00.000Z',
sessionId: 's1',
cwd,
message: {
type: 'message', role: 'assistant', model: 'claude-sonnet-4-6', id: 'm1', content: [],
usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
},
})
await writeFile(join(dir, 's1.jsonl'), line + '\n', 'utf-8')
process.env['CLAUDE_CONFIG_DIR'] = base
}
async function writeCodexFixture(cwd: string): Promise<void> {
const home = await mkdtemp(join(tmpdir(), 'codeburn-merge-codex-'))
tmpDirs.push(home)
const dir = join(home, 'sessions', '2026', '04', '16')
await mkdir(dir, { recursive: true })
const meta = JSON.stringify({
type: 'session_meta', timestamp: '2026-04-16T10:00:00Z',
payload: { cwd, originator: 'codex-cli', session_id: 'codex-1', model: 'gpt-5.3-codex' },
})
const tokens = JSON.stringify({
type: 'event_msg', timestamp: '2026-04-16T10:01:00Z',
payload: {
type: 'token_count',
info: {
model: 'gpt-5.3-codex',
last_token_usage: { input_tokens: 1000, cached_input_tokens: 0, output_tokens: 200, reasoning_output_tokens: 0, total_tokens: 1200 },
total_token_usage: { input_tokens: 1000, cached_input_tokens: 0, output_tokens: 200, reasoning_output_tokens: 0, total_tokens: 1200 },
},
},
})
await writeFile(join(dir, 'rollout-codex-1.jsonl'), meta + '\n' + tokens + '\n', 'utf-8')
process.env['CODEX_HOME'] = home
}
describe('proxy pricing: cross-provider merge', () => {
it('re-derives proxied == total when Claude and Codex sessions merge under a proxy path', async () => {
await writeClaudeFixture(MERGE_CWD)
await writeCodexFixture(MERGE_CWD)
// Import AFTER env is set so the codex provider reads CODEX_HOME.
const { parseAllSessions, clearSessionCache } = await import('../src/parser.js')
const { setProxyPaths, loadPricing } = await import('../src/models.js')
await loadPricing()
setProxyPaths([MERGE_CWD])
clearSessionCache()
const range = { start: new Date(Date.UTC(2026, 3, 15)), end: new Date(Date.UTC(2026, 3, 17)) }
const projects = await parseAllSessions(range, 'all')
// Sanity: both providers landed in a single merged project (proves the
// cross-provider merge path actually ran, not just a lone Claude project).
expect(projects).toHaveLength(1)
const merged = projects[0]!
const providers = new Set(
merged.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls.map(c => c.provider))),
)
expect(providers.has('claude')).toBe(true)
expect(providers.has('codex')).toBe(true)
// The fix: the whole merged total is subscription-covered, not just the
// first provider's slice. Without the merge re-derivation this is < total.
expect(merged.totalProxiedCostUSD).toBeCloseTo(merged.totalCostUSD, 10)
expect(merged.totalCostUSD - merged.totalProxiedCostUSD).toBeCloseTo(0, 10)
setProxyPaths([])
clearSessionCache()
})
})
+264
View File
@@ -0,0 +1,264 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { setProxyPaths, isProxiedPath, getProxyPathsConfigHash, setLocalModelSavings, setModelAliases, loadPricing } from '../src/models.js'
import { parseAllSessions, clearSessionCache, filterProjectsByDateRange } from '../src/parser.js'
import type { DateRange, ProjectSummary } from '../src/types.js'
// ── Part A: isProxiedPath matching rule (pure) ─────────────────────────────
describe('isProxiedPath: path matching rule', () => {
beforeEach(() => setProxyPaths([]))
afterEach(() => setProxyPaths([]))
it('never matches when no proxy paths are configured', () => {
expect(isProxiedPath('/Users/me/work/acme')).toBe(false)
})
it('matches an exact path', () => {
setProxyPaths(['/Users/me/work/acme'])
expect(isProxiedPath('/Users/me/work/acme')).toBe(true)
})
it('matches a child directory under the prefix', () => {
setProxyPaths(['/Users/me/work'])
expect(isProxiedPath('/Users/me/work/acme/sub')).toBe(true)
})
it('does NOT match across a partial path segment (boundary guard)', () => {
// The single most important negative: a string prefix that is not a
// directory-segment boundary must not silently zero unrelated spend.
setProxyPaths(['/Users/me/proj'])
expect(isProxiedPath('/Users/me/project-unrelated')).toBe(false)
})
it('is tolerant of trailing slashes on both config and cwd', () => {
setProxyPaths(['/Users/me/work/'])
expect(isProxiedPath('/Users/me/work')).toBe(true)
expect(isProxiedPath('/Users/me/work/')).toBe(true)
})
it('is case-insensitive (macOS/Windows default filesystems)', () => {
setProxyPaths(['/Users/Me/Work'])
expect(isProxiedPath('/users/me/work/acme')).toBe(true)
})
it('matches a Windows-style config against a forward-slash cwd', () => {
setProxyPaths(['C:\\Users\\me\\work'])
expect(isProxiedPath('C:/Users/me/work/acme')).toBe(true)
})
it('never matches an empty/undefined/null cwd', () => {
setProxyPaths(['/Users/me/work'])
expect(isProxiedPath('')).toBe(false)
expect(isProxiedPath(undefined)).toBe(false)
expect(isProxiedPath(null)).toBe(false)
})
it('drops a root "/" entry so it can never match everything', () => {
setProxyPaths(['/'])
expect(isProxiedPath('/Users/me/anything')).toBe(false)
})
it('drops blank / non-string entries', () => {
setProxyPaths(['', ' ', undefined as unknown as string, '/Users/me/work'])
expect(isProxiedPath('/Users/me/work/x')).toBe(true)
expect(isProxiedPath('/somewhere/else')).toBe(false)
})
it('matches when any one of several configured paths matches', () => {
setProxyPaths(['/Users/me/a', '/Users/me/b'])
expect(isProxiedPath('/Users/me/b/deep')).toBe(true)
expect(isProxiedPath('/Users/me/c')).toBe(false)
})
it('is reset by setProxyPaths([])', () => {
setProxyPaths(['/Users/me/work'])
expect(isProxiedPath('/Users/me/work')).toBe(true)
setProxyPaths([])
expect(isProxiedPath('/Users/me/work')).toBe(false)
})
it('matches a leading-slash-stripped cwd (non-Claude provider path form)', () => {
// Codex/unsanitizePath project paths drop the leading slash; the configured
// path keeps it. Matching must be agnostic to that difference.
setProxyPaths(['/Users/me/work'])
expect(isProxiedPath('Users/me/work/acme')).toBe(true)
expect(isProxiedPath('Users/me/work')).toBe(true)
})
})
describe('getProxyPathsConfigHash: cache-key stability', () => {
beforeEach(() => setProxyPaths([]))
afterEach(() => setProxyPaths([]))
it('is empty when unconfigured', () => {
expect(getProxyPathsConfigHash()).toBe('')
})
it('is order-independent', () => {
setProxyPaths(['/a', '/b'])
const h1 = getProxyPathsConfigHash()
setProxyPaths(['/b', '/a'])
expect(getProxyPathsConfigHash()).toBe(h1)
})
it('does NOT collide two materially different sets (delimited join)', () => {
// Regression guard: a separator-less join would make {'/a','/b'} and
// {'/a/b'} hash identically and let the session cache serve stale numbers.
setProxyPaths(['/a', '/b'])
const h1 = getProxyPathsConfigHash()
setProxyPaths(['/a/b'])
expect(getProxyPathsConfigHash()).not.toBe(h1)
})
})
// ── Part B: end-to-end attribution through parseAllSessions ────────────────
const FIXTURE_DAY = Date.UTC(2026, 3, 16)
const RANGE_START = new Date(FIXTURE_DAY - 24 * 60 * 60 * 1000)
const RANGE_END = new Date(FIXTURE_DAY + 24 * 60 * 60 * 1000)
const makeRange = (): DateRange => ({ start: RANGE_START, end: RANGE_END })
// A stable, non-existent absolute path: resolveCanonicalProjectPath finds no
// .git ancestor and returns it unchanged, so projectPath is predictable.
const FIXTURE_CWD = '/private/var/eywa-proxy-fixture/acme'
let tmpDirs: string[] = []
beforeAll(async () => {
await loadPricing()
})
beforeEach(() => {
setProxyPaths([])
setLocalModelSavings({})
setModelAliases({})
clearSessionCache()
})
afterEach(async () => {
setProxyPaths([])
clearSessionCache()
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function setupProxiedSession(cwd: string = FIXTURE_CWD): Promise<void> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-proxy-'))
tmpDirs.push(base)
const projectDir = join(base, 'projects', 'p')
await mkdir(projectDir, { recursive: true })
const line = JSON.stringify({
type: 'assistant',
timestamp: '2026-04-16T10:00:00.000Z',
sessionId: 's1',
cwd,
message: {
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-6',
id: 'msg-1',
content: [],
usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
},
})
await writeFile(join(projectDir, 's1.jsonl'), line + '\n', 'utf-8')
process.env['CLAUDE_CONFIG_DIR'] = base
}
const allCalls = (projects: ProjectSummary[]) =>
projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls)))
describe('proxy pricing: end-to-end through parseAllSessions', () => {
it('attributes nothing as proxied when no proxy paths are configured', async () => {
await setupProxiedSession()
const projects = await parseAllSessions(makeRange(), 'all')
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(total).toBeGreaterThan(0)
expect(proxied).toBe(0)
})
it('flags the full cost as proxied when the project is under a proxy path, WITHOUT altering costUSD', async () => {
await setupProxiedSession()
setProxyPaths([FIXTURE_CWD])
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(total).toBeGreaterThan(0)
// "Full cost, flagged": the billable figure is preserved, the same amount
// is reported as subscription-covered, so net out-of-pocket is 0.
expect(proxied).toBeCloseTo(total, 10)
expect(total - proxied).toBeCloseTo(0, 10)
// The raw per-call cost is never destroyed — it stays at the full API rate.
const calls = allCalls(projects)
expect(calls.length).toBeGreaterThan(0)
for (const c of calls) expect(c.costUSD).toBeGreaterThan(0)
})
it('matches a parent prefix on a segment boundary', async () => {
await setupProxiedSession()
setProxyPaths(['/private/var/eywa-proxy-fixture'])
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(proxied).toBeGreaterThan(0)
})
it('does NOT flag a sibling path that is only a string prefix (no spend silently zeroed)', async () => {
await setupProxiedSession()
// '/private/var/eywa-proxy-fixture/ac' is a string prefix of '.../acme'
// but not a directory-segment boundary — must not match.
setProxyPaths(['/private/var/eywa-proxy-fixture/ac'])
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(total).toBeGreaterThan(0)
expect(proxied).toBe(0)
})
it('attributes nothing when a different, unrelated path is configured', async () => {
await setupProxiedSession()
setProxyPaths(['/Users/someone/else'])
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(proxied).toBe(0)
})
it('preserves proxy attribution after date-range filtering (filterProjectsByDateRange)', async () => {
await setupProxiedSession()
setProxyPaths([FIXTURE_CWD])
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const filtered = filterProjectsByDateRange(projects, makeRange())
expect(filtered.length).toBeGreaterThan(0)
const total = filtered.reduce((s, p) => s + p.totalCostUSD, 0)
const proxied = filtered.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(total).toBeGreaterThan(0)
expect(proxied).toBeCloseTo(total, 10)
})
it('does not serve stale proxy attribution from the in-memory cache after proxyPaths changes', async () => {
// parseAllSessions caches ProjectSummary[] for 180s keyed partly on the
// proxy-config hash. Toggling proxyPaths must change the key so the second
// call recomputes rather than returning the pre-change (proxied=0) result.
await setupProxiedSession()
const before = await parseAllSessions(makeRange(), 'all')
expect(before.reduce((s, p) => s + p.totalProxiedCostUSD, 0)).toBe(0)
setProxyPaths([FIXTURE_CWD]) // deliberately NO clearSessionCache()
const after = await parseAllSessions(makeRange(), 'all')
const proxied = after.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(proxied).toBeGreaterThan(0)
})
})
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest'
import { shouldSkipLine } from '../src/parser.js'
const threshold = '2026-04-01T00:00:00.000Z'
function makeLine(type: string, timestamp: string, payloadSize = 0): string {
const payload = payloadSize > 0 ? `,"content":"${'x'.repeat(payloadSize)}"` : ''
return `{"type":"${type}","sessionId":"s1","timestamp":"${timestamp}"${payload}}`
}
function makeLineWithLongCwd(type: string, timestamp: string, cwdLength: number): string {
const cwd = '/projects/' + 'a'.repeat(cwdLength)
return `{"type":"${type}","sessionId":"s1","cwd":"${cwd}","timestamp":"${timestamp}","message":{"role":"user","content":"hi"}}`
}
describe('shouldSkipLine', () => {
it('skips old user lines', () => {
expect(shouldSkipLine(makeLine('user', '2026-03-01T10:00:00Z'), threshold)).toBe(true)
})
it('skips old assistant lines', () => {
expect(shouldSkipLine(makeLine('assistant', '2026-03-15T10:00:00Z'), threshold)).toBe(true)
})
it('does not skip in-range user lines', () => {
expect(shouldSkipLine(makeLine('user', '2026-04-05T10:00:00Z'), threshold)).toBe(false)
})
it('does not skip in-range assistant lines', () => {
expect(shouldSkipLine(makeLine('assistant', '2026-04-10T10:00:00Z'), threshold)).toBe(false)
})
it('never skips attachment lines regardless of timestamp', () => {
expect(shouldSkipLine(makeLine('attachment', '2026-01-01T00:00:00Z'), threshold)).toBe(false)
})
it('never skips system lines regardless of timestamp', () => {
expect(shouldSkipLine(makeLine('system', '2026-01-01T00:00:00Z'), threshold)).toBe(false)
})
it('never skips summary lines regardless of timestamp', () => {
expect(shouldSkipLine(makeLine('summary', '2026-01-01T00:00:00Z'), threshold)).toBe(false)
})
it('does not skip lines with no timestamp field', () => {
expect(shouldSkipLine('{"type":"user","sessionId":"s1"}', threshold)).toBe(false)
})
it('does not skip lines with unparseable timestamp', () => {
expect(shouldSkipLine('{"type":"user","timestamp":"bad"}', threshold)).toBe(false)
})
it('does not skip malformed JSON', () => {
expect(shouldSkipLine('not json at all', threshold)).toBe(false)
})
it('only reads top-level type and timestamp fields', () => {
const line = '{"message":{"type":"assistant","timestamp":"2026-03-01T10:00:00Z"},"type":"user","timestamp":"2026-04-05T10:00:00Z"}'
expect(shouldSkipLine(line, threshold)).toBe(false)
})
it('handles timestamp pushed past 200 chars by long cwd', () => {
const line = makeLineWithLongCwd('user', '2026-03-01T10:00:00Z', 300)
expect(line.indexOf('"timestamp"')).toBeGreaterThan(200)
expect(shouldSkipLine(line, threshold)).toBe(true)
})
it('handles timestamp at the edge of the 2048 head window', () => {
const line = makeLineWithLongCwd('user', '2026-03-01T10:00:00Z', 1900)
expect(line.indexOf('"timestamp"')).toBeGreaterThan(1900)
expect(shouldSkipLine(line, threshold)).toBe(true)
})
it('returns false when timestamp is beyond the head window', () => {
const line = makeLineWithLongCwd('user', '2026-03-01T10:00:00Z', 2100)
expect(line.indexOf('"timestamp"')).toBeGreaterThan(2048)
expect(shouldSkipLine(line, threshold)).toBe(false)
})
it('skips old assistant line with large payload without parsing it', () => {
const line = makeLine('assistant', '2026-02-01T10:00:00Z', 50_000_000)
expect(line.length).toBeGreaterThan(50_000_000)
expect(shouldSkipLine(line, threshold)).toBe(true)
})
})
+61
View File
@@ -0,0 +1,61 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join, basename } from 'path'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { collectJsonlFiles, readAgentType } from '../src/parser.js'
let root: string
beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'codeburn-collect-')) })
afterEach(async () => { await rm(root, { recursive: true, force: true }) })
describe('collectJsonlFiles', () => {
// Regression for #470: workflow/ultracode subagent transcripts live nested at
// `<session>/subagents/workflows/<wf>/agent-*.jsonl`. A flat scan dropped them,
// so usage went uncounted whenever the workflow feature was on.
it('collects nested workflow subagent transcripts, not just top-level subagent files', async () => {
const sessionDir = join(root, 'session-1')
const wfDir = join(sessionDir, 'subagents', 'workflows', 'wf_abc')
await mkdir(wfDir, { recursive: true })
await writeFile(join(root, 'session-1.jsonl'), '{}\n')
await writeFile(join(sessionDir, 'subagents', 'agent-direct.jsonl'), '{}\n')
await writeFile(join(wfDir, 'agent-nested.jsonl'), '{}\n')
// Sidecar metadata must never be picked up as a transcript.
await writeFile(join(wfDir, 'agent-nested.meta.json'), '{}\n')
const found = (await collectJsonlFiles(root)).map(f => basename(f)).sort()
expect(found).toContain('session-1.jsonl')
expect(found).toContain('agent-direct.jsonl')
expect(found).toContain('agent-nested.jsonl')
expect(found).not.toContain('agent-nested.meta.json')
})
it('returns an empty list for a missing directory without throwing', async () => {
await expect(collectJsonlFiles(join(root, 'does-not-exist'))).resolves.toEqual([])
})
})
describe('readAgentType (Claude-scoped agent-type detection)', () => {
it('reads agentType from a subagent transcripts sibling .meta.json', async () => {
const dir = join(root, 'session', 'subagents')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'agent-x.jsonl'), '{}\n')
await writeFile(join(dir, 'agent-x.meta.json'), JSON.stringify({ agentType: 'Explore' }))
expect(await readAgentType(join(dir, 'agent-x.jsonl'))).toBe('Explore')
})
it('falls back to workflow-subagent for nested workflow agents without a meta', async () => {
const dir = join(root, 'session', 'subagents', 'workflows', 'wf_1')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'agent-y.jsonl'), '{}\n')
expect(await readAgentType(join(dir, 'agent-y.jsonl'))).toBe('workflow-subagent')
})
it('returns undefined for an ordinary (non-subagent) session file', async () => {
await writeFile(join(root, 'session.jsonl'), '{}\n')
expect(await readAgentType(join(root, 'session.jsonl'))).toBeUndefined()
})
})
+414
View File
@@ -0,0 +1,414 @@
// Tests for durable-source monotonic cost behaviour (PR #477 / copilot-otel).
// Five scenarios:
// (a) file-purge monotonic — copilot JSONL file deleted → total unchanged
// (b) OTel-prune monotonic — OTel DB rows pruned → total unchanged
// (c) no double-count — same source parsed twice → counted once
// (d) non-durable evicts — deleted source for non-durable provider IS removed
// (e) 90-day age-out — orphan ≥ 91d old is pruned; ≤ 89d is retained
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm, unlink } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { createRequire } from 'node:module'
import { isSqliteAvailable } from '../src/sqlite.js'
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
import { loadCache, saveCache } from '../src/session-cache.js'
import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/providers/types.js'
// ── Synthetic provider state ───────────────────────────────────────────────
// Module-level so the vi.mock factory closure captures them by reference and
// tests can mutate them freely without re-creating the mock.
let _synthSources: SessionSource[] = []
let _synthDurable = false
let _synthYields: ParsedProviderCall[] = []
vi.mock('../src/providers/index.js', async (importOriginal) => {
type Mod = typeof import('../src/providers/index.js')
const actual = await importOriginal<Mod>()
return {
...actual,
async discoverAllSessions(filter?: string) {
// Pass through for specific non-synthetic providers; inject synthetic
// sources only when filter is undefined/'all'/'test-synthetic'.
if (filter && filter !== 'all' && filter !== 'test-synthetic') {
return actual.discoverAllSessions(filter)
}
const base = filter === 'test-synthetic'
? []
: await actual.discoverAllSessions(filter)
return [..._synthSources, ...base]
},
async getProvider(name: string) {
if (name === 'test-synthetic') {
return {
name: 'test-synthetic',
displayName: 'Test Synthetic',
durableSources: _synthDurable,
modelDisplayName: (m: string) => m,
toolDisplayName: (t: string) => t,
async discoverSessions() { return _synthSources },
createSessionParser(_s: SessionSource, _k: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
for (const call of _synthYields) {
// Respect seenKeys so that when multiple sources share the same
// dedup key, only the first source yields it (mirrors real parsers).
if (_k.has(call.deduplicationKey)) continue
_k.add(call.deduplicationKey)
yield call
}
},
}
},
}
}
return actual.getProvider(name)
},
}
})
// ── OTel DB helpers ───────────────────────────────────────────────────────
const requireForTest = createRequire(import.meta.url)
type TestDb = {
exec(sql: string): void
prepare(sql: string): { run(...p: unknown[]): void }
close(): void
}
function createOtelDb(dbPath: string): void {
const { DatabaseSync } = requireForTest('node:sqlite') as {
DatabaseSync: new (path: string) => TestDb
}
const db = new DatabaseSync(dbPath)
db.exec(`
CREATE TABLE spans (
span_id TEXT PRIMARY KEY NOT NULL,
trace_id TEXT NOT NULL,
operation_name TEXT,
start_time_ms INTEGER NOT NULL DEFAULT 0,
response_model TEXT
);
CREATE TABLE span_attributes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
span_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT
);
`)
db.close()
}
interface OtelConvSpec {
spanId: string
traceId: string
convId: string
model: string
input: number
output: number
startTimeMs?: number
}
function insertOtelConv(dbPath: string, spec: OtelConvSpec): void {
const { DatabaseSync } = requireForTest('node:sqlite') as {
DatabaseSync: new (path: string) => TestDb
}
const db = new DatabaseSync(dbPath)
db.prepare(
`INSERT INTO spans (span_id, trace_id, operation_name, start_time_ms, response_model)
VALUES (?, ?, ?, ?, ?)`
).run(spec.spanId, spec.traceId, 'chat', spec.startTimeMs ?? Date.now(), spec.model)
const attr = db.prepare(
`INSERT INTO span_attributes (span_id, key, value) VALUES (?, ?, ?)`
)
const attrs: Record<string, string | number> = {
'gen_ai.conversation.id': spec.convId,
'gen_ai.response.model': spec.model,
'gen_ai.usage.input_tokens': spec.input,
'gen_ai.usage.output_tokens': spec.output,
'gen_ai.usage.cache_read.input_tokens': 0,
'gen_ai.usage.cache_creation.input_tokens': 0,
}
for (const [k, v] of Object.entries(attrs)) attr.run(spec.spanId, k, String(v))
db.close()
}
// ── Copilot JSONL helpers ─────────────────────────────────────────────────
async function createJsonlSession(
sessionStateDir: string,
sessionId: string,
outputTokens: number,
): Promise<string> {
const dir = join(sessionStateDir, sessionId)
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), `id: ${sessionId}\ncwd: /home/user/testproj\n`)
const lines = [
JSON.stringify({ type: 'session.model_change', timestamp: '2026-05-01T10:00:00Z', data: { newModel: 'gpt-4.1' } }),
JSON.stringify({ type: 'user.message', timestamp: '2026-05-01T10:00:05Z', data: { content: 'hello', interactionId: 'int-1' } }),
JSON.stringify({ type: 'assistant.message', timestamp: '2026-05-01T10:00:10Z', data: { messageId: 'msg-1', outputTokens, interactionId: 'int-1', toolRequests: [] } }),
]
await writeFile(join(dir, 'events.jsonl'), lines.join('\n') + '\n')
return join(dir, 'events.jsonl')
}
// ── Helpers ───────────────────────────────────────────────────────────────
function totalCost(projects: Awaited<ReturnType<typeof parseAllSessions>>): number {
return projects
.flatMap(p => p.sessions)
.flatMap(s => s.turns)
.flatMap(t => t.assistantCalls)
.reduce((s, c) => s + c.costUSD, 0)
}
function totalOutput(projects: Awaited<ReturnType<typeof parseAllSessions>>): number {
return projects
.flatMap(p => p.sessions)
.flatMap(s => s.turns)
.flatMap(t => t.assistantCalls)
.reduce((s, c) => s + c.usage.outputTokens, 0)
}
// ── Common env setup ──────────────────────────────────────────────────────
let tmpHome: string
let tmpCache: string
beforeEach(async () => {
tmpHome = await mkdtemp(join(tmpdir(), 'cb-parser-test-home-'))
tmpCache = await mkdtemp(join(tmpdir(), 'cb-parser-test-cache-'))
process.env['HOME'] = tmpHome
process.env['CODEBURN_CACHE_DIR'] = tmpCache
// Reset synthetic provider state
_synthSources = []
_synthDurable = false
_synthYields = []
})
afterEach(async () => {
clearSessionCache()
vi.unstubAllEnvs()
_synthSources = []
await rm(tmpHome, { recursive: true, force: true })
await rm(tmpCache, { recursive: true, force: true })
})
// ═══════════════════════════════════════════════════════════════════════════
// (a) File-purge monotonic: copilot JSONL file deleted → total unchanged
// ═══════════════════════════════════════════════════════════════════════════
describe('(a) copilot JSONL file-purge monotonic', () => {
it('preserves monthly total after events.jsonl is deleted', async () => {
const sessionStateDir = join(tmpHome, 'session-state')
await mkdir(sessionStateDir, { recursive: true })
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1')
vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpHome, 'no-ws'))
const eventsPath = await createJsonlSession(sessionStateDir, 'sess-del', 200)
// First parse: file exists → cached
const proj1 = await parseAllSessions(undefined, 'copilot')
const out1 = totalOutput(proj1)
expect(out1).toBe(200)
// Delete the source file (simulates VS Code / CLI pruning it)
await unlink(eventsPath)
clearSessionCache()
// Second parse: file gone but copilot is durable → total must not drop
const proj2 = await parseAllSessions(undefined, 'copilot')
const out2 = totalOutput(proj2)
expect(out2).toBeGreaterThanOrEqual(out1)
expect(out2).toBe(out1)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (b) OTel-prune monotonic: OTel DB rows pruned → total unchanged
// ═══════════════════════════════════════════════════════════════════════════
describe.skipIf(!isSqliteAvailable())(
'(b) OTel DB-prune monotonic',
() => {
it('preserves total after one conversation is pruned from the OTel DB', async () => {
const dbPath = join(tmpHome, 'agent-traces.db')
vi.stubEnv('CODEBURN_COPILOT_OTEL_DB', dbPath)
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '')
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', join(tmpHome, 'no-jsonl'))
vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpHome, 'no-ws'))
// DB with two conversations
createOtelDb(dbPath)
insertOtelConv(dbPath, { spanId: 's1', traceId: 't1', convId: 'prune-c1', model: 'gpt-4.1', input: 500, output: 50 })
insertOtelConv(dbPath, { spanId: 's2', traceId: 't2', convId: 'prune-c2', model: 'gpt-4.1', input: 1000, output: 100 })
const proj1 = await parseAllSessions(undefined, 'copilot')
const out1 = totalOutput(proj1)
expect(out1).toBe(150) // 50 + 100
// Simulate OTel pruning conv-1 from the DB: rebuild DB with only conv-2
clearSessionCache()
await rm(dbPath)
createOtelDb(dbPath)
insertOtelConv(dbPath, { spanId: 's2', traceId: 't2', convId: 'prune-c2', model: 'gpt-4.1', input: 1000, output: 100 })
// Second parse: DB was rebuilt without conv-1. The union-merge in
// parseProviderSources keeps conv-1's turns in the cache (since its
// dedup keys are not re-emitted by the re-parse) → total must not drop.
const proj2 = await parseAllSessions(undefined, 'copilot')
const out2 = totalOutput(proj2)
expect(out2).toBeGreaterThanOrEqual(out1)
expect(out2).toBe(out1)
})
}
)
// ═══════════════════════════════════════════════════════════════════════════
// (c) No double-count: same fully-present source parsed twice → counted once
// ═══════════════════════════════════════════════════════════════════════════
describe.skipIf(!isSqliteAvailable())(
'(c) OTel source parsed twice is counted once',
() => {
it('second parse of unchanged DB yields same total, not double', async () => {
const dbPath = join(tmpHome, 'agent-traces.db')
vi.stubEnv('CODEBURN_COPILOT_OTEL_DB', dbPath)
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '')
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', join(tmpHome, 'no-jsonl'))
vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpHome, 'no-ws'))
createOtelDb(dbPath)
insertOtelConv(dbPath, { spanId: 'dedup-s1', traceId: 'dedup-t1', convId: 'dedup-c1', model: 'gpt-4.1', input: 300, output: 30 })
const proj1 = await parseAllSessions(undefined, 'copilot')
expect(totalOutput(proj1)).toBe(30)
clearSessionCache()
// Second parse — disk cache is populated, fingerprint unchanged
const proj2 = await parseAllSessions(undefined, 'copilot')
expect(totalOutput(proj2)).toBe(30) // NOT 60
})
}
)
// ═══════════════════════════════════════════════════════════════════════════
// (d) Non-durable evicts: deleted source for non-durable provider is removed
// ═══════════════════════════════════════════════════════════════════════════
describe('(d) non-durable provider evicts deleted sources', () => {
it('removes cache entry for a path that leaves discoverSessions()', async () => {
// Two real temp files as source paths (fingerprintFile needs them to exist)
const fileA = join(tmpHome, 'synth-a.txt')
const fileB = join(tmpHome, 'synth-b.txt')
await writeFile(fileA, 'placeholder-a')
await writeFile(fileB, 'placeholder-b')
const dedupA = 'synth-dedup-evict-a'
const dedupB = 'synth-dedup-evict-b'
const makeCall = (deduplicationKey: string): ParsedProviderCall => ({
provider: 'test-synthetic', model: 'gpt-4o',
inputTokens: 10, outputTokens: 5,
cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
costUSD: 0.001, tools: [], bashCommands: [],
timestamp: new Date().toISOString(),
speed: 'standard',
deduplicationKey,
userMessage: 'test', sessionId: 'synth-sess',
})
_synthDurable = false
_synthSources = [
{ path: fileA, project: 'test', provider: 'test-synthetic' },
{ path: fileB, project: 'test', provider: 'test-synthetic' },
]
_synthYields = [makeCall(dedupA)]
// First parse: both sources present → data for A cached
const proj1 = await parseAllSessions(undefined, 'test-synthetic')
expect(totalOutput(proj1)).toBeGreaterThan(0)
clearSessionCache()
// Remove A from discovered sources (simulates file-gone + discoverSessions skips it).
// B stays so sources.length > 0 → eviction loop fires.
_synthSources = [{ path: fileB, project: 'test', provider: 'test-synthetic' }]
_synthYields = [] // B yields nothing (empty file)
const proj2 = await parseAllSessions(undefined, 'test-synthetic')
// A's cache entry must be evicted → total should be 0
expect(totalOutput(proj2)).toBe(0)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (e) 90-day age-out: orphan ≥ 91d old is pruned; ≤ 89d is retained
// ═══════════════════════════════════════════════════════════════════════════
describe('(e) 90-day age-out for durable providers', () => {
it('prunes an orphaned cache entry whose newest call is 91 days old', async () => {
const synthFile = join(tmpHome, 'synth-age.txt')
await writeFile(synthFile, 'placeholder')
const ts91dAgo = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000).toISOString()
_synthDurable = true
_synthSources = [{ path: synthFile, project: 'test', provider: 'test-synthetic' }]
_synthYields = [{
provider: 'test-synthetic', model: 'gpt-4o',
inputTokens: 10, outputTokens: 8,
cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
costUSD: 0.002, tools: [], bashCommands: [],
timestamp: ts91dAgo,
speed: 'standard',
deduplicationKey: 'synth-age-out-91d',
userMessage: 'old', sessionId: 'synth-old',
}]
// First parse: cached with 91d-old timestamp → immediately pruned by 90-day check
const proj1 = await parseAllSessions(undefined, 'test-synthetic')
expect(totalOutput(proj1)).toBe(0) // pruned right away
// Confirm: entry is not in the persistent cache after first parse
clearSessionCache()
_synthSources = [] // no longer discovered
const proj2 = await parseAllSessions(undefined, 'test-synthetic')
expect(totalOutput(proj2)).toBe(0)
})
it('retains an orphaned cache entry whose newest call is 89 days old', async () => {
const synthFile = join(tmpHome, 'synth-retain.txt')
await writeFile(synthFile, 'placeholder')
const ts89dAgo = new Date(Date.now() - 89 * 24 * 60 * 60 * 1000).toISOString()
_synthDurable = true
_synthSources = [{ path: synthFile, project: 'test', provider: 'test-synthetic' }]
_synthYields = [{
provider: 'test-synthetic', model: 'gpt-4o',
inputTokens: 10, outputTokens: 7,
cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
costUSD: 0.002, tools: [], bashCommands: [],
timestamp: ts89dAgo,
speed: 'standard',
deduplicationKey: 'synth-retain-89d',
userMessage: 'recent-ish', sessionId: 'synth-recent',
}]
// First parse: cached with 89d-old timestamp → NOT pruned (within 90d window)
const proj1 = await parseAllSessions(undefined, 'test-synthetic')
expect(totalOutput(proj1)).toBe(7)
// Remove source (simulate it being orphaned)
clearSessionCache()
_synthSources = [] // no longer discovered → orphan pass handles it
// Second parse: orphan with 89d timestamp → retained + counted via orphan pass
const proj2 = await parseAllSessions(undefined, 'test-synthetic')
expect(totalOutput(proj2)).toBe(7)
})
})
+343
View File
@@ -0,0 +1,343 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { savePlan } from '../src/config.js'
import { activePlansFromMap, computePeriodFromResetDay, getPlanUsage, getPlanUsageFromProjects, getPlanUsages } from '../src/plan-usage.js'
import type { ProjectSummary } from '../src/types.js'
const { parseAllSessionsMock } = vi.hoisted(() => ({
parseAllSessionsMock: vi.fn(),
}))
vi.mock('../src/parser.js', () => ({
parseAllSessions: parseAllSessionsMock,
}))
describe('computePeriodFromResetDay', () => {
it('uses current month when today is on/after reset day', () => {
const { periodStart, periodEnd } = computePeriodFromResetDay(1, new Date('2026-04-17T10:00:00.000Z'))
expect(periodStart.getFullYear()).toBe(2026)
expect(periodStart.getMonth()).toBe(3)
expect(periodStart.getDate()).toBe(1)
expect(periodEnd.getMonth()).toBe(4)
expect(periodEnd.getDate()).toBe(1)
})
it('uses previous month when today is before reset day', () => {
const { periodStart, periodEnd } = computePeriodFromResetDay(15, new Date('2026-04-03T10:00:00.000Z'))
expect(periodStart.getMonth()).toBe(2)
expect(periodStart.getDate()).toBe(15)
expect(periodEnd.getMonth()).toBe(3)
expect(periodEnd.getDate()).toBe(15)
})
it('clamps reset day into 1..28', () => {
const { periodStart } = computePeriodFromResetDay(99, new Date('2026-04-27T10:00:00.000Z'))
expect(periodStart.getDate()).toBe(28)
})
})
describe('getPlanUsage', () => {
beforeEach(() => {
parseAllSessionsMock.mockReset()
})
it('passes provider filter from plan and computes status', async () => {
parseAllSessionsMock.mockResolvedValue([
{
totalCostUSD: 160,
sessions: [],
},
])
const usage = await getPlanUsage({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
}, new Date('2026-04-10T10:00:00.000Z'))
expect(parseAllSessionsMock).toHaveBeenCalledWith(
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
'claude',
)
expect(usage.spentApiEquivalentUsd).toBe(160)
expect(usage.percentUsed).toBe(80)
expect(usage.status).toBe('near')
})
it('projects using median daily spend (not mean)', async () => {
const dailyCosts = [1, 100, 1, 100, 1, 100, 1]
const turns = dailyCosts.map((cost, idx) => ({
timestamp: `2026-04-${String(idx + 1).padStart(2, '0')}T12:00:00.000Z`,
assistantCalls: [{ costUSD: cost }],
}))
parseAllSessionsMock.mockResolvedValue([
{
totalCostUSD: dailyCosts.reduce((sum, value) => sum + value, 0),
sessions: [{ turns }],
},
])
const usage = await getPlanUsage({
id: 'custom',
monthlyUsd: 500,
provider: 'all',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
}, new Date('2026-04-07T12:00:00.000Z'))
// Median(1,100,1,100,1,100,1) = 1, so remaining 23 days adds 23.
expect(Math.round(usage.projectedMonthUsd)).toBe(327)
expect(parseAllSessionsMock).toHaveBeenCalledWith(
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
'all',
)
})
it('computes plan usage from pre-fetched projects', () => {
const usage = getPlanUsageFromProjects({
id: 'custom',
monthlyUsd: 100,
provider: 'all',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
}, [
{
totalCostUSD: 40,
sessions: [
{
turns: [
{ timestamp: '2026-04-02T12:00:00.000Z', assistantCalls: [{ costUSD: 20 }] },
{ timestamp: '2026-04-03T12:00:00.000Z', assistantCalls: [{ costUSD: 20 }] },
],
},
],
},
], new Date('2026-04-10T10:00:00.000Z'))
expect(usage.spentApiEquivalentUsd).toBe(40)
expect(usage.budgetUsd).toBe(100)
expect(usage.status).toBe('under')
})
it('projects month-end spend from API call timestamps', () => {
const usage = getPlanUsageFromProjects({
id: 'custom',
monthlyUsd: 100,
provider: 'all',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
}, [
{
project: 'codeburn',
projectPath: '/tmp/codeburn',
totalCostUSD: 10,
totalApiCalls: 1,
sessions: [
{
turns: [
{
timestamp: '2026-03-31T23:59:00.000Z',
assistantCalls: [{ costUSD: 10, timestamp: '2026-04-01T10:00:00.000Z' }],
},
],
},
],
},
] as ProjectSummary[], new Date('2026-04-01T12:00:00.000Z'))
expect(Math.round(usage.projectedMonthUsd)).toBe(300)
})
it('returns active plans in provider display order', () => {
const plans = activePlansFromMap({
codex: {
id: 'custom',
monthlyUsd: 200,
provider: 'codex',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
},
claude: {
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
},
cursor: {
id: 'none',
monthlyUsd: 0,
provider: 'cursor',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
},
})
expect(plans.map(plan => plan.provider)).toEqual(['claude', 'codex'])
})
it('keeps the provider-specific parser filter for one active plan', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-usage-test-'))
process.env['HOME'] = dir
try {
await savePlan({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
})
parseAllSessionsMock.mockResolvedValue([
{
project: 'codeburn',
projectPath: '/tmp/codeburn',
totalCostUSD: 80,
totalApiCalls: 1,
sessions: [],
},
] satisfies ProjectSummary[])
const usages = await getPlanUsages(new Date('2026-04-10T12:00:00.000Z'))
expect(parseAllSessionsMock).toHaveBeenCalledTimes(1)
expect(parseAllSessionsMock).toHaveBeenCalledWith(
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
'claude',
)
expect(usages).toHaveLength(1)
expect(usages[0]?.spentApiEquivalentUsd).toBe(80)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('computes multiple active plan usages from one all-provider parse', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-usage-test-'))
process.env['HOME'] = dir
try {
await savePlan({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
})
await savePlan({
id: 'custom',
monthlyUsd: 100,
provider: 'codex',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
})
parseAllSessionsMock.mockResolvedValue([
{
project: 'codeburn',
projectPath: '/tmp/codeburn',
totalCostUSD: 150,
totalApiCalls: 2,
sessions: [
{
sessionId: 'session-1',
project: 'codeburn',
firstTimestamp: '2026-04-03T10:00:00.000Z',
lastTimestamp: '2026-04-03T11:00:00.000Z',
totalCostUSD: 150,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 2,
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {},
skillBreakdown: {},
turns: [
{
userMessage: 'work',
timestamp: '2026-04-03T10:00:00.000Z',
sessionId: 'session-1',
category: 'coding',
retries: 0,
hasEdits: true,
assistantCalls: [
{
provider: 'claude',
model: 'claude-opus-4-7',
usage: {
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: 100,
tools: [],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-04-03T10:00:00.000Z',
bashCommands: [],
deduplicationKey: 'claude-1',
},
{
provider: 'codex',
model: 'gpt-5.5',
usage: {
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: 50,
tools: [],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-04-03T11:00:00.000Z',
bashCommands: [],
deduplicationKey: 'codex-1',
},
],
},
],
},
],
},
] satisfies ProjectSummary[])
const usages = await getPlanUsages(new Date('2026-04-10T12:00:00.000Z'))
expect(parseAllSessionsMock).toHaveBeenCalledTimes(1)
expect(parseAllSessionsMock).toHaveBeenCalledWith(
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
'all',
)
expect(usages.map(usage => usage.plan.provider)).toEqual(['claude', 'codex'])
expect(usages.map(usage => usage.spentApiEquivalentUsd)).toEqual([100, 50])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
})
+178
View File
@@ -0,0 +1,178 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, it, expect } from 'vitest'
import { clearPlan, readPlan, readPlans, saveConfig, savePlan } from '../src/config.js'
import { getPresetPlan, isPlanId, isPlanProvider } from '../src/plans.js'
describe('plan presets', () => {
it('resolves builtin presets', () => {
expect(getPresetPlan('claude-pro')).toMatchObject({ id: 'claude-pro', monthlyUsd: 20, provider: 'claude' })
expect(getPresetPlan('claude-max')).toMatchObject({ id: 'claude-max', monthlyUsd: 200, provider: 'claude' })
expect(getPresetPlan('cursor-pro')).toMatchObject({ id: 'cursor-pro', monthlyUsd: 20, provider: 'cursor' })
expect(getPresetPlan('custom')).toBeNull()
})
it('validates ids and providers', () => {
expect(isPlanId('claude-pro')).toBe(true)
expect(isPlanId('none')).toBe(true)
expect(isPlanId('bad-plan')).toBe(false)
expect(isPlanProvider('all')).toBe(true)
expect(isPlanProvider('claude')).toBe(true)
expect(isPlanProvider('invalid')).toBe(false)
})
})
describe('plan config persistence', () => {
it('round-trips per-provider plans and clears one provider at a time', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-test-'))
process.env['HOME'] = dir
try {
await savePlan({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 12,
setAt: '2026-04-17T12:00:00.000Z',
})
await savePlan({
id: 'custom',
monthlyUsd: 200,
provider: 'codex',
resetDay: 1,
setAt: '2026-04-18T12:00:00.000Z',
})
const plans = await readPlans()
expect(plans.claude).toMatchObject({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 12,
})
expect(plans.codex).toMatchObject({
id: 'custom',
monthlyUsd: 200,
provider: 'codex',
resetDay: 1,
})
expect(await readPlan()).toMatchObject({ id: 'claude-max', provider: 'claude' })
await clearPlan('codex')
expect((await readPlans()).codex).toBeUndefined()
expect((await readPlans()).claude).toMatchObject({ id: 'claude-max' })
await clearPlan('all')
expect((await readPlans()).claude).toMatchObject({ id: 'claude-max' })
await clearPlan()
expect(await readPlan()).toBeUndefined()
expect(await readPlans()).toEqual({})
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('reads legacy single-plan config as a provider-keyed plan map', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-test-'))
process.env['HOME'] = dir
try {
await saveConfig({
plan: {
id: 'cursor-pro',
monthlyUsd: 20,
provider: 'cursor',
resetDay: 3,
setAt: '2026-04-17T12:00:00.000Z',
},
})
const plans = await readPlans()
expect(plans.cursor).toMatchObject({
id: 'cursor-pro',
monthlyUsd: 20,
provider: 'cursor',
resetDay: 3,
})
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('drops a hand-edited all plan when provider-specific plans are present', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-test-'))
process.env['HOME'] = dir
try {
await saveConfig({
plans: {
all: {
id: 'custom',
monthlyUsd: 300,
resetDay: 1,
setAt: '2026-04-17T12:00:00.000Z',
},
claude: {
id: 'claude-max',
monthlyUsd: 200,
resetDay: 1,
setAt: '2026-04-18T12:00:00.000Z',
},
},
})
const plans = await readPlans()
expect(plans.all).toBeUndefined()
expect(plans.claude).toMatchObject({ id: 'claude-max', provider: 'claude' })
expect(await readPlan()).toMatchObject({ id: 'claude-max', provider: 'claude' })
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('does not allow an all-provider plan to overlap provider-specific plans', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-test-'))
process.env['HOME'] = dir
try {
await savePlan({
id: 'custom',
monthlyUsd: 100,
provider: 'all',
resetDay: 1,
setAt: '2026-04-17T12:00:00.000Z',
})
await savePlan({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 1,
setAt: '2026-04-18T12:00:00.000Z',
})
expect(await readPlans()).toMatchObject({
claude: { id: 'claude-max' },
})
expect((await readPlans()).all).toBeUndefined()
await savePlan({
id: 'custom',
monthlyUsd: 300,
provider: 'all',
resetDay: 1,
setAt: '2026-04-19T12:00:00.000Z',
})
expect(await readPlans()).toMatchObject({
all: { id: 'custom', monthlyUsd: 300 },
})
expect((await readPlans()).claude).toBeUndefined()
} finally {
await rm(dir, { recursive: true, force: true })
}
})
})
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import fallback from '../src/data/pricing-fallback.json' assert { type: 'json' }
// The gap-fill fallback is generated from models.dev / OpenRouter. These assert
// the bundler's hygiene guarantees on the committed artifact, so a future
// rebundle that regresses them fails CI rather than shipping bad pricing.
describe('pricing-fallback.json data hygiene', () => {
const entries = Object.entries(fallback as Record<string, (number | null)[]>)
it('is non-empty', () => {
expect(entries.length).toBeGreaterThan(50)
})
it('has no negative rates (OpenRouter -1 "variable price" sentinels)', () => {
const bad = entries.filter(([, v]) => (v[0] ?? 0) < 0 || (v[1] ?? 0) < 0 || (v[2] ?? 0) < 0 || (v[3] ?? 0) < 0)
expect(bad.map(([k]) => k)).toEqual([])
})
it('has no entry that is free on both input and output', () => {
const bad = entries.filter(([, v]) => v[0] === 0 && v[1] === 0)
expect(bad.map(([k]) => k)).toEqual([])
})
it('has no unreachable @pin or date-suffixed keys', () => {
const bad = entries.filter(([k]) => /@/.test(k) || /\d{8}$/.test(k))
expect(bad.map(([k]) => k)).toEqual([])
})
it('stores per-token rates (no per-million values leaked through)', () => {
// A per-million value would be >= 1; real per-token rates are tiny.
const bad = entries.filter(([, v]) => (v[0] ?? 0) >= 1 || (v[1] ?? 0) >= 1)
expect(bad.map(([k]) => k)).toEqual([])
})
})
+171
View File
@@ -0,0 +1,171 @@
import { describe, it, expect, vi } from 'vitest'
import { providers, getAllProviders, getProvider, safeDiscoverSessions, discoverAllSessions } from '../src/providers/index.js'
import type { Provider } from '../src/providers/types.js'
function fakeProvider(name: string, discover: Provider['discoverSessions']): Provider {
return {
name,
displayName: name,
modelDisplayName: (m: string) => m,
toolDisplayName: (t: string) => t,
discoverSessions: discover,
} as unknown as Provider
}
describe('provider registry', () => {
it('has core providers registered synchronously', () => {
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'open-design', 'pi', 'omp', 'qwen', 'roo-code', 'zerostack', 'grok'])
})
it('codebuff tool display names normalize codebuff-native names to canonical set', () => {
const codebuff = providers.find(p => p.name === 'codebuff')!
expect(codebuff.toolDisplayName('read_files')).toBe('Read')
expect(codebuff.toolDisplayName('code_search')).toBe('Grep')
expect(codebuff.toolDisplayName('str_replace')).toBe('Edit')
expect(codebuff.toolDisplayName('run_terminal_command')).toBe('Bash')
expect(codebuff.toolDisplayName('spawn_agents')).toBe('Agent')
expect(codebuff.toolDisplayName('write_todos')).toBe('TodoWrite')
expect(codebuff.toolDisplayName('unknown_tool')).toBe('unknown_tool')
})
it('codebuff model display names cover known agent tiers', () => {
const codebuff = providers.find(p => p.name === 'codebuff')!
expect(codebuff.modelDisplayName('codebuff')).toBe('Codebuff')
expect(codebuff.modelDisplayName('codebuff-base2')).toBe('Codebuff Base 2')
expect(codebuff.modelDisplayName('some-future-model')).toBe('some-future-model')
})
it('includes sqlite providers after async load', async () => {
const all = await getAllProviders()
const names = all.map(p => p.name)
expect(names).toContain('claude')
expect(names).toContain('codex')
expect(names).toContain('forge')
expect(names).toContain('warp')
expect(names.length).toBeGreaterThanOrEqual(2)
})
it('forge is available through async provider loading', async () => {
const forge = await getProvider('forge')
expect(forge).toBeDefined()
expect(forge!.name).toBe('forge')
})
it('warp model and tool display names are normalized', async () => {
const warp = await getProvider('warp')
expect(warp).toBeDefined()
expect(warp!.modelDisplayName('warp-auto-efficient')).toBe('Warp Auto (efficient)')
expect(warp!.modelDisplayName('gpt-5.3-codex')).toBe('GPT-5.3 Codex')
expect(warp!.toolDisplayName('run_command')).toBe('Bash')
})
it('opencode model display names strip provider prefix', async () => {
const all = await getAllProviders()
const oc = all.find(p => p.name === 'opencode')
if (!oc) return
expect(oc.modelDisplayName('anthropic/claude-opus-4-6-20260205')).toBe('Opus 4.6')
expect(oc.modelDisplayName('google/gemini-2.5-pro')).toBe('Gemini 2.5 Pro')
})
it('opencode tool display names normalize builtins', async () => {
const all = await getAllProviders()
const oc = all.find(p => p.name === 'opencode')
if (!oc) return
expect(oc.toolDisplayName('bash')).toBe('Bash')
expect(oc.toolDisplayName('edit')).toBe('Edit')
expect(oc.toolDisplayName('task')).toBe('Agent')
expect(oc.toolDisplayName('unknown_tool')).toBe('unknown_tool')
})
it('claude tool display names are identity', () => {
const claude = providers.find(p => p.name === 'claude')!
expect(claude.toolDisplayName('Bash')).toBe('Bash')
expect(claude.toolDisplayName('Read')).toBe('Read')
})
it('codex tool display names are normalized', () => {
const codex = providers.find(p => p.name === 'codex')!
expect(codex.toolDisplayName('exec_command')).toBe('Bash')
expect(codex.toolDisplayName('read_file')).toBe('Read')
expect(codex.toolDisplayName('write_file')).toBe('Edit')
expect(codex.toolDisplayName('spawn_agent')).toBe('Agent')
})
it('codex model display names are human-readable', () => {
const codex = providers.find(p => p.name === 'codex')!
expect(codex.modelDisplayName('gpt-5.4')).toBe('GPT-5.4')
expect(codex.modelDisplayName('gpt-5.4-mini')).toBe('GPT-5.4 Mini')
expect(codex.modelDisplayName('gpt-5.3-codex')).toBe('GPT-5.3 Codex')
expect(codex.modelDisplayName('gpt-5.5')).toBe('GPT-5.5')
})
it('claude model display names are human-readable', () => {
const claude = providers.find(p => p.name === 'claude')!
expect(claude.modelDisplayName('claude-opus-4-6-20260205')).toBe('Opus 4.6')
expect(claude.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6')
})
it('kimi model and tool display names are normalized', () => {
const kimi = providers.find(p => p.name === 'kimi')!
expect(kimi.modelDisplayName('kimi-auto')).toBe('Kimi (auto)')
expect(kimi.modelDisplayName('kimi-k2-thinking-turbo')).toBe('Kimi K2 Thinking Turbo')
expect(kimi.toolDisplayName('Shell')).toBe('Bash')
expect(kimi.toolDisplayName('WriteFile')).toBe('Write')
})
it('lingtai-tui model display names are normalized', () => {
const lingtai = providers.find(p => p.name === 'lingtai-tui')!
expect(lingtai.displayName).toBe('LingTai TUI')
expect(lingtai.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6')
expect(lingtai.toolDisplayName('custom_tool')).toBe('custom_tool')
})
it('cursor model display names handle auto mode', async () => {
const all = await getAllProviders()
const cursor = all.find(p => p.name === 'cursor')!
expect(cursor.modelDisplayName('cursor-auto')).toBe('Cursor (auto)')
expect(cursor.modelDisplayName('claude-4.5-opus-high-thinking')).toBe('Opus 4.5 (Thinking)')
expect(cursor.modelDisplayName('grok-code-fast-1')).toBe('Grok Code Fast')
expect(cursor.modelDisplayName('unknown-model')).toBe('unknown-model')
})
describe('provider-discovery isolation', () => {
it('safeDiscoverSessions returns [] and warns once instead of propagating', async () => {
const warn = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
const boom = fakeProvider('boom-helper', async () => { throw new Error('crafted file blew up') })
try {
await expect(safeDiscoverSessions(boom)).resolves.toEqual([])
expect(warn.mock.calls.length).toBeGreaterThanOrEqual(1)
expect(String(warn.mock.calls[0]![0])).toContain('boom-helper')
// Deduped on repeat within the same run: no additional warning.
const afterFirst = warn.mock.calls.length
await safeDiscoverSessions(boom)
expect(warn.mock.calls.length).toBe(afterFirst)
} finally {
warn.mockRestore()
}
})
it('discoverAllSessions drops a throwing provider but keeps the healthy ones', async () => {
const warn = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
const boom = fakeProvider('boom-loop', async () => { throw new Error('kaboom') })
const ok1 = fakeProvider('ok1', async () => [{ path: '/a.jsonl', project: 'p1', provider: 'ok1' }])
const ok2 = fakeProvider('ok2', async () => [{ path: '/b.jsonl', project: 'p2', provider: 'ok2' }])
try {
// A throwing provider in the middle must not abort the loop.
const sources = await discoverAllSessions('all', [ok1, boom, ok2])
expect(sources.map(s => s.path)).toEqual(['/a.jsonl', '/b.jsonl'])
expect(warn.mock.calls.some(c => String(c[0]).includes('boom-loop'))).toBe(true)
} finally {
warn.mockRestore()
}
})
it('discoverAllSessions honors the provider filter', async () => {
const ok1 = fakeProvider('keep', async () => [{ path: '/keep.jsonl', project: 'k', provider: 'keep' }])
const ok2 = fakeProvider('drop', async () => [{ path: '/drop.jsonl', project: 'd', provider: 'drop' }])
const sources = await discoverAllSessions('keep', [ok1, ok2])
expect(sources.map(s => s.path)).toEqual(['/keep.jsonl'])
})
})
})
+158
View File
@@ -0,0 +1,158 @@
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DateRange } from '../src/types.js'
let home: string
let cacheDir: string
let vibeHome: string
let clearParserCache: (() => void) | undefined
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'codeburn-turn-group-home-'))
cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-turn-group-cache-'))
vibeHome = await mkdtemp(join(tmpdir(), 'codeburn-turn-group-vibe-'))
process.env['HOME'] = home
process.env['CODEBURN_CACHE_DIR'] = cacheDir
process.env['VIBE_HOME'] = vibeHome
})
afterEach(async () => {
clearParserCache?.()
clearParserCache = undefined
vi.resetModules()
await rm(home, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
await rm(vibeHome, { recursive: true, force: true })
})
function dayRange(): DateRange {
return {
start: new Date('2026-05-16T00:00:00.000Z'),
end: new Date('2026-05-16T23:59:59.999Z'),
}
}
async function loadParser() {
vi.resetModules()
const parser = await import('../src/parser.js')
clearParserCache = parser.clearSessionCache
return parser.parseAllSessions
}
describe('provider turn grouping', () => {
it('groups Gemini assistant messages under their user turn so retries are counted', async () => {
const chatsDir = join(home, '.gemini', 'tmp', 'project-a', 'chats')
await mkdir(chatsDir, { recursive: true })
await writeFile(join(chatsDir, 'session-gemini.json'), JSON.stringify({
sessionId: 'gemini-session-1',
startTime: '2026-05-16T10:00:00.000Z',
messages: [
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'implement parser update in src/parser.ts' },
{
id: 'g1',
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'editing',
model: 'gemini-3.1-pro-preview',
tokens: { input: 100, output: 30 },
toolCalls: [{ id: 't1', name: 'edit_file', args: { path: 'src/parser.ts' } }],
},
{
id: 'g2',
timestamp: '2026-05-16T10:00:10.000Z',
type: 'gemini',
content: 'testing',
model: 'gemini-3.1-pro-preview',
tokens: { input: 80, output: 20 },
toolCalls: [{ id: 't2', name: 'run_command', args: { command: 'npm test' } }],
},
{
id: 'g3',
timestamp: '2026-05-16T10:00:15.000Z',
type: 'gemini',
content: 'fixing after test',
model: 'gemini-3.1-pro-preview',
tokens: { input: 90, output: 25 },
toolCalls: [{ id: 't3', name: 'edit_file', args: { path: 'src/parser.ts' } }],
},
],
}))
const parseAllSessions = await loadParser()
const projects = await parseAllSessions(dayRange(), 'gemini')
const session = projects[0]!.sessions[0]!
const turn = session.turns[0]!
expect(session.turns).toHaveLength(1)
expect(turn.assistantCalls.map(call => call.deduplicationKey)).toEqual([
'gemini:gemini-session-1:g1',
'gemini:gemini-session-1:g2',
'gemini:gemini-session-1:g3',
])
expect(turn.hasEdits).toBe(true)
expect(turn.retries).toBe(1)
expect(session.categoryBreakdown[turn.category].editTurns).toBe(1)
expect(session.categoryBreakdown[turn.category].oneShotTurns).toBe(0)
})
it('groups Mistral Vibe assistant messages and uses Vibe session_cost when present', async () => {
const sessionDir = join(vibeHome, 'logs', 'session', 'session_20260516_100000_vibe')
await mkdir(sessionDir, { recursive: true })
await writeFile(join(sessionDir, 'meta.json'), JSON.stringify({
session_id: 'vibe-session-1',
start_time: '2026-05-16T10:00:00.000Z',
end_time: '2026-05-16T10:01:00.000Z',
environment: { working_directory: '/Users/test/project-a' },
stats: {
session_prompt_tokens: 300,
session_completion_tokens: 90,
session_cost: 0.123456,
input_price_per_million: 100,
output_price_per_million: 100,
},
config: { active_model: 'mistral-medium-3.5', models: [] },
title: 'vibe parser update',
}))
await writeFile(join(sessionDir, 'messages.jsonl'), [
{ role: 'user', content: 'implement parser update in src/providers/mistral-vibe.ts', message_id: 'u1' },
{
role: 'assistant',
content: 'editing',
message_id: 'a1',
tool_calls: [{ id: 't1', type: 'function', function: { name: 'search_replace', arguments: '{"file_path":"src/providers/mistral-vibe.ts"}' } }],
},
{
role: 'assistant',
content: 'testing',
message_id: 'a2',
tool_calls: [{ id: 't2', type: 'function', function: { name: 'bash', arguments: '{"command":"npm test"}' } }],
},
{
role: 'assistant',
content: 'fixing after test',
message_id: 'a3',
tool_calls: [{ id: 't3', type: 'function', function: { name: 'write_file', arguments: '{"path":"src/providers/mistral-vibe.ts"}' } }],
},
].map(message => JSON.stringify(message)).join('\n') + '\n')
const parseAllSessions = await loadParser()
const projects = await parseAllSessions(dayRange(), 'mistral-vibe')
const session = projects[0]!.sessions[0]!
const turn = session.turns[0]!
expect(session.turns).toHaveLength(1)
expect(turn.assistantCalls.map(call => call.deduplicationKey)).toEqual([
'mistral-vibe:vibe-session-1:a1',
'mistral-vibe:vibe-session-1:a2',
'mistral-vibe:vibe-session-1:a3',
])
expect(turn.retries).toBe(1)
expect(session.totalCostUSD).toBeCloseTo(0.123456, 8)
expect(session.totalInputTokens).toBe(300)
expect(session.totalOutputTokens).toBe(90)
expect(session.categoryBreakdown[turn.category].oneShotTurns).toBe(0)
})
})
+608
View File
@@ -0,0 +1,608 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
import { isSqliteAvailable } from '../../src/sqlite.js'
import {
antigravityAppDataDirFromSourcePath,
antigravityCascadeIdFromPath,
createAntigravityProvider,
discoverAntigravitySessionSources,
extractAntigravityAppDataDirFromLine,
extractAntigravityGeneratorMetadata,
extractAntigravityModelMap,
getAntigravityStatusLineEventsPath,
parseAntigravityServerInfo,
parseAntigravityServerInfoFromLine,
recordAntigravityStatusLinePayload,
shouldReparseAntigravitySource,
} from '../../src/providers/antigravity.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
const requireForTest = createRequire(import.meta.url)
type CurrentCliFixture = {
conversationId: string
rows: Array<{ idx: number; hex: string }>
}
type TestDb = {
exec(sql: string): void
prepare(sql: string): { run(...params: unknown[]): void }
close(): void
}
function createCurrentAntigravityCliDb(dbPath: string, fixture: CurrentCliFixture): void {
const { DatabaseSync: Database } = requireForTest('node:sqlite')
const db = new Database(dbPath) as TestDb
try {
db.exec('CREATE TABLE gen_metadata (idx integer, data blob, size integer NOT NULL DEFAULT 0, PRIMARY KEY (idx))')
db.exec('CREATE TABLE trajectory_metadata_blob (id text DEFAULT "main", data blob, PRIMARY KEY (id))')
db.prepare('INSERT INTO trajectory_metadata_blob (id, data) VALUES (?, ?)').run(
'main',
Buffer.from('file:///Users/example/private-project'),
)
for (const row of fixture.rows) {
const data = Buffer.from(row.hex, 'hex')
db.prepare('INSERT INTO gen_metadata (idx, data, size) VALUES (?, ?, ?)').run(row.idx, data, data.length)
}
} finally {
db.close()
}
}
async function collectAntigravityCalls(source: { path: string; project: string; provider: string }): Promise<ParsedProviderCall[]> {
const parser = createAntigravityProvider().createSessionParser(source, new Set())
const calls: ParsedProviderCall[] = []
for await (const call of parser.parse()) calls.push(call)
return calls
}
describe('antigravity provider helpers', () => {
it('parses legacy https server flags from POSIX process args', () => {
const server = parseAntigravityServerInfoFromLine(
'/Applications/Antigravity.app/language_server_macos_arm --app_data_dir antigravity --https_server_port 57101 --csrf_token 01234567-89ab-cdef-0123-456789abcdef',
)
expect(server).toEqual({
port: 57101,
csrfToken: '01234567-89ab-cdef-0123-456789abcdef',
})
})
it('parses Windows extension server flags and equals syntax', () => {
const server = parseAntigravityServerInfoFromLine(
'C:\\Users\\Admin\\AppData\\Local\\Programs\\Antigravity\\resources\\app\\extensions\\antigravity\\bin\\language_server_windows_x64.exe --extension_server_port=62225 --extension_server_csrf_token=abcdef01-2345-6789-abcd-ef0123456789',
)
expect(server).toEqual({
port: 62225,
csrfToken: 'abcdef01-2345-6789-abcd-ef0123456789',
})
})
it('parses Windows extension server flags and space syntax', () => {
const server = parseAntigravityServerInfo([
'node something-unrelated',
'language_server_windows_x64.exe --app_data_dir C:\\Users\\Admin\\.gemini\\antigravity --extension_server_port 62300 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210',
])
expect(server).toEqual({
port: 62300,
csrfToken: 'fedcba98-7654-3210-fedc-ba9876543210',
})
})
it('parses quoted flag values', () => {
const server = parseAntigravityServerInfoFromLine(
'Antigravity language_server_windows_x64.exe --extension_server_port "62301" --extension_server_csrf_token "fedcba98-7654-3210-fedc-ba9876543211"',
)
expect(server).toEqual({
port: 62301,
csrfToken: 'fedcba98-7654-3210-fedc-ba9876543211',
})
})
it('normalizes app_data_dir from app and CLI process args', () => {
expect(extractAntigravityAppDataDirFromLine(
'language_server --app_data_dir antigravity --https_server_port 0 --csrf_token 01234567-89ab-cdef-0123-456789abcdef',
)).toBe('antigravity')
expect(extractAntigravityAppDataDirFromLine(
'language_server --app_data_dir /Users/dev/.gemini/antigravity-cli --https_server_port 0 --csrf_token 01234567-89ab-cdef-0123-456789abcdef',
)).toBe('antigravity-cli')
expect(extractAntigravityAppDataDirFromLine(
'language_server.exe --app_data_dir "C:\\Users\\Admin\\.gemini\\antigravity-cli" --extension_server_port 62225 --extension_server_csrf_token abcdef01-2345-6789-abcd-ef0123456789',
)).toBe('antigravity-cli')
expect(extractAntigravityAppDataDirFromLine(
'language_server_windows_x64.exe --app_data_dir antigravity-ide --extension_server_port 8720 --extension_server_csrf_token 39800f1b-343a-40b0-8eb5-850702450346',
)).toBe('antigravity-ide')
})
it('accepts Antigravity 2 ephemeral port zero', () => {
const server = parseAntigravityServerInfoFromLine(
'antigravity language_server_macos_arm --https_server_port 0 --csrf_token 01234567-89ab-cdef-0123-456789abcdef',
)
expect(server).toEqual({
port: 0,
csrfToken: '01234567-89ab-cdef-0123-456789abcdef',
})
})
it('matches language-server and antigravity markers case-insensitively', () => {
const server = parseAntigravityServerInfoFromLine(
'ANTIGRAVITY LANGUAGE_SERVER_WINDOWS_X64.EXE --extension_server_port 62302 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543212',
)
expect(server).toEqual({
port: 62302,
csrfToken: 'fedcba98-7654-3210-fedc-ba9876543212',
})
})
it('ignores process args without an antigravity marker', () => {
expect(parseAntigravityServerInfoFromLine(
'language_server --extension_server_port 62300 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210',
)).toBeNull()
})
it('ignores invalid ports', () => {
expect(parseAntigravityServerInfoFromLine(
'antigravity language_server --extension_server_port 99999 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210',
)).toBeNull()
})
it('ignores chained flag names as values', () => {
expect(parseAntigravityServerInfoFromLine(
'antigravity language_server --extension_server_port=--extension_server_csrf_token --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210',
)).toBeNull()
})
it('ignores implausibly short CSRF tokens', () => {
expect(parseAntigravityServerInfoFromLine(
'antigravity language_server --extension_server_port 62300 --extension_server_csrf_token short',
)).toBeNull()
})
it('extracts model maps from wrapped and unwrapped RPC responses', () => {
expect(extractAntigravityModelMap({
response: { models: { high: { model: 'MODEL_PLACEHOLDER_M7' } } },
})).toEqual({ MODEL_PLACEHOLDER_M7: 'high' })
expect(extractAntigravityModelMap({
models: { low: { model: 'MODEL_PLACEHOLDER_M8' } },
})).toEqual({ MODEL_PLACEHOLDER_M8: 'low' })
expect(extractAntigravityModelMap({
models: { bad: null, good: { model: 'MODEL_PLACEHOLDER_M9' } },
})).toEqual({ MODEL_PLACEHOLDER_M9: 'good' })
expect(extractAntigravityModelMap({
models: { 'gemini-3-flash-agent': { model: 'MODEL_PLACEHOLDER_M133', displayName: 'Gemini 3.5 Flash (High)' } },
})).toEqual({ MODEL_PLACEHOLDER_M133: 'gemini-3.5-flash-high' })
expect(extractAntigravityModelMap(null)).toEqual({})
})
it('extracts generator metadata from wrapped and unwrapped RPC responses', () => {
const metadata = [{
chatModel: {
model: 'gemini-3-pro',
usage: {
model: 'gemini-3-pro',
inputTokens: '10',
outputTokens: '4',
apiProvider: 'google',
},
},
}]
expect(extractAntigravityGeneratorMetadata({ response: { generatorMetadata: metadata } })).toEqual(metadata)
expect(extractAntigravityGeneratorMetadata({ generatorMetadata: metadata })).toEqual(metadata)
expect(extractAntigravityGeneratorMetadata({ response: { generatorMetadata: null } })).toEqual([])
expect(extractAntigravityGeneratorMetadata(null)).toEqual([])
})
it('derives cascade ids from legacy .pb and Antigravity 2 .db files', () => {
expect(antigravityCascadeIdFromPath('/tmp/123.pb')).toBe('123')
expect(antigravityCascadeIdFromPath('/tmp/456.db')).toBe('456')
expect(antigravityCascadeIdFromPath('/tmp/789.db-wal')).toBe('789.db-wal')
})
it('routes app and CLI source paths to matching Antigravity app data dirs', () => {
expect(antigravityAppDataDirFromSourcePath(
'/Users/dev/.gemini/antigravity/conversations/session.db',
)).toBe('antigravity')
expect(antigravityAppDataDirFromSourcePath(
'/Users/dev/.gemini/antigravity-cli/conversations/session.pb',
)).toBe('antigravity-cli')
expect(antigravityAppDataDirFromSourcePath(
'C:\\Users\\Admin\\.gemini\\antigravity-cli\\implicit\\session.pb',
)).toBe('antigravity-cli')
expect(antigravityAppDataDirFromSourcePath(
'/Users/dev/.gemini/antigravity-ide/conversations/session.db',
)).toBe('antigravity-ide')
expect(antigravityAppDataDirFromSourcePath(
'C:\\Users\\Admin\\.gemini\\antigravity-ide\\implicit\\session.pb',
)).toBe('antigravity-ide')
})
it('discovers legacy .pb files and Antigravity 2 .db files only', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-'))
try {
await writeFile(join(dir, 'legacy.pb'), '')
await writeFile(join(dir, 'antigravity-2.db'), '')
await writeFile(join(dir, 'uppercase.DB'), '')
await writeFile(join(dir, 'antigravity-2.db-wal'), '')
await mkdir(join(dir, 'directory.pb'))
const sources = await discoverAntigravitySessionSources([{
dir,
project: 'test-project',
extensions: ['.pb', '.db'],
}])
expect(sources).toEqual([
{ path: join(dir, 'antigravity-2.db'), project: 'test-project', provider: 'antigravity' },
{ path: join(dir, 'legacy.pb'), project: 'test-project', provider: 'antigravity' },
{ path: join(dir, 'uppercase.DB'), project: 'test-project', provider: 'antigravity' },
])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('discovers antigravity-ide conversation and implicit files', async () => {
const tempHome = await mkdtemp(join(tmpdir(), 'codeburn-home-'))
const conversationsDir = join(tempHome, '.gemini', 'antigravity-ide', 'conversations')
const implicitDir = join(tempHome, '.gemini', 'antigravity-ide', 'implicit')
await mkdir(conversationsDir, { recursive: true })
await mkdir(implicitDir, { recursive: true })
await writeFile(join(conversationsDir, 'session1.db'), '')
await writeFile(join(implicitDir, 'session2.pb'), '')
const roots = [
{
dir: conversationsDir,
project: 'antigravity-ide',
extensions: ['.pb', '.db'] as const,
},
{
dir: implicitDir,
project: 'antigravity-ide',
extensions: ['.pb'] as const,
},
]
const sources = await discoverAntigravitySessionSources(roots)
expect(sources).toEqual([
{ path: join(conversationsDir, 'session1.db'), project: 'antigravity-ide', provider: 'antigravity' },
{ path: join(implicitDir, 'session2.pb'), project: 'antigravity-ide', provider: 'antigravity' },
])
await rm(tempHome, { recursive: true, force: true })
})
it('displays Gemini 3.5 Flash thinking variants as the base model', () => {
const provider = createAntigravityProvider()
expect(provider.modelDisplayName('gemini-3.5-flash')).toBe('Gemini 3.5 Flash')
expect(provider.modelDisplayName('gemini-3.5-flash-high')).toBe('Gemini 3.5 Flash')
expect(provider.modelDisplayName('gemini-3.5-flash-medium')).toBe('Gemini 3.5 Flash')
expect(provider.modelDisplayName('gemini-3.5-flash-low')).toBe('Gemini 3.5 Flash')
expect(provider.modelDisplayName('Gemini 3.5 Flash (High)')).toBe('Gemini 3.5 Flash')
})
it('captures exact Antigravity CLI statusLine usage as fallback calls', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-statusline-'))
process.env['CODEBURN_CACHE_DIR'] = dir
try {
const payload = {
conversation_id: 'ce061468-2e2b-4c6f-bf4f-e072bd5fa986',
session_id: 'session-1',
cwd: '/workspace/project',
model: {
id: 'Gemini 3.5 Flash (High)',
display_name: 'Gemini 3.5 Flash (High)',
},
context_window: {
current_usage: {
input_tokens: 28407,
output_tokens: 137,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
}
expect(await recordAntigravityStatusLinePayload(payload)).toBe(true)
expect(await recordAntigravityStatusLinePayload(payload)).toBe(true)
const recorded = await readFile(getAntigravityStatusLineEventsPath(), 'utf-8')
expect(recorded).not.toContain('/workspace/project')
expect(JSON.parse(recorded.split(/\r?\n/)[0]!)).not.toHaveProperty('cwd')
const source = {
path: getAntigravityStatusLineEventsPath(),
project: 'antigravity-cli',
provider: 'antigravity',
}
const parser = createAntigravityProvider().createSessionParser(source, new Set())
const calls = []
for await (const call of parser.parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
provider: 'antigravity',
model: 'Gemini 3.5 Flash (High)',
inputTokens: 28407,
outputTokens: 137,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
sessionId: 'ce061468-2e2b-4c6f-bf4f-e072bd5fa986',
project: 'antigravity-cli',
})
expect(calls[0]!.projectPath).toBeUndefined()
expect(calls[0]!.costUSD).toBeGreaterThan(0)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('skips statusLine fallback calls when RPC cache already covered the conversation', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-statusline-rpc-dedup-'))
process.env['CODEBURN_CACHE_DIR'] = dir
try {
expect(await recordAntigravityStatusLinePayload({
conversation_id: 'rpc-covered-conversation',
session_id: 'session-1',
model: 'Gemini 3.5 Flash (High)',
context_window: {
current_usage: {
input_tokens: 1000,
output_tokens: 100,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
})).toBe(true)
const parser = createAntigravityProvider().createSessionParser({
path: getAntigravityStatusLineEventsPath(),
project: 'antigravity-cli',
provider: 'antigravity',
}, new Set(['antigravity:rpc-covered-conversation:0']))
const calls = []
for await (const call of parser.parse()) calls.push(call)
expect(calls).toEqual([])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('skips singleton statusLine snapshots and deltas monotonic usage', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-statusline-runs-'))
process.env['CODEBURN_CACHE_DIR'] = dir
const basePayload = {
conversation_id: 'statusline-runs',
session_id: 'session-1',
model: 'Gemini 3.5 Flash (High)',
}
const withUsage = (
input_tokens: number,
output_tokens: number,
cache_read_input_tokens = 0,
) => ({
...basePayload,
context_window: {
current_usage: {
input_tokens,
output_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens,
},
},
})
try {
expect(await recordAntigravityStatusLinePayload(withUsage(100, 10))).toBe(true)
expect(await recordAntigravityStatusLinePayload(withUsage(200, 20))).toBe(true)
expect(await recordAntigravityStatusLinePayload(withUsage(200, 20))).toBe(true)
expect(await recordAntigravityStatusLinePayload(withUsage(300, 30, 50))).toBe(true)
const parser = createAntigravityProvider().createSessionParser({
path: getAntigravityStatusLineEventsPath(),
project: 'antigravity-cli',
provider: 'antigravity',
}, new Set())
const calls = []
for await (const call of parser.parse()) calls.push(call)
expect(calls).toHaveLength(2)
expect(calls.map(call => [call.inputTokens, call.outputTokens, call.cacheReadInputTokens])).toEqual([
[200, 20, 0],
[100, 10, 50],
])
expect(calls.map(call => call.cachedInputTokens)).toEqual([0, 0])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('treats non-monotonic statusLine usage as a new request snapshot', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-statusline-reset-'))
process.env['CODEBURN_CACHE_DIR'] = dir
const payload = (
input_tokens: number,
output_tokens: number,
cache_read_input_tokens = 0,
) => ({
conversation_id: 'statusline-reset',
session_id: 'session-1',
model: 'Gemini 3.5 Flash (High)',
context_window: {
current_usage: {
input_tokens,
output_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens,
},
},
})
try {
expect(await recordAntigravityStatusLinePayload(payload(1000, 100))).toBe(true)
expect(await recordAntigravityStatusLinePayload(payload(1000, 100))).toBe(true)
expect(await recordAntigravityStatusLinePayload(payload(200, 30, 500))).toBe(true)
const parser = createAntigravityProvider().createSessionParser({
path: getAntigravityStatusLineEventsPath(),
project: 'antigravity-cli',
provider: 'antigravity',
}, new Set())
const calls = []
for await (const call of parser.parse()) calls.push(call)
expect(calls).toHaveLength(2)
expect(calls.map(call => [call.inputTokens, call.outputTokens, call.cacheReadInputTokens])).toEqual([
[1000, 100, 0],
[200, 30, 500],
])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('always reparses append-only statusLine sources but not unchanged cached cascades', () => {
const statusLinePath = getAntigravityStatusLineEventsPath()
expect(shouldReparseAntigravitySource(statusLinePath, 1)).toBe(true)
expect(shouldReparseAntigravitySource('/tmp/antigravity/conversation.pb', 0)).toBe(true)
expect(shouldReparseAntigravitySource('/tmp/antigravity/conversation.pb', 1)).toBe(false)
})
it('parses current Antigravity CLI SQLite conversations with non-zero token usage', async () => {
if (!isSqliteAvailable()) return
const tempHome = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-current-cli-'))
const cacheDir = join(tempHome, 'cache')
const previousCacheDir = process.env['CODEBURN_CACHE_DIR']
process.env['CODEBURN_CACHE_DIR'] = cacheDir
try {
const fixture = JSON.parse(await readFile(
new URL('../fixtures/antigravity-cli-current/gen-metadata.json', import.meta.url),
'utf-8',
)) as CurrentCliFixture
const conversationsDir = join(tempHome, '.gemini', 'antigravity-cli', 'conversations')
const logsDir = join(
tempHome,
'.gemini',
'antigravity-cli',
'brain',
fixture.conversationId,
'.system_generated',
'logs',
)
await mkdir(conversationsDir, { recursive: true })
await mkdir(logsDir, { recursive: true })
await writeFile(
join(logsDir, 'transcript.jsonl'),
await readFile(
new URL(
'../fixtures/antigravity-cli-current/brain/fixture-current-cli/.system_generated/logs/transcript.jsonl',
import.meta.url,
),
'utf-8',
),
)
const dbPath = join(conversationsDir, `${fixture.conversationId}.db`)
createCurrentAntigravityCliDb(dbPath, fixture)
const sources = await discoverAntigravitySessionSources([{
dir: conversationsDir,
project: 'antigravity-cli',
extensions: ['.pb', '.db'],
}])
expect(sources).toEqual([{ path: dbPath, project: 'antigravity-cli', provider: 'antigravity' }])
const calls = await collectAntigravityCalls(sources[0]!)
expect(calls.length).toBeGreaterThanOrEqual(1)
expect(calls[0]).toMatchObject({
provider: 'antigravity',
model: 'gemini-3.1-pro-high',
inputTokens: 30265,
outputTokens: 659,
reasoningTokens: 71,
sessionId: fixture.conversationId,
project: 'antigravity-cli',
})
expect(calls[0]!.projectPath).toBeUndefined()
expect(calls[0]!.costUSD).toBeGreaterThan(0)
} finally {
if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir
await rm(tempHome, { recursive: true, force: true })
}
})
it('deduplicates current SQLite rows against RPC response ids with hyphens', async () => {
if (!isSqliteAvailable()) return
const tempHome = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-current-cli-dedup-'))
const cacheDir = join(tempHome, 'cache')
const previousCacheDir = process.env['CODEBURN_CACHE_DIR']
process.env['CODEBURN_CACHE_DIR'] = cacheDir
try {
const fixture = JSON.parse(await readFile(
new URL('../fixtures/antigravity-cli-current/gen-metadata.json', import.meta.url),
'utf-8',
)) as CurrentCliFixture
const conversationsDir = join(tempHome, '.gemini', 'antigravity-cli', 'conversations')
await mkdir(conversationsDir, { recursive: true })
const dbPath = join(conversationsDir, `${fixture.conversationId}.db`)
createCurrentAntigravityCliDb(dbPath, fixture)
const parser = createAntigravityProvider().createSessionParser({
path: dbPath,
project: 'antigravity-cli',
provider: 'antigravity',
}, new Set([`antigravity:${fixture.conversationId}:fixture-response-1`]))
const calls = []
for await (const call of parser.parse()) calls.push(call)
expect(calls).toEqual([])
} finally {
if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir
await rm(tempHome, { recursive: true, force: true })
}
})
})
+377
View File
@@ -0,0 +1,377 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { delimiter as pathDelimiter, join } from 'path'
import { tmpdir, homedir } from 'os'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { claude } from '../../src/providers/claude.js'
import { clearSessionCache, filterProjectsByClaudeConfigSource, parseAllSessions } from '../../src/parser.js'
let tmpRoot: string
const savedEnv = {
CLAUDE_CONFIG_DIR: process.env['CLAUDE_CONFIG_DIR'],
CLAUDE_CONFIG_DIRS: process.env['CLAUDE_CONFIG_DIRS'],
HOME: process.env['HOME'],
}
beforeEach(async () => {
clearSessionCache()
tmpRoot = await mkdtemp(join(tmpdir(), 'codeburn-claude-multi-'))
// Point HOME at a scratch dir so the default `~/.claude` fallback resolves
// somewhere we control. Without this, a stray `~/.claude` on the test
// machine could leak into discovery.
process.env['HOME'] = join(tmpRoot, 'home')
await mkdir(process.env['HOME'], { recursive: true })
delete process.env['CLAUDE_CONFIG_DIR']
delete process.env['CLAUDE_CONFIG_DIRS']
})
afterEach(async () => {
clearSessionCache()
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) delete process.env[k]
else process.env[k] = v
}
await rm(tmpRoot, { recursive: true, force: true })
})
async function makeConfigDir(name: string, projectSlugs: string[]): Promise<string> {
const dir = join(tmpRoot, name)
for (const slug of projectSlugs) {
const projectDir = join(dir, 'projects', slug)
await mkdir(projectDir, { recursive: true })
// Discovery only checks for the project subdirectory. A real session
// file is not required; the parser is exercised separately below.
}
return dir
}
async function writeSession(configDir: string, slug: string, sessionId: string, lines: string[]): Promise<void> {
const dir = join(configDir, 'projects', slug)
await mkdir(dir, { recursive: true })
await writeFile(join(dir, `${sessionId}.jsonl`), lines.join('\n'))
}
function summaryLine(sessionId: string, cwd: string): string {
return JSON.stringify({
type: 'summary',
summary: 'test',
leafUuid: 'l',
sessionId,
cwd,
timestamp: '2026-05-09T00:00:00.000Z',
})
}
function userLine(uuid: string, sessionId: string, cwd: string, text: string): string {
return JSON.stringify({
type: 'user',
uuid,
sessionId,
cwd,
timestamp: '2026-05-09T00:00:01.000Z',
message: { role: 'user', content: text },
})
}
function assistantLine(uuid: string, parentUuid: string, sessionId: string, cwd: string): string {
return JSON.stringify({
type: 'assistant',
uuid,
parentUuid,
sessionId,
cwd,
timestamp: '2026-05-09T00:00:02.000Z',
message: {
id: `msg_${uuid}`,
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-6',
content: [{ type: 'text', text: 'reply' }],
usage: { input_tokens: 100, output_tokens: 50 },
},
})
}
describe('claude provider — CLAUDE_CONFIG_DIRS discovery', () => {
it('falls back to ~/.claude when no env var is set', async () => {
const homeDir = process.env['HOME']!
await mkdir(join(homeDir, '.claude', 'projects', '-Users-you-app'), { recursive: true })
const sources = await claude.discoverSessions()
const projectDirs = sources.map(s => s.path)
expect(projectDirs).toContain(join(homeDir, '.claude', 'projects', '-Users-you-app'))
})
it('honors CLAUDE_CONFIG_DIR for a single override', async () => {
const dir = await makeConfigDir('claude-work', ['-Users-you-app'])
process.env['CLAUDE_CONFIG_DIR'] = dir
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(dir, 'projects', '-Users-you-app'))).toBe(true)
// The default `~/.claude` should NOT also be scanned when the override is set.
expect(sources.every(s => !s.path.startsWith(join(process.env['HOME']!, '.claude')))).toBe(true)
})
it('CLAUDE_CONFIG_DIRS overrides CLAUDE_CONFIG_DIR and walks every dir in the list', async () => {
const work = await makeConfigDir('claude-work', ['-Users-you-app'])
const personal = await makeConfigDir('claude-personal', ['-Users-you-app'])
const single = await makeConfigDir('claude-other', ['-Users-you-other'])
process.env['CLAUDE_CONFIG_DIR'] = single
process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter)
const sources = await claude.discoverSessions()
const paths = sources.map(s => s.path)
expect(paths).toContain(join(work, 'projects', '-Users-you-app'))
expect(paths).toContain(join(personal, 'projects', '-Users-you-app'))
// CLAUDE_CONFIG_DIR should be ignored once CLAUDE_CONFIG_DIRS is non-empty.
expect(paths.some(p => p.startsWith(single))).toBe(false)
})
it('emits the same project name for the same slug across dirs (so parser merges)', async () => {
const work = await makeConfigDir('claude-work', ['-Users-you-app'])
const personal = await makeConfigDir('claude-personal', ['-Users-you-app'])
process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter)
const sources = await claude.discoverSessions()
const ourSources = sources.filter(s =>
s.path === join(work, 'projects', '-Users-you-app') ||
s.path === join(personal, 'projects', '-Users-you-app'),
)
expect(ourSources).toHaveLength(2)
expect(new Set(ourSources.map(s => s.project))).toEqual(new Set(['-Users-you-app']))
expect(new Set(ourSources.map(s => s.sourceKind))).toEqual(new Set(['claude-config']))
expect(new Set(ourSources.map(s => s.sourceLabel))).toEqual(new Set(['claude-work', 'claude-personal']))
expect(ourSources.every(s => typeof s.sourceId === 'string' && s.sourceId.startsWith('claude-config:'))).toBe(true)
})
it('tolerates a non-existent dir in the list without dropping the real ones', async () => {
const real = await makeConfigDir('claude-real', ['-Users-you-app'])
const fake = join(tmpRoot, 'does-not-exist')
process.env['CLAUDE_CONFIG_DIRS'] = [real, fake].join(pathDelimiter)
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(real, 'projects', '-Users-you-app'))).toBe(true)
})
it('dedupes when the same dir appears twice in CLAUDE_CONFIG_DIRS', async () => {
const dir = await makeConfigDir('claude-once', ['-Users-you-app'])
process.env['CLAUDE_CONFIG_DIRS'] = [dir, dir].join(pathDelimiter)
const sources = await claude.discoverSessions()
const ourSources = sources.filter(s => s.path === join(dir, 'projects', '-Users-you-app'))
expect(ourSources).toHaveLength(1)
})
it('skips empty entries (leading, trailing, doubled delimiters)', async () => {
const dir = await makeConfigDir('claude-only', ['-Users-you-app'])
process.env['CLAUDE_CONFIG_DIRS'] = `${pathDelimiter}${dir}${pathDelimiter}${pathDelimiter}`
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(dir, 'projects', '-Users-you-app'))).toBe(true)
})
it('expands ~ in CLAUDE_CONFIG_DIR', async () => {
const homeDir = process.env['HOME']!
await mkdir(join(homeDir, 'custom-claude', 'projects', '-Users-you-app'), { recursive: true })
process.env['CLAUDE_CONFIG_DIR'] = '~/custom-claude'
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(homeDir, 'custom-claude', 'projects', '-Users-you-app'))).toBe(true)
})
it('falls back to CLAUDE_CONFIG_DIR when CLAUDE_CONFIG_DIRS is set but empty', async () => {
const single = await makeConfigDir('claude-fallback', ['-Users-you-app'])
process.env['CLAUDE_CONFIG_DIR'] = single
process.env['CLAUDE_CONFIG_DIRS'] = ''
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(single, 'projects', '-Users-you-app'))).toBe(true)
})
it('skips entries that point at a file rather than a directory', async () => {
const real = await makeConfigDir('claude-real', ['-Users-you-app'])
const filePath = join(tmpRoot, 'not-a-dir.txt')
await writeFile(filePath, 'this is not a config dir')
process.env['CLAUDE_CONFIG_DIRS'] = [real, filePath].join(pathDelimiter)
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(real, 'projects', '-Users-you-app'))).toBe(true)
expect(sources.every(s => !s.path.startsWith(filePath))).toBe(true)
})
})
describe('claude provider — config.json claudeConfigDirs (menubar-driven)', () => {
async function writeConfigJson(value: unknown): Promise<void> {
const dir = join(process.env['HOME']!, '.config', 'codeburn')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'config.json'), JSON.stringify({ claudeConfigDirs: value }))
}
it('honors claudeConfigDirs from config.json when no env var is set', async () => {
const work = await makeConfigDir('claude-work', ['-Users-you-app'])
const personal = await makeConfigDir('claude-personal', ['-Users-you-app'])
await writeConfigJson([work, personal])
const sources = await claude.discoverSessions()
const paths = sources.map(s => s.path)
expect(paths).toContain(join(work, 'projects', '-Users-you-app'))
expect(paths).toContain(join(personal, 'projects', '-Users-you-app'))
})
it('lets env CLAUDE_CONFIG_DIRS override config.json', async () => {
const fromEnv = await makeConfigDir('claude-env', ['-Users-you-app'])
const fromFile = await makeConfigDir('claude-file', ['-Users-you-app'])
await writeConfigJson([fromFile])
process.env['CLAUDE_CONFIG_DIRS'] = fromEnv
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(fromEnv, 'projects', '-Users-you-app'))).toBe(true)
expect(sources.every(s => !s.path.startsWith(fromFile))).toBe(true)
})
it('lets env CLAUDE_CONFIG_DIR override config.json', async () => {
const fromEnv = await makeConfigDir('claude-env', ['-Users-you-app'])
const fromFile = await makeConfigDir('claude-file', ['-Users-you-app'])
await writeConfigJson([fromFile])
process.env['CLAUDE_CONFIG_DIR'] = fromEnv
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(fromEnv, 'projects', '-Users-you-app'))).toBe(true)
expect(sources.every(s => !s.path.startsWith(fromFile))).toBe(true)
})
it('falls back to ~/.claude when config.json claudeConfigDirs is empty', async () => {
const homeDir = process.env['HOME']!
await mkdir(join(homeDir, '.claude', 'projects', '-Users-you-app'), { recursive: true })
await writeConfigJson([])
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(homeDir, '.claude', 'projects', '-Users-you-app'))).toBe(true)
})
it('expands ~ in config.json entries', async () => {
const homeDir = process.env['HOME']!
await mkdir(join(homeDir, 'cfg-claude', 'projects', '-Users-you-app'), { recursive: true })
await writeConfigJson(['~/cfg-claude'])
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(homeDir, 'cfg-claude', 'projects', '-Users-you-app'))).toBe(true)
})
it('ignores non-string and blank entries in config.json', async () => {
const real = await makeConfigDir('claude-real', ['-Users-you-app'])
await writeConfigJson([real, 42, '', ' ', null])
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(real, 'projects', '-Users-you-app'))).toBe(true)
})
it('falls back to ~/.claude when claudeConfigDirs is not an array', async () => {
const homeDir = process.env['HOME']!
await mkdir(join(homeDir, '.claude', 'projects', '-Users-you-app'), { recursive: true })
await writeConfigJson('not-an-array')
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(homeDir, '.claude', 'projects', '-Users-you-app'))).toBe(true)
})
})
describe('claude parser — multi-dir aggregation (issue #208 option 1)', () => {
it('merges sessions from two config dirs into a single ProjectSummary when the canonical cwd matches', async () => {
const work = await makeConfigDir('claude-work', [])
const personal = await makeConfigDir('claude-personal', [])
process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter)
// Both accounts touch the same real project path. Same cwd -> same merge key.
const slug = '-Users-you-shared-app'
const cwd = '/Users/you/shared-app'
await writeSession(work, slug, 'sess-work', [
summaryLine('sess-work', cwd),
userLine('u1', 'sess-work', cwd, 'hi from work'),
assistantLine('a1', 'u1', 'sess-work', cwd),
])
await writeSession(personal, slug, 'sess-personal', [
summaryLine('sess-personal', cwd),
userLine('u2', 'sess-personal', cwd, 'hi from personal'),
assistantLine('a2', 'u2', 'sess-personal', cwd),
])
const projects = await parseAllSessions(undefined, 'claude')
const matches = projects.filter(p => p.project === slug)
expect(matches).toHaveLength(1)
expect(matches[0]!.totalApiCalls).toBe(2)
// Two sessions, one from each dir, both rolled up.
expect(matches[0]!.sessions.map(s => s.sessionId).sort()).toEqual(['sess-personal', 'sess-work'])
// No `account` or `accountPath` field should appear on the ProjectSummary
// — option 1 explicitly avoids attribution.
expect((matches[0]! as Record<string, unknown>)['account']).toBeUndefined()
expect((matches[0]! as Record<string, unknown>)['accountPath']).toBeUndefined()
})
it('keeps source metadata on merged sessions so one Claude config can be selected', async () => {
const work = await makeConfigDir('claude-work', [])
const personal = await makeConfigDir('claude-personal', [])
process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter)
const slug = '-Users-you-shared-app'
const cwd = '/Users/you/shared-app'
await writeSession(work, slug, 'sess-work', [
summaryLine('sess-work', cwd),
userLine('u1', 'sess-work', cwd, 'hi from work'),
assistantLine('a1', 'u1', 'sess-work', cwd),
])
await writeSession(personal, slug, 'sess-personal', [
summaryLine('sess-personal', cwd),
userLine('u2', 'sess-personal', cwd, 'hi from personal'),
assistantLine('a2', 'u2', 'sess-personal', cwd),
])
const projects = await parseAllSessions(undefined, 'claude')
const merged = projects.find(p => p.project === slug)
expect(merged).toBeDefined()
expect(merged!.sessions).toHaveLength(2)
const sourceIds = new Map(merged!.sessions.map(s => [s.source?.label, s.source?.id]))
const workSourceId = sourceIds.get('claude-work')
expect(workSourceId).toBeDefined()
const workOnly = filterProjectsByClaudeConfigSource(projects, workSourceId!)
expect(workOnly).toHaveLength(1)
expect(workOnly[0]!.project).toBe(slug)
expect(workOnly[0]!.totalApiCalls).toBe(1)
expect(workOnly[0]!.sessions.map(s => s.sessionId)).toEqual(['sess-work'])
expect(workOnly[0]!.sessions[0]!.source?.label).toBe('claude-work')
})
// Documents the path-aware merge behavior: the mergedMap in parseAllSessions
// now keys by normalized cwd path (crossProviderKey), not by slug. Two dirs
// can share the same slug but have different underlying cwds — those stay
// separate because they represent genuinely different repositories. In real
// Claude usage different cwds always produce different slugs anyway, so this
// scenario is contrived, but the test pins the new behavior explicitly.
it('keeps sessions with the same slug but different cwds as separate projects', async () => {
const work = await makeConfigDir('claude-work', [])
const personal = await makeConfigDir('claude-personal', [])
process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter)
const slug = '-Users-you-app'
await writeSession(work, slug, 'sess-work', [
summaryLine('sess-work', '/Users/you/work-app'),
userLine('u1', 'sess-work', '/Users/you/work-app', 'work'),
assistantLine('a1', 'u1', 'sess-work', '/Users/you/work-app'),
])
await writeSession(personal, slug, 'sess-personal', [
summaryLine('sess-personal', '/Users/you/personal-app'),
userLine('u2', 'sess-personal', '/Users/you/personal-app', 'personal'),
assistantLine('a2', 'u2', 'sess-personal', '/Users/you/personal-app'),
])
const projects = await parseAllSessions(undefined, 'claude')
const matches = projects.filter(p => p.project === slug)
// Different cwds → different crossProviderKey → two separate project rows.
expect(matches).toHaveLength(2)
expect(matches.every(m => m.totalApiCalls === 1)).toBe(true)
})
})
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm, utimes } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { cline, createClineProvider } from '../../src/providers/cline.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
async function writeTask(baseDir: string, taskId: string, opts?: {
tokensIn?: number
tokensOut?: number
model?: string
userMessage?: string
cost?: number
}): Promise<string> {
const taskDir = join(baseDir, 'tasks', taskId)
await mkdir(taskDir, { recursive: true })
const messages: unknown[] = []
if (opts?.userMessage) {
messages.push({ type: 'say', say: 'user_feedback', text: opts.userMessage, ts: 1700000000000 })
}
const usage: Record<string, unknown> = {
tokensIn: opts?.tokensIn ?? 100,
tokensOut: opts?.tokensOut ?? 50,
}
if (opts?.cost !== undefined) usage.cost = opts.cost
messages.push({ type: 'say', say: 'api_req_started', text: JSON.stringify(usage), ts: 1700000001000 })
const modelTag = opts?.model ? `<model>${opts.model}</model>` : ''
const history = [
{ role: 'user', content: [{ type: 'text', text: `hello\n<environment_details>\n${modelTag}\n</environment_details>` }] },
]
await writeFile(join(taskDir, 'ui_messages.json'), JSON.stringify(messages))
await writeFile(join(taskDir, 'api_conversation_history.json'), JSON.stringify(history))
return taskDir
}
describe('cline provider - discovery', () => {
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
it('discovers Cline tasks from VS Code globalStorage and home data roots', async () => {
const vscodeDir = join(tmpDir, 'globalStorage')
const homeDataDir = join(tmpDir, 'cline-data')
await writeTask(vscodeDir, 'task-vscode')
await writeTask(homeDataDir, 'task-home')
const provider = createClineProvider([vscodeDir, homeDataDir])
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(2)
expect(sessions.map(s => s.provider)).toEqual(['cline', 'cline'])
expect(sessions.map(s => s.project)).toEqual(['Cline', 'Cline'])
expect(sessions.map(s => s.path).sort()).toEqual([
join(homeDataDir, 'tasks', 'task-home'),
join(vscodeDir, 'tasks', 'task-vscode'),
].sort())
})
it('deduplicates the same task id across roots by keeping the newest task directory', async () => {
const vscodeDir = join(tmpDir, 'globalStorage')
const homeDataDir = join(tmpDir, 'cline-data')
const oldTask = await writeTask(vscodeDir, 'task-same')
const newTask = await writeTask(homeDataDir, 'task-same')
await utimes(join(oldTask, 'ui_messages.json'), new Date('2026-01-01T00:00:00Z'), new Date('2026-01-01T00:00:00Z'))
await utimes(join(newTask, 'ui_messages.json'), new Date('2026-02-01T00:00:00Z'), new Date('2026-02-01T00:00:00Z'))
const provider = createClineProvider([vscodeDir, homeDataDir])
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.path).toBe(newTask)
})
it('skips task directories without ui_messages.json', async () => {
const vscodeDir = join(tmpDir, 'globalStorage')
await mkdir(join(vscodeDir, 'tasks', 'task-no-ui'), { recursive: true })
const provider = createClineProvider(vscodeDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(0)
})
})
describe('cline provider - parsing', () => {
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
it('parses Cline usage with cline provider identity', async () => {
const taskDir = await writeTask(tmpDir, 'task-parse', {
tokensIn: 200,
tokensOut: 100,
model: 'anthropic/claude-sonnet-4-5',
userMessage: 'build the feature',
cost: 0.07,
})
const source = { path: taskDir, project: 'Cline', provider: 'cline' }
const calls: ParsedProviderCall[] = []
for await (const call of cline.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]!.provider).toBe('cline')
expect(calls[0]!.model).toBe('claude-sonnet-4-5')
expect(calls[0]!.inputTokens).toBe(200)
expect(calls[0]!.outputTokens).toBe(100)
expect(calls[0]!.costUSD).toBe(0.07)
expect(calls[0]!.userMessage).toBe('build the feature')
expect(calls[0]!.deduplicationKey).toMatch(/^cline:task-parse:/)
})
})
describe('cline provider - metadata', () => {
it('has correct name and displayName', () => {
expect(cline.name).toBe('cline')
expect(cline.displayName).toBe('Cline')
})
it('passes through model and tool display names', () => {
expect(cline.modelDisplayName('claude-sonnet-4-5')).toBe('claude-sonnet-4-5')
expect(cline.toolDisplayName('read_file')).toBe('read_file')
})
})
+480
View File
@@ -0,0 +1,480 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createCodebuffProvider } from '../../src/providers/codebuff.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'codebuff-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
type ToolBlock = {
type: 'tool'
toolName: string
input?: Record<string, unknown>
}
type TextBlock = { type: 'text'; content: string }
type Block = ToolBlock | TextBlock
type AiOpts = {
id?: string
credits?: number
timestamp?: string
blocks?: Block[]
metadata?: Record<string, unknown>
}
function aiMessage(opts: AiOpts = {}) {
const m: Record<string, unknown> = {
id: opts.id ?? 'msg-ai-1',
variant: 'ai',
content: '',
timestamp: opts.timestamp ?? '2026-04-14T10:00:30.000Z',
}
if (opts.blocks !== undefined) m['blocks'] = opts.blocks
if (opts.credits !== undefined) m['credits'] = opts.credits
if (opts.metadata !== undefined) m['metadata'] = opts.metadata
return m
}
function userMessage(content: string, timestamp?: string) {
return {
id: 'msg-user-1',
variant: 'user',
content,
timestamp: timestamp ?? '2026-04-14T10:00:10.000Z',
}
}
async function writeChat(
baseDir: string,
projectName: string,
chatId: string,
messages: unknown[],
runState?: unknown,
): Promise<string> {
const chatDir = join(baseDir, 'projects', projectName, 'chats', chatId)
await mkdir(chatDir, { recursive: true })
await writeFile(join(chatDir, 'chat-messages.json'), JSON.stringify(messages))
if (runState !== undefined) {
await writeFile(join(chatDir, 'run-state.json'), JSON.stringify(runState))
}
return chatDir
}
describe('codebuff provider - session discovery', () => {
it('discovers sessions under projects/<name>/chats/<chatId>/', async () => {
await writeChat(
tmpDir,
'myproject',
'2026-04-14T10-00-00.000Z',
[userMessage('hi'), aiMessage({ credits: 10 })],
{ sessionState: { projectContext: { cwd: '/Users/test/myproject' } } },
)
const provider = createCodebuffProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.provider).toBe('codebuff')
expect(sessions[0]!.project).toBe('myproject')
expect(sessions[0]!.path).toContain('2026-04-14T10-00-00.000Z')
})
it('uses the cwd basename from run-state.json when present', async () => {
await writeChat(
tmpDir,
'sanitized-folder',
'2026-04-14T11-00-00.000Z',
[aiMessage({ credits: 5 })],
{ sessionState: { projectContext: { cwd: '/Users/test/real-project' } } },
)
const provider = createCodebuffProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.project).toBe('real-project')
})
it('falls back to the folder name when run-state.json is missing', async () => {
await writeChat(tmpDir, 'fallback-project', '2026-04-14T12-00-00.000Z', [
aiMessage({ credits: 3 }),
])
const provider = createCodebuffProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.project).toBe('fallback-project')
})
it('discovers sessions across multiple projects', async () => {
await writeChat(tmpDir, 'proj-a', '2026-04-14T10-00-00.000Z', [aiMessage({ credits: 1 })])
await writeChat(tmpDir, 'proj-b', '2026-04-14T10-30-00.000Z', [aiMessage({ credits: 2 })])
const provider = createCodebuffProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(2)
const projects = sessions.map(s => s.project).sort()
expect(projects).toEqual(['proj-a', 'proj-b'])
})
it('returns empty for a non-existent directory', async () => {
const provider = createCodebuffProvider('/nonexistent/codebuff-path')
const sessions = await provider.discoverSessions()
expect(sessions).toEqual([])
})
it('skips chat folders without chat-messages.json', async () => {
const chatDir = join(tmpDir, 'projects', 'proj', 'chats', '2026-04-14T10-00-00.000Z')
await mkdir(chatDir, { recursive: true })
// No chat-messages.json created.
const provider = createCodebuffProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toEqual([])
})
})
describe('codebuff provider - JSONL parsing', () => {
it('yields one call per assistant message with credits, mapping codebuff tools to canonical names', async () => {
const chatDir = await writeChat(
tmpDir,
'proj',
'2026-04-14T10-00-00.000Z',
[
userMessage('implement the feature'),
aiMessage({
credits: 42,
metadata: {
runState: { sessionState: { mainAgentState: { agentType: 'base2' } } },
},
blocks: [
{ type: 'tool', toolName: 'read_files', input: {} },
{ type: 'tool', toolName: 'str_replace', input: {} },
{ type: 'tool', toolName: 'run_terminal_command', input: { command: 'npm test' } },
{ type: 'tool', toolName: 'suggest_followups', input: {} },
],
}),
],
)
const provider = createCodebuffProvider(tmpDir)
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
const call = calls[0]!
expect(call.provider).toBe('codebuff')
expect(call.model).toBe('codebuff-base2')
expect(call.userMessage).toBe('implement the feature')
// `suggest_followups` is intentionally dropped from the tool breakdown.
expect(call.tools).toEqual(['Read', 'Edit', 'Bash'])
expect(call.bashCommands).toContain('npm')
// Credits × $0.01 = $0.42 when token counts are absent.
expect(call.costUSD).toBeCloseTo(0.42, 6)
expect(call.inputTokens).toBe(0)
expect(call.outputTokens).toBe(0)
})
it('prefers direct metadata.usage tokens when available and still records credits', async () => {
const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [
aiMessage({
credits: 10,
metadata: {
model: 'claude-haiku-4-5-20251001',
usage: {
inputTokens: 5000,
outputTokens: 2000,
cacheCreationInputTokens: 1000,
cacheReadInputTokens: 500,
},
},
}),
])
const provider = createCodebuffProvider(tmpDir)
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
const call = calls[0]!
expect(call.model).toBe('claude-haiku-4-5-20251001')
expect(call.inputTokens).toBe(5000)
expect(call.outputTokens).toBe(2000)
expect(call.cacheCreationInputTokens).toBe(1000)
expect(call.cacheReadInputTokens).toBe(500)
expect(call.cachedInputTokens).toBe(500)
// With real token counts the calculated cost takes precedence over credits.
expect(call.costUSD).toBeGreaterThan(0)
})
it('falls back to providerOptions.codebuff.usage in the stashed RunState history', async () => {
const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [
aiMessage({
credits: 7,
metadata: {
runState: {
sessionState: {
mainAgentState: {
messageHistory: [
{ role: 'user' },
{
role: 'assistant',
providerOptions: {
codebuff: {
model: 'openai/gpt-4o',
usage: {
prompt_tokens: 2000,
completion_tokens: 800,
prompt_tokens_details: { cached_tokens: 400 },
},
},
},
},
],
},
},
},
},
}),
])
const provider = createCodebuffProvider(tmpDir)
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
expect(calls[0]!.model).toBe('openai/gpt-4o')
expect(calls[0]!.inputTokens).toBe(2000)
expect(calls[0]!.outputTokens).toBe(800)
expect(calls[0]!.cacheReadInputTokens).toBe(400)
})
it('skips assistant messages with no credits and no tokens', async () => {
const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [
aiMessage({ blocks: [{ type: 'text', content: 'mode-divider' }] }),
])
const provider = createCodebuffProvider(tmpDir)
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(0)
})
it('deduplicates calls seen across multiple parses', async () => {
const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [
aiMessage({ id: 'msg-dup', credits: 3 }),
])
const provider = createCodebuffProvider(tmpDir)
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
const seenKeys = new Set<string>()
const firstRun: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
firstRun.push(call)
}
const secondRun: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
secondRun.push(call)
}
expect(firstRun).toHaveLength(1)
expect(secondRun).toHaveLength(0)
})
it('yields one call per assistant message in a multi-turn chat, preserving user messages', async () => {
const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [
userMessage('first question'),
aiMessage({ id: 'a1', credits: 5, timestamp: '2026-04-14T10:00:30.000Z' }),
userMessage('second question', '2026-04-14T10:01:00.000Z'),
aiMessage({ id: 'a2', credits: 8, timestamp: '2026-04-14T10:01:30.000Z' }),
])
const provider = createCodebuffProvider(tmpDir)
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(2)
expect(calls[0]!.userMessage).toBe('first question')
expect(calls[0]!.costUSD).toBeCloseTo(0.05, 6)
expect(calls[1]!.userMessage).toBe('second question')
expect(calls[1]!.costUSD).toBeCloseTo(0.08, 6)
})
it('handles a missing chat-messages.json gracefully', async () => {
const provider = createCodebuffProvider(tmpDir)
const source = {
path: join(tmpDir, 'projects', 'missing', 'chats', 'nope'),
project: 'missing',
provider: 'codebuff',
}
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(0)
})
it('skips a malformed chat-messages.json without throwing', async () => {
const chatDir = join(tmpDir, 'projects', 'proj', 'chats', '2026-04-14T10-00-00.000Z')
await mkdir(chatDir, { recursive: true })
await writeFile(join(chatDir, 'chat-messages.json'), 'not-valid-json')
const provider = createCodebuffProvider(tmpDir)
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(0)
})
})
describe('codebuff provider - sessionId channel scoping', () => {
it('produces distinct sessionIds for the same chatId across different channel roots', async () => {
const chatId = '2026-04-14T10-00-00.000Z'
const channelA = join(tmpDir, 'manicode')
const channelB = join(tmpDir, 'manicode-dev')
const cwd = '/Users/test/shared-project'
const runState = { sessionState: { projectContext: { cwd } } }
const chatDirA = await writeChat(
channelA,
'shared-project',
chatId,
[userMessage('hi'), aiMessage({ credits: 5 })],
runState,
)
const chatDirB = await writeChat(
channelB,
'shared-project',
chatId,
[userMessage('hi'), aiMessage({ credits: 5 })],
runState,
)
const providerA = createCodebuffProvider(channelA)
const providerB = createCodebuffProvider(channelB)
const sourceA = { path: chatDirA, project: 'shared-project', provider: 'codebuff' }
const sourceB = { path: chatDirB, project: 'shared-project', provider: 'codebuff' }
const callsA: ParsedProviderCall[] = []
for await (const call of providerA.createSessionParser(sourceA, new Set()).parse()) {
callsA.push(call)
}
const callsB: ParsedProviderCall[] = []
for await (const call of providerB.createSessionParser(sourceB, new Set()).parse()) {
callsB.push(call)
}
expect(callsA).toHaveLength(1)
expect(callsB).toHaveLength(1)
// The whole point of the fix: same chatId + same project should NOT
// collapse into a single session when the chats live under different
// channel roots.
expect(callsA[0]!.sessionId).not.toBe(callsB[0]!.sessionId)
expect(callsA[0]!.sessionId).toBe(`manicode/${chatId}`)
expect(callsB[0]!.sessionId).toBe(`manicode-dev/${chatId}`)
// The sessionId must not contain ':' -- src/parser.ts keys sessions as
// `${provider}:${sessionId}:${project}` and reconstructs the session via
// `key.split(':')[1]`, so a colon would truncate the id downstream.
expect(callsA[0]!.sessionId).not.toContain(':')
expect(callsB[0]!.sessionId).not.toContain(':')
})
it('includes the channel name in the sessionId', async () => {
const chatId = '2026-04-14T10-00-00.000Z'
const channelRoot = join(tmpDir, 'manicode-staging')
const chatDir = await writeChat(channelRoot, 'proj', chatId, [aiMessage({ credits: 3 })])
const provider = createCodebuffProvider(channelRoot)
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
expect(calls[0]!.sessionId).toBe(`manicode-staging/${chatId}`)
expect(calls[0]!.sessionId).not.toContain(':')
})
it('falls back to the chatId when the path does not match the expected structure', async () => {
const chatId = '2026-04-14T10-00-00.000Z'
// Not the canonical <channel>/projects/<proj>/chats/<chatId> layout.
const chatDir = join(tmpDir, 'oddly-shaped', chatId)
await mkdir(chatDir, { recursive: true })
await writeFile(
join(chatDir, 'chat-messages.json'),
JSON.stringify([aiMessage({ credits: 2 })]),
)
const provider = createCodebuffProvider(tmpDir)
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
expect(calls[0]!.sessionId).toBe(chatId)
})
})
describe('codebuff provider - display names', () => {
const provider = createCodebuffProvider('/tmp')
it('has the correct identifiers', () => {
expect(provider.name).toBe('codebuff')
expect(provider.displayName).toBe('Codebuff')
})
it('maps known Codebuff tiers to readable names', () => {
expect(provider.modelDisplayName('codebuff')).toBe('Codebuff')
expect(provider.modelDisplayName('codebuff-base2')).toBe('Codebuff Base 2')
expect(provider.modelDisplayName('codebuff-lite')).toBe('Codebuff Lite')
})
it('returns the raw name for unknown models', () => {
expect(provider.modelDisplayName('claude-sonnet-4-6')).toBe('claude-sonnet-4-6')
})
it('normalizes tool names to the canonical set', () => {
expect(provider.toolDisplayName('read_files')).toBe('Read')
expect(provider.toolDisplayName('str_replace')).toBe('Edit')
expect(provider.toolDisplayName('run_terminal_command')).toBe('Bash')
expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool')
})
})
+593
View File
@@ -0,0 +1,593 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createCodexProvider } from '../../src/providers/codex.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'codex-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
function sessionMeta(opts: { cwd?: string; originator?: string; session_id?: string; model?: string; forked_from_id?: string; timestamp?: string } = {}) {
return JSON.stringify({
type: 'session_meta',
timestamp: opts.timestamp ?? '2026-04-14T10:00:00Z',
payload: {
cwd: opts.cwd ?? '/Users/test/myproject',
originator: opts.originator ?? 'codex-cli',
session_id: opts.session_id ?? 'sess-001',
model: opts.model ?? 'gpt-5.3-codex',
...(opts.forked_from_id ? { forked_from_id: opts.forked_from_id } : {}),
},
})
}
function tokenCount(opts: {
timestamp?: string
last?: { input?: number; cached?: number; output?: number; reasoning?: number }
total?: { input?: number; cached?: number; output?: number; reasoning?: number; total?: number }
model?: string
}) {
return JSON.stringify({
type: 'event_msg',
timestamp: opts.timestamp ?? '2026-04-14T10:01:00Z',
payload: {
type: 'token_count',
info: {
model: opts.model,
last_token_usage: opts.last ? {
input_tokens: opts.last.input ?? 0,
cached_input_tokens: opts.last.cached ?? 0,
output_tokens: opts.last.output ?? 0,
reasoning_output_tokens: opts.last.reasoning ?? 0,
total_tokens: (opts.last.input ?? 0) + (opts.last.cached ?? 0) + (opts.last.output ?? 0) + (opts.last.reasoning ?? 0),
} : undefined,
total_token_usage: opts.total ? {
input_tokens: opts.total.input ?? 0,
cached_input_tokens: opts.total.cached ?? 0,
output_tokens: opts.total.output ?? 0,
reasoning_output_tokens: opts.total.reasoning ?? 0,
total_tokens: opts.total.total ?? ((opts.total.input ?? 0) + (opts.total.cached ?? 0) + (opts.total.output ?? 0) + (opts.total.reasoning ?? 0)),
} : undefined,
},
},
})
}
function functionCall(name: string, timestamp?: string) {
return JSON.stringify({
type: 'response_item',
timestamp: timestamp ?? '2026-04-14T10:00:30Z',
payload: { type: 'function_call', name },
})
}
function mcpToolCallEnd(server: string, tool: string, timestamp?: string) {
return JSON.stringify({
type: 'event_msg',
timestamp: timestamp ?? '2026-04-14T10:00:30Z',
payload: {
type: 'mcp_tool_call_end',
call_id: 'call-1',
invocation: { server, tool, arguments: {} },
duration: '1.2s',
result: { Ok: { content: [] } },
},
})
}
function userMessage(text: string, timestamp?: string) {
return JSON.stringify({
type: 'response_item',
timestamp: timestamp ?? '2026-04-14T10:00:00Z',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text }],
},
})
}
async function writeSession(dir: string, date: string, filename: string, lines: string[]) {
const [year, month, day] = date.split('-')
const sessionDir = join(dir, 'sessions', year!, month!, day!)
await mkdir(sessionDir, { recursive: true })
const filePath = join(sessionDir, filename)
await writeFile(filePath, lines.join('\n') + '\n')
return filePath
}
describe('codex provider - model display names', () => {
it('maps gpt-5.3-codex-spark to its own label', () => {
const provider = createCodexProvider(tmpDir)
const name = provider.modelDisplayName('gpt-5.3-codex-spark')
expect(name).not.toBe('GPT-5.3 Codex')
expect(name).toBe('GPT-5.3 Codex Spark')
})
it('maps gpt-5.3-codex reasoning suffixes to the base label', () => {
const provider = createCodexProvider(tmpDir)
expect(provider.modelDisplayName('gpt-5.3-codex-high')).toBe('GPT-5.3 Codex')
expect(provider.modelDisplayName('gpt-5.3-codex-low')).toBe('GPT-5.3 Codex')
})
})
describe('codex provider - session discovery', () => {
it('discovers sessions in YYYY/MM/DD structure', async () => {
await writeSession(tmpDir, '2026-04-14', 'rollout-abc123.jsonl', [
sessionMeta({ cwd: '/Users/test/myproject' }),
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
])
const provider = createCodexProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.provider).toBe('codex')
expect(sessions[0]!.project).toBe('Users-test-myproject')
expect(sessions[0]!.path).toContain('rollout-abc123.jsonl')
})
it('returns empty for non-existent directory', async () => {
const provider = createCodexProvider('/nonexistent/path/that/does/not/exist')
const sessions = await provider.discoverSessions()
expect(sessions).toEqual([])
})
it('accepts case-insensitive originator (Codex Desktop)', async () => {
await writeSession(tmpDir, '2026-04-14', 'rollout-desktop.jsonl', [
sessionMeta({ originator: 'Codex Desktop' }),
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
])
const provider = createCodexProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
})
it('accepts session_meta lines larger than 16 KB (Codex CLI 0.128+)', async () => {
// Codex CLI 0.128+ embeds the full base_instructions / system prompt in the
// first session_meta line, often pushing it past 20 KB. Regression guard
// against a fixed-size buffer in readFirstLine.
const bigPayload = JSON.stringify({
type: 'session_meta',
timestamp: '2026-05-02T00:00:00Z',
payload: {
cwd: '/Users/test/big',
originator: 'codex-tui',
session_id: 'sess-big',
model: 'gpt-5.5',
base_instructions: { text: 'x'.repeat(40_000) },
},
})
await writeSession(tmpDir, '2026-05-02', 'rollout-big.jsonl', [
bigPayload,
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
])
const provider = createCodexProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.path).toContain('rollout-big.jsonl')
// Confirm the large meta line was actually parsed (cwd extracted),
// not just that some path was registered.
expect(sessions[0]!.project).toBe('Users-test-big')
})
it('handles a session_meta line without trailing newline', async () => {
const [year, month, day] = '2026-05-02'.split('-')
const sessionDir = join(tmpDir, 'sessions', year!, month!, day!)
await mkdir(sessionDir, { recursive: true })
// Write a single session_meta line, deliberately without a trailing \n.
await writeFile(
join(sessionDir, 'rollout-no-nl.jsonl'),
JSON.stringify({
type: 'session_meta',
timestamp: '2026-05-02T00:00:00Z',
payload: {
cwd: '/Users/test/nonl',
originator: 'codex-tui',
session_id: 'sess-nonl',
model: 'gpt-5.5',
},
}),
)
const provider = createCodexProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.project).toBe('Users-test-nonl')
})
it('handles a session_meta line that spans multiple stream chunks', async () => {
// createReadStream defaults to a 64 KiB highWaterMark, so a >64 KiB first
// line forces readline to assemble the line across chunk boundaries.
const bigPayload = JSON.stringify({
type: 'session_meta',
timestamp: '2026-05-02T00:00:00Z',
payload: {
cwd: '/Users/test/multichunk',
originator: 'codex-tui',
session_id: 'sess-multichunk',
model: 'gpt-5.5',
base_instructions: { text: 'y'.repeat(120_000) },
},
})
await writeSession(tmpDir, '2026-05-02', 'rollout-multichunk.jsonl', [
bigPayload,
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
])
const provider = createCodexProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.project).toBe('Users-test-multichunk')
})
it('rejects truncated/torn first-line writes without throwing', async () => {
// Simulate a partial write where Codex started the session_meta object
// but hasn't flushed the rest yet (no closing brace, no newline).
const [year, month, day] = '2026-05-02'.split('-')
const sessionDir = join(tmpDir, 'sessions', year!, month!, day!)
await mkdir(sessionDir, { recursive: true })
await writeFile(
join(sessionDir, 'rollout-torn.jsonl'),
'{"type":"session_meta","timestamp":"2026-05-02T00:00:00Z","payload":{"cwd":"/x","originator":"codex-tui","session_id":"s","model":"gpt',
)
const provider = createCodexProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(0)
})
it('returns no sessions for an empty rollout file', async () => {
const [year, month, day] = '2026-05-02'.split('-')
const sessionDir = join(tmpDir, 'sessions', year!, month!, day!)
await mkdir(sessionDir, { recursive: true })
await writeFile(join(sessionDir, 'rollout-empty.jsonl'), '')
const provider = createCodexProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(0)
})
it('skips files without codex session_meta', async () => {
const [year, month, day] = '2026-04-14'.split('-')
const sessionDir = join(tmpDir, 'sessions', year!, month!, day!)
await mkdir(sessionDir, { recursive: true })
await writeFile(
join(sessionDir, 'rollout-bad.jsonl'),
JSON.stringify({ type: 'other', payload: {} }) + '\n',
)
const provider = createCodexProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toEqual([])
})
})
describe('codex provider - JSONL parsing', () => {
it('extracts token usage from last_token_usage', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-parse.jsonl', [
sessionMeta({ session_id: 'sess-parse', model: 'gpt-5.3-codex' }),
userMessage('fix the bug'),
functionCall('exec_command'),
functionCall('read_file'),
tokenCount({
timestamp: '2026-04-14T10:01:00Z',
last: { input: 500, cached: 100, output: 200, reasoning: 50 },
total: { total: 850 },
}),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const parser = provider.createSessionParser(source, new Set())
const calls: ParsedProviderCall[] = []
for await (const call of parser.parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
const call = calls[0]!
expect(call.provider).toBe('codex')
expect(call.model).toBe('gpt-5.3-codex')
expect(call.inputTokens).toBe(400)
expect(call.cachedInputTokens).toBe(100)
expect(call.cacheReadInputTokens).toBe(100)
expect(call.outputTokens).toBe(200)
expect(call.reasoningTokens).toBe(50)
expect(call.tools).toEqual(['Bash', 'Read'])
expect(call.userMessage).toBe('fix the bug')
expect(call.sessionId).toBe('sess-parse')
expect(call.costUSD).toBeGreaterThan(0)
expect(call.deduplicationKey).toContain('codex:')
})
it('attributes MCP calls emitted as event_msg/mcp_tool_call_end', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp.jsonl', [
sessionMeta({ session_id: 'sess-mcp', model: 'gpt-5.5' }),
userMessage('look up the issue'),
mcpToolCallEnd('github', 'get_issue'),
tokenCount({
timestamp: '2026-04-14T10:01:00Z',
last: { input: 300, output: 100 },
total: { total: 400 },
}),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const parser = provider.createSessionParser(source, new Set())
const calls: ParsedProviderCall[] = []
for await (const call of parser.parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
expect(calls[0]!.tools).toEqual(['mcp__github__get_issue'])
})
it('attributes CLI-wrapped MCP calls (mcp-cli call server tool) to MCP + Bash', async () => {
const execStr = (command: string) => JSON.stringify({
type: 'response_item',
timestamp: '2026-04-14T10:00:30Z',
payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command }) },
})
// command as an array (Codex sometimes logs argv form).
const execArr = (command: string[]) => JSON.stringify({
type: 'response_item',
timestamp: '2026-04-14T10:00:30Z',
payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command }) },
})
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcpcli.jsonl', [
sessionMeta({ session_id: 'sess-mcpcli', model: 'gpt-5.5' }),
userMessage('look up an issue via the MCP CLI'),
// Real invocation forms that MUST attribute to MCP:
execStr("bash -lc \"mcp-cli call github get_issue '{\\\"id\\\": 5}'\""), // bash -lc wrapper
execStr('mcp-cli -c ./mcp.json call linear list_issues'), // flags before subcommand
execArr(['mcp-cli', 'call', 'slack', 'post_message', '{}']), // argv array form
// Lookups and unrelated commands that must NOT attribute:
execStr('mcp-cli info github'),
execStr('mcp-cli grep "*issue*"'),
execStr('my-mcp-cli-wrapper call github get_issue'), // not the mcp-cli binary
execStr('ls -la'),
tokenCount({ timestamp: '2026-04-14T10:01:00Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const parser = provider.createSessionParser(source, new Set())
const calls: ParsedProviderCall[] = []
for await (const call of parser.parse()) calls.push(call)
expect(calls).toHaveLength(1)
const tools = calls[0]!.tools
// Every exec still counts as Bash (7 exec_commands total).
expect(tools.filter(t => t === 'Bash')).toHaveLength(7)
// Exactly the three `call` invocations attribute to MCP; info/grep/wrapper/ls do not.
expect(tools.filter(t => t.startsWith('mcp__')).sort()).toEqual([
'mcp__github__get_issue',
'mcp__linear__list_issues',
'mcp__slack__post_message',
])
})
it('normalizes Codex subagent tool calls to Agent', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-agent.jsonl', [
sessionMeta({ session_id: 'sess-agent', model: 'gpt-5.5' }),
userMessage('delegate the review'),
functionCall('spawn_agent'),
functionCall('wait_agent'),
functionCall('close_agent'),
tokenCount({
timestamp: '2026-04-14T10:01:00Z',
last: { input: 300, output: 100 },
total: { total: 400 },
}),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const parser = provider.createSessionParser(source, new Set())
const calls: ParsedProviderCall[] = []
for await (const call of parser.parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
expect(calls[0]!.tools).toEqual(['Agent', 'Agent', 'Agent'])
})
it('skips duplicate token_count events', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-dedup.jsonl', [
sessionMeta(),
tokenCount({
timestamp: '2026-04-14T10:01:00Z',
last: { input: 500, output: 200 },
total: { total: 700 },
}),
tokenCount({
timestamp: '2026-04-14T10:01:01Z',
last: { input: 500, output: 200 },
total: { total: 700 },
}),
tokenCount({
timestamp: '2026-04-14T10:02:00Z',
last: { input: 300, output: 100 },
total: { total: 1100 },
}),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const parser = provider.createSessionParser(source, new Set())
const calls: ParsedProviderCall[] = []
for await (const call of parser.parse()) {
calls.push(call)
}
expect(calls).toHaveLength(2)
expect(calls[0]!.inputTokens).toBe(500)
expect(calls[1]!.inputTokens).toBe(300)
})
it('does not drop the first event when total_token_usage is omitted (cumulativeTotal=0)', async () => {
// Regression for the prevCumulativeTotal-initialized-to-0 bug. Sessions
// that emit only last_token_usage (no total_token_usage) report
// cumulativeTotal=0 on every event. With a 0-initialized prev, the first
// event matched the dedup guard and was silently dropped, losing the
// session's opening turn. The null sentinel fixes this.
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-zero-total.jsonl', [
sessionMeta(),
tokenCount({
timestamp: '2026-04-14T10:01:00Z',
last: { input: 500, output: 200 },
// No `total` — info.total_token_usage will be undefined.
}),
tokenCount({
timestamp: '2026-04-14T10:01:01Z',
last: { input: 100, output: 50 },
}),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const parser = provider.createSessionParser(source, new Set())
const calls: ParsedProviderCall[] = []
for await (const call of parser.parse()) {
calls.push(call)
}
// Both events should produce calls — the first with input=500, second
// with input=100. With the buggy 0-init, only the second would survive
// (or neither, depending on equality timing).
expect(calls.length).toBeGreaterThanOrEqual(1)
expect(calls[0]!.inputTokens).toBe(500)
})
it('still dedups consecutive zero-cumulative duplicates', async () => {
// The other half of the regression: two consecutive events with the
// same cumulativeTotal (here both 0 because total_token_usage is
// omitted) and identical last_token_usage must NOT both ingest. The
// second is a duplicate.
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-zero-dup.jsonl', [
sessionMeta(),
tokenCount({
timestamp: '2026-04-14T10:01:00Z',
last: { input: 500, output: 200 },
}),
tokenCount({
timestamp: '2026-04-14T10:01:01Z',
last: { input: 500, output: 200 },
}),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const parser = provider.createSessionParser(source, new Set())
const calls: ParsedProviderCall[] = []
for await (const call of parser.parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
})
})
describe('codex provider - forked session dedupe', () => {
// Aggregate every discovered session through ONE shared seenKeys, exactly as
// the real provider report does, then sum the global token total.
async function aggregateTokens(dir: string): Promise<{ tokens: number; calls: number }> {
const provider = createCodexProvider(dir)
const sessions = (await provider.discoverSessions()).sort((a, b) => (a.path < b.path ? -1 : 1))
const seenKeys = new Set<string>()
let tokens = 0
let calls = 0
for (const s of sessions) {
for await (const c of provider.createSessionParser(s, seenKeys).parse()) {
calls++
tokens += c.inputTokens + c.outputTokens + c.cachedInputTokens + c.reasoningTokens
}
}
return { tokens, calls }
}
it('does not double-count a fork that replays the parent past the 5s cutoff', async () => {
// Parent does 1100 tokens of real work. The fork replays both events with
// timestamps well beyond the 5s fork cutoff, then adds one genuine event
// (+400). The replays must collide with the parent and drop, so the global
// total is 1500 -- not 2600 (which keying on the fork's own session id would
// produce by double-counting the replayed history).
await writeSession(tmpDir, '2026-04-14', 'rollout-1-parent.jsonl', [
sessionMeta({ session_id: 'sess-parent' }),
tokenCount({ timestamp: '2026-04-14T10:00:01Z', last: { input: 700 }, total: { total: 700 } }),
tokenCount({ timestamp: '2026-04-14T10:00:02Z', last: { input: 400 }, total: { total: 1100 } }),
])
await writeSession(tmpDir, '2026-04-14', 'rollout-2-fork.jsonl', [
sessionMeta({ session_id: 'sess-fork', forked_from_id: 'sess-parent' }),
tokenCount({ timestamp: '2026-04-14T10:00:10Z', last: { input: 700 }, total: { total: 700 } }),
tokenCount({ timestamp: '2026-04-14T10:00:11Z', last: { input: 400 }, total: { total: 1100 } }),
tokenCount({ timestamp: '2026-04-14T10:00:12Z', last: { input: 400 }, total: { total: 1500 } }),
])
const { tokens } = await aggregateTokens(tmpDir)
expect(tokens).toBe(1500)
})
it('keeps a genuine divergent fork event that shares a cumulative total with the parent', async () => {
// Parent reaches cumulative 1600 via input (last input 500). The fork replays
// 700 and 1100, then does genuinely different work that also reaches
// cumulative 1600 but via OUTPUT (last output 500). Keying on cumulativeTotal
// alone would collide the fork's 1600 with the parent's 1600 and drop it
// (undercount, losing 500). The content-addressed key keeps both.
await writeSession(tmpDir, '2026-04-14', 'rollout-1-parent.jsonl', [
sessionMeta({ session_id: 'sess-parent' }),
tokenCount({ timestamp: '2026-04-14T10:00:01Z', last: { input: 700 }, total: { total: 700 } }),
tokenCount({ timestamp: '2026-04-14T10:00:02Z', last: { input: 400 }, total: { total: 1100 } }),
tokenCount({ timestamp: '2026-04-14T10:00:03Z', last: { input: 500 }, total: { input: 1600, total: 1600 } }),
])
await writeSession(tmpDir, '2026-04-14', 'rollout-2-fork.jsonl', [
sessionMeta({ session_id: 'sess-fork', forked_from_id: 'sess-parent' }),
tokenCount({ timestamp: '2026-04-14T10:00:10Z', last: { input: 700 }, total: { total: 700 } }),
tokenCount({ timestamp: '2026-04-14T10:00:11Z', last: { input: 400 }, total: { total: 1100 } }),
tokenCount({ timestamp: '2026-04-14T10:00:12Z', last: { output: 500 }, total: { input: 1100, output: 500, total: 1600 } }),
])
const { tokens } = await aggregateTokens(tmpDir)
// parent 1600 + fork's genuine +500 = 2100; replays (700, 1100) dropped.
expect(tokens).toBe(2100)
})
it('does not overcount a total-only fork whose replay straddles the 5s cutoff', async () => {
// The dedupe key must be derived from the cumulative token breakdown, not
// per-event deltas. In the fallback branch (events with total_token_usage
// but no last_token_usage), the delta is computed against a running `prev`.
// A fork skips replays within 5s of the fork (prev NOT advanced), so a
// replay kept just past the cutoff would compute a different delta than the
// parent did and, with a delta-based key, fail to dedupe -> double-count.
// The cumulative totals are copied verbatim, so a cumulative-based key
// collides regardless of the cutoff. Parent does 300 tokens; the fork is a
// pure replay (no new work), so the global total must stay 300.
await writeSession(tmpDir, '2026-04-14', 'rollout-1-parent.jsonl', [
sessionMeta({ session_id: 'sess-parent' }),
tokenCount({ timestamp: '2026-04-14T10:00:01Z', total: { input: 100, total: 100 } }),
tokenCount({ timestamp: '2026-04-14T10:00:02Z', total: { input: 200, total: 200 } }),
tokenCount({ timestamp: '2026-04-14T10:00:03Z', total: { input: 300, total: 300 } }),
])
await writeSession(tmpDir, '2026-04-14', 'rollout-2-fork.jsonl', [
sessionMeta({ session_id: 'sess-fork', forked_from_id: 'sess-parent' }),
// 10:00:01 is within the 5s cutoff -> skipped (prev not advanced).
tokenCount({ timestamp: '2026-04-14T10:00:01Z', total: { input: 100, total: 100 } }),
// These land past the cutoff and replay the parent's cumulative totals.
tokenCount({ timestamp: '2026-04-14T10:00:08Z', total: { input: 200, total: 200 } }),
tokenCount({ timestamp: '2026-04-14T10:00:09Z', total: { input: 300, total: 300 } }),
])
const { tokens } = await aggregateTokens(tmpDir)
expect(tokens).toBe(300)
})
})
File diff suppressed because it is too large Load Diff
+317
View File
@@ -0,0 +1,317 @@
import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'
import { mkdirSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { createRequire } from 'node:module'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { isSqliteAvailable } from '../../src/sqlite.js'
import { createCrushProvider } from '../../src/providers/crush.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
const requireForTest = createRequire(import.meta.url)
type TestDb = {
exec(sql: string): void
prepare(sql: string): { run(...params: unknown[]): void }
close(): void
}
let tmpRoot: string
beforeEach(async () => {
tmpRoot = await mkdtemp(join(tmpdir(), 'crush-test-'))
})
afterEach(async () => {
await rm(tmpRoot, { recursive: true, force: true })
})
// CREATE TABLE statements taken verbatim from charmbracelet/crush@v0.66.1
// internal/db/migrations/20250424200609_initial.sql, with subsequent ALTERs
// folded in (summary_message_id, provider on messages, is_summary_message,
// todos on sessions). Keeping the literal upstream column ordering and
// constraints makes drift easy to spot.
function createCrushDb(dir: string): string {
mkdirSync(dir, { recursive: true })
const dbPath = join(dir, 'crush.db')
const { DatabaseSync: Database } = requireForTest('node:sqlite')
const db = new Database(dbPath)
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
parent_session_id TEXT,
title TEXT NOT NULL,
message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0),
prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0),
completion_tokens INTEGER NOT NULL DEFAULT 0 CHECK (completion_tokens >= 0),
cost REAL NOT NULL DEFAULT 0.0 CHECK (cost >= 0.0),
updated_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
summary_message_id TEXT,
todos TEXT
)
`)
db.exec(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
parts TEXT NOT NULL DEFAULT '[]',
model TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
finished_at INTEGER,
provider TEXT,
is_summary_message INTEGER DEFAULT 0 NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
)
`)
db.close()
return dbPath
}
function withTestDb(dbPath: string, fn: (db: TestDb) => void): void {
const { DatabaseSync: Database } = requireForTest('node:sqlite')
const db = new Database(dbPath)
try {
fn(db)
} finally {
db.close()
}
}
type SessionFixture = {
id: string
parentId?: string | null
promptTokens?: number
completionTokens?: number
cost?: number
createdAt?: number
updatedAt?: number
messageCount?: number
}
function insertSession(db: TestDb, s: SessionFixture): void {
db.prepare(`
INSERT INTO sessions (id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
s.id,
s.parentId ?? null,
'test session',
s.messageCount ?? 0,
s.promptTokens ?? 0,
s.completionTokens ?? 0,
s.cost ?? 0,
s.createdAt ?? 1_700_000_000,
s.updatedAt ?? s.createdAt ?? 1_700_000_000,
)
}
function insertMessage(db: TestDb, sessionId: string, role: string, model: string | null, id: string): void {
db.prepare(`
INSERT INTO messages (id, session_id, role, parts, model, created_at, updated_at)
VALUES (?, ?, ?, '[]', ?, ?, ?)
`).run(id, sessionId, role, model, 1_700_000_000, 1_700_000_000)
}
async function writeRegistry(globalDataDir: string, entries: Record<string, { path: string; data_dir: string }>): Promise<void> {
await mkdir(globalDataDir, { recursive: true })
await writeFile(join(globalDataDir, 'projects.json'), JSON.stringify(entries))
}
async function collect(parser: { parse(): AsyncGenerator<ParsedProviderCall> }): Promise<ParsedProviderCall[]> {
const out: ParsedProviderCall[] = []
for await (const call of parser.parse()) out.push(call)
return out
}
describe('crush provider', () => {
it('reports correct identity', () => {
const p = createCrushProvider()
expect(p.name).toBe('crush')
expect(p.displayName).toBe('Crush')
expect(p.modelDisplayName('gpt-5')).toBe('gpt-5')
})
it('returns no sessions when registry is missing', async () => {
const globalData = join(tmpRoot, 'crush-global')
process.env['CRUSH_GLOBAL_DATA'] = globalData
const p = createCrushProvider()
const sessions = await p.discoverSessions()
expect(sessions).toEqual([])
})
it('returns no sessions when registry is malformed JSON', async () => {
const globalData = join(tmpRoot, 'crush-global')
await mkdir(globalData, { recursive: true })
await writeFile(join(globalData, 'projects.json'), '{ not json')
process.env['CRUSH_GLOBAL_DATA'] = globalData
const p = createCrushProvider()
const sessions = await p.discoverSessions()
expect(sessions).toEqual([])
})
it('discovers root sessions with cost or tokens, skipping zero rows and child sessions', async () => {
if (!isSqliteAvailable()) return
const projectDir = join(tmpRoot, 'project-a')
const dbPath = createCrushDb(join(projectDir, '.crush'))
withTestDb(dbPath, db => {
insertSession(db, { id: 'root-with-cost', cost: 0.42, promptTokens: 100, completionTokens: 50, createdAt: 1_700_000_001 })
insertSession(db, { id: 'root-no-spend', cost: 0, promptTokens: 0, completionTokens: 0, createdAt: 1_700_000_002 })
insertSession(db, { id: 'child', parentId: 'root-with-cost', cost: 0.01, createdAt: 1_700_000_003 })
insertSession(db, { id: 'root-tokens-only', cost: 0, promptTokens: 5, completionTokens: 5, createdAt: 1_700_000_004 })
})
const globalData = join(tmpRoot, 'crush-global')
await writeRegistry(globalData, {
'proj-a': { path: projectDir, data_dir: '.crush' },
})
process.env['CRUSH_GLOBAL_DATA'] = globalData
const p = createCrushProvider()
const sessions = await p.discoverSessions()
const ids = sessions.map(s => s.path.split(':').pop()).sort()
expect(ids).toEqual(['root-tokens-only', 'root-with-cost'])
expect(sessions.every(s => s.provider === 'crush')).toBe(true)
})
it('parses a session into a ParsedProviderCall with real tokens, cost, and dominant model', async () => {
if (!isSqliteAvailable()) return
const projectDir = join(tmpRoot, 'project-b')
const dbPath = createCrushDb(join(projectDir, '.crush'))
withTestDb(dbPath, db => {
insertSession(db, {
id: 'sess-1',
promptTokens: 1234,
completionTokens: 567,
cost: 0.0789,
createdAt: 1_700_000_010,
updatedAt: 1_700_000_999,
})
// Most-used model wins.
insertMessage(db, 'sess-1', 'assistant', 'claude-sonnet-4-6', 'm1')
insertMessage(db, 'sess-1', 'assistant', 'claude-sonnet-4-6', 'm2')
insertMessage(db, 'sess-1', 'assistant', 'gpt-5', 'm3')
})
const globalData = join(tmpRoot, 'crush-global')
await writeRegistry(globalData, {
'proj-b': { path: projectDir, data_dir: '.crush' },
})
process.env['CRUSH_GLOBAL_DATA'] = globalData
const p = createCrushProvider()
const sources = await p.discoverSessions()
expect(sources).toHaveLength(1)
const calls = await collect(p.createSessionParser(sources[0]!, new Set()))
expect(calls).toHaveLength(1)
const call = calls[0]!
expect(call.provider).toBe('crush')
expect(call.model).toBe('claude-sonnet-4-6')
expect(call.inputTokens).toBe(1234)
expect(call.outputTokens).toBe(567)
expect(call.costUSD).toBeCloseTo(0.0789, 6)
expect(call.sessionId).toBe('sess-1')
expect(call.deduplicationKey).toBe('crush:sess-1')
// Crush stores epoch seconds; 1_700_000_999 sec → 2023-11-14T22:29:59.000Z.
expect(call.timestamp).toBe(new Date(1_700_000_999 * 1000).toISOString())
})
it('falls back to "unknown" when no message has a model', async () => {
if (!isSqliteAvailable()) return
const projectDir = join(tmpRoot, 'project-c')
const dbPath = createCrushDb(join(projectDir, '.crush'))
withTestDb(dbPath, db => {
insertSession(db, { id: 'sess-no-model', cost: 0.05, promptTokens: 10, completionTokens: 5, createdAt: 1_700_000_500 })
insertMessage(db, 'sess-no-model', 'user', null, 'm1')
insertMessage(db, 'sess-no-model', 'assistant', null, 'm2')
})
const globalData = join(tmpRoot, 'crush-global')
await writeRegistry(globalData, {
'proj-c': { path: projectDir, data_dir: '.crush' },
})
process.env['CRUSH_GLOBAL_DATA'] = globalData
const p = createCrushProvider()
const sources = await p.discoverSessions()
const calls = await collect(p.createSessionParser(sources[0]!, new Set()))
expect(calls[0]!.model).toBe('unknown')
})
it('respects seenKeys for deduplication', async () => {
if (!isSqliteAvailable()) return
const projectDir = join(tmpRoot, 'project-d')
const dbPath = createCrushDb(join(projectDir, '.crush'))
withTestDb(dbPath, db => {
insertSession(db, { id: 'sess-dup', cost: 0.10, promptTokens: 100, completionTokens: 50, createdAt: 1_700_000_700 })
})
const globalData = join(tmpRoot, 'crush-global')
await writeRegistry(globalData, {
'proj-d': { path: projectDir, data_dir: '.crush' },
})
process.env['CRUSH_GLOBAL_DATA'] = globalData
const p = createCrushProvider()
const sources = await p.discoverSessions()
const seen = new Set<string>()
const first = await collect(p.createSessionParser(sources[0]!, seen))
expect(first).toHaveLength(1)
const second = await collect(p.createSessionParser(sources[0]!, seen))
expect(second).toHaveLength(0)
})
it('accepts an array-shaped projects.json (legacy format)', async () => {
if (!isSqliteAvailable()) return
const projectDir = join(tmpRoot, 'project-e')
const dbPath = createCrushDb(join(projectDir, '.crush'))
withTestDb(dbPath, db => {
insertSession(db, { id: 'sess-arr', cost: 0.01, promptTokens: 1, completionTokens: 1, createdAt: 1_700_000_800 })
})
const globalData = join(tmpRoot, 'crush-global')
await mkdir(globalData, { recursive: true })
await writeFile(
join(globalData, 'projects.json'),
JSON.stringify([{ path: projectDir, data_dir: '.crush' }]),
)
process.env['CRUSH_GLOBAL_DATA'] = globalData
const p = createCrushProvider()
const sources = await p.discoverSessions()
expect(sources).toHaveLength(1)
})
it('ignores registry entries whose db is missing', async () => {
if (!isSqliteAvailable()) return
const globalData = join(tmpRoot, 'crush-global')
await writeRegistry(globalData, {
'ghost': { path: join(tmpRoot, 'does-not-exist'), data_dir: '.crush' },
})
process.env['CRUSH_GLOBAL_DATA'] = globalData
const p = createCrushProvider()
const sources = await p.discoverSessions()
expect(sources).toEqual([])
})
it('is registered via getAllProviders', async () => {
if (!isSqliteAvailable()) return
const { getAllProviders } = await import('../../src/providers/index.js')
const providers = await getAllProviders()
const found = providers.find(p => p.name === 'crush')
expect(found).toBeDefined()
expect(found!.displayName).toBe('Crush')
})
})
+329
View File
@@ -0,0 +1,329 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { getAllProviders } from '../../src/providers/index.js'
import { createCursorAgentProvider } from '../../src/providers/cursor-agent.js'
import type { ParsedProviderCall, Provider, SessionSource } from '../../src/providers/types.js'
import { isSqliteAvailable } from '../../src/sqlite.js'
const CHARS_PER_TOKEN = 4
const CURSOR_AGENT_DEFAULT_MODEL = 'cursor-agent-auto'
const FIXED_UUID = '123e4567-e89b-12d3-a456-426614174000'
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
type TestDb = {
exec(sql: string): void
prepare(sql: string): { run(...params: unknown[]): void }
close(): void
}
let tempRoots: string[] = []
beforeEach(() => {
tempRoots = []
})
afterEach(async () => {
await Promise.all(tempRoots.filter(existsSync).map((dir) => rm(dir, { recursive: true, force: true })))
})
async function makeBaseDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'cursor-agent-test-'))
tempRoots.push(dir)
return dir
}
async function collectCalls(provider: Provider, source: SessionSource): Promise<ParsedProviderCall[]> {
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
calls.push(call)
}
return calls
}
function withTestDb(dbPath: string, fn: (db: TestDb) => void): void {
const { DatabaseSync: Database } = require('node:sqlite')
const db = new Database(dbPath)
fn(db)
db.close()
}
describe('cursor-agent provider', () => {
it('is registered', async () => {
const all = await getAllProviders()
const provider = all.find((p) => p.name === 'cursor-agent')
expect(provider).toBeDefined()
expect(provider?.displayName).toBe('Cursor Agent')
})
it('maps default model to Cursor (auto) label', () => {
const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture')
expect(provider.modelDisplayName('cursor-agent-auto')).toBe('Cursor (auto)')
})
it('maps known models and appends estimation label', () => {
const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture')
expect(provider.modelDisplayName('claude-4.5-opus-high-thinking')).toBe('Opus 4.5 (Thinking) (est.)')
expect(provider.modelDisplayName('claude-4.6-sonnet')).toBe('Sonnet 4.6 (est.)')
expect(provider.modelDisplayName('composer-1')).toBe('Composer 1 (est.)')
})
it('falls through to raw model name for unknown models with single est. suffix', () => {
const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture')
expect(provider.modelDisplayName('claude-5-future-model')).toBe('claude-5-future-model (est.)')
expect(provider.modelDisplayName('gpt-9')).toBe('gpt-9 (est.)')
})
it('returns identity for tool display name', () => {
const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture')
expect(provider.toolDisplayName('cursor:edit')).toBe('cursor:edit')
})
it('returns empty discovery when projects dir is missing', async () => {
const baseDir = await makeBaseDir()
const provider = createCursorAgentProvider(baseDir)
const sources = await provider.discoverSessions()
expect(sources).toEqual([])
})
it('discovers a single transcript', async () => {
const baseDir = await makeBaseDir()
const transcriptDir = join(baseDir, 'projects', 'test-proj', 'agent-transcripts')
await mkdir(transcriptDir, { recursive: true })
const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`)
await writeFile(transcriptPath, 'user:\n<user_query>hello</user_query>\nA:\nworld\n')
const provider = createCursorAgentProvider(baseDir)
const sources = await provider.discoverSessions()
expect(sources).toHaveLength(1)
expect(sources[0]!.provider).toBe('cursor-agent')
expect(sources[0]!.path).toBe(transcriptPath)
})
it('discovers transcripts across multiple projects', async () => {
const baseDir = await makeBaseDir()
const transcriptA = join(baseDir, 'projects', 'proj-one', 'agent-transcripts')
const transcriptB = join(baseDir, 'projects', 'proj-two', 'agent-transcripts')
await mkdir(transcriptA, { recursive: true })
await mkdir(transcriptB, { recursive: true })
await writeFile(join(transcriptA, `${FIXED_UUID}.txt`), 'user:\n<user_query>a</user_query>\nA:\na\n')
await writeFile(join(transcriptB, `${FIXED_UUID}.txt`), 'user:\n<user_query>b</user_query>\nA:\nb\n')
const provider = createCursorAgentProvider(baseDir)
const sources = await provider.discoverSessions()
expect(sources).toHaveLength(2)
expect(sources.every((s) => s.provider === 'cursor-agent')).toBe(true)
})
it('does not scan a workspace root when agent-transcripts is missing', async () => {
const baseDir = await makeBaseDir()
const workspaceRoot = join(baseDir, 'projects', 'workspace-without-transcripts')
await mkdir(workspaceRoot, { recursive: true })
await writeFile(
join(workspaceRoot, 'extension-state.txt'),
'user:\n<user_query>not a transcript</user_query>\nA:\nnot a cursor-agent answer\n',
)
const provider = createCursorAgentProvider(baseDir)
const sources = await provider.discoverSessions()
expect(sources).toEqual([])
})
it('prefers jsonl over same-session txt inside UUID transcript dirs', async () => {
const baseDir = await makeBaseDir()
const sessionDir = join(baseDir, 'projects', 'proj-with-duplicates', 'agent-transcripts', FIXED_UUID)
const jsonlPath = join(sessionDir, `${FIXED_UUID}.jsonl`)
const txtPath = join(sessionDir, `${FIXED_UUID}.txt`)
await mkdir(sessionDir, { recursive: true })
await writeFile(
jsonlPath,
'{"role":"user","message":{"content":[{"type":"text","text":"<user_query>jsonl wins</user_query>"}]}}\n{"role":"assistant","message":{"content":[{"type":"text","text":"jsonl answer"}]}}\n',
)
await writeFile(txtPath, 'user:\n<user_query>txt duplicate</user_query>\nA:\ntxt answer\n')
const provider = createCursorAgentProvider(baseDir)
const sources = await provider.discoverSessions()
expect(sources).toHaveLength(1)
expect(sources[0]!.path).toBe(jsonlPath)
})
it('parses one user/assistant pair with estimated token counts', async () => {
const baseDir = await makeBaseDir()
const transcriptDir = join(baseDir, 'projects', 'my-proj', 'agent-transcripts')
await mkdir(transcriptDir, { recursive: true })
const userText = 'explain parser output'
const assistantText = 'first line\nsecond line'
const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`)
await writeFile(
transcriptPath,
`user:\n<user_query>${userText}</user_query>\nA:\n${assistantText}\n`
)
const provider = createCursorAgentProvider(baseDir)
const source = (await provider.discoverSessions())[0]!
const calls = await collectCalls(provider, source)
expect(calls).toHaveLength(1)
expect(calls[0]!.provider).toBe('cursor-agent')
expect(calls[0]!.model).toBe(CURSOR_AGENT_DEFAULT_MODEL)
expect(calls[0]!.inputTokens).toBe(Math.ceil(userText.length / CHARS_PER_TOKEN))
expect(calls[0]!.outputTokens).toBe(Math.ceil(assistantText.length / CHARS_PER_TOKEN))
expect(calls[0]!.reasoningTokens).toBe(0)
expect(calls[0]!.deduplicationKey).toBe(`cursor-agent:${FIXED_UUID}:0`)
})
it('parses without sqlite db and defaults model', async () => {
const baseDir = await makeBaseDir()
const transcriptDir = join(baseDir, 'projects', 'fallback-proj', 'agent-transcripts')
await mkdir(transcriptDir, { recursive: true })
const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`)
await writeFile(transcriptPath, 'user:\n<user_query>hello world</user_query>\nA:\n[Thinking]private\nvisible\n')
const provider = createCursorAgentProvider(baseDir)
const source = (await provider.discoverSessions())[0]!
const calls = await collectCalls(provider, source)
expect(calls).toHaveLength(1)
expect(calls[0]!.model).toBe(CURSOR_AGENT_DEFAULT_MODEL)
expect(calls[0]!.reasoningTokens).toBe(2)
expect(calls[0]!.outputTokens).toBe(2)
})
it('skips unrecognized transcript format and writes stderr message', async () => {
const baseDir = await makeBaseDir()
const transcriptDir = join(baseDir, 'projects', 'bad-proj', 'agent-transcripts')
await mkdir(transcriptDir, { recursive: true })
const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`)
await writeFile(transcriptPath, 'no markers in this transcript')
const provider = createCursorAgentProvider(baseDir)
const source = (await provider.discoverSessions())[0]!
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const calls = await collectCalls(provider, source)
expect(calls).toHaveLength(0)
expect(stderrSpy).toHaveBeenCalled()
expect(String(stderrSpy.mock.calls[0]?.[0] ?? '')).toContain('unrecognized cursor-agent transcript format')
stderrSpy.mockRestore()
})
it('warns only once for the same unrecognized transcript', async () => {
const baseDir = await makeBaseDir()
const transcriptDir = join(baseDir, 'projects', 'bad-proj-repeat', 'agent-transcripts')
await mkdir(transcriptDir, { recursive: true })
const transcriptPath = join(transcriptDir, 'repeat-bad.txt')
await writeFile(transcriptPath, 'no cursor-agent markers here')
const provider = createCursorAgentProvider(baseDir)
const source = (await provider.discoverSessions())[0]!
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
await collectCalls(provider, source)
await collectCalls(provider, source)
const warnings = stderrSpy.mock.calls
.map(call => String(call[0] ?? ''))
.filter(message => message.includes('unrecognized cursor-agent transcript format'))
expect(warnings).toHaveLength(1)
stderrSpy.mockRestore()
})
it('discovers jsonl transcripts stored directly under project dir (workspace-less layout)', async () => {
const baseDir = await makeBaseDir()
const fixtureRoot = join(import.meta.dirname, '../fixtures/cursor-agent/workspace-less')
const sessionDir = join(baseDir, 'projects', 'agent-transcripts', '1031d227-0c67-4e17-8954-0b6e2b3322f0')
await mkdir(sessionDir, { recursive: true })
await writeFile(
join(sessionDir, '1031d227-0c67-4e17-8954-0b6e2b3322f0.jsonl'),
await readFile(
join(
fixtureRoot,
'projects/agent-transcripts/1031d227-0c67-4e17-8954-0b6e2b3322f0/1031d227-0c67-4e17-8954-0b6e2b3322f0.jsonl',
),
'utf-8',
),
)
const provider = createCursorAgentProvider(baseDir)
const sources = await provider.discoverSessions()
expect(sources).toHaveLength(1)
expect(sources[0]!.project).toBe('transcripts')
expect(sources[0]!.path.endsWith('.jsonl')).toBe(true)
const calls = await collectCalls(provider, sources[0]!)
expect(calls).toHaveLength(1)
expect(calls[0]!.sessionId).toBe('1031d227-0c67-4e17-8954-0b6e2b3322f0')
expect(calls[0]!.userMessage).toBe('Run a quick smoke test')
expect(calls[0]!.costUSD).toBeGreaterThan(0)
})
it('falls back to stable sha1 conversation id for non-uuid filenames', async () => {
const baseDir = await makeBaseDir()
const transcriptDir = join(baseDir, 'projects', 'sha-proj', 'agent-transcripts')
await mkdir(transcriptDir, { recursive: true })
const transcriptPath = join(transcriptDir, 'not-a-uuid.txt')
await writeFile(transcriptPath, 'user:\n<user_query>test</user_query>\nA:\nresult\n')
const provider = createCursorAgentProvider(baseDir)
const source = (await provider.discoverSessions())[0]!
const callsFirst = await collectCalls(provider, source)
const callsSecond = await collectCalls(provider, source)
expect(callsFirst).toHaveLength(1)
expect(callsSecond).toHaveLength(1)
expect(callsFirst[0]!.sessionId).toHaveLength(16)
expect(callsFirst[0]!.deduplicationKey.startsWith('cursor-agent:')).toBe(true)
expect(callsFirst[0]!.sessionId).toBe(callsSecond[0]!.sessionId)
expect(callsFirst[0]!.deduplicationKey).toBe(callsSecond[0]!.deduplicationKey)
})
})
skipUnlessSqlite('cursor-agent sqlite metadata', () => {
it('uses model metadata from ai-code-tracking db when present', async () => {
const baseDir = await makeBaseDir()
const transcriptDir = join(baseDir, 'projects', 'proj-with-db', 'agent-transcripts')
const aiTrackingDir = join(baseDir, 'ai-tracking')
await mkdir(transcriptDir, { recursive: true })
await mkdir(aiTrackingDir, { recursive: true })
await writeFile(
join(transcriptDir, `${FIXED_UUID}.txt`),
'user:\n<user_query>estimate cost</user_query>\nA:\nanswer\n'
)
const dbPath = join(aiTrackingDir, 'ai-code-tracking.db')
withTestDb(dbPath, (db) => {
db.exec('CREATE TABLE conversation_summaries (conversationId TEXT, title TEXT, tldr TEXT, model TEXT, mode TEXT, updatedAt INTEGER)')
db.prepare('INSERT INTO conversation_summaries (conversationId, title, tldr, model, mode, updatedAt) VALUES (?, ?, ?, ?, ?, ?)')
.run(FIXED_UUID, 'Demo title', '', 'claude-4.6-sonnet', 'agent', 1735689600000)
})
const provider = createCursorAgentProvider(baseDir)
const source = (await provider.discoverSessions())[0]!
const calls = await collectCalls(provider, source)
expect(calls).toHaveLength(1)
expect(calls[0]!.model).toBe('claude-4.6-sonnet')
expect(calls[0]!.timestamp).toBe('2025-01-01T00:00:00.000Z')
})
})
+176
View File
@@ -0,0 +1,176 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { isSqliteAvailable, openDatabase } from '../../src/sqlite.js'
import { getAllProviders } from '../../src/providers/index.js'
import type { Provider, ParsedProviderCall } from '../../src/providers/types.js'
/// Pinned regression for the v3 bubble-dedup fix. The previous (v2) code used
/// the bubble row's mutable token counts as part of the deduplication key, so
/// the same bubble was counted twice once Cursor wrote the streaming-complete
/// final token totals on top of the streaming-in-progress row. v3 switched to
/// the SQLite primary `key` column (which is the stable bubbleId:<id>:<id>
/// path) so re-parsing the same DB after token updates produces zero new
/// calls. This test:
/// 1. Builds a tmp SQLite DB with the cursorDiskKV schema and one bubble row
/// with low token counts (the streaming-in-progress shape).
/// 2. Parses it through the cursor provider. Asserts one call.
/// 3. Mutates the row in place to higher token counts (the streaming-complete
/// shape) without changing the SQLite key.
/// 4. Re-parses with the SAME seenKeys set. Asserts zero new calls.
/// If a future refactor brings back token-count-based dedup, the second parse
/// will produce a duplicate call and this test will fail.
const skipReason = isSqliteAvailable()
? null
: 'node:sqlite not available — needs Node 22+; skipping'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'cursor-dedup-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
function buildBubbleValue(opts: {
conversationId: string
text: string
inputTokens: number
outputTokens: number
type: 1 | 2
createdAt?: string
}): string {
return JSON.stringify({
type: opts.type,
conversationId: opts.conversationId,
text: opts.text,
tokenCount: {
inputTokens: opts.inputTokens,
outputTokens: opts.outputTokens,
},
createdAt: opts.createdAt ?? new Date().toISOString(),
modelId: 'gpt-5',
capabilityType: 'composer',
})
}
async function createCursorTestDb(): Promise<string> {
// Cursor uses a non-extension state DB filename (state.vscdb in the real app);
// any path works for openDatabase as long as we set up the schema and the
// directory layout the parser expects. The parser only checks the DB
// contents — discovery is bypassed because we hand it the path directly.
const dbPath = join(tmpDir, 'state.vscdb')
await writeFile(dbPath, '')
// Use the underlying node:sqlite to create the schema.
// We need cursorDiskKV with key + value columns.
const Module = await import('node:module')
const requireForSqlite = Module.createRequire(import.meta.url)
const { DatabaseSync } = requireForSqlite('node:sqlite') as {
DatabaseSync: new (path: string) => {
exec(sql: string): void
prepare(sql: string): { run(...p: unknown[]): unknown }
close(): void
}
}
const db = new DatabaseSync(dbPath)
db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)')
// Single assistant bubble (type=2). The parser yields one ParsedProviderCall
// per bubbleId:% row, so a multi-row fixture would muddy the dedup count;
// we keep the test surface minimal — one bubble through one parse, then
// the same bubble again after token mutation.
const bubbleKey = 'bubbleId:abc-123:bubble-xyz'
db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(
bubbleKey,
buildBubbleValue({
conversationId: 'abc-123',
text: 'def hello(): pass',
inputTokens: 100,
outputTokens: 20,
type: 2,
})
)
db.close()
return dbPath
}
async function updateAssistantBubbleTokens(dbPath: string, inputTokens: number, outputTokens: number): Promise<void> {
const Module = await import('node:module')
const requireForSqlite = Module.createRequire(import.meta.url)
const { DatabaseSync } = requireForSqlite('node:sqlite') as {
DatabaseSync: new (path: string) => {
prepare(sql: string): { run(...p: unknown[]): unknown }
close(): void
}
}
const db = new DatabaseSync(dbPath)
db.prepare('UPDATE cursorDiskKV SET value = ? WHERE key = ?').run(
buildBubbleValue({
conversationId: 'abc-123',
text: 'def hello(): pass',
inputTokens,
outputTokens,
type: 2,
}),
'bubbleId:abc-123:bubble-xyz'
)
db.close()
}
async function getCursorProvider(): Promise<Provider> {
const all = await getAllProviders()
const p = all.find(p => p.name === 'cursor')
if (!p) throw new Error('cursor provider not registered')
return p
}
describe.skipIf(skipReason !== null)('cursor bubble dedup (regression for v3 fix)', () => {
it('does not double-count when bubble token counts mutate between parses', async () => {
const dbPath = await createCursorTestDb()
const provider = await getCursorProvider()
// First parse: streaming-in-progress shape.
const seenKeys = new Set<string>()
const source = { path: dbPath, project: 'test-project', provider: 'cursor' }
const firstRunCalls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
firstRunCalls.push(call)
}
expect(firstRunCalls.length).toBe(1)
// Cursor mutates the same bubble row to its final token totals when the
// stream completes. Simulate by updating in place. The SQLite primary
// key stays the same.
await updateAssistantBubbleTokens(dbPath, 250, 80)
// Second parse with the SAME seenKeys: must yield zero new calls. If the
// dedup key were derived from token counts (the v2 bug), this would
// produce a duplicate.
const secondRunCalls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
secondRunCalls.push(call)
}
expect(secondRunCalls.length).toBe(0)
})
it('does not yield the same bubble twice within a single parser run', async () => {
const dbPath = await createCursorTestDb()
const provider = await getCursorProvider()
const seenKeys = new Set<string>()
const source = { path: dbPath, project: 'test-project', provider: 'cursor' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
calls.push(call)
}
// One bubble in the DB → one call. (The user message row at type=1 is
// not surfaced as a separate ParsedProviderCall; it's threaded into the
// assistant call's userMessage field.)
expect(calls.length).toBe(1)
})
})
+129
View File
@@ -0,0 +1,129 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { isSqliteAvailable } from '../../src/sqlite.js'
import { getAllProviders } from '../../src/providers/index.js'
import type { Provider, ParsedProviderCall } from '../../src/providers/types.js'
import type { DateRange } from '../../src/types.js'
/// Regression for #482: the Cursor scan must not drop in-range sessions just
/// because the DB has more bubbles than the scan budget. The old code kept the
/// most-recent MAX_BUBBLES rows *by ROWID* and warned unconditionally; the new
/// code pages the requested time window and only truncates (with a warning)
/// when the in-range scan genuinely exceeds the budget. We shrink the budget
/// via CODEBURN_CURSOR_MAX_BUBBLES so a tiny fixture exercises the capped path.
const skipReason = isSqliteAvailable() ? null : 'node:sqlite not available — needs Node 22+; skipping'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'cursor-cap-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
type Bubble = { conversationId: string; createdAt: string; model: string; tokens: number }
/// Inserts assistant bubbles in array order, so ROWID follows array index.
async function createDb(bubbles: Bubble[]): Promise<string> {
const dbPath = join(tmpDir, 'state.vscdb')
await writeFile(dbPath, '')
const Module = await import('node:module')
const requireForSqlite = Module.createRequire(import.meta.url)
const { DatabaseSync } = requireForSqlite('node:sqlite') as {
DatabaseSync: new (path: string) => {
exec(sql: string): void
prepare(sql: string): { run(...p: unknown[]): unknown }
close(): void
}
}
const db = new DatabaseSync(dbPath)
db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)')
const stmt = db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)')
bubbles.forEach((b, i) => {
stmt.run(
`bubbleId:${b.conversationId}:bubble-${i}`,
JSON.stringify({
type: 2,
conversationId: b.conversationId,
text: 'def hello(): pass',
tokenCount: { inputTokens: b.tokens, outputTokens: b.tokens },
createdAt: b.createdAt,
modelInfo: { modelName: b.model },
}),
)
})
db.close()
return dbPath
}
async function getCursorProvider(): Promise<Provider> {
const p = (await getAllProviders()).find(p => p.name === 'cursor')
if (!p) throw new Error('cursor provider not registered')
return p
}
async function parse(dbPath: string, range: DateRange): Promise<ParsedProviderCall[]> {
const provider = await getCursorProvider()
const source = { path: dbPath, project: 'test', provider: 'cursor' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set<string>(), range).parse()) {
calls.push(call)
}
return calls
}
const iso = (daysAgo: number) => new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString()
const last30Days = (): DateRange => ({ start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), end: new Date() })
const last120Days = (): DateRange => ({ start: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000), end: new Date() })
describe.skipIf(skipReason !== null)('cursor large-DB scan cap (#482)', () => {
it('keeps in-range sessions even when they have low ROWIDs and the DB is over budget', async () => {
// In-range bubbles inserted FIRST (low ROWID); out-of-range bubbles inserted
// LATER (high ROWID). The old "most-recent N by ROWID" cap would scan only
// the high-ROWID out-of-range rows and drop the in-range ones entirely.
const dbPath = await createDb([
{ conversationId: 'recent-A', createdAt: iso(1), model: 'gpt-5', tokens: 100 },
{ conversationId: 'recent-B', createdAt: iso(2), model: 'gpt-5', tokens: 100 },
{ conversationId: 'old-C', createdAt: iso(300), model: 'gpt-5', tokens: 100 },
{ conversationId: 'old-D', createdAt: iso(300), model: 'gpt-5', tokens: 100 },
])
process.env['CODEBURN_CURSOR_MAX_BUBBLES'] = '2' // total 4 > budget 2 -> capped path
const calls = await parse(dbPath, last30Days())
// Both in-range sessions are present (the old ROWID cap returned 0 here).
expect(calls.length).toBe(2)
})
it('returns the whole window when in-range bubbles fit the budget (over-budget DB)', async () => {
const dbPath = await createDb([
{ conversationId: 'A', createdAt: iso(1), model: 'gpt-5', tokens: 100 },
{ conversationId: 'B', createdAt: iso(2), model: 'gpt-5', tokens: 100 },
{ conversationId: 'old', createdAt: iso(300), model: 'gpt-5', tokens: 100 },
{ conversationId: 'older', createdAt: iso(301), model: 'gpt-5', tokens: 100 },
])
process.env['CODEBURN_CURSOR_MAX_BUBBLES'] = '3' // total 4 > budget 3, but in-range 2 <= 3
const calls = await parse(dbPath, last30Days())
expect(calls.length).toBe(2) // both in-range, none truncated
})
it('truncates to the budget and keeps the newest in-range bubbles when over budget', async () => {
// Four in-range bubbles, oldest->newest by ROWID; budget 2 keeps the two newest.
const dbPath = await createDb([
{ conversationId: 'd1', createdAt: iso(40), model: 'old-model', tokens: 100 },
{ conversationId: 'd2', createdAt: iso(30), model: 'old-model', tokens: 100 },
{ conversationId: 'd3', createdAt: iso(2), model: 'new-model', tokens: 100 },
{ conversationId: 'd4', createdAt: iso(1), model: 'new-model', tokens: 100 },
])
process.env['CODEBURN_CURSOR_MAX_BUBBLES'] = '2' // total 4 > budget 2
const calls = await parse(dbPath, last120Days())
expect(calls.length).toBe(2)
// Budget keeps the highest-ROWID (newest-inserted) bubbles.
expect(calls.every(c => c.model === 'new-model')).toBe(true)
})
})
+412
View File
@@ -0,0 +1,412 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { createRequire } from 'node:module'
import {
createCursorProvider,
clearCursorWorkspaceMapCache,
} from '../../src/providers/cursor.js'
import { isSqliteAvailable } from '../../src/sqlite.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
const requireForTest = createRequire(import.meta.url)
const skipReason = isSqliteAvailable()
? null
: 'node:sqlite not available — needs Node 22+; skipping'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'cursor-tokens-test-'))
clearCursorWorkspaceMapCache()
})
afterEach(async () => {
clearCursorWorkspaceMapCache()
await rm(tmpDir, { recursive: true, force: true })
})
function buildDb(fn: (db: {
exec(sql: string): void
prepare(sql: string): { run(...params: unknown[]): void }
close(): void
}) => void): string {
const dbPath = join(tmpDir, 'state.vscdb')
const { DatabaseSync: Database } = requireForTest('node:sqlite')
const db = new Database(dbPath)
db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)')
db.exec('CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)')
fn(db)
db.close()
return dbPath
}
function insertBubble(db: {
prepare(sql: string): { run(...params: unknown[]): void }
}, opts: {
composerId: string
bubbleUuid: string
type: 1 | 2
text: string
model?: string
inputTokens?: number
outputTokens?: number
createdAt?: string
requestId?: string
codeBlocks?: string
}): void {
const key = `bubbleId:${opts.composerId}:${opts.bubbleUuid}`
const value = JSON.stringify({
type: opts.type,
conversationId: '',
createdAt: opts.createdAt ?? new Date().toISOString(),
tokenCount: {
inputTokens: opts.inputTokens ?? 0,
outputTokens: opts.outputTokens ?? 0,
},
modelInfo: opts.model ? { modelName: opts.model } : undefined,
text: opts.text,
codeBlocks: opts.codeBlocks ?? '[]',
requestId: opts.requestId,
})
db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(key, value)
}
function insertComposerData(db: {
prepare(sql: string): { run(...params: unknown[]): void }
}, opts: {
composerId: string
totalUsedTokens?: number | null
contextTokensUsed?: number | null
createdAt?: number
}): void {
const key = `composerData:${opts.composerId}`
const breakdown = opts.totalUsedTokens !== undefined
? { totalUsedTokens: opts.totalUsedTokens }
: {}
const value = JSON.stringify({
promptTokenBreakdown: breakdown,
contextTokensUsed: opts.contextTokensUsed ?? undefined,
createdAt: opts.createdAt ?? undefined,
})
db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(key, value)
}
function insertAgentKv(db: {
prepare(sql: string): { run(...params: unknown[]): void }
}, opts: {
blobId: string
role: string
content: unknown
requestId?: string
}): void {
const key = `agentKv:blob:${opts.blobId}`
const value = JSON.stringify({
role: opts.role,
content: opts.content,
providerOptions: opts.requestId
? { cursor: { requestId: opts.requestId } }
: undefined,
})
db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(key, value)
}
async function collectCalls(provider: ReturnType<typeof createCursorProvider>, dbPath: string): Promise<ParsedProviderCall[]> {
const source = { path: dbPath, project: 'test', provider: 'cursor' as const }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
calls.push(call)
}
return calls
}
describe.skipIf(skipReason !== null)('cursor real context tokens (#575)', () => {
it('credits composerData.promptTokenBreakdown.totalUsedTokens as input', async () => {
const composerId = 'aaaa1111-2222-3333-4444-555566667777'
const dbPath = buildDb((db) => {
insertComposerData(db, { composerId, totalUsedTokens: 50000 })
insertBubble(db, {
composerId, bubbleUuid: 'b1', type: 1, text: 'user prompt',
inputTokens: 0, outputTokens: 0,
})
insertBubble(db, {
composerId, bubbleUuid: 'b2', type: 2, text: 'assistant reply',
model: 'claude-4.6-sonnet', inputTokens: 0, outputTokens: 0,
})
})
const provider = createCursorProvider(dbPath)
const calls = await collectCalls(provider, dbPath)
const credited = calls.find(c => c.inputTokens === 50000)
expect(credited).toBeDefined()
expect(credited!.deduplicationKey).toBe(`cursor:composer-input:${composerId}`)
expect(credited!.costIsEstimated).toBe(true)
})
it('credits real input tokens once per conversation, not per bubble', async () => {
const composerId = 'bbbb1111-2222-3333-4444-555566667777'
const dbPath = buildDb((db) => {
insertComposerData(db, { composerId, totalUsedTokens: 30000 })
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'turn 1' })
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'reply 1', model: 'gpt-5' })
insertBubble(db, { composerId, bubbleUuid: 'b3', type: 1, text: 'turn 2' })
insertBubble(db, { composerId, bubbleUuid: 'b4', type: 2, text: 'reply 2', model: 'gpt-5' })
})
const provider = createCursorProvider(dbPath)
const calls = await collectCalls(provider, dbPath)
const credited = calls.filter(c => c.inputTokens === 30000)
expect(credited.length).toBe(1)
// The metered conversation's user-bubble text must not be counted on top.
const inputTotal = calls.reduce((s, c) => s + c.inputTokens, 0)
expect(inputTotal).toBe(30000)
})
it('anchors the conversation record to composerData.createdAt, independent of the parse window', async () => {
const composerId = 'ab121111-2222-3333-4444-555566667777'
const startMs = Date.parse('2026-06-01T10:00:00.000Z')
const dbPath = buildDb((db) => {
insertComposerData(db, { composerId, totalUsedTokens: 20000, createdAt: startMs })
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'later turn' })
})
const provider = createCursorProvider(dbPath)
const calls = await collectCalls(provider, dbPath)
const credited = calls.find(c => c.inputTokens === 20000)
expect(credited).toBeDefined()
expect(credited!.timestamp).toBe(new Date(startMs).toISOString())
})
it('falls back to text estimation when no composerData exists', async () => {
const composerId = 'cccc1111-2222-3333-4444-555566667777'
const dbPath = buildDb((db) => {
insertBubble(db, {
composerId, bubbleUuid: 'b1', type: 1, text: 'hello world this is a test',
})
})
const provider = createCursorProvider(dbPath)
const calls = await collectCalls(provider, dbPath)
const userCall = calls.find(c => c.inputTokens > 0)
expect(userCall).toBeDefined()
expect(userCall!.inputTokens).toBe(Math.ceil('hello world this is a test'.length / 4))
})
it('uses contextTokensUsed when totalUsedTokens is null', async () => {
const composerId = 'dddd1111-2222-3333-4444-555566667777'
const dbPath = buildDb((db) => {
insertComposerData(db, { composerId, totalUsedTokens: null, contextTokensUsed: 42000 })
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'prompt' })
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'reply', model: 'gpt-5' })
})
const provider = createCursorProvider(dbPath)
const calls = await collectCalls(provider, dbPath)
const credited = calls.find(c => c.inputTokens === 42000)
expect(credited).toBeDefined()
})
it('uses contextTokensUsed when totalUsedTokens is present but zero', async () => {
const composerId = 'de001111-2222-3333-4444-555566667777'
const dbPath = buildDb((db) => {
insertComposerData(db, { composerId, totalUsedTokens: 0, contextTokensUsed: 42000 })
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'prompt' })
})
const provider = createCursorProvider(dbPath)
const calls = await collectCalls(provider, dbPath)
const credited = calls.find(c => c.inputTokens === 42000)
expect(credited).toBeDefined()
})
it('skips the meter when any bubble carries real tokenCounts', async () => {
const composerId = 'ef001111-2222-3333-4444-555566667777'
const dbPath = buildDb((db) => {
insertComposerData(db, { composerId, totalUsedTokens: 80000 })
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'prompt', inputTokens: 6000 })
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'reply', model: 'gpt-5', outputTokens: 900 })
})
const provider = createCursorProvider(dbPath)
const calls = await collectCalls(provider, dbPath)
// Real per-bubble counts are authoritative; the snapshot must not stack.
expect(calls.find(c => c.inputTokens === 80000)).toBeUndefined()
const inputTotal = calls.reduce((s, c) => s + c.inputTokens, 0)
expect(inputTotal).toBe(6000)
})
it('attributes aggregated agentKv tools once, with canonical Bash names', async () => {
const composerId = 'eeee1111-2222-3333-4444-555566667777'
const requestId = 'req-001'
const dbPath = buildDb((db) => {
insertComposerData(db, { composerId, totalUsedTokens: 10000 })
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'do stuff', requestId })
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'doing stuff', model: 'gpt-5' })
insertBubble(db, { composerId, bubbleUuid: 'b3', type: 1, text: 'do more stuff', requestId: 'req-002' })
insertBubble(db, { composerId, bubbleUuid: 'b4', type: 2, text: 'doing more stuff', model: 'gpt-5' })
insertAgentKv(db, {
blobId: 'akv-1', role: 'user',
content: [{ type: 'text', text: 'do stuff' }],
requestId,
})
insertAgentKv(db, {
blobId: 'akv-2', role: 'assistant',
content: [
{ type: 'tool-call', toolName: 'Read', args: {} },
{ type: 'tool-call', toolName: 'Shell', args: { command: 'npm test' } },
],
})
})
const provider = createCursorProvider(dbPath)
const calls = await collectCalls(provider, dbPath)
const callWithTools = calls.find(c => c.tools.length > 0)
expect(callWithTools).toBeDefined()
expect(callWithTools!.tools).toContain('Read')
expect(callWithTools!.tools).toContain('Bash')
expect(callWithTools!.bashCommands).toContain('npm')
const allTools = calls.flatMap(c => c.tools)
const allBashCommands = calls.flatMap(c => c.bashCommands)
expect(allTools.filter(t => t === 'Read').length).toBe(1)
expect(allTools.filter(t => t === 'Bash').length).toBe(1)
expect(allBashCommands.filter(cmd => cmd === 'npm').length).toBe(1)
})
it('uses conversation model for pricing the conversation record', async () => {
const composerId = 'ffff1111-2222-3333-4444-555566667777'
const dbPath = buildDb((db) => {
insertComposerData(db, { composerId, totalUsedTokens: 100000 })
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'prompt' })
insertBubble(db, {
composerId, bubbleUuid: 'b2', type: 2, text: 'reply',
model: 'claude-4.5-opus-high-thinking',
})
})
const provider = createCursorProvider(dbPath)
const calls = await collectCalls(provider, dbPath)
const creditedCall = calls.find(c => c.inputTokens === 100000)
expect(creditedCall).toBeDefined()
expect(creditedCall!.model).toBe('claude-4.5-opus-high-thinking')
})
it('estimates input from the agent stream when a non-Composer turn has empty bubble text', async () => {
const composerId = '99990000-1111-2222-3333-444455556666'
const requestId = 'req-gpt-1'
const prompt = '<user_info>OS: darwin</user_info> refactor the auth module and add tests'
const dbPath = buildDb((db) => {
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: '', requestId })
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'done', model: 'gpt-5' })
insertAgentKv(db, { blobId: 'akv-1', role: 'user', content: prompt, requestId })
})
const provider = createCursorProvider(dbPath)
const calls = await collectCalls(provider, dbPath)
const credited = calls.find(c => c.inputTokens > 0)
expect(credited).toBeDefined()
expect(credited!.inputTokens).toBe(Math.ceil(prompt.length / 4))
})
it('counts tool and system stream rows as context for meterless sessions', async () => {
const composerId = '77770000-1111-2222-3333-444455556666'
const requestId = 'req-gpt-2'
const prompt = 'summarize the repo'
const toolResult = 'x'.repeat(4000)
const dbPath = buildDb((db) => {
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: '', requestId })
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'done', model: 'gpt-5' })
insertAgentKv(db, { blobId: 'akv-1', role: 'user', content: prompt, requestId })
insertAgentKv(db, { blobId: 'akv-2', role: 'tool', content: [{ type: 'text', text: toolResult }] })
})
const provider = createCursorProvider(dbPath)
const calls = await collectCalls(provider, dbPath)
const credited = calls.find(c => c.inputTokens > 0)
expect(credited).toBeDefined()
expect(credited!.inputTokens).toBe(Math.ceil((prompt.length + toolResult.length) / 4))
})
it('does not double count turns that also have bubble text in stream-estimated conversations', async () => {
const composerId = '66660000-1111-2222-3333-444455556666'
const requestId = 'req-gpt-3'
const streamPrompt = 'the full prompt with injected context'
const dbPath = buildDb((db) => {
// Turn 1 has visible bubble text; turn 2's lives only in the stream.
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'visible text', requestId })
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 1, text: '' })
insertAgentKv(db, { blobId: 'akv-1', role: 'user', content: streamPrompt, requestId })
})
const provider = createCursorProvider(dbPath)
const calls = await collectCalls(provider, dbPath)
const inputTotal = calls.reduce((s, c) => s + c.inputTokens, 0)
expect(inputTotal).toBe(Math.ceil(streamPrompt.length / 4))
})
it('emits sessions recorded only in the agent stream', async () => {
const requestId = 'req-headless-1'
const prompt = 'run the nightly data export'
const reply = 'export completed with 3 warnings'
const dbPath = buildDb((db) => {
insertAgentKv(db, { blobId: 'akv-1', role: 'user', content: prompt, requestId })
insertAgentKv(db, { blobId: 'akv-2', role: 'assistant', content: [{ type: 'text', text: reply }] })
})
const provider = createCursorProvider(dbPath)
const calls = await collectCalls(provider, dbPath)
const session = calls.find(c => c.deduplicationKey === `cursor:agentKv:${requestId}`)
expect(session).toBeDefined()
expect(session!.inputTokens).toBe(Math.ceil(prompt.length / 4))
expect(session!.outputTokens).toBe(Math.ceil(reply.length / 4))
})
it('pairs each assistant reply with its own turn\'s user question', async () => {
const composerId = '55550000-1111-2222-3333-444455556666'
const dbPath = buildDb((db) => {
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'first question' })
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'first reply', model: 'gpt-5' })
insertBubble(db, { composerId, bubbleUuid: 'b3', type: 1, text: 'second question' })
insertBubble(db, { composerId, bubbleUuid: 'b4', type: 2, text: 'second reply', model: 'gpt-5' })
})
const provider = createCursorProvider(dbPath)
const calls = await collectCalls(provider, dbPath)
const firstReply = calls.find(c => c.userMessage.includes('first reply'))
const secondReply = calls.find(c => c.userMessage.includes('second reply'))
expect(firstReply).toBeDefined()
expect(secondReply).toBeDefined()
expect(firstReply!.userMessage).toContain('first question')
expect(secondReply!.userMessage).toContain('second question')
})
it('does not fabricate input when an empty-text turn has no agent stream', async () => {
const composerId = '88880000-1111-2222-3333-444455556666'
const dbPath = buildDb((db) => {
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: '' })
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'done', model: 'gpt-5' })
})
const provider = createCursorProvider(dbPath)
const calls = await collectCalls(provider, dbPath)
expect(calls.find(c => c.inputTokens > 0)).toBeUndefined()
})
})
@@ -0,0 +1,330 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { mkdirSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { createRequire } from 'node:module'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import {
createCursorProvider,
clearCursorWorkspaceMapCache,
} from '../../src/providers/cursor.js'
import { isSqliteAvailable } from '../../src/sqlite.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
const requireForTest = createRequire(import.meta.url)
let userDir: string
beforeEach(async () => {
userDir = await mkdtemp(join(tmpdir(), 'cursor-ws-test-'))
// Layout matches Cursor's: <userDir>/{globalStorage,workspaceStorage}/.
await mkdir(join(userDir, 'globalStorage'), { recursive: true })
await mkdir(join(userDir, 'workspaceStorage'), { recursive: true })
clearCursorWorkspaceMapCache()
})
afterEach(async () => {
clearCursorWorkspaceMapCache()
await rm(userDir, { recursive: true, force: true })
})
function globalDbPath(): string {
return join(userDir, 'globalStorage', 'state.vscdb')
}
/// Builds a global state.vscdb with the cursorDiskKV table and a small set of
/// bubbles for the requested composer ids. Each bubble carries enough fields
/// to satisfy parseBubbles() — created_at, tokenCount, conversationId, type.
function createGlobalDb(composerIds: string[]): string {
const dbPath = globalDbPath()
const { DatabaseSync: Database } = requireForTest('node:sqlite')
const db = new Database(dbPath)
db.exec(`CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)`)
// ItemTable is unused by the global parser but creating it mirrors the
// real schema so a stray query against it does not error.
db.exec(`CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)`)
const insert = db.prepare(`INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)`)
const baseTime = Date.now() - 24 * 3600 * 1000
for (const composerId of composerIds) {
// Exactly one assistant bubble per composer so the test math is
// "one composer == one call". User bubbles also produce calls in the
// real parser (text-length token estimation), but they are not
// necessary to exercise the workspace routing logic.
const bubbleId = `bubbleId:${composerId}:bubble-${composerId.slice(0, 6)}`
const bubble = {
type: 2, // assistant
conversationId: composerId,
createdAt: new Date(baseTime).toISOString(),
tokenCount: { inputTokens: 100, outputTokens: 50 },
modelInfo: { modelName: 'claude-4.6-sonnet' },
text: 'assistant reply for ' + composerId,
codeBlocks: '[]',
}
insert.run(bubbleId, JSON.stringify(bubble))
}
db.close()
return dbPath
}
/// Creates one workspaceStorage/<hash>/ subdir with workspace.json (folder URI)
/// and state.vscdb (composer.composerData listing the supplied composerIds).
function createWorkspaceDir(hash: string, folderUri: string, composerIds: string[]): void {
const dir = join(userDir, 'workspaceStorage', hash)
mkdirSync(dir, { recursive: true })
const wsJsonPath = join(dir, 'workspace.json')
// We cannot do a top-level await in a sync helper; the caller writes via
// mkdirSync above and the JSON via Node's sync writeFile shim through the
// require'd 'fs'. Using readFileSync-friendly imports to keep this test
// helper sync.
const fs = requireForTest('fs') as typeof import('fs')
fs.writeFileSync(wsJsonPath, JSON.stringify({ folder: folderUri }))
const wsDbPath = join(dir, 'state.vscdb')
const { DatabaseSync: Database } = requireForTest('node:sqlite')
const db = new Database(wsDbPath)
db.exec(`CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)`)
const composerData = {
allComposers: composerIds.map(id => ({
composerId: id,
name: 'session-' + id.slice(0, 6),
unifiedMode: 'agent',
})),
}
db.prepare(`INSERT INTO ItemTable (key, value) VALUES (?, ?)`).run(
'composer.composerData',
JSON.stringify(composerData),
)
db.close()
}
async function collect(parser: { parse(): AsyncGenerator<ParsedProviderCall> }): Promise<ParsedProviderCall[]> {
const out: ParsedProviderCall[] = []
for await (const call of parser.parse()) out.push(call)
return out
}
describe('cursor provider — per-project breakdown (#196)', () => {
it('emits one source per workspace plus an orphan source', async () => {
if (!isSqliteAvailable()) return
const dbPath = createGlobalDb([
'composer-work-1',
'composer-work-2',
'composer-personal-1',
'composer-orphan-1',
])
createWorkspaceDir('hash-work', 'file:///Users/me/work-app', ['composer-work-1', 'composer-work-2'])
createWorkspaceDir('hash-personal', 'file:///Users/me/personal-app', ['composer-personal-1'])
const provider = createCursorProvider(dbPath)
const sources = await provider.discoverSessions()
const projects = sources.map(s => s.project).sort()
expect(projects).toContain('-Users-me-work-app')
expect(projects).toContain('-Users-me-personal-app')
// Orphan source is labeled 'cursor' so a user with no workspaces
// sees the same project name as before the breakdown change.
expect(projects).toContain('cursor')
})
it('routes calls to the right workspace and excludes others', async () => {
if (!isSqliteAvailable()) return
const dbPath = createGlobalDb([
'composer-work-1',
'composer-work-2',
'composer-personal-1',
])
createWorkspaceDir('hash-work', 'file:///Users/me/work-app', ['composer-work-1', 'composer-work-2'])
createWorkspaceDir('hash-personal', 'file:///Users/me/personal-app', ['composer-personal-1'])
const provider = createCursorProvider(dbPath)
const sources = await provider.discoverSessions()
const workSource = sources.find(s => s.project === '-Users-me-work-app')!
const personalSource = sources.find(s => s.project === '-Users-me-personal-app')!
const workCalls = await collect(provider.createSessionParser(workSource, new Set()))
const personalCalls = await collect(provider.createSessionParser(personalSource, new Set()))
const workComposerIds = new Set(workCalls.map(c => c.sessionId))
expect(workComposerIds).toEqual(new Set(['composer-work-1', 'composer-work-2']))
const personalComposerIds = new Set(personalCalls.map(c => c.sessionId))
expect(personalComposerIds).toEqual(new Set(['composer-personal-1']))
})
it('orphan source captures composers not registered in any workspace', async () => {
if (!isSqliteAvailable()) return
const dbPath = createGlobalDb([
'composer-mapped',
'composer-orphan-a',
'composer-orphan-b',
])
createWorkspaceDir('hash-only', 'file:///Users/me/only-app', ['composer-mapped'])
const provider = createCursorProvider(dbPath)
const sources = await provider.discoverSessions()
const orphanSource = sources.find(s => s.project === 'cursor')!
const orphanCalls = await collect(provider.createSessionParser(orphanSource, new Set()))
const ids = new Set(orphanCalls.map(c => c.sessionId))
expect(ids).toEqual(new Set(['composer-orphan-a', 'composer-orphan-b']))
})
it('totals across all sources equal totals from the legacy single-source behavior', async () => {
if (!isSqliteAvailable()) return
const dbPath = createGlobalDb([
'composer-work-1',
'composer-personal-1',
'composer-orphan-1',
])
createWorkspaceDir('hash-work', 'file:///Users/me/work-app', ['composer-work-1'])
createWorkspaceDir('hash-personal', 'file:///Users/me/personal-app', ['composer-personal-1'])
const provider = createCursorProvider(dbPath)
const sources = await provider.discoverSessions()
const seen = new Set<string>()
let totalCalls = 0
let totalCost = 0
for (const source of sources) {
const calls = await collect(provider.createSessionParser(source, seen))
totalCalls += calls.length
for (const call of calls) totalCost += call.costUSD
}
// Three composers, one assistant call each => three calls overall.
expect(totalCalls).toBe(3)
expect(totalCost).toBeGreaterThan(0)
})
it('emits a single `cursor` source (legacy-equivalent) when no workspace mapping exists', async () => {
if (!isSqliteAvailable()) return
// No createWorkspaceDir calls -> workspaceStorage exists but is empty.
const dbPath = createGlobalDb(['composer-1', 'composer-2'])
const provider = createCursorProvider(dbPath)
const sources = await provider.discoverSessions()
expect(sources).toHaveLength(1)
expect(sources[0]!.project).toBe('cursor')
const calls = await collect(provider.createSessionParser(sources[0]!, new Set()))
// All composers fall through to the orphan/catch-all source, matching
// the pre-PR behavior where every Cursor session showed under one row.
const ids = new Set(calls.map(c => c.sessionId))
expect(ids).toEqual(new Set(['composer-1', 'composer-2']))
})
it('handles multi-root workspaces (workspace.json without folder) by skipping them', async () => {
if (!isSqliteAvailable()) return
const dbPath = createGlobalDb(['composer-multi'])
// Multi-root workspace: workspace.json carries `configuration` not `folder`.
const dir = join(userDir, 'workspaceStorage', 'hash-multi')
mkdirSync(dir, { recursive: true })
await writeFile(
join(dir, 'workspace.json'),
JSON.stringify({ configuration: 'file:///path/to/.code-workspace' }),
)
// No state.vscdb either — multi-root composer never registers.
const provider = createCursorProvider(dbPath)
const sources = await provider.discoverSessions()
// Multi-root produces no workspace mapping; only the orphan source
// (labeled 'cursor') remains, and it captures the multi-root composer.
const projects = sources.map(s => s.project)
expect(projects).toEqual(['cursor'])
const calls = await collect(provider.createSessionParser(sources[0]!, new Set()))
expect(calls.map(c => c.sessionId)).toEqual(['composer-multi'])
})
it('sanitizes vscode-remote URIs into a slug', async () => {
if (!isSqliteAvailable()) return
const dbPath = createGlobalDb(['composer-remote'])
createWorkspaceDir(
'hash-remote',
'vscode-remote://wsl+Ubuntu/home/me/proj',
['composer-remote'],
)
const provider = createCursorProvider(dbPath)
const sources = await provider.discoverSessions()
const project = sources.find(s => s.project !== 'cursor')!.project
// file:// would yield "-Users-me-proj"; remote URIs get the scheme rewritten.
expect(project).toMatch(/wsl-Ubuntu/)
expect(project).toContain('home')
expect(project).toContain('proj')
})
it('drops sub-composer rows whose composer id is not a UUID', async () => {
if (!isSqliteAvailable()) return
const dbPath = globalDbPath()
const { DatabaseSync: Database } = requireForTest('node:sqlite')
const db = new Database(dbPath)
db.exec(`CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)`)
db.exec(`CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)`)
const insert = db.prepare(`INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)`)
// One real composer with one bubble. Real composer ids are UUIDs.
const realComposerId = 'cccc1111-2222-3333-4444-555566667777'
insert.run(`bubbleId:${realComposerId}:bubble-real`, JSON.stringify({
type: 2,
conversationId: realComposerId,
createdAt: new Date().toISOString(),
tokenCount: { inputTokens: 100, outputTokens: 50 },
modelInfo: { modelName: 'claude-4.6-sonnet' },
text: 'real',
codeBlocks: '[]',
}))
// A sub-composer row mirroring the real Cursor shape: the composer
// segment has an embedded newline and is not UUID-shaped. Must be
// dropped, not surfaced as its own session.
insert.run(`bubbleId:task-call_xxx\nfc_yyy:bubble-sub`, JSON.stringify({
type: 2,
conversationId: '',
createdAt: new Date().toISOString(),
tokenCount: { inputTokens: 10, outputTokens: 5 },
modelInfo: { modelName: 'claude-4.6-sonnet' },
text: 'sub',
codeBlocks: '[]',
}))
db.close()
createWorkspaceDir('hash-only', 'file:///Users/me/only', [realComposerId])
const provider = createCursorProvider(dbPath)
const sources = await provider.discoverSessions()
const seen = new Set<string>()
let allCalls = 0
for (const source of sources) {
const calls = await collect(provider.createSessionParser(source, seen))
allCalls += calls.length
}
// One real composer -> one call. Sub-composer dropped. Total: 1.
expect(allCalls).toBe(1)
})
it('remains backwards-compatible when given a legacy bare DB path', async () => {
if (!isSqliteAvailable()) return
const dbPath = createGlobalDb(['composer-legacy-1', 'composer-legacy-2'])
createWorkspaceDir('hash-legacy', 'file:///Users/me/legacy', ['composer-legacy-1'])
const provider = createCursorProvider(dbPath)
// Hand-construct a legacy SessionSource (no workspace tag) and verify
// it still yields every call regardless of workspace mapping.
const legacySource = { path: dbPath, project: 'cursor', provider: 'cursor' }
const calls = await collect(provider.createSessionParser(legacySource, new Set()))
const ids = new Set(calls.map(c => c.sessionId))
expect(ids).toEqual(new Set(['composer-legacy-1', 'composer-legacy-2']))
})
})
+167
View File
@@ -0,0 +1,167 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { createRequire } from 'node:module'
import { getAllProviders } from '../../src/providers/index.js'
import { getCursorTimeFloor, createCursorProvider, clearCursorWorkspaceMapCache } from '../../src/providers/cursor.js'
import { isSqliteAvailable } from '../../src/sqlite.js'
import type { Provider } from '../../src/providers/types.js'
describe('cursor provider', () => {
let cursorProvider: Provider
beforeEach(async () => {
const all = await getAllProviders()
cursorProvider = all.find(p => p.name === 'cursor')!
})
it('is registered', () => {
expect(cursorProvider).toBeDefined()
expect(cursorProvider.name).toBe('cursor')
expect(cursorProvider.displayName).toBe('Cursor')
})
describe('model display names', () => {
it('maps cursor-auto to Cursor (auto) label', () => {
expect(cursorProvider.modelDisplayName('cursor-auto')).toBe('Cursor (auto)')
})
it('maps known models to readable names', () => {
expect(cursorProvider.modelDisplayName('claude-4.5-opus-high-thinking')).toBe('Opus 4.5 (Thinking)')
expect(cursorProvider.modelDisplayName('claude-4-sonnet-thinking')).toBe('Sonnet 4 (Thinking)')
expect(cursorProvider.modelDisplayName('grok-code-fast-1')).toBe('Grok Code Fast')
expect(cursorProvider.modelDisplayName('gemini-3-pro')).toBe('Gemini 3 Pro')
expect(cursorProvider.modelDisplayName('gpt-5')).toBe('GPT-5')
expect(cursorProvider.modelDisplayName('composer-1')).toBe('Composer 1')
})
it('returns raw name for unknown models', () => {
expect(cursorProvider.modelDisplayName('some-future-model')).toBe('some-future-model')
})
})
describe('tool display names', () => {
it('returns raw tool name as identity', () => {
expect(cursorProvider.toolDisplayName('some_tool')).toBe('some_tool')
})
})
describe('time floor', () => {
it('uses dateRange.start when within the six-month cap', () => {
const start = new Date(2026, 3, 1)
expect(getCursorTimeFloor({ start, end: new Date(2026, 5, 2) })).toBe(start.toISOString())
})
})
describe('session discovery', () => {
it('returns empty when sqlite is not available', async () => {
const sessions = await cursorProvider.discoverSessions()
expect(Array.isArray(sessions)).toBe(true)
})
it('returns empty when db does not exist', async () => {
const sessions = await cursorProvider.discoverSessions()
expect(sessions.every(s => s.provider === 'cursor')).toBe(true)
})
})
})
describe('cursor sqlite adapter', () => {
it('reports availability', async () => {
const { isSqliteAvailable } = await import('../../src/sqlite.js')
const available = isSqliteAvailable()
expect(typeof available).toBe('boolean')
})
it('provides error message when not available', async () => {
const { getSqliteLoadError } = await import('../../src/sqlite.js')
const error = getSqliteLoadError()
expect(typeof error).toBe('string')
expect(error.length).toBeGreaterThan(0)
})
})
describe('cursor cache', () => {
it('returns null when no cache exists', async () => {
const { readCachedResults } = await import('../../src/cursor-cache.js')
const result = await readCachedResults('/nonexistent/path.db', new Date(0).toISOString())
expect(result).toBeNull()
})
})
// Regression: Cursor renamed the per-workspace composer list key from
// 'composer.composerData' to 'composer.composerHeaders'. loadWorkspaceMap must
// read both, otherwise every composer orphans into the 'cursor' catch-all and
// per-project attribution is lost.
describe('cursor workspace mapping (composer.composerHeaders regression)', () => {
const requireForTest = createRequire(import.meta.url)
type TestDb = {
exec(sql: string): void
prepare(sql: string): { run(...params: unknown[]): void }
close(): void
}
let root: string
function writeItemTableDb(dbPath: string, key: string, composerIds: string[]): void {
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (p: string) => TestDb }
const db = new DatabaseSync(dbPath)
db.exec('CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)')
if (key) {
const value = JSON.stringify({ allComposers: composerIds.map(composerId => ({ composerId })) })
db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)').run(key, value)
}
db.close()
}
async function makeWorkspace(hash: string, folderUri: string, key: string, composerIds: string[]): Promise<void> {
const wsDir = join(root, 'User', 'workspaceStorage', hash)
await mkdir(wsDir, { recursive: true })
await writeFile(join(wsDir, 'workspace.json'), JSON.stringify({ folder: folderUri }))
writeItemTableDb(join(wsDir, 'state.vscdb'), key, composerIds)
}
async function makeGlobalDb(): Promise<string> {
const gsDir = join(root, 'User', 'globalStorage')
await mkdir(gsDir, { recursive: true })
const dbPath = join(gsDir, 'state.vscdb')
// discoverSessions only needs the global DB to exist; the workspace map is
// built from the sibling workspaceStorage dir.
writeItemTableDb(dbPath, '', [])
return dbPath
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'cursor-ws-test-'))
clearCursorWorkspaceMapCache()
})
afterEach(async () => {
clearCursorWorkspaceMapCache()
await rm(root, { recursive: true, force: true })
})
it.skipIf(!isSqliteAvailable())(
'maps composers to their workspace via composer.composerHeaders (new Cursor key)',
async () => {
await makeWorkspace('ws-headers', 'file:///home/user/myapp', 'composer.composerHeaders', ['comp-1', 'comp-2'])
const dbPath = await makeGlobalDb()
const sources = await createCursorProvider(dbPath).discoverSessions()
const projects = sources.map(s => s.project)
// Before the fix these composers orphaned to the 'cursor' catch-all.
expect(projects).toContain('-home-user-myapp')
},
)
it.skipIf(!isSqliteAvailable())(
'still maps composers via the legacy composer.composerData key',
async () => {
await makeWorkspace('ws-legacy', 'file:///home/user/legacy', 'composer.composerData', ['old-1'])
const dbPath = await makeGlobalDb()
const sources = await createCursorProvider(dbPath).discoverSessions()
expect(sources.map(s => s.project)).toContain('-home-user-legacy')
},
)
})
+816
View File
@@ -0,0 +1,816 @@
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { isSqliteAvailable } from '../../src/sqlite.js'
import { createDevinProvider } from '../../src/providers/devin.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'devin-provider-'))
process.env['HOME'] = tmpDir
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
async function configureDevinRate(rate = 1): Promise<void> {
await mkdir(join(tmpDir, '.config', 'codeburn'), { recursive: true })
await writeFile(join(tmpDir, '.config', 'codeburn', 'config.json'), JSON.stringify({
devin: { acuUsdRate: rate },
}))
}
async function writeTranscript(name: string, transcript: unknown): Promise<string> {
const transcriptsDir = join(tmpDir, 'transcripts')
await mkdir(transcriptsDir, { recursive: true })
const filePath = join(transcriptsDir, name)
await writeFile(filePath, JSON.stringify(transcript))
return filePath
}
async function parseTranscript(filePath: string, project = 'devin'): Promise<ParsedProviderCall[]> {
const provider = createDevinProvider(tmpDir)
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser({ path: filePath, project, provider: 'devin' }, new Set()).parse()) {
calls.push(call)
}
return calls
}
function createSessionsDb(): void {
const { DatabaseSync: Database } = require('node:sqlite')
const db = new Database(join(tmpDir, 'sessions.db'))
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
working_directory TEXT,
backend_type TEXT,
model TEXT,
agent_mode TEXT,
created_at INTEGER,
last_activity_at INTEGER,
title TEXT,
hidden INTEGER NOT NULL DEFAULT 0
)
`)
db.prepare(`
INSERT INTO sessions (id, working_directory, model, created_at, last_activity_at, title, hidden)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run('db-session', '/Users/example/work/codeburn', 'claude-sonnet-4-6', 1_800_000_000, 1_800_000_010, 'CodeBurn', 0)
db.prepare(`
INSERT INTO sessions (id, working_directory, model, created_at, last_activity_at, title, hidden)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run('hidden-session', '/Users/example/work/hidden', 'claude-opus-4-6', 1_800_000_000, 1_800_000_010, 'Hidden', 1)
db.close()
}
describe('devin provider', () => {
it('discovers Devin CLI transcript json files', async () => {
await configureDevinRate()
const filePath = await writeTranscript('glimmer-platinum.json', { steps: [] })
await writeFile(join(tmpDir, 'transcripts', 'ignore.txt'), '{}')
const provider = createDevinProvider(tmpDir)
const sources = await provider.discoverSessions()
expect(sources).toEqual([
{ path: filePath, project: 'devin', provider: 'devin' },
])
})
it('stays disabled until the Devin ACU rate is configured', async () => {
await writeTranscript('glimmer-platinum.json', {
session_id: 'session-123',
steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.5 } }],
})
const provider = createDevinProvider(tmpDir)
expect(await provider.discoverSessions()).toEqual([])
expect(await parseTranscript(join(tmpDir, 'transcripts', 'glimmer-platinum.json'))).toEqual([])
})
it('parses per-step ACUs, tokens, tools, and model resolution', async () => {
await configureDevinRate()
const filePath = await writeTranscript('glimmer-platinum.json', {
schema_version: '1',
session_id: 'session-123',
agent: { model_name: 'agent-model' },
steps: [
{
step_id: 1,
message: 'please inspect the repo',
metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' },
},
{
step_id: 2,
model_name: 'step-model',
metadata: {
created_at: '2027-01-15T08:00:01.000Z',
committed_acu_cost: 0.02076149918138981,
generation_model: 'claude-opus-4-6',
metrics: {
input_tokens: 100,
output_tokens: 20,
cache_creation_tokens: 10,
cache_read_tokens: 5,
},
},
tool_calls: [{ function_name: 'read_file' }],
},
{
step_id: 3,
model_name: 'claude-sonnet-4-6',
metadata: {
created_at: '2027-01-15T08:00:02.000Z',
committed_acu_cost: 0.005421000067144632,
metrics: { input_tokens: 1 },
},
tool_calls: [{ function_name: 'str_replace' }],
},
],
})
const calls = await parseTranscript(filePath)
expect(calls).toHaveLength(2)
expect(calls.reduce((sum, call) => sum + call.costUSD, 0)).toBeCloseTo(0.026182499248534442, 15)
expect(calls[0]).toMatchObject({
provider: 'devin',
model: 'Opus 4.6',
inputTokens: 100,
outputTokens: 20,
cacheCreationInputTokens: 10,
cacheReadInputTokens: 5,
cachedInputTokens: 5,
costUSD: 0.02076149918138981,
tools: ['read_file'],
timestamp: '2027-01-15T08:00:01.000Z',
deduplicationKey: 'devin:session-123:2',
userMessage: 'please inspect the repo',
sessionId: 'session-123',
})
expect(calls[1]).toMatchObject({
model: 'Sonnet 4.6',
timestamp: '2027-01-15T08:00:02.000Z',
tools: ['str_replace'],
deduplicationKey: 'devin:session-123:3',
})
})
it('renders Devin generation_model variants as friendly display names with effort tiers', async () => {
await configureDevinRate()
const cases = [
{
schema: '1.4',
modelName: 'GPT-5.4',
location: 'metadata',
generationModel: 'gpt-5-3-codex-xhigh',
expected: 'GPT-5.3 Codex (xhigh)',
},
{
schema: '1.4',
modelName: 'GPT-5.5',
location: 'metadata',
generationModel: 'gpt-5-4-low',
expected: 'GPT-5.4 (low)',
},
{
schema: '1.4',
modelName: 'GPT-5.5',
location: 'metadata',
generationModel: 'gpt-5-5-medium',
expected: 'GPT-5.5 (medium)',
},
{
schema: '1.4',
modelName: 'Gemini 3 Flash',
location: 'metadata',
generationModel: 'MODEL_PRIVATE_11',
expected: 'Gemini 3 Flash',
},
{
schema: '1.4',
modelName: 'Gemini 3 Flash',
location: 'metadata',
generationModel: 'MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL',
expected: 'Gemini 3 Flash',
},
{
schema: '1.7',
modelName: 'GPT-5.3-Codex',
location: 'extra',
generationModel: 'gpt-5-3-codex-xhigh',
expected: 'GPT-5.3 Codex (xhigh)',
},
{
schema: '1.4',
modelName: 'GPT-5',
location: 'metadata',
generationModel: 'gpt-5',
expected: 'GPT-5',
},
{
schema: '1.4',
modelName: 'GPT-5.6',
location: 'metadata',
generationModel: 'gpt-5-6-medium',
expected: 'GPT-5.6 (medium)',
},
{
schema: '1.4',
modelName: 'GPT-5',
location: 'metadata',
generationModel: 'gpt-5-codex-xhigh',
expected: 'GPT-5 Codex (xhigh)',
},
{
schema: '1.4',
modelName: 'GPT-5',
location: 'metadata',
generationModel: 'gpt-5-codex',
expected: 'GPT-5 Codex',
},
{
schema: '1.4',
modelName: 'GPT-4',
location: 'metadata',
generationModel: 'gpt-4-1106-preview',
expected: 'gpt-4-1106-preview',
},
] as const
for (let index = 0; index < cases.length; index++) {
const row = cases[index]!
const metadata: Record<string, unknown> = {
created_at: '2027-01-15T08:00:01.000Z',
committed_acu_cost: 0.1,
metrics: { input_tokens: 1 },
}
const extra: Record<string, unknown> = {}
if (row.location === 'metadata') {
metadata['generation_model'] = row.generationModel
} else {
extra['generation_model'] = row.generationModel
}
const step: Record<string, unknown> = {
step_id: index + 1,
source: 'assistant',
message: 'working',
metadata,
}
if (row.location === 'extra') step['extra'] = extra
const filePath = await writeTranscript(`model-variant-${index}.json`, {
schema_version: row.schema,
session_id: `model-variant-${index}`,
agent: { model_name: row.modelName },
steps: [step],
})
const calls = await parseTranscript(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.model).toBe(row.expected)
}
})
it('leaves already-friendly Devin model display names unchanged', () => {
const provider = createDevinProvider(tmpDir)
expect(provider.modelDisplayName('GPT-5.3 Codex (xhigh)')).toBe('GPT-5.3 Codex (xhigh)')
})
it('includes token-only steps and skips user-input or empty steps', async () => {
await configureDevinRate()
const filePath = await writeTranscript('token-only.json', {
session_id: 'token-session',
agent: { model_name: 'agent-model' },
steps: [
{
step_id: 'user-cost',
metadata: {
is_user_input: true,
committed_acu_cost: 99,
metrics: { input_tokens: 99 },
},
},
{ step_id: 'empty', metadata: { created_at: '2026-06-05T10:00:00.000Z' } },
{
step_id: 'tokens',
metadata: {
created_at: '2026-06-05T10:00:01.000Z',
metrics: { output_tokens: 42 },
},
},
],
})
const calls = await parseTranscript(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.model).toBe('agent-model')
expect(calls[0]!.outputTokens).toBe(42)
expect(calls[0]!.costUSD).toBe(0)
})
it('converts ACUs to costUSD using the configured Devin rate', async () => {
await configureDevinRate(2.5)
const filePath = await writeTranscript('configured-rate.json', {
session_id: 'configured-rate',
agent: { model_name: 'agent-model' },
steps: [
{ step_id: 's1', metadata: { committed_acu_cost: 0.4 } },
],
})
const calls = await parseTranscript(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.costUSD).toBeCloseTo(1, 12)
})
it('falls back to filename session id and deduplicates by step id', async () => {
await configureDevinRate()
const filePath = await writeTranscript('fallback-session.json', {
steps: [
{
step_id: 1,
metadata: {
request_id: 'req-1',
committed_acu_cost: 0.1,
},
},
{
step_id: 2,
metadata: {
created_at: '2026-06-05T10:00:00.000Z',
committed_acu_cost: 0.2,
},
},
],
})
const calls = await parseTranscript(filePath)
expect(calls.map(c => c.sessionId)).toEqual(['fallback-session', 'fallback-session'])
expect(calls.map(c => c.model)).toEqual(['devin', 'devin'])
expect(calls.map(c => c.deduplicationKey)).toEqual([
'devin:fallback-session:1',
'devin:fallback-session:2',
])
})
it('extracts user message from ContentPart[] messages (ATIF v1.7 multimodal)', async () => {
await configureDevinRate()
const filePath = await writeTranscript('content-parts.json', {
session_id: 'cp-session',
agent: { model_name: 'agent-model' },
steps: [
{
step_id: 1,
message: [
{ type: 'text', text: 'look at this screenshot' },
{ type: 'image', source: { media_type: 'image/png', path: '/tmp/screenshot.png' } },
],
metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' },
},
{
step_id: 2,
metadata: {
created_at: '2027-01-15T08:00:01.000Z',
committed_acu_cost: 0.1,
metrics: { input_tokens: 50 },
},
},
],
})
const calls = await parseTranscript(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.userMessage).toBe('look at this screenshot /tmp/screenshot.png')
})
it('parses ATIF v1.7 transcripts with agent.extra, final_metrics, and observations', async () => {
await configureDevinRate()
const filePath = await writeTranscript('atif-v17.json', {
schema_version: '1.7',
session_id: 'v17-session',
agent: {
name: 'devin',
version: '2.0',
model_name: 'claude-sonnet-4-6',
extra: { backend: 'cloud', permission_mode: 'auto' },
},
final_metrics: {
total_prompt_tokens: 500,
total_completion_tokens: 200,
total_cached_tokens: 50,
total_steps: 2,
},
steps: [
{
step_id: 1,
message: 'fix the bug',
metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' },
},
{
step_id: 2,
source: 'assistant',
model_name: 'claude-sonnet-4-6',
message: 'I will read the file first',
tool_calls: [{ tool_call_id: 'tc1', function_name: 'read_file', arguments: { path: 'src/main.ts' } }],
observation: {
results: [{ source_call_id: 'tc1', content: 'file contents here' }],
},
extra: {
committed_acu_cost: 0.15,
generation_model: 'claude-sonnet-4-6',
telemetry: { source: 'devin-cli', operation: 'generate' },
},
metadata: {
created_at: '2027-01-15T08:00:01.000Z',
committed_acu_cost: 0.15,
metrics: { input_tokens: 200, output_tokens: 50, cache_creation_tokens: 20, cache_read_tokens: 10 },
},
},
],
})
const calls = await parseTranscript(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
provider: 'devin',
model: 'Sonnet 4.6',
inputTokens: 200,
outputTokens: 50,
cacheCreationInputTokens: 20,
cacheReadInputTokens: 10,
costUSD: 0.15,
tools: ['read_file'],
userMessage: 'fix the bug',
sessionId: 'v17-session',
})
})
it('handles plain string user messages alongside ContentPart[] messages', async () => {
await configureDevinRate()
const filePath = await writeTranscript('mixed-messages.json', {
session_id: 'mixed-msg-session',
agent: { model_name: 'agent-model' },
steps: [
{
step_id: 1,
message: 'plain text user message',
metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' },
},
{
step_id: 2,
metadata: {
created_at: '2027-01-15T08:00:01.000Z',
committed_acu_cost: 0.1,
metrics: { input_tokens: 50 },
},
},
{
step_id: 3,
message: [{ type: 'text', text: 'multimodal user message' }],
metadata: { is_user_input: true, created_at: '2027-01-15T08:00:02.000Z' },
},
{
step_id: 4,
metadata: {
created_at: '2027-01-15T08:00:03.000Z',
committed_acu_cost: 0.2,
metrics: { input_tokens: 100 },
},
},
],
})
const calls = await parseTranscript(filePath)
expect(calls).toHaveLength(2)
expect(calls[0]!.userMessage).toBe('plain text user message')
expect(calls[1]!.userMessage).toBe('multimodal user message')
})
it('reads ACU cost from step.extra when metadata.committed_acu_cost is absent', async () => {
await configureDevinRate()
const filePath = await writeTranscript('extra-acu.json', {
session_id: 'extra-acu-session',
agent: { model_name: 'agent-model' },
steps: [
{
step_id: 1,
extra: { committed_acu_cost: 0.3 },
metadata: {
created_at: '2027-01-15T08:00:00.000Z',
metrics: { input_tokens: 10 },
},
},
],
})
const calls = await parseTranscript(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.costUSD).toBeCloseTo(0.3, 12)
})
it('prefers metadata.committed_acu_cost over extra.committed_acu_cost', async () => {
await configureDevinRate()
const filePath = await writeTranscript('acu-priority.json', {
session_id: 'acu-priority-session',
agent: { model_name: 'agent-model' },
steps: [
{
step_id: 1,
extra: { committed_acu_cost: 0.99 },
metadata: {
created_at: '2027-01-15T08:00:00.000Z',
committed_acu_cost: 0.11,
metrics: { input_tokens: 10 },
},
},
],
})
const calls = await parseTranscript(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.costUSD).toBeCloseTo(0.11, 12)
})
it('reads tokens from step.metrics when metadata.metrics is absent', async () => {
await configureDevinRate()
const filePath = await writeTranscript('step-metrics.json', {
session_id: 'step-metrics-session',
agent: { model_name: 'agent-model' },
steps: [
{
step_id: 1,
metrics: {
prompt_tokens: 300,
completion_tokens: 75,
cached_tokens: 15,
extra: { cache_creation_input_tokens: 25 },
},
extra: { committed_acu_cost: 0.2 },
metadata: { created_at: '2027-01-15T08:00:00.000Z' },
},
],
})
const calls = await parseTranscript(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
inputTokens: 300,
outputTokens: 75,
cacheCreationInputTokens: 25,
cacheReadInputTokens: 15,
cachedInputTokens: 15,
costUSD: 0.2,
})
})
it('prefers step.metrics over metadata.metrics when both are present', async () => {
await configureDevinRate()
const filePath = await writeTranscript('metrics-priority.json', {
session_id: 'metrics-priority-session',
agent: { model_name: 'agent-model' },
steps: [
{
step_id: 1,
metrics: {
prompt_tokens: 500,
completion_tokens: 100,
cached_tokens: 20,
extra: { cache_creation_input_tokens: 30 },
},
metadata: {
created_at: '2027-01-15T08:00:00.000Z',
committed_acu_cost: 0.1,
metrics: {
input_tokens: 1,
output_tokens: 1,
cache_creation_tokens: 1,
cache_read_tokens: 1,
},
},
},
],
})
const calls = await parseTranscript(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
inputTokens: 500,
outputTokens: 100,
cacheCreationInputTokens: 30,
cacheReadInputTokens: 20,
cachedInputTokens: 20,
})
})
it('handles observation results with ContentPart[] content', async () => {
await configureDevinRate()
const filePath = await writeTranscript('observation-content-parts.json', {
session_id: 'obs-cp-session',
agent: { model_name: 'agent-model' },
steps: [
{
step_id: 1,
message: 'check the image',
metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' },
},
{
step_id: 2,
source: 'assistant',
message: 'reading file',
tool_calls: [{ tool_call_id: 'tc1', function_name: 'read_file', arguments: {} }],
observation: {
results: [{
source_call_id: 'tc1',
content: [
{ type: 'text', text: 'file output here' },
{ type: 'image', source: { media_type: 'image/png', path: '/tmp/output.png' } },
],
}],
},
metadata: {
created_at: '2027-01-15T08:00:01.000Z',
committed_acu_cost: 0.1,
metrics: { input_tokens: 100, output_tokens: 30 },
},
},
],
})
const calls = await parseTranscript(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
tools: ['read_file'],
costUSD: 0.1,
inputTokens: 100,
outputTokens: 30,
})
})
it('falls back to metadata.metrics when step.metrics is present but empty', async () => {
await configureDevinRate()
const filePath = await writeTranscript('empty-step-metrics.json', {
session_id: 'empty-metrics-session',
agent: { model_name: 'agent-model' },
steps: [
{
step_id: 1,
metrics: {},
metadata: {
created_at: '2027-01-15T08:00:00.000Z',
committed_acu_cost: 0.1,
metrics: { input_tokens: 80, output_tokens: 20, cache_read_tokens: 5 },
},
},
],
})
const calls = await parseTranscript(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
inputTokens: 80,
outputTokens: 20,
cacheReadInputTokens: 5,
costUSD: 0.1,
})
})
it('normalizes an image-only ContentPart[] user message to its path', async () => {
await configureDevinRate()
const filePath = await writeTranscript('image-only.json', {
session_id: 'image-only-session',
agent: { model_name: 'agent-model' },
steps: [
{
step_id: 1,
message: [
{ type: 'image', source: { media_type: 'image/png', path: '/tmp/only.png' } },
],
metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' },
},
{
step_id: 2,
metadata: {
created_at: '2027-01-15T08:00:01.000Z',
committed_acu_cost: 0.1,
metrics: { input_tokens: 40 },
},
},
],
})
const calls = await parseTranscript(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.userMessage).toBe('/tmp/only.png')
})
it('ignores array-root and malformed transcripts', async () => {
await configureDevinRate()
const arrayPath = await writeTranscript('array.json', [])
const malformedPath = join(tmpDir, 'transcripts', 'bad.json')
await writeFile(malformedPath, '{')
expect(await parseTranscript(arrayPath)).toEqual([])
expect(await parseTranscript(malformedPath)).toEqual([])
})
it('deduplicates calls with a shared seen key set', async () => {
await configureDevinRate()
const filePath = await writeTranscript('dupe.json', {
session_id: 'dupe-session',
steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.5 } }],
})
const provider = createDevinProvider(tmpDir)
const seenKeys = new Set<string>()
const source = { path: filePath, project: 'devin', provider: 'devin' }
const first: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, seenKeys).parse()) first.push(call)
const second: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, seenKeys).parse()) second.push(call)
expect(first).toHaveLength(1)
expect(second).toHaveLength(0)
})
})
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
skipUnlessSqlite('devin provider sessions.db enrichment', () => {
it('uses sessions.db to enrich project, projectPath, model, and timestamp fallbacks', async () => {
await configureDevinRate()
createSessionsDb()
const filePath = await writeTranscript('db-session.json', {
session_id: 'db-session',
steps: [
{
step_id: 's1',
metadata: {
committed_acu_cost: 0.25,
metrics: { input_tokens: 10 },
},
},
],
})
const calls = await parseTranscript(filePath, 'fallback-project')
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
model: 'Sonnet 4.6',
project: 'codeburn',
projectPath: '/Users/example/work/codeburn',
timestamp: '2027-01-15T08:00:10.000Z',
costUSD: 0.25,
})
})
it('uses sessions.db project labels during discovery when transcript filename matches the session id', async () => {
await configureDevinRate()
createSessionsDb()
const filePath = await writeTranscript('db-session.json', { session_id: 'db-session', steps: [] })
const provider = createDevinProvider(tmpDir)
const sources = await provider.discoverSessions()
expect(sources).toEqual([
{ path: filePath, project: 'codeburn', provider: 'devin' },
])
})
it('skips sessions hidden in sessions.db', async () => {
await configureDevinRate()
createSessionsDb()
await writeTranscript('hidden-session.json', {
session_id: 'hidden-session',
steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.25 } }],
})
const provider = createDevinProvider(tmpDir)
expect(await provider.discoverSessions()).toEqual([])
const calls = await parseTranscript(join(tmpDir, 'transcripts', 'hidden-session.json'))
expect(calls).toEqual([])
})
})
+148
View File
@@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createDroidProvider } from '../../src/providers/droid.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let factoryDir: string
async function writeSession(opts: {
projectDir?: string
sessionId?: string
lines?: unknown[]
settings?: unknown
subdir?: string
}): Promise<string> {
const sessionId = opts.sessionId ?? 'session-1'
const projectDir = opts.projectDir ?? '/tmp/my-project'
const subdir = opts.subdir ?? '-tmp-my-project'
const dir = join(factoryDir, 'sessions', subdir)
await mkdir(dir, { recursive: true })
const jsonlPath = join(dir, `${sessionId}.jsonl`)
const lines = opts.lines ?? [
{ type: 'session_start', id: sessionId, cwd: projectDir, title: 'Test session' },
{ type: 'message', id: 'u1', timestamp: '2026-04-20T10:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'build this' }] } },
{ type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] } },
]
await writeFile(jsonlPath, lines.map(line => JSON.stringify(line)).join('\n'))
if (opts.settings !== undefined) {
await writeFile(join(dir, `${sessionId}.settings.json`), JSON.stringify(opts.settings))
}
return jsonlPath
}
async function parseAll(filePath: string, seen = new Set<string>()): Promise<ParsedProviderCall[]> {
const provider = createDroidProvider(factoryDir)
const parser = provider.createSessionParser({ path: filePath, project: 'proj', provider: 'droid' }, seen)
const calls: ParsedProviderCall[] = []
for await (const call of parser.parse()) calls.push(call)
return calls
}
describe('droid provider', () => {
beforeEach(async () => {
factoryDir = await mkdtemp(join(tmpdir(), 'codeburn-droid-test-'))
})
afterEach(async () => {
await rm(factoryDir, { recursive: true, force: true })
})
it('discovers Droid JSONL sessions', async () => {
await writeSession({ settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } } })
const provider = createDroidProvider(factoryDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.provider).toBe('droid')
expect(sessions[0]!.path.endsWith('session-1.jsonl')).toBe(true)
})
it('parses calls and distributes session-level token usage', async () => {
const path = await writeSession({
lines: [
{ type: 'session_start', id: 'session-1', cwd: '/tmp/my-project' },
{ type: 'message', id: 'u1', timestamp: '2026-04-20T10:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: '<system-reminder>x</system-reminder>' }, { type: 'text', text: 'build this' }] } },
{ type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'first' }] } },
{ type: 'message', id: 'a2', timestamp: '2026-04-20T10:00:02.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'second' }] } },
],
settings: { model: 'custom:gpt-5-[Proxy]-0', tokenUsage: { inputTokens: 101, outputTokens: 51, cacheCreationTokens: 7, cacheReadTokens: 11, thinkingTokens: 5 } },
})
const calls = await parseAll(path)
expect(calls).toHaveLength(2)
expect(calls[0]!.provider).toBe('droid')
expect(calls[0]!.model).toBe('gpt-5')
expect(calls[0]!.inputTokens).toBe(50)
expect(calls[1]!.inputTokens).toBe(51)
expect(calls[0]!.outputTokens).toBe(25)
expect(calls[1]!.outputTokens).toBe(26)
expect(calls[0]!.cacheReadInputTokens).toBe(5)
expect(calls[1]!.cacheReadInputTokens).toBe(6)
expect(calls[0]!.userMessage).toBe('build this')
expect(calls[0]!.sessionId).toBe('session-1')
})
it('extracts tools and meaningful bash command names', async () => {
const path = await writeSession({
lines: [
{ type: 'session_start', id: 'session-1', cwd: '/tmp/my-project' },
{ type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', message: { role: 'assistant', content: [
{ type: 'tool_use', name: 'Execute', input: { command: "python3 - <<'PY'\nimport os\n}\nPY" } },
{ type: 'tool_use', name: 'Read', input: { file_path: '/tmp/a' } },
{ type: 'tool_use', name: 'Task', input: { prompt: 'do work' } },
] } },
],
settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } },
})
const calls = await parseAll(path)
expect(calls).toHaveLength(1)
expect(calls[0]!.tools).toEqual(['Bash', 'Read', 'Agent'])
expect(calls[0]!.bashCommands).toContain('python3')
expect(calls[0]!.bashCommands).not.toContain('import')
expect(calls[0]!.bashCommands).not.toContain('}')
})
it('deduplicates calls by session and message id', async () => {
const path = await writeSession({ settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } } })
const seen = new Set<string>()
expect(await parseAll(path, seen)).toHaveLength(1)
expect(await parseAll(path, seen)).toHaveLength(0)
})
it('strips Droid model wrappers for display', () => {
const provider = createDroidProvider(factoryDir)
expect(provider.modelDisplayName('custom:GLM-5.1-[Proxy]-0')).toBe('GLM-5.1')
expect(provider.modelDisplayName('custom:claude-sonnet-4-6-1')).toBe('Sonnet 4.6')
})
it('returns no calls when settings are missing', async () => {
const path = await writeSession({})
expect(await parseAll(path)).toHaveLength(0)
})
it('skips internal .factory sessions during discovery', async () => {
await writeSession({ projectDir: factoryDir, subdir: '-internal', settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } } })
const provider = createDroidProvider(factoryDir)
expect(await provider.discoverSessions()).toHaveLength(0)
})
it('returns no calls for empty sessions', async () => {
const path = await writeSession({
lines: [{ type: 'session_start', id: 'empty', cwd: '/tmp/my-project' }],
settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } },
})
expect(await parseAll(path)).toHaveLength(0)
})
})
+275
View File
@@ -0,0 +1,275 @@
import { mkdtemp, readFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createRequire } from 'node:module'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { isSqliteAvailable } from '../../src/sqlite.js'
import { createForgeProvider } from '../../src/providers/forge.js'
import { getProvider } from '../../src/providers/index.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
const requireForTest = createRequire(import.meta.url)
type TestDb = {
exec(sql: string): void
prepare(sql: string): { run(...params: unknown[]): void }
close(): void
}
let tmpRoot: string
beforeEach(async () => {
tmpRoot = await mkdtemp(join(tmpdir(), 'forge-test-'))
})
afterEach(async () => {
await rm(tmpRoot, { recursive: true, force: true })
})
function createForgeDb(): string {
const dbPath = join(tmpRoot, 'forge.db')
const { DatabaseSync: Database } = requireForTest('node:sqlite')
const db = new Database(dbPath)
db.exec(`
CREATE TABLE conversations(
conversation_id TEXT PRIMARY KEY NOT NULL,
title TEXT,
workspace_id BIGINT NOT NULL,
context TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
metrics TEXT
)
`)
db.close()
return dbPath
}
function withTestDb(dbPath: string, fn: (db: TestDb) => void): void {
const { DatabaseSync: Database } = requireForTest('node:sqlite')
const db = new Database(dbPath)
try {
fn(db)
} finally {
db.close()
}
}
function insertConversationRow(db: TestDb, overrides: {
conversationId?: string
title?: string | null
workspaceId?: number | string
context?: string | null
createdAt?: string
updatedAt?: string | null
} = {}): void {
db.prepare(`
INSERT INTO conversations (conversation_id, title, workspace_id, context, created_at, updated_at, metrics)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
overrides.conversationId ?? 'conv-1',
'title' in overrides ? overrides.title : 'Forge Project',
overrides.workspaceId ?? 123,
overrides.context ?? null,
overrides.createdAt ?? '2026-05-06 15:00:00',
'updatedAt' in overrides ? overrides.updatedAt : '2026-05-06 15:20:41.379094',
null,
)
}
function insertConversation(db: TestDb): void {
const context = {
conversation_id: 'conv-1',
messages: [
{ message: { text: { role: 'User', content: 'implement forge' } } },
{
message: {
text: {
role: 'Assistant',
content: '',
model: 'claude-opus-4-6',
tool_calls: [
{ name: 'shell', call_id: 'call-1', arguments: { command: 'git status && npm test' } },
{ name: 'Read', call_id: 'call-2', arguments: { file_path: '/tmp/a' } },
],
},
},
usage: {
prompt_tokens: { actual: 1200 },
completion_tokens: { actual: 300 },
total_tokens: { actual: 1500 },
cached_tokens: { actual: 200 },
},
},
],
}
insertConversationRow(db, { context: JSON.stringify(context) })
}
async function collect(parser: { parse(): AsyncGenerator<ParsedProviderCall> }): Promise<ParsedProviderCall[]> {
const out: ParsedProviderCall[] = []
for await (const call of parser.parse()) out.push(call)
return out
}
describe('forge provider', () => {
it('discovers conversations with context and parses assistant usage/tool calls', async () => {
if (!isSqliteAvailable()) return
const dbPath = createForgeDb()
withTestDb(dbPath, insertConversation)
const provider = createForgeProvider(dbPath)
const sources = await provider.discoverSessions()
expect(sources).toEqual([
{
path: `${dbPath}:conv-1`,
project: 'Forge Project',
provider: 'forge',
},
])
const seenKeys = new Set<string>()
const calls = await collect(provider.createSessionParser(sources[0]!, seenKeys))
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
provider: 'forge',
model: 'claude-opus-4-6',
inputTokens: 1000,
outputTokens: 300,
cacheReadInputTokens: 200,
cachedInputTokens: 200,
cacheCreationInputTokens: 0,
tools: ['Bash', 'Read'],
bashCommands: ['git', 'npm'],
userMessage: 'implement forge',
sessionId: 'conv-1',
timestamp: '2026-05-06T15:20:41.379Z',
deduplicationKey: 'forge:conv-1:call-1',
})
const duplicates = await collect(provider.createSessionParser(sources[0]!, seenKeys))
expect(duplicates).toEqual([])
})
it('does not select conversation context while discovering sessions', async () => {
const source = await readFile(new URL('../../src/providers/forge.ts', import.meta.url), 'utf8')
const discoverySql = source.match(/async function discoverFromDb[\s\S]*?db\.query<[^>]+>\(\s*`([\s\S]*?)`/)?.[1]
const selectedColumns = discoverySql?.split('FROM conversations')[0] ?? ''
expect(discoverySql).toContain('WHERE context IS NOT NULL')
expect(selectedColumns).not.toMatch(/\bcontext\b/)
expect(selectedColumns).not.toMatch(/\bcreated_at\b|\bupdated_at\b/)
})
it('returns no sessions when the database is missing', async () => {
const provider = createForgeProvider(join(tmpRoot, 'missing.db'))
await expect(provider.discoverSessions()).resolves.toEqual([])
})
it('skips zero-token assistant usage', async () => {
if (!isSqliteAvailable()) return
const dbPath = createForgeDb()
const context = {
messages: [
{ message: { text: { role: 'User', content: 'zero tokens' } } },
{
message: { text: { role: 'Assistant', model: 'claude-opus-4-6' } },
usage: {
prompt_tokens: { actual: 0 },
completion_tokens: { actual: 0 },
cached_tokens: { actual: 0 },
},
},
],
}
withTestDb(dbPath, db => insertConversationRow(db, { context: JSON.stringify(context) }))
const provider = createForgeProvider(dbPath)
const sources = await provider.discoverSessions()
const calls = await collect(provider.createSessionParser(sources[0]!, new Set()))
expect(calls).toEqual([])
})
it('parses multiple assistant messages with the nearest previous user prompt', async () => {
if (!isSqliteAvailable()) return
const dbPath = createForgeDb()
const context = {
messages: [
{ message: { text: { role: 'User', content: 'first request' } } },
{
message: { text: { role: 'Assistant', model: 'claude-opus-4-6', tool_calls: [{ name: 'shell', call_id: 'call-1', arguments: { command: 'npm test' } }] } },
usage: { prompt_tokens: { actual: 100 }, completion_tokens: { actual: 20 } },
},
{ message: { text: { role: 'User', content: 'second request' } } },
{
message: { text: { role: 'Assistant', model: 'claude-sonnet-4-6', tool_calls: [{ name: 'Read', call_id: 'call-2', arguments: { file_path: '/tmp/a' } }] } },
usage: { prompt_tokens: { actual: 200 }, completion_tokens: { actual: 30 } },
},
],
}
withTestDb(dbPath, db => insertConversationRow(db, { context: JSON.stringify(context) }))
const provider = createForgeProvider(dbPath)
const sources = await provider.discoverSessions()
const calls = await collect(provider.createSessionParser(sources[0]!, new Set()))
expect(calls).toHaveLength(2)
expect(calls.map(call => call.userMessage)).toEqual(['first request', 'second request'])
expect(calls.map(call => call.deduplicationKey)).toEqual(['forge:conv-1:call-1', 'forge:conv-1:call-2'])
})
it('uses workspace_id as project when title is null', async () => {
if (!isSqliteAvailable()) return
const dbPath = createForgeDb()
withTestDb(dbPath, db => insertConversationRow(db, { title: null, workspaceId: 'workspace-1', context: '{}' }))
const provider = createForgeProvider(dbPath)
const sources = await provider.discoverSessions()
expect(sources[0]?.project).toBe('workspace-1')
})
it('uses large integer workspace_id values as strings when title is null', async () => {
if (!isSqliteAvailable()) return
const dbPath = createForgeDb()
withTestDb(dbPath, db => {
db.exec(`
INSERT INTO conversations (conversation_id, title, workspace_id, context, created_at, updated_at, metrics)
VALUES ('conv-1', NULL, 8549909960051246556, '{}', '2026-05-06 15:00:00', '2026-05-06 15:20:41.379094', NULL)
`)
})
const provider = createForgeProvider(dbPath)
const sources = await provider.discoverSessions()
expect(sources[0]?.project).toBe('8549909960051246556')
})
it('does not throw and yields no calls for invalid JSON context', async () => {
if (!isSqliteAvailable()) return
const dbPath = createForgeDb()
withTestDb(dbPath, db => insertConversationRow(db, { context: '{invalid' }))
const provider = createForgeProvider(dbPath)
const sources = await provider.discoverSessions()
await expect(collect(provider.createSessionParser(sources[0]!, new Set()))).resolves.toEqual([])
})
it('is available through the provider registry', async () => {
const provider = await getProvider('forge')
expect(provider?.name).toBe('forge')
})
})
+193
View File
@@ -0,0 +1,193 @@
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createGeminiProvider } from '../../src/providers/gemini.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gemini-provider-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
async function parseFixture(messages: unknown[]): Promise<ParsedProviderCall[]> {
const filePath = join(tmpDir, 'session-gemini.json')
await writeFile(filePath, JSON.stringify({
sessionId: 'gemini-session-1',
startTime: '2026-05-16T10:00:00.000Z',
messages,
}))
const provider = createGeminiProvider()
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser({ path: filePath, project: 'gemini-project', provider: 'gemini' }, new Set()).parse()) {
calls.push(call)
}
return calls
}
describe('gemini provider', () => {
it('emits one provider call per Gemini message with token usage', async () => {
const calls = await parseFixture([
{
id: 'u1',
timestamp: '2026-05-16T10:00:00.000Z',
type: 'user',
content: 'inspect the repo',
},
{
id: 'g1',
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'reading files',
model: 'gemini-3.1-pro-preview',
tokens: { input: 120, cached: 20, output: 30, thoughts: 5 },
toolCalls: [{ id: 't1', name: 'read_file', args: { path: 'src/index.ts' } }],
},
{
id: 'u2',
timestamp: '2026-05-16T10:01:00.000Z',
type: 'user',
content: [{ text: 'run tests' }],
},
{
id: 'g2',
timestamp: '2026-05-16T10:01:10.000Z',
type: 'gemini',
content: 'running tests',
model: 'gemini-3.1-pro-preview',
tokens: { input: 80, cached: 10, output: 25 },
toolCalls: [{ id: 't2', name: 'run_command', args: { command: 'npm test' } }],
},
])
expect(calls).toHaveLength(2)
expect(calls.map(c => c.deduplicationKey)).toEqual([
'gemini:gemini-session-1:g1',
'gemini:gemini-session-1:g2',
])
expect(calls.map(c => c.timestamp)).toEqual([
'2026-05-16T10:00:05.000Z',
'2026-05-16T10:01:10.000Z',
])
expect(calls.map(c => c.userMessage)).toEqual(['inspect the repo', 'run tests'])
expect(calls[0]!.inputTokens).toBe(100)
expect(calls[0]!.cacheReadInputTokens).toBe(20)
expect(calls[0]!.reasoningTokens).toBe(5)
expect(calls[0]!.tools).toEqual(['Read'])
expect(calls[1]!.inputTokens).toBe(70)
expect(calls[1]!.cacheReadInputTokens).toBe(10)
expect(calls[1]!.tools).toEqual(['Bash'])
expect(calls[1]!.bashCommands).toEqual(['npm'])
})
it('keeps aggregate token totals when splitting a Gemini session into calls', async () => {
const calls = await parseFixture([
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
{
id: 'g1',
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'first',
model: 'gemini-3.1-pro-preview',
tokens: { input: 120, cached: 20, output: 30, thoughts: 5 },
},
{
id: 'g2',
timestamp: '2026-05-16T10:00:10.000Z',
type: 'gemini',
content: 'second',
model: 'gemini-3.1-pro-preview',
tokens: { input: 80, cached: 10, output: 25, thoughts: 0 },
},
])
expect(calls).toHaveLength(2)
expect(calls.reduce((sum, call) => sum + call.inputTokens, 0)).toBe(170)
expect(calls.reduce((sum, call) => sum + call.cacheReadInputTokens, 0)).toBe(30)
expect(calls.reduce((sum, call) => sum + call.outputTokens, 0)).toBe(55)
expect(calls.reduce((sum, call) => sum + call.reasoningTokens, 0)).toBe(5)
})
it('skips Gemini messages without token usage', async () => {
const calls = await parseFixture([
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
{
id: 'info',
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'tool-only notice',
model: 'gemini-3.1-pro-preview',
},
])
expect(calls).toEqual([])
})
it('uses a deterministic ordinal key when Gemini message ids are missing', async () => {
const messages = [
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
{
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'first',
model: 'gemini-3.1-pro-preview',
tokens: { input: 10, output: 5 },
},
{
timestamp: '2026-05-16T10:00:10.000Z',
type: 'gemini',
content: 'second',
model: 'gemini-3.1-pro-preview',
tokens: { input: 12, output: 6 },
},
]
const first = await parseFixture(messages)
const second = await parseFixture(messages)
expect(first.map(c => c.deduplicationKey)).toEqual([
'gemini:gemini-session-1:idx-0',
'gemini:gemini-session-1:idx-1',
])
expect(second.map(c => c.deduplicationKey)).toEqual(first.map(c => c.deduplicationKey))
})
it('does not poison seenKeys when a Gemini message timestamp is invalid', async () => {
const filePath = join(tmpDir, 'session-gemini.json')
await writeFile(filePath, JSON.stringify({
sessionId: 'gemini-session-1',
startTime: '2026-05-16T10:00:00.000Z',
messages: [
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
{
id: 'g1',
timestamp: 'not-a-date',
type: 'gemini',
content: 'first',
model: 'gemini-3.1-pro-preview',
tokens: { input: 10, output: 5 },
},
],
}))
const provider = createGeminiProvider()
const seenKeys = new Set<string>()
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(
{ path: filePath, project: 'gemini-project', provider: 'gemini' },
seenKeys,
).parse()) {
calls.push(call)
}
expect(calls).toEqual([])
expect(seenKeys.has('gemini:gemini-session-1:g1')).toBe(false)
})
})
+187
View File
@@ -0,0 +1,187 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createGrokProvider } from '../../src/providers/grok.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'grok-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
// Mirrors the real on-disk layout:
// <sessionsDir>/<url-encoded-cwd>/<uuid>/{summary.json, signals.json, updates.jsonl}
async function writeSession(opts: {
cwdEncoded?: string
uuid?: string
cwd?: string
model?: string
turns?: Array<{ promptId: string; totals: number[] }>
toolCalls?: Array<{ title: string; rawInput: Record<string, unknown> }>
toolsUsed?: string[]
} = {}) {
const cwdEncoded = opts.cwdEncoded ?? '%2FUsers%2Ftest'
const uuid = opts.uuid ?? '019edf9c-0000-7000-8000-000000000001'
const cwd = opts.cwd ?? '/Users/test/myproject'
const model = opts.model ?? 'grok-build'
const dir = join(tmpDir, cwdEncoded, uuid)
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'summary.json'), JSON.stringify({
info: { id: uuid, cwd },
created_at: '2026-06-19T11:20:40.686261Z',
updated_at: '2026-06-19T11:31:12.282793Z',
last_active_at: '2026-06-19T11:31:12.222328Z',
num_messages: 42,
current_model_id: model,
session_summary: 'User asks about the repo',
generated_title: 'User asks about the repo',
}))
await writeFile(join(dir, 'signals.json'), JSON.stringify({
primaryModelId: model,
modelsUsed: [model],
toolsUsed: opts.toolsUsed ?? ['read_file', 'run_terminal_command', 'grep'],
contextTokensUsed: 40000,
contextWindowTokens: 512000,
}))
const turns = opts.turns ?? [
{ promptId: 'p1', totals: [20000, 25000] },
{ promptId: 'p2', totals: [30000, 35000] },
{ promptId: 'p3', totals: [40000, 45000] },
]
const lines: string[] = []
for (const turn of turns) {
for (const total of turn.totals) {
lines.push(JSON.stringify({
timestamp: '2026-06-19T11:30:00.000Z',
method: 'session/update',
params: {
sessionId: uuid,
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } },
_meta: { totalTokens: total, promptId: turn.promptId, updateType: 'AgentMessageChunk', modelId: model },
},
}))
}
}
for (const tc of opts.toolCalls ?? [
{ title: 'read_file', rawInput: { target_directory: '.' } },
{ title: 'grep', rawInput: { pattern: 'x' } },
{ title: 'run_terminal_command', rawInput: { command: 'git status' } },
{ title: 'spawn_subagent', rawInput: { subagent_type: 'general-purpose', prompt: 'x' } },
]) {
lines.push(JSON.stringify({
timestamp: '2026-06-19T11:30:05.000Z',
method: 'session/update',
params: { sessionId: uuid, update: { sessionUpdate: 'tool_call', toolCallId: 'c1', title: tc.title, rawInput: tc.rawInput } },
}))
}
await writeFile(join(dir, 'updates.jsonl'), lines.join('\n') + '\n')
return { dir, uuid }
}
describe('grok provider - discovery', () => {
it('discovers each session dir and derives project from cwd', async () => {
await writeSession({ cwd: '/Users/test/myproject' })
const sessions = await createGrokProvider(tmpDir).discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.provider).toBe('grok')
expect(sessions[0]!.project).toBe('myproject')
expect(sessions[0]!.path).toMatch(/updates\.jsonl$/)
})
it('returns empty for a non-existent sessions dir', async () => {
const sessions = await createGrokProvider('/nope/does/not/exist').discoverSessions()
expect(sessions).toEqual([])
})
it('skips directories without a summary.json', async () => {
await mkdir(join(tmpDir, '%2Ftmp', 'not-a-session'), { recursive: true })
const sessions = await createGrokProvider(tmpDir).discoverSessions()
expect(sessions).toEqual([])
})
})
describe('grok provider - parsing', () => {
async function parse(seen = new Set<string>()) {
const provider = createGrokProvider(tmpDir)
const [source] = await provider.discoverSessions()
const calls: ParsedProviderCall[] = []
if (!source) return calls
for await (const call of provider.createSessionParser(source, seen).parse()) {
calls.push(call)
}
return calls
}
it('emits one estimated call per session from the totalTokens curve', async () => {
await writeSession()
const calls = await parse()
expect(calls).toHaveLength(1)
const call = calls[0]!
expect(call.model).toBe('grok-build')
// input = peak context (max totalTokens across the session)
expect(call.inputTokens).toBe(45000)
// cache reads = re-sent context (sum of per-turn starts 90000 minus peak 45000)
expect(call.cacheReadInputTokens).toBe(45000)
// output = sum of per-turn growth (3 turns x 5000)
expect(call.outputTokens).toBe(15000)
expect(call.costIsEstimated).toBe(true)
expect(call.costUSD).toBeGreaterThan(0)
expect(call.tools).toEqual(['Read', 'Grep', 'Bash', 'Agent'])
expect(call.bashCommands).toContain('git')
expect(call.subagentTypes).toEqual(['general-purpose'])
expect(call.project).toBe('myproject')
expect(call.deduplicationKey).toContain('grok:')
})
it('skips a session with no token growth', async () => {
await writeSession({ turns: [{ promptId: 'p1', totals: [0, 0] }] })
expect(await parse()).toHaveLength(0)
})
it('deduplicates across repeated parses', async () => {
await writeSession()
const seen = new Set<string>()
expect(await parse(seen)).toHaveLength(1)
expect(await parse(seen)).toHaveLength(0)
})
it('sums fresh input across a compaction instead of only the last peak', async () => {
await writeSession({ turns: [
{ promptId: 'p1', totals: [100000, 400000] },
{ promptId: 'p2', totals: [20000, 50000] },
] })
const calls = await parse()
expect(calls).toHaveLength(1)
// 400k (segment 1 peak) + 50k (post-compaction segment), not just the 400k global peak
expect(calls[0]!.inputTokens).toBe(450000)
})
})
describe('grok provider - display names', () => {
const provider = createGrokProvider('/tmp')
it('has the right name and displayName', () => {
expect(provider.name).toBe('grok')
expect(provider.displayName).toBe('Grok Build')
})
it('labels grok-build', () => {
expect(provider.modelDisplayName('grok-build')).toBe('Grok Build')
})
it('normalizes tool names', () => {
expect(provider.toolDisplayName('run_terminal_command')).toBe('Bash')
expect(provider.toolDisplayName('mystery_tool')).toBe('mystery_tool')
})
})

Some files were not shown because too many files have changed in this diff Show More