Files
iamtoruk c30ebecff4 Merge origin/main into feat/copilot-session-store
Resolves the conflicts #946 accumulated while it was in validation. Eight
files conflicted; the session-store accounting is unchanged.

src/daily-cache.ts — version collision. This PR minted 25 when main was at
24; #1056 (`codex-auto-review` pricing) then spent 25 on main. The bump moves
to 26/MIN 26 and daily-cache.v26.json, with main's full comment ladder kept as
the foundation and this PR's paragraph rewritten to name 26 and record the
collision. PENDING_REDERIVE_PROVIDERS and the B1 migration semantics from
b6481c19 carry over intact, retargeted at 26.

src/models.ts, src/parser.ts, src/audit-report.ts, src/models-report.ts —
#1075/#1078 replaced the per-site "reasoning is already inside output" tests
with billableOutputTokens() and REASONING_INCLUDED_IN_OUTPUT. This PR had
added copilot to that case at three sites independently. Union: all three
sites take main's helper call verbatim, and copilot joins claude and codex in
the set — same accounting this PR shipped, now through main's single source of
truth. It also reaches parser.ts activeGeneratedTokens (a fourth site, from
#1079), which is the same correction: a copilot supplementary call carries
reasoning with output 0, so counting it as generated repeats the per-turn
output. The audit legend already said so on this side.

src/providers/copilot.ts — comment-only. #1054's lastEventTimestamp-first
shutdown fallback was derived from this branch, so the code was already
identical on both sides: the `shutdownTimestamp` expression and the
`copilot:<sid>:shutdown:<model>:<n>` key are byte-for-byte main's. Both
rationales are kept (leg-collapse on date, and residual anchoring).

src/session-cache.ts — PROVIDER_PARSE_VERSIONS.copilot takes this PR's
`-session-store-v3` suffix; main's #1051 note about why a fingerprint change
is expensive is kept above it. Codex keeps main's #1092 suffix chain untouched.

src/main.ts — #1067 deleted the unreachable live dailyMap fallback that this
PR had taught behavioral weight. Main's deletion wins; the now-unused
isBehavioralTurn import goes with it.

tests/parser.test.ts — import union.

Also: scripts/upgrade-path/run.mjs NEW_DAILY_CACHE -> daily-cache.v26.json,
CODEBURN_COPILOT_SESSION_STORE_DB added to the #1064 env-isolation CLEARED
list, and the CHANGELOG entry's stale "v21" corrected to v26.

Verified: tsc clean; 3132 tests pass across 223 files; test:locks 26/26;
verify:upgrade PASSED, re-deriving daily-cache.v26.json and holding durable
copilot history across the bump. #1054's regression ("keeps three stampless
shutdown legs as :n keys with lastEventTimestamp") passes on the merged tree.
Real-corpus A/B against origin/main over 2026-07-01..2026-08-22: codex, grok,
kimicode and opencode byte-identical in export, audit and models; claude drifts
only monotonically with run order (a live session writing transcripts, confirmed
by interleaving four runs). This machine has no copilot data, so the copilot
recovery semantics rest on the suites and the upgrade-path corpus.
2026-08-22 09:36:51 -07:00

3560 lines
189 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 — only ORPHANS ≥ 91d are pruned (#992); a still-
// discovered source stays, flagged or not
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 { calculateCost } from '../src/models.js'
import { aggregateProjectsIntoDays } from '../src/day-aggregator.js'
import { DAILY_CACHE_VERSION, currentTzKey, ensureCacheHydrated, saveDailyCache } from '../src/daily-cache.js'
import { clearSessionCache, isSessionHydrationComplete, parseAllSessions, setParseReuseValidator } from '../src/parser.js'
import { CACHE_VERSION, clearLoadCacheMemo, computeEnvFingerprint, loadCache, PROVIDER_PARSE_VERSIONS, saveCache } from '../src/session-cache.js'
import { cacheDirSnapshot, 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: ((source: SessionSource) => void | Promise<void>) | null = null
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> {
_synthParseCalls++
await _synthOnParse?.(_s)
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`)
// 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<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 = []
_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 a 91-day-old entry whose still-discovered source declares retainWhilePresent', async () => {
const synthFile = join(tmpHome, 'synth-retain-flag.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', retainWhilePresent: true }]
_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-retain-flag-91d',
userMessage: 'old', sessionId: 'synth-flag',
}]
// Flagged + discovered: the file is the durable record; pruning it would
// drop data only it holds. Served and retained across passes.
const proj1 = await parseAllSessions(undefined, 'test-synthetic')
expect(totalOutput(proj1)).toBe(8)
clearSessionCache()
const proj2 = await parseAllSessions(undefined, 'test-synthetic')
expect(totalOutput(proj2)).toBe(8)
// Once ORPHANED the flag no longer applies — orphan age-out prunes.
clearSessionCache()
_synthSources = [] // no longer discovered
const proj3 = await parseAllSessions(undefined, 'test-synthetic')
expect(totalOutput(proj3)).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)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Emission gate: a reasoning-only copilot session still emits
// ═══════════════════════════════════════════════════════════════════════════
// A rollup can carry ONLY reasoning tokens (zero input/cache/output, zero
// cost — copilot reasoning is never priced). The gate admits any
// usage-bearing session; dropping this one would silently lose the only
// record of its usage.
describe('reasoning-only copilot session emission', () => {
it('emits a session whose sole rollup carries only reasoning tokens', async () => {
const sessionStateDir = join(tmpHome, 'session-state-reason-only')
await mkdir(sessionStateDir, { recursive: true })
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', join(tmpHome, 'no-store.db'))
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 ts = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString()
const dir = join(sessionStateDir, 'sess-reason')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-reason\ncwd: /home/user/testproj\n')
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: ts, data: { newModel: 'gpt-5' } }),
JSON.stringify({
type: 'session.shutdown',
timestamp: ts,
data: {
shutdownType: 'routine',
modelMetrics: {
'gpt-5': {
requests: { count: 1, cost: 0 },
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 800 },
},
},
},
}),
].join('\n') + '\n')
const projects = await parseAllSessions(undefined, 'copilot')
const sessions = projects.flatMap(p => p.sessions).filter(s => s.sessionId === 'sess-reason')
expect(sessions).toHaveLength(1)
expect(sessions[0]!.totalReasoningTokens).toBe(800)
// The rollup is supplementary accounting: usage served, zero call weight.
expect(sessions[0]!.apiCalls).toBe(0)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (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)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (f2) Version-bump survival for an EXTANT-but-pruned durable DB. The orphan
// carry-forward in (f) keyed on "the source path is gone", but a durable
// SQLite source keeps its file forever while the CLI prunes rows out of
// it — so a parse-version bump was deleting exactly the history only the
// cache still held (#946 review, blocker 1). Also pins the other
// direction: an intact source re-read under the bump must not DOUBLE,
// which is the dedup-key-stability contract the union merge rests on.
// ═══════════════════════════════════════════════════════════════════════════
describe.skipIf(!isSqliteAvailable())('(f2) durable history survives a bump on an extant, pruned DB', () => {
const stubOtelOnly = (dbPath: string): void => {
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'))
}
// Take the same code path a real PROVIDER_PARSE_VERSIONS bump takes: any
// mismatching persisted envFingerprint rebuilds the provider section.
const simulateVersionBump = async (): Promise<void> => {
clearSessionCache()
const disk = await readCacheOnDisk()
expect(disk.providers['copilot']).toBeDefined()
// Self-check: a sentinel equal to the real fingerprint would make the
// section MATCH and the test would pass while exercising nothing.
expect(disk.providers['copilot']!.envFingerprint).not.toBe('0000000000000000')
disk.providers['copilot']!.envFingerprint = '0000000000000000'
await writeCacheOnDisk(disk)
}
it('keeps a pruned conversation whose DB file still exists', async () => {
const dbPath = join(tmpHome, 'agent-traces.db')
stubOtelOnly(dbPath)
createOtelDb(dbPath)
insertOtelConv(dbPath, { spanId: 's1', traceId: 't1', convId: 'bump-c1', model: 'gpt-4.1', input: 500, output: 50 })
insertOtelConv(dbPath, { spanId: 's2', traceId: 't2', convId: 'bump-c2', model: 'gpt-4.1', input: 1000, output: 100 })
expect(totalOutput(await parseAllSessions(undefined, 'copilot'))).toBe(150)
// The CLI prunes conv-1. The DB file is still there, still valid, just
// smaller — the shape that "carry forward only vanished paths" missed.
clearSessionCache()
await rm(dbPath)
createOtelDb(dbPath)
insertOtelConv(dbPath, { spanId: 's2', traceId: 't2', convId: 'bump-c2', model: 'gpt-4.1', input: 1000, output: 100 })
expect(totalOutput(await parseAllSessions(undefined, 'copilot'))).toBe(150)
await simulateVersionBump()
// The bump re-reads the live DB (conv-2) and unions it with the cached
// conv-1 the DB can no longer produce. Nothing is lost, nothing doubles.
expect(totalOutput(await parseAllSessions(undefined, 'copilot'))).toBe(150)
// And it is PERSISTED, not just served: a cold read of the rewritten
// cache still has it, otherwise the loss is merely deferred one run.
clearSessionCache()
expect(totalOutput(await parseAllSessions(undefined, 'copilot'))).toBe(150)
const disk = await readCacheOnDisk()
const keys = Object.values(disk.providers['copilot']?.files ?? {})
.flatMap(f => f.turns).flatMap(t => t.calls).map(c => c.deduplicationKey)
expect(keys).toContain('copilot-otel:s1')
})
it('does not double an intact DB re-read under the bump', async () => {
const dbPath = join(tmpHome, 'agent-traces-intact.db')
stubOtelOnly(dbPath)
createOtelDb(dbPath)
insertOtelConv(dbPath, { spanId: 's1', traceId: 't1', convId: 'intact-c1', model: 'gpt-4.1', input: 500, output: 50 })
insertOtelConv(dbPath, { spanId: 's2', traceId: 't2', convId: 'intact-c2', model: 'gpt-4.1', input: 1000, output: 100 })
expect(totalOutput(await parseAllSessions(undefined, 'copilot'))).toBe(150)
await simulateVersionBump()
// Every row is still derivable, so the union must recognise all of them
// by dedup key and append nothing.
expect(totalOutput(await parseAllSessions(undefined, 'copilot'))).toBe(150)
clearSessionCache()
expect(totalOutput(await parseAllSessions(undefined, 'copilot'))).toBe(150)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (f3) Cross-source snapshot ordering for the durable record. Copilot
// reconciles a session.shutdown rollup against the session-store rows
// written up to it; the two are different files, so a session that shuts
// down mid-pass can leave whichever we read first short. Reading the
// store LAST makes the row set a superset of anything a rollup we read
// can claim, and a store we did not re-read (unchanged at classification)
// that moves during the pass is a partial hydration, not a number to
// seal. (#946 review, blocker 2.)
// ═══════════════════════════════════════════════════════════════════════════
describe('(f3) durable-record read ordering and mid-pass fence', () => {
it('parses a retainWhilePresent source after every other changed source', async () => {
const storeFile = join(tmpHome, 'ordering-store.txt')
const journalA = join(tmpHome, 'ordering-a.txt')
const journalB = join(tmpHome, 'ordering-b.txt')
for (const f of [storeFile, journalA, journalB]) await writeFile(f, 'v1')
// Deliberately listed FIRST: discovery order must not decide this.
_synthDurable = true
_synthSources = [
{ path: storeFile, project: 'test', provider: 'test-synthetic', retainWhilePresent: true },
{ path: journalA, project: 'test', provider: 'test-synthetic' },
{ path: journalB, project: 'test', provider: 'test-synthetic' },
]
_synthYields = []
const order: string[] = []
_synthOnParse = (source) => { order.push(source.path) }
await parseAllSessions(undefined, 'test-synthetic')
_synthOnParse = null
expect(order).toHaveLength(3)
expect(order[order.length - 1]).toBe(storeFile)
})
it('reports partial hydration when a cache-served store moves during the pass', async () => {
const storeFile = join(tmpHome, 'fence-store.txt')
const journal = join(tmpHome, 'fence-journal.txt')
await writeFile(storeFile, 'rows-v1')
await writeFile(journal, 'journal-v1')
_synthDurable = true
_synthSources = [
{ path: storeFile, project: 'test', provider: 'test-synthetic', retainWhilePresent: true },
{ path: journal, project: 'test', provider: 'test-synthetic' },
]
_synthYields = []
// Warm both entries so the store can be served from cache next pass.
await parseAllSessions(undefined, 'test-synthetic')
expect(isSessionHydrationComplete()).toBe(true)
// Only the journal changed, so only the journal is re-read — and while it
// is being read, the store grows. This is the exact window the ordering
// cannot close: the rows we are about to reconcile the journal against
// were pinned before the journal was read.
clearSessionCache()
await writeFile(journal, 'journal-v2-longer')
_synthOnParse = async (source) => {
if (source.path === journal) await writeFile(storeFile, 'rows-v2-longer')
}
await parseAllSessions(undefined, 'test-synthetic')
_synthOnParse = null
expect(isSessionHydrationComplete()).toBe(false)
// The next refresh sees the store as changed, re-reads it last, and the
// fence lifts on its own — it holds a day back, it does not wedge.
clearSessionCache()
await parseAllSessions(undefined, 'test-synthetic')
expect(isSessionHydrationComplete()).toBe(true)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (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 <other> 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<ReturnType<typeof parseAllSessions>>): Set<string> =>
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<string> {
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<ReturnType<typeof parseAllSessions>>) => {
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)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (i) Growing session-store DB: durable merge appends only the new rows
// ═══════════════════════════════════════════════════════════════════════════
// session-store.db records one usage row per API request; rows only ever
// append (AUTOINCREMENT ids). This exercises the PRODUCTION path end to end:
// both representations parse and cache, serve-time precedence
// (parseProviderSources) drops the covered session's shutdown rollup, and a
// re-parse after INSERTs appends exactly the new rows under the durable
// union-by-dedup-key merge — totals must equal the DB, not the rollup, and
// never double-count.
function createStoreDb(dbPath: string): void {
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (path: string) => TestDb }
const db = new DatabaseSync(dbPath)
db.exec(`
CREATE TABLE sessions (id TEXT PRIMARY KEY, cwd TEXT, repository TEXT, created_at TEXT);
CREATE TABLE assistant_usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER,
output_tokens INTEGER,
cache_read_tokens INTEGER,
cache_write_tokens INTEGER,
reasoning_tokens INTEGER,
created_at TEXT
);
`)
db.close()
}
function insertStoreRow(
dbPath: string,
sessionId: string,
inputTokens: number, // cache-inclusive, as the CLI writes it
cacheRead: number,
cacheWrite: number,
createdAt: string,
reasoning = 0,
cwd = '/home/user/testproj',
): void {
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (path: string) => TestDb }
const db = new DatabaseSync(dbPath)
db.prepare(`INSERT OR IGNORE INTO sessions (id, cwd) VALUES (?, ?)`).run(sessionId, cwd)
db.prepare(
`INSERT INTO assistant_usage_events
(session_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, created_at)
VALUES (?, 'claude-sonnet-4-5', ?, 0, ?, ?, ?, ?)`
).run(sessionId, inputTokens, cacheRead, cacheWrite, reasoning, createdAt)
db.close()
}
describe.skipIf(!isSqliteAvailable())('(i) growing session-store DB durable merge', () => {
it('totals track the store exactly as rows append, with the rollup reconciled away', async () => {
const sessionStateDir = join(tmpHome, 'session-state')
await mkdir(sessionStateDir, { recursive: true })
const dbPath = join(tmpHome, 'session-store.db')
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', dbPath)
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()
// The session's events.jsonl carries per-turn output AND a shutdown
// rollup summing exactly the two covered requests (the production shape:
// rows commit before the rollup is written, cache-inclusive on both
// sides). If reconciliation failed to replace the rollup, totals would
// double; if it dropped usage, they would fall short of the rows.
const dir = join(sessionStateDir, 'sess-store')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-store\ncwd: /home/user/testproj\n')
await writeFile(join(dir, 'events.jsonl'), [
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: [] } }),
JSON.stringify({
type: 'session.shutdown',
timestamp: at(20),
data: {
shutdownType: 'routine',
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 2, cost: 1 },
usage: { inputTokens: 20000, outputTokens: 17, cacheReadTokens: 17000, cacheWriteTokens: 2400, reasoningTokens: 40 },
},
},
},
}),
].join('\n') + '\n')
createStoreDb(dbPath)
// Row 1 carries reasoning tokens: they are a subset of the session's
// per-turn output and must ride as metadata WITHOUT entering the
// query-path cost recompute (cachedCallToApiCall discards the parser's
// costUSD for copilot and re-derives from tokens — the assertion below
// is the only guard that exercises that production path).
insertStoreRow(dbPath, 'sess-store', 12000, 10000, 1500, at(12), 40) // input 500
insertStoreRow(dbPath, 'sess-store', 8000, 7000, 900, at(15)) // input 100
const sumUsage = (projects: Awaited<ReturnType<typeof parseAllSessions>>) => {
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),
output: calls.reduce((s, c) => s + c.usage.outputTokens, 0),
cost: calls.reduce((s, c) => s + c.costUSD, 0),
}
}
// The reasoning-free cost of everything above: per-turn output plus the
// two store rows priced on input/cache alone. A higher observed cost
// means the 40 reasoning tokens were billed at the output rate on a call
// that owns no output — double-billing them against the per-turn call.
const expectedCost =
calculateCost('claude-sonnet-4-5', 0, 17, 0, 0, 0) +
calculateCost('claude-sonnet-4-5', 500, 0, 1500, 10000, 0) +
calculateCost('claude-sonnet-4-5', 100, 0, 900, 7000, 0)
// First parse: input/cache equal the DB rows exactly (the rollup is
// reconciled away, residual zero); output stays with the per-turn event.
const first = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(first.cost).toBeCloseTo(expectedCost, 12)
expect(first).toEqual({ input: 600, cacheRead: 17000, cacheWrite: 2400, output: 17, cost: first.cost })
// The session continues: one more API request lands as one more row.
// Re-parse against the warm disk cache — the durable merge must append
// only the new row's key, keeping totals equal to the DB.
clearSessionCache()
insertStoreRow(dbPath, 'sess-store', 5000, 4600, 300, at(30)) // input 100
const second = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(second.cost).toBeCloseTo(expectedCost + calculateCost('claude-sonnet-4-5', 100, 0, 300, 4600, 0), 12)
expect(second).toEqual({ input: 700, cacheRead: 21600, cacheWrite: 2700, output: 17, cost: second.cost })
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (k) Serve-time precedence heals a durably double-cached session
// ═══════════════════════════════════════════════════════════════════════════
// The union merge never deletes, so a rollup cached while the store was
// unreadable (an unsupported-schema epoch, a runtime without node:sqlite,
// restored files) survives the store later becoming readable — and its
// session's rows would then be cached beside it. Serve-time precedence must
// drop the rollup calls whenever store calls exist for the session, healing
// the state instead of double-counting it forever.
describe.skipIf(!isSqliteAvailable())('(k) serve-time precedence over stale cached rollups', () => {
it('stops counting a cached rollup once the store covers its session', async () => {
const sessionStateDir = join(tmpHome, 'session-state')
await mkdir(sessionStateDir, { recursive: true })
const dbPath = join(tmpHome, 'session-store.db')
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', dbPath)
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-stale')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-stale\ncwd: /home/user/testproj\n')
await writeFile(join(dir, 'events.jsonl'), [
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: 25, toolRequests: [] } }),
JSON.stringify({
type: 'session.shutdown',
timestamp: at(20),
data: {
shutdownType: 'routine',
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 1, cost: 1 },
usage: { inputTokens: 20000, outputTokens: 25, cacheReadTokens: 17000, cacheWriteTokens: 2400, reasoningTokens: 0 },
},
},
},
}),
].join('\n') + '\n')
const sumUsage = (projects: Awaited<ReturnType<typeof parseAllSessions>>) => {
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),
output: calls.reduce((s, c) => s + c.usage.outputTokens, 0),
}
}
// Run 1: no store exists — the rollup is legitimately the only record
// and gets durably cached.
const first = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(first).toEqual({ input: 600, cacheRead: 17000, output: 25 })
// The store now becomes readable WITH rows for the same session — the
// uncovered→covered transition no writer ordering protects (schema
// epoch ending, node:sqlite appearing, restored files). events.jsonl is
// unchanged, so its cached rollup calls survive the merge untouched.
clearSessionCache()
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-stale', 12000, 10000, 1500, at(12)) // input 500
insertStoreRow(dbPath, 'sess-stale', 8000, 7000, 900, at(15)) // input 100
// Run 2: totals must equal per-turn output + store rows — the cached
// rollup (input 600 / cacheRead 17,000) must not ALSO count.
const second = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(second).toEqual({ input: 600, cacheRead: 17000, output: 25 })
// Run 3 (warm disk cache, nothing changed): still healed, still once.
clearSessionCache()
const third = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(third).toEqual({ input: 600, cacheRead: 17000, output: 25 })
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (l) Age-out never expires the still-discovered session store
// ═══════════════════════════════════════════════════════════════════════════
// An idle machine whose session-store rows are all >90 days old: the store is
// still on disk and IS the durable record (crash-only rows have no rollup to
// fall back to), so its discovery declares retainWhilePresent and the age-out
// leaves it alone. Ordinary journal-style sources keep the pre-existing
// schedule — pruned at 90 days whether or not the file remains — and orphaned
// store entries age out normally once the DB itself is gone.
describe.skipIf(!isSqliteAvailable())('(l) age-out exempts still-discovered store data', () => {
it('serves >90d-old store rows while the store is still on disk', async () => {
const sessionStateDir = join(tmpHome, 'session-state')
await mkdir(sessionStateDir, { recursive: true })
const dbPath = join(tmpHome, 'session-store.db')
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', dbPath)
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() - 91 * 24 * 60 * 60 * 1000
const at = (offsetSec: number): string => new Date(base + offsetSec * 1000).toISOString()
const dir = join(sessionStateDir, 'sess-idle')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-idle\ncwd: /home/user/testproj\n')
await writeFile(join(dir, 'events.jsonl'), [
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: 25, toolRequests: [] } }),
JSON.stringify({
type: 'session.shutdown',
timestamp: at(20),
data: {
shutdownType: 'routine',
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 1, cost: 1 },
usage: { inputTokens: 20000, outputTokens: 25, cacheReadTokens: 17000, cacheWriteTokens: 2400, reasoningTokens: 0 },
},
},
},
}),
].join('\n') + '\n')
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-idle', 12000, 10000, 1500, at(12)) // input 500
insertStoreRow(dbPath, 'sess-idle', 8000, 7000, 900, at(15)) // input 100
const sumUsage = (projects: Awaited<ReturnType<typeof parseAllSessions>>) => {
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),
output: calls.reduce((s, c) => s + c.usage.outputTokens, 0),
}
}
// Both runs: the store rows must serve — never zero. Under the orphan-only
// age-out (#992) the >90d events.jsonl is still discovered, so its per-turn
// output stays too; the rollup's input/cache is reconciled away against the
// rows exactly as on a fresh session, which is what makes the >90d case
// indistinguishable from any other. Idempotent across the cache round-trip.
const first = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(first).toEqual({ input: 600, cacheRead: 17000, output: 25 })
clearSessionCache()
const second = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(second).toEqual({ input: 600, cacheRead: 17000, output: 25 })
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Shared setup for the serve-time precedence scenarios (m)/(n)/(o): a CLI
// session-state dir + a session-store.db, all env-pinned into tmpHome.
// ═══════════════════════════════════════════════════════════════════════════
async function setupCopilotStoreEnv(): Promise<{
dbPath: string
at: (offsetSec: number) => string
writeSession: (sessionId: string, opts: { output: number; rollup?: boolean }) => Promise<string>
sumUsage: (projects: Awaited<ReturnType<typeof parseAllSessions>>) => { input: number; cacheRead: number; cacheWrite: number; output: number }
}> {
const sessionStateDir = join(tmpHome, 'session-state')
await mkdir(sessionStateDir, { recursive: true })
const dbPath = join(tmpHome, 'session-store.db')
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', dbPath)
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()
// The rollup always uses the maintainer's repro numbers: cache-inclusive
// input 20,000 → uncached 600, cacheRead 17,000, cacheWrite 2,400.
const writeSession = async (sessionId: string, opts: { output: number; rollup?: boolean }): 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: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
JSON.stringify({ type: 'assistant.message', timestamp: at(10), data: { messageId: 'msg-1', outputTokens: opts.output, toolRequests: [] } }),
]
if (opts.rollup) {
lines.push(JSON.stringify({
type: 'session.shutdown',
timestamp: at(20),
data: {
shutdownType: 'routine',
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 1, cost: 1 },
usage: { inputTokens: 20000, outputTokens: opts.output, cacheReadTokens: 17000, cacheWriteTokens: 2400, reasoningTokens: 0 },
},
},
},
}))
}
const eventsPath = join(dir, 'events.jsonl')
await writeFile(eventsPath, lines.join('\n') + '\n')
return eventsPath
}
const sumUsage = (projects: Awaited<ReturnType<typeof parseAllSessions>>) => {
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),
output: calls.reduce((s, c) => s + c.usage.outputTokens, 0),
}
}
return { dbPath, at, writeSession, sumUsage }
}
// ═══════════════════════════════════════════════════════════════════════════
// (m) The probe-to-parse race, at serve level: rows commit, THEN the shutdown
// line lands — counted once, store side wins
// ═══════════════════════════════════════════════════════════════════════════
// The #946 round-2 repro. Under parse-time suppression, a coverage snapshot
// taken at discovery went stale the moment a session ended between probe and
// parse (rows commit BEFORE the shutdown line is appended), and the rollup
// was emitted beside the rows — doubling input 100 / cacheRead 8,000 /
// cacheWrite 2,000 durably. Serve-time precedence has no snapshot to go
// stale: whatever store rows made it into the serve set drop the session's
// rollups, no matter when either side was parsed or cached.
describe.skipIf(!isSqliteAvailable())('(m) rows-then-shutdown race counted once at serve time', () => {
it('drops the rollup cached after its session ended mid-run', async () => {
const { dbPath, at, writeSession, sumUsage } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
const eventsPath = await writeSession('sess-race', { output: 345 })
// Run 1 parses the live session mid-flight: no rows, no rollup yet.
const first = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(first).toEqual({ input: 0, cacheRead: 0, cacheWrite: 0, output: 345 })
// The session ends: rows committed FIRST (the CLI's write order), the
// shutdown rollup appended second, both between two codeburn runs.
clearSessionCache()
insertStoreRow(dbPath, 'sess-race', 10100, 8000, 2000, at(15)) // input 100
await writeFile(eventsPath, JSON.stringify({
type: 'session.shutdown',
timestamp: at(20),
data: {
shutdownType: 'routine',
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 1, cost: 1 },
usage: { inputTokens: 10100, outputTokens: 345, cacheReadTokens: 8000, cacheWriteTokens: 2000, reasoningTokens: 0 },
},
},
},
}) + '\n', { flag: 'a' })
// Both representations parse and cache; the serve set holds the row, so
// the rollup is dropped: 100/8,000/2,000 once, not twice.
const second = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(second).toEqual({ input: 100, cacheRead: 8000, cacheWrite: 2000, output: 345 })
// Warm re-run: still once.
clearSessionCache()
const third = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(third).toEqual({ input: 100, cacheRead: 8000, cacheWrite: 2000, output: 345 })
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (n) A store holding no billable rows for a session never suppresses it
// ═══════════════════════════════════════════════════════════════════════════
// Two scenarios collapse into this serve-set shape. (1) Atomic replacement:
// discovery probes store A, a writer renames store B over it, the parse
// reads B — under a parse-time coverage snapshot, sessions covered by A but
// absent from B would lose their rollups AND their rows; at serve time only
// what the parse actually produced suppresses. (2) The billable predicate:
// all-zero rows emit no calls, so a session with only those must keep its
// rollup — suppression on mere row-existence would zero its input/cache.
describe.skipIf(!isSqliteAvailable())('(n) no billable store rows → the rollup still counts', () => {
it('keeps the rollup when the served store holds only zero-usage rows for its session', async () => {
const { dbPath, at, writeSession, sumUsage } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
// sess-r: only an all-zero row (emits nothing). sess-other: billable.
insertStoreRow(dbPath, 'sess-r', 0, 0, 0, at(5))
insertStoreRow(dbPath, 'sess-other', 5050, 5000, 0, at(6)) // input 50
await writeSession('sess-r', { output: 25, rollup: true })
const totals = sumUsage(await parseAllSessions(undefined, 'copilot'))
// sess-r's rollup (600/17,000/2,400) + sess-other's row (50/5,000/0)
// + sess-r's per-turn output.
expect(totals).toEqual({ input: 650, cacheRead: 22000, cacheWrite: 2400, output: 25 })
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (o) Store-absence epoch: deleting the store changes nothing served
// ═══════════════════════════════════════════════════════════════════════════
// The round-6 finding on the previous design: gating reconciliation on the
// store being DISCOVERED made an absence epoch flip served totals — the
// orphaned rows and the returning rollup both counted, and a daily history
// finalized during the epoch kept the doubled day forever even after the
// store came back. Reconciliation therefore reads only the cached serve set:
// the rows (durable orphans included) keep replacing the rollup, totals are
// identical before and after the deletion, and sealed history can never
// flip. The orphaned rows remain the session's record until the 90-day
// age-out prunes them — at which point the rollup calls, still cached in
// events.jsonl's entry, become the record again with the same exactly-once
// guarantee.
describe.skipIf(!isSqliteAvailable())('(o) absence epoch: served totals are independent of store presence', () => {
it('serves identical totals before and after the store file is deleted', async () => {
const { dbPath, at, writeSession, sumUsage } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-e', 12000, 10000, 1500, at(12)) // input 500
insertStoreRow(dbPath, 'sess-e', 8000, 7000, 900, at(15)) // input 100
await writeSession('sess-e', { output: 25, rollup: true })
// Store present: rows win, rollup reconciled away.
const first = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(first).toEqual({ input: 600, cacheRead: 17000, cacheWrite: 2400, output: 25 })
// The store vanishes; its cached rows become durable orphans.
clearSessionCache()
await rm(dbPath, { force: true })
// Nothing changes: the cached rows are still the session's record, the
// rollup stays reconciled away, and no epoch can double-count a day.
const second = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(second).toEqual({ input: 600, cacheRead: 17000, cacheWrite: 2400, output: 25 })
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (p) The reverse race: a row committing after the store read never zeroes
// the refresh
// ═══════════════════════════════════════════════════════════════════════════
// The #946 round-4 finding 3 shape. The store parses before events.jsonl; a
// row can commit after that read but before the jsonl parse reaches the
// shutdown line. Under parse-time suppression the live coverage re-check
// then saw the row and suppressed the rollup — against a store snapshot
// that had emitted nothing — losing the request's input/cache for the whole
// refresh. Serve-time reconciliation cannot outrun the serve set: the
// rollup stands until rows actually land, partially-landed rows are topped
// up by the residual to the rollup's own totals, and full coverage retires
// the residual — counted once at every step and zero at none.
// ═══════════════════════════════════════════════════════════════════════════
// (c5) initiator = 'compaction' identifies the summarization request
// ═══════════════════════════════════════════════════════════════════════════
// The CLI's context-summarization call writes its own assistant_usage_events
// row, labelled `initiator = 'compaction'` where the store carries that
// column. Two consequences, both of which need the label: it has no
// assistant.message, so it must not compete for a per-turn pairing partner,
// and it commits just BEFORE the compaction stamp that anchors the leg's
// interval, so without the label the anchor pushes it outside the subtraction
// while the post-reset rollup still counts it - the documented one-request
// over-serve. The column is optional twice over (absent on older stores, NULL
// on many rows of newer ones), so the unlabelled path must be unchanged.
describe.skipIf(!isSqliteAvailable())('(c5) compaction-initiated store rows', () => {
const createStoreDbWithInitiator = (dbPath: string): void => {
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (p: string) => TestDb }
const db = new DatabaseSync(dbPath)
db.exec(`
CREATE TABLE sessions (id TEXT PRIMARY KEY, cwd TEXT, repository TEXT, created_at TEXT);
CREATE TABLE assistant_usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL, model TEXT NOT NULL,
input_tokens INTEGER, output_tokens INTEGER,
cache_read_tokens INTEGER, cache_write_tokens INTEGER,
reasoning_tokens INTEGER, created_at TEXT,
total_nano_aiu INTEGER, request_multiplier REAL, initiator TEXT);
`)
db.close()
}
const insertLabelled = (
dbPath: string,
sid: string,
input: number,
at: string,
initiator: string | null,
extra: { output?: number; cacheRead?: number } = {},
): void => {
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (p: string) => TestDb }
const db = new DatabaseSync(dbPath)
db.prepare('INSERT OR IGNORE INTO sessions (id, cwd) VALUES (?, ?)').run(sid, '/home/user/testproj')
db.prepare(`INSERT INTO assistant_usage_events
(session_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
reasoning_tokens, created_at, total_nano_aiu, request_multiplier, initiator)
VALUES (?, 'claude-sonnet-4-5', ?, ?, ?, 0, 0, ?, 1000, 1, ?)`)
.run(sid, input, extra.output ?? 0, extra.cacheRead ?? 0, at, initiator)
db.close()
}
const setup = async (name: string) => {
const sessionStateDir = join(tmpHome, `cmpinit-state-${name}`)
await mkdir(sessionStateDir, { recursive: true })
const dbPath = join(tmpHome, `cmpinit-store-${name}.db`)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', dbPath)
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() - 3 * 24 * 3600 * 1000
const at = (sec: number): string => new Date(base + sec * 1000).toISOString()
createStoreDbWithInitiator(dbPath)
return { sessionStateDir, dbPath, at }
}
// Row A (user request, 100) - compaction row C (50) - compaction stamp -
// rollup claiming 200 for the post-compaction request whose row is missing.
const writeSession = async (sessionStateDir: string, at: (s: number) => string): Promise<void> => {
const dir = join(sessionStateDir, 'sess-ci')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-ci\ncwd: /home/user/testproj\n')
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
JSON.stringify({ type: 'session.compaction_complete', timestamp: at(10), data: { success: true, kind: 'background' } }),
JSON.stringify({ type: 'session.shutdown', timestamp: at(20), data: {
shutdownType: 'routine',
modelMetrics: { 'claude-sonnet-4-5': { requests: { count: 1, cost: 1 },
usage: { inputTokens: 250, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 } } },
} }),
].join('\n') + '\n')
}
const totalInput = (projects: Awaited<ReturnType<typeof parseAllSessions>>): number =>
projects.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
.reduce((sum, c) => sum + c.usage.inputTokens, 0)
it('subtracts a labelled compaction row from the leg it belongs to', async () => {
const { sessionStateDir, dbPath, at } = await setup('labelled')
await writeSession(sessionStateDir, at)
insertLabelled(dbPath, 'sess-ci', 100, at(5), null) // user request, pre-compaction
insertLabelled(dbPath, 'sess-ci', 50, at(9), 'compaction') // the summarization call
// The rollup resets at the compaction and then counts the summarization
// call (250 = 50 + a 200 request whose row has not landed). Row A is
// excluded by the anchor; the compaction row is subtracted by its label.
// Served: 100 + 50 + residual 200 = 350. Without the label the compaction
// row would fall outside the interval and serve twice (400).
expect(totalInput(await parseAllSessions(undefined, 'copilot'))).toBe(350)
})
it('leaves an UNLABELLED store on its previous behaviour', async () => {
const { sessionStateDir, dbPath, at } = await setup('unlabelled')
await writeSession(sessionStateDir, at)
insertLabelled(dbPath, 'sess-ci', 100, at(5), null)
insertLabelled(dbPath, 'sess-ci', 50, at(9), null) // same row, no label
// Indistinguishable from a user request: the anchor excludes it, so its
// 50 is served once as a row and again inside the residual. Over-serve,
// never lose - the documented bound, one request per compaction.
expect(totalInput(await parseAllSessions(undefined, 'copilot'))).toBe(400)
})
it('keeps a compaction row out of per-turn pairing', async () => {
const { sessionStateDir, dbPath, at } = await setup('pairing')
const dir = join(sessionStateDir, 'sess-ci')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-ci\ncwd: /home/user/testproj\n')
// One real request with its assistant.message, and a compaction row a few
// seconds later - inside the 2-minute pairing window.
await writeFile(join(dir, 'events.jsonl'), [
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: 'm1', outputTokens: 25, toolRequests: [] } }),
].join('\n') + '\n')
insertLabelled(dbPath, 'sess-ci', 100, at(11), null)
insertLabelled(dbPath, 'sess-ci', 50, at(13), 'compaction')
const projects = await parseAllSessions(undefined, 'copilot')
const calls = projects.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
const supp = (k: string) => calls.find(c => c.deduplicationKey.includes(k))?.supplementaryAccounting ?? false
// The user request's row pairs with m1 and goes supplementary. The
// compaction row never enters the pairing pass, so it cannot steal that
// partner and keeps its own weight as the real request it is.
const storeCalls = calls.filter(c => c.deduplicationKey.startsWith('copilot-store:'))
expect(storeCalls).toHaveLength(2)
expect(storeCalls.filter(c => c.supplementaryAccounting)).toHaveLength(1)
expect(supp('copilot:sess-ci:m1')).toBe(false)
})
// Validation round 5, machine B, session f38d4326: the compaction row's
// prompt side was counted and priced while its 3,085 output tokens were
// dropped - the only token discrepancy across a 30-session store-matched
// comparison (540,158,021 served vs 540,161,106 in the store). Its output
// has no assistant.message anywhere in events.jsonl, so the store row is the
// only place it exists.
const totalOutput = (projects: Awaited<ReturnType<typeof parseAllSessions>>): number =>
projects.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
.reduce((sum, c) => sum + c.usage.outputTokens, 0)
it("counts the compaction row's own output, which no assistant.message owns", async () => {
const { sessionStateDir, dbPath, at } = await setup('compaction-output')
const dir = join(sessionStateDir, 'sess-ci')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-ci\ncwd: /home/user/testproj\n')
// The real geometry: created_at equals the compaction_complete stamp to
// the millisecond, and there is no assistant.message for this request.
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
JSON.stringify({ type: 'session.compaction_complete', timestamp: at(10), data: { success: true, kind: 'background' } }),
].join('\n') + '\n')
insertLabelled(dbPath, 'sess-ci', 273205, at(10), 'compaction', { output: 3085, cacheRead: 266933 })
const projects = await parseAllSessions(undefined, 'copilot')
const storeCalls = projects.flatMap(p => p.sessions).flatMap(s => s.turns)
.flatMap(t => t.assistantCalls).filter(c => c.deduplicationKey.startsWith('copilot-store:'))
expect(storeCalls).toHaveLength(1)
// input_tokens is cache-inclusive: 273,205 - 266,933 = 6,272 uncached.
expect(storeCalls[0]!.usage).toMatchObject({
inputTokens: 6272,
cacheReadInputTokens: 266933,
outputTokens: 3085,
})
expect(totalOutput(projects)).toBe(3085)
})
it('leaves an UNLABELLED row at output 0 (its per-turn call owns that output)', async () => {
const { sessionStateDir, dbPath, at } = await setup('unlabelled-output')
const dir = join(sessionStateDir, 'sess-ci')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-ci\ncwd: /home/user/testproj\n')
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
JSON.stringify({ type: 'assistant.message', timestamp: at(11), data: { messageId: 'm1', outputTokens: 3085, toolRequests: [] } }),
].join('\n') + '\n')
insertLabelled(dbPath, 'sess-ci', 273205, at(10), null, { output: 3085, cacheRead: 266933 })
// Counted once, by the per-turn call - never twice.
expect(totalOutput(await parseAllSessions(undefined, 'copilot'))).toBe(3085)
})
// The dedup hazard kelchm flagged: on the same turn index, 1.5 s apart, with
// near-identical token shapes. Nothing may collapse them - the store dedup
// key carries the row id, and the content discriminator differs too.
it('keeps the compaction row and its 1.5s-adjacent twin as two distinct calls', async () => {
const { sessionStateDir, dbPath, at } = await setup('adjacent-twin')
const dir = join(sessionStateDir, 'sess-ci')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-ci\ncwd: /home/user/testproj\n')
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
JSON.stringify({ type: 'session.compaction_complete', timestamp: at(10), data: { success: true, kind: 'background' } }),
].join('\n') + '\n')
insertLabelled(dbPath, 'sess-ci', 272139, at(8.5), null, { output: 400, cacheRead: 267005 })
insertLabelled(dbPath, 'sess-ci', 273205, at(10), 'compaction', { output: 3085, cacheRead: 266933 })
const projects = await parseAllSessions(undefined, 'copilot')
const storeCalls = projects.flatMap(p => p.sessions).flatMap(s => s.turns)
.flatMap(t => t.assistantCalls).filter(c => c.deduplicationKey.startsWith('copilot-store:'))
expect(storeCalls).toHaveLength(2)
expect(new Set(storeCalls.map(c => c.deduplicationKey)).size).toBe(2)
// Both rows' prompt sides survive; only the labelled one contributes output.
expect(storeCalls.reduce((s, c) => s + c.usage.inputTokens, 0)).toBe((272139 - 267005) + 6272)
expect(totalOutput(projects)).toBe(3085)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (c6) A migrated cache must reconcile like a virgin one
// ═══════════════════════════════════════════════════════════════════════════
// #946 validation round 6 (@vidoluco): a cache generated by 0.9.20, copied and
// then migrated by this branch, dropped exactly ONE call a virgin cache serves
// - `mai-code-1-flash-picker`, 324 input / 91,136 cache read / $0.01, sitting
// two minutes after the labelled compaction row in its session.
//
// `compactedAt` is a capture-only field this branch adds to the shutdown-ROLLUP
// call, and 0.9.20 already cached that call under the same dedup key. The
// durable union appends only unseen keys, so the bump could not reach it: the
// migrated cache ran the compaction-anchored residual math with no anchor,
// which is exactly the pre-anchor behaviour - the interval opens at -Infinity,
// the PRE-compaction rows are subtracted from a rollup that never counted
// them, and the residual clamps to zero and disappears.
describe.skipIf(!isSqliteAvailable())('(c6) a migrated cache reconciles like a virgin one', () => {
const MODEL = 'mai-code-1-flash-picker'
const createStore = (dbPath: string): void => {
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (p: string) => TestDb }
const db = new DatabaseSync(dbPath)
db.exec(`
CREATE TABLE sessions (id TEXT PRIMARY KEY, cwd TEXT, repository TEXT, created_at TEXT);
CREATE TABLE assistant_usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL, model TEXT NOT NULL,
input_tokens INTEGER, output_tokens INTEGER,
cache_read_tokens INTEGER, cache_write_tokens INTEGER,
reasoning_tokens INTEGER, created_at TEXT,
total_nano_aiu INTEGER, request_multiplier REAL, initiator TEXT);
`)
db.close()
}
const insertRow = (dbPath: string, sid: string, input: number, cacheRead: number, at: string, initiator: string | null, output = 0): void => {
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (p: string) => TestDb }
const db = new DatabaseSync(dbPath)
db.prepare('INSERT OR IGNORE INTO sessions (id, cwd) VALUES (?, ?)').run(sid, '/home/user/pickerproj')
db.prepare(`INSERT INTO assistant_usage_events
(session_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
reasoning_tokens, created_at, total_nano_aiu, request_multiplier, initiator)
VALUES (?, ?, ?, ?, ?, 0, 0, ?, 1000, 1, ?)`).run(sid, MODEL, input, output, cacheRead, at, initiator)
db.close()
}
const build = async () => {
const sessionStateDir = join(tmpHome, 'c6-state')
await mkdir(sessionStateDir, { recursive: true })
const dbPath = join(tmpHome, 'c6-store.db')
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', dbPath)
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() - 4 * 24 * 3600 * 1000
const at = (sec: number): string => new Date(base + sec * 1000).toISOString()
createStore(dbPath)
const dir = join(sessionStateDir, 'sess-8acb5587')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-8acb5587\ncwd: /home/user/pickerproj\n')
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: MODEL } }),
JSON.stringify({ type: 'session.compaction_complete', timestamp: at(300), data: { success: true, kind: 'background' } }),
// The rollup RESET at the compaction, so it claims only the two
// post-compaction requests (324 + 324 uncached, 91,136 + 91,136 cached).
JSON.stringify({ type: 'session.shutdown', timestamp: at(600), data: {
shutdownType: 'routine',
modelMetrics: { [MODEL]: { requests: { count: 2, cost: 1 },
usage: { inputTokens: 182920, outputTokens: 0, cacheReadTokens: 182272, cacheWriteTokens: 0, reasoningTokens: 0 } } },
} }),
].join('\n') + '\n')
// A big PRE-compaction request the rollup never counted...
insertRow(dbPath, 'sess-8acb5587', 500000, 499000, at(100), null)
// ...and the post-compaction row, two minutes after the compaction stamp.
// Its twin never wrote a row, so the residual is exactly one request.
insertRow(dbPath, 'sess-8acb5587', 91460, 91136, at(420), null)
return { dbPath, at }
}
/// What a 0.9.20-generated cache looks like to this build: the rollup call is
/// already cached under its (stable) dedup key with the OLD parser's fields -
/// no `compactedAt` - and the env fingerprint mismatches, which is the code
/// path a PROVIDER_PARSE_VERSIONS bump takes.
const ageCacheToPreBumpFields = async (): Promise<void> => {
clearSessionCache()
const disk = await readCacheOnDisk()
const section = disk.providers['copilot']
expect(section).toBeDefined()
let stripped = 0
for (const file of Object.values(section!.files)) {
for (const turn of file.turns) {
for (const call of turn.calls) {
if (call.compactedAt !== undefined) { delete call.compactedAt; stripped++ }
}
}
}
// Self-check: if nothing was stripped this fixture proves nothing.
expect(stripped).toBeGreaterThan(0)
expect(section!.envFingerprint).not.toBe('0000000000000000')
section!.envFingerprint = '0000000000000000'
await writeCacheOnDisk(disk)
clearSessionCache()
}
const served = async () => {
const calls = (await parseAllSessions(undefined, 'copilot'))
.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),
output: calls.reduce((s, c) => s + c.usage.outputTokens, 0),
residuals: calls.filter(c => c.deduplicationKey.includes(':shutdown-residual:')).length,
}
}
it('keeps the post-compaction residual after a bump onto a pre-bump cache', async () => {
await build()
const virgin = await served()
// 1,000 (pre-compaction row) + 324 (post row) + 324 (residual).
expect(virgin).toMatchObject({ input: 1648, cacheRead: 681272, residuals: 1 })
await ageCacheToPreBumpFields()
expect(await served()).toEqual(virgin)
// Persisted, not merely served: the loss must not come back on a cold read.
clearSessionCache()
expect(await served()).toEqual(virgin)
})
// Same root cause, other direction: the compaction row's output (the round-5
// fix) has to reach a cache written before ITS bump too, which append-only
// could never do - the row's dedup key is deliberately stable.
it('backfills a store row whose fields the bump changed', async () => {
const { dbPath, at } = await build()
insertRow(dbPath, 'sess-8acb5587', 6272, 0, at(300), 'compaction', 3085)
const virgin = await served()
expect(virgin.output).toBe(3085)
// Age the cached compaction row to its pre-fix shape (output 0), strip the
// rollup anchor, and bump - exactly a branch tester's v2 cache.
clearSessionCache()
const disk = await readCacheOnDisk()
for (const file of Object.values(disk.providers['copilot']!.files)) {
for (const turn of file.turns) {
for (const call of turn.calls) {
if (call.initiator === 'compaction') call.usage.outputTokens = 0
delete call.compactedAt
}
}
}
disk.providers['copilot']!.envFingerprint = '0000000000000000'
await writeCacheOnDisk(disk)
clearSessionCache()
expect(await served()).toEqual(virgin)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (c4) Attributed cost equals cost recomputed from the served tokens
// ═══════════════════════════════════════════════════════════════════════════
// `codeburn audit` reports both, and a gap between them means some call is
// carrying dollars its tokens do not justify - the shape of a double-served
// request. It is the cheapest end-to-end check there is on copilot's
// reconciliation, so it runs over every combination of the three
// representations a session can be written in.
//
// Magnitudes are a real reported day (2026-08-18, gpt-5.6-terra: 146 store
// rows, net input 17,792 / cache write 501,395 / cache read 12,097,364 /
// output 63,344 / reasoning 24,831, billed $4.47 by GitHub), collapsed to two
// requests. $4.4687 is what those tokens price at, and what the store billed.
describe.skipIf(!isSqliteAvailable())('(c4) attributed cost tracks recomputed cost', () => {
const MODEL = 'gpt-5.6-terra'
const BILLED_USD = 4.4687
const build = async (name: string, opts: { messages: boolean; rollup: boolean; rows: boolean }) => {
const sessionStateDir = join(tmpHome, `att-state-${name}`)
await mkdir(sessionStateDir, { recursive: true })
const dbPath = join(tmpHome, `att-store-${name}.db`)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', dbPath)
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() - 3 * 24 * 3600 * 1000
const at = (sec: number): string => new Date(base + sec * 1000).toISOString()
createStoreDb(dbPath)
const dir = join(sessionStateDir, 'sess-att')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-att\ncwd: /home/user/testproj\n')
const lines = [JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: MODEL } })]
if (opts.messages) {
lines.push(JSON.stringify({ type: 'assistant.message', timestamp: at(10), data: { messageId: 'm1', outputTokens: 31672, toolRequests: [] } }))
lines.push(JSON.stringify({ type: 'assistant.message', timestamp: at(20), data: { messageId: 'm2', outputTokens: 31672, toolRequests: [] } }))
}
if (opts.rollup) {
lines.push(JSON.stringify({
type: 'session.shutdown', timestamp: at(30), data: {
shutdownType: 'routine',
modelMetrics: { [MODEL]: { requests: { count: 2, cost: 1 }, usage: {
// cache-inclusive, as the CLI writes it
inputTokens: 17792 + 12097364 + 501395, outputTokens: 63344,
cacheReadTokens: 12097364, cacheWriteTokens: 501395, reasoningTokens: 24831 } } },
},
}))
}
await writeFile(join(dir, 'events.jsonl'), lines.join('\n') + '\n')
if (opts.rows) {
insertStoreRow(dbPath, 'sess-att', 8896 + 6048682 + 250697, 6048682, 250697, at(11), 12415)
insertStoreRow(dbPath, 'sess-att', 8896 + 6048682 + 250698, 6048682, 250698, at(21), 12416)
}
}
const audit = async () => {
const { aggregateAudit } = await import('../src/audit-report.js')
const rows = await aggregateAudit(await parseAllSessions(undefined, 'copilot'))
const row = rows.find(r => r.provider === 'copilot' && r.model === MODEL)
expect(row).toBeDefined()
return row!
}
for (const shape of [
{ name: 'rows+messages+rollup', messages: true, rollup: true, rows: true },
{ name: 'rows+messages', messages: true, rollup: false, rows: true },
{ name: 'rows+rollup', messages: false, rollup: true, rows: true },
{ name: 'messages+rollup', messages: true, rollup: true, rows: false },
]) {
it(`never attributes more than the served tokens price at: ${shape.name}`, async () => {
await build(shape.name.replace(/\+/g, '-'), shape)
const row = await audit()
expect(row.attributedCostUSD).toBeCloseTo(row.cost.recomputedTotalUSD, 6)
})
}
it('lands on the billed amount when the store covers the session', async () => {
await build('billed', { messages: true, rollup: true, rows: true })
const row = await audit()
// Attributed == recomputed == what GitHub billed for these requests.
expect(row.cost.recomputedTotalUSD).toBeCloseTo(BILLED_USD, 3)
expect(row.attributedCostUSD).toBeCloseTo(BILLED_USD, 3)
// Reasoning is inside output and must not be re-priced on top of it:
// the pre-fix reading showed 63,344 + 24,831 = 88,175.
expect(row.displayed.outputTokens).toBe(63344)
expect(row.raw.reasoningTokens).toBe(24831)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (c3) session.shutdown counters are CUMULATIVE across resume legs
// ═══════════════════════════════════════════════════════════════════════════
// Measured on a real 3-leg session (CLI 1.0.80): every counter in a leg,
// billing and tokens alike, includes the legs before it, and the LAST leg of a
// complete session equals the session's store-row total exactly. The parser
// already converts cumulative to per-leg deltas at emission, so the interval
// arithmetic downstream sees per-leg claims - these pin that end to end,
// because reading the raw journal makes it look like the opposite.
describe.skipIf(!isSqliteAvailable())('(c3) cumulative shutdown counters across resume legs', () => {
const setup = async (name: string) => {
const sessionStateDir = join(tmpHome, `cum-state-${name}`)
await mkdir(sessionStateDir, { recursive: true })
const dbPath = join(tmpHome, `cum-store-${name}.db`)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', dbPath)
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() - 4 * 24 * 3600 * 1000
const at = (sec: number): string => new Date(base + sec * 1000).toISOString()
createStoreDb(dbPath)
return { sessionStateDir, dbPath, at }
}
const rollup = (ts: string, cumulativeInput: number): string => JSON.stringify({
type: 'session.shutdown',
timestamp: ts,
data: {
shutdownType: 'routine',
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 1, cost: 1 },
usage: { inputTokens: cumulativeInput, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
},
},
},
})
const totalInput = (projects: Awaited<ReturnType<typeof parseAllSessions>>): number =>
projects.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
.reduce((sum, c) => sum + c.usage.inputTokens, 0)
it('serves a complete 3-leg session at exactly its store total, not the sum of its legs', async () => {
const { sessionStateDir, dbPath, at } = await setup('complete')
const dir = join(sessionStateDir, 'sess-cum')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-cum\ncwd: /home/user/testproj\n')
// Cumulative legs 100 / 400 / 900. Summing them naively gives 1,400; the
// truth is 900, which the last leg states and the rows confirm.
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
rollup(at(10), 100), rollup(at(20), 400), rollup(at(30), 900),
].join('\n') + '\n')
// Rows covering the session completely, summing to the last leg exactly -
// the invariant observed on real multi-leg sessions.
insertStoreRow(dbPath, 'sess-cum', 100, 0, 0, at(5))
insertStoreRow(dbPath, 'sess-cum', 300, 0, 0, at(15))
insertStoreRow(dbPath, 'sess-cum', 500, 0, 0, at(25))
// Complete coverage: every residual retires and the session serves its
// rows once. A per-leg reading of the journal would serve 1,400.
expect(totalInput(await parseAllSessions(undefined, 'copilot'))).toBe(900)
})
it('serves an uncovered cumulative session once, at its last leg', async () => {
const { sessionStateDir, dbPath, at } = await setup('uncovered')
const dir = join(sessionStateDir, 'sess-cum2')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-cum2\ncwd: /home/user/testproj\n')
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
rollup(at(10), 100), rollup(at(20), 400), rollup(at(30), 900),
].join('\n') + '\n')
// One row, far too small to cover anything: the rest serves as residuals.
insertStoreRow(dbPath, 'sess-cum2', 50, 0, 0, at(5))
// 50 (row) + 50 (leg 1 residual) + 300 (leg 2 delta) + 500 (leg 3 delta).
expect(totalInput(await parseAllSessions(undefined, 'copilot'))).toBe(900)
})
it('treats a leg SMALLER than its predecessor as a fresh epoch, not a negative delta', async () => {
const { sessionStateDir, dbPath, at } = await setup('reset')
const dir = join(sessionStateDir, 'sess-reset')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-reset\ncwd: /home/user/testproj\n')
// Leg 2 reports LESS than leg 1. Either an older per-leg CLI or a
// mid-session counter reset; both mean leg 2 stands on its own. Clamping
// the delta to 0 would silently drop it.
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
rollup(at(10), 900), rollup(at(20), 200),
].join('\n') + '\n')
insertStoreRow(dbPath, 'sess-reset', 10, 0, 0, at(5))
// 10 (row) + 890 (leg 1 residual) + 200 (leg 2 in full).
expect(totalInput(await parseAllSessions(undefined, 'copilot'))).toBe(1100)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (c2) Residual dedup keys name the leg's INSTANT, never its position
// ═══════════════════════════════════════════════════════════════════════════
// A residual key used to carry the leg's index into the session's sorted leg
// list. An index is only stable while nothing is ever inserted before it, and
// legs are sorted across every cached file for the session — so an earlier leg
// arriving later would renumber the residuals after it. The sync ledger has
// already sent the old names, and a renamed key is a span the receiver takes a
// second time (there is no retraction for a usage span).
//
// The leg's own timestamp cannot move: session files are append-only, and the
// equal-timestamp coalescing above makes it unique per leg. Pinning that the
// key is DERIVED from it is what forecloses the whole class, which is stronger
// than pinning one insertion scenario — today a second file's leg collides on
// the rollup's own dedup key before it can ever reach the residual sweep, so
// the reachable repro would prove nothing about the next one. (Review B, B-4.)
describe.skipIf(!isSqliteAvailable())('(c2) residual keys are derived from the leg timestamp', () => {
it('names each residual after its leg instant, not its index', async () => {
const dayN = Date.now() - 5 * 24 * 60 * 60 * 1000
const at = (offsetHours: number): string => new Date(dayN + offsetHours * 3600 * 1000).toISOString()
const sessionStateDir = join(tmpHome, 'reskey-state')
await mkdir(sessionStateDir, { recursive: true })
const dbPath = join(tmpHome, 'reskey-store.db')
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', dbPath)
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'))
// Two legs, one partially-covering row: both legs mint a residual.
const dir = join(sessionStateDir, 'sess-reskey')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-reskey\ncwd: /home/user/testproj\n')
const rollup = (ts: string, cumulativeInput: number): string => JSON.stringify({
type: 'session.shutdown',
timestamp: ts,
data: {
shutdownType: 'routine',
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 1, cost: 1 },
usage: { inputTokens: cumulativeInput, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
},
},
},
})
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
JSON.stringify({ type: 'assistant.message', timestamp: at(1), data: { messageId: 'msg-1', outputTokens: 10, toolRequests: [] } }),
rollup(at(2), 500),
rollup(at(26), 800),
].join('\n') + '\n')
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-reskey', 100, 0, 0, at(25))
const keys = (await parseAllSessions(undefined, 'copilot'))
.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
.map(c => c.deduplicationKey)
.filter(k => k.includes(':shutdown-residual:'))
.sort()
expect(keys).toEqual([
`copilot:sess-reskey:shutdown-residual:claude-sonnet-4-5:${Date.parse(at(2))}`,
`copilot:sess-reskey:shutdown-residual:claude-sonnet-4-5:${Date.parse(at(26))}`,
].sort())
// The positional names must be gone: `:0` / `:1` are exactly what a leg
// insertion would have renumbered.
expect(keys.some(k => k.endsWith(':0') || k.endsWith(':1'))).toBe(false)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (c1) In-session compaction anchors the leg's store-row interval
// ═══════════════════════════════════════════════════════════════════════════
// The CLI's session.shutdown rollup RESETS its counters at a successful
// in-session compaction (confirmed against @github/copilot 1.0.80:
// `session.compaction_complete` with success:true, whose compactionTokensUsed
// the CLI charges through its own recordUsage path). A leg containing one
// therefore describes only its POST-compaction requests, and subtracting the
// whole pre-compaction conversation from it makes the residual short by
// exactly that much. The floor hides the error while the store is complete;
// a partial snapshot turns it into a permanent undercount once a day seals.
// (#946 review, blocker 2, the half the read-ordering fence cannot reach.)
describe.skipIf(!isSqliteAvailable())('(c1) compaction-anchored residual intervals', () => {
// One session, one model, laid out as: row(s), an optional compaction, then
// a single shutdown rollup covering only what follows the compaction.
const writeCompactedSession = async (
sessionStateDir: string,
sessionId: string,
at: (offsetSec: number) => string,
opts: { compaction?: { atSec: number; success: boolean }; rollupInput: number },
): Promise<void> => {
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: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
]
if (opts.compaction) {
lines.push(JSON.stringify({
type: 'session.compaction_start',
timestamp: at(opts.compaction.atSec - 1),
data: { currentTokens: 180000, tokenLimit: 200000, trigger: 'automatic' },
}))
// Real payload shape, extra fields included on purpose: the parser must
// read `success` and tolerate everything else.
lines.push(JSON.stringify({
type: 'session.compaction_complete',
timestamp: at(opts.compaction.atSec),
data: {
success: opts.compaction.success,
kind: opts.compaction.success ? 'background' : 'manualFailure',
compactionTokensUsed: { inputTokens: 1200, outputTokens: 300, copilotUsage: { totalNanoAiu: 4000000 } },
preCompactionTokens: 180000, postCompactionTokens: 20000,
messagesRemoved: 42, tokensRemoved: 160000, trigger: 'automatic',
model: 'claude-sonnet-4-5', requestId: 'req-x', duration: 900,
},
}))
}
lines.push(JSON.stringify({
type: 'session.shutdown',
timestamp: at(20),
data: {
shutdownType: 'routine',
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 1, cost: 1 },
usage: { inputTokens: opts.rollupInput, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
},
},
},
}))
await writeFile(join(dir, 'events.jsonl'), lines.join('\n') + '\n')
}
const setup = async (name: string) => {
const sessionStateDir = join(tmpHome, `cmp-state-${name}`)
await mkdir(sessionStateDir, { recursive: true })
const dbPath = join(tmpHome, `cmp-store-${name}.db`)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', dbPath)
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()
createStoreDb(dbPath)
return { sessionStateDir, dbPath, at }
}
const totalInput = (projects: Awaited<ReturnType<typeof parseAllSessions>>): number =>
projects.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
.reduce((sum, c) => sum + c.usage.inputTokens, 0)
it("totals the maintainer's compaction repro at 300 with the post-compaction row missing", async () => {
const { sessionStateDir, dbPath, at } = await setup('repro')
// Row A: 100 tokens, BEFORE the compaction. Request B's row has not landed
// in this snapshot. The sole rollup carries 200 — request B only.
insertStoreRow(dbPath, 'sess-cmp', 100, 0, 0, at(5))
await writeCompactedSession(sessionStateDir, 'sess-cmp', at, {
compaction: { atSec: 10, success: true }, rollupInput: 200,
})
// A(100) served as a row + a residual of the full 200 the rollup claims,
// because no row in (compaction, shutdown] can account for any of it.
// Anchoring at the previous leg instead subtracts A and reports 200.
expect(totalInput(await parseAllSessions(undefined, 'copilot'))).toBe(300)
// Idempotent through the cache: compactedAt is persisted with the leg.
clearSessionCache()
expect(totalInput(await parseAllSessions(undefined, 'copilot'))).toBe(300)
})
it('does not double once the post-compaction row lands', async () => {
const { sessionStateDir, dbPath, at } = await setup('complete')
insertStoreRow(dbPath, 'sess-cmp2', 100, 0, 0, at(5)) // pre-compaction
insertStoreRow(dbPath, 'sess-cmp2', 200, 0, 0, at(15)) // post-compaction, = the rollup
await writeCompactedSession(sessionStateDir, 'sess-cmp2', at, {
compaction: { atSec: 10, success: true }, rollupInput: 200,
})
// The post-compaction row covers the leg exactly, so the residual retires
// to zero and the total is the two rows. Same answer the missing-row case
// converges to, which is the point.
expect(totalInput(await parseAllSessions(undefined, 'copilot'))).toBe(300)
})
it('ignores a FAILED compaction — nothing reset, so nothing to anchor', async () => {
const { sessionStateDir, dbPath, at } = await setup('failed')
insertStoreRow(dbPath, 'sess-cmp3', 100, 0, 0, at(5))
await writeCompactedSession(sessionStateDir, 'sess-cmp3', at, {
compaction: { atSec: 10, success: false }, rollupInput: 200,
})
// A failed compaction leaves the counters alone, so the rollup really does
// cover row A: 100 (row) + 100 (residual) = 200, unchanged behaviour.
expect(totalInput(await parseAllSessions(undefined, 'copilot'))).toBe(200)
})
it('leaves a leg with no compaction on the previous-leg interval', async () => {
const { sessionStateDir, dbPath, at } = await setup('none')
insertStoreRow(dbPath, 'sess-cmp4', 100, 0, 0, at(5))
await writeCompactedSession(sessionStateDir, 'sess-cmp4', at, { rollupInput: 200 })
expect(totalInput(await parseAllSessions(undefined, 'copilot'))).toBe(200)
})
})
describe.skipIf(!isSqliteAvailable())('(p) reconciliation never outruns the served store rows', () => {
it('serves the rollup total at every stage while rows progressively land', async () => {
const { dbPath, at, writeSession, sumUsage } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
await writeSession('sess-rev', { output: 25, rollup: true })
// Refresh 1: the store was read before the rows committed — it holds
// nothing for this session. The rollup is the only record and must
// count; a zero here is the old design's lost refresh.
const first = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(first).toEqual({ input: 600, cacheRead: 17000, cacheWrite: 2400, output: 25 })
// The first row lands: it serves per-request, and the rollup usage it
// does not yet represent serves once as the residual — the total never
// dips below what the rollup proved happened.
clearSessionCache()
insertStoreRow(dbPath, 'sess-rev', 12000, 10000, 1500, at(12)) // input 500
const second = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(second).toEqual({ input: 600, cacheRead: 17000, cacheWrite: 2400, output: 25 })
// The second row completes coverage: totals unchanged, now served
// entirely by rows — the residual has retired to zero.
clearSessionCache()
insertStoreRow(dbPath, 'sess-rev', 8000, 7000, 900, at(15)) // input 100
const third = await parseAllSessions(undefined, 'copilot')
expect(sumUsage(third)).toEqual({ input: 600, cacheRead: 17000, cacheWrite: 2400, output: 25 })
const keys = third.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls).map(c => c.deduplicationKey)
expect(keys.filter(k => k.startsWith('copilot-store:')).length).toBe(2)
expect(keys.some(k => k.includes(':shutdown-residual:'))).toBe(false)
expect(keys.some(k => k.includes(':shutdown:'))).toBe(false)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (s) Behavioral weight: supplementary accounting never fabricates activity
// ═══════════════════════════════════════════════════════════════════════════
// The round-6 finding 1 + the maintainer's weight contract. A shutdown rollup
// is aggregate accounting, never a request; a store row is a real request but
// pairs 1:1 with its served per-turn call when one exists. Blanket weight 1
// fabricated apiCalls/turns/modelCalls for every covered request; blanket
// weight 0 would hide crash-recovered requests. The pinned contract:
// JSONL + matching row = 1 call/turn; JSONL + rollup = the JSONL count only;
// store-only request = 1 call; rollup-only = 0 calls with full usage.
describe.skipIf(!isSqliteAvailable())('(s) behavioral weight of store rows and rollups', () => {
it('pins call/turn weight across the four review scenarios', async () => {
const { dbPath, at, writeSession } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
// A: one real request, both representations served.
await writeSession('sess-paired', { output: 25 })
insertStoreRow(dbPath, 'sess-paired', 12000, 10000, 1500, at(12))
// B: pre-store session — per-turn call + rollup, no rows.
await writeSession('sess-rollup', { output: 30, rollup: true })
// C: store-only request (crash lost the per-turn call; no session-state).
insertStoreRow(dbPath, 'sess-crash', 8000, 7000, 900, at(15))
const sessions = (await parseAllSessions(undefined, 'copilot')).flatMap(p => p.sessions)
const byId = new Map(sessions.map(s => [s.sessionId, s]))
const modelCalls = (s: NonNullable<ReturnType<typeof byId.get>>): number =>
Object.values(s.modelBreakdown).reduce((sum, m) => sum + m.calls, 0)
const paired = byId.get('sess-paired')!
expect(paired.apiCalls).toBe(1)
expect(paired.turns.length).toBe(1)
expect(modelCalls(paired)).toBe(1)
expect(paired.totalInputTokens).toBe(500) // the row's tokens count in full
expect(paired.totalCacheReadTokens).toBe(10000)
expect(paired.totalOutputTokens).toBe(25)
const withRollup = byId.get('sess-rollup')!
expect(withRollup.apiCalls).toBe(1) // the per-turn call only
expect(withRollup.turns.length).toBe(1)
expect(modelCalls(withRollup)).toBe(1)
expect(withRollup.totalInputTokens).toBe(600) // rollup tokens retained
const crash = byId.get('sess-crash')!
expect(crash.apiCalls).toBe(1) // a real, store-only request
expect(crash.turns.length).toBe(1)
expect(crash.totalInputTokens).toBe(100)
})
it('pairs rows per model: a subagent row without its per-turn call still counts', async () => {
const { dbPath, at, writeSession } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
// One sonnet request served both ways, plus a haiku subagent request
// whose per-turn call was lost — the haiku row must not pair against the
// sonnet call.
await writeSession('sess-multi', { output: 25 }) // sonnet per-turn call
insertStoreRow(dbPath, 'sess-multi', 12000, 10000, 1500, at(12)) // sonnet row → paired
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (path: string) => TestDb }
const db = new DatabaseSync(dbPath)
db.prepare(
`INSERT INTO assistant_usage_events
(session_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, created_at)
VALUES ('sess-multi', 'claude-haiku-4-5', 5050, 0, 5000, 0, 0, ?)`
).run(at(14))
db.close()
const session = (await parseAllSessions(undefined, 'copilot')).flatMap(p => p.sessions)
.find(s => s.sessionId === 'sess-multi')!
expect(session.apiCalls).toBe(2) // the sonnet request + the haiku request
const calls = Object.fromEntries(Object.entries(session.modelBreakdown).map(([m, b]) => [m, b.calls]))
expect(Object.values(calls).reduce((a, b) => a + b, 0)).toBe(2)
expect(session.totalInputTokens).toBe(500 + 50)
})
it('serves a rollup-only session with zero call weight but full usage', async () => {
const { dbPath, at } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
// events.jsonl holding ONLY the shutdown rollup — no per-turn events
// survived. Its tokens are real; its "calls" are not.
const dir = join(tmpHome, 'session-state', 'sess-agg')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-agg\ncwd: /home/user/testproj\n')
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
JSON.stringify({
type: 'session.shutdown',
timestamp: at(20),
data: {
shutdownType: 'routine',
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 2, cost: 1 },
usage: { inputTokens: 20000, outputTokens: 0, cacheReadTokens: 17000, cacheWriteTokens: 2400, reasoningTokens: 0 },
},
},
},
}),
].join('\n') + '\n')
const sessions = (await parseAllSessions(undefined, 'copilot')).flatMap(p => p.sessions)
const agg = sessions.find(s => s.sessionId === 'sess-agg')
expect(agg).toBeDefined()
expect(agg!.apiCalls).toBe(0)
expect(agg!.totalInputTokens).toBe(600)
expect(agg!.totalCacheReadTokens).toBe(17000)
expect(agg!.totalCostUSD).toBeGreaterThan(0)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (t) A deferred store read marks hydration incomplete
// ═══════════════════════════════════════════════════════════════════════════
// The round-6 finding 2. A changed store whose read defers (busy, EACCES —
// every retryable shape funnels through the same busy-shaped throw) keeps
// serving its cached rows, but the data the read would have added is missing
// from the pass: reporting hydration complete would let the daily backfill
// finalize history without it. The fence must hold until the read lands.
describe.skipIf(!isSqliteAvailable())('(t) deferred store read marks hydration incomplete', () => {
it.skipIf(process.getuid?.() === 0)('holds the fence while the changed store is unreadable, then recovers', async () => {
const { chmod } = await import('fs/promises')
const { dbPath, at, writeSession, sumUsage } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-h', 12000, 10000, 1500, at(12)) // input 500
await writeSession('sess-h', { output: 25 })
const first = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(first).toEqual({ input: 500, cacheRead: 10000, cacheWrite: 1500, output: 25 })
expect(isSessionHydrationComplete()).toBe(true)
// The store grows, then becomes unreadable before the refresh reads it.
clearSessionCache()
insertStoreRow(dbPath, 'sess-h', 8000, 7000, 900, at(20)) // input 100
await chmod(dbPath, 0o000)
try {
const second = sumUsage(await parseAllSessions(undefined, 'copilot'))
// Cached rows keep serving — the deferral is invisible in totals...
expect(second).toEqual({ input: 500, cacheRead: 10000, cacheWrite: 1500, output: 25 })
// ...but the pass must not claim full hydration: a row is missing.
expect(isSessionHydrationComplete()).toBe(false)
} finally {
await chmod(dbPath, 0o644)
}
// Readable again: the deferred row lands and the fence lifts.
clearSessionCache()
const third = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(third).toEqual({ input: 600, cacheRead: 17000, cacheWrite: 2400, output: 25 })
expect(isSessionHydrationComplete()).toBe(true)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (t2) The fence actually HOLDS THE DAILY WATERMARK, end to end
// ═══════════════════════════════════════════════════════════════════════════
// The maintainer's refinement asked for a production-path regression proving
// "busy store + unchanged cached JSONL => hydration incomplete AND daily
// watermark held" — the flag alone is not the contract. This wires the REAL
// isSessionHydrationComplete into ensureCacheHydrated (no stub) and asserts
// the daily cache refuses to finalize while the store's re-read is deferred.
describe.skipIf(!isSqliteAvailable())('(t2) deferred store holds the daily watermark', () => {
it.skipIf(process.getuid?.() === 0)('leaves the daily cache incomplete until the store is readable', async () => {
const { chmod } = await import('fs/promises')
const { dbPath, at, writeSession } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-wm', 12000, 10000, 1500, at(12))
await writeSession('sess-wm', { output: 25 })
// Seed a daily cache whose watermark is deliberately BEHIND yesterday, so
// every run below faces a real backfill gap to seal while the watermark is
// non-null and its preservation is observable. The session cache stays
// warm and the JSONL unchanged — the reviewer's shape, where no
// session-state parser runs and only the store's own deferral can reach
// the fence.
const dayStr = (n: number): string => {
const d = new Date()
d.setDate(d.getDate() - n)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const seededWatermark = dayStr(5)
const seedDailyCache = async () => {
await saveDailyCache({
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
tzKey: currentTzKey(),
lastComputedDate: seededWatermark,
complete: true,
watermarkTrusted: true,
days: [],
})
}
const hydrate = async () => {
clearSessionCache()
return ensureCacheHydrated(
range => parseAllSessions(range, 'copilot'),
aggregateProjectsIntoDays,
'',
// The production wiring: the real flag, not a stub.
isSessionHydrationComplete,
)
}
// The store grows and turns unreadable before the refresh reads it.
await seedDailyCache()
insertStoreRow(dbPath, 'sess-wm', 8000, 7000, 900, at(20))
await chmod(dbPath, 0o000)
let degraded: Awaited<ReturnType<typeof hydrate>>
try {
degraded = await hydrate()
expect(isSessionHydrationComplete()).toBe(false)
// The contract the reviewer asked for: history does not seal, and the
// watermark is HELD where it was — not advanced by a parse that never
// read the new row, and not reset either.
expect(degraded.complete).toBe(false)
expect(degraded.lastComputedDate).toBe(seededWatermark)
} finally {
await chmod(dbPath, 0o644)
}
// Readable again — and healing must happen IN PLACE, against the same
// persisted incomplete cache the degraded run left behind (no reseed):
// the deferred row lands, the day seals, and the watermark advances.
const healed = await hydrate()
expect(isSessionHydrationComplete()).toBe(true)
expect(healed.complete).toBe(true)
expect(healed.lastComputedDate).not.toBe(seededWatermark)
expect(healed.lastComputedDate! > seededWatermark).toBe(true)
// The row deferred during the outage is in the sealed history.
const sealed = healed.days.reduce((s, d) => s + (d.providers['copilot']?.inputTokens ?? 0), 0)
expect(sealed).toBe(600)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (u) A project learned after rows were cached cannot split the session
// ═══════════════════════════════════════════════════════════════════════════
// The round-6 companion finding. Store rows cached before their session's
// session-state dir existed carry the store's cwd-derived project; when
// events.jsonl later appears (workspace.yaml cwd), the store file itself may
// be unchanged — its cached calls are reused verbatim. Project is part of the
// session grouping key, so without serve-time unification one real session
// splits in two. The serve pass rewrites cached store calls to the
// session-state project whenever the serve set knows it.
describe.skipIf(!isSqliteAvailable())('(u) late-learned project identity unifies cached store rows', () => {
it('serves one session under the session-state project after it appears', async () => {
const { dbPath, at, writeSession } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-late', 12000, 10000, 1500, at(12), 0, '/home/user/storeproj')
const s1 = (await parseAllSessions(undefined, 'copilot')).flatMap(p => p.sessions)
.find(s => s.sessionId === 'sess-late')!
expect(s1.project).toBe('storeproj')
// The session-state dir appears; the store file is UNCHANGED, so its
// cached call still carries 'storeproj' until serve-time unification.
clearSessionCache()
await writeSession('sess-late', { output: 25 })
const sessions = (await parseAllSessions(undefined, 'copilot')).flatMap(p => p.sessions)
.filter(s => s.sessionId === 'sess-late')
expect(sessions).toHaveLength(1)
expect(sessions[0]!.project).toBe('testproj')
expect(sessions[0]!.totalInputTokens).toBe(500)
expect(sessions[0]!.totalOutputTokens).toBe(25)
expect(sessions[0]!.apiCalls).toBe(1)
})
it('keeps one session when the session-state dir is pruned and the jsonl orphans', async () => {
// The reverse shape (round-6.5 finding): events.jsonl becomes a durable
// orphan, losing its source.project, while the store rows live on. Both
// sides must adopt the surviving label — the store's own — or one real
// request serves as two sessions with doubled call weight.
const { dbPath, at, writeSession } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-prune', 12000, 10000, 1500, at(12), 0, '/home/user/storeproj')
await writeSession('sess-prune', { output: 25 })
const s1 = (await parseAllSessions(undefined, 'copilot')).flatMap(p => p.sessions)
.filter(s => s.sessionId === 'sess-prune')
expect(s1).toHaveLength(1)
expect(s1[0]!.apiCalls).toBe(1)
clearSessionCache()
await rm(join(tmpHome, 'session-state', 'sess-prune'), { recursive: true, force: true })
const s2 = (await parseAllSessions(undefined, 'copilot')).flatMap(p => p.sessions)
.filter(s => s.sessionId === 'sess-prune')
expect(s2).toHaveLength(1)
// The rows were cached carrying the jsonl-derived label, so the session
// keeps it even after the jsonl orphans — stable, and never split.
expect(s2[0]!.project).toBe('testproj')
expect(s2[0]!.apiCalls).toBe(1)
expect(s2[0]!.totalInputTokens).toBe(500)
expect(s2[0]!.totalOutputTokens).toBe(25)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (v) A same-path store reset cannot swallow new usage
// ═══════════════════════════════════════════════════════════════════════════
// The round-6 finding 4. Recreating the DB at the same path restarts the
// AUTOINCREMENT sequence; under a bare <sid>:<rowId> key the durable union
// rejected the recreated row as already-cached and its usage vanished. The
// content-discriminated key admits it; the original row remains served as
// real past usage (the durable contract: cached history never shrinks).
describe.skipIf(!isSqliteAvailable())('(v) same-path store reset', () => {
it('serves the recreated row alongside the durable original', async () => {
const { dbPath, at, sumUsage } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-reset', 12000, 10000, 1500, at(12)) // input 500
const first = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(first).toEqual({ input: 500, cacheRead: 10000, cacheWrite: 1500, output: 0 })
clearSessionCache()
await rm(dbPath, { force: true })
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-reset', 8000, 7000, 900, at(30)) // row id 1 again
const second = sumUsage(await parseAllSessions(undefined, 'copilot'))
expect(second).toEqual({ input: 600, cacheRead: 17000, cacheWrite: 2400, output: 0 })
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (w) Mixed coverage: a crash tail cannot cancel a leg's missing rows
// ═══════════════════════════════════════════════════════════════════════════
// The round-6.5 converging finding (grok + gpt-5.6-sol independently). A
// lifetime `max(0, rollup allRows)` residual lets store rows the rollup
// never covered (a crash tail, a later reset) cancel usage the rollup DID
// cover whose rows are missing. Residuals are therefore computed per rollup
// leg over only the rows in that leg's interval — rows commit before their
// leg's shutdown line, so a row after the leg belongs to the tail, never to
// the subtraction.
describe.skipIf(!isSqliteAvailable())('(w) per-leg residual under mixed coverage', () => {
it('serves the covered gap AND the crash tail in full', async () => {
const { dbPath, at } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
const dir = join(tmpHome, 'session-state', 'sess-mix')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-mix\ncwd: /home/user/testproj\n')
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
JSON.stringify({ type: 'assistant.message', timestamp: at(5), data: { messageId: 'msg-1', outputTokens: 25, toolRequests: [] } }),
// The rollup covers requests R1+R2 (cache-inclusive input 10,200 →
// uncached 200, cacheRead 10,000).
JSON.stringify({
type: 'session.shutdown',
timestamp: at(20),
data: {
shutdownType: 'routine',
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 2, cost: 1 },
usage: { inputTokens: 10200, outputTokens: 25, cacheReadTokens: 10000, cacheWriteTokens: 0, reasoningTokens: 0 },
},
},
},
}),
].join('\n') + '\n')
// R1's row exists (input 100 / cacheRead 5,000); R2's row is missing
// (pre-store leg). R3 is a crash-tail row AFTER the shutdown (input 300)
// that no rollup ever covered.
insertStoreRow(dbPath, 'sess-mix', 5100, 5000, 0, at(10)) // R1
insertStoreRow(dbPath, 'sess-mix', 300, 0, 0, at(30)) // R3, after the leg
const session = (await parseAllSessions(undefined, 'copilot')).flatMap(p => p.sessions)
.find(s => s.sessionId === 'sess-mix')!
// Truth: R1 (100/5,000) + R2 (100/5,000, via the leg residual) + R3 (300).
// A lifetime subtraction would have served input 400 — R3's crash input
// cancelling R2's missing row.
expect(session.totalInputTokens).toBe(500)
expect(session.totalCacheReadTokens).toBe(10000)
expect(session.totalOutputTokens).toBe(25)
// Weight: the per-turn call + the unpaired crash row.
expect(session.apiCalls).toBe(2)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (x) Per-leg residuals keep each leg's gap on that leg's own day
// ═══════════════════════════════════════════════════════════════════════════
// Round-6.5: one residual stamped at the LAST leg moved every earlier leg's
// uncovered usage onto the final day — day 1 sealed 0, day 2 sealed double.
// Each leg's residual is anchored at that leg's own timestamp.
describe.skipIf(!isSqliteAvailable())('(x) multi-leg residual day attribution', () => {
it('serves each leg\'s uncovered usage on its own day', async () => {
const dayN = Date.now() - 5 * 24 * 60 * 60 * 1000
const at = (offsetHours: number): string => new Date(dayN + offsetHours * 3600 * 1000).toISOString()
const sessionStateDir = join(tmpHome, 'session-state')
await mkdir(sessionStateDir, { recursive: true })
const dbPath = join(tmpHome, 'session-store.db')
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', dbPath)
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'))
// A resumed session: leg 1 shuts down on day 1 (cumulative input 500,
// cache-free for arithmetic clarity), leg 2 the next day (cumulative 800
// → delta 300). The store has one leg-2 row (100); leg 1 predates the
// store entirely and leg 2 is only partially covered, so BOTH legs carry
// a nonzero residual on their own day.
const dir = join(sessionStateDir, 'sess-legs')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-legs\ncwd: /home/user/testproj\n')
const rollup = (ts: string, cumulativeInput: number): string => JSON.stringify({
type: 'session.shutdown',
timestamp: ts,
data: {
shutdownType: 'routine',
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 1, cost: 1 },
usage: { inputTokens: cumulativeInput, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
},
},
},
})
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
JSON.stringify({ type: 'assistant.message', timestamp: at(1), data: { messageId: 'msg-1', outputTokens: 10, toolRequests: [] } }),
rollup(at(2), 500),
rollup(at(26), 800),
].join('\n') + '\n')
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-legs', 100, 0, 0, at(25))
const inputIn = async (startH: number, endH: number): Promise<number> => {
const projects = await parseAllSessions(
{ start: new Date(dayN + startH * 3600 * 1000), end: new Date(dayN + endH * 3600 * 1000) }, 'copilot')
return projects.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
.reduce((s, c) => s + c.usage.inputTokens, 0)
}
expect(await inputIn(-1, 12)).toBe(500) // day 1: leg 1's gap, at leg 1's stamp
expect(await inputIn(12, 36)).toBe(300) // day 2: the row (100) + leg 2's residual (200)
expect(await inputIn(-1, 36)).toBe(800) // both: exactly once
// A range that excludes every behavioral turn (the per-turn call) but
// holds residual turns from different days: they must stay SEPARATE
// turns on their own days — an anchorless merge would re-anchor day-2
// accounting onto day 1's turn stamp.
const anchorless = (await parseAllSessions(
{ start: new Date(dayN + 1.5 * 3600 * 1000), end: new Date(dayN + 36 * 3600 * 1000) }, 'copilot'))
.flatMap(p => p.sessions).filter(s => s.sessionId === 'sess-legs')
const turnDays = anchorless.flatMap(s => s.turns).map(t => new Date(t.timestamp).toISOString().slice(0, 10))
expect(new Set(turnDays).size).toBeGreaterThanOrEqual(2)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (w2) Residual edge shapes: equal-timestamp legs, unparseable leg stamps,
// and crash rows outside the pairing window
// ═══════════════════════════════════════════════════════════════════════════
describe.skipIf(!isSqliteAvailable())('(w2) residual and pairing edge shapes', () => {
it('coalesces equal-timestamp legs so their shared rows subtract once', async () => {
const { dbPath, at } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
const dir = join(tmpHome, 'session-state', 'sess-ties')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-ties\ncwd: /home/user/testproj\n')
const rollup = (ts: string, cumulativeInput: number): string => JSON.stringify({
type: 'session.shutdown',
timestamp: ts,
data: {
shutdownType: 'routine',
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 1, cost: 1 },
usage: { inputTokens: cumulativeInput, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
},
},
},
})
// Two shutdown legs stamped the SAME second (deltas 100 and 200); the
// store holds one 200-token row at that instant. A strict interval rule
// hands the row to the first leg and mints a full 200 residual for the
// second (serving 400); coalescing subtracts it once (serving 300).
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
rollup(at(20), 100),
rollup(at(20), 300),
].join('\n') + '\n')
insertStoreRow(dbPath, 'sess-ties', 200, 0, 0, at(20))
const input = (await parseAllSessions(undefined, 'copilot')).flatMap(p => p.sessions)
.filter(s => s.sessionId === 'sess-ties')
.reduce((s, x) => s + x.totalInputTokens, 0)
expect(input).toBe(300)
})
it('serves a rollup with an unparseable timestamp instead of silently dropping it', async () => {
const { dbPath, at } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
const dir = join(tmpHome, 'session-state', 'sess-badts')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-badts\ncwd: /home/user/testproj\n')
// The leg's timestamp cannot parse, so it can never enter the residual
// sweep — dropping it because rows exist would silently lose its usage.
// It serves unchanged (weightless); the bounded overlap with the row is
// the documented corruption-shaped trade (over-serve, never lose).
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
JSON.stringify({
type: 'session.shutdown',
timestamp: 'not-a-timestamp',
data: {
shutdownType: 'routine',
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 1, cost: 1 },
usage: { inputTokens: 500, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
},
},
},
}),
].join('\n') + '\n')
insertStoreRow(dbPath, 'sess-badts', 100, 0, 0, at(10))
const sessions = (await parseAllSessions(undefined, 'copilot')).flatMap(p => p.sessions)
.filter(s => s.sessionId === 'sess-badts')
const input = sessions.reduce((s, x) => s + x.totalInputTokens, 0)
expect(input).toBe(600) // row 100 + un-droppable rollup 500; never 100
expect(sessions.reduce((s, x) => s + x.apiCalls, 0)).toBe(1) // the row; the rollup stays weightless
// The rollup adopted a STABLE valid timestamp (its file's preceding
// valid stamp, else the session's earliest), so a RANGED query (the
// shape the daily backfill uses) serves the same 600 — a raw
// unparseable stamp would be invisible to callsInRange and quietly lose
// the 500 from every sealed day.
const base = new Date(at(0)).getTime()
const rangedInput = async (): Promise<number> =>
(await parseAllSessions(
{ start: new Date(base - 3600 * 1000), end: new Date(base + 3600 * 1000) }, 'copilot'))
.flatMap(p => p.sessions).filter(s => s.sessionId === 'sess-badts')
.reduce((s, x) => s + x.totalInputTokens, 0)
expect(await rangedInput()).toBe(600)
// The session resumes a day later: the fallback must NOT move with it —
// a day sealed with the rollup would otherwise lose it to the new day
// and the daily union would count it twice.
clearSessionCache()
insertStoreRow(dbPath, 'sess-badts', 50, 0, 0, at(86400))
expect(await rangedInput()).toBe(600)
})
it('anchors a timestamp-less shutdown after its rows, not at session start', async () => {
// Audit finding: the leg's stamp anchors its interval in the residual
// sweep. A shutdown event with no `timestamp` fell back to
// sessionStartTime — BEFORE every row — so the leg covered nothing and
// re-minted its whole usage beside the rows it duplicates (600/17,000
// served as 1,200/34,000). The fallback now prefers the last event seen,
// which is at the end of the leg where a shutdown actually happens.
const { dbPath, at } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
const dir = join(tmpHome, 'session-state', 'sess-nots')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-nots\ncwd: /home/user/testproj\n')
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
JSON.stringify({ type: 'assistant.message', timestamp: at(18), data: { messageId: 'msg-1', outputTokens: 25, toolRequests: [] } }),
// No `timestamp` on the shutdown; sessionStartTime precedes every row.
JSON.stringify({
type: 'session.shutdown',
data: {
shutdownType: 'routine',
sessionStartTime: at(0),
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 2, cost: 1 },
usage: { inputTokens: 20000, outputTokens: 25, cacheReadTokens: 17000, cacheWriteTokens: 2400, reasoningTokens: 0 },
},
},
},
}),
].join('\n') + '\n')
insertStoreRow(dbPath, 'sess-nots', 12000, 10000, 1500, at(12)) // input 500
insertStoreRow(dbPath, 'sess-nots', 8000, 7000, 900, at(15)) // input 100
const session = (await parseAllSessions(undefined, 'copilot')).flatMap(p => p.sessions)
.find(s => s.sessionId === 'sess-nots')!
expect(session.totalInputTokens).toBe(600)
expect(session.totalCacheReadTokens).toBe(17000)
expect(session.totalCacheWriteTokens).toBe(2400)
})
it('keeps a crash row\'s call weight when it sits outside the pairing window', async () => {
const { dbPath, at, writeSession } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
// The per-turn call at at(10) lost its own row; a crash-only row lands 5
// minutes later. A wide pairing window would pair the two and hide the
// crash request; the tight window leaves the row unpaired and counted.
await writeSession('sess-far', { output: 25 })
insertStoreRow(dbPath, 'sess-far', 100, 0, 0, at(310))
const session = (await parseAllSessions(undefined, 'copilot')).flatMap(p => p.sessions)
.find(s => s.sessionId === 'sess-far')!
expect(session.apiCalls).toBe(2)
expect(session.totalInputTokens).toBe(100)
expect(session.totalOutputTokens).toBe(25)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (y) Pairing is range-invariant: adjacent day queries stay additive
// ═══════════════════════════════════════════════════════════════════════════
// Round-6.5 (gpt-5.6-sol): weights recomputed from a range slice let a store
// row and its per-turn call each count as a call on opposite sides of a day
// boundary — two calls for one request across adjacent queries. The pairing
// is computed once over the full serve set and arrives as a key set, so a
// slice cannot change a row's verdict.
describe.skipIf(!isSqliteAvailable())('(y) range-invariant behavioral pairing', () => {
it('counts one call across two adjacent ranges that split row from per-turn call', async () => {
const { dbPath, at, writeSession } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
// writeSession puts the per-turn call at at(10); the row lands at at(60),
// both within the pairing window.
await writeSession('sess-split', { output: 25 })
insertStoreRow(dbPath, 'sess-split', 12000, 10000, 1500, at(60))
const base = new Date(at(0)).getTime()
const range = async (fromSec: number, toSec: number) => {
const projects = await parseAllSessions(
{ start: new Date(base + fromSec * 1000), end: new Date(base + toSec * 1000) }, 'copilot')
const sessions = projects.flatMap(p => p.sessions).filter(s => s.sessionId === 'sess-split')
return {
apiCalls: sessions.reduce((s, x) => s + x.apiCalls, 0),
input: sessions.reduce((s, x) => s + x.totalInputTokens, 0),
output: sessions.reduce((s, x) => s + x.totalOutputTokens, 0),
}
}
// Range A holds only the per-turn call; range B only the row.
const a = await range(0, 30)
expect(a).toEqual({ apiCalls: 1, input: 0, output: 25 })
const b = await range(31, 120)
expect(b).toEqual({ apiCalls: 0, input: 500, output: 0 })
// The full range pairs them the same way: still exactly one call.
const full = await range(0, 120)
expect(full).toEqual({ apiCalls: 1, input: 500, output: 25 })
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (z) The hydration verdict travels with its result
// ═══════════════════════════════════════════════════════════════════════════
describe.skipIf(!isSqliteAvailable())('(z) hydration verdict integrity', () => {
// Round-6.5 (gpt-5.6-sol): a memoized partial parse served after a later,
// complete parse inherited the later parse's `true`, letting the daily
// backfill seal history around the deferred data.
it.skipIf(process.getuid?.() === 0)('a memo hit restores the verdict its data was parsed under', async () => {
const { chmod } = await import('fs/promises')
const { dbPath, at, writeSession } = await setupCopilotStoreEnv()
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-memo', 12000, 10000, 1500, at(12))
await writeSession('sess-memo', { output: 25 })
await parseAllSessions(undefined, 'copilot')
expect(isSessionHydrationComplete()).toBe(true)
// The store grows and becomes unreadable: the re-parse defers (false)
// and that verdict is memoized with the result.
clearSessionCache()
insertStoreRow(dbPath, 'sess-memo', 8000, 7000, 900, at(20))
await chmod(dbPath, 0o000)
try {
await parseAllSessions(undefined, 'copilot')
expect(isSessionHydrationComplete()).toBe(false)
// An unrelated provider parses completely and flips the global to true…
await parseAllSessions(undefined, 'claude')
expect(isSessionHydrationComplete()).toBe(true)
// …but re-serving the deferred copilot result from the memo must
// restore ITS verdict, not inherit the later parse's.
await parseAllSessions(undefined, 'copilot')
expect(isSessionHydrationComplete()).toBe(false)
} finally {
await chmod(dbPath, 0o644)
}
})
// Round-6.5 (gpt-5.6-sol): a source whose FINGERPRINT cannot be read was
// skipped before any parser could raise the deferral shape, leaving the
// fence open while the cached (stale) rows served.
it.skipIf(process.getuid?.() === 0)('an unreadable fingerprint defers instead of silently skipping', async () => {
const { chmod } = await import('fs/promises')
const sessionStateDir = join(tmpHome, 'session-state')
await mkdir(sessionStateDir, { recursive: true })
const storeDir = join(tmpHome, 'store-dir')
await mkdir(storeDir, { recursive: true })
const dbPath = join(storeDir, 'session-store.db')
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', dbPath)
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()
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-fp', 12000, 10000, 1500, at(12))
const first = await parseAllSessions(undefined, 'copilot')
expect(first.flatMap(p => p.sessions).find(s => s.sessionId === 'sess-fp')!.totalInputTokens).toBe(500)
expect(isSessionHydrationComplete()).toBe(true)
// The store grows, then its parent dir loses traversal: discovery still
// emits the source (EACCES is not absence), the fingerprint read fails,
// and the pass must report incomplete hydration — the new row is missing.
clearSessionCache()
insertStoreRow(dbPath, 'sess-fp', 8000, 7000, 900, at(20))
await chmod(storeDir, 0o000)
try {
const second = await parseAllSessions(undefined, 'copilot')
expect(second.flatMap(p => p.sessions).find(s => s.sessionId === 'sess-fp')!.totalInputTokens).toBe(500)
expect(isSessionHydrationComplete()).toBe(false)
} finally {
await chmod(storeDir, 0o755)
}
clearSessionCache()
const third = await parseAllSessions(undefined, 'copilot')
expect(third.flatMap(p => p.sessions).find(s => s.sessionId === 'sess-fp')!.totalInputTokens).toBe(600)
expect(isSessionHydrationComplete()).toBe(true)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (j) Rollup-day reattribution: usage lands on the request days, not the day
// the CLI finally shut down
// ═══════════════════════════════════════════════════════════════════════════
// Observed in the wild: a session ran entirely on day N (per-request DB rows)
// but its session.shutdown rollup was stamped the NEXT morning when the CLI
// was closed. The rollup path put the whole session's input/cache on day N+1;
// with the store covering the session, the tokens must land on day N and the
// session must contribute NOTHING to day N+1 — while still counting exactly
// once in an unfiltered (lifetime) parse. This is the per-day attribution
// change the daily-cache v18 bump re-derives for.
describe.skipIf(!isSqliteAvailable())('(j) rollup-day reattribution to request days', () => {
it('counts a next-morning-shutdown session on its request day only', async () => {
const sessionStateDir = join(tmpHome, 'session-state')
await mkdir(sessionStateDir, { recursive: true })
const dbPath = join(tmpHome, 'session-store.db')
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', dbPath)
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'))
// "Day N" = 5 days ago; the shutdown lands ~19h later ("next morning").
const dayN = Date.now() - 5 * 24 * 60 * 60 * 1000
const at = (offsetHours: number): string => new Date(dayN + offsetHours * 3600 * 1000).toISOString()
const dir = join(sessionStateDir, 'sess-overnight')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-overnight\ncwd: /home/user/testproj\n')
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
JSON.stringify({ type: 'assistant.message', timestamp: at(1), data: { messageId: 'msg-1', outputTokens: 25, toolRequests: [] } }),
JSON.stringify({
type: 'session.shutdown',
timestamp: at(19),
data: {
shutdownType: 'routine',
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 2, cost: 1 },
usage: { inputTokens: 20000, outputTokens: 25, cacheReadTokens: 17000, cacheWriteTokens: 2400, reasoningTokens: 0 },
},
},
},
}),
].join('\n') + '\n')
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-overnight', 12000, 10000, 1500, at(1)) // input 500
insertStoreRow(dbPath, 'sess-overnight', 8000, 7000, 900, at(2)) // input 100
const inventory = (projects: Awaited<ReturnType<typeof parseAllSessions>>) => {
const sessions = projects.flatMap(p => p.sessions).filter(s => s.turns.some(t => t.assistantCalls.length > 0))
const calls = sessions.flatMap(s => s.turns).flatMap(t => t.assistantCalls)
return {
sessions: sessions.length,
input: calls.reduce((s, c) => s + c.usage.inputTokens, 0),
cacheRead: calls.reduce((s, c) => s + c.usage.cacheReadInputTokens, 0),
output: calls.reduce((s, c) => s + c.usage.outputTokens, 0),
}
}
// Lifetime: exactly one session, tokens counted once, from the store.
const lifetime = inventory(await parseAllSessions(undefined, 'copilot'))
expect(lifetime).toEqual({ sessions: 1, input: 600, cacheRead: 17000, output: 25 })
// A range covering only the shutdown stamp (rollup path would have put
// 600/17000 here): the session must contribute nothing at all.
const shutdownDay = inventory(await parseAllSessions(
{ start: new Date(dayN + 12 * 3600 * 1000), end: new Date(dayN + 36 * 3600 * 1000) }, 'copilot'))
expect(shutdownDay).toEqual({ sessions: 0, input: 0, cacheRead: 0, output: 0 })
// The request day carries everything.
const requestDay = inventory(await parseAllSessions(
{ start: new Date(dayN - 1 * 3600 * 1000), end: new Date(dayN + 12 * 3600 * 1000) }, 'copilot'))
expect(requestDay).toEqual({ sessions: 1, input: 600, cacheRead: 17000, output: 25 })
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (sc) month-sharded cache integration for the copilot serve set
// ═══════════════════════════════════════════════════════════════════════════
// The sharded session cache loads only the month shards a ranged query can
// report on — EXCEPT durable providers, which always load in full. Copilot's
// reconciliation depends on that exemption: pairing, residual subtraction and
// project unification run over the complete cached serve set. These tests pin
// the integration at observable seams (adversarial review killed a first,
// vacuous version of each — the scenarios below were rebuilt so that removing
// the guarded line makes each one fail).
describe.skipIf(!isSqliteAvailable())('(sc) month-sharded cache integration for copilot', () => {
function stubCopilotEnv(sessionStateDir: string, dbPath: string): void {
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', dbPath)
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 copilotCalls = (projects: Awaited<ReturnType<typeof parseAllSessions>>) =>
projects.flatMap(p => p.sessions.map(s => ({ project: p.project, session: s })))
.flatMap(({ project, session }) => session.turns.flatMap(t => t.assistantCalls.map(c => ({ project, call: c }))))
it('serves scope-skippable months through the durable full-load, with and without the persisted stamp', async () => {
const sessionStateDir = join(tmpHome, 'session-state-sc1')
await mkdir(sessionStateDir, { recursive: true })
const dbPath = join(tmpHome, 'session-store-sc1.db')
stubCopilotEnv(sessionStateDir, dbPath)
// Journal activity 75 days ago; a store row 5 days ago. 70 days apart is
// always ≥2 UTC month boundaries, which beats the loader's one-month
// scope slack — so a 20-day ranged query's scope can never include the
// journal's month, and only the durable full-load exemption keeps the
// journal (and its session-derived project label) in the serve set.
const tsA = new Date(Date.now() - 75 * 24 * 3600 * 1000)
const tsB = new Date(Date.now() - 5 * 24 * 3600 * 1000)
const iso = (d: Date, plusSec = 0): string => new Date(d.getTime() + plusSec * 1000).toISOString()
const dir = join(sessionStateDir, 'sess-sc1')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-sc1\ncwd: /home/user/journalproj\n')
await writeFile(join(dir, 'events.jsonl'), [
JSON.stringify({ type: 'session.model_change', timestamp: iso(tsA), data: { newModel: 'claude-sonnet-4-5' } }),
JSON.stringify({ type: 'assistant.message', timestamp: iso(tsA, 10), data: { messageId: 'm-a', outputTokens: 30, toolRequests: [] } }),
].join('\n') + '\n')
// A real 75-day-old journal has a 75-day-old mtime, which the ranged
// parse's own source filter skips — so under a scoped load, the CACHE is
// the only place its turns can come from. A fresh mtime would let a
// spurious re-parse mask a broken durable exemption.
const { utimes } = await import('fs/promises')
await utimes(join(dir, 'events.jsonl'), tsA, tsA)
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-sc1', 800, 200, 100, iso(tsA, 12), 0, '/home/user/storeproj') // month A, pairs with m-a
insertStoreRow(dbPath, 'sess-sc1', 400, 250, 50, iso(tsB), 0, '/home/user/storeproj') // month B, store-only
// Unranged parse: one session label everywhere — the journal-derived one.
const full = copilotCalls(await parseAllSessions(undefined, 'copilot'))
expect(full.length).toBeGreaterThan(0)
expect(new Set(full.map(c => c.project))).toEqual(new Set(['journalproj']))
// Ranged parse over the last 20 days: the journal's shard is outside the
// load scope. The durable exemption must still load it, so the in-range
// store row keeps the session's label instead of falling back to the
// store's own cwd.
clearSessionCache()
clearLoadCacheMemo()
const ranged = copilotCalls(await parseAllSessions(
{ start: new Date(Date.now() - 20 * 24 * 3600 * 1000), end: new Date() }, 'copilot'))
expect(ranged.length).toBeGreaterThan(0)
expect(new Set(ranged.map(c => c.project))).toEqual(new Set(['journalproj']))
expect(ranged.reduce((s, c) => s + c.call.usage.inputTokens, 0)).toBe(100) // 400 250 50
// The unstamped-section arm of the exemption (DURABLE_PROVIDER_NAMES by
// name, no envelope flag) is pinned at the loadCache seam in
// tests/session-cache.test.ts — the parse pipeline has too many
// self-healing layers to isolate that one predicate end-to-end.
})
it('persists an age-out deletion from a seeded, unchanged entry (the deletion itself must dirty the shard)', async () => {
const { stat } = await import('fs/promises')
const synthFile = join(tmpHome, 'synth-shard-age.txt')
await writeFile(synthFile, 'placeholder')
const st = await stat(synthFile)
const ts91dAgo = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000).toISOString()
// Seed the >90d entry on disk with a MATCHING fingerprint, so the parse
// performs no re-parse of the file: the age-out delete is the only cache
// mutation, and only its own dirty mark can persist the deletion.
// complete: true, so the parse's own completion flip cannot rewrite the
// envelope — the age-out delete's dirty mark must be the ONLY thing that
// publishes the deletion.
await writeCacheOnDisk({
version: CACHE_VERSION,
complete: true,
providers: {
'test-synthetic': {
envFingerprint: computeEnvFingerprint('test-synthetic'),
durable: true,
files: {
[synthFile]: {
fingerprint: { dev: st.dev, ino: st.ino, mtimeMs: st.mtimeMs, sizeBytes: st.size },
mcpInventory: [],
turns: [{
timestamp: ts91dAgo, sessionId: 'synth-shard-old', userMessage: 'old',
calls: [{
provider: 'test-synthetic', model: 'gpt-4o',
usage: { inputTokens: 10, outputTokens: 8, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, cacheCreationOneHourTokens: 0 },
costUSD: 0.002, speed: 'standard', timestamp: ts91dAgo,
tools: [], bashCommands: [], skills: [], subagentTypes: [],
deduplicationKey: 'synth-shard-age-91d',
}],
}],
},
},
},
},
})
_synthDurable = true
// Orphaned: discovery no longer returns the seeded path, which under the
// orphan-only age-out (#992) is what makes the entry eligible at all.
_synthSources = []
// A fresh-timestamped decoy: if the parse wrongly re-reads the file it
// no longer discovers, this call would serve and break the zero assertion.
_synthYields = [{
provider: 'test-synthetic', model: 'gpt-4o',
inputTokens: 10, outputTokens: 99,
cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
costUSD: 0.002, tools: [], bashCommands: [],
timestamp: new Date().toISOString(),
speed: 'standard',
deduplicationKey: 'synth-shard-age-decoy',
userMessage: 'decoy', sessionId: 'synth-shard-decoy',
}]
const proj1 = await parseAllSessions(undefined, 'test-synthetic')
expect(totalOutput(proj1)).toBe(0)
const disk1 = await readCacheOnDisk()
expect(disk1.providers['test-synthetic']?.files?.[synthFile]).toBeUndefined()
clearSessionCache()
_synthSources = []
const proj2 = await parseAllSessions(undefined, 'test-synthetic')
expect(totalOutput(proj2)).toBe(0)
const disk2 = await readCacheOnDisk()
expect(disk2.providers['test-synthetic']?.files?.[synthFile]).toBeUndefined()
})
it('re-buckets a retained >90d store on an older append without a duplicate shard copy', async () => {
const sessionStateDir = join(tmpHome, 'session-state-sc3')
await mkdir(sessionStateDir, { recursive: true })
const dbPath = join(tmpHome, 'session-store-sc3.db')
stubCopilotEnv(sessionStateDir, dbPath)
const at = (daysAgo: number): string => new Date(Date.now() - daysAgo * 24 * 3600 * 1000).toISOString()
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-ret', 100, 0, 0, at(95))
insertStoreRow(dbPath, 'sess-ret', 100, 0, 0, at(95))
const sumInput = (projects: Awaited<ReturnType<typeof parseAllSessions>>) =>
projects.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
.reduce((s, c) => s + c.usage.inputTokens, 0)
// retainWhilePresent: >90d rows serve and the entry survives its save.
expect(sumInput(await parseAllSessions(undefined, 'copilot'))).toBe(200)
// Appending an OLDER row moves the file's oldest-turn month, which
// re-buckets the shard. The path must live in exactly ONE shard file —
// a stale copy in the old bucket would resurrect dropped data on a
// partial load. cacheDirSnapshot exposes raw shard bytes; the merged
// loadCache view (which collapses duplicates) cannot see this.
insertStoreRow(dbPath, 'sess-ret', 150, 0, 0, at(130))
clearSessionCache()
clearLoadCacheMemo()
expect(sumInput(await parseAllSessions(undefined, 'copilot'))).toBe(350)
const snapshot = await cacheDirSnapshot()
const shardSections = snapshot.split('\n').filter(s => s.length > 0 && !s.startsWith('envelope.json:'))
const holding = shardSections.filter(s => s.includes(dbPath))
expect(holding).toHaveLength(1)
const disk = await readCacheOnDisk()
expect(disk.providers['copilot']!.files[dbPath]!.turns.flatMap(t => t.calls)).toHaveLength(3)
})
it.skipIf(process.getuid?.() === 0)('re-parses an incomplete memo instead of extending it to the validated-clean cap', async () => {
const sessionStateDir = join(tmpHome, 'session-state-sc4')
await mkdir(sessionStateDir, { recursive: true })
const dbPath = join(tmpHome, 'session-store-sc4.db')
stubCopilotEnv(sessionStateDir, dbPath)
const { chmod } = await import('fs/promises')
const recent = new Date(Date.now() - 3600 * 1000).toISOString()
createStoreDb(dbPath)
insertStoreRow(dbPath, 'sess-vc', 100, 0, 0, recent)
// Healthy parse, then the store grows and becomes unreadable: the next
// parse defers and memoizes an INCOMPLETE result.
await parseAllSessions(undefined, 'copilot')
insertStoreRow(dbPath, 'sess-vc', 100, 0, 0, recent)
await chmod(dbPath, 0o000)
clearSessionCache()
await parseAllSessions(undefined, 'copilot')
expect(isSessionHydrationComplete()).toBe(false)
// The lock clears with no further filesystem event. Past the 180s TTL a
// 'clean' validator would extend a COMPLETE memo to five minutes — but an
// incomplete one promised a retry, so the same call must RE-PARSE, pick
// up the deferred row, and report complete.
await chmod(dbPath, 0o644)
setParseReuseValidator(() => 'clean')
vi.useFakeTimers({ now: Date.now() })
vi.setSystemTime(Date.now() + 4 * 60 * 1000)
try {
const retried = await parseAllSessions(undefined, 'copilot')
expect(isSessionHydrationComplete()).toBe(true)
const input = retried.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
.reduce((s, c) => s + c.usage.inputTokens, 0)
expect(input).toBe(200)
} finally {
vi.useRealTimers()
setParseReuseValidator(null)
}
})
})