codeburn/tests/dsh-cache-invalidation.test.ts

261 lines
12 KiB
TypeScript

import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { createHash } from 'crypto'
import { mkdir, readFile, rm, writeFile } from 'fs/promises'
import { join } from 'path'
const testRoot = vi.hoisted(() => {
const root = `${process.env['TMPDIR'] || '/tmp'}/dsh-cache-inv-${process.pid}-${Date.now()}`
process.env['DSH_HOME'] = `${root}/dsh`
return root
})
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
import { billableOutputTokens, calculateCost } from '../src/models.js'
import { aggregateSessions } from '../src/sessions-report.js'
import { aggregateModelStats } from '../src/compare-stats.js'
import { aggregateProjectsIntoDays } from '../src/day-aggregator.js'
import { currentTzKey, ensureCacheHydrated, loadDailyCache } from '../src/daily-cache.js'
import {
CACHE_VERSION,
computeEnvFingerprint,
fingerprintFile,
type SessionCache,
} from '../src/session-cache.js'
import { readCacheOnDisk, writeCacheOnDisk } from './fixtures/session-cache-io.js'
const DSH_HOME = join(testRoot, 'dsh')
const CACHE_DIR = join(testRoot, 'cache')
const SESSION_DIR = join(DSH_HOME, 'sessions', '--home-u-proj--', 'session-cache')
function header(version: 0 | 3): string {
return JSON.stringify({
type: 'session', version, id: 'session-cache', createdAt: 1786707336131,
cwd: '/home/u/proj', ...(version === 3 ? { isSeeded: false } : {}), delegationDepth: 0,
})
}
function message(seq: number, step: number, inputTokens: number, reasoningTokens = 0, time = 1786707340000 + seq): string {
return JSON.stringify({
type: 'assistant/message', seq, time, surfaceOp: 'append',
data: {
turn: 1, step,
message: { role: 'assistant', content: [], source: { kind: 'model', provider: 'openai', model: 'gpt-5.4' } },
usage: { inputTokens, outputTokens: 5, reasoningTokens },
stream: [],
},
})
}
async function writeLog(version: 0 | 3, inputs: number[]): Promise<string> {
await mkdir(SESSION_DIR, { recursive: true })
const path = join(SESSION_DIR, version === 0 ? 'session.jsonl' : 'session.v3.jsonl')
await writeFile(path, [header(version), ...inputs.map((input, index) => message(index, index + 1, input))].join('\n') + '\n')
return path
}
async function calls() {
return (await parseAllSessions(undefined, 'dsh'))
.flatMap(project => project.sessions)
.flatMap(session => session.turns)
.flatMap(turn => turn.assistantCalls)
}
function preFixFingerprint(): string {
return createHash('sha256')
.update([`DSH_HOME=${process.env['DSH_HOME'] ?? ''}`, 'parser=seed-aware-v1'].join('\0'))
.digest('hex')
.slice(0, 16)
}
async function seedEmptyCache(path: string, envFingerprint: string): Promise<void> {
const fingerprint = await fingerprintFile(path)
if (!fingerprint) throw new Error('failed to fingerprint DSH fixture')
const cache: SessionCache = {
version: CACHE_VERSION,
complete: true,
providers: {
dsh: {
envFingerprint,
files: { [path]: { fingerprint, mcpInventory: [], turns: [] } },
},
},
}
await mkdir(CACHE_DIR, { recursive: true })
await writeCacheOnDisk(cache)
clearSessionCache()
}
beforeEach(async () => {
process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR
process.env['DSH_HOME'] = DSH_HOME
clearSessionCache()
await rm(testRoot, { recursive: true, force: true })
})
afterAll(async () => {
clearSessionCache()
await rm(testRoot, { recursive: true, force: true })
})
describe('DSH multi-generation session cache', () => {
it('reparses v4 caches that stored exclusive output before the shared inclusive rule', async () => {
const path = await writeLog(3, [])
await writeFile(path, [header(3), message(0, 1, 100, 3)].join('\n') + '\n')
await calls()
const cache = await readCacheOnDisk()
const section = cache.providers.dsh!
section.envFingerprint = createHash('sha256').update([
`DSH_HOME=${DSH_HOME}`, 'parser=session-formats-v0-v3-attempts-v4',
].join('\0')).digest('hex').slice(0, 16)
for (const turn of section.files[path]!.turns) {
for (const call of turn.calls) call.usage.outputTokens = 2
}
await writeCacheOnDisk(cache)
clearSessionCache()
const corrected = await calls()
expect(corrected[0]!.usage).toMatchObject({ outputTokens: 5, reasoningTokens: 3 })
expect(corrected[0]!.costUSD).toBeCloseTo(calculateCost('gpt-5.4', 100, 5, 0, 0, 0), 12)
})
it('counts separate legacy and current sessions while counting migrated history only once, cold and warm', async () => {
const versions = [0, 1, 2, 3] as const
for (const version of versions) {
const content = await readFile(join(import.meta.dirname, `fixtures/dsh/v${version}.jsonl`), 'utf8')
for (const id of [`independent-v${version}`, ...(version === 0 || version === 3 ? ['migrated'] : [])]) {
const rows = content.trim().split('\n')
const metadata = JSON.parse(rows[0]!)
rows[0] = JSON.stringify({ ...metadata, id })
const dir = join(DSH_HOME, 'sessions', '--fixture--', id)
await mkdir(dir, { recursive: true })
const filename = version === 0 ? 'session.jsonl' : `session.v${version}.jsonl`
await writeFile(join(dir, filename), rows.join('\n') + '\n')
}
}
// Six files represent five sessions. All four independent versions count;
// only the migrated session's v0 file is superseded by its own v3 file.
for (const phase of ['cold', 'warm', 'reloaded']) {
if (phase === 'reloaded') clearSessionCache()
const projects = await parseAllSessions(undefined, 'dsh')
const sessions = projects.flatMap(project => project.sessions)
expect(sessions.map(session => session.sessionId).sort()).toEqual([
'independent-v0', 'independent-v1', 'independent-v2', 'independent-v3', 'migrated',
])
const parsed = sessions.flatMap(session => session.turns).flatMap(turn => turn.assistantCalls)
expect(parsed).toHaveLength(5)
expect(parsed.reduce((sum, call) => sum + call.usage.inputTokens, 0)).toBe(500)
expect(parsed.reduce((sum, call) => sum + billableOutputTokens('dsh', call.usage.outputTokens, call.usage.reasoningTokens), 0)).toBe(100)
expect(parsed.reduce((sum, call) => sum + call.usage.cacheReadInputTokens, 0)).toBe(150)
expect(parsed.reduce((sum, call) => sum + call.usage.cacheCreationInputTokens, 0)).toBe(25)
}
})
it('re-derives finalized v31 days from the authoritative generation even when the call count falls', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-08-16T12:00:00Z'))
try {
await writeLog(0, [100, 200])
const staleDays = aggregateProjectsIntoDays(await parseAllSessions(undefined, 'dsh'))
expect(staleDays[0]!.calls).toBe(2)
const oldCache = {
version: 31, savingsConfigHash: 'dsh-test', tzKey: currentTzKey(),
lastComputedDate: '2026-08-15', days: staleDays, complete: true, watermarkTrusted: true,
}
const oldPath = join(CACHE_DIR, 'daily-cache.v31.json')
await writeFile(oldPath, JSON.stringify(oldCache))
await writeLog(3, [20])
clearSessionCache()
expect((await loadDailyCache()).pendingRederive).toContain('dsh')
const parse = vi.fn(range => parseAllSessions(range, 'dsh'))
const hydrated = await ensureCacheHydrated(parse, aggregateProjectsIntoDays, 'dsh-test')
expect(parse).toHaveBeenCalledTimes(1)
expect(hydrated.complete).toBe(true)
expect(hydrated.pendingRederive).toBeUndefined()
expect(hydrated.days).toEqual([expect.objectContaining({
inputTokens: 20, outputTokens: 5, calls: 1,
providers: expect.objectContaining({ dsh: expect.objectContaining({ inputTokens: 20, calls: 1 }) }),
})])
expect(JSON.parse(await readFile(oldPath, 'utf8'))).toEqual(oldCache)
parse.mockClear()
expect((await ensureCacheHydrated(parse, aggregateProjectsIntoDays, 'dsh-test')).days).toEqual(hydrated.days)
expect(parse).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('filters a cross-day session identically before and after a disk-cache reload', async () => {
const path = await writeLog(3, [])
await writeFile(path, [header(3),
message(0, 1, 10, 0, Date.parse('2026-08-14T23:59:00Z')),
message(1, 2, 20, 0, Date.parse('2026-08-15T00:01:00Z')),
].join('\n') + '\n')
const range = { start: new Date('2026-08-15T00:00:00Z'), end: new Date('2026-08-15T23:59:59Z') }
for (const reload of [false, true]) {
if (reload) clearSessionCache()
const projects = await parseAllSessions(range, 'dsh')
expect(aggregateSessions(projects)).toEqual([expect.objectContaining({ inputTokens: 20, outputTokens: 5, calls: 1 })])
expect(aggregateProjectsIntoDays(projects)).toEqual([expect.objectContaining({ date: '2026-08-15', inputTokens: 20, calls: 1 })])
}
})
it.each(['unknown', 'corrupt'])('does not revive a cached older generation when its successor is %s', async (kind) => {
await writeLog(0, [100])
expect(await calls()).toHaveLength(1)
const successor = kind === 'unknown' ? 'session.v4.jsonl' : 'session.v3.jsonl'
await writeFile(join(SESSION_DIR, successor), kind === 'unknown'
? JSON.stringify({ type: 'session', version: 4 }) + '\n'
: '{broken\n')
clearSessionCache()
expect(await calls()).toEqual([])
clearSessionCache()
expect(await calls()).toEqual([])
})
it('preserves inclusive reasoning buckets and shared totals through cold and hot caches', async () => {
const path = await writeLog(3, [])
await writeFile(path, [header(3), message(0, 1, 100, 3)].join('\n') + '\n')
const expectedCost = calculateCost('gpt-5.4', 100, 5, 0, 0, 0)
for (const reload of [false, false, true]) {
if (reload) clearSessionCache()
const projects = await parseAllSessions(undefined, 'dsh')
const parsedCalls = projects.flatMap(project => project.sessions)
.flatMap(session => session.turns).flatMap(turn => turn.assistantCalls)
expect(parsedCalls).toHaveLength(1)
expect(parsedCalls[0]!.usage).toMatchObject({ inputTokens: 100, outputTokens: 5, reasoningTokens: 3 })
expect(parsedCalls[0]!.costUSD).toBeCloseTo(expectedCost, 12)
expect(aggregateSessions(projects)).toEqual([expect.objectContaining({ outputTokens: 5, cost: expectedCost })])
expect(aggregateModelStats(projects)).toEqual([expect.objectContaining({ outputTokens: 5, cost: expectedCost })])
expect(aggregateProjectsIntoDays(projects)).toEqual([expect.objectContaining({ outputTokens: 5 })])
}
})
it('invalidates a pre-v3 parser fingerprint instead of trusting stale empty results', async () => {
const path = await writeLog(3, [100])
expect(computeEnvFingerprint('dsh')).not.toBe(preFixFingerprint())
await seedEmptyCache(path, computeEnvFingerprint('dsh'))
expect(await calls()).toHaveLength(0)
await seedEmptyCache(path, preFixFingerprint())
expect((await calls()).map(call => call.usage.inputTokens)).toEqual([100])
})
it('switches generations without double counting, reparses appends, and survives a cold reload', async () => {
await writeLog(0, [10])
expect((await calls()).map(call => call.usage.inputTokens)).toEqual([10])
const v3Path = await writeLog(3, [20])
clearSessionCache()
expect((await calls()).map(call => call.usage.inputTokens)).toEqual([20])
await writeFile(v3Path, [header(3), message(0, 1, 20), message(1, 2, 30)].join('\n') + '\n')
clearSessionCache()
expect((await calls()).map(call => call.usage.inputTokens)).toEqual([20, 30])
clearSessionCache()
expect((await calls()).map(call => call.usage.inputTokens)).toEqual([20, 30])
})
})