// 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() 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 { 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`) 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>): 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 = [] }) 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) }) })