mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-29 10:55:35 +00:00
* fix(copilot): bill CLI input and cache tokens from session.shutdown Copilot CLI sessions (~/.copilot/session-state/*/events.jsonl) recorded only outputTokens, because the parser read assistant.message events and ignored the session.shutdown rollup. That rollup is the sole on-disk source of a CLI session's input, cache-read and cache-write tokens, so cost was underreported (in the reported case 35k+ cache-read tokens were dropped entirely). Handle session.shutdown in the CLI (non-transcript) JSONL path and emit one supplementary call per model from modelMetrics.<model>.usage. The per-turn assistant.message events keep owning output tokens, tools and turn structure; the shutdown call contributes only the input and cache tokens they lack, with output excluded, so no dimension double-counts and the combined cost equals the full CLI-measured cost. usage.inputTokens is cache-inclusive (input + cache_read + cache_write), so the cache components are subtracted to recover the uncached input calculateCost expects. These are real counts written by the CLI, so costIsEstimated is false. Sessions without session.shutdown (crashed or still running) keep the exact previous output-only behavior, and the VS Code, JetBrains and OTel paths are untouched. Bump the copilot entry in PROVIDER_PARSE_VERSIONS so already-cached CLI sessions re-parse and pick up the corrected tokens and cost. * fix(copilot): never emit an empty-timestamp shutdown call A session.shutdown without its own timestamp and without sessionStartTime produced a call with an empty timestamp, which the date-range filters in parser.ts drop silently, erasing exactly the tokens this feature bills. Fall back to the last stamped event in the session. * fix(cache): durable orphans survive provider fingerprint changes Bumping a durable provider's PROVIDER_PARSE_VERSIONS changed its env fingerprint, and getOrCreateProviderSection replaced the whole section on mismatch. For copilot OTel orphans the discarded cache entry is the only remaining record of pruned spans, so the first post-upgrade run silently and permanently erased month-to-date history. Carry forward exactly the entries whose source no longer exists; present sources still drop and re-parse under the new fingerprint, which is the point of a bump. Regression test forges a pre-upgrade fingerprint on a pruned-source orphan and asserts it stays counted and cached across the transition.
457 lines
21 KiB
TypeScript
457 lines
21 KiB
TypeScript
// Tests for durable-source monotonic cost behaviour (PR #477 / copilot-otel).
|
|
// Five scenarios:
|
|
// (a) file-purge monotonic — copilot JSONL file deleted → total unchanged
|
|
// (b) OTel-prune monotonic — OTel DB rows pruned → total unchanged
|
|
// (c) no double-count — same source parsed twice → counted once
|
|
// (d) non-durable evicts — deleted source for non-durable provider IS removed
|
|
// (e) 90-day age-out — orphan ≥ 91d old is pruned; ≤ 89d is retained
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import { mkdtemp, mkdir, writeFile, rm, unlink } from 'fs/promises'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
import { createRequire } from 'node:module'
|
|
|
|
import { isSqliteAvailable } from '../src/sqlite.js'
|
|
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
|
|
import { loadCache, saveCache } from '../src/session-cache.js'
|
|
import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/providers/types.js'
|
|
|
|
// ── Synthetic provider state ───────────────────────────────────────────────
|
|
// Module-level so the vi.mock factory closure captures them by reference and
|
|
// tests can mutate them freely without re-creating the mock.
|
|
let _synthSources: SessionSource[] = []
|
|
let _synthDurable = false
|
|
let _synthYields: ParsedProviderCall[] = []
|
|
|
|
vi.mock('../src/providers/index.js', async (importOriginal) => {
|
|
type Mod = typeof import('../src/providers/index.js')
|
|
const actual = await importOriginal<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> {
|
|
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`)
|
|
const lines = [
|
|
JSON.stringify({ type: 'session.model_change', timestamp: '2026-05-01T10:00:00Z', data: { newModel: 'gpt-4.1' } }),
|
|
JSON.stringify({ type: 'user.message', timestamp: '2026-05-01T10:00:05Z', data: { content: 'hello', interactionId: 'int-1' } }),
|
|
JSON.stringify({ type: 'assistant.message', timestamp: '2026-05-01T10:00:10Z', data: { messageId: 'msg-1', outputTokens, interactionId: 'int-1', toolRequests: [] } }),
|
|
]
|
|
await writeFile(join(dir, 'events.jsonl'), lines.join('\n') + '\n')
|
|
return join(dir, 'events.jsonl')
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
function totalCost(projects: Awaited<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 = []
|
|
})
|
|
|
|
afterEach(async () => {
|
|
clearSessionCache()
|
|
vi.unstubAllEnvs()
|
|
|
|
_synthSources = []
|
|
|
|
await rm(tmpHome, { recursive: true, force: true })
|
|
await rm(tmpCache, { recursive: true, force: true })
|
|
})
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// (a) File-purge monotonic: copilot JSONL file deleted → total unchanged
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe('(a) copilot JSONL file-purge monotonic', () => {
|
|
it('preserves monthly total after events.jsonl is deleted', async () => {
|
|
const sessionStateDir = join(tmpHome, 'session-state')
|
|
await mkdir(sessionStateDir, { recursive: true })
|
|
|
|
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
|
|
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1')
|
|
vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpHome, 'no-ws'))
|
|
|
|
const eventsPath = await createJsonlSession(sessionStateDir, 'sess-del', 200)
|
|
|
|
// First parse: file exists → cached
|
|
const proj1 = await parseAllSessions(undefined, 'copilot')
|
|
const out1 = totalOutput(proj1)
|
|
expect(out1).toBe(200)
|
|
|
|
// Delete the source file (simulates VS Code / CLI pruning it)
|
|
await unlink(eventsPath)
|
|
clearSessionCache()
|
|
|
|
// Second parse: file gone but copilot is durable → total must not drop
|
|
const proj2 = await parseAllSessions(undefined, 'copilot')
|
|
const out2 = totalOutput(proj2)
|
|
expect(out2).toBeGreaterThanOrEqual(out1)
|
|
expect(out2).toBe(out1)
|
|
})
|
|
})
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// (b) OTel-prune monotonic: OTel DB rows pruned → total unchanged
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe.skipIf(!isSqliteAvailable())(
|
|
'(b) OTel DB-prune monotonic',
|
|
() => {
|
|
it('preserves total after one conversation is pruned from the OTel DB', async () => {
|
|
const dbPath = join(tmpHome, 'agent-traces.db')
|
|
vi.stubEnv('CODEBURN_COPILOT_OTEL_DB', dbPath)
|
|
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '')
|
|
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', join(tmpHome, 'no-jsonl'))
|
|
vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpHome, 'no-ws'))
|
|
|
|
// DB with two conversations
|
|
createOtelDb(dbPath)
|
|
insertOtelConv(dbPath, { spanId: 's1', traceId: 't1', convId: 'prune-c1', model: 'gpt-4.1', input: 500, output: 50 })
|
|
insertOtelConv(dbPath, { spanId: 's2', traceId: 't2', convId: 'prune-c2', model: 'gpt-4.1', input: 1000, output: 100 })
|
|
|
|
const proj1 = await parseAllSessions(undefined, 'copilot')
|
|
const out1 = totalOutput(proj1)
|
|
expect(out1).toBe(150) // 50 + 100
|
|
|
|
// Simulate OTel pruning conv-1 from the DB: rebuild DB with only conv-2
|
|
clearSessionCache()
|
|
await rm(dbPath)
|
|
createOtelDb(dbPath)
|
|
insertOtelConv(dbPath, { spanId: 's2', traceId: 't2', convId: 'prune-c2', model: 'gpt-4.1', input: 1000, output: 100 })
|
|
|
|
// Second parse: DB was rebuilt without conv-1. The union-merge in
|
|
// parseProviderSources keeps conv-1's turns in the cache (since its
|
|
// dedup keys are not re-emitted by the re-parse) → total must not drop.
|
|
const proj2 = await parseAllSessions(undefined, 'copilot')
|
|
const out2 = totalOutput(proj2)
|
|
expect(out2).toBeGreaterThanOrEqual(out1)
|
|
expect(out2).toBe(out1)
|
|
})
|
|
}
|
|
)
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// (c) No double-count: same fully-present source parsed twice → counted once
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe.skipIf(!isSqliteAvailable())(
|
|
'(c) OTel source parsed twice is counted once',
|
|
() => {
|
|
it('second parse of unchanged DB yields same total, not double', async () => {
|
|
const dbPath = join(tmpHome, 'agent-traces.db')
|
|
vi.stubEnv('CODEBURN_COPILOT_OTEL_DB', dbPath)
|
|
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '')
|
|
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', join(tmpHome, 'no-jsonl'))
|
|
vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpHome, 'no-ws'))
|
|
|
|
createOtelDb(dbPath)
|
|
insertOtelConv(dbPath, { spanId: 'dedup-s1', traceId: 'dedup-t1', convId: 'dedup-c1', model: 'gpt-4.1', input: 300, output: 30 })
|
|
|
|
const proj1 = await parseAllSessions(undefined, 'copilot')
|
|
expect(totalOutput(proj1)).toBe(30)
|
|
|
|
clearSessionCache()
|
|
|
|
// Second parse — disk cache is populated, fingerprint unchanged
|
|
const proj2 = await parseAllSessions(undefined, 'copilot')
|
|
expect(totalOutput(proj2)).toBe(30) // NOT 60
|
|
})
|
|
}
|
|
)
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// (d) Non-durable evicts: deleted source for non-durable provider is removed
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe('(d) non-durable provider evicts deleted sources', () => {
|
|
it('removes cache entry for a path that leaves discoverSessions()', async () => {
|
|
// Two real temp files as source paths (fingerprintFile needs them to exist)
|
|
const fileA = join(tmpHome, 'synth-a.txt')
|
|
const fileB = join(tmpHome, 'synth-b.txt')
|
|
await writeFile(fileA, 'placeholder-a')
|
|
await writeFile(fileB, 'placeholder-b')
|
|
|
|
const dedupA = 'synth-dedup-evict-a'
|
|
const dedupB = 'synth-dedup-evict-b'
|
|
|
|
const makeCall = (deduplicationKey: string): ParsedProviderCall => ({
|
|
provider: 'test-synthetic', model: 'gpt-4o',
|
|
inputTokens: 10, outputTokens: 5,
|
|
cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
|
|
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
|
|
costUSD: 0.001, tools: [], bashCommands: [],
|
|
timestamp: new Date().toISOString(),
|
|
speed: 'standard',
|
|
deduplicationKey,
|
|
userMessage: 'test', sessionId: 'synth-sess',
|
|
})
|
|
|
|
_synthDurable = false
|
|
_synthSources = [
|
|
{ path: fileA, project: 'test', provider: 'test-synthetic' },
|
|
{ path: fileB, project: 'test', provider: 'test-synthetic' },
|
|
]
|
|
_synthYields = [makeCall(dedupA)]
|
|
|
|
// First parse: both sources present → data for A cached
|
|
const proj1 = await parseAllSessions(undefined, 'test-synthetic')
|
|
expect(totalOutput(proj1)).toBeGreaterThan(0)
|
|
|
|
clearSessionCache()
|
|
|
|
// Remove A from discovered sources (simulates file-gone + discoverSessions skips it).
|
|
// B stays so sources.length > 0 → eviction loop fires.
|
|
_synthSources = [{ path: fileB, project: 'test', provider: 'test-synthetic' }]
|
|
_synthYields = [] // B yields nothing (empty file)
|
|
|
|
const proj2 = await parseAllSessions(undefined, 'test-synthetic')
|
|
// A's cache entry must be evicted → total should be 0
|
|
expect(totalOutput(proj2)).toBe(0)
|
|
})
|
|
})
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// (e) 90-day age-out: orphan ≥ 91d old is pruned; ≤ 89d is retained
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe('(e) 90-day age-out for durable providers', () => {
|
|
it('prunes an orphaned cache entry whose newest call is 91 days old', async () => {
|
|
const synthFile = join(tmpHome, 'synth-age.txt')
|
|
await writeFile(synthFile, 'placeholder')
|
|
|
|
const ts91dAgo = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000).toISOString()
|
|
|
|
_synthDurable = true
|
|
_synthSources = [{ path: synthFile, project: 'test', provider: 'test-synthetic' }]
|
|
_synthYields = [{
|
|
provider: 'test-synthetic', model: 'gpt-4o',
|
|
inputTokens: 10, outputTokens: 8,
|
|
cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
|
|
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
|
|
costUSD: 0.002, tools: [], bashCommands: [],
|
|
timestamp: ts91dAgo,
|
|
speed: 'standard',
|
|
deduplicationKey: 'synth-age-out-91d',
|
|
userMessage: 'old', sessionId: 'synth-old',
|
|
}]
|
|
|
|
// First parse: cached with 91d-old timestamp → immediately pruned by 90-day check
|
|
const proj1 = await parseAllSessions(undefined, 'test-synthetic')
|
|
expect(totalOutput(proj1)).toBe(0) // pruned right away
|
|
|
|
// Confirm: entry is not in the persistent cache after first parse
|
|
clearSessionCache()
|
|
_synthSources = [] // no longer discovered
|
|
const proj2 = await parseAllSessions(undefined, 'test-synthetic')
|
|
expect(totalOutput(proj2)).toBe(0)
|
|
})
|
|
|
|
it('retains an orphaned cache entry whose newest call is 89 days old', async () => {
|
|
const synthFile = join(tmpHome, 'synth-retain.txt')
|
|
await writeFile(synthFile, 'placeholder')
|
|
|
|
const ts89dAgo = new Date(Date.now() - 89 * 24 * 60 * 60 * 1000).toISOString()
|
|
|
|
_synthDurable = true
|
|
_synthSources = [{ path: synthFile, project: 'test', provider: 'test-synthetic' }]
|
|
_synthYields = [{
|
|
provider: 'test-synthetic', model: 'gpt-4o',
|
|
inputTokens: 10, outputTokens: 7,
|
|
cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
|
|
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
|
|
costUSD: 0.002, tools: [], bashCommands: [],
|
|
timestamp: ts89dAgo,
|
|
speed: 'standard',
|
|
deduplicationKey: 'synth-retain-89d',
|
|
userMessage: 'recent-ish', sessionId: 'synth-recent',
|
|
}]
|
|
|
|
// First parse: cached with 89d-old timestamp → NOT pruned (within 90d window)
|
|
const proj1 = await parseAllSessions(undefined, 'test-synthetic')
|
|
expect(totalOutput(proj1)).toBe(7)
|
|
|
|
// Remove source (simulate it being orphaned)
|
|
clearSessionCache()
|
|
_synthSources = [] // no longer discovered → orphan pass handles it
|
|
|
|
// Second parse: orphan with 89d timestamp → retained + counted via orphan pass
|
|
const proj2 = await parseAllSessions(undefined, 'test-synthetic')
|
|
expect(totalOutput(proj2)).toBe(7)
|
|
})
|
|
})
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// (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 { readFile, writeFile: writeFileFs } = await import('fs/promises')
|
|
const cachePath = join(tmpCache, 'session-cache.json')
|
|
const disk = JSON.parse(await readFile(cachePath, 'utf-8')) as { providers: Record<string, { envFingerprint: string }> }
|
|
expect(disk.providers['copilot']).toBeDefined()
|
|
disk.providers['copilot']!.envFingerprint = '0000000000000000'
|
|
await writeFileFs(cachePath, JSON.stringify(disk), 'utf-8')
|
|
|
|
// 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)
|
|
})
|
|
})
|