codeburn/packages/cli/tests/session-cache.test.ts
ozymandiashh 2223f8523e fix(core): reconcile #1074 migrations without data loss
Preserve and re-key durable Copilot history across fingerprint and privacy-key changes, retain local sent-ledger aliases for all affected providers, and retry transient durable reads without clearing cached turns.

Restore the published observation 0.2.0 contract byte-for-byte and move the model identifier hardening to observation 0.3.0.
2026-08-21 22:25:53 +03:00

924 lines
37 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createHash } from 'crypto'
import { mkdtemp, readFile, rm, writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { basename, join } from 'path'
import {
CACHE_VERSION,
PROVIDER_ENV_VARS,
type CachedCall,
type CachedFile,
type CachedTurn,
type FileFingerprint,
type SessionCache,
cleanupOrphanedTempFiles,
computeEnvFingerprint,
DURABLE_PROVIDER_NAMES,
KEY_DERIVED_PROVIDERS,
PROVIDER_PARSE_VERSIONS,
emptyCache,
fingerprintFile,
loadCache,
mergeCallByDedupKey,
reconcileFile,
saveCache,
sessionCachePath,
} from '../src/session-cache.js'
// Version-suffixed filename (e.g. session-cache.v5.json) the cache now writes to.
const CACHE_FILE = () => basename(sessionCachePath())
const TMP_DIR = join(tmpdir(), `codeburn-scache-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
beforeEach(() => {
process.env['CODEBURN_CACHE_DIR'] = TMP_DIR
})
afterEach(async () => {
if (existsSync(TMP_DIR)) await rm(TMP_DIR, { recursive: true })
})
function makeCall(overrides: Partial<CachedCall> = {}): CachedCall {
return {
provider: 'claude',
model: 'claude-sonnet-4-20250514',
usage: {
inputTokens: 1000,
outputTokens: 500,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
cacheCreationOneHourTokens: 0,
},
speed: 'standard',
timestamp: '2026-05-15T10:00:00Z',
tools: ['Read', 'Edit'],
bashCommands: [],
skills: [],
deduplicationKey: 'msg-abc123',
...overrides,
}
}
function makeTurn(overrides: Partial<CachedTurn> = {}): CachedTurn {
return {
timestamp: '2026-05-15T10:00:00Z',
sessionId: 'sess-1',
userMessage: 'fix the bug',
calls: [makeCall()],
...overrides,
}
}
function makeCachedFile(overrides: Partial<CachedFile> = {}): CachedFile {
return {
fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 },
mcpInventory: [],
turns: [makeTurn()],
...overrides,
}
}
// ── emptyCache ─────────────────────────────────────────────────────────
describe('emptyCache', () => {
it('returns a valid empty cache', () => {
const cache = emptyCache()
expect(cache.version).toBe(CACHE_VERSION)
expect(cache.providers).toEqual({})
})
})
// ── loadCache / saveCache ──────────────────────────────────────────────
describe('loadCache / saveCache', () => {
it('returns empty cache when no file exists', async () => {
const cache = await loadCache()
expect(cache.version).toBe(CACHE_VERSION)
expect(cache.providers).toEqual({})
})
it('round-trips a cache through save and load', async () => {
const cache: SessionCache = {
version: CACHE_VERSION,
providers: {
claude: {
envFingerprint: 'abc123',
files: {
'/path/to/session.jsonl': makeCachedFile(),
},
},
},
}
await saveCache(cache)
const loaded = await loadCache()
expect(loaded).toEqual(cache)
})
it('persists a failed-parse marker across save/load (negative-result cache)', async () => {
const cache: SessionCache = {
version: CACHE_VERSION,
providers: {
pi: {
envFingerprint: 'abc123',
files: {
'/path/to/bad.jsonl': makeCachedFile({ turns: [], failed: true }),
},
},
},
}
await saveCache(cache)
const loaded = await loadCache()
// The `failed` flag and empty turns survive validation + load, so the file
// stays skipped on the next run instead of being re-read and re-thrown.
expect(loaded.providers['pi']?.files['/path/to/bad.jsonl']?.failed).toBe(true)
expect(loaded.providers['pi']?.files['/path/to/bad.jsonl']?.turns).toEqual([])
})
it('preserves the estimated-cost flag through save/load; a measured call stays unflagged', async () => {
const cache: SessionCache = {
version: CACHE_VERSION,
providers: {
warp: {
envFingerprint: 'abc123',
files: {
'/path/to/warp.sqlite': makeCachedFile({
turns: [makeTurn({ calls: [
makeCall({ deduplicationKey: 'est', costUSD: 0.5, isEstimated: true }),
makeCall({ deduplicationKey: 'measured', costUSD: 0.5 }),
] })],
}),
},
},
},
}
await saveCache(cache)
const loaded = await loadCache()
const calls = loaded.providers['warp']?.files['/path/to/warp.sqlite']?.turns[0]?.calls
expect(calls?.[0]?.isEstimated).toBe(true)
// A call with no flag round-trips as undefined, not silently coerced to true.
expect(calls?.[1]?.isEstimated).toBeUndefined()
})
it('returns empty cache on version mismatch', async () => {
const bad: SessionCache = { version: 999, providers: { claude: { envFingerprint: 'x', files: {} } } }
await mkdir(TMP_DIR, { recursive: true })
await writeFile(join(TMP_DIR, 'session-cache.json'), JSON.stringify(bad))
const loaded = await loadCache()
expect(loaded.version).toBe(CACHE_VERSION)
expect(loaded.providers).toEqual({})
})
it('returns empty cache on corrupt JSON', async () => {
await mkdir(TMP_DIR, { recursive: true })
await writeFile(join(TMP_DIR, 'session-cache.json'), '{broken')
const loaded = await loadCache()
expect(loaded.version).toBe(CACHE_VERSION)
expect(loaded.providers).toEqual({})
})
it('atomic write does not leave partial file on error', async () => {
await saveCache(emptyCache())
const raw = await readFile(sessionCachePath(), 'utf-8')
expect(JSON.parse(raw)).toEqual(emptyCache())
})
})
// ── versioned filename + legacy adoption ───────────────────────────────
describe('versioned cache file + legacy adoption', () => {
function validCache(): SessionCache {
return {
version: CACHE_VERSION,
providers: { claude: { envFingerprint: 'abc123', files: { '/path/to/session.jsonl': makeCachedFile() } } },
}
}
it('writes and reads the version-suffixed file, never the legacy name', async () => {
expect(basename(sessionCachePath())).toBe(`session-cache.v${CACHE_VERSION}.json`)
await saveCache(validCache())
expect(existsSync(sessionCachePath())).toBe(true)
expect(existsSync(join(TMP_DIR, 'session-cache.json'))).toBe(false)
expect(await loadCache()).toEqual(validCache())
})
it('adopts a matching-version legacy file once, without deleting or rewriting it', async () => {
await mkdir(TMP_DIR, { recursive: true })
const legacy = join(TMP_DIR, 'session-cache.json')
await writeFile(legacy, JSON.stringify(validCache()))
// Versioned file absent → adopt-copy from legacy on first load.
expect(await loadCache()).toEqual(validCache())
expect(existsSync(sessionCachePath())).toBe(true)
// Legacy left intact (not deleted, not rewritten).
expect(existsSync(legacy)).toBe(true)
expect(JSON.parse(await readFile(legacy, 'utf-8'))).toEqual(validCache())
// Adoption is one-time: a later legacy edit is ignored once the versioned
// file exists.
const mutated: SessionCache = { version: CACHE_VERSION, providers: { codex: { envFingerprint: 'zzz', files: {} } } }
await writeFile(legacy, JSON.stringify(mutated))
expect(await loadCache()).toEqual(validCache())
})
it('ignores a different-version legacy file and never touches it', async () => {
await mkdir(TMP_DIR, { recursive: true })
const legacy = join(TMP_DIR, 'session-cache.json')
const stale = { version: 999, providers: { claude: { envFingerprint: 'x', files: {} } } }
await writeFile(legacy, JSON.stringify(stale))
expect((await loadCache()).providers).toEqual({})
// No versioned file adopted; legacy left byte-intact.
expect(existsSync(sessionCachePath())).toBe(false)
expect(JSON.parse(await readFile(legacy, 'utf-8'))).toEqual(stale)
})
it('saveCache never creates or overwrites a pre-existing legacy file', async () => {
await mkdir(TMP_DIR, { recursive: true })
const legacy = join(TMP_DIR, 'session-cache.json')
const legacyContent = JSON.stringify({ version: CACHE_VERSION, providers: {} })
await writeFile(legacy, legacyContent)
await saveCache(validCache())
// The versioned file holds the new data; the legacy file is byte-untouched.
expect(await readFile(legacy, 'utf-8')).toBe(legacyContent)
expect(JSON.parse(await readFile(sessionCachePath(), 'utf-8'))).toEqual(validCache())
})
})
// ── computeEnvFingerprint ──────────────────────────────────────────────
describe('computeEnvFingerprint', () => {
it('returns stable hash for same env', () => {
const a = computeEnvFingerprint('claude')
const b = computeEnvFingerprint('claude')
expect(a).toBe(b)
expect(a).toHaveLength(16)
})
it('changes when env var changes', () => {
const before = computeEnvFingerprint('claude')
process.env['CLAUDE_CONFIG_DIR'] = '/tmp/different'
const after = computeEnvFingerprint('claude')
expect(before).not.toBe(after)
})
it('returns stable hash for unknown provider (no env vars)', () => {
const a = computeEnvFingerprint('unknown-provider')
const b = computeEnvFingerprint('unknown-provider')
expect(a).toBe(b)
})
it('includes parser versions in provider fingerprints', () => {
expect(computeEnvFingerprint('claude')).not.toBe(computeEnvFingerprint('unknown-provider'))
expect(computeEnvFingerprint('copilot')).not.toBe(computeEnvFingerprint('unknown-provider'))
expect(computeEnvFingerprint('kiro')).not.toBe(computeEnvFingerprint('unknown-provider'))
expect(computeEnvFingerprint('warp')).not.toBe(computeEnvFingerprint('unknown-provider'))
})
// opencode/kilo-code session-model-v1 (#930): bumped PROVIDER_PARSE_VERSIONS
// so already-cached files reparse once and pick up the fix. Reproduces the
// pre-bump fingerprint and asserts it now differs, i.e. a cached file keyed
// under the old fingerprint is treated as stale and reparsed.
const preBumpFingerprint = (provider: string, oldParserVersion?: string): string => {
const parts = (PROVIDER_ENV_VARS[provider] ?? []).map(v => `${v}=${process.env[v] ?? ''}`)
if (oldParserVersion) parts.push(`parser=${oldParserVersion}`)
return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 16)
}
it('bumped opencode/kilo-code parser versions invalidate a pre-bump cached fingerprint', () => {
expect(computeEnvFingerprint('opencode')).not.toBe(preBumpFingerprint('opencode'))
expect(computeEnvFingerprint('kilo-code')).not.toBe(preBumpFingerprint('kilo-code', 'worktree-project-grouping-v1'))
})
it('every dedup-key-hygiene provider carries a parse version so cached old-shape keys re-derive', () => {
// The six sourceRef providers changed their dedup key shape (raw source
// path -> keyed fingerprint) and copilot's JetBrains digest changed value
// (sha256 -> HMAC). Without an entry here the env fingerprint would not
// change, a warm session cache would keep serving the old keys, and those
// keys seed the dedup sets — re-ingesting the same records under the new
// shape. The comparison baseline is a provider with no entry and no env
// vars, whose fingerprint omits the `parser=` component entirely.
for (const provider of ['codebuff', 'zerostack', 'pi', 'omp', 'grok', 'lingtai-tui', 'copilot']) {
expect(computeEnvFingerprint(provider), provider).not.toBe(computeEnvFingerprint('unknown-provider'))
}
})
it('reparses #1074 source-key caches once so local sent-ledger aliases land', () => {
const interim: Record<string, string> = {
codebuff: 'source-ref-fingerprint-v1',
zerostack: 'source-ref-fingerprint-v1',
pi: 'source-ref-fingerprint-v1',
omp: 'source-ref-fingerprint-v1',
grok: 'estimated-cost-v1-source-ref-fingerprint-v1',
'lingtai-tui': 'token-ledger-registry-activity-v3-source-ref-fingerprint-v1',
}
for (const [provider, parseVersion] of Object.entries(interim)) {
expect(PROVIDER_PARSE_VERSIONS[provider], provider).not.toBe(parseVersion)
expect(PROVIDER_PARSE_VERSIONS[provider], provider).toContain('ledger-alias-v1')
}
})
})
// ── computeEnvFingerprint: privacy-key binding ─────────────────────────
// Seven providers derive their dedup keys from the per-install privacy key: the
// six sourceRef ones, plus copilot (its JetBrains per-turn digest is an HMAC
// under that key). A parse version cannot see the key change — a lost, rotated,
// or ephemeral key produces different public keys. computeEnvFingerprint folds
// a digest of the key in for exactly KEY_DERIVED_PROVIDERS so ordinary caches
// re-parse and Copilot's durable cache enters its explicit re-key migration.
describe('computeEnvFingerprint — privacy-key binding', () => {
async function fingerprintUnderKey(key: string, providers: string[]): Promise<string[]> {
const home = await mkdtemp(join(tmpdir(), 'codeburn-envfp-'))
await mkdir(join(home, '.config', 'codeburn'), { recursive: true })
await writeFile(join(home, '.config', 'codeburn', 'privacy-key'), key + '\n')
process.env.HOME = home
// Fresh module registry so privacy-key.ts re-reads the file instead of
// serving its memoized key — this is what a new process would do.
vi.resetModules()
const mod = await import('../src/session-cache.js')
const out = providers.map(p => mod.computeEnvFingerprint(p))
await rm(home, { recursive: true, force: true })
return out
}
const KEY_A = 'a1'.repeat(32)
const KEY_B = 'b2'.repeat(32)
it('rotating the key moves every key-derived provider and no other', async () => {
// Both halves matter. Without the "moves" half a fingerprint that ignored
// the key would pass; without the "does not move" half a fingerprint that
// folded the key in for EVERY provider would pass, churning caches that owe
// the key nothing.
const KEY_DERIVED = ['codebuff', 'zerostack', 'pi', 'omp', 'grok', 'lingtai-tui', 'copilot']
const UNAFFECTED = ['claude', 'codex', 'cursor', 'warp', 'unknown-provider']
const providers = [...KEY_DERIVED, ...UNAFFECTED]
const underA = await fingerprintUnderKey(KEY_A, providers)
const underB = await fingerprintUnderKey(KEY_B, providers)
KEY_DERIVED.forEach((name, i) => {
expect(underB[i], `${name} must re-parse when the privacy key rotates`).not.toBe(underA[i])
})
UNAFFECTED.forEach((name, i) => {
const at = KEY_DERIVED.length + i
expect(underB[at], `${name} must NOT churn when the privacy key rotates`).toBe(underA[at])
})
})
it('copilot moves on rotation even though its parse version says nothing about source refs', async () => {
// The regression this pins: the fold set used to be sniffed out of the
// parse-version string (`includes('source-ref-fingerprint-v1')`). Copilot's
// public keys are just as key-derived — an HMAC over the stable local
// JetBrains identity — but their parse version reads `…-dedup-key-hmac-v2`.
// The explicit set ensures a rotation is detected; the durable migration
// then re-keys cached-only records and reconciles DB-present records without
// either retaining stale public keys or appending a second copy.
expect(PROVIDER_PARSE_VERSIONS['copilot']).not.toContain('source-ref-fingerprint-v1')
expect(DURABLE_PROVIDER_NAMES.has('copilot')).toBe(true)
expect(KEY_DERIVED_PROVIDERS.has('copilot')).toBe(true)
const [underA] = await fingerprintUnderKey(KEY_A, ['copilot'])
const [underB] = await fingerprintUnderKey(KEY_B, ['copilot'])
expect(underB).not.toBe(underA)
})
it('is stable when the key is unchanged', async () => {
const first = await fingerprintUnderKey(KEY_A, ['codebuff'])
const again = await fingerprintUnderKey(KEY_A, ['codebuff'])
expect(again[0]).toBe(first[0])
})
})
// ── provider env overrides invalidate the fingerprint (#920) ─────────────
describe('provider env overrides invalidate the fingerprint (#920)', () => {
// Nine providers honored an env var that relocates where discovery looks
// without the var being declared in PROVIDER_ENV_VARS, so
// computeEnvFingerprint did not hash it and the cache section survived the
// change: sessions parsed from the old root kept being reported and the new
// root was never read. Each pair below must change the fingerprint when the
// var is set. codex/CODEX_HOME is the control — it already worked and must
// keep working.
const CASES: Array<[provider: string, varName: string]> = [
['kiro', 'KIRO_HOME'],
['grok', 'GROK_HOME'],
['kimi', 'KIMI_SHARE_DIR'],
['mux', 'MUX_ROOT'],
['mistral-vibe', 'VIBE_HOME'],
['zerostack', 'ZS_DATA_DIR'],
['codebuff', 'CODEBUFF_DATA_DIR'],
['goose', 'GOOSE_PATH_ROOT'],
['crush', 'CRUSH_GLOBAL_DATA'],
['codex', 'CODEX_HOME'],
]
const VARS = CASES.map(([, varName]) => varName)
// Save and restore every var we touch (beforeEach/afterEach), so a leaked
// env var never breaks unrelated tests in the same worker — and an ambient
// value never makes the "unset" case a lie.
const saved = new Map<string, string | undefined>()
beforeEach(() => {
for (const varName of VARS) {
saved.set(varName, process.env[varName])
delete process.env[varName]
}
})
afterEach(() => {
for (const varName of VARS) {
const original = saved.get(varName)
if (original === undefined) delete process.env[varName]
else process.env[varName] = original
}
})
for (const [provider, varName] of CASES) {
it(`changes the ${provider} fingerprint when ${varName} is set`, () => {
const unset = computeEnvFingerprint(provider)
process.env[varName] = '/tmp/codeburn-920-override'
const set = computeEnvFingerprint(provider)
expect(set).not.toBe(unset)
// Round trip: restoring the variable to its original state restores the
// original fingerprint, so the hash is a pure function of the
// environment.
delete process.env[varName]
expect(computeEnvFingerprint(provider)).toBe(unset)
})
}
it('changes the vercel-gateway fingerprint when AI_GATEWAY_API_KEY is set', () => {
const prev = process.env['AI_GATEWAY_API_KEY']
try {
const unset = computeEnvFingerprint('vercel-gateway')
process.env['AI_GATEWAY_API_KEY'] = 'sk-liv...-abc'
const set = computeEnvFingerprint('vercel-gateway')
expect(set).not.toBe(unset)
delete process.env['AI_GATEWAY_API_KEY']
expect(computeEnvFingerprint('vercel-gateway')).toBe(unset)
} finally {
if (prev === undefined) delete process.env['AI_GATEWAY_API_KEY']
else process.env['AI_GATEWAY_API_KEY'] = prev
}
})
// Copilot is deliberately NOT declared in PROVIDER_ENV_VARS (Ruling 1 of
// lane 04). The durable migration now makes parse-version/key changes safe,
// but a discovery-root or account switch has different semantics: blindly
// carrying the old durable section would combine two source namespaces.
// Keep these overrides out of the generic fingerprint until Copilot has an
// explicit namespace migration. The assertions pin that intentional deferral.
describe('copilot is deliberately undeclared in PROVIDER_ENV_VARS', () => {
it('has no PROVIDER_ENV_VARS entry at all', () => {
expect(PROVIDER_ENV_VARS['copilot']).toBeUndefined()
})
// The nine reads copilot.ts performs whose declaration is deferred (each
// is allowlisted in tests/provider-env-declarations.test.ts): setting any
// of them must leave the Copilot fingerprint untouched.
const DEFERRED_COPILOT_VARS = [
'CODEBURN_COPILOT_SESSION_STATE_DIR',
'CODEBURN_COPILOT_OTEL_DB',
'CODEBURN_COPILOT_JETBRAINS_DIR',
'CODEBURN_COPILOT_WS_STORAGE_DIR',
'CODEBURN_COPILOT_GLOBAL_STORAGE_DIR',
'CODEBURN_COPILOT_DISABLE_OTEL',
'APPDATA',
'LOCALAPPDATA',
'XDG_CONFIG_HOME',
]
for (const varName of DEFERRED_COPILOT_VARS) {
it(`does not move the copilot fingerprint when ${varName} is set (deliberately undeclared)`, () => {
const prev = process.env[varName]
try {
const before = computeEnvFingerprint('copilot')
process.env[varName] = `/tmp/codeburn-copilot-920/${varName}`
expect(computeEnvFingerprint('copilot')).toBe(before)
delete process.env[varName]
expect(computeEnvFingerprint('copilot')).toBe(before)
} finally {
if (prev === undefined) delete process.env[varName]
else process.env[varName] = prev
}
})
}
})
})
// ── fingerprintFile ────────────────────────────────────────────────────
describe('fingerprintFile', () => {
it('returns fingerprint for existing file', async () => {
await mkdir(TMP_DIR, { recursive: true })
const filePath = join(TMP_DIR, 'test.jsonl')
await writeFile(filePath, 'line1\nline2\n')
const fp = await fingerprintFile(filePath)
expect(fp).not.toBeNull()
expect(fp!.sizeBytes).toBe(12)
expect(fp!.dev).toBeGreaterThan(0)
expect(fp!.ino).toBeGreaterThan(0)
expect(fp!.mtimeMs).toBeGreaterThan(0)
})
it('returns null for non-existent file', async () => {
const fp = await fingerprintFile('/no/such/file')
expect(fp).toBeNull()
})
it('resolves compound path with # separator (Cursor workspace)', async () => {
await mkdir(TMP_DIR, { recursive: true })
const filePath = join(TMP_DIR, 'state.vscdb')
await writeFile(filePath, 'cursor-data')
const fp = await fingerprintFile(`${filePath}#cursor-ws=__orphan__`)
expect(fp).not.toBeNull()
expect(fp!.sizeBytes).toBe(11)
})
it('resolves compound path with : separator (OpenCode session)', async () => {
await mkdir(TMP_DIR, { recursive: true })
const filePath = join(TMP_DIR, 'opencode.db')
await writeFile(filePath, 'opencode-data')
const fp = await fingerprintFile(`${filePath}:ses_abc123`)
expect(fp).not.toBeNull()
expect(fp!.sizeBytes).toBe(13)
})
it('returns null when base file does not exist for compound path', async () => {
const fp = await fingerprintFile('/no/such/file.db#cursor-ws=workspace')
expect(fp).toBeNull()
})
it('prefers # separator over : when both present', async () => {
await mkdir(TMP_DIR, { recursive: true })
const filePath = join(TMP_DIR, 'state.vscdb')
await writeFile(filePath, 'both-seps')
// Path has both # and : — should strip at # first and find the base file
const fp = await fingerprintFile(`${filePath}#cursor-ws=ws:extra-colon`)
expect(fp).not.toBeNull()
expect(fp!.sizeBytes).toBe(9)
})
})
// ── reconcileFile ──────────────────────────────────────────────────────
describe('reconcileFile', () => {
it('returns "new" when no cached entry', () => {
const fp: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }
expect(reconcileFile(fp, undefined)).toEqual({ action: 'new' })
})
it('returns "unchanged" when all fields match', () => {
const fp: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }
const cached = makeCachedFile({ fingerprint: { ...fp } })
expect(reconcileFile(fp, cached)).toEqual({ action: 'unchanged' })
})
it('returns "appended" when ino same, size grew, and has lastCompleteLineOffset', () => {
const cached = makeCachedFile({
fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 },
lastCompleteLineOffset: 4500,
})
const current: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 2000, sizeBytes: 8000 }
const result = reconcileFile(current, cached)
expect(result).toEqual({ action: 'appended', readFromOffset: 4500 })
})
it('returns "modified" when ino changed', () => {
const cached = makeCachedFile({
fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 },
})
const current: FileFingerprint = { dev: 1, ino: 200, mtimeMs: 2000, sizeBytes: 5000 }
expect(reconcileFile(current, cached)).toEqual({ action: 'modified' })
})
it('a failed marker at the same fingerprint stays "unchanged" (not re-parsed)', () => {
const fp: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }
const marker = makeCachedFile({ fingerprint: { ...fp }, turns: [], failed: true })
expect(reconcileFile(fp, marker)).toEqual({ action: 'unchanged' })
})
it('a failed marker is re-parsed once the file changes', () => {
const marker = makeCachedFile({
fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 },
turns: [],
failed: true,
})
const changed: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 2000, sizeBytes: 6000 }
expect(reconcileFile(changed, marker)).toEqual({ action: 'modified' })
})
it('returns "modified" when the cached offset is stranded beyond the current EOF', () => {
// A truncate-then-regrow can leave the resume offset past live bytes; resuming
// there would drop the appended tail, so it must fall back to a full re-parse.
const cached = makeCachedFile({
fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 },
lastCompleteLineOffset: 9_000_000,
})
const current: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 2000, sizeBytes: 8000 }
expect(reconcileFile(current, cached)).toEqual({ action: 'modified' })
})
it('returns "modified" when size shrank', () => {
const cached = makeCachedFile({
fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 },
lastCompleteLineOffset: 4500,
})
const current: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 2000, sizeBytes: 3000 }
expect(reconcileFile(current, cached)).toEqual({ action: 'modified' })
})
it('returns "modified" when same size but different mtime', () => {
const cached = makeCachedFile({
fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 },
})
const current: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 2000, sizeBytes: 5000 }
expect(reconcileFile(current, cached)).toEqual({ action: 'modified' })
})
it('returns "modified" for DB provider (no lastCompleteLineOffset) on any fingerprint change', () => {
const cached = makeCachedFile({
fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 },
})
const current: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 2000, sizeBytes: 8000 }
expect(reconcileFile(current, cached)).toEqual({ action: 'modified' })
})
it('returns "modified" when dev changed even if ino same and size grew', () => {
const cached = makeCachedFile({
fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 },
lastCompleteLineOffset: 4500,
})
const current: FileFingerprint = { dev: 2, ino: 100, mtimeMs: 2000, sizeBytes: 8000 }
expect(reconcileFile(current, cached)).toEqual({ action: 'modified' })
})
})
// ── mergeCallByDedupKey ────────────────────────────────────────────────
describe('mergeCallByDedupKey', () => {
it('keeps earlier timestamp', () => {
const existing = makeCall({ timestamp: '2026-05-15T10:00:00Z' })
const incoming = makeCall({ timestamp: '2026-05-15T10:01:00Z' })
const merged = mergeCallByDedupKey(existing, incoming)
expect(merged.timestamp).toBe('2026-05-15T10:00:00Z')
})
it('takes incoming usage (latest wins)', () => {
const existing = makeCall({ usage: { ...makeCall().usage, outputTokens: 100 } })
const incoming = makeCall({ usage: { ...makeCall().usage, outputTokens: 999 } })
const merged = mergeCallByDedupKey(existing, incoming)
expect(merged.usage.outputTokens).toBe(999)
})
it('takes incoming tools (latest wins)', () => {
const existing = makeCall({ tools: ['Read'] })
const incoming = makeCall({ tools: ['Read', 'Edit', 'Bash'] })
const merged = mergeCallByDedupKey(existing, incoming)
expect(merged.tools).toEqual(['Read', 'Edit', 'Bash'])
})
})
// ── deep validation (loadCache) ────────────────────────────────────────
describe('loadCache validation', () => {
async function writeRawCache(data: unknown): Promise<void> {
await mkdir(TMP_DIR, { recursive: true })
await writeFile(join(TMP_DIR, 'session-cache.json'), JSON.stringify(data))
}
it('rejects providers as array', async () => {
await writeRawCache({ version: CACHE_VERSION, providers: [] })
expect((await loadCache()).providers).toEqual({})
})
it('rejects provider section missing envFingerprint', async () => {
await writeRawCache({ version: CACHE_VERSION, providers: { claude: { files: {} } } })
expect((await loadCache()).providers).toEqual({})
})
it('rejects provider section with files as array', async () => {
await writeRawCache({ version: CACHE_VERSION, providers: { claude: { envFingerprint: 'x', files: [] } } })
expect((await loadCache()).providers).toEqual({})
})
it('rejects file with invalid fingerprint (missing ino)', async () => {
await writeRawCache({
version: CACHE_VERSION,
providers: { claude: { envFingerprint: 'x', files: {
'/f': { fingerprint: { dev: 1, mtimeMs: 1, sizeBytes: 1 }, mcpInventory: [], turns: [] },
} } },
})
expect((await loadCache()).providers).toEqual({})
})
it('rejects file with non-numeric fingerprint field', async () => {
await writeRawCache({
version: CACHE_VERSION,
providers: { claude: { envFingerprint: 'x', files: {
'/f': { fingerprint: { dev: 1, ino: 'bad', mtimeMs: 1, sizeBytes: 1 }, mcpInventory: [], turns: [] },
} } },
})
expect((await loadCache()).providers).toEqual({})
})
it('rejects turn with missing sessionId', async () => {
const badTurn = { timestamp: 'x', userMessage: 'y', calls: [] }
await writeRawCache({
version: CACHE_VERSION,
providers: { claude: { envFingerprint: 'x', files: {
'/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [badTurn] },
} } },
})
expect((await loadCache()).providers).toEqual({})
})
it('rejects call with missing usage object', async () => {
const badCall = { provider: 'claude', model: 'm', deduplicationKey: 'k', timestamp: 't', tools: [], bashCommands: [], skills: [] }
const turn = { timestamp: 'x', sessionId: 's', userMessage: 'y', calls: [badCall] }
await writeRawCache({
version: CACHE_VERSION,
providers: { claude: { envFingerprint: 'x', files: {
'/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [turn] },
} } },
})
expect((await loadCache()).providers).toEqual({})
})
it('rejects call with NaN in usage', async () => {
const badUsage = { inputTokens: NaN, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, cacheCreationOneHourTokens: 0 }
const call = { provider: 'claude', model: 'm', usage: badUsage, deduplicationKey: 'k', timestamp: 't', tools: [], bashCommands: [], skills: [], speed: 'standard' }
const turn = { timestamp: 'x', sessionId: 's', userMessage: 'y', calls: [call] }
await writeRawCache({
version: CACHE_VERSION,
providers: { claude: { envFingerprint: 'x', files: {
'/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [turn] },
} } },
})
expect((await loadCache()).providers).toEqual({})
})
function validCallJson() {
return {
provider: 'claude', model: 'm', deduplicationKey: 'k', timestamp: 't', speed: 'standard',
tools: ['Read'], bashCommands: ['ls'], skills: [],
usage: { inputTokens: 1, outputTokens: 1, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, cacheCreationOneHourTokens: 0 },
}
}
function wrapCall(callOverride: Record<string, unknown>) {
return {
version: CACHE_VERSION,
providers: { claude: { envFingerprint: 'x', files: {
'/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [
{ timestamp: 'x', sessionId: 's', userMessage: 'y', calls: [{ ...validCallJson(), ...callOverride }] },
] },
} } },
}
}
function wrapFile(fileOverride: Record<string, unknown>) {
return {
version: CACHE_VERSION,
providers: { claude: { envFingerprint: 'x', files: {
'/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [], ...fileOverride },
} } },
}
}
it('rejects tools containing non-string element', async () => {
await writeRawCache(wrapCall({ tools: ['Read', 42] }))
expect((await loadCache()).providers).toEqual({})
})
it('rejects bashCommands containing object element', async () => {
await writeRawCache(wrapCall({ bashCommands: [{}] }))
expect((await loadCache()).providers).toEqual({})
})
it('rejects skills containing null element', async () => {
await writeRawCache(wrapCall({ skills: [null] }))
expect((await loadCache()).providers).toEqual({})
})
it('rejects invalid speed value', async () => {
await writeRawCache(wrapCall({ speed: 'turbo' }))
expect((await loadCache()).providers).toEqual({})
})
it('rejects non-string project', async () => {
await writeRawCache(wrapCall({ project: 123 }))
expect((await loadCache()).providers).toEqual({})
})
it('rejects non-string projectPath', async () => {
await writeRawCache(wrapCall({ projectPath: true }))
expect((await loadCache()).providers).toEqual({})
})
it('rejects mcpInventory containing non-string element', async () => {
await writeRawCache(wrapFile({ mcpInventory: ['valid', 99] }))
expect((await loadCache()).providers).toEqual({})
})
it('rejects non-numeric lastCompleteLineOffset', async () => {
await writeRawCache(wrapFile({ lastCompleteLineOffset: 'bad' }))
expect((await loadCache()).providers).toEqual({})
})
it('rejects NaN lastCompleteLineOffset', async () => {
await writeRawCache(wrapFile({ lastCompleteLineOffset: null }))
expect((await loadCache()).providers).toEqual({})
})
it('rejects non-string canonicalCwd', async () => {
await writeRawCache(wrapFile({ canonicalCwd: 42 }))
expect((await loadCache()).providers).toEqual({})
})
it('accepts optional fields when absent', async () => {
const cache: SessionCache = {
version: CACHE_VERSION,
providers: { claude: { envFingerprint: 'x', files: {
'/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [] },
} } },
}
await writeRawCache(cache)
expect((await loadCache())).toEqual(cache)
})
it('accepts a fully valid cache with all fields populated', async () => {
const cache: SessionCache = {
version: CACHE_VERSION,
providers: {
claude: {
envFingerprint: 'abc',
files: { '/f': makeCachedFile() },
},
},
}
await writeRawCache(cache)
const loaded = await loadCache()
expect(loaded).toEqual(cache)
})
})
// ── cleanupOrphanedTempFiles ───────────────────────────────────────────
describe('cleanupOrphanedTempFiles', () => {
it('removes .tmp files older than 5 minutes', async () => {
await mkdir(TMP_DIR, { recursive: true })
const oldTmp = join(TMP_DIR, `${CACHE_FILE()}.abc123.tmp`)
await writeFile(oldTmp, 'stale')
const { utimes } = await import('fs/promises')
const oldTime = new Date(Date.now() - 10 * 60 * 1000)
await utimes(oldTmp, oldTime, oldTime)
await cleanupOrphanedTempFiles()
expect(existsSync(oldTmp)).toBe(false)
})
it('preserves recent .tmp files', async () => {
await mkdir(TMP_DIR, { recursive: true })
const recentTmp = join(TMP_DIR, `${CACHE_FILE()}.def456.tmp`)
await writeFile(recentTmp, 'recent')
await cleanupOrphanedTempFiles()
expect(existsSync(recentTmp)).toBe(true)
})
it('ignores .tmp files from other caches', async () => {
await mkdir(TMP_DIR, { recursive: true })
const otherTmp = join(TMP_DIR, 'codex-results.json.abc123.tmp')
await writeFile(otherTmp, 'other cache temp')
const { utimes } = await import('fs/promises')
const oldTime = new Date(Date.now() - 10 * 60 * 1000)
await utimes(otherTmp, oldTime, oldTime)
await cleanupOrphanedTempFiles()
expect(existsSync(otherTmp)).toBe(true)
})
it('does not fail when cache dir does not exist', async () => {
process.env['CODEBURN_CACHE_DIR'] = '/no/such/dir'
await cleanupOrphanedTempFiles()
})
})