chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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) })
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
+2
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
@@ -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."}]}}
|
||||
+5
@@ -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}}
|
||||
+3
@@ -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"}}
|
||||
Vendored
+3
@@ -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
@@ -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
@@ -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
@@ -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}}}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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."')
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 transcript’s 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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']))
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user