diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a82e7d..295f31f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ - **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo) - `codeburn sync push --attribution` (opt-in): sends git attribution spans — the session→commit correlation from `codeburn yield` (`codeburn.session.attribution` and `codeburn.commit` span types with normalized repo remote, commit SHAs, merged/reverted state, and PR links). Nothing new is sent without the flag; local-only repos and Windows filesystem paths are never emitted as repo identities, and sessions whose project path no longer resolves never inherit the push-time working directory's repo. See docs/sync/README.md "Git attribution". +### Added (CLI) +- **Copilot input/cache tokens are read per request from `~/.copilot/session-store.db`.** The Copilot CLI and the GitHub Copilot desktop app both write this SQLite store unconditionally, one `assistant_usage_events` row per API request — where the `session.shutdown` rollup in `events.jsonl` appears only on clean shutdown (a crash silently lost the whole session's input/cache accounting), lumps each session leg into one per-model total, and resets its counters at in-session compaction (a clean 107-request session's sole rollup was observed covering exactly its five post-compaction requests), so even cleanly-closed long sessions were underreported. Sessions the store covers now take their input, cache-read, cache-write and reasoning tokens from the DB rows, per request and with real timestamps; their now-redundant shutdown rollups are dropped at serve time — the serve set is one coherent snapshot, so nothing double-counts no matter how or when the two representations reached the cache; sessions from CLI builds predating the table (or a missing store) keep the rollup path unchanged, and a transiently locked store defers only its own re-read while previously read rows keep serving (and keep taking precedence). Per-turn output stays with the `events.jsonl` per-turn calls. Copilot reasoning tokens are no longer double-billed: they are a subset of the output tokens the per-turn calls already price (per the store's own `token_details_json`), but the query-time cost recompute charged them again at the output rate on the supplementary input/cache calls. The copilot session cache takes a parse-version bump and the daily cache bumps from v17 to v18 so already-finalized days re-derive with per-request day attribution and the corrected reasoning pricing. Billing-grade cost from `total_nano_aiu` and throughput from the store's latency columns are follow-ups (#890). + ### Fixed (CLI) - **Copilot CLI sessions report their input and cache tokens.** The Copilot CLI writes the same `producer: 'copilot-agent'` in its `session.start` events that VS Code transcripts carry, so content-based detection classified every CLI session as a transcript and skipped its `session.shutdown` rollup — the only place the CLI records input, cache-read and cache-write tokens — leaving cache hit rate at 0.0% and dramatically underreporting cost. Whether a file is a transcript is now decided by where discovery found it, never by its contents. Resumed sessions, whose legs each append a cumulative rollup, are billed as per-leg deltas so a growing session never double-counts or goes stale; the GitHub Copilot desktop app writes the same session store, so its usage is covered by the same fix. The copilot session cache takes a parse-version bump and the daily cache bumps from v16 to v17 for the one-time re-parse that heals already-recorded days whose logs still exist. (#944) - **Copilot CLI subagent runs are attributed to their agent.** Newer CLIs announce delegation with `subagent.started`/`subagent.completed` rather than `subagent.selected`, so delegated turns lost their agent label; the label now also clears when the subagent completes instead of bleeding onto the parent's later turns. Rides the #944 re-parse, so already-cached sessions gain the attribution. (#944) diff --git a/src/daily-cache.ts b/src/daily-cache.ts index f3cbfd02..e81c706c 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -5,7 +5,15 @@ import { homedir } from 'os' import { join } from 'path' import type { DateRange, ProjectSummary } from './types.js' -// Bumped to 17: copilot CLI sessions were misclassified as VS Code transcripts +// Bumped to 18: copilot input/cache tokens for sessions covered by the CLI's +// session-store.db moved from one shutdown-rollup lump (stamped at session +// end) to per-request DB rows with real timestamps. Lifetime totals barely +// move, but per-day attribution does — a session straddling midnight now +// lands its input/cache on the days the requests actually happened — so days +// finalized at v17 would disagree with the live parse. Re-derivation rides +// the v14 carry-forward semantics; sourceless days carry forward as-is. +// +// v17: copilot CLI sessions were misclassified as VS Code transcripts // (#944), so days finalized at v16 or earlier carry output-only copilot costs — // the session.shutdown rollup's input/cache tokens were dropped. Raising // MIN_SUPPORTED_VERSION forces the one-time re-derivation under the @@ -73,8 +81,8 @@ import type { DateRange, ProjectSummary } from './types.js' // that older binaries skipped. v8 added local-model savings to the daily // rollup; the `savingsConfigHash` field is invalidated separately when the // user changes their `localModelSavings` mapping. -export const DAILY_CACHE_VERSION = 17 -const MIN_SUPPORTED_VERSION = 17 +export const DAILY_CACHE_VERSION = 18 +const MIN_SUPPORTED_VERSION = 18 // Version-suffixed so different binaries each own a distinct file and never // clobber an incompatible schema. Bumping the version mints a fresh filename; // adoptOlderDailyCaches then unions days out of every previous file (including diff --git a/src/parser.ts b/src/parser.ts index 004f25c2..ab8376b0 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2524,7 +2524,14 @@ function providerCallsToCachedTurns(calls: ParsedProviderCall[]): CachedTurn[] { function cachedCallToApiCall(call: CachedCall): ParsedApiCall { const u = call.usage - const outputForCost = call.provider === 'claude' + // Claude thinking and Copilot reasoning tokens are already INSIDE + // outputTokens (Copilot's own per-request token_details_json prices + // input/cache/output and nothing else, and its reasoning counts are a + // subset of the output count), so adding them here would bill them twice — + // for copilot literally so: its session-store/shutdown supplementary calls + // carry reasoningTokens with outputTokens 0 while the per-turn call bills + // the full output. Other providers report reasoning separately from output. + const outputForCost = call.provider === 'claude' || call.provider === 'copilot' ? u.outputTokens : u.outputTokens + u.reasoningTokens const costUSD = calculateCost( @@ -3039,11 +3046,18 @@ async function parseProviderSources( } } - // 90-day age-out for durable providers: remove entries whose newest call is - // older than 90 days so the cache doesn't grow unboundedly over time. + // 90-day age-out for durable providers: remove ORPHANED entries whose + // newest call is older than 90 days so the cache doesn't grow unboundedly. + // Entries whose path is still discovered are exempt — their source can be + // re-read at will, so there is no unbounded-orphan growth to cap, and + // pruning them zeroes data the source still holds: an idle machine whose + // session-store rows are all >90d old would otherwise serve NOTHING for + // those sessions while their events.jsonl rollups stay suppressed by the + // store's coverage (the store must be servable wherever it suppresses). if (!readOnly && provider.durableSources) { const cutoffMs = Date.now() - 90 * 24 * 60 * 60 * 1000 for (const [cachedPath, cachedFile] of Object.entries(section.files)) { + if (allDiscoveredFiles.has(cachedPath)) continue const newestTs = cachedFile.turns .flatMap(t => t.calls) .map(c => new Date(c.timestamp).getTime()) @@ -3056,6 +3070,42 @@ async function parseProviderSources( } } + // Copilot rollup-vs-store precedence, enforced at SERVE time — the sole + // suppression mechanism. Parsers cache both representations of a covered + // session unconditionally (per-request store rows and the shutdown + // rollup); the serve set is the one coherent snapshot, so dropping rollup + // calls here whenever it holds store calls for that session cannot be + // raced by writers between a probe and a parse, and heals any path into + // the cache (stale coverage epochs, restored files, historical + // double-caching). Only a still-DISCOVERED store suppresses: rows cached + // from a store since deleted or reset must not shadow fresh rollups that + // are now the only live record — during such an absence epoch the overlap + // legs may double-count for up to 90 days until the orphaned store entry + // ages out (accepted; the inverse choice would zero live sessions + // forever). Gates on the discovered sources, not allDiscoveredFiles, + // because read-only runs add cached orphans to the latter — an absent + // store must not suppress in read-only when it wouldn't in a refresh. + let copilotStoreSessions: Set | null = null + if (providerName === 'copilot') { + const discoveredPaths = new Set(sources.map(s => s.path)) + for (const [cachedPath, cachedFile] of Object.entries(section.files)) { + if (!discoveredPaths.has(cachedPath)) continue + for (const turn of cachedFile.turns) { + if (turn.calls.some(c => c.deduplicationKey.startsWith('copilot-store:'))) { + ;(copilotStoreSessions ??= new Set()).add(turn.sessionId) + } + } + } + } + const dropSuppressedRollups = (turn: CachedTurn): CachedTurn | null => { + if (!copilotStoreSessions?.has(turn.sessionId)) return turn + const kept = turn.calls.filter( + c => !c.deduplicationKey.startsWith(`copilot:${turn.sessionId}:shutdown:`) + ) + if (kept.length === turn.calls.length) return turn + return kept.length > 0 ? { ...turn, calls: kept } : null + } + // Query-time: derive SessionSummary from all cached turns. // Uses seenKeys (shared across providers) for cross-provider dedup. const sessionMap = new Map; title?: string }>() @@ -3064,7 +3114,9 @@ async function parseProviderSources( const cachedFile = section.files[source.path] if (!cachedFile) continue - for (const turn of cachedFile.turns) { + for (const rawTurn of cachedFile.turns) { + const turn = dropSuppressedRollups(rawTurn) + if (!turn) continue const hasDup = turn.calls.some(c => seenKeys.has(c.deduplicationKey)) if (hasDup) continue @@ -3121,7 +3173,9 @@ async function parseProviderSources( for (const [cachedPath, cachedFile] of Object.entries(section.files)) { if (allDiscoveredFiles.has(cachedPath)) continue // already counted above - for (const turn of cachedFile.turns) { + for (const rawTurn of cachedFile.turns) { + const turn = dropSuppressedRollups(rawTurn) + if (!turn) continue const hasDup = turn.calls.some(c => seenKeys.has(c.deduplicationKey)) if (hasDup) continue diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index e472fdb7..27c98f34 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -40,6 +40,7 @@ // CODEBURN_COPILOT_WS_STORAGE_DIR — Override VS Code workspaceStorage // CODEBURN_COPILOT_GLOBAL_STORAGE_DIR — Override VS Code globalStorage // CODEBURN_COPILOT_JETBRAINS_DIR — Override the JetBrains github-copilot root +// CODEBURN_COPILOT_SESSION_STORE_DB — Override the ~/.copilot/session-store.db path // // ARCHITECTURE: // discoverSessions() returns OTel sessions and legacy JSONL sessions. When @@ -262,6 +263,10 @@ function getCopilotSessionStateDir(override?: string): string { return override ?? process.env['CODEBURN_COPILOT_SESSION_STATE_DIR'] ?? join(homedir(), '.copilot', 'session-state') } +function getSessionStoreDbPath(override?: string): string { + return override ?? process.env['CODEBURN_COPILOT_SESSION_STORE_DB'] ?? join(homedir(), '.copilot', 'session-store.db') +} + /** * Locate the agent-traces.db file. * @@ -837,6 +842,17 @@ function createJsonlParser( // (and its cost) is not double-counted. Combined with the per-turn // output cost, this yields the full, CLI-measured session cost. if (isTranscript) continue + // When session-store.db holds per-request usage rows for this + // session, those rows are authoritative for input/cache: written + // per request instead of only on clean shutdown, and they describe + // the SAME tokens this rollup lumps together. That precedence is + // enforced at SERVE time, not here: this rollup is always parsed + // and cached, and parseProviderSources drops a session's rollup + // calls whenever the discovered store's rows are being served (see + // dropSuppressedRollups there). Read-time precedence over one + // coherent serve set cannot be raced by writers between a coverage + // probe and this parse, and a briefly unreadable store never + // blocks this file. const shutdownData = event.data as SessionShutdownData const modelMetrics = shutdownData.modelMetrics if (!isRecord(modelMetrics)) continue @@ -865,7 +881,14 @@ function createJsonlParser( // its counters (a fresh accounting epoch): delta from zero, else // this leg's post-reset usage would be clamped away entirely. // inputTokens is the monotonic sentinel — it is cache-inclusive, - // so any usage at all grows it. + // so any usage at all grows it. In-session COMPACTION is a + // confirmed reset trigger (CLI 1.0.78: a clean single-process + // 107-request session's sole rollup covered exactly its five + // post-compaction requests), so rollup-only accounting + // undercounts any compacted session — usage between the last + // pre-reset rollup and the reset is simply never written here. + // Only the per-request session-store rows record it; that is why + // they are authoritative for covered sessions. const prev = prevRaw && cumulative.inputTokens < numberOrZero(prevRaw.inputTokens) ? undefined @@ -1866,7 +1889,7 @@ function createOtelParser( outputTokens, cacheCreationTokens, cacheReadTokens, - 0 // reasoningTokens — not exposed in current OTel schema + 0 // webSearchRequests — not applicable to OTel spans ) yield { @@ -1899,6 +1922,221 @@ function createOtelParser( } } +// --------------------------------------------------------------------------- +// Session-store SQLite parser — per-request usage rows from session-store.db +// --------------------------------------------------------------------------- +// +// The Copilot CLI and the GitHub Copilot desktop app both write +// ~/.copilot/session-store.db unconditionally. Its assistant_usage_events +// table records one row per API request AS IT HAPPENS, where the +// session.shutdown rollup in events.jsonl is written only on clean shutdown +// (a crash loses the whole session's input/cache accounting) and lumps a +// session leg into one per-model total. The DB rows are therefore +// authoritative for input/cache tokens; parseProviderSources drops the +// covered sessions' rollup calls at serve time (see dropSuppressedRollups). +// +// The emitted calls mirror the shutdown-call contract exactly: input/cache/ +// reasoning only, output 0 — per-turn output (and its tools/userMessage +// metadata) stays owned by the events.jsonl assistant.message calls, so +// emitting output here would double-count it. Billing-grade cost from +// total_nano_aiu and throughput from the latency columns are deliberately +// not read yet (upstream #890). + +// The one usage query, shared verbatim between the discovery probe and the +// parser so the two can never diverge on schema: discovery runs it LIMIT 1 +// (prepare validates every table and column it touches) before emitting the +// source, so a store whose shape the parser cannot read is classified absent +// — its sessions keep their shutdown rollups — instead of surfacing a source +// that could only ever fail. +const SESSION_STORE_USAGE_SELECT = `SELECT e.id, e.session_id, e.model, + e.input_tokens, e.cache_read_tokens, e.cache_write_tokens, + e.reasoning_tokens, e.created_at, + s.cwd, s.repository, s.created_at AS session_created_at + FROM assistant_usage_events e + LEFT JOIN sessions s ON s.id = e.session_id` + +// Type alias, not interface: db.query's Row constraint needs the implicit +// index signature only anonymous object types carry. +type SessionStoreUsageRow = { + id: number + session_id: string + model: string + input_tokens: number | null + cache_read_tokens: number | null + cache_write_tokens: number | null + reasoning_tokens: number | null + created_at: string | null + cwd: string | null + repository: string | null + session_created_at: string | null +} + +function createSessionStoreParser( + source: SessionStoreSessionSource, + seenKeys: Set +): SessionParser { + return { + async *parse(): AsyncGenerator { + // Lazy-load the SQLite module (same pattern as the OTel source) + const { openDatabase, isSqliteBusyError } = await import('../sqlite.js') + + // The open sits inside the same classify-and-defer boundary as the + // query: discovery validated this store moments ago, so a failure HERE + // (EACCES/CANTOPEN/EMFILE race) is transient-shaped — letting it + // propagate raw would cache a failed marker at the current fingerprint + // and zero the covered sessions until the file next changes. + let db: ReturnType + try { + db = openDatabase(source.path) + } catch (err) { + if (isSqliteBusyError(err)) throw err + throw Object.assign( + new Error('copilot session-store.db unreadable at open; deferring'), + { code: 'SQLITE_BUSY' } + ) + } + try { + let rows: SessionStoreUsageRow[] + try { + rows = db.query(`${SESSION_STORE_USAGE_SELECT} ORDER BY e.id ASC`) + } catch (err) { + // Discovery prepare-validated this exact query moments ago, so any + // failure here means the store became unreadable or changed shape + // mid-run. Yielding nothing would cache an EMPTY success at this + // fingerprint while the covered sessions' rollups stay suppressed + // — a silent under-count that persists until the file changes. + // Every failure defers instead: parseProviderSources + // skips-and-retries on the busy shape without writing the cache. + if (isSqliteBusyError(err)) throw err + throw Object.assign( + new Error('copilot session-store.db unreadable mid-parse; deferring'), + { code: 'SQLITE_BUSY' } + ) + } + + // created_at defaults to SQLite's datetime('now') — UTC but + // timezone-less ('2026-08-07 17:56:38', or with fractional seconds + // under 'subsec'), which Date.parse reads as LOCAL time and would + // shift the request onto the wrong day. The CLI writes explicit + // ISO-Z strings (audited: every observed row), so this normalizes + // only the defensive zoneless shapes to UTC; anything carrying its + // own zone/offset passes through untouched. + const normalizeTimestamp = (raw: string | null): string => + raw + ? timestampToISO( + /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?$/.test(raw) + ? raw.replace(' ', 'T') + 'Z' + : raw + ) + : '' + let prevTimestamp = '' + + for (const row of rows) { + if (!row.session_id) continue + + // A call with an empty timestamp is invisible to every date-range + // filter, so never emit one: fall back from the row's own + // created_at to the previous row's timestamp, then to the + // session's created_at. The previous row deliberately outranks the + // session's own created_at even across sessions — ids are GLOBALLY + // insertion-ordered, so the previous row is the nearest earlier + // clock reading, while a resumed session's created_at can be days + // stale. Both columns carry SQLite defaults, so an empty chain is + // unreachable outside a hand-built store; such a row is skipped, + // and if that desert covers a whole session its rollup simply + // stays unsuppressed at serve time. + const timestamp = + normalizeTimestamp(row.created_at) || + prevTimestamp || + normalizeTimestamp(row.session_created_at) + if (!timestamp) continue + prevTimestamp = timestamp + // TEXT NOT NULL still admits '': a billable row must NEVER be + // dropped for an unnameable model — its session's rollup was + // suppressed on the promise that every billable row is emitted + // (the coverage predicate does not know about models). Price as + // 'unknown' instead; the pricing engine reports unknown models at + // $0 with a fix-it hint rather than silently losing the tokens. + const model = row.model || 'unknown' + + const cacheReadTokens = numberOrZero(row.cache_read_tokens) + const cacheWriteTokens = numberOrZero(row.cache_write_tokens) + const reasoningTokens = numberOrZero(row.reasoning_tokens) + // input_tokens is cache-INCLUSIVE (input + cache_read + cache_write), + // the same convention the shutdown rollup uses — confirmed against + // token_details_json, whose tokenType:"input" entries hold exactly + // this difference. calculateCost expects the uncached remainder with + // cache tokens billed separately, so subtract; clamp guards a future + // schema that reports input non-inclusively. + const inputTokens = Math.max( + 0, + numberOrZero(row.input_tokens) - cacheReadTokens - cacheWriteTokens + ) + + // Nothing this call would add over the per-turn events (output is + // intentionally excluded), so skip it to avoid an empty $0 row. + if (inputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0 && reasoningTokens === 0) continue + + // `id` is AUTOINCREMENT: stable across re-parses and never reused, + // so a growing DB appends only new keys under the durable + // union-by-dedup-key cache merge. + const dedupKey = `copilot-store:${row.session_id}:${row.id}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + // One DB spans every project, so the project must ride each call + // (the per-source fallback would lump them all together). Prefer + // the label discovery derived from the session's own session-state + // dir (workspace.yaml cwd — the same one its per-turn output calls + // carry, so the session never splits across two projects); the + // store's sessions.cwd/repository names only sessions with no + // session-state dir on this machine. + const project = + source.projectsBySessionId?.get(row.session_id) ?? + (row.cwd + ? basename(row.cwd) + : row.repository + ? basename(row.repository.replace(/\.git$/, '')) + : row.session_id) + + // Tokens are real per-request counts written by the CLI, so this + // cost is measured, not char-estimated. reasoning_tokens rides as + // metadata only, never as a cost line: it is a SUBSET of the row's + // output_tokens (the row's own token_details_json prices exactly + // input/cache_read/cache_write/output, no reasoning entry), and + // output — reasoning included — is billed by the per-turn + // assistant.message call. Pricing reasoning here would double-count. + const costUSD = calculateCost(model, inputTokens, 0, cacheWriteTokens, cacheReadTokens, 0) + + yield { + provider: 'copilot', + sessionId: row.session_id, + project, + model, + inputTokens, + outputTokens: 0, + cacheCreationInputTokens: cacheWriteTokens, + cacheReadInputTokens: cacheReadTokens, + cachedInputTokens: 0, + reasoningTokens, + webSearchRequests: 0, + costUSD, + costIsEstimated: false, + tools: [], + bashCommands: [], + timestamp, + speed: 'standard' as const, + deduplicationKey: dedupKey, + userMessage: '', + } + } + } finally { + db.close() + } + }, + } +} + // --------------------------------------------------------------------------- // Extended SessionSource for OTel sessions // --------------------------------------------------------------------------- @@ -1912,6 +2150,19 @@ interface JsonlSessionSource extends SessionSource { sourceType: 'jsonl' } +// The Copilot CLI / GitHub desktop-app session store (~/.copilot/session-store.db). +// One source per DB file; the parser iterates every session's usage rows in a +// single DB open, mirroring the OTel source. +interface SessionStoreSessionSource extends SessionSource { + sourceType: 'session-store' + // sessionId → project label derived from the session-state dirs + // (workspace.yaml cwd), attached at discovery. The store's own + // sessions.cwd can lag or miss what the session actually ran in, and the + // per-turn output calls already carry the jsonl-derived label — using the + // same one keeps a session's store rows and output calls in one session. + projectsBySessionId?: Map +} + // A VS Code workspaceStorage transcript. Distinct from 'jsonl' (CLI // session-state) so classification rides provenance, not file contents (#944). interface TranscriptSessionSource extends SessionSource { @@ -1959,6 +2210,10 @@ function isTranscriptSource(source: SessionSource): source is TranscriptSessionS return (source as TranscriptSessionSource).sourceType === 'transcript' } +function isSessionStoreSource(source: SessionSource): source is SessionStoreSessionSource { + return (source as SessionStoreSessionSource).sourceType === 'session-store' +} + // --------------------------------------------------------------------------- // Session discovery: JSONL (original) // --------------------------------------------------------------------------- @@ -2020,6 +2275,65 @@ async function discoverOtelSessions( return [{ path: dbPath, project: 'copilot-chat', provider: 'copilot', sourceType: 'otel' }] } +// --------------------------------------------------------------------------- +// Session discovery: session-store SQLite +// --------------------------------------------------------------------------- + +/** + * Probe session-store.db. This decides only whether a store SOURCE exists; + * which sessions it covers is decided at serve time from what its parse + * actually cached (see dropSuppressedRollups in parseProviderSources), so + * nothing a writer does between this probe and the parse can change + * accounting. + * + * Permanent absence — no file, no sqlite driver, or a schema the parser's + * own query cannot prepare against ("no such table": CLI builds before the + * store existed; "no such column": a future migration) — returns null: no + * source, no suppression, and the shutdown-rollup path carries the sessions + * exactly as before. + * + * EVERY other failure still emits the source. Measured against the real + * driver (node:sqlite, WAL store): a write lock never blocks readers and a + * hot -wal without its -shm reads fine, so the reachable failures here are + * corruption-class — SQLITE_CORRUPT (11), SQLITE_NOTADB (26, e.g. mid + * atomic-replace), SQLITE_CANTOPEN (14, deleted after the stat) — plus the + * classic busy/locked pair and stat-level EACCES/EIO. None of those prove + * the store is gone, so the path must stay discovered: the parse raises the + * busy shape parseProviderSources skips-and-retries, previously cached rows + * keep serving, and serve-time suppression keeps holding from the cache + * instead of flapping the covered sessions' rollups back in. + */ +async function discoverSessionStoreSource( + dbPath: string +): Promise { + const source: SessionStoreSessionSource = { + path: dbPath, + project: 'copilot', + provider: 'copilot', + sourceType: 'session-store', + } + try { + await stat(dbPath) + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + return code === 'ENOENT' || code === 'ENOTDIR' ? null : source + } + const { openDatabase, isSqliteAvailable } = await import('../sqlite.js') + if (!isSqliteAvailable()) return null + try { + const db = openDatabase(dbPath) + try { + db.query(`${SESSION_STORE_USAGE_SELECT} LIMIT 1`) + } finally { + db.close() + } + return source + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return /no such (table|column)/i.test(message) ? null : source + } +} + // --------------------------------------------------------------------------- // Session discovery: JetBrains (IntelliJ IDEA, PyCharm, …) // --------------------------------------------------------------------------- @@ -2373,7 +2687,8 @@ export function createCopilotProvider( sessionStateDir?: string, workspaceStorageDir?: string, globalStorageDir?: string, - jetbrainsDir?: string + jetbrainsDir?: string, + sessionStoreDb?: string ): Provider { // jsonlDir is resolved lazily inside discoverSessions so that env-var // overrides set after module load (e.g. in tests) are respected. @@ -2434,10 +2749,40 @@ export function createCopilotProvider( } } + // 1b. Discover the CLI / GitHub desktop-app session store. Written per + // API request (crash-proof) where the events.jsonl shutdown rollup + // exists only after a clean exit, so its rows are authoritative for + // input/cache tokens; serve-time precedence (dropSuppressedRollups in + // parseProviderSources) drops the covered sessions' rollups whenever + // this source's rows are being served. True absence (older CLI schema, + // no sqlite driver) leaves the rollup path untouched; an unreadable + // store still surfaces the source so its parse defers and cached rows + // keep serving (see discoverSessionStoreSource). + let storeSource: SessionStoreSessionSource | null = null + try { + storeSource = await discoverSessionStoreSource(getSessionStoreDbPath(sessionStoreDb)) + } catch { + // Unreachable in practice (the probe catches its own errors): a + // throw here means the sqlite module itself is unusable, and + // rollup-only accounting is then the correct mode — the same + // fallback as a missing driver. + storeSource = null + } + if (storeSource) sources.push(storeSource) + // 2. Discover JSONL sessions (fallback — output tokens only) try { const jsonlDir = getCopilotSessionStateDir(sessionStateDir) const jsonlSources = await discoverJsonlSessions(jsonlDir) + if (storeSource) { + // Same sessionId derivation as createJsonlParser: the CLI keys + // session-state dirs and session-store rows by the same id, so the + // store parser can attribute each session's rows to the same + // project its per-turn output calls carry. + storeSource.projectsBySessionId = new Map( + jsonlSources.map(src => [basename(dirname(src.path)), src.project]) + ) + } sources.push(...jsonlSources) } catch { // JSONL discovery failed @@ -2497,6 +2842,9 @@ export function createCopilotProvider( if (isOtelSource(source)) { return createOtelParser(source, seenKeys) } + if (isSessionStoreSource(source)) { + return createSessionStoreParser(source, seenKeys) + } if (isChatSessionSource(source)) { return createChatSessionParser(source, seenKeys) } diff --git a/src/session-cache.ts b/src/session-cache.ts index a86aea89..9e3701b4 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -186,6 +186,11 @@ export const PROVIDER_ENV_VARS: Record = { crush: ['XDG_DATA_HOME'], warp: ['WARP_DB_PATH'], antigravity: ['CODEBURN_CACHE_DIR'], + // Only the session-store override is fingerprinted: unlike the other + // CODEBURN_COPILOT_* vars (which merely move discovery), repointing this DB + // changes which sessions COVER their events.jsonl shutdown rollups — the + // cached parse of OTHER files depends on it, so it must force a re-parse. + copilot: ['CODEBURN_COPILOT_SESSION_STORE_DB'], qwen: ['QWEN_DATA_DIR'], 'ibm-bob': ['XDG_CONFIG_HOME'], quickdesk: ['QUICKWORK_HOME'], @@ -227,7 +232,12 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // source-provenance-v1 (#944): CLI sessions were misread as VS Code // transcripts (both carry producer 'copilot-agent'), skipping the shutdown // input/cache rollup; this bump re-parses them so the missing tokens land. - copilot: 'cli-shutdown-cost-v1-skills-source-provenance-v1', + // session-store-v1: input/cache for sessions covered by session-store.db + // moved from shutdown-rollup calls to per-request DB rows. This bump + // re-parses pre-store caches so the DB rows land; the now-redundant rollup + // calls stay cached (the durable union merge never deletes) and are + // dropped at serve time instead (parseProviderSources). + copilot: 'cli-shutdown-cost-v1-skills-source-provenance-v1-session-store-v1', grok: 'estimated-cost-v1', hermes: 'reasoning-output-accounting-v1-est-cost', 'lingtai-tui': 'token-ledger-registry-activity-v3', diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 6d54b78f..ccb79593 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -13,6 +13,7 @@ import { join } from 'path' import { createRequire } from 'node:module' import { isSqliteAvailable } from '../src/sqlite.js' +import { calculateCost } from '../src/models.js' import { clearSessionCache, parseAllSessions } from '../src/parser.js' import { loadCache, saveCache, sessionCachePath } from '../src/session-cache.js' import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/providers/types.js' @@ -374,11 +375,15 @@ describe('(e) 90-day age-out for durable providers', () => { userMessage: 'old', sessionId: 'synth-old', }] - // First parse: cached with 91d-old timestamp → immediately pruned by 90-day check + // First parse: the source is still DISCOVERED, so the age-out must not + // touch it — its data is re-readable at will and pruning it would zero + // usage the source still holds (the copilot session-store suppresses + // rollups on the promise that its rows are servable). const proj1 = await parseAllSessions(undefined, 'test-synthetic') - expect(totalOutput(proj1)).toBe(0) // pruned right away + expect(totalOutput(proj1)).toBe(8) - // Confirm: entry is not in the persistent cache after first parse + // Once ORPHANED, the 91d-old entry is pruned — the age-out exists to + // bound orphan growth, not to expire live sources. clearSessionCache() _synthSources = [] // no longer discovered const proj2 = await parseAllSessions(undefined, 'test-synthetic') @@ -679,3 +684,576 @@ describe('(f) growing resumed CLI session durable merge', () => { expect(second).toEqual({ input: 74463 - 49489 - 24968, cacheRead: 49489, cacheWrite: 24968 }) }) }) + +// ═══════════════════════════════════════════════════════════════════════════ +// (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, +): 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, '/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) + 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 suppressed', 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 whose numbers deliberately DIFFER from the DB rows: the final + // totals prove which side won, not that the two happened to agree. + 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: 1, cost: 1 }, + usage: { inputTokens: 999999, outputTokens: 17, cacheReadTokens: 900000, cacheWriteTokens: 90000, reasoningTokens: 0 }, + }, + }, + }, + }), + ].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>) => { + 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 999999-token + // rollup is suppressed); 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>) => { + 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 still-discovered sources +// ═══════════════════════════════════════════════════════════════════════════ +// An idle machine whose session-store rows are all >90 days old: the store is +// still on disk and still suppresses the sessions' rollups, so pruning its +// cache entry (as the pre-fix age-out did to every entry, discovered or not) +// served ZERO for those sessions every run — and the daily re-derive would +// freeze the zeros. Live sources are exempt from the age-out; only orphans +// are bounded by it. +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>) => { + 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 — with the rollup + // suppressed exactly as for fresh data. + 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 + sumUsage: (projects: Awaited>) => { 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 => { + 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>) => { + 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: orphaned rows stop suppressing live rollups +// ═══════════════════════════════════════════════════════════════════════════ +// The #946 round-5 finding. The store is deleted (reinstall, `~/.copilot` +// reset) but its cached rows live on as durable orphans. New CLI sessions — +// and re-served old ones — then have rollups as their ONLY live record; if +// orphaned rows kept suppressing, those sessions would serve zero +// input/cache for as long as the orphans lived. Suppression is therefore +// gated on the store being DISCOVERED. The cost is bounded and accepted: +// during the absence epoch the orphan rows and the overlapping rollup both +// count (the over-count below) until the 90-day age-out prunes the orphan; +// the inverse gate would under-count live sessions indefinitely. +describe.skipIf(!isSqliteAvailable())('(o) absence epoch: orphaned store rows do not suppress', () => { + it('counts the rollup again once the store file is gone', 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 dropped. + 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 }) + + // The rollup is the only live record now and must count. The orphaned + // rows still serve too — the documented ≤90d over-count of the overlap + // leg, healed when the orphan ages out. + const second = sumUsage(await parseAllSessions(undefined, 'copilot')) + expect(second).toEqual({ input: 1200, cacheRead: 34000, cacheWrite: 4800, 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 precedence cannot suppress against rows the serve set +// does not hold: the rollup stands until the row actually lands, then the +// store wins, counted once at every step and zero at none. +describe.skipIf(!isSqliteAvailable())('(p) suppression never outruns the served store rows', () => { + it('keeps the rollup until the store row lands, then swaps, never zero', 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 row 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 row lands; the next refresh serves it and drops the rollup. + clearSessionCache() + insertStoreRow(dbPath, 'sess-rev', 12000, 10000, 1500, at(12)) // input 500 + + const second = sumUsage(await parseAllSessions(undefined, 'copilot')) + expect(second).toEqual({ input: 500, cacheRead: 10000, cacheWrite: 1500, output: 25 }) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// (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>) => { + 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 }) + }) +}) diff --git a/tests/providers/copilot.test.ts b/tests/providers/copilot.test.ts index ae128be9..a174eb13 100644 --- a/tests/providers/copilot.test.ts +++ b/tests/providers/copilot.test.ts @@ -5,12 +5,25 @@ import { tmpdir } from 'os' import { createRequire } from 'node:module' import { copilot, createCopilotProvider, getVSCodeGlobalStorageDirs, getVSCodeWorkspaceStorageDirs } from '../../src/providers/copilot.js' -import { isSqliteAvailable } from '../../src/sqlite.js' +import { isSqliteAvailable, isSqliteBusyError } from '../../src/sqlite.js' import { calculateCost } from '../../src/models.js' import type { ParsedProviderCall } from '../../src/providers/types.js' let tmpDir: string +// The machine running this suite may itself have a real +// ~/.copilot/session-store.db, which discoverSessions would pick up by +// default and leak into every discovery test's source list. Pin the path to +// a nonexistent file globally; tests that need a store pass an explicit +// fixture path to createCopilotProvider (or re-stub the env themselves). +beforeEach(() => { + vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', '/nonexistent/session-store.db') +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + async function createSessionDir(sessionId: string, lines: string[], cwd = '/home/user/myproject') { const sessionDir = join(tmpDir, sessionId) await mkdir(sessionDir, { recursive: true }) @@ -1784,6 +1797,615 @@ describe('copilot provider - OTel cache token parsing', () => { // separate serialized fields. These helpers reproduce that on-disk shape so // tests exercise the real regex/scan extraction path. +// --------------------------------------------------------------------------- +// Session-store tests (~/.copilot/session-store.db) +// +// The Copilot CLI and the GitHub Copilot desktop app write per-request usage +// rows into assistant_usage_events. These tests verify the row → call +// contract (cache-inclusive input decomposed, output excluded), the +// discovery-time shutdown-rollup suppression for covered sessions, and the +// graceful-absence path for stores predating the table. Fixture DBs are +// built programmatically — never committed binaries. +// --------------------------------------------------------------------------- + +/** Creates a minimal session-store.db schema matching the Copilot CLI store. */ +function createSessionStoreDb(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, + branch TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE assistant_usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + model TEXT NOT NULL, + input_tokens INTEGER, + output_tokens INTEGER, + cache_read_tokens INTEGER, + cache_write_tokens INTEGER, + reasoning_tokens INTEGER, + created_at TEXT DEFAULT (datetime('now')) + ); + `) + db.close() +} + +interface UsageRowDef { + sessionId: string + model: string + // Cache-INCLUSIVE, as the CLI writes it (input + cache_read + cache_write). + inputTokens: number + outputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number + reasoningTokens?: number + // Explicit null writes SQL NULL (exercises the timestamp fallback chain); + // undefined gets a fixed default so unrelated tests stay deterministic. + createdAt?: string | null + cwd?: string + repository?: string + sessionCreatedAt?: string | null +} + +function insertUsageRow(dbPath: string, row: UsageRowDef): 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, repository, created_at) VALUES (?, ?, ?, ?)`) + .run(row.sessionId, row.cwd ?? null, row.repository ?? null, row.sessionCreatedAt ?? null) + 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 (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + row.sessionId, + row.model, + row.inputTokens, + row.outputTokens ?? 0, + row.cacheReadTokens ?? 0, + row.cacheWriteTokens ?? 0, + row.reasoningTokens ?? 0, + row.createdAt === undefined ? '2026-08-01T12:00:00.000Z' : row.createdAt, + ) + db.close() +} + +const storeSource = (path: string) => + ({ path, project: 'copilot', provider: 'copilot', sourceType: 'session-store' }) + +describe.skipIf(!isSqliteAvailable())('copilot provider - session-store parsing', () => { + let dbPath: string + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'copilot-store-test-')) + dbPath = join(tmpDir, 'session-store.db') + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('decomposes cache-inclusive input_tokens per request row, output excluded', async () => { + createSessionStoreDb(dbPath) + // First two requests of a real CLI session: input_tokens is + // cache-INCLUSIVE (24680 = 2 + 0 + 24678), confirmed by the rows' own + // token_details_json split (tokenType:"input" holds the uncached + // remainder). The rows carry output tokens which must NOT be emitted — + // per-turn output is owned by the events.jsonl assistant.message calls. + insertUsageRow(dbPath, { + sessionId: 'sess-a', model: 'claude-sonnet-4-5', + inputTokens: 24680, outputTokens: 81, cacheReadTokens: 0, cacheWriteTokens: 24678, + createdAt: '2026-08-07T17:56:38.756Z', cwd: '/home/user/myproject', + }) + insertUsageRow(dbPath, { + sessionId: 'sess-a', model: 'claude-sonnet-4-5', + inputTokens: 24793, outputTokens: 19, cacheReadTokens: 24678, cacheWriteTokens: 113, + createdAt: '2026-08-07T17:56:40.414Z', + }) + + const calls = await collectCalls(storeSource(dbPath)) + expect(calls).toHaveLength(2) + + const first = calls[0]! + expect(first.deduplicationKey).toBe('copilot-store:sess-a:1') + expect(first.model).toBe('claude-sonnet-4-5') + expect(first.inputTokens).toBe(2) // 24680 - 0 - 24678 + expect(first.cacheReadInputTokens).toBe(0) + expect(first.cacheCreationInputTokens).toBe(24678) + expect(first.outputTokens).toBe(0) + expect(first.costIsEstimated).toBe(false) // measured, not estimated + expect(first.costUSD).toBeCloseTo(calculateCost('claude-sonnet-4-5', 2, 0, 24678, 0, 0), 12) + expect(first.costUSD).toBeGreaterThan(0) + expect(first.project).toBe('myproject') // sessions.cwd basename + expect(first.sessionId).toBe('sess-a') + expect(first.timestamp).toBe('2026-08-07T17:56:38.756Z') + + const second = calls[1]! + expect(second.deduplicationKey).toBe('copilot-store:sess-a:2') + expect(second.inputTokens).toBe(2) // 24793 - 24678 - 113 + expect(second.cacheReadInputTokens).toBe(24678) + expect(second.cacheCreationInputTokens).toBe(113) + }) + + it('bills a multi-model (delegating) session per row model', async () => { + createSessionStoreDb(dbPath) + // A delegating CLI session: subagent requests land as their own rows with + // a distinct model, exactly as observed for haiku-backed subagents. + insertUsageRow(dbPath, { + sessionId: 'sess-multi', model: 'claude-sonnet-4-5', + inputTokens: 10100, cacheReadTokens: 8000, cacheWriteTokens: 2000, reasoningTokens: 94, + }) + insertUsageRow(dbPath, { + sessionId: 'sess-multi', model: 'claude-haiku-4.5', + inputTokens: 5050, cacheReadTokens: 5000, cacheWriteTokens: 0, + }) + + const calls = await collectCalls(storeSource(dbPath)) + expect(calls).toHaveLength(2) + + const sonnet = calls.find(c => c.model === 'claude-sonnet-4-5')! + expect(sonnet.inputTokens).toBe(100) + expect(sonnet.reasoningTokens).toBe(94) + // Reasoning is metadata, never a cost line: the CLI's own + // token_details_json prices only input/cache/output, and reasoning + // tokens are a subset of output_tokens — billed by the per-turn + // assistant.message call. A cost above input+cache pricing here means + // reasoning got billed twice. + expect(sonnet.costUSD).toBeCloseTo(calculateCost('claude-sonnet-4-5', 100, 0, 2000, 8000, 0), 12) + const haiku = calls.find(c => c.model === 'claude-haiku-4.5')! + expect(haiku.inputTokens).toBe(50) + expect(haiku.cacheReadInputTokens).toBe(5000) + }) + + it('skips all-zero rows and reads SQL-default timestamps as UTC', async () => { + createSessionStoreDb(dbPath) + // A row with no input/cache/reasoning adds nothing over the per-turn + // events (output is excluded by design) — no empty $0 call. + insertUsageRow(dbPath, { sessionId: 'sess-z', model: 'gpt-5', inputTokens: 0, outputTokens: 42 }) + // created_at written by SQLite's datetime('now') default: UTC but + // timezone-less, with and without subseconds. Neither may be read as + // local time — that would land the request on the wrong day. + insertUsageRow(dbPath, { + sessionId: 'sess-z', model: 'gpt-5', + inputTokens: 500, cacheReadTokens: 200, createdAt: '2026-08-07 17:56:38', + }) + insertUsageRow(dbPath, { + sessionId: 'sess-z', model: 'gpt-5', + inputTokens: 600, cacheReadTokens: 300, createdAt: '2026-08-07 23:59:59.756', + }) + + const calls = await collectCalls(storeSource(dbPath)) + expect(calls).toHaveLength(2) + expect(calls[0]!.timestamp).toBe('2026-08-07T17:56:38.000Z') + expect(calls[1]!.timestamp).toBe('2026-08-07T23:59:59.756Z') + }) + + it('keeps dedup keys stable as the store grows', async () => { + createSessionStoreDb(dbPath) + insertUsageRow(dbPath, { sessionId: 'sess-grow', model: 'gpt-5', inputTokens: 1000, cacheReadTokens: 400 }) + + const seen = new Set() + const first = await collectCalls(storeSource(dbPath), seen) + expect(first.map(c => c.deduplicationKey)).toEqual(['copilot-store:sess-grow:1']) + + // Unchanged store re-parsed with the shared dedup set: nothing re-emits. + expect(await collectCalls(storeSource(dbPath), seen)).toHaveLength(0) + + // New request row: only it is emitted, under the next AUTOINCREMENT id — + // the append-only shape the durable union-by-key cache merge requires. + insertUsageRow(dbPath, { sessionId: 'sess-grow', model: 'gpt-5', inputTokens: 2000, cacheReadTokens: 900 }) + const grown = await collectCalls(storeSource(dbPath), seen) + expect(grown.map(c => c.deduplicationKey)).toEqual(['copilot-store:sess-grow:2']) + }) + + it('parses BOTH the store rows and the shutdown rollup for a covered session', async () => { + // Precedence is serve-time only: the parsers cache both representations + // unconditionally, and parseProviderSources drops the rollup calls of + // sessions whose store rows are being served (tests/parser.test.ts (i), + // (k), (m)). Suppressing here would re-open the probe-to-parse races the + // serve-time design closes, so this pins the parse-level contract: no + // parser-side suppression, ever. + createSessionStoreDb(dbPath) + insertUsageRow(dbPath, { + sessionId: 'sess-covered', model: 'claude-sonnet-4-5', + inputTokens: 10100, cacheReadTokens: 8000, cacheWriteTokens: 2000, + cwd: '/home/user/myproject', + }) + const eventsPath = await createSessionDir('sess-covered', [ + modelChange('claude-sonnet-4-5'), + userMessage('do the thing'), + assistantMessage({ messageId: 'msg-1', outputTokens: 345 }), + shutdownEvent({ + modelMetrics: { + 'claude-sonnet-4-5': { inputTokens: 71282, outputTokens: 345, cacheReadTokens: 35495, cacheWriteTokens: 35783, reasoningTokens: 31 }, + }, + }), + ]) + + const provider = createCopilotProvider(tmpDir, '/nonexistent/ws', '/nonexistent/global', '/nonexistent/jb', dbPath) + const sources = await provider.discoverSessions() + const store = sources.find(s => (s as { sourceType?: string }).sourceType === 'session-store') + expect(store).toBeDefined() + const jsonl = sources.find(s => s.path === eventsPath) + expect(jsonl).toBeDefined() + + const seen = new Set() + const collect = async (src: typeof sources[number]) => { + const out: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(src, seen).parse()) out.push(call) + return out + } + const storeCalls = await collect(store!) + const jsonlCalls = await collect(jsonl!) + + expect(storeCalls.map(c => c.deduplicationKey)).toEqual(['copilot-store:sess-covered:1']) + const rollup = jsonlCalls.find(c => c.deduplicationKey === 'copilot:sess-covered:shutdown:claude-sonnet-4-5:1') + expect(rollup).toBeDefined() + expect(rollup!.cacheReadInputTokens).toBe(35495) + expect(storeCalls[0]!.inputTokens).toBe(100) + expect(storeCalls[0]!.cacheReadInputTokens).toBe(8000) + }) + + it('keeps the shutdown rollup for sessions the store does not cover', async () => { + createSessionStoreDb(dbPath) + // The store knows about a DIFFERENT session (e.g. one run under a newer + // CLI); sess-uncovered predates the table's rows and must keep its + // rollup-derived input/cache. + insertUsageRow(dbPath, { sessionId: 'sess-other', model: 'gpt-5', inputTokens: 700, cacheReadTokens: 300 }) + const eventsPath = await createSessionDir('sess-uncovered', [ + modelChange('claude-sonnet-4-5'), + assistantMessage({ messageId: 'msg-1', outputTokens: 100 }), + shutdownEvent({ + modelMetrics: { + 'claude-sonnet-4-5': { inputTokens: 10100, outputTokens: 100, cacheReadTokens: 8000, cacheWriteTokens: 2000 }, + }, + }), + ]) + + const provider = createCopilotProvider(tmpDir, '/nonexistent/ws', '/nonexistent/global', '/nonexistent/jb', dbPath) + const sources = await provider.discoverSessions() + const jsonl = sources.find(s => s.path === eventsPath)! + + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(jsonl, new Set()).parse()) calls.push(call) + + const rollup = calls.find(c => c.deduplicationKey === 'copilot:sess-uncovered:shutdown:claude-sonnet-4-5:1') + expect(rollup).toBeDefined() + expect(rollup!.inputTokens).toBe(100) + expect(rollup!.cacheReadInputTokens).toBe(8000) + }) + + it('a locked store still surfaces its source and never blocks session-state parsing', async () => { + createSessionStoreDb(dbPath) + insertUsageRow(dbPath, { sessionId: 'sess-locked', model: 'gpt-5', inputTokens: 1000, cacheReadTokens: 400 }) + const eventsPath = await createSessionDir('sess-locked', [ + modelChange('claude-sonnet-4-5'), + assistantMessage({ messageId: 'msg-1', outputTokens: 100 }), + shutdownEvent({ + modelMetrics: { + 'claude-sonnet-4-5': { inputTokens: 10100, outputTokens: 100, cacheReadTokens: 8000, cacheWriteTokens: 2000 }, + }, + }), + ]) + + // Hold an exclusive write transaction across discovery AND both parses, + // the shape of a CLI mid-checkpoint. A lock proves nothing about + // absence, so the source must still surface — its path stays discovered + // and previously cached rows keep serving (and keep suppressing at + // serve time) — while its parse raises the busy shape + // parseProviderSources skips-and-retries. The session-state file no + // longer waits on the store for anything: its parse (rollup included) + // must succeed with the store locked the whole time. + const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (path: string) => TestDb } + const locker = new DatabaseSync(dbPath) + locker.exec('BEGIN EXCLUSIVE') + try { + const provider = createCopilotProvider(tmpDir, '/nonexistent/ws', '/nonexistent/global', '/nonexistent/jb', dbPath) + const sources = await provider.discoverSessions() + const store = sources.find(s => (s as { sourceType?: string }).sourceType === 'session-store') + expect(store).toBeDefined() + + const consumeStore = async () => { + for await (const _ of provider.createSessionParser(store!, new Set()).parse()) void _ + } + await expect(consumeStore()).rejects.toSatisfy((err: unknown) => isSqliteBusyError(err)) + + const jsonl = sources.find(s => s.path === eventsPath)! + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(jsonl, new Set()).parse()) calls.push(call) + expect(calls.some(c => c.deduplicationKey === 'copilot:sess-locked:shutdown:claude-sonnet-4-5:1')).toBe(true) + } finally { + locker.exec('ROLLBACK') + locker.close() + } + }) + + it('surfaces the source when the store path cannot be stat-ed, and defers its parse', async () => { + // EACCES/EIO on stat must NOT read as absence: a store may exist that + // this run cannot see. The source stays discovered — so serve-time + // suppression keeps holding from previously cached rows — and its parse + // raises the busy shape parseProviderSources skips-and-retries. The + // session-state file parses normally either way. + if (typeof process.getuid === 'function' && process.getuid() === 0) return // root ignores modes + const deniedDir = join(tmpDir, 'denied') + await mkdir(deniedDir, { recursive: true }) + const deniedDb = join(deniedDir, 'session-store.db') + createSessionStoreDb(deniedDb) + const eventsPath = await createSessionDir('sess-denied', [ + modelChange('claude-sonnet-4-5'), + assistantMessage({ messageId: 'msg-1', outputTokens: 10 }), + shutdownEvent({ + modelMetrics: { + 'claude-sonnet-4-5': { inputTokens: 5100, outputTokens: 10, cacheReadTokens: 4000, cacheWriteTokens: 1000 }, + }, + }), + ]) + + const { chmod } = await import('fs/promises') + await chmod(deniedDir, 0o000) + try { + const provider = createCopilotProvider(tmpDir, '/nonexistent/ws', '/nonexistent/global', '/nonexistent/jb', deniedDb) + const sources = await provider.discoverSessions() + const store = sources.find(s => (s as { sourceType?: string }).sourceType === 'session-store') + expect(store).toBeDefined() + const consumeStore = async () => { + for await (const _ of provider.createSessionParser(store!, new Set()).parse()) void _ + } + await expect(consumeStore()).rejects.toSatisfy((err: unknown) => isSqliteBusyError(err)) + + const jsonl = sources.find(s => s.path === eventsPath)! + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(jsonl, new Set()).parse()) calls.push(call) + expect(calls.some(c => c.deduplicationKey === 'copilot:sess-denied:shutdown:claude-sonnet-4-5:1')).toBe(true) + } finally { + await chmod(deniedDir, 0o755) + } + }) + + it('defers the store source when the DB becomes unopenable after discovery', async () => { + // An EACCES/CANTOPEN race between discovery and parse must defer, not + // fall through to the generic parse-failure path — that would cache a + // failed marker at the current fingerprint and zero the covered + // sessions until the file next changes. + if (typeof process.getuid === 'function' && process.getuid() === 0) return // root ignores modes + createSessionStoreDb(dbPath) + insertUsageRow(dbPath, { sessionId: 'sess-open', model: 'gpt-5', inputTokens: 1000, cacheReadTokens: 400 }) + + const provider = createCopilotProvider(tmpDir, '/nonexistent/ws', '/nonexistent/global', '/nonexistent/jb', dbPath) + const sources = await provider.discoverSessions() + const store = sources.find(s => (s as { sourceType?: string }).sourceType === 'session-store')! + + const { chmod } = await import('fs/promises') + await chmod(dbPath, 0o000) + try { + const consume = async () => { + for await (const _ of provider.createSessionParser(store, new Set()).parse()) void _ + } + await expect(consume()).rejects.toSatisfy((err: unknown) => isSqliteBusyError(err)) + } finally { + await chmod(dbPath, 0o644) + } + }) + + it('defers the store parse when the schema changes mid-run', async () => { + // Discovery prepare-validated the schema this run, so a query failure at + // parse time proves a mid-run migration. Falling through to the generic + // parse-failure path would cache an EMPTY success at the current + // fingerprint while cached rows keep suppressing rollups at serve time — + // a silent under-count until the file next changes. Defer instead. + createSessionStoreDb(dbPath) + insertUsageRow(dbPath, { sessionId: 'sess-migrate', model: 'gpt-5', inputTokens: 1000, cacheReadTokens: 400 }) + const provider = createCopilotProvider(tmpDir, '/nonexistent/ws', '/nonexistent/global', '/nonexistent/jb', dbPath) + const sources = await provider.discoverSessions() + const store = sources.find(s => (s as { sourceType?: string }).sourceType === 'session-store')! + + const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (path: string) => TestDb } + const migrator = new DatabaseSync(dbPath) + migrator.exec('ALTER TABLE assistant_usage_events DROP COLUMN reasoning_tokens') + migrator.close() + + const consume = async () => { + for await (const _ of provider.createSessionParser(store, new Set()).parse()) void _ + } + await expect(consume()).rejects.toMatchObject({ code: 'SQLITE_BUSY' }) + }) + + it('emits billable rows with an empty model as unknown instead of dropping them', async () => { + // TEXT NOT NULL admits '': a billable row must never be dropped for an + // unnameable model — serve-time precedence suppresses the session's + // rollup whenever its store rows serve, so a skipped row's tokens would + // simply vanish. Price as 'unknown' instead. + createSessionStoreDb(dbPath) + insertUsageRow(dbPath, { + sessionId: 'sess-nomodel', model: '', + inputTokens: 10100, cacheReadTokens: 8000, cacheWriteTokens: 2000, + }) + + const calls = await collectCalls(storeSource(dbPath)) + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('unknown') + expect(calls[0]!.inputTokens).toBe(100) + expect(calls[0]!.cacheReadInputTokens).toBe(8000) + }) + + it('surfaces the source when the store is corrupt, and defers its parse', async () => { + // Corruption-class failures (SQLITE_CORRUPT/NOTADB/CANTOPEN — measured as + // the store's realistic failure modes; WAL write locks don't even block + // readers) must NOT read as absence: the file may be mid atomic-replace + // and readable next run. The source stays discovered — cached rows keep + // serving and keep suppressing at serve time — while its parse defers + // with the busy shape. Session-state files parse normally throughout. + await writeFile(dbPath, 'not a sqlite database at all') + const eventsPath = await createSessionDir('sess-corrupt', [ + modelChange('claude-sonnet-4-5'), + assistantMessage({ messageId: 'msg-1', outputTokens: 100 }), + shutdownEvent({ + modelMetrics: { + 'claude-sonnet-4-5': { inputTokens: 10100, outputTokens: 100, cacheReadTokens: 8000, cacheWriteTokens: 2000 }, + }, + }), + ]) + + const provider = createCopilotProvider(tmpDir, '/nonexistent/ws', '/nonexistent/global', '/nonexistent/jb', dbPath) + const sources = await provider.discoverSessions() + const store = sources.find(s => (s as { sourceType?: string }).sourceType === 'session-store') + expect(store).toBeDefined() + const consumeStore = async () => { + for await (const _ of provider.createSessionParser(store!, new Set()).parse()) void _ + } + await expect(consumeStore()).rejects.toMatchObject({ code: 'SQLITE_BUSY' }) + + const jsonl = sources.find(s => s.path === eventsPath)! + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(jsonl, new Set()).parse()) calls.push(call) + expect(calls.some(c => c.deduplicationKey === 'copilot:sess-corrupt:shutdown:claude-sonnet-4-5:1')).toBe(true) + }) + + it('treats a store whose schema the parser cannot read as absent', async () => { + // A schema mismatch ("no such column") is a permanent shape, not a + // transient failure: deferring would stall CLI parsing forever, and the + // rollups ARE the right source for a store the parser can't read. The + // probe runs the parser's exact query, so the mismatch is caught before + // any rollup is suppressed. + 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); + CREATE TABLE assistant_usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER + ); + INSERT INTO assistant_usage_events (session_id, model, input_tokens) VALUES ('sess-newschema', 'gpt-5', 900); + `) + db.close() + + const eventsPath = await createSessionDir('sess-newschema', [ + modelChange('claude-sonnet-4-5'), + assistantMessage({ messageId: 'msg-1', outputTokens: 50 }), + shutdownEvent({ + modelMetrics: { + 'claude-sonnet-4-5': { inputTokens: 5100, outputTokens: 50, cacheReadTokens: 4000, cacheWriteTokens: 1000 }, + }, + }), + ]) + + const provider = createCopilotProvider(tmpDir, '/nonexistent/ws', '/nonexistent/global', '/nonexistent/jb', dbPath) + const sources = await provider.discoverSessions() + expect(sources.some(s => (s as { sourceType?: string }).sourceType === 'session-store')).toBe(false) + + const jsonl = sources.find(s => s.path === eventsPath)! + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(jsonl, new Set()).parse()) calls.push(call) + expect(calls.some(c => c.deduplicationKey.includes(':shutdown:'))).toBe(true) + }) + + it('treats a store without assistant_usage_events as absent', async () => { + // Older CLI builds create session-store.db without the usage table. The + // source must not surface (and must not throw), and no session gets its + // shutdown rollup suppressed. + 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)') + db.close() + + const eventsPath = await createSessionDir('sess-old-cli', [ + modelChange('claude-sonnet-4-5'), + assistantMessage({ messageId: 'msg-1', outputTokens: 50 }), + shutdownEvent({ + modelMetrics: { + 'claude-sonnet-4-5': { inputTokens: 5100, outputTokens: 50, cacheReadTokens: 4000, cacheWriteTokens: 1000 }, + }, + }), + ]) + + const provider = createCopilotProvider(tmpDir, '/nonexistent/ws', '/nonexistent/global', '/nonexistent/jb', dbPath) + const sources = await provider.discoverSessions() + expect(sources.some(s => (s as { sourceType?: string }).sourceType === 'session-store')).toBe(false) + + const jsonl = sources.find(s => s.path === eventsPath)! + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(jsonl, new Set()).parse()) calls.push(call) + expect(calls.some(c => c.deduplicationKey.includes(':shutdown:'))).toBe(true) + }) + + it('never emits an empty timestamp: falls back to the previous row, then sessions.created_at', async () => { + // A call with an empty timestamp is invisible to every date-range filter + // — the tokens would silently vanish from daily/monthly views while the + // session's rollup stays suppressed. Rows are id-ordered, so the nearest + // earlier row is the closest clock reading; a NULL on the very first row + // falls back to the session's own created_at. + createSessionStoreDb(dbPath) + insertUsageRow(dbPath, { + sessionId: 'sess-nots', model: 'gpt-5', + inputTokens: 1000, cacheReadTokens: 400, createdAt: null, + sessionCreatedAt: '2026-08-05T09:00:00.000Z', + }) + insertUsageRow(dbPath, { + sessionId: 'sess-nots', model: 'gpt-5', + inputTokens: 2000, cacheReadTokens: 900, createdAt: '2026-08-05T09:05:00.000Z', + }) + insertUsageRow(dbPath, { + sessionId: 'sess-nots', model: 'gpt-5', + inputTokens: 3000, cacheReadTokens: 1400, createdAt: null, + }) + + const calls = await collectCalls(storeSource(dbPath)) + expect(calls).toHaveLength(3) + expect(calls[0]!.timestamp).toBe('2026-08-05T09:00:00.000Z') // sessions.created_at + expect(calls[1]!.timestamp).toBe('2026-08-05T09:05:00.000Z') // its own created_at + expect(calls[2]!.timestamp).toBe('2026-08-05T09:05:00.000Z') // previous row's + }) + + it('attributes store rows to the jsonl-derived project, over sessions.cwd', async () => { + // The per-turn output calls carry the workspace.yaml-derived project, + // and the session grouping key includes project — so a store row landing + // under any OTHER label (the sessionId fallback for a NULL cwd, or a + // stale/differing sessions.cwd) splits one real session into two. + // Sessions with no session-state dir keep the cwd → repository → + // sessionId fallback chain. + createSessionStoreDb(dbPath) + // The review's verbatim shape: NULL cwd AND repository, jsonl present. + insertUsageRow(dbPath, { + sessionId: 'sess-attr-null', model: 'gpt-5', + inputTokens: 1000, cacheReadTokens: 400, + }) + // A present-but-differing sessions.cwd must also lose to the jsonl label. + insertUsageRow(dbPath, { + sessionId: 'sess-attr-stale', model: 'gpt-5', + inputTokens: 1500, cacheReadTokens: 600, cwd: '/home/user/stale-db-cwd', + }) + insertUsageRow(dbPath, { + sessionId: 'sess-nojsonl', model: 'gpt-5', + inputTokens: 2000, cacheReadTokens: 900, cwd: '/home/user/db-only-proj', + }) + await createSessionDir('sess-attr-null', [ + modelChange('gpt-5'), + assistantMessage({ messageId: 'msg-1', outputTokens: 10 }), + ], '/home/user/jsonl-proj') + await createSessionDir('sess-attr-stale', [ + modelChange('gpt-5'), + assistantMessage({ messageId: 'msg-2', outputTokens: 10 }), + ], '/home/user/jsonl-proj') + + const provider = createCopilotProvider(tmpDir, '/nonexistent/ws', '/nonexistent/global', '/nonexistent/jb', dbPath) + const sources = await provider.discoverSessions() + const store = sources.find(s => (s as { sourceType?: string }).sourceType === 'session-store')! + + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(store, new Set()).parse()) calls.push(call) + expect(calls.find(c => c.sessionId === 'sess-attr-null')!.project).toBe('jsonl-proj') + expect(calls.find(c => c.sessionId === 'sess-attr-stale')!.project).toBe('jsonl-proj') + expect(calls.find(c => c.sessionId === 'sess-nojsonl')!.project).toBe('db-only-proj') + }) +}) + describe('copilot provider - JetBrains parsing', () => { beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'copilot-jetbrains-test-'))