codeburn/tests/providers/copilot.test.ts
Resham Joshi 98051b4263
fix(copilot): bill CLI input and cache tokens from session.shutdown (#684)
* fix(copilot): bill CLI input and cache tokens from session.shutdown

Copilot CLI sessions (~/.copilot/session-state/*/events.jsonl) recorded
only outputTokens, because the parser read assistant.message events and
ignored the session.shutdown rollup. That rollup is the sole on-disk
source of a CLI session's input, cache-read and cache-write tokens, so
cost was underreported (in the reported case 35k+ cache-read tokens were
dropped entirely).

Handle session.shutdown in the CLI (non-transcript) JSONL path and emit
one supplementary call per model from modelMetrics.<model>.usage. The
per-turn assistant.message events keep owning output tokens, tools and
turn structure; the shutdown call contributes only the input and cache
tokens they lack, with output excluded, so no dimension double-counts and
the combined cost equals the full CLI-measured cost. usage.inputTokens is
cache-inclusive (input + cache_read + cache_write), so the cache
components are subtracted to recover the uncached input calculateCost
expects. These are real counts written by the CLI, so costIsEstimated is
false. Sessions without session.shutdown (crashed or still running) keep
the exact previous output-only behavior, and the VS Code, JetBrains and
OTel paths are untouched.

Bump the copilot entry in PROVIDER_PARSE_VERSIONS so already-cached CLI
sessions re-parse and pick up the corrected tokens and cost.

* fix(copilot): never emit an empty-timestamp shutdown call

A session.shutdown without its own timestamp and without sessionStartTime
produced a call with an empty timestamp, which the date-range filters in
parser.ts drop silently, erasing exactly the tokens this feature bills.
Fall back to the last stamped event in the session.

* fix(cache): durable orphans survive provider fingerprint changes

Bumping a durable provider's PROVIDER_PARSE_VERSIONS changed its env
fingerprint, and getOrCreateProviderSection replaced the whole section on
mismatch. For copilot OTel orphans the discarded cache entry is the only
remaining record of pruned spans, so the first post-upgrade run silently
and permanently erased month-to-date history. Carry forward exactly the
entries whose source no longer exists; present sources still drop and
re-parse under the new fingerprint, which is the point of a bump.

Regression test forges a pre-upgrade fingerprint on a pruned-source
orphan and asserts it stays counted and cached across the transition.
2026-07-16 10:37:58 -07:00

2049 lines
90 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join, posix, win32 } from 'path'
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 { calculateCost } from '../../src/models.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
async function createSessionDir(sessionId: string, lines: string[], cwd = '/home/user/myproject') {
const sessionDir = join(tmpDir, sessionId)
await mkdir(sessionDir, { recursive: true })
await writeFile(join(sessionDir, 'workspace.yaml'), `id: ${sessionId}\ncwd: ${cwd}\n`)
await writeFile(join(sessionDir, 'events.jsonl'), lines.join('\n') + '\n')
return join(sessionDir, 'events.jsonl')
}
function modelChange(newModel: string, previousModel?: string) {
return JSON.stringify({ type: 'session.model_change', timestamp: '2026-04-15T10:00:01Z', data: { newModel, previousModel } })
}
function userMessage(content: string) {
return JSON.stringify({ type: 'user.message', timestamp: '2026-04-15T10:00:10Z', data: { content, interactionId: 'int-1' } })
}
function assistantMessage(opts: { messageId: string; outputTokens: number; tools?: string[]; timestamp?: string }) {
return JSON.stringify({
type: 'assistant.message',
timestamp: opts.timestamp ?? '2026-04-15T10:00:15Z',
data: {
messageId: opts.messageId,
outputTokens: opts.outputTokens,
interactionId: 'int-1',
toolRequests: (opts.tools ?? []).map(name => ({ name, toolCallId: `call-${name}`, type: 'function' })),
},
})
}
// A CLI session.shutdown rollup. `usage.inputTokens` is written cache-inclusive
// by the real CLI (input + cache_read + cache_write), matching the issue sample.
function shutdownEvent(opts: {
modelMetrics: Record<string, {
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
reasoningTokens?: number
}>
timestamp?: string
}) {
const modelMetrics: Record<string, unknown> = {}
for (const [model, u] of Object.entries(opts.modelMetrics)) {
modelMetrics[model] = {
requests: { count: 1, cost: 1 },
usage: {
inputTokens: u.inputTokens,
outputTokens: u.outputTokens,
cacheReadTokens: u.cacheReadTokens,
cacheWriteTokens: u.cacheWriteTokens,
reasoningTokens: u.reasoningTokens ?? 0,
},
}
}
return JSON.stringify({
type: 'session.shutdown',
timestamp: opts.timestamp ?? '2026-04-15T10:05:00Z',
data: { shutdownType: 'routine', sessionStartTime: 1784102040274, modelMetrics },
})
}
function transcriptSessionStart(sessionId: string) {
return JSON.stringify({ type: 'session.start', data: { sessionId, producer: 'copilot-agent' } })
}
function transcriptUserMessage(content: string) {
return JSON.stringify({ type: 'user.message', data: { content, attachments: [] } })
}
function transcriptAssistantMessage(opts: { messageId: string; content?: string; reasoningText?: string; toolCallIds?: string[]; toolNames?: string[] }) {
return JSON.stringify({
type: 'assistant.message',
data: {
messageId: opts.messageId,
content: opts.content ?? '',
reasoningText: opts.reasoningText ?? '',
toolRequests: (opts.toolCallIds ?? []).map((id, i) => ({
toolCallId: id,
name: opts.toolNames?.[i] ?? (i === 0 ? 'read_file' : 'run_in_terminal'),
type: 'function',
})),
},
})
}
function chatSessionSampleRequest(overrides: Record<string, unknown> = {}) {
return {
requestId: 'request_8c8ce017-6e3f-460a-9931-5a16825d231a',
modelId: 'copilot/claude-sonnet-4.6',
completionTokens: 490,
result: {
metadata: {
promptTokens: 32543,
outputTokens: 60,
resolvedModel: 'claude-sonnet-4-6',
toolCallRounds: [{ thinking: { tokens: 0 }, modelId: 'claude-sonnet-4.6' }],
agentId: 'github.copilot.editsAgent',
},
},
...overrides,
}
}
async function createChatSessionFile(filePath: string, entries: unknown[]) {
await writeFile(filePath, entries.map(entry => JSON.stringify(entry)).join('\n') + '\n')
}
async function collectCalls(source: { path: string; project: string; provider: string; sourceType?: string }, seenKeys = new Set<string>()) {
const calls: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, seenKeys).parse()) calls.push(call)
return calls
}
describe('copilot provider - JSONL parsing', () => {
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'copilot-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
it('parses a basic assistant message', async () => {
const eventsPath = await createSessionDir('sess-001', [
modelChange('gpt-4.1'),
userMessage('write a function'),
assistantMessage({ messageId: 'msg-1', outputTokens: 150 }),
])
const source = { path: eventsPath, project: 'myproject', provider: 'copilot' }
const calls: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
const call = calls[0]!
expect(call.provider).toBe('copilot')
expect(call.model).toBe('gpt-4.1')
expect(call.outputTokens).toBe(150)
expect(call.inputTokens).toBe(0)
expect(call.userMessage).toBe('write a function')
expect(call.sessionId).toBe('sess-001')
expect(call.bashCommands).toEqual([])
expect(call.costUSD).toBeGreaterThan(0)
})
it('tracks model changes mid-session', async () => {
const eventsPath = await createSessionDir('sess-002', [
modelChange('gpt-5-mini'),
userMessage('first'),
assistantMessage({ messageId: 'msg-1', outputTokens: 50, timestamp: '2026-04-15T10:00:10Z' }),
modelChange('gpt-4.1', 'gpt-5-mini'),
userMessage('second'),
assistantMessage({ messageId: 'msg-2', outputTokens: 80, timestamp: '2026-04-15T10:01:00Z' }),
])
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
const calls: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(2)
expect(calls[0]!.model).toBe('gpt-5-mini')
expect(calls[1]!.model).toBe('gpt-4.1')
})
it('extracts tool names from toolRequests', async () => {
const eventsPath = await createSessionDir('sess-003', [
modelChange('gpt-4.1'),
userMessage('run tests'),
assistantMessage({ messageId: 'msg-1', outputTokens: 60, tools: ['bash', 'read_file', 'write_file'] }),
])
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
const calls: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls[0]!.tools).toEqual(['Bash', 'Read', 'Edit'])
})
it('normalizes Copilot MCP tool names from toolRequests', async () => {
const eventsPath = await createSessionDir('sess-mcp-tools', [
modelChange('gpt-4.1'),
userMessage('list MCP-backed tasks and issues'),
assistantMessage({
messageId: 'msg-1',
outputTokens: 60,
tools: ['github-mcp-server-list_issues', 'cyberday-get_tasks', 'mempalace-mempalace_search', 'bash'],
}),
])
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
const calls: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls[0]!.tools).toEqual([
'mcp__github_mcp_server__list_issues',
'mcp__cyberday__get_tasks',
'mcp__mempalace__mempalace_search',
'Bash',
])
})
it('does not crash on malformed toolRequests (string / null / missing)', async () => {
// Regression guard: a corrupt session previously aborted the whole file's
// parse loop because .map was called on a non-array. The fix coerces any
// non-array shape (string, null, missing) to []. We mix one corrupt event
// between two healthy events and assert both healthy events still parse.
const corruptToolRequestsString = JSON.stringify({
type: 'assistant.message',
timestamp: '2026-04-15T10:00:15Z',
data: { messageId: 'corrupt-string', outputTokens: 50, toolRequests: 'not an array' },
})
const corruptToolRequestsNull = JSON.stringify({
type: 'assistant.message',
timestamp: '2026-04-15T10:00:16Z',
data: { messageId: 'corrupt-null', outputTokens: 50, toolRequests: null },
})
const eventsPath = await createSessionDir('sess-corrupt', [
modelChange('gpt-4.1'),
assistantMessage({ messageId: 'msg-before', outputTokens: 100 }),
corruptToolRequestsString,
corruptToolRequestsNull,
assistantMessage({ messageId: 'msg-after', outputTokens: 200 }),
])
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
const calls: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
// The healthy messages BEFORE and AFTER the corrupt events both parse —
// proving that the corrupt event no longer aborts the per-file parse loop.
// Pre-fix, .map on a non-array threw and we'd see < 4 calls.
expect(calls).toHaveLength(4)
expect(calls.find(c => c.outputTokens === 100)).toBeDefined() // msg-before
expect(calls.find(c => c.outputTokens === 200)).toBeDefined() // msg-after
// Corrupt events produce calls with empty tools, not crashes.
const corruptCalls = calls.filter(c => c.outputTokens === 50)
expect(corruptCalls.length).toBe(2)
for (const c of corruptCalls) {
expect(c.tools).toEqual([])
}
})
it('ignores malformed non-string tool names', async () => {
const malformedToolName = JSON.stringify({
type: 'assistant.message',
timestamp: '2026-04-15T10:00:15Z',
data: {
messageId: 'malformed-tool-name',
outputTokens: 50,
toolRequests: [null, { name: 123, toolCallId: 'call-bad', type: 'function' }],
},
})
const eventsPath = await createSessionDir('sess-malformed-tool-name', [
modelChange('gpt-4.1'),
malformedToolName,
assistantMessage({ messageId: 'msg-after', outputTokens: 100, tools: ['github-mcp-server-list_issues'] }),
])
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
const calls: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(2)
expect(calls[0]!.tools).toEqual([])
expect(calls[1]!.tools).toEqual(['mcp__github_mcp_server__list_issues'])
})
it('skips assistant messages with zero outputTokens', async () => {
const eventsPath = await createSessionDir('sess-004', [
modelChange('gpt-4.1'),
assistantMessage({ messageId: 'msg-empty', outputTokens: 0 }),
assistantMessage({ messageId: 'msg-real', outputTokens: 42 }),
])
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
const calls: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]!.outputTokens).toBe(42)
})
it('deduplicates messages across parser runs', async () => {
const eventsPath = await createSessionDir('sess-005', [
modelChange('gpt-4.1'),
assistantMessage({ messageId: 'msg-dup', outputTokens: 100 }),
])
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
const seenKeys = new Set<string>()
const calls1: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, seenKeys).parse()) calls1.push(call)
const calls2: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, seenKeys).parse()) calls2.push(call)
expect(calls1).toHaveLength(1)
expect(calls2).toHaveLength(0)
})
it('returns empty for missing file', async () => {
const source = { path: '/nonexistent/events.jsonl', project: 'test', provider: 'copilot' }
const calls: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(0)
})
it('skips assistant messages before the first model_change event', async () => {
const eventsPath = await createSessionDir('sess-no-model', [
assistantMessage({ messageId: 'msg-early', outputTokens: 50 }),
modelChange('gpt-4.1'),
assistantMessage({ messageId: 'msg-after', outputTokens: 80 }),
])
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
const calls: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]!.outputTokens).toBe(80)
expect(calls[0]!.model).toBe('gpt-4.1')
})
it('infers OpenAI auto bucket for transcript toolCallId prefix call_', async () => {
const eventsPath = await createSessionDir('sess-tr-call', [
transcriptSessionStart('sess-tr-call'),
transcriptUserMessage('check model inference'),
transcriptAssistantMessage({
messageId: 'msg-1',
content: 'done',
toolCallIds: ['call_abc123'],
}),
])
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
const calls: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]!.model).toBe('copilot-openai-auto')
})
it('infers Anthropic auto bucket for transcript toolCallId prefixes tooluse_/toolu_vrtx_', async () => {
const eventsPath = await createSessionDir('sess-tr-claude', [
transcriptSessionStart('sess-tr-claude'),
transcriptUserMessage('check model inference'),
transcriptAssistantMessage({
messageId: 'msg-1',
content: 'done',
toolCallIds: ['tooluse_XY', 'toolu_vrtx_01ABC'],
}),
])
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
const calls: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]!.model).toBe('copilot-anthropic-auto')
})
it('chooses the dominant inferred transcript model when prefixes are mixed', async () => {
const eventsPath = await createSessionDir('sess-tr-mixed', [
transcriptSessionStart('sess-tr-mixed'),
transcriptUserMessage('mixed'),
transcriptAssistantMessage({
messageId: 'msg-1',
content: 'one',
toolCallIds: ['toolu_bdrk_123'],
}),
transcriptAssistantMessage({
messageId: 'msg-2',
content: 'two',
toolCallIds: ['call_1'],
}),
transcriptAssistantMessage({
messageId: 'msg-3',
content: 'three',
toolCallIds: ['call_2'],
}),
])
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
const calls: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(3)
expect(calls.every(c => c.model === 'copilot-openai-auto')).toBe(true)
})
it('normalizes Copilot MCP tool names from VS Code transcripts', async () => {
const eventsPath = await createSessionDir('sess-tr-mcp-tools', [
transcriptSessionStart('sess-tr-mcp-tools'),
transcriptUserMessage('use GitHub MCP'),
transcriptAssistantMessage({
messageId: 'msg-1',
content: 'done',
toolCallIds: ['call_abc123', 'call_def456'],
toolNames: ['github-mcp-server-list_issues', 'read_file'],
}),
])
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
const calls: ParsedProviderCall[] = []
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]!.tools).toEqual(['mcp__github_mcp_server__list_issues', 'Read'])
})
})
describe('copilot provider - session.shutdown token/cost rollup', () => {
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'copilot-shutdown-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
it('reads input/cache tokens and measured cost from session.shutdown', async () => {
const eventsPath = await createSessionDir('sess-shutdown', [
modelChange('claude-sonnet-4-5'),
userMessage('do the thing'),
assistantMessage({ messageId: 'msg-1', outputTokens: 345 }),
shutdownEvent({
modelMetrics: {
// Real sample from issue #676: inputTokens is cache-inclusive
// (4 + 35495 + 35783 = 71282), so pure input is 4.
'claude-sonnet-4-5': {
inputTokens: 71282,
outputTokens: 345,
cacheReadTokens: 35495,
cacheWriteTokens: 35783,
reasoningTokens: 31,
},
},
}),
])
const source = { path: eventsPath, project: 'myproject', provider: 'copilot' }
const calls = await collectCalls(source)
// One per-turn assistant.message call + one supplementary shutdown call.
expect(calls).toHaveLength(2)
const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-shutdown:shutdown:claude-sonnet-4-5')
expect(shutdown).toBeDefined()
expect(shutdown!.model).toBe('claude-sonnet-4-5')
expect(shutdown!.inputTokens).toBe(4) // 71282 - 35495 - 35783
expect(shutdown!.cacheReadInputTokens).toBe(35495)
expect(shutdown!.cacheCreationInputTokens).toBe(35783)
expect(shutdown!.reasoningTokens).toBe(31)
expect(shutdown!.outputTokens).toBe(0) // owned by the per-turn event
expect(shutdown!.costIsEstimated).toBe(false) // measured, not char-estimated
const expectedShutdownCost = calculateCost('claude-sonnet-4-5', 4, 0, 35783, 35495, 0)
expect(shutdown!.costUSD).toBeCloseTo(expectedShutdownCost, 12)
expect(shutdown!.costUSD).toBeGreaterThan(0)
// No dimension double-counts: output stays with the per-turn event only,
// input/cache come only from the shutdown rollup.
const total = (k: keyof ParsedProviderCall) =>
calls.reduce((s, c) => s + (c[k] as number), 0)
expect(total('outputTokens')).toBe(345)
expect(total('inputTokens')).toBe(4)
expect(total('cacheReadInputTokens')).toBe(35495)
expect(total('cacheCreationInputTokens')).toBe(35783)
// Session cost = per-turn output cost + shutdown input/cache cost = full cost.
const perTurn = calls.find(c => c.deduplicationKey === 'copilot:sess-shutdown:msg-1')
expect(perTurn!.costUSD).toBeCloseTo(calculateCost('claude-sonnet-4-5', 0, 345, 0, 0, 0), 12)
const sessionCost = calls.reduce((s, c) => s + c.costUSD, 0)
const fullCost = calculateCost('claude-sonnet-4-5', 4, 345, 35783, 35495, 0)
expect(sessionCost).toBeCloseTo(fullCost, 12)
})
it('keeps output-only behavior when session.shutdown is absent', async () => {
const eventsPath = await createSessionDir('sess-no-shutdown', [
modelChange('claude-sonnet-4-5'),
userMessage('no shutdown here'),
assistantMessage({ messageId: 'msg-1', outputTokens: 150 }),
])
const source = { path: eventsPath, project: 'myproject', provider: 'copilot' }
const calls = await collectCalls(source)
expect(calls).toHaveLength(1)
const call = calls[0]!
expect(call.outputTokens).toBe(150)
expect(call.inputTokens).toBe(0)
expect(call.cacheReadInputTokens).toBe(0)
expect(call.cacheCreationInputTokens).toBe(0)
expect(call.costIsEstimated).toBeUndefined()
expect(call.costUSD).toBeCloseTo(calculateCost('claude-sonnet-4-5', 0, 150, 0, 0, 0), 12)
})
it('attributes shutdown tokens and cost per model', async () => {
const eventsPath = await createSessionDir('sess-multi', [
modelChange('claude-sonnet-4-5'),
userMessage('first'),
assistantMessage({ messageId: 'msg-1', outputTokens: 100 }),
modelChange('gpt-5', 'claude-sonnet-4-5'),
assistantMessage({ messageId: 'msg-2', outputTokens: 200 }),
shutdownEvent({
modelMetrics: {
'claude-sonnet-4-5': { inputTokens: 10100, outputTokens: 100, cacheReadTokens: 8000, cacheWriteTokens: 2000, reasoningTokens: 0 },
'gpt-5': { inputTokens: 5050, outputTokens: 200, cacheReadTokens: 5000, cacheWriteTokens: 0, reasoningTokens: 12 },
},
}),
])
const source = { path: eventsPath, project: 'myproject', provider: 'copilot' }
const calls = await collectCalls(source)
const shutdownCalls = calls.filter(c => c.deduplicationKey.includes(':shutdown:'))
expect(shutdownCalls).toHaveLength(2)
const sonnet = shutdownCalls.find(c => c.model === 'claude-sonnet-4-5')!
expect(sonnet.inputTokens).toBe(100) // 10100 - 8000 - 2000
expect(sonnet.cacheReadInputTokens).toBe(8000)
expect(sonnet.cacheCreationInputTokens).toBe(2000)
expect(sonnet.outputTokens).toBe(0)
expect(sonnet.costUSD).toBeCloseTo(calculateCost('claude-sonnet-4-5', 100, 0, 2000, 8000, 0), 12)
expect(sonnet.costUSD).toBeGreaterThan(0)
const gpt = shutdownCalls.find(c => c.model === 'gpt-5')!
expect(gpt.inputTokens).toBe(50) // 5050 - 5000 - 0
expect(gpt.cacheReadInputTokens).toBe(5000)
expect(gpt.cacheCreationInputTokens).toBe(0)
expect(gpt.reasoningTokens).toBe(12)
expect(gpt.costUSD).toBeCloseTo(calculateCost('gpt-5', 50, 0, 0, 5000, 0), 12)
})
it('keeps shutdown dedup keys stable across re-parses', async () => {
const eventsPath = await createSessionDir('sess-reparse', [
modelChange('claude-sonnet-4-5'),
assistantMessage({ messageId: 'msg-1', outputTokens: 345 }),
shutdownEvent({
modelMetrics: {
'claude-sonnet-4-5': { inputTokens: 71282, outputTokens: 345, cacheReadTokens: 35495, cacheWriteTokens: 35783, reasoningTokens: 31 },
},
}),
])
const source = { path: eventsPath, project: 'myproject', provider: 'copilot' }
const seen = new Set<string>()
const first = await collectCalls(source, seen)
expect(first).toHaveLength(2)
// Durable provider: re-parsing with the same seenKeys re-emits nothing.
const second = await collectCalls(source, seen)
expect(second).toHaveLength(0)
})
it('falls back to the last stamped event when shutdown carries no timestamp at all', async () => {
// A shutdown with neither its own timestamp nor sessionStartTime must not
// yield an empty-timestamp call: the date-range filters in parser.ts drop
// those silently, which would erase exactly the tokens this fix bills.
const bareShutdown = JSON.stringify({
type: 'session.shutdown',
data: {
shutdownType: 'routine',
modelMetrics: {
'claude-sonnet-4-5': {
requests: { count: 1, cost: 1 },
usage: { inputTokens: 9000, outputTokens: 300, cacheReadTokens: 4000, cacheWriteTokens: 1000, reasoningTokens: 0 },
},
},
},
})
const eventsPath = await createSessionDir('sess-no-ts', [
modelChange('claude-sonnet-4-5'),
assistantMessage({ messageId: 'msg-1', outputTokens: 300, timestamp: '2026-04-15T10:00:15Z' }),
bareShutdown,
])
const calls = await collectCalls({ path: eventsPath, project: 'myproject', provider: 'copilot' })
const shutdown = calls.find(c => c.deduplicationKey.includes(':shutdown:'))
expect(shutdown).toBeDefined()
expect(shutdown!.timestamp).toBe('2026-04-15T10:00:15Z')
})
it('ignores session.shutdown for VS Code transcript sessions', async () => {
const eventsPath = await createSessionDir('sess-tr-shutdown', [
transcriptSessionStart('sess-tr-shutdown'),
transcriptUserMessage('hi'),
transcriptAssistantMessage({ messageId: 'msg-1', content: 'done', toolCallIds: ['call_abc'] }),
shutdownEvent({
modelMetrics: {
'claude-sonnet-4-5': { inputTokens: 71282, outputTokens: 345, cacheReadTokens: 35495, cacheWriteTokens: 35783 },
},
}),
])
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
const calls = await collectCalls(source)
// Only the transcript assistant call; the shutdown rollup is CLI-only.
expect(calls).toHaveLength(1)
expect(calls.every(c => !c.deduplicationKey.includes(':shutdown:'))).toBe(true)
expect(calls[0]!.model).toBe('copilot-openai-auto')
})
})
describe('copilot provider - chatSessions parsing', () => {
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'copilot-chatsessions-test-'))
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1')
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('parses sample journal token counts and cost', async () => {
const filePath = join(tmpDir, 'sample.jsonl')
await createChatSessionFile(filePath, [
{ kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-session-1', requests: [] } },
{ kind: 2, k: ['requests'], v: [chatSessionSampleRequest()] },
])
const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' })
expect(calls).toHaveLength(1)
expect(calls[0]!.inputTokens).toBe(32543)
expect(calls[0]!.outputTokens).toBe(60)
expect(calls[0]!.model).toBe('claude-sonnet-4-6')
expect(calls[0]!.costUSD).toBeGreaterThan(0)
})
it('returns no calls for an empty reconstructed requests array', async () => {
const filePath = join(tmpDir, 'empty.jsonl')
await createChatSessionFile(filePath, [
{ kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-empty', requests: [] } },
])
const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' })
expect(calls).toHaveLength(0)
})
it('discovers and parses emptyWindowChatSessions from globalStorage', async () => {
const globalDir = join(tmpDir, 'globalStorage')
const emptyWindowDir = join(globalDir, 'emptyWindowChatSessions')
await mkdir(emptyWindowDir, { recursive: true })
const filePath = join(emptyWindowDir, 'empty-window.jsonl')
await createChatSessionFile(filePath, [
{ kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'empty-window-session', requests: [] } },
{ kind: 2, k: ['requests'], v: [chatSessionSampleRequest()] },
])
const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', globalDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.project).toBe('copilot-chat')
expect((sessions[0] as { sourceType?: string }).sourceType).toBe('chatsession')
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(sessions[0]!, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]!.inputTokens).toBe(32543)
})
it('skips chatSessions discovery when an OTel source is present', async () => {
if (!isSqliteAvailable()) return
vi.unstubAllEnvs()
const dbPath = join(tmpDir, 'agent-traces.db')
vi.stubEnv('CODEBURN_COPILOT_OTEL_DB', dbPath)
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '')
createOtelDb(dbPath)
insertSpan(dbPath, {
spanId: 'span-chatsession-skip',
traceId: 'trace-chatsession-skip',
operationName: 'chat',
startTimeMs: 1000,
attrs: {
'gen_ai.conversation.id': 'conv-chatsession-skip',
'gen_ai.response.model': 'gpt-4.1',
'gen_ai.usage.input_tokens': 100,
'gen_ai.usage.output_tokens': 10,
},
})
const wsDir = join(tmpDir, 'vscode-ws')
const hashDir = join(wsDir, 'abc123')
const workspaceChatSessionsDir = join(hashDir, 'chatSessions')
const globalDir = join(tmpDir, 'globalStorage')
const emptyWindowDir = join(globalDir, 'emptyWindowChatSessions')
await mkdir(workspaceChatSessionsDir, { recursive: true })
await mkdir(emptyWindowDir, { recursive: true })
await writeFile(join(hashDir, 'workspace.json'), JSON.stringify({ folder: 'file:///home/user/myapp' }))
await createChatSessionFile(join(workspaceChatSessionsDir, 'workspace.jsonl'), [
{ kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-workspace', requests: [] } },
{ kind: 2, k: ['requests'], v: [chatSessionSampleRequest()] },
])
await createChatSessionFile(join(emptyWindowDir, 'empty-window.jsonl'), [
{ kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-empty-window', requests: [] } },
{ kind: 2, k: ['requests'], v: [chatSessionSampleRequest({ requestId: 'request-empty-window' })] },
])
const provider = createCopilotProvider('/nonexistent/legacy', wsDir, globalDir)
const sources = await provider.discoverSessions()
expect(sources.filter(s => (s as { sourceType?: string }).sourceType === 'otel')).toHaveLength(1)
expect(sources.filter(s => (s as { sourceType?: string }).sourceType === 'chatsession')).toHaveLength(0)
})
it('applies append-then-edit journal updates', async () => {
const filePath = join(tmpDir, 'append-edit.jsonl')
await createChatSessionFile(filePath, [
{ kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-edit', requests: [] } },
{ kind: 2, k: ['requests'], v: [chatSessionSampleRequest()] },
{ kind: 1, k: ['requests', 0, 'result', 'metadata', 'outputTokens'], v: 88 },
])
const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' })
expect(calls).toHaveLength(1)
expect(calls[0]!.outputTokens).toBe(88)
})
it('deduplicates by requestId across parser runs', async () => {
const filePath = join(tmpDir, 'dedupe.jsonl')
await createChatSessionFile(filePath, [
{ kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-dedupe', requests: [] } },
{ kind: 2, v: [chatSessionSampleRequest()] },
])
const source = { path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' }
const seenKeys = new Set<string>()
const calls1 = await collectCalls(source, seenKeys)
const calls2 = await collectCalls(source, seenKeys)
expect(calls1).toHaveLength(1)
expect(calls2).toHaveLength(0)
})
it('ignores prototype-pollution journal paths without crashing', async () => {
const filePath = join(tmpDir, 'proto.jsonl')
await createChatSessionFile(filePath, [
{ kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-proto', requests: [] } },
{ kind: 1, k: ['__proto__', 'polluted'], v: true },
{ kind: 1, k: ['constructor', 'prototype', 'polluted'], v: true },
{ kind: 2, k: ['requests'], v: [chatSessionSampleRequest()] },
])
expect(({} as { polluted?: unknown }).polluted).toBeUndefined()
const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' })
expect(calls).toHaveLength(1)
expect(({} as { polluted?: unknown }).polluted).toBeUndefined()
})
it('skips legacy transcripts for a workspace hash that has chatSessions', async () => {
const wsDir = join(tmpDir, 'vscode-ws')
const hashDir = join(wsDir, 'abc123')
const chatSessionsDir = join(hashDir, 'chatSessions')
const transcriptsDir = join(hashDir, 'GitHub.copilot-chat', 'transcripts')
await mkdir(chatSessionsDir, { recursive: true })
await mkdir(transcriptsDir, { recursive: true })
await writeFile(join(hashDir, 'workspace.json'), JSON.stringify({ folder: 'file:///home/user/myapp' }))
await createChatSessionFile(join(chatSessionsDir, 'chat.jsonl'), [
{ kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-modern', requests: [] } },
{ kind: 2, k: ['requests'], v: [chatSessionSampleRequest()] },
])
await writeFile(join(transcriptsDir, 'legacy.jsonl'), transcriptSessionStart('legacy') + '\n')
const provider = createCopilotProvider('/nonexistent/legacy', wsDir, '/nonexistent/global')
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect((sessions[0] as { sourceType?: string }).sourceType).toBe('chatsession')
expect(sessions[0]!.path).toContain(`${join('abc123', 'chatSessions')}`)
})
})
describe('copilot provider - discoverSessions', () => {
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'copilot-test-'))
// Disable OTel discovery so tests aren't contaminated by real sessions
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1')
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('discovers sessions from directory', async () => {
await createSessionDir('sess-disc-001', [modelChange('gpt-4.1')])
await createSessionDir('sess-disc-002', [modelChange('gpt-4.1')])
const provider = createCopilotProvider(tmpDir, '/nonexistent/vscode')
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(2)
expect(sessions.every(s => s.provider === 'copilot')).toBe(true)
expect(sessions.every(s => s.path.endsWith('events.jsonl'))).toBe(true)
})
it('reads project name from workspace.yaml cwd', async () => {
await createSessionDir('sess-disc-003', [modelChange('gpt-4.1')], '/home/user/myapp')
const provider = createCopilotProvider(tmpDir, '/nonexistent/vscode')
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.project).toBe('myapp')
})
it('strips quotes and trailing comments from workspace.yaml cwd', async () => {
const sessionDir = join(tmpDir, 'sess-quoted')
await mkdir(sessionDir, { recursive: true })
await writeFile(join(sessionDir, 'workspace.yaml'), 'cwd: "/home/user/myapp" # project root\n')
await writeFile(join(sessionDir, 'events.jsonl'), '\n')
const provider = createCopilotProvider(tmpDir, '/nonexistent/vscode')
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.project).toBe('myapp')
})
it('returns empty when directory does not exist', async () => {
const provider = createCopilotProvider('/nonexistent/path', '/nonexistent/vscode')
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(0)
})
it('skips entries without events.jsonl', async () => {
const emptyDir = join(tmpDir, 'empty-session')
await mkdir(emptyDir, { recursive: true })
const provider = createCopilotProvider(tmpDir, '/nonexistent/vscode')
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(0)
})
it('discovers VS Code workspace transcripts', async () => {
const wsDir = join(tmpDir, 'vscode-ws')
const transcriptsDir = join(wsDir, 'abc123', 'GitHub.copilot-chat', 'transcripts')
await mkdir(transcriptsDir, { recursive: true })
await writeFile(join(wsDir, 'abc123', 'workspace.json'), JSON.stringify({ folder: 'file:///home/user/myapp' }))
await writeFile(join(transcriptsDir, 'session-1.jsonl'), JSON.stringify({ type: 'session.start', data: { sessionId: 's1', producer: 'copilot-agent' } }) + '\n')
const provider = createCopilotProvider('/nonexistent/legacy', wsDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.project).toBe('myapp')
expect(sessions[0]!.path).toContain('session-1.jsonl')
})
it('includes VSCodium workspaceStorage paths on all supported platforms', () => {
expect(getVSCodeWorkspaceStorageDirs('/Users/test', 'darwin')).toContain(
posix.join('/Users/test', 'Library', 'Application Support', 'VSCodium', 'User', 'workspaceStorage'),
)
expect(getVSCodeWorkspaceStorageDirs('C:\\Users\\test', 'win32')).toContain(
win32.join('C:\\Users\\test', 'AppData', 'Roaming', 'VSCodium', 'User', 'workspaceStorage'),
)
expect(getVSCodeWorkspaceStorageDirs('/home/test', 'linux')).toContain(
posix.join('/home/test', '.config', 'VSCodium', 'User', 'workspaceStorage'),
)
})
it('includes VSCodium globalStorage paths on all supported platforms', () => {
expect(getVSCodeGlobalStorageDirs('/Users/test', 'darwin')).toContain(
posix.join('/Users/test', 'Library', 'Application Support', 'VSCodium', 'User', 'globalStorage'),
)
expect(getVSCodeGlobalStorageDirs('C:\\Users\\test', 'win32')).toContain(
win32.join('C:\\Users\\test', 'AppData', 'Roaming', 'VSCodium', 'User', 'globalStorage'),
)
expect(getVSCodeGlobalStorageDirs('/home/test', 'linux')).toContain(
posix.join('/home/test', '.config', 'VSCodium', 'User', 'globalStorage'),
)
})
})
describe('copilot provider - metadata', () => {
it('has correct name and displayName', () => {
expect(copilot.name).toBe('copilot')
expect(copilot.displayName).toBe('Copilot')
})
it('normalizes tool display names', () => {
expect(copilot.toolDisplayName('bash')).toBe('Bash')
expect(copilot.toolDisplayName('read_file')).toBe('Read')
expect(copilot.toolDisplayName('write_file')).toBe('Edit')
expect(copilot.toolDisplayName('web_search')).toBe('WebSearch')
expect(copilot.toolDisplayName('github-mcp-server-list_issues')).toBe('mcp__github_mcp_server__list_issues')
expect(copilot.toolDisplayName('unknown_tool')).toBe('unknown_tool')
})
it('normalizes model display names', () => {
expect(copilot.modelDisplayName('gpt-4.1')).toBe('GPT-4.1')
expect(copilot.modelDisplayName('gpt-4.1-mini')).toBe('GPT-4.1 Mini')
expect(copilot.modelDisplayName('gpt-4.1-nano')).toBe('GPT-4.1 Nano')
expect(copilot.modelDisplayName('gpt-5-mini')).toBe('GPT-5 Mini')
expect(copilot.modelDisplayName('o3')).toBe('o3')
expect(copilot.modelDisplayName('o4-mini')).toBe('o4-mini')
expect(copilot.modelDisplayName('copilot-openai-auto')).toBe('Copilot (OpenAI auto)')
expect(copilot.modelDisplayName('copilot-anthropic-auto')).toBe('Copilot (Anthropic auto)')
expect(copilot.modelDisplayName('unknown-model-xyz')).toBe('unknown-model-xyz')
})
it('longest-prefix match wins for versioned model IDs', () => {
// gpt-5-mini-2026-01-01 must match gpt-5-mini, not gpt-5
expect(copilot.modelDisplayName('gpt-5-mini-2026-01-01')).toBe('GPT-5 Mini')
expect(copilot.modelDisplayName('gpt-4.1-mini-2026-01-01')).toBe('GPT-4.1 Mini')
})
})
// ---------------------------------------------------------------------------
// OTel cache token tests
//
// These tests verify that the OTel SQLite parser correctly extracts
// cacheReadInputTokens and cacheCreationInputTokens from the agent-traces.db
// schema, and that multiple conversations from the same DB file are each
// parsed independently with their full cache token data intact.
//
// This is the regression guard for the bug documented in DEBUG_HANDOFF.md:
// cache tokens were extracted during parsing but lost in aggregation because
// all conversations shared the same file path key in the session cache.
// ---------------------------------------------------------------------------
const requireForTest = createRequire(import.meta.url)
type TestDb = {
exec(sql: string): void
prepare(sql: string): { run(...params: unknown[]): void }
close(): void
}
/** Creates a minimal agent-traces.db schema matching the VS Code Copilot Chat OTel store. */
function createOtelDb(dbPath: string): void {
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (path: string) => TestDb }
const db = new DatabaseSync(dbPath)
db.exec(`
CREATE TABLE spans (
span_id TEXT PRIMARY KEY NOT NULL,
trace_id TEXT NOT NULL,
operation_name TEXT,
start_time_ms INTEGER NOT NULL DEFAULT 0,
response_model TEXT
);
CREATE TABLE span_attributes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
span_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT
);
`)
db.close()
}
interface SpanDef {
spanId: string
traceId: string
operationName: string
startTimeMs?: number
responseModel?: string
attrs: Record<string, string | number>
}
function insertSpan(dbPath: string, span: SpanDef): void {
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (path: string) => TestDb }
const db = new DatabaseSync(dbPath)
db.prepare(
`INSERT INTO spans (span_id, trace_id, operation_name, start_time_ms, response_model)
VALUES (?, ?, ?, ?, ?)`
).run(span.spanId, span.traceId, span.operationName, span.startTimeMs ?? 0, span.responseModel ?? null)
const attrStmt = db.prepare(
`INSERT INTO span_attributes (span_id, key, value) VALUES (?, ?, ?)`
)
for (const [key, value] of Object.entries(span.attrs)) {
attrStmt.run(span.spanId, key, String(value))
}
db.close()
}
describe('copilot provider - OTel cache token parsing', () => {
let tmpDir: string
let dbPath: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'copilot-otel-test-'))
dbPath = join(tmpDir, 'agent-traces.db')
vi.stubEnv('CODEBURN_COPILOT_OTEL_DB', dbPath)
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '')
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('skips tests when node:sqlite is unavailable', () => {
if (!isSqliteAvailable()) return
// Placeholder — subsequent tests use isSqliteAvailable guard
})
it('extracts cache tokens from a single OTel conversation', async () => {
if (!isSqliteAvailable()) return
createOtelDb(dbPath)
insertSpan(dbPath, {
spanId: 'span-001',
traceId: 'trace-001',
operationName: 'chat',
startTimeMs: 1000,
responseModel: 'gpt-4.1',
attrs: {
'gen_ai.conversation.id': 'conv-001',
'gen_ai.response.model': 'gpt-4.1',
'gen_ai.usage.input_tokens': 1000,
'gen_ai.usage.output_tokens': 200,
'gen_ai.usage.cache_read.input_tokens': 50000,
'gen_ai.usage.cache_creation.input_tokens': 500,
},
})
const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws')
const sources = await provider.discoverSessions()
const otelSources = sources.filter(s => s.path.startsWith(dbPath))
expect(otelSources).toHaveLength(1)
expect(otelSources[0]!.provider).toBe('copilot')
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(otelSources[0]!, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
const call = calls[0]!
expect(call.model).toBe('gpt-4.1')
expect(call.inputTokens).toBe(1000)
expect(call.outputTokens).toBe(200)
expect(call.cacheReadInputTokens).toBe(50000)
expect(call.cacheCreationInputTokens).toBe(500)
expect(call.costUSD).toBeGreaterThan(0)
})
it('discovers one source per OTel DB file (not per conversation)', async () => {
if (!isSqliteAvailable()) return
createOtelDb(dbPath)
// Two independent conversations in the same DB
insertSpan(dbPath, {
spanId: 'span-a1', traceId: 'trace-a', operationName: 'chat', startTimeMs: 1000,
attrs: {
'gen_ai.conversation.id': 'conv-alpha',
'gen_ai.response.model': 'gpt-4.1',
'gen_ai.usage.input_tokens': 800,
'gen_ai.usage.output_tokens': 100,
'gen_ai.usage.cache_read.input_tokens': 40000,
'gen_ai.usage.cache_creation.input_tokens': 400,
},
})
insertSpan(dbPath, {
spanId: 'span-b1', traceId: 'trace-b', operationName: 'chat', startTimeMs: 2000,
attrs: {
'gen_ai.conversation.id': 'conv-beta',
'gen_ai.response.model': 'claude-sonnet-4',
'gen_ai.usage.input_tokens': 600,
'gen_ai.usage.output_tokens': 80,
'gen_ai.usage.cache_read.input_tokens': 30000,
'gen_ai.usage.cache_creation.input_tokens': 300,
},
})
const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws')
const sources = await provider.discoverSessions()
// One source per DB file (not per conversation)
const otelSources = sources.filter(s => s.path === dbPath)
expect(otelSources).toHaveLength(1)
expect(otelSources[0]!.path).toBe(dbPath)
// But the parser still yields calls from BOTH conversations
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(otelSources[0]!, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(2)
const sessionIds = new Set(calls.map(c => c.sessionId))
expect(sessionIds.size).toBe(2)
})
it('preserves cache tokens when parsing multiple conversations from one DB', async () => {
if (!isSqliteAvailable()) return
createOtelDb(dbPath)
insertSpan(dbPath, {
spanId: 'span-c1', traceId: 'trace-c', operationName: 'chat', startTimeMs: 1000,
attrs: {
'gen_ai.conversation.id': 'conv-c',
'gen_ai.response.model': 'gpt-4.1',
'gen_ai.usage.input_tokens': 500,
'gen_ai.usage.output_tokens': 100,
'gen_ai.usage.cache_read.input_tokens': 20000,
'gen_ai.usage.cache_creation.input_tokens': 200,
},
})
insertSpan(dbPath, {
spanId: 'span-d1', traceId: 'trace-d', operationName: 'chat', startTimeMs: 2000,
attrs: {
'gen_ai.conversation.id': 'conv-d',
'gen_ai.response.model': 'gpt-4.1',
'gen_ai.usage.input_tokens': 700,
'gen_ai.usage.output_tokens': 150,
'gen_ai.usage.cache_read.input_tokens': 35000,
'gen_ai.usage.cache_creation.input_tokens': 350,
},
})
const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws')
const sources = await provider.discoverSessions()
// One source per DB file — the parser iterates all conversations internally
const otelSource = sources.find(s => s.path === dbPath)
expect(otelSource).toBeDefined()
const allCalls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(otelSource!, new Set()).parse()) {
allCalls.push(call)
}
expect(allCalls).toHaveLength(2)
const totalCacheRead = allCalls.reduce((sum, c) => sum + c.cacheReadInputTokens, 0)
const totalCacheCreate = allCalls.reduce((sum, c) => sum + c.cacheCreationInputTokens, 0)
// Both conversations' cache tokens must survive end-to-end
expect(totalCacheRead).toBe(55000) // 20000 + 35000
expect(totalCacheCreate).toBe(550) // 200 + 350
})
it('includes tool names from execute_tool spans in the same trace', async () => {
if (!isSqliteAvailable()) return
createOtelDb(dbPath)
// chat span
insertSpan(dbPath, {
spanId: 'span-e1', traceId: 'trace-e', operationName: 'chat', startTimeMs: 1000,
attrs: {
'gen_ai.conversation.id': 'conv-e',
'gen_ai.response.model': 'gpt-4.1',
'gen_ai.usage.input_tokens': 300,
'gen_ai.usage.output_tokens': 50,
'gen_ai.usage.cache_read.input_tokens': 10000,
'gen_ai.usage.cache_creation.input_tokens': 100,
},
})
// execute_tool span in the same trace
insertSpan(dbPath, {
spanId: 'span-e2', traceId: 'trace-e', operationName: 'execute_tool', startTimeMs: 1500,
attrs: {
'gen_ai.tool.name': 'readFile',
},
})
const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws')
const sources = await provider.discoverSessions()
const src = sources.find(s => s.path.startsWith(dbPath))
expect(src).toBeDefined()
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(src!, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
expect(calls[0]!.tools).toContain('Read')
expect(calls[0]!.cacheReadInputTokens).toBe(10000)
})
it('skips OTel spans with zero input and output tokens', async () => {
if (!isSqliteAvailable()) return
createOtelDb(dbPath)
insertSpan(dbPath, {
spanId: 'span-f1', traceId: 'trace-f', operationName: 'chat', startTimeMs: 1000,
attrs: {
'gen_ai.conversation.id': 'conv-f',
'gen_ai.response.model': 'gpt-4.1',
'gen_ai.usage.input_tokens': 0,
'gen_ai.usage.output_tokens': 0,
'gen_ai.usage.cache_read.input_tokens': 50000,
'gen_ai.usage.cache_creation.input_tokens': 500,
},
})
const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws')
const sources = await provider.discoverSessions()
const src = sources.find(s => s.path.startsWith(dbPath))
expect(src).toBeDefined()
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(src!, new Set()).parse()) {
calls.push(call)
}
// Span with zero input AND output tokens is skipped
expect(calls).toHaveLength(0)
})
it('OTel source path equals the plain DB file path and durableSources is true', async () => {
if (!isSqliteAvailable()) return
createOtelDb(dbPath)
insertSpan(dbPath, {
spanId: 'span-g1', traceId: 'trace-g', operationName: 'chat', startTimeMs: 1000,
attrs: {
'gen_ai.conversation.id': 'conv-g',
'gen_ai.response.model': 'gpt-4.1',
'gen_ai.usage.input_tokens': 100,
'gen_ai.usage.output_tokens': 10,
'gen_ai.usage.cache_read.input_tokens': 0,
'gen_ai.usage.cache_creation.input_tokens': 0,
},
})
const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws')
// durableSources must be true on the copilot provider
expect(provider.durableSources).toBe(true)
const sources = await provider.discoverSessions()
const otelSrc = sources.find(s => s.path.startsWith(dbPath))
expect(otelSrc).toBeDefined()
// Path is the plain DB file path (no #otel-conv= compound suffix)
expect(otelSrc!.path).toBe(dbPath)
// Parser must open the DB and produce results for all conversations
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(otelSrc!, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
expect(calls[0]!.inputTokens).toBe(100)
})
it('attributes genuine subagents but excludes the root agent', async () => {
if (!isSqliteAvailable()) return
createOtelDb(dbPath)
// Root agent turn: chat span + invoke_agent WITHOUT a parent session.
insertSpan(dbPath, {
spanId: 'span-root-chat', traceId: 'trace-root', operationName: 'chat', startTimeMs: 1000,
attrs: {
'gen_ai.conversation.id': 'conv-h',
'gen_ai.response.model': 'gpt-4.1',
'gen_ai.usage.input_tokens': 400,
'gen_ai.usage.output_tokens': 60,
'gen_ai.usage.cache_read.input_tokens': 0,
'gen_ai.usage.cache_creation.input_tokens': 0,
},
})
insertSpan(dbPath, {
spanId: 'span-root-agent', traceId: 'trace-root', operationName: 'invoke_agent', startTimeMs: 1010,
attrs: {
'gen_ai.conversation.id': 'conv-h',
'gen_ai.agent.name': 'GitHub Copilot Chat',
},
})
// Genuine subagent: its own trace holds the subagent's chat span plus an
// invoke_agent span carrying copilot_chat.parent_chat_session_id.
insertSpan(dbPath, {
spanId: 'span-sub-chat', traceId: 'trace-sub', operationName: 'chat', startTimeMs: 2000,
attrs: {
'gen_ai.conversation.id': 'conv-h',
'gen_ai.response.model': 'claude-haiku-4.5',
'gen_ai.usage.input_tokens': 250,
'gen_ai.usage.output_tokens': 30,
'gen_ai.usage.cache_read.input_tokens': 0,
'gen_ai.usage.cache_creation.input_tokens': 0,
},
})
insertSpan(dbPath, {
spanId: 'span-sub-agent', traceId: 'trace-sub', operationName: 'invoke_agent', startTimeMs: 2010,
attrs: {
'gen_ai.conversation.id': 'conv-h',
'gen_ai.agent.name': 'Explore',
'copilot_chat.parent_chat_session_id': 'conv-h',
},
})
const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws')
const sources = await provider.discoverSessions()
const src = sources.find(s => s.path.startsWith(dbPath))
expect(src).toBeDefined()
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(src!, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(2)
const rootCall = calls.find(c => c.model === 'gpt-4.1')!
const subCall = calls.find(c => c.model === 'claude-haiku-4.5')!
// Root agent must NOT surface as a subagent
expect(rootCall.subagentTypes ?? []).not.toContain('GitHub Copilot Chat')
expect(rootCall.subagentTypes ?? []).toHaveLength(0)
// Genuine subagent is attributed to its own call
expect(subCall.subagentTypes).toEqual(['Explore'])
})
it('normalises multi-line OTel shell scripts, dropping control-flow keywords', async () => {
if (!isSqliteAvailable()) return
createOtelDb(dbPath)
insertSpan(dbPath, {
spanId: 'span-sh-chat', traceId: 'trace-sh', operationName: 'chat', startTimeMs: 1000,
attrs: {
'gen_ai.conversation.id': 'conv-sh',
'gen_ai.response.model': 'gpt-4.1',
'gen_ai.usage.input_tokens': 100,
'gen_ai.usage.output_tokens': 10,
'gen_ai.usage.cache_read.input_tokens': 0,
'gen_ai.usage.cache_creation.input_tokens': 0,
},
})
// A full multi-line script with control flow and newline-separated commands,
// exactly as the OTel store records it.
insertSpan(dbPath, {
spanId: 'span-sh-tool', traceId: 'trace-sh', operationName: 'execute_tool', startTimeMs: 1500,
attrs: {
'gen_ai.tool.name': 'run_in_terminal',
'gen_ai.tool.call.arguments': JSON.stringify({
command: 'for f in *.ts; do\n echo "$f"\ndone\ngit status\nnpm test',
}),
},
})
const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws')
const sources = await provider.discoverSessions()
const src = sources.find(s => s.path.startsWith(dbPath))
expect(src).toBeDefined()
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(src!, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
const bash = calls[0]!.bashCommands
// Real commands separated by newlines/`;` are captured
expect(bash).toEqual(expect.arrayContaining(['echo', 'git', 'npm']))
// Control-flow keywords are NOT reported as commands
for (const kw of ['for', 'do', 'done']) {
expect(bash).not.toContain(kw)
}
})
})
// ---------------------------------------------------------------------------
// JetBrains (IntelliJ / PyCharm / …) session parsing
// ---------------------------------------------------------------------------
//
// The JetBrains Copilot plugin persists sessions to a Nitrite (H2 MVStore) .db
// (~/.config/github-copilot/<ide>/<kind>/<storeId>/copilot-*-nitrite.db) of
// Java-serialized documents. Assistant replies are nested-escaped
// {"__first__":{"type":"Subgraph",…}} blobs; the model and projectName are
// separate serialized fields. These helpers reproduce that on-disk shape so
// tests exercise the real regex/scan extraction path.
describe('copilot provider - JetBrains parsing', () => {
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'copilot-jetbrains-test-'))
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1')
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
vi.unstubAllEnvs()
})
// A JetBrains source: session content lives in the Nitrite .db.
function jbDbSource(path: string, sessionId: string, mtime = '2026-07-03T12:00:00.000Z') {
return {
path, project: 'copilot-jetbrains', provider: 'copilot', sourceType: 'jetbrains',
sessionId, storeId: sessionId, dbPath: path, mtime,
} as unknown as { path: string; project: string; provider: string; sourceType?: string }
}
// ---- Nitrite .db parsing ----
// Build an assistant response blob in the real nested-escaped shape:
// {"__first__":{"type":"Subgraph","value":"{\"<uuid>\":{\"type\":\"Value\",
// \"value\":\"{\\\"type\\\":\\\"Markdown\\\",\\\"data\\\":\\\"{\\\\\\\"text\\\\\\\":...}\"}"}}
function jbAssistantBlob(text: string, opts: { model?: string; errored?: boolean; files?: string[] } = {}) {
const innerMd = { type: 'Markdown', data: JSON.stringify({ text, annotations: [] }) }
const valueMap: Record<string, unknown> = {
'a1b2c3d4-0000-0000-0000-000000000001': { type: 'Value', value: JSON.stringify(innerMd) },
}
if (opts.model) valueMap['__model__'] = { type: 'Value', value: `{"model":"${opts.model}"}` }
// Files the turn referenced — project is derived from these file:// paths.
if (opts.files) {
valueMap['__refs__'] = {
type: 'Value',
value: JSON.stringify({ type: 'References', data: opts.files.map((f) => `file://${f}`).join(' ') }),
}
}
const outer: Record<string, unknown> = {
__first__: { type: 'Subgraph', value: JSON.stringify(valueMap) },
}
if (opts.errored) {
// Real failed turns store the error under a type:"Error" record with a
// `message` field (NOT a Markdown `text`), so it is not billable output.
outer['__err__'] = {
type: 'Value',
value: JSON.stringify({ type: 'Error', message: 'Sorry, an error occurred while generating a response' }),
}
}
return JSON.stringify(outer)
}
// An AGENT-MODE assistant blob: the reply lives in an AgentRound record, and
// (as in real agent sessions) the Markdown record holds the USER's prompt,
// which must NOT be counted as the reply. `rounds` is a list of AgentRound
// replies (a single blob can carry several); a pure tool-call round has ''.
function jbAgentBlob(rounds: string[], opts: { model?: string; userPrompt?: string; errored?: boolean } = {}) {
const valueMap: Record<string, unknown> = {}
let n = 0
// The user prompt as a Markdown record — a decoy the reply extractor must
// skip in agent mode (real stores put the prompt here, not the answer).
if (opts.userPrompt !== undefined) {
const md = { type: 'Markdown', data: JSON.stringify({ text: opts.userPrompt, annotations: [] }) }
valueMap[`u0000000-0000-0000-0000-00000000000${n++}`] = { type: 'Value', value: JSON.stringify(md) }
}
for (const reply of rounds) {
const ar = { type: 'AgentRound', data: JSON.stringify({ roundId: n, reply, toolCalls: [] }) }
valueMap[`a0000000-0000-0000-0000-00000000000${n++}`] = { type: 'Value', value: JSON.stringify(ar) }
}
if (opts.model) valueMap['__model__'] = { type: 'Value', value: `{"model":"${opts.model}"}` }
const outer: Record<string, unknown> = { __first__: { type: 'Subgraph', value: JSON.stringify(valueMap) } }
if (opts.errored) {
outer['__err__'] = {
type: 'Value',
value: JSON.stringify({ type: 'Error', message: 'Sorry, an error occurred while generating a response' }),
}
}
return JSON.stringify(outer)
}
// A conversation title record in the real framing: `$<GUID>…name…value<TITLE>t\x00\x06source`.
function jbConversationRecord(guid: string, title: string) {
return `$${guid}t\x00\x04namesq\x00\x01?@\x00\x00w\x00\x00t\x00value t\x00${title}t\x00\x06sourcet\x00copilotx`
}
// Assemble a minimal Nitrite-.db-shaped buffer: MVStore header + entity-class
// anchor + optional conversation records + assistant blobs. When a blob is
// preceded by a conversation record, turns attribute to that conversation.
function jbDbContent(blobs: string[], conversations: string[] = []) {
return (
'H:2,block:9,blockSize:1000,format:3\n' +
'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' +
conversations.join('\n') + '\n' +
blobs.join('\nt\x00\x00model\n') +
'\n'
)
}
async function createJetBrainsDb(root: string, ide: string, kind: string, storeId: string, content: string) {
const dir = join(root, ide, kind, storeId)
await mkdir(dir, { recursive: true })
const dbName =
kind === 'chat-agent-sessions'
? 'copilot-agent-sessions-nitrite.db'
: kind === 'chat-edit-sessions'
? 'copilot-edit-sessions-nitrite.db'
: 'copilot-chat-nitrite.db'
await writeFile(join(dir, dbName), content)
return join(dir, dbName)
}
// The plugin-recorded project label, in the real Java-serialized framing:
// the field key `projectName` followed by TC_STRING `0x74 <u16 len> <value>`,
// then the sibling `user` field. This is what extractJetBrainsProjectName reads.
function jbProjectNameField(name: string) {
// TC_STRING length is the UTF-8 BYTE count (the .db is written UTF-8 and
// read back as latin1), not the JS UTF-16 code-unit count.
const len = Buffer.byteLength(name, 'utf8')
const hi = String.fromCharCode((len >> 8) & 0xff)
const lo = String.fromCharCode(len & 0xff)
return `t\x00\x0bprojectName\x74${hi}${lo}${name}t\x00\x04usert\x00\x08dev-user`
}
it('parses assistant turns from a Nitrite .db and estimates cost', async () => {
const content = jbDbContent([
jbAssistantBlob('Hello! How can I help you today?'),
jbAssistantBlob('Here is a longer architecture overview with plenty of detail.', { model: 'claude-opus-4.5' }),
])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-1', content)
const calls = await collectCalls(jbDbSource(dbPath, 'conv-1'))
expect(calls).toHaveLength(2)
expect(calls[0]!.outputTokens).toBeGreaterThan(0)
expect(calls[0]!.costIsEstimated).toBe(true)
expect(calls[0]!.inputTokens).toBe(0)
// Per-turn model recovered from inside the blob, normalised dots→dashes.
expect(calls[1]!.model).toBe('claude-opus-4-5')
expect(calls[1]!.costUSD).toBeGreaterThan(0)
// Dedup keys are conversation-scoped, content-derived, and distinct.
expect(calls[0]!.deduplicationKey).toMatch(/^copilot:jb:conv-1:[0-9a-f]{12}:1$/)
expect(calls[1]!.deduplicationKey).toMatch(/^copilot:jb:conv-1:[0-9a-f]{12}:1$/)
expect(calls[0]!.deduplicationKey).not.toBe(calls[1]!.deduplicationKey)
})
it('recovers a reply containing quotes without garbling or duplicating it', async () => {
// Regression: the unescape loop must run extraction ONLY on the final,
// fully-unescaped form. Accumulating matches at every depth would union a
// half-unescaped (quote-truncated) capture with the full one, producing a
// garbled duplicate and inflating the token/cost estimate.
const reply = 'Use `printf "%s"` to print, then check "status" here.'
const content = jbDbContent([jbAssistantBlob(reply, { model: 'claude-opus-4.5' })])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-quote', content)
const calls = await collectCalls(jbDbSource(dbPath, 'conv-quote'))
expect(calls).toHaveLength(1)
// Token estimate reflects the true reply length (CHARS_PER_TOKEN = 4), not
// an inflated garbled copy.
expect(calls[0]!.outputTokens).toBe(Math.ceil(reply.length / 4))
})
it('counts a multibyte UTF-8 reply by codepoints, not latin1 bytes', async () => {
// The .db is read as latin1; the parser must re-decode to UTF-8 so a
// multibyte char counts as one codepoint for the token estimate.
const reply = 'café ☕ déjà vu — naïve façade' // several multibyte chars
const content = jbDbContent([jbAssistantBlob(reply, { model: 'claude-opus-4.5' })])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-utf8', content)
const calls = await collectCalls(jbDbSource(dbPath, 'conv-utf8'))
expect(calls).toHaveLength(1)
expect(calls[0]!.outputTokens).toBe(Math.ceil(reply.length / 4))
})
it('extracts agent-mode replies from AgentRound (not the user prompt Markdown)', async () => {
// Agent-mode sessions (e.g. PyCharm) store the reply in an AgentRound record;
// the Markdown record holds the USER prompt. The reply extractor must read
// the AgentRound reply and ignore the prompt — otherwise the turn bills $0
// (reply never found) or bills the user's words as output.
const reply = "Here's a quick summary of this repo: it does X, Y, and Z."
const content = jbDbContent([
jbAgentBlob([reply], { model: 'claude-opus-4.5', userPrompt: 'summarise this repo' }),
])
const dbPath = await createJetBrainsDb(tmpDir, 'py', 'chat-agent-sessions', 'conv-agent', content)
const calls = await collectCalls(jbDbSource(dbPath, 'conv-agent'))
expect(calls).toHaveLength(1)
// Priced from the AgentRound reply, not the (shorter) user prompt.
expect(calls[0]!.outputTokens).toBe(Math.ceil(reply.length / 4))
expect(calls[0]!.costUSD).toBeGreaterThan(0)
expect(calls[0]!.model).toBe('claude-opus-4-5')
})
it('skips pure tool-call agent rounds (empty reply → no billable output)', async () => {
// A round that only issued tool calls has reply:'' — it contributes nothing,
// exactly like a Steps-only ask-mode blob.
const content = jbDbContent([jbAgentBlob([''], { model: 'claude-opus-4.5' })])
const dbPath = await createJetBrainsDb(tmpDir, 'py', 'chat-agent-sessions', 'conv-toolonly', content)
const calls = await collectCalls(jbDbSource(dbPath, 'conv-toolonly'))
expect(calls).toHaveLength(0)
})
it('a failed agent turn bills $0 and never counts the user prompt as the reply', async () => {
// Failed agent turn: empty AgentRound reply + an error marker + a user-prompt
// Markdown record. The parser must NOT fall back to the Markdown (that would
// bill the user's words); an agent blob is agent mode regardless of whether
// its reply is empty, so this is an errored turn → $0.
const content = jbDbContent([
jbAgentBlob([''], { model: 'claude-opus-4.5', userPrompt: 'do the thing', errored: true }),
])
const dbPath = await createJetBrainsDb(tmpDir, 'py', 'chat-agent-sessions', 'conv-agenterr', content)
const calls = await collectCalls(jbDbSource(dbPath, 'conv-agenterr'))
expect(calls).toHaveLength(1)
expect(calls[0]!.outputTokens).toBe(0)
expect(calls[0]!.costUSD).toBe(0)
})
it('collects multiple AgentRound replies within one blob', async () => {
// A multi-round agent turn: the first round explores (tool call, empty
// reply), the second answers. Both non-empty replies are joined.
const content = jbDbContent([
jbAgentBlob(['Let me explore the project.', '', 'Done — here is what it does.'], {
model: 'claude-opus-4.5',
}),
])
const dbPath = await createJetBrainsDb(tmpDir, 'py', 'chat-agent-sessions', 'conv-multiround', content)
const calls = await collectCalls(jbDbSource(dbPath, 'conv-multiround'))
expect(calls).toHaveLength(1)
const joined = 'Let me explore the project.\nDone — here is what it does.'
expect(calls[0]!.outputTokens).toBe(Math.ceil(joined.length / 4))
})
it('treats errored turns as $0 (failed generation, no billable output)', async () => {
const content = jbDbContent([
jbAssistantBlob('', { errored: true }),
jbAssistantBlob('A real successful reply.', { model: 'claude-opus-4.5' }),
])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-err', content)
const calls = await collectCalls(jbDbSource(dbPath, 'conv-err'))
expect(calls).toHaveLength(2)
const errored = calls.find((c) => c.outputTokens === 0)
const good = calls.find((c) => c.outputTokens > 0)
expect(errored).toBeDefined()
expect(errored!.costUSD).toBe(0)
expect(good).toBeDefined()
expect(good!.costUSD).toBeGreaterThan(0)
})
it('de-duplicates repeated byte-copies of the same reply within a .db', async () => {
const content = jbDbContent([
jbAssistantBlob('identical reply text stored twice'),
jbAssistantBlob('identical reply text stored twice'),
])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-dup', content)
const calls = await collectCalls(jbDbSource(dbPath, 'conv-dup'))
expect(calls).toHaveLength(1)
})
it('skips Steps/progress-only assistant blobs (no billable text)', async () => {
const stepsBlob = JSON.stringify({
__first__: {
type: 'Subgraph',
value: JSON.stringify({ x: { type: 'Value', value: JSON.stringify({ type: 'Steps', data: '[]' }) } }),
},
})
const content = jbDbContent([stepsBlob, jbAssistantBlob('The only real answer.')])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-steps', content)
const calls = await collectCalls(jbDbSource(dbPath, 'conv-steps'))
expect(calls).toHaveLength(1)
expect(calls[0]!.outputTokens).toBeGreaterThan(0)
})
it('per-turn model differences within one .db (opus vs gpt) are priced separately', async () => {
const content = jbDbContent([
jbAssistantBlob('Opus answer with enough words to score tokens.', { model: 'claude-opus-4.5' }),
jbAssistantBlob('GPT answer with enough words to score tokens.', { model: 'gpt-5.3' }),
])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-multi', content)
const calls = await collectCalls(jbDbSource(dbPath, 'conv-multi'))
expect(calls).toHaveLength(2)
expect(calls.map((c) => c.model).sort()).toEqual(['claude-opus-4-5', 'gpt-5.3'])
})
it('splits one .db into sessions by conversation; project = repo, title = session label', async () => {
const guidA = '6acf5299-f9f7-404f-812d-dbe8300e1e5b'
const guidB = '485825c0-3331-46a7-acb2-c71875ad6640'
// Conversation A references a file in a real git repo; B touches no files.
const repoDir = join(tmpDir, 'container', 'web-api')
await mkdir(join(repoDir, '.git'), { recursive: true })
await mkdir(join(repoDir, 'src'), { recursive: true })
const fileA = join(repoDir, 'src', 'Main.java')
// Interleave each conversation record before its own turns (turns attribute
// to the nearest preceding conversation GUID). Title evolves default→final.
const content =
'H:2,block:9,blockSize:1000,format:3\n' +
'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' +
jbConversationRecord(guidA, 'New Agent Session') + '\n' +
jbConversationRecord(guidA, 'Understanding the API Architecture') + '\n' +
jbAssistantBlob('Answer about the web API.', { model: 'claude-opus-4.5', files: [fileA] }) + '\n' +
jbConversationRecord(guidB, 'Exploring the Controller Layer in Spring Boot') + '\n' +
jbAssistantBlob('Answer about the controller layer breakdown.', { model: 'gpt-5.3' }) + '\n'
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'multi-conv', content)
const calls = await collectCalls(jbDbSource(dbPath, 'multi-conv'))
expect(calls).toHaveLength(2)
const bySession = new Map(calls.map((c) => [c.sessionId, c]))
// Sessions are split by conversation GUID.
expect(bySession.has(guidA)).toBe(true)
expect(bySession.has(guidB)).toBe(true)
// Project = the git repo root of the referenced file; else the generic
// bucket when the chat touched no files.
expect(bySession.get(guidA)!.project).toBe('web-api')
expect(bySession.get(guidB)!.project).toBe('copilot-jetbrains')
// The conversation TITLE is the session label (userMessage), NOT the project.
expect(bySession.get(guidA)!.userMessage).toBe('Understanding the API Architecture')
expect(bySession.get(guidB)!.userMessage).toBe('Exploring the Controller Layer in Spring Boot')
// Titles must never appear as project names (they are chat threads).
expect(calls.map((c) => c.project)).not.toContain('Understanding the API Architecture')
})
it('is idempotent across re-parses of the same .db (shared seenKeys)', async () => {
const content = jbDbContent([jbAssistantBlob('first reply'), jbAssistantBlob('second reply')])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-idem', content)
const seen = new Set<string>()
const first = await collectCalls(jbDbSource(dbPath, 'conv-idem'), seen)
const second = await collectCalls(jbDbSource(dbPath, 'conv-idem'), seen)
expect(first).toHaveLength(2)
expect(second).toHaveLength(0)
})
it('discovers a store dir with a Nitrite .db', async () => {
const content = jbDbContent([jbAssistantBlob('hi there')])
await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'db-only', content)
const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir)
const sessions = await provider.discoverSessions()
const jb = sessions.filter((s) => (s as { sourceType?: string }).sourceType === 'jetbrains')
expect(jb).toHaveLength(1)
expect((jb[0] as { dbPath?: string }).dbPath).toContain('copilot-agent-sessions-nitrite.db')
})
it('infers project as the git repo root of a referenced file (deep subdir → repo root)', async () => {
// Create a real git repo on disk so the .git walk-up can resolve it.
const repoDir = join(tmpDir, 'container', 'myapp')
await mkdir(join(repoDir, '.git'), { recursive: true })
await mkdir(join(repoDir, 'src', 'a'), { recursive: true })
const fileA = join(repoDir, 'src', 'a', 'One.ts')
const content = jbDbContent([
jbAssistantBlob('Editing files in a real repo.', { model: 'gpt-4.1', files: [fileA] }),
])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-gitwalk', content)
const calls = await collectCalls(jbDbSource(dbPath, 'conv-gitwalk'))
expect(calls).toHaveLength(1)
// Project = basename of the nearest ancestor with .git (the repo root
// 'myapp'), NOT the deep subdir 'a'/'src' or the container dir.
expect(calls[0]!.project).toBe('myapp')
expect(calls[0]!.model).toBe('gpt-4.1')
})
it('falls back to copilot-jetbrains when no referenced file resolves to a git repo', async () => {
const content = jbDbContent([
jbAssistantBlob('Editing a file outside any repo.', {
model: 'gpt-4.1',
files: ['/nonexistent/no-repo-here/src/One.ts'],
}),
])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-norepo', content)
const calls = await collectCalls(jbDbSource(dbPath, 'conv-norepo'))
expect(calls).toHaveLength(1)
expect(calls[0]!.project).toBe('copilot-jetbrains')
})
it('resolves a git repo whose name contains a space', async () => {
const repoDir = join(tmpDir, 'My Project')
await mkdir(join(repoDir, '.git'), { recursive: true })
await mkdir(join(repoDir, 'src'), { recursive: true })
const file = join(repoDir, 'src', 'One.ts')
const content = jbDbContent([
jbAssistantBlob('Reading a file in a spaced repo.', { model: 'gpt-4.1', files: [file] }),
])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-space', content)
const calls = await collectCalls(jbDbSource(dbPath, 'conv-space'))
expect(calls).toHaveLength(1)
expect(calls[0]!.project).toBe('My Project')
})
it('discovers JetBrains sessions across IDE dirs and session kinds', async () => {
const content = jbDbContent([jbAssistantBlob('Hello from agent mode.', { model: 'claude-opus-4.5' })])
await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'a1', content)
await createJetBrainsDb(tmpDir, 'intellij', 'chat-agent-sessions', 'b1', content)
const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir)
const sessions = await provider.discoverSessions()
const jb = sessions.filter((s) => (s as { sourceType?: string }).sourceType === 'jetbrains')
expect(jb.map((s) => (s as { sessionId?: string }).sessionId).sort()).toEqual(['a1', 'b1'])
})
it('does not crash on a corrupt/truncated .db', async () => {
const dbPath = await createJetBrainsDb(
tmpDir,
'iu',
'chat-agent-sessions',
'conv-corrupt',
'H:2,block:9\ncom.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n{"__first__":{"type":"Subgraph"' // truncated, unbalanced
)
const calls = await collectCalls(jbDbSource(dbPath, 'conv-corrupt'))
expect(Array.isArray(calls)).toBe(true) // no throw; may be empty
})
// ---- projectName field (JetBrains Copilot 1.12+) ----
it('uses the plugin-recorded projectName over the file-path git-walk', async () => {
// Same store carries both a projectName AND a file ref; projectName wins.
const repoDir = join(tmpDir, 'container', 'walkable-repo')
await mkdir(join(repoDir, '.git'), { recursive: true })
const file = join(repoDir, 'Main.java')
const content = jbDbContent([
jbProjectNameField('shared-utils'),
jbAssistantBlob('An answer referencing a file in a real git repo.', {
model: 'claude-opus-4.5',
files: [file],
}),
])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-pn', content)
// discoverSessions populates source.projectName; feed the resolved source.
const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir)
const sessions = await provider.discoverSessions()
const src = sessions.find((s) => (s as { storeId?: string }).storeId === 'conv-pn')!
expect((src as { projectName?: string }).projectName).toBe('shared-utils')
const calls = await collectCalls(src as never)
expect(calls.length).toBeGreaterThan(0)
// projectName beats the git-walk result (`walkable-repo`).
expect(calls.every((c) => c.project === 'shared-utils')).toBe(true)
})
it('joins projectName across kind dirs by store id (turns in agent, name in edit)', async () => {
// The billable turns live in chat-agent-sessions but carry NO projectName;
// the sibling chat-edit-sessions store (same id) records it. Discovery must
// join them so the agent session is labelled with the real repo.
const storeId = 'store-xyz-123'
const agentContent = jbDbContent([
jbAssistantBlob('Architecture overview of the repo, no file refs at all.', { model: 'claude-opus-4.5' }),
])
await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', storeId, agentContent)
// Edit-kind store: has the projectName, but no billable turns.
const editContent = jbDbContent([], []) + jbProjectNameField('web-api')
await createJetBrainsDb(tmpDir, 'iu', 'chat-edit-sessions', storeId, editContent)
const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir)
const sessions = await provider.discoverSessions()
const jb = sessions.filter((s) => (s as { sourceType?: string }).sourceType === 'jetbrains')
// Every source for this store id inherits the sibling-recorded name.
for (const s of jb) {
expect((s as { projectName?: string }).projectName).toBe('web-api')
}
const agentSrc = jb.find((s) => ((s as { dbPath?: string }).dbPath ?? '').includes('chat-agent-sessions'))!
const calls = await collectCalls(agentSrc as never)
expect(calls.length).toBeGreaterThan(0)
expect(calls.every((c) => c.project === 'web-api')).toBe(true)
})
it('falls back to git-walk then bucket when no projectName is recorded', async () => {
// No projectName, no file refs → the honest generic bucket (older plugins).
const content = jbDbContent([jbAssistantBlob('A reply with no project signal at all.')])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-nopn', content)
const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir)
const sessions = await provider.discoverSessions()
const src = sessions.find((s) => (s as { storeId?: string }).storeId === 'conv-nopn')!
expect((src as { projectName?: string }).projectName).toBeUndefined()
const calls = await collectCalls(src as never)
expect(calls.every((c) => c.project === 'copilot-jetbrains')).toBe(true)
})
it('extractJetBrainsProjectName reads the length-prefixed value, immune to embedded quotes', async () => {
// A value containing a quote/newline must not truncate: length-prefixed read.
const tricky = 'weird"name'
const raw = jbDbContent([jbAssistantBlob('x')]) + jbProjectNameField(tricky)
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-sessions', 'conv-tricky', raw)
const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir)
const sessions = await provider.discoverSessions()
const src = sessions.find((s) => (s as { storeId?: string }).storeId === 'conv-tricky')!
expect((src as { projectName?: string }).projectName).toBe(tricky)
})
it('reads a non-ASCII (multibyte UTF-8) projectName', async () => {
// The value is length-delimited in UTF-8 bytes and re-decoded latin1→utf8,
// so a repo name with multibyte characters must round-trip intact.
const name = 'проект-café'
const raw = jbDbContent([jbAssistantBlob('x')]) + jbProjectNameField(name)
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-sessions', 'conv-utf8name', raw)
const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir)
const sessions = await provider.discoverSessions()
const src = sessions.find((s) => (s as { storeId?: string }).storeId === 'conv-utf8name')!
expect((src as { projectName?: string }).projectName).toBe(name)
})
// ---------------------------------------------------------------------------
// Old plugin format (≤1.5.x, e.g. 1.5.59-243)
// ---------------------------------------------------------------------------
// In the old plugin all session turns live inside ONE large binary-framed
// outer Nitrite document. Each turn's response is stored as a UUID-keyed
// Value entry containing an AgentRound record (one escaping level deeper than
// the __first__/Subgraph format used by plugins ≥1.12.x).
/**
* Build an outer Nitrite document in the old plugin format.
* The document is preceded by a single binary byte (0x81) and starts with a
* UUID-keyed Value entry. Each AgentRound is stored as a Value whose value
* field is a JSON string containing {\"type\":\"AgentRound\",\"data\":\"...\"}
* (one level of JSON-string escaping from the document root).
*/
function jbOldFormatDoc(rounds: Array<{ reply: string; model?: string }>, opts: { upperUuid?: boolean } = {}) {
const cased = (u: string) => (opts.upperUuid ? u.toUpperCase() : u)
const entries: Record<string, unknown> = {}
// Lead entry (mimics the References record always present in real DBs)
entries[cased('0f383f5c-f169-4fee-9115-c06d4dd8985f')] = {
type: 'Value',
value: JSON.stringify({ type: 'References', data: '[]' }),
}
rounds.forEach((r, i) => {
const uuid = cased(`ccadf30b-fa34-4387-9f14-0a5f63457d${String(i).padStart(2, '0')}`)
const agentRoundData = JSON.stringify({ roundId: i + 1, reply: r.reply, toolCalls: [] })
const agentRoundValue = JSON.stringify({ type: 'AgentRound', data: agentRoundData })
entries[uuid] = { type: 'Value', value: agentRoundValue }
if (r.model) {
const modelUuid = cased(`bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbb${String(i).padStart(4, '0')}`)
entries[modelUuid] = { type: 'Value', value: `{"model":"${r.model}"}` }
}
})
// Binary framing byte (0x81) followed by the JSON document
return '\x81' + JSON.stringify(entries)
}
it('parses agent turns from old plugin format (≤1.5.x, no __first__ blobs)', async () => {
// The old plugin stores all turns in one big outer Nitrite document with a
// binary framing byte. The fallback path must find and parse it.
const convGuid = '17a5d71b-27f7-4937-8803-7fc2cbb705cb'
const convRecord = jbConversationRecord(convGuid, 'Understanding HBase Architecture')
const oldFormatContent =
'H:2,block:8,blockSize:1000,format:3\n' +
'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' +
convRecord + '\n' +
jbOldFormatDoc([
{ reply: "I'll scan the repository to find the top-level project structure.", model: 'gpt-4.1' },
{ reply: "Now I'll open the README to explain architecture." },
{ reply: '' }, // empty reply (pure tool-call round) — must not produce a call
])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'old-fmt-1', oldFormatContent)
const calls = await collectCalls(jbDbSource(dbPath, 'old-fmt-1'))
// The fallback emits one call per outer document (all replies joined).
expect(calls).toHaveLength(1)
expect(calls[0]!.costIsEstimated).toBe(true)
// The two NON-EMPTY rounds are captured and joined; the empty (tool-call)
// round contributes nothing. Assert the exact combined token count so the
// test fails if either reply is dropped or the empty round leaks in.
const joined =
"I'll scan the repository to find the top-level project structure.\n" +
"Now I'll open the README to explain architecture."
expect(calls[0]!.outputTokens).toBe(Math.ceil(joined.length / 4))
// The session label is the conversation TITLE, not the reply text.
expect(calls[0]!.userMessage).toBe('Understanding HBase Architecture')
})
it('parses old plugin format when the outer-doc UUIDs are uppercase hex', async () => {
// The outer-doc detection must be case-insensitive: an uppercase UUID must
// not make the whole session fall through to $0.
const convRecord = jbConversationRecord('27b6e82c-38f8-4048-9914-8fd3dcc816dc', 'Conv Upper')
const content =
'H:2,block:8,blockSize:1000,format:3\n' +
'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' +
convRecord + '\n' +
jbOldFormatDoc([{ reply: 'An uppercase-UUID reply with enough words to score.' }], { upperUuid: true })
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'old-fmt-upper', content)
const calls = await collectCalls(jbDbSource(dbPath, 'old-fmt-upper'))
expect(calls).toHaveLength(1)
expect(calls[0]!.outputTokens).toBeGreaterThan(0)
})
it('old plugin format: does not parse when __first__ blobs already yield turns (no double-count)', async () => {
// When the newer __first__/Subgraph path finds turns, the old-format fallback
// must not run (turns.length > 0 prevents it).
const content = jbDbContent([
jbAgentBlob(['A reply from the new format.']),
])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'new-fmt-guard', content)
const calls = await collectCalls(jbDbSource(dbPath, 'new-fmt-guard'))
// Only the one Subgraph-format turn — no old-format duplicates
expect(calls).toHaveLength(1)
expect(calls[0]!.outputTokens).toBeGreaterThan(0)
})
})
describe('copilot provider - JetBrains dedup key stability across store rewrites', () => {
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'copilot-jetbrains-dedup-'))
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1')
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
vi.unstubAllEnvs()
})
function jbDedupSource(path: string, sessionId: string) {
return {
path, project: 'copilot-jetbrains', provider: 'copilot', sourceType: 'jetbrains',
sessionId, storeId: sessionId, dbPath: path, mtime: '2026-07-03T12:00:00.000Z',
} as unknown as { path: string; project: string; provider: string; sourceType?: string }
}
function blobFor(text: string) {
const innerMd = { type: 'Markdown', data: JSON.stringify({ text, annotations: [] }) }
const valueMap = { 'a1b2c3d4-0000-0000-0000-000000000001': { type: 'Value', value: JSON.stringify(innerMd) } }
return JSON.stringify({ __first__: { type: 'Subgraph', value: JSON.stringify(valueMap) } })
}
function dbContent(blobs: string[]) {
return (
'H:2,block:9,blockSize:1000,format:3\n' +
'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' +
'\n' + blobs.join('\nt\x00\x00model\n') + '\n'
)
}
it('a compaction that moves a new blob ahead of an old one must not re-bill the old turn', async () => {
// copilot is a durable provider: cached turns are never deleted, and a
// re-parse appends any dedup key it has not seen. MVStore compaction can
// rewrite the file with blobs in a different byte order. If dedup keys were
// positional (conversation + scan index), a rewrite that puts a NEW turn
// before an OLD one would hand the new turn the old turn's key (skipped as
// already-seen) and re-emit the old turn under a fresh index — billing it
// twice and never billing the new turn. Content-derived keys are immune.
const oldReply = 'The original answer, long enough to carry a token estimate.'
const newReply = 'A fresh answer written after the compaction happened.'
const dir = join(tmpDir, 'iu', 'chat-agent-sessions', 'conv-rewrite')
await mkdir(dir, { recursive: true })
const dbPath = join(dir, 'copilot-agent-sessions-nitrite.db')
const seen = new Set<string>()
// Scan 1: the store holds only the old turn.
await writeFile(dbPath, dbContent([blobFor(oldReply)]))
const first = await collectCalls(jbDedupSource(dbPath, 'conv-rewrite'), seen)
expect(first).toHaveLength(1)
expect(first[0]!.outputTokens).toBe(Math.ceil(oldReply.length / 4))
// Scan 2: compaction rewrote the file — the new turn now sits BEFORE the
// old one in byte order.
await writeFile(dbPath, dbContent([blobFor(newReply), blobFor(oldReply)]))
const second = await collectCalls(jbDedupSource(dbPath, 'conv-rewrite'), seen)
// Exactly the new turn must be billed — once, at its own length. The old
// turn is already cached and must not re-enter under a different key.
expect(second).toHaveLength(1)
expect(second[0]!.outputTokens).toBe(Math.ceil(newReply.length / 4))
})
})