// 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, setParseReuseValidator } from '../src/parser.js' import { loadCache, saveCache } from '../src/session-cache.js' import { readCacheOnDisk, writeCacheOnDisk } from './fixtures/session-cache-io.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[] = [] let _synthParseCalls = 0 let _synthOnParse: (() => void | Promise) | null = null vi.mock('../src/providers/index.js', async (importOriginal) => { type Mod = typeof import('../src/providers/index.js') const actual = await importOriginal() 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): SessionParser { return { async *parse(): AsyncGenerator { _synthParseCalls++ await _synthOnParse?.() 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 = { '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 { const dir = join(sessionStateDir, sessionId) await mkdir(dir, { recursive: true }) await writeFile(join(dir, 'workspace.yaml'), `id: ${sessionId}\ncwd: /home/user/testproj\n`) // Relative timestamps: fixed calendar dates rot. The original '2026-05-01' // crossed copilot's durable 90-day age-out on 2026-07-30, at which point the // very first parse pruned the freshly-cached session and both durable tests // started failing everywhere with "expected +0 to be 200". const base = Date.now() - 5 * 24 * 60 * 60 * 1000 const at = (offsetSec: number): string => new Date(base + offsetSec * 1000).toISOString() const lines = [ JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'gpt-4.1' } }), JSON.stringify({ type: 'user.message', timestamp: at(5), data: { content: 'hello', interactionId: 'int-1' } }), JSON.stringify({ type: 'assistant.message', timestamp: at(10), 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>): 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>): 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 = [] _synthParseCalls = 0 _synthOnParse = null }) afterEach(async () => { clearSessionCache() setParseReuseValidator(null) vi.unstubAllEnvs() _synthSources = [] _synthOnParse = null 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('keeps a discovered 91-day source persisted until discovery removes it', 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 refresh: a still-discovered durable source is live and persisted, // regardless of the age of its newest call. const proj1 = await parseAllSessions(undefined, 'test-synthetic') expect.soft(totalOutput(proj1)).toBe(8) expect.soft(_synthParseCalls).toBe(1) const cache1 = await loadCache() const persisted1 = cache1.providers['test-synthetic']?.files[synthFile] expect.soft(persisted1).toBeDefined() // Second refresh: force the public seam through the persisted cache. The // unchanged fingerprint must serve the cached parse without invoking the // provider parser again. clearSessionCache() const proj2 = await parseAllSessions(undefined, 'test-synthetic') expect.soft(totalOutput(proj2)).toBe(8) expect.soft(_synthParseCalls).toBe(1) const cache2 = await loadCache() expect.soft(cache2.providers['test-synthetic']?.files[synthFile]?.fingerprint) .toEqual(persisted1?.fingerprint) // Third refresh: once discovery removes the old source, it becomes an // orphan and the durable 90-day age-out prunes it from results and disk. clearSessionCache() _synthSources = [] const proj3 = await parseAllSessions(undefined, 'test-synthetic') expect.soft(totalOutput(proj3)).toBe(0) const cache3 = await loadCache() expect.soft(cache3.providers['test-synthetic']?.files[synthFile]).toBeUndefined() }) it('keeps a discovered 91-day source through a month-scoped refresh', async () => { const synthFile = join(tmpHome, 'synth-scoped.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-scoped', userMessage: 'old', sessionId: 'synth-old-scoped', }] expect.soft(totalOutput(await parseAllSessions(undefined, 'test-synthetic'))).toBe(8) // A today-ranged refresh loads under a month scope that excludes the entry's // shard. Durable providers are never scoped, so the age-out still sees the // entry as discovered and the save must carry its month across intact. clearSessionCache() const today = new Date() const start = new Date(today); start.setHours(0, 0, 0, 0) const end = new Date(today); end.setHours(23, 59, 59, 999) expect.soft(totalOutput(await parseAllSessions({ start, end }, 'test-synthetic'))).toBe(0) clearSessionCache() expect.soft(totalOutput(await parseAllSessions(undefined, 'test-synthetic'))).toBe(8) expect.soft(_synthParseCalls).toBe(1) const cache = await loadCache() expect.soft(cache.providers['test-synthetic']?.files[synthFile]).toBeDefined() }) 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) }) }) // ═══════════════════════════════════════════════════════════════════════════ // (f) Version-bump survival: a PROVIDER_PARSE_VERSIONS bump (or any env // fingerprint change) must NOT erase durable orphans. The cache is the // only remaining record of usage whose source was pruned; discarding the // section wholesale on fingerprint mismatch permanently lost that history // (caught in the #684 re-review). // ═══════════════════════════════════════════════════════════════════════════ describe('(f) durable orphans survive a parse-version bump', () => { it('keeps counting a pruned-source orphan after the provider fingerprint changes', 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')) // Parse once so the session is cached, then prune the source: the cache // entry becomes a durable orphan (its only record). const eventsPath = await createJsonlSession(sessionStateDir, 'sess-bump', 200) const before = totalOutput(await parseAllSessions(undefined, 'copilot')) expect(before).toBe(200) await unlink(eventsPath) clearSessionCache() // Simulate the fingerprint a PREVIOUS release computed (any mismatching // value takes the same code path as a real parse-version bump). const disk = await readCacheOnDisk() expect(disk.providers['copilot']).toBeDefined() disk.providers['copilot']!.envFingerprint = '0000000000000000' await writeCacheOnDisk(disk) // First parse after the "upgrade": the orphan must still be counted and // must survive in the rewritten cache, not be erased with the section. const after = totalOutput(await parseAllSessions(undefined, 'copilot')) expect(after).toBe(200) clearSessionCache() const again = totalOutput(await parseAllSessions(undefined, 'copilot')) expect(again).toBe(200) }) }) // ═══════════════════════════════════════════════════════════════════════════ // (g) Skill attribution is independent of turn category // ═══════════════════════════════════════════════════════════════════════════ describe('(g) skill attribution is independent of turn category', () => { it('puts a Skill + Edit turn in skillBreakdown while preserving coding category', async () => { const synthFile = join(tmpHome, 'synth-skill.txt') await writeFile(synthFile, 'placeholder') _synthSources = [{ path: synthFile, project: 'test', provider: 'test-synthetic' }] _synthYields = [{ provider: 'test-synthetic', model: 'gpt-4o', inputTokens: 10, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, costUSD: 0.001, tools: ['Skill', 'Edit'], bashCommands: [], skills: ['telemetry-review'], timestamp: '2026-07-18T12:00:00.000Z', speed: 'standard', deduplicationKey: 'synth-skill-edit', userMessage: '', sessionId: 'synth-skill-session', }] const projects = await parseAllSessions(undefined, 'test-synthetic') const session = projects.flatMap(project => project.sessions)[0] expect(session).toBeDefined() expect(session!.turns[0]!.category).toBe('coding') expect(session!.turns[0]!.subCategory).toBe('telemetry-review') expect(session!.categoryBreakdown.coding.turns).toBe(1) expect(session!.skillBreakdown['telemetry-review']?.turns).toBe(1) }) }) // ═══════════════════════════════════════════════════════════════════════════ // (h) Provider filter isolates claude: a --provider run must not // re-surface cached claude sessions through the orphan pass, while a run // that DOES include claude still preserves PR-bearing orphans. // ═══════════════════════════════════════════════════════════════════════════ describe('(h) provider filter excludes claude from the orphan pass', () => { const SYNTH_SOURCE = (path: string): SessionSource[] => [{ path, project: 'synth-proj', provider: 'test-synthetic' }] // The provider lives on each parsed call, not on SessionSummary. const providersOf = (projects: Awaited>): Set => new Set(projects .flatMap(p => p.sessions) .flatMap(s => s.turns) .flatMap(t => t.assistantCalls) .map(c => c.provider)) const SYNTH_CALL: ParsedProviderCall = { provider: 'test-synthetic', model: 'gpt-4o', inputTokens: 10, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, costUSD: 0.25, tools: [], bashCommands: [], skills: [], timestamp: '2026-07-18T12:00:00.000Z', speed: 'standard', deduplicationKey: 'synth-isolation-call', userMessage: '', sessionId: 'synth-isolation-session', } // A claude transcript carrying a pr-link: `prLinks` is exactly what lets a // cached entry survive the write-mode orphan gate, so it is the shape that // leaks. Cost is deliberately far larger than the synthetic call's, so a leak // is unmistakable rather than a rounding difference. async function writeClaudeSessionWithPrLink(): Promise { const projectDir = join(tmpHome, '.claude', 'projects', 'leaky-app') await mkdir(projectDir, { recursive: true }) const filePath = join(projectDir, 'session.jsonl') await writeFile(filePath, [ JSON.stringify({ type: 'user', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:00.000Z', message: { role: 'user', content: 'ship it' }, }), JSON.stringify({ type: 'assistant', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:10.000Z', message: { id: 'msg-leak-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [{ type: 'text', text: 'done' }], usage: { input_tokens: 900_000, output_tokens: 90_000 }, }, }), JSON.stringify({ type: 'pr-link', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:20.000Z', prUrl: 'https://github.com/getagentseal/codeburn/pull/1', }), ].join('\n') + '\n') return filePath } it('does not surface cached claude sessions when filtering to another provider', async () => { const synthFile = join(tmpHome, 'synth-isolation.txt') await writeFile(synthFile, 'placeholder') await writeClaudeSessionWithPrLink() _synthSources = SYNTH_SOURCE(synthFile) _synthYields = [SYNTH_CALL] // Baseline: what the synthetic provider costs on its own, before anything // claude-shaped has ever entered the session cache. Self-calibrating, since // cost is re-derived from tokens by the pricing engine. const baseline = await parseAllSessions(undefined, 'test-synthetic') const synthOnlyCost = totalCost(baseline) expect([...providersOf(baseline)]).toEqual(['test-synthetic']) clearSessionCache() // Warm the session cache so the claude file is persisted WITH its prLinks. const all = await parseAllSessions(undefined, 'all') expect(providersOf(all)).toContain('claude') expect(totalCost(all)).toBeGreaterThan(synthOnlyCost) clearSessionCache() // Filtering to the synthetic provider must yield ONLY its own spend. Before // the fix, claudeDirs was empty yet scanProjectDirs still ran, so every // cached PR-bearing claude file was treated as a pruned orphan and re-added. const filtered = await parseAllSessions(undefined, 'test-synthetic') expect([...providersOf(filtered)]).toEqual(['test-synthetic']) expect(totalCost(filtered)).toBeCloseTo(synthOnlyCost, 10) }) it('still preserves a PR-bearing claude orphan when claude IS in scope', async () => { const filePath = await writeClaudeSessionWithPrLink() _synthSources = [] _synthYields = [] const before = await parseAllSessions(undefined, 'all') const costBefore = totalCost(before) expect(costBefore).toBeGreaterThan(0) // Every claude transcript disappears from disk. Claude is still in scope, so // the orphan pass must keep the PR-attributed spend alive — this is the case // a naive `claudeDirs.length > 0` guard would silently break. await unlink(filePath) clearSessionCache() const after = await parseAllSessions(undefined, 'all') expect(totalCost(after)).toBeCloseTo(costBefore, 10) }) }) // ═══════════════════════════════════════════════════════════════════════════ // (f) Growing resumed CLI session: durable merge appends only the new leg // ═══════════════════════════════════════════════════════════════════════════ // Resumed Copilot CLI sessions append one CUMULATIVE session.shutdown per leg // (#944). The parser emits per-leg deltas keyed by occurrence; this exercises // the PRODUCTION merge path — the durable union-by-dedup-key merge against the // on-disk cache when the file grows between parses — which the unit tests // (which pre-seed seenKeys) cannot reach. describe('(f) growing resumed CLI session durable merge', () => { it('totals equal the final cumulative rollup after the file grows a leg', 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')) vi.stubEnv('CODEBURN_COPILOT_GLOBAL_STORAGE_DIR', join(tmpHome, 'no-global')) vi.stubEnv('CODEBURN_COPILOT_JETBRAINS_DIR', join(tmpHome, 'no-jb')) const base = Date.now() - 5 * 24 * 60 * 60 * 1000 const at = (offsetSec: number): string => new Date(base + offsetSec * 1000).toISOString() const dir = join(sessionStateDir, 'sess-grow') await mkdir(dir, { recursive: true }) await writeFile(join(dir, 'workspace.yaml'), 'id: sess-grow\ncwd: /home/user/testproj\n') const eventsPath = join(dir, 'events.jsonl') // Cumulative rollups from a real resumed CLI 1.0.78 session. const shutdown = (ts: string, inputTokens: number, cacheReadTokens: number, cacheWriteTokens: number, outputTokens: number) => JSON.stringify({ type: 'session.shutdown', timestamp: ts, data: { shutdownType: 'routine', modelMetrics: { 'claude-sonnet-4-5': { requests: { count: 1, cost: 1 }, usage: { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens: 0 }, }, }, }, }) const leg1 = [ JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }), JSON.stringify({ type: 'assistant.message', timestamp: at(10), data: { messageId: 'msg-1', outputTokens: 17, toolRequests: [] } }), shutdown(at(20), 24672, 0, 24670, 17), ] await writeFile(eventsPath, leg1.join('\n') + '\n') const sumUsage = (projects: Awaited>) => { const calls = projects.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls) return { input: calls.reduce((s, c) => s + c.usage.inputTokens, 0), cacheRead: calls.reduce((s, c) => s + c.usage.cacheReadInputTokens, 0), cacheWrite: calls.reduce((s, c) => s + c.usage.cacheCreationInputTokens, 0), } } const first = sumUsage(await parseAllSessions(undefined, 'copilot')) expect(first).toEqual({ input: 2, cacheRead: 0, cacheWrite: 24670 }) // The session resumes: leg 2 appends per-turn events plus a CUMULATIVE // rollup. The cached leg-1 delta must be kept once and only the leg-2 // delta appended — totals equal the final cumulative rollup exactly. clearSessionCache() await writeFile(eventsPath, [ ...leg1, JSON.stringify({ type: 'assistant.message', timestamp: at(100), data: { messageId: 'msg-2', outputTokens: 132, toolRequests: [] } }), shutdown(at(120), 74463, 49489, 24968, 149), ].join('\n') + '\n') const second = sumUsage(await parseAllSessions(undefined, 'copilot')) expect(second).toEqual({ input: 74463 - 49489 - 24968, cacheRead: 49489, cacheWrite: 24968 }) }) }) // ═══════════════════════════════════════════════════════════════════════════ // (q) Burst reuse: a through-now range re-anchored seconds later reuses the // previous parse instead of re-running discovery (serve fast-path) // ═══════════════════════════════════════════════════════════════════════════ describe('(q) parse burst reuse (CODEBURN_PARSE_BURST_MS)', () => { it('serves a re-anchored range from the previous parse inside the window, never outside it', async () => { vi.stubEnv('CODEBURN_PARSE_BURST_MS', '10000') clearSessionCache() const start = new Date(Date.now() - 60 * 60 * 1000) const ts = new Date(Date.now() - 10 * 60 * 1000).toISOString() const synthFile = join(tmpHome, 'synth-burst.txt') await writeFile(synthFile, 'placeholder') _synthSources = [{ path: synthFile, project: 'p', provider: 'test-synthetic' }] _synthYields = [{ provider: 'test-synthetic', model: 'synth-model', inputTokens: 1, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, costUSD: 0, costIsEstimated: false, tools: [], bashCommands: [], skills: [], timestamp: ts, speed: 'standard', deduplicationKey: 'synth-burst-1', userMessage: 'hi', sessionId: 'sb-1', }] as never const first = await parseAllSessions({ start, end: new Date() }, 'test-synthetic') expect(totalOutput(first)).toBe(5) // The world changes (a second call appears), but a burst-window re-anchor // must serve the PREVIOUS parse: same data, no re-discovery. _synthYields = [..._synthYields, { ...( _synthYields[0] as object ), deduplicationKey: 'synth-burst-2', outputTokens: 7, }] as never const second = await parseAllSessions({ start, end: new Date(Date.now() + 1000) }, 'test-synthetic') expect(totalOutput(second)).toBe(5) // Outside the window (env cleared = burst disabled), the fresh parse sees // the new call: proof the reuse was the burst path, not staleness. The // source file must actually change, or the fingerprint-keyed disk cache // (correctly) serves the old turns. vi.stubEnv('CODEBURN_PARSE_BURST_MS', '0') await writeFile(synthFile, 'placeholder v2 with a second call') clearSessionCache() const third = await parseAllSessions({ start, end: new Date(Date.now() + 2000) }, 'test-synthetic') expect(totalOutput(third)).toBe(12) vi.unstubAllEnvs() _synthSources = [] _synthYields = [] }) }) describe('(r) validated parse reuse (setParseReuseValidator)', () => { it('falls back to the exact TTL when watcher coverage is unknown, but rejects dirty', async () => { vi.stubEnv('CODEBURN_PARSE_BURST_MS', '0') clearSessionCache() const start = new Date(Date.now() - 60 * 60 * 1000) const end = new Date() const ts = new Date(Date.now() - 10 * 60 * 1000).toISOString() const synthFile = join(tmpHome, 'synth-unknown-exact.txt') await writeFile(synthFile, 'first input') _synthSources = [{ path: synthFile, project: 'p', provider: 'test-synthetic' }] _synthYields = [{ provider: 'test-synthetic', model: 'synth-model', inputTokens: 1, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, costUSD: 0, costIsEstimated: false, tools: [], bashCommands: [], skills: [], timestamp: ts, speed: 'standard', deduplicationKey: 'synth-unknown-exact-1', userMessage: 'hi', sessionId: 'sue-1', }] as never expect(totalOutput(await parseAllSessions({ start, end }, 'test-synthetic'))).toBe(5) _synthYields = [..._synthYields, { ...( _synthYields[0] as object ), deduplicationKey: 'synth-unknown-exact-2', outputTokens: 7, }] as never await writeFile(synthFile, 'second input with changed fingerprint') // An unhealthy/pre-arm watcher cannot extend freshness, but it must retain // the normal exact-key TTL instead of forcing a full rescan every request. setParseReuseValidator(() => 'unknown') expect(totalOutput(await parseAllSessions({ start, end }, 'test-synthetic'))).toBe(5) // The same entry must be rejected immediately once a real change is known. setParseReuseValidator(() => 'dirty') expect(totalOutput(await parseAllSessions({ start, end }, 'test-synthetic'))).toBe(12) }) it('falls back to the short burst when watcher coverage is unknown, but dirty wins inside it', async () => { vi.stubEnv('CODEBURN_PARSE_BURST_MS', '10000') clearSessionCache() const start = new Date(Date.now() - 60 * 60 * 1000) const firstEnd = new Date() const ts = new Date(Date.now() - 10 * 60 * 1000).toISOString() const synthFile = join(tmpHome, 'synth-unknown-burst.txt') await writeFile(synthFile, 'first input') _synthSources = [{ path: synthFile, project: 'p', provider: 'test-synthetic' }] _synthYields = [{ provider: 'test-synthetic', model: 'synth-model', inputTokens: 1, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, costUSD: 0, costIsEstimated: false, tools: [], bashCommands: [], skills: [], timestamp: ts, speed: 'standard', deduplicationKey: 'synth-unknown-burst-1', userMessage: 'hi', sessionId: 'sub-1', }] as never expect(totalOutput(await parseAllSessions({ start, end: firstEnd }, 'test-synthetic'))).toBe(5) _synthYields = [..._synthYields, { ...( _synthYields[0] as object ), deduplicationKey: 'synth-unknown-burst-2', outputTokens: 7, }] as never await writeFile(synthFile, 'second input with changed fingerprint') setParseReuseValidator(() => 'unknown') expect(totalOutput(await parseAllSessions( { start, end: new Date(firstEnd.getTime() + 100) }, 'test-synthetic', ))).toBe(5) setParseReuseValidator(() => 'dirty') expect(totalOutput(await parseAllSessions( { start, end: new Date(firstEnd.getTime() + 200) }, 'test-synthetic', ))).toBe(12) }) it('reuses past the burst window while the validator reports quiet, never when dirty', async () => { vi.stubEnv('CODEBURN_PARSE_BURST_MS', '1') clearSessionCache() const start = new Date(Date.now() - 60 * 60 * 1000) const ts = new Date(Date.now() - 10 * 60 * 1000).toISOString() const synthFile = join(tmpHome, 'synth-validated.txt') await writeFile(synthFile, 'placeholder') _synthSources = [{ path: synthFile, project: 'p', provider: 'test-synthetic' }] _synthYields = [{ provider: 'test-synthetic', model: 'synth-model', inputTokens: 1, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, costUSD: 0, costIsEstimated: false, tools: [], bashCommands: [], skills: [], timestamp: ts, speed: 'standard', deduplicationKey: 'synth-val-1', userMessage: 'hi', sessionId: 'sv-1', }] as never const first = await parseAllSessions({ start, end: new Date() }, 'test-synthetic') expect(totalOutput(first)).toBe(5) // 1ms burst window has certainly elapsed; with a quiet validator the // previous parse is still served (world changed, result must not). await new Promise(r => setTimeout(r, 5)) setParseReuseValidator(() => 'clean') _synthYields = [..._synthYields, { ...( _synthYields[0] as object ), deduplicationKey: 'synth-val-2', outputTokens: 7 }] as never await writeFile(synthFile, 'placeholder v2') const second = await parseAllSessions({ start, end: new Date(Date.now() + 500) }, 'test-synthetic') expect(totalOutput(second)).toBe(5) // A dirty validator ends the reuse: fresh parse sees the new call. setParseReuseValidator(() => 'dirty') const third = await parseAllSessions({ start, end: new Date(Date.now() + 1000) }, 'test-synthetic') expect(totalOutput(third)).toBe(12) setParseReuseValidator(null) vi.unstubAllEnvs() _synthSources = [] _synthYields = [] }) it('rejects an exact-key memo when a root event arrived during its parse', async () => { clearSessionCache() const start = new Date(Date.now() - 60 * 60 * 1000) const end = new Date() const ts = new Date(Date.now() - 10 * 60 * 1000).toISOString() const synthFile = join(tmpHome, 'synth-exact-event-during-parse.txt') await writeFile(synthFile, 'first input') _synthSources = [{ path: synthFile, project: 'p', provider: 'test-synthetic' }] _synthYields = [{ provider: 'test-synthetic', model: 'synth-model', inputTokens: 1, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, costUSD: 0, costIsEstimated: false, tools: [], bashCommands: [], skills: [], timestamp: ts, speed: 'standard', deduplicationKey: 'synth-exact-event-1', userMessage: 'hi', sessionId: 'see-1', }] as never let rootEventAt = 0 setParseReuseValidator(sinceTs => rootEventAt === 0 || rootEventAt < sinceTs ? 'clean' : 'dirty') _synthOnParse = async () => { await new Promise(resolve => setTimeout(resolve, 10)) rootEventAt = Date.now() await new Promise(resolve => setTimeout(resolve, 10)) } const first = await parseAllSessions({ start, end }, 'test-synthetic') expect(totalOutput(first)).toBe(5) _synthOnParse = null _synthYields = [..._synthYields, { ...( _synthYields[0] as object ), deduplicationKey: 'synth-exact-event-2', outputTokens: 7, }] as never await writeFile(synthFile, 'second input') const second = await parseAllSessions({ start, end }, 'test-synthetic') expect(totalOutput(second)).toBe(12) }) it('does not bless a root event that arrived while the cached parse was running', async () => { vi.stubEnv('CODEBURN_PARSE_BURST_MS', '1') clearSessionCache() const start = new Date(Date.now() - 60 * 60 * 1000) const firstEnd = new Date() const ts = new Date(Date.now() - 10 * 60 * 1000).toISOString() const synthFile = join(tmpHome, 'synth-event-during-parse.txt') await writeFile(synthFile, 'first input') _synthSources = [{ path: synthFile, project: 'p', provider: 'test-synthetic' }] _synthYields = [{ provider: 'test-synthetic', model: 'synth-model', inputTokens: 1, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, costUSD: 0, costIsEstimated: false, tools: [], bashCommands: [], skills: [], timestamp: ts, speed: 'standard', deduplicationKey: 'synth-event-1', userMessage: 'hi', sessionId: 'se-1', }] as never let rootEventAt = 0 _synthOnParse = async () => { // Bracket the controlled event so it is strictly after parse start and // strictly before completion, independent of same-millisecond clocks. await new Promise(resolve => setTimeout(resolve, 10)) rootEventAt = Date.now() await new Promise(resolve => setTimeout(resolve, 10)) } const first = await parseAllSessions({ start, end: firstEnd }, 'test-synthetic') expect(totalOutput(first)).toBe(5) expect(rootEventAt).toBeGreaterThan(0) _synthOnParse = null // Outside the 1ms burst, old code validated against cachePut completion // and reused stale output because the in-parse event appeared older. The // parse-start timestamp makes the validator reject reuse and rescan. await new Promise(resolve => setTimeout(resolve, 5)) setParseReuseValidator(sinceTs => rootEventAt < sinceTs ? 'clean' : 'dirty') _synthYields = [..._synthYields, { ...( _synthYields[0] as object ), deduplicationKey: 'synth-event-2', outputTokens: 7, }] as never await writeFile(synthFile, 'second input') const second = await parseAllSessions( { start, end: new Date(firstEnd.getTime() + 500) }, 'test-synthetic', ) expect(totalOutput(second)).toBe(12) }) })