mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-11-07 15:18:31 +00:00
The dot-minor lookahead from #1531 stops claude-opus-4 matching claude-opus-4.7, and 4.7 was not in the token list, so those turns fell back to copilot-anthropic-auto (Sonnet 4.5 rates). Add the token and bump the copilot session-cache parse tag so cached turns re-parse.
4425 lines
196 KiB
TypeScript
4425 lines
196 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||
import { mkdtemp, mkdir, readFile, writeFile, rm, utimes } from 'fs/promises'
|
||
import { join, posix, win32 } from 'path'
|
||
import { tmpdir } from 'os'
|
||
import { createRequire } from 'node:module'
|
||
import { dirname } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
|
||
import { ChatCompletionContentPartKind, copilot, createCopilotProvider, findJetBrainsModelToken, getVSCodeGlobalStorageDirs, getVSCodeWorkspaceStorageDirs } from '../../src/providers/copilot.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 })
|
||
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
|
||
}
|
||
|
||
// Write a transcript inside the test's tmpDir sandbox, but at the production
|
||
// directory shape — {ws}/{hash}/GitHub.copilot-chat/transcripts/<id>.jsonl —
|
||
// because sessionId derivation reads the path structure (file basename for
|
||
// transcripts). Never touches the real VS Code storage.
|
||
async function createTranscriptFile(sessionId: string, lines: string[]) {
|
||
const transcriptsDir = join(tmpDir, 'ws', 'hash1', 'GitHub.copilot-chat', 'transcripts')
|
||
await mkdir(transcriptsDir, { recursive: true })
|
||
const path = join(transcriptsDir, `${sessionId}.jsonl`)
|
||
await writeFile(path, lines.join('\n') + '\n')
|
||
return path
|
||
}
|
||
|
||
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('attributes turns between subagent.started and subagent.completed to the subagent', async () => {
|
||
// CLI ≥ ~1.0.7x writes subagent.started/completed (not subagent.selected);
|
||
// event shapes from a real delegating 1.0.78 session. The label must cover
|
||
// the subagent's turns and clear afterwards, not bleed onto the parent's.
|
||
const eventsPath = await createSessionDir('sess-subagent-cli', [
|
||
modelChange('claude-sonnet-5'),
|
||
userMessage('delegate a search'),
|
||
JSON.stringify({
|
||
type: 'subagent.started',
|
||
timestamp: '2026-08-07T10:00:11Z',
|
||
data: { toolCallId: 'toolu_01SZnHjC', agentName: 'explore', agentDisplayName: 'Explore Agent' },
|
||
}),
|
||
JSON.stringify({
|
||
type: 'assistant.message',
|
||
timestamp: '2026-08-07T10:00:14Z',
|
||
data: { messageId: 'msg-sub', model: 'claude-haiku-4.5', outputTokens: 197, toolRequests: [] },
|
||
}),
|
||
JSON.stringify({
|
||
type: 'subagent.completed',
|
||
timestamp: '2026-08-07T10:00:19Z',
|
||
data: { toolCallId: 'toolu_01SZnHjC', agentName: 'explore', model: 'claude-haiku-4.5', totalTokens: 26435 },
|
||
}),
|
||
JSON.stringify({
|
||
type: 'assistant.message',
|
||
timestamp: '2026-08-07T10:00:22Z',
|
||
data: { messageId: 'msg-parent', model: 'claude-sonnet-5', outputTokens: 51, toolRequests: [] },
|
||
}),
|
||
])
|
||
|
||
const calls = await collectCalls({ path: eventsPath, project: 'test', provider: 'copilot', sourceType: 'jsonl' })
|
||
const sub = calls.find(c => c.deduplicationKey.endsWith(':msg-sub'))!
|
||
expect(sub.subagentTypes).toEqual(['explore'])
|
||
expect(sub.model).toBe('claude-haiku-4.5')
|
||
const parent = calls.find(c => c.deduplicationKey.endsWith(':msg-parent'))!
|
||
expect(parent.subagentTypes).toBeUndefined()
|
||
})
|
||
|
||
it('completing a nested subagent restores the outer label, matched by toolCallId', async () => {
|
||
const started = (id: string, name: string) =>
|
||
JSON.stringify({ type: 'subagent.started', timestamp: '2026-08-07T10:00:11Z', data: { toolCallId: id, agentName: name } })
|
||
const completed = (id: string) =>
|
||
JSON.stringify({ type: 'subagent.completed', timestamp: '2026-08-07T10:00:19Z', data: { toolCallId: id, agentName: 'x' } })
|
||
const msg = (messageId: string, outputTokens = 10) =>
|
||
JSON.stringify({ type: 'assistant.message', timestamp: '2026-08-07T10:00:14Z', data: { messageId, model: 'claude-sonnet-5', outputTokens, toolRequests: [] } })
|
||
|
||
const eventsPath = await createSessionDir('sess-subagent-nested', [
|
||
modelChange('claude-sonnet-5'),
|
||
started('call-A', 'explore'),
|
||
started('call-B', 'plan'),
|
||
msg('msg-inner'), // while B runs → 'plan'
|
||
completed('call-B'),
|
||
msg('msg-outer'), // B done, A still active → 'explore', NOT unlabeled
|
||
completed('call-A'),
|
||
msg('msg-after'), // all done → no label
|
||
])
|
||
|
||
const calls = await collectCalls({ path: eventsPath, project: 'test', provider: 'copilot', sourceType: 'jsonl' })
|
||
const byId = (id: string) => calls.find(c => c.deduplicationKey.endsWith(`:${id}`))!
|
||
expect(byId('msg-inner').subagentTypes).toEqual(['plan'])
|
||
expect(byId('msg-outer').subagentTypes).toEqual(['explore'])
|
||
expect(byId('msg-after').subagentTypes).toBeUndefined()
|
||
})
|
||
|
||
it('ignores a completed event whose non-empty toolCallId matches no active run', async () => {
|
||
// A completion for a run we never saw start must not evict an unrelated
|
||
// active run; only a genuinely ID-less completion may pop the stack.
|
||
const eventsPath = await createSessionDir('sess-subagent-unmatched', [
|
||
modelChange('claude-sonnet-5'),
|
||
JSON.stringify({
|
||
type: 'subagent.started',
|
||
timestamp: '2026-08-07T10:00:11Z',
|
||
data: { toolCallId: 'call-A', agentName: 'explore' },
|
||
}),
|
||
JSON.stringify({
|
||
type: 'subagent.completed',
|
||
timestamp: '2026-08-07T10:00:12Z',
|
||
data: { toolCallId: 'call-unknown', agentName: 'phantom' },
|
||
}),
|
||
JSON.stringify({
|
||
type: 'assistant.message',
|
||
timestamp: '2026-08-07T10:00:14Z',
|
||
data: { messageId: 'msg-1', model: 'claude-sonnet-5', outputTokens: 10, toolRequests: [] },
|
||
}),
|
||
JSON.stringify({
|
||
type: 'subagent.completed',
|
||
timestamp: '2026-08-07T10:00:15Z',
|
||
data: { agentName: 'legacy-no-id' },
|
||
}),
|
||
JSON.stringify({
|
||
type: 'assistant.message',
|
||
timestamp: '2026-08-07T10:00:16Z',
|
||
data: { messageId: 'msg-2', model: 'claude-sonnet-5', outputTokens: 12, toolRequests: [] },
|
||
}),
|
||
])
|
||
|
||
const calls = await collectCalls({ path: eventsPath, project: 'test', provider: 'copilot', sourceType: 'jsonl' })
|
||
// The unmatched completion left 'explore' active…
|
||
expect(calls.find(c => c.deduplicationKey.endsWith(':msg-1'))!.subagentTypes).toEqual(['explore'])
|
||
// …and the ID-less completion (legacy shape) ended it.
|
||
expect(calls.find(c => c.deduplicationKey.endsWith(':msg-2'))!.subagentTypes).toBeUndefined()
|
||
})
|
||
|
||
it('keeps subagent.selected sticky when no completed event ever arrives', async () => {
|
||
// Older CLIs only write subagent.selected; nothing clears it.
|
||
const eventsPath = await createSessionDir('sess-subagent-selected', [
|
||
modelChange('claude-sonnet-5'),
|
||
JSON.stringify({ type: 'subagent.selected', data: { agentName: 'refactor' } }),
|
||
assistantMessage({ messageId: 'msg-1', outputTokens: 25 }),
|
||
assistantMessage({ messageId: 'msg-2', outputTokens: 30, timestamp: '2026-04-15T10:01:00Z' }),
|
||
])
|
||
const calls = await collectCalls({ path: eventsPath, project: 'test', provider: 'copilot', sourceType: 'jsonl' })
|
||
expect(calls.map(c => c.subagentTypes)).toEqual([['refactor'], ['refactor']])
|
||
expect(calls.map(c => c.costIsEstimated)).toEqual([undefined, undefined])
|
||
})
|
||
|
||
it('infers OpenAI auto bucket for transcript toolCallId prefix call_', async () => {
|
||
const eventsPath = await createTranscriptFile('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', sourceType: 'transcript' }
|
||
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')
|
||
// A transcript records no tokens: the $0 is unknown usage, not a measured zero.
|
||
expect(calls[0]!.costIsEstimated).toBe(true)
|
||
// Each transcript is its own session, keyed by file basename — NOT the
|
||
// shared parent dir name 'transcripts', which would collapse every
|
||
// transcript into one session and one dedup namespace.
|
||
expect(calls[0]!.sessionId).toBe('sess-tr-call')
|
||
})
|
||
|
||
it('infers Anthropic auto bucket for transcript toolCallId prefixes tooluse_/toolu_vrtx_', async () => {
|
||
const eventsPath = await createTranscriptFile('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', sourceType: 'transcript' }
|
||
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 createTranscriptFile('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', sourceType: 'transcript' }
|
||
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('parses a producerless transcript with explicit model info and no tool calls', async () => {
|
||
// Prefix inference has nothing to work with here; the explicit
|
||
// session.model_change must still establish the model, and the shutdown
|
||
// rollup must stay ignored — provenance, not the producer field, gates it.
|
||
const eventsPath = await createTranscriptFile('sess-tr-explicit', [
|
||
JSON.stringify({ type: 'session.start', data: { sessionId: 'sess-tr-explicit' } }),
|
||
modelChange('gpt-4.1'),
|
||
transcriptUserMessage('hi'),
|
||
JSON.stringify({
|
||
type: 'assistant.message',
|
||
timestamp: '2026-04-15T10:00:15Z',
|
||
data: { messageId: 'msg-1', outputTokens: 80, toolRequests: [] },
|
||
}),
|
||
shutdownEvent({
|
||
modelMetrics: {
|
||
'gpt-4.1': { inputTokens: 1000, outputTokens: 80, cacheReadTokens: 500, cacheWriteTokens: 200 },
|
||
},
|
||
}),
|
||
])
|
||
|
||
const calls = await collectCalls({ path: eventsPath, project: 'test', provider: 'copilot', sourceType: 'transcript' })
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.model).toBe('gpt-4.1')
|
||
expect(calls[0]!.outputTokens).toBe(80)
|
||
expect(calls.every(c => !c.deduplicationKey.includes(':shutdown:'))).toBe(true)
|
||
})
|
||
|
||
it('normalizes Copilot MCP tool names from VS Code transcripts', async () => {
|
||
const eventsPath = await createTranscriptFile('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', sourceType: 'transcript' }
|
||
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:1')
|
||
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('emits per-leg deltas for a resumed session with cumulative shutdown rollups', async () => {
|
||
// Numbers from a real resumed CLI 1.0.78 session (3 legs via --resume):
|
||
// each leg appends a session.shutdown whose modelMetrics are CUMULATIVE.
|
||
// Emitting deltas keyed by shutdown timestamp keeps a growing file
|
||
// append-only under the durable union-by-key cache merge — re-parsing
|
||
// after each resume adds only the new leg, never double-counting earlier ones.
|
||
const legs = [
|
||
{ inputTokens: 24672, outputTokens: 17, cacheReadTokens: 0, cacheWriteTokens: 24670 },
|
||
{ inputTokens: 74463, outputTokens: 149, cacheReadTokens: 49489, cacheWriteTokens: 24968 },
|
||
{ inputTokens: 124783, outputTokens: 243, cacheReadTokens: 99569, cacheWriteTokens: 25204 },
|
||
]
|
||
const lines = [modelChange('claude-sonnet-5'), assistantMessage({ messageId: 'msg-1', outputTokens: 17 })]
|
||
for (const [i, leg] of legs.entries()) {
|
||
lines.push(shutdownEvent({ modelMetrics: { 'claude-sonnet-5': leg }, timestamp: `2026-08-0${i + 1}T10:00:00Z` }))
|
||
}
|
||
const eventsPath = await createSessionDir('sess-resumed', lines)
|
||
const calls = await collectCalls({ path: eventsPath, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' })
|
||
|
||
const shutdowns = calls.filter(c => c.deduplicationKey.includes(':shutdown:'))
|
||
expect(shutdowns.map(c => c.deduplicationKey)).toEqual([
|
||
'copilot:sess-resumed:shutdown:claude-sonnet-5:1',
|
||
'copilot:sess-resumed:shutdown:claude-sonnet-5:2',
|
||
'copilot:sess-resumed:shutdown:claude-sonnet-5:3',
|
||
])
|
||
// Each leg lands on its own shutdown timestamp (a resumed session can
|
||
// span days; whole-rollup emission would collapse them onto one).
|
||
expect(shutdowns.map(c => c.timestamp)).toEqual([
|
||
'2026-08-01T10:00:00Z', '2026-08-02T10:00:00Z', '2026-08-03T10:00:00Z',
|
||
])
|
||
// Per-leg deltas sum exactly to the final cumulative rollup.
|
||
const sum = (k: 'inputTokens' | 'cacheReadInputTokens' | 'cacheCreationInputTokens') =>
|
||
shutdowns.reduce((a, c) => a + c[k], 0)
|
||
expect(sum('cacheReadInputTokens')).toBe(99569)
|
||
expect(sum('cacheCreationInputTokens')).toBe(25204)
|
||
expect(sum('inputTokens')).toBe(124783 - 99569 - 25204)
|
||
|
||
// A later re-parse of the grown file (prior legs already cached) emits
|
||
// only what the seen-key set lacks.
|
||
const seen = new Set(calls.map(c => c.deduplicationKey))
|
||
const again = await collectCalls({ path: eventsPath, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' }, seen)
|
||
expect(again).toHaveLength(0)
|
||
})
|
||
|
||
it('starts a fresh delta baseline when a cumulative rollup goes backwards (counter reset)', async () => {
|
||
// Hypothetical but cheap to guard: if the CLI ever resets its counters
|
||
// mid-session, the post-reset epoch must be billed from zero — a stale
|
||
// high-water baseline would clamp it away (and the reset leg's real usage
|
||
// with it).
|
||
const eventsPath = await createSessionDir('sess-reset', [
|
||
modelChange('claude-sonnet-5'),
|
||
assistantMessage({ messageId: 'msg-1', outputTokens: 10 }),
|
||
shutdownEvent({
|
||
modelMetrics: { 'claude-sonnet-5': { inputTokens: 10000, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 5000 } },
|
||
timestamp: '2026-08-01T10:00:00Z',
|
||
}),
|
||
// Reset: cumulative drops below the previous rollup → new epoch.
|
||
shutdownEvent({
|
||
modelMetrics: { 'claude-sonnet-5': { inputTokens: 2000, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 1000 } },
|
||
timestamp: '2026-08-02T10:00:00Z',
|
||
}),
|
||
shutdownEvent({
|
||
modelMetrics: { 'claude-sonnet-5': { inputTokens: 5000, outputTokens: 8, cacheReadTokens: 2000, cacheWriteTokens: 1500 } },
|
||
timestamp: '2026-08-03T10:00:00Z',
|
||
}),
|
||
])
|
||
const calls = await collectCalls({ path: eventsPath, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' })
|
||
const shutdowns = calls.filter(c => c.deduplicationKey.includes(':shutdown:'))
|
||
expect(shutdowns).toHaveLength(3)
|
||
// Leg 1: epoch-1 usage in full.
|
||
expect(shutdowns[0]!.inputTokens).toBe(5000) // 10000 − 0 − 5000
|
||
expect(shutdowns[0]!.cacheCreationInputTokens).toBe(5000)
|
||
// Leg 2 (reset): billed from zero, not clamped away against the old baseline.
|
||
expect(shutdowns[1]!.inputTokens).toBe(1000) // 2000 − 0 − 1000
|
||
expect(shutdowns[1]!.cacheCreationInputTokens).toBe(1000)
|
||
// Leg 3: normal delta within the new epoch.
|
||
expect(shutdowns[2]!.inputTokens).toBe(500) // (5000−2000) − 2000 − 500
|
||
expect(shutdowns[2]!.cacheReadInputTokens).toBe(2000)
|
||
expect(shutdowns[2]!.cacheCreationInputTokens).toBe(500)
|
||
})
|
||
|
||
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('keeps three stampless shutdown legs as :n keys with lastEventTimestamp, not sessionStartTime', async () => {
|
||
// sessionStartTime is identical on every leg. Putting it in the key (or
|
||
// preferring it over lastEventTimestamp for the call timestamp) collapses
|
||
// a 3-leg journal onto one row. Discovery only yields events.jsonl, so
|
||
// two-journal fixtures are unreachable; this is the reachable class.
|
||
const lastEvent = '2026-08-01T10:00:15Z'
|
||
const sessionStartTime = 1784102040274
|
||
const stamplessShutdown = (usage: {
|
||
inputTokens: number
|
||
outputTokens: number
|
||
cacheReadTokens: number
|
||
cacheWriteTokens: number
|
||
}) => JSON.stringify({
|
||
type: 'session.shutdown',
|
||
data: {
|
||
shutdownType: 'routine',
|
||
sessionStartTime,
|
||
modelMetrics: {
|
||
'claude-sonnet-5': {
|
||
requests: { count: 1, cost: 1 },
|
||
usage: { ...usage, reasoningTokens: 0 },
|
||
},
|
||
},
|
||
},
|
||
})
|
||
const eventsPath = await createSessionDir('sess-stampless', [
|
||
modelChange('claude-sonnet-5'),
|
||
assistantMessage({ messageId: 'msg-1', outputTokens: 10, timestamp: lastEvent }),
|
||
stamplessShutdown({ inputTokens: 3000, outputTokens: 10, cacheReadTokens: 1000, cacheWriteTokens: 500 }),
|
||
stamplessShutdown({ inputTokens: 7000, outputTokens: 20, cacheReadTokens: 3000, cacheWriteTokens: 1000 }),
|
||
stamplessShutdown({ inputTokens: 10000, outputTokens: 30, cacheReadTokens: 5000, cacheWriteTokens: 1500 }),
|
||
])
|
||
const calls = await collectCalls({ path: eventsPath, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' })
|
||
const shutdowns = calls.filter(c => c.deduplicationKey.includes(':shutdown:'))
|
||
expect(shutdowns.map(c => c.deduplicationKey)).toEqual([
|
||
'copilot:sess-stampless:shutdown:claude-sonnet-5:1',
|
||
'copilot:sess-stampless:shutdown:claude-sonnet-5:2',
|
||
'copilot:sess-stampless:shutdown:claude-sonnet-5:3',
|
||
])
|
||
expect(shutdowns.map(c => c.timestamp)).toEqual([lastEvent, lastEvent, lastEvent])
|
||
expect(shutdowns[0]!.inputTokens).toBe(1500)
|
||
expect(shutdowns[1]!.inputTokens).toBe(1500)
|
||
expect(shutdowns[2]!.inputTokens).toBe(500)
|
||
expect(shutdowns.reduce((a, c) => a + c.inputTokens, 0)).toBe(3500)
|
||
expect(shutdowns.reduce((a, c) => a + c.cacheReadInputTokens, 0)).toBe(5000)
|
||
expect(shutdowns.reduce((a, c) => a + c.cacheCreationInputTokens, 0)).toBe(1500)
|
||
})
|
||
|
||
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 createTranscriptFile('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', sourceType: 'transcript' }
|
||
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')
|
||
})
|
||
|
||
// Regression test for #944: events are redacted copies of a real Copilot CLI
|
||
// 1.0.78 session. The CLI writes the same producer ('copilot-agent') as VS
|
||
// Code transcripts, so content sniffing skipped this session's shutdown
|
||
// rollup — reporting 100 of its 49,573 tokens and zero input/cache.
|
||
it('parses a CLI session whose session.start carries producer copilot-agent (issue #944)', async () => {
|
||
const eventsPath = await createSessionDir('sess-cli-producer', [
|
||
JSON.stringify({
|
||
type: 'session.start',
|
||
timestamp: '2026-08-07T17:56:35.573Z',
|
||
data: {
|
||
sessionId: 'sess-cli-producer',
|
||
version: 1,
|
||
producer: 'copilot-agent',
|
||
copilotVersion: '1.0.78',
|
||
startTime: '2026-08-07T17:56:35.554Z',
|
||
context: { cwd: '/home/user/myproject' },
|
||
},
|
||
}),
|
||
JSON.stringify({
|
||
type: 'session.model_change',
|
||
timestamp: '2026-08-07T17:56:36.725Z',
|
||
data: { newModel: 'claude-sonnet-5', reasoningEffort: null },
|
||
}),
|
||
JSON.stringify({
|
||
type: 'user.message',
|
||
timestamp: '2026-08-07T17:56:36.732Z',
|
||
data: { content: 'Run echo and summarize the output.' },
|
||
}),
|
||
JSON.stringify({
|
||
type: 'assistant.message',
|
||
timestamp: '2026-08-07T17:56:38.763Z',
|
||
data: {
|
||
messageId: 'a982a391-9ee3-4fbd-89a9-26d5af78c890',
|
||
model: 'claude-sonnet-5',
|
||
content: '',
|
||
toolRequests: [{
|
||
toolCallId: 'toolu_017eL3f5aeGiLoALignYMZEN',
|
||
name: 'bash',
|
||
arguments: { command: 'echo codeburn-repro-944', description: 'Echo test string' },
|
||
type: 'function',
|
||
}],
|
||
turnId: '0',
|
||
outputTokens: 81,
|
||
},
|
||
}),
|
||
JSON.stringify({
|
||
type: 'assistant.message',
|
||
timestamp: '2026-08-07T17:56:40.417Z',
|
||
data: {
|
||
messageId: '8758ea51-797f-4285-972c-495911e2839f',
|
||
model: 'claude-sonnet-5',
|
||
content: 'The command printed the string "codeburn-repro-944".',
|
||
toolRequests: [],
|
||
turnId: '1',
|
||
outputTokens: 19,
|
||
},
|
||
}),
|
||
JSON.stringify({
|
||
type: 'session.shutdown',
|
||
timestamp: '2026-08-07T17:56:40.591Z',
|
||
data: {
|
||
shutdownType: 'routine',
|
||
sessionStartTime: 1786125395554,
|
||
modelMetrics: {
|
||
'claude-sonnet-5': {
|
||
requests: { count: 2, cost: 1 },
|
||
usage: { inputTokens: 49473, outputTokens: 100, cacheReadTokens: 24678, cacheWriteTokens: 24791, reasoningTokens: 0 },
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
])
|
||
|
||
// Discovery tags session-state files 'jsonl'; provenance, not the shared
|
||
// producer value, must classify this as a CLI session.
|
||
const calls = await collectCalls({ path: eventsPath, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' })
|
||
|
||
// Two per-turn output calls with the REAL model — not the
|
||
// 'copilot-anthropic-auto' bucket transcript inference would pick from the
|
||
// toolu_ toolCallId prefix.
|
||
const perTurn = calls.filter(c => !c.deduplicationKey.includes(':shutdown:'))
|
||
expect(perTurn.map(c => c.outputTokens)).toEqual([81, 19])
|
||
expect(perTurn.every(c => c.model === 'claude-sonnet-5')).toBe(true)
|
||
|
||
// The shutdown rollup lands: the tokens the misclassification dropped.
|
||
const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-cli-producer:shutdown:claude-sonnet-5:1')
|
||
expect(shutdown).toBeDefined()
|
||
expect(shutdown!.inputTokens).toBe(4) // 49473 − 24678 − 24791 (cache-inclusive)
|
||
expect(shutdown!.cacheReadInputTokens).toBe(24678)
|
||
expect(shutdown!.cacheCreationInputTokens).toBe(24791)
|
||
expect(shutdown!.outputTokens).toBe(0) // owned by the per-turn events
|
||
expect(shutdown!.costIsEstimated).toBe(false)
|
||
expect(shutdown!.costUSD).toBeCloseTo(calculateCost('claude-sonnet-5', 4, 0, 24791, 24678, 0), 12)
|
||
expect(shutdown!.costUSD).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('treats a bare (untagged) source as CLI format, not transcript', async () => {
|
||
// Producer sniffing must not resurface for sources without a sourceType tag
|
||
// (the pre-tagging shape): same events, same result as the tagged parse.
|
||
const eventsPath = await createSessionDir('sess-cli-untagged', [
|
||
JSON.stringify({
|
||
type: 'session.start',
|
||
timestamp: '2026-08-07T17:56:35.573Z',
|
||
data: { sessionId: 'sess-cli-untagged', producer: 'copilot-agent', copilotVersion: '1.0.78' },
|
||
}),
|
||
modelChange('claude-sonnet-5'),
|
||
userMessage('hello'),
|
||
assistantMessage({ messageId: 'msg-1', outputTokens: 42 }),
|
||
shutdownEvent({
|
||
modelMetrics: {
|
||
'claude-sonnet-5': { inputTokens: 1000, outputTokens: 42, cacheReadTokens: 600, cacheWriteTokens: 300 },
|
||
},
|
||
}),
|
||
])
|
||
|
||
const calls = await collectCalls({ path: eventsPath, project: 'myproject', provider: 'copilot' })
|
||
expect(calls.some(c => c.deduplicationKey.includes(':shutdown:'))).toBe(true)
|
||
expect(calls.find(c => c.deduplicationKey.includes(':shutdown:'))!.cacheReadInputTokens).toBe(600)
|
||
})
|
||
|
||
it('wires discovery through parsing: a discovered CLI session keeps its shutdown rollup', async () => {
|
||
// The full #944 pipeline: discoverSessions must tag the session-state file
|
||
// so that the parser it hands off to keeps the shutdown tokens.
|
||
await createSessionDir('sess-wire', [
|
||
JSON.stringify({
|
||
type: 'session.start',
|
||
timestamp: '2026-08-07T17:56:35.573Z',
|
||
data: { sessionId: 'sess-wire', producer: 'copilot-agent', copilotVersion: '1.0.78' },
|
||
}),
|
||
modelChange('claude-sonnet-5'),
|
||
userMessage('hello'),
|
||
assistantMessage({ messageId: 'msg-1', outputTokens: 42 }),
|
||
shutdownEvent({
|
||
modelMetrics: {
|
||
'claude-sonnet-5': { inputTokens: 5000, outputTokens: 42, cacheReadTokens: 3000, cacheWriteTokens: 1500 },
|
||
},
|
||
}),
|
||
])
|
||
|
||
// Keep discovery hermetic: a real agent-traces.db on the host must not leak in.
|
||
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1')
|
||
try {
|
||
const provider = createCopilotProvider(tmpDir, '/nonexistent/vscode', '/nonexistent/global', '/nonexistent/jetbrains')
|
||
const sessions = await provider.discoverSessions()
|
||
expect(sessions).toHaveLength(1)
|
||
|
||
const calls: ParsedProviderCall[] = []
|
||
for await (const call of provider.createSessionParser(sessions[0]!, new Set()).parse()) calls.push(call)
|
||
|
||
const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-wire:shutdown:claude-sonnet-5:1')
|
||
expect(shutdown).toBeDefined()
|
||
expect(shutdown!.inputTokens).toBe(500) // 5000 − 3000 − 1500
|
||
expect(shutdown!.cacheReadInputTokens).toBe(3000)
|
||
expect(shutdown!.cacheCreationInputTokens).toBe(1500)
|
||
} finally {
|
||
vi.unstubAllEnvs()
|
||
}
|
||
})
|
||
})
|
||
|
||
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('reads request-level input tokens from snapshots, appended requests and patches', async () => {
|
||
// VS Code serializes usage.promptTokens directly on the request, even
|
||
// when the extension result has no metadata. Include an input-only row.
|
||
const filePath = fileURLToPath(new URL('../fixtures/copilot/request-token-journal.jsonl', import.meta.url))
|
||
const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' })
|
||
|
||
expect(calls.map(c => [c.inputTokens, c.outputTokens])).toEqual([[12345, 678], [1000, 0]])
|
||
expect(calls.map(c => c.costUSD)).toEqual([
|
||
calculateCost('gpt-4o', 12345, 678, 0, 0, 0),
|
||
calculateCost('gpt-4o', 1000, 0, 0, 0, 0),
|
||
])
|
||
})
|
||
|
||
it.each(['json-first', 'journal-first'])('deduplicates root usage across both formats (%s)', async order => {
|
||
const journalPath = fileURLToPath(new URL('../fixtures/copilot/request-token-journal.jsonl', import.meta.url))
|
||
const snapshot = JSON.parse((await readFile(journalPath, 'utf8')).split('\n')[0]!).v
|
||
snapshot.requests.push({ requestId: 'input-only', modelId: 'copilot/gpt-4o', promptTokens: 1000, completionTokens: 0 })
|
||
const jsonPath = join(tmpDir, 'root-usage.json')
|
||
await writeFile(jsonPath, JSON.stringify(snapshot))
|
||
const seen = new Set<string>()
|
||
const paths = order === 'json-first' ? [jsonPath, journalPath] : [journalPath, jsonPath]
|
||
const first = await collectCalls({ path: paths[0]!, project: 'p', provider: 'copilot', sourceType: 'chatsession' }, seen)
|
||
const second = await collectCalls({ path: paths[1]!, project: 'p', provider: 'copilot', sourceType: 'chatsession' }, seen)
|
||
expect(first.map(c => [c.inputTokens, c.outputTokens])).toEqual([[12345, 678], [1000, 0]])
|
||
expect(second).toEqual([])
|
||
expect([...seen]).toEqual(['copilot-chatsession:chat-root-usage:snapshot', 'copilot-chatsession:chat-root-usage:input-only'])
|
||
})
|
||
|
||
it.each([undefined, 0, -5, '500', null])('falls back to root input for invalid or absent metadata (%s)', async metadataInput => {
|
||
const path = join(tmpDir, 'fallback.jsonl')
|
||
await createChatSessionFile(path, [{ kind: 0, v: { sessionId: 'fallback', requests: [{
|
||
requestId: 'r', promptTokens: 1000, completionTokens: 20,
|
||
modelId: 'copilot/gpt-4o', result: { metadata: { promptTokens: metadataInput } },
|
||
}] } }])
|
||
const calls = await collectCalls({ path, project: 'p', provider: 'copilot', sourceType: 'chatsession' })
|
||
expect(calls.map(c => [c.inputTokens, c.outputTokens])).toEqual([[1000, 20]])
|
||
})
|
||
|
||
it('keeps metadata precedence without summing duplicate token fields', async () => {
|
||
const path = join(tmpDir, 'precedence.jsonl')
|
||
await createChatSessionFile(path, [{ kind: 0, v: { sessionId: 'precedence', requests: [{
|
||
requestId: 'r', promptTokens: 1000, completionTokens: 490, modelId: 'copilot/gpt-4o',
|
||
result: { metadata: { promptTokens: 500, outputTokens: 60 } },
|
||
}] } }])
|
||
const calls = await collectCalls({ path, project: 'p', provider: 'copilot', sourceType: 'chatsession' })
|
||
expect(calls.map(c => [c.inputTokens, c.outputTokens])).toEqual([[500, 60]])
|
||
})
|
||
|
||
it.each([0, -1, '1000', null])('skips invalid or zero root usage without estimating text (%s)', async promptTokens => {
|
||
const path = join(tmpDir, 'invalid.jsonl')
|
||
await createChatSessionFile(path, [{ kind: 0, v: { sessionId: 'invalid', requests: [{
|
||
requestId: 'r', promptTokens, completionTokens: 0, message: { text: 'No reported usage' },
|
||
}] } }])
|
||
expect(await collectCalls({ path, project: 'p', provider: 'copilot', sourceType: 'chatsession' })).toEqual([])
|
||
})
|
||
|
||
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('extracts structured skill calls without inferring from prompt text', async () => {
|
||
const filePath = join(tmpDir, 'skill.jsonl')
|
||
await createChatSessionFile(filePath, [
|
||
{ kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-skill', requests: [] } },
|
||
{ kind: 2, k: ['requests'], v: [chatSessionSampleRequest({
|
||
result: {
|
||
metadata: {
|
||
promptTokens: 100,
|
||
outputTokens: 20,
|
||
resolvedModel: 'claude-sonnet-4-6',
|
||
renderedUserMessage: [{ type: 1, text: 'Follow instructions in #prompt:SKILL.md' }],
|
||
toolCallRounds: [{
|
||
toolCalls: [{ name: 'skill', arguments: { skill: 'ponytail' } }],
|
||
}],
|
||
},
|
||
},
|
||
})] },
|
||
])
|
||
|
||
const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' })
|
||
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.tools).toEqual(['Skill'])
|
||
expect(calls[0]!.skills).toEqual(['ponytail'])
|
||
})
|
||
|
||
it('keeps every tool name a round record carries', async () => {
|
||
const filePath = join(tmpDir, 'multi-tool.jsonl')
|
||
await createChatSessionFile(filePath, [
|
||
{ kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-multi', requests: [] } },
|
||
{ kind: 2, k: ['requests'], v: [chatSessionSampleRequest({
|
||
result: {
|
||
metadata: {
|
||
promptTokens: 100,
|
||
outputTokens: 20,
|
||
resolvedModel: 'claude-sonnet-4-6',
|
||
toolCallRounds: [{ toolCalls: [{ name: 'read_file', tool: 'bash' }] }],
|
||
},
|
||
},
|
||
})] },
|
||
])
|
||
|
||
const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' })
|
||
|
||
expect(calls[0]!.tools).toEqual(['Read', 'Bash'])
|
||
})
|
||
|
||
it('extracts shell commands and the original prompt from chat-session metadata', async () => {
|
||
const filePath = join(tmpDir, 'shell-and-prompt.jsonl')
|
||
await createChatSessionFile(filePath, [
|
||
{ kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-shell', requests: [] } },
|
||
{ kind: 2, k: ['requests'], v: [chatSessionSampleRequest({
|
||
message: { text: 'Run the checks' },
|
||
result: {
|
||
metadata: {
|
||
promptTokens: 100,
|
||
outputTokens: 20,
|
||
resolvedModel: 'claude-sonnet-4-6',
|
||
renderedUserMessage: [{ type: 1, text: '<context>Injected system text</context>' }],
|
||
toolCallRounds: [{
|
||
toolCalls: [
|
||
{ name: 'run_in_terminal', arguments: JSON.stringify({ command: 'git status && bun test' }) },
|
||
{ name: 'runCommand', arguments: { command: 'npm run build' } },
|
||
{ name: 'run_in_terminal', arguments: '{bad json' },
|
||
{ name: 'read_file' },
|
||
],
|
||
}],
|
||
},
|
||
},
|
||
})] },
|
||
])
|
||
|
||
const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' })
|
||
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.tools).toEqual(['Shell', 'Read'])
|
||
expect(calls[0]!.bashCommands).toEqual(['git', 'bun', 'npm'])
|
||
expect(calls[0]!.userMessage).toBe('Run the checks')
|
||
})
|
||
|
||
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)
|
||
// Session-state files are tagged as CLI sources — the tag (not the file's
|
||
// producer value) decides transcript vs CLI parsing (#944).
|
||
expect(sessions.every(s => (s as { sourceType?: string }).sourceType === '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')
|
||
expect((sessions[0] as { sourceType?: string }).sourceType).toBe('transcript')
|
||
})
|
||
|
||
it('includes VSCodium workspaceStorage paths on all supported platforms', () => {
|
||
vi.stubEnv('APPDATA', undefined)
|
||
vi.stubEnv('XDG_CONFIG_HOME', undefined)
|
||
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', () => {
|
||
vi.stubEnv('APPDATA', undefined)
|
||
vi.stubEnv('XDG_CONFIG_HOME', undefined)
|
||
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('extracts structured skill calls from execute_tool spans', async () => {
|
||
if (!isSqliteAvailable()) return
|
||
|
||
createOtelDb(dbPath)
|
||
insertSpan(dbPath, {
|
||
spanId: 'span-skill-chat', traceId: 'trace-skill', operationName: 'chat', startTimeMs: 1000,
|
||
attrs: {
|
||
'gen_ai.conversation.id': 'conv-skill',
|
||
'gen_ai.response.model': 'gpt-4.1',
|
||
'gen_ai.usage.input_tokens': 100,
|
||
'gen_ai.usage.output_tokens': 20,
|
||
},
|
||
})
|
||
insertSpan(dbPath, {
|
||
spanId: 'span-skill-tool', traceId: 'trace-skill', operationName: 'execute_tool', startTimeMs: 1100,
|
||
attrs: {
|
||
'gen_ai.tool.name': 'skill',
|
||
'gen_ai.tool.call.arguments': JSON.stringify({ skill: 'ponytail' }),
|
||
},
|
||
})
|
||
|
||
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 = await collectCalls(src!)
|
||
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.tools).toContain('Skill')
|
||
expect(calls[0]!.skills).toEqual(['ponytail'])
|
||
})
|
||
|
||
it('attributes a trace-level skill to one chat span, not every span in the trace', async () => {
|
||
if (!isSqliteAvailable()) return
|
||
|
||
createOtelDb(dbPath)
|
||
// Five chat spans in ONE trace, each with its own tokens.
|
||
const perSpanTokens = [
|
||
{ input: 100, output: 20 },
|
||
{ input: 200, output: 40 },
|
||
{ input: 300, output: 60 },
|
||
{ input: 400, output: 80 },
|
||
{ input: 500, output: 100 },
|
||
]
|
||
perSpanTokens.forEach((t, i) => {
|
||
insertSpan(dbPath, {
|
||
spanId: `span-multi-${i}`, traceId: 'trace-multi', operationName: 'chat', startTimeMs: 1000 + i,
|
||
attrs: {
|
||
'gen_ai.conversation.id': 'conv-multi',
|
||
'gen_ai.response.model': 'gpt-4.1',
|
||
'gen_ai.usage.input_tokens': t.input,
|
||
'gen_ai.usage.output_tokens': t.output,
|
||
},
|
||
})
|
||
})
|
||
// One skill call for the whole trace.
|
||
insertSpan(dbPath, {
|
||
spanId: 'span-multi-tool', traceId: 'trace-multi', operationName: 'execute_tool', startTimeMs: 1500,
|
||
attrs: {
|
||
'gen_ai.tool.name': 'skill',
|
||
'gen_ai.tool.call.arguments': JSON.stringify({ skill: 'graphify' }),
|
||
},
|
||
})
|
||
|
||
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 = await collectCalls(src!)
|
||
|
||
expect(calls).toHaveLength(5)
|
||
// The skill and Skill tool land on exactly one span, not all five.
|
||
const withSkill = calls.filter(c => (c.skills ?? []).includes('graphify'))
|
||
expect(withSkill).toHaveLength(1)
|
||
expect(calls.filter(c => c.tools.includes('Skill'))).toHaveLength(1)
|
||
// Every other span carries no trace metadata.
|
||
expect(calls.filter(c => (c.skills ?? []).length > 0)).toHaveLength(1)
|
||
expect(calls.filter(c => c.tools.length > 0)).toHaveLength(1)
|
||
// Per-span token costs are untouched by the attribution fix.
|
||
const byInput = new Map(calls.map(c => [c.inputTokens, c]))
|
||
for (const t of perSpanTokens) {
|
||
const c = byInput.get(t.input)
|
||
expect(c).toBeDefined()
|
||
expect(c!.outputTokens).toBe(t.output)
|
||
expect(c!.costUSD).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
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)
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('copilot provider - OTel workspace attribution (#1529)', () => {
|
||
let tmpDir: string
|
||
let dbPath: string
|
||
let wsDir: string
|
||
|
||
beforeEach(async () => {
|
||
tmpDir = await mkdtemp(join(tmpdir(), 'copilot-otel-ws-test-'))
|
||
dbPath = join(tmpDir, 'agent-traces.db')
|
||
wsDir = join(tmpDir, 'workspaceStorage')
|
||
vi.stubEnv('CODEBURN_COPILOT_OTEL_DB', dbPath)
|
||
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '')
|
||
})
|
||
|
||
afterEach(async () => {
|
||
await rm(tmpDir, { recursive: true, force: true })
|
||
vi.unstubAllEnvs()
|
||
})
|
||
|
||
async function workspace(hash: string, workspaceJson: object | null, sessionFiles: string[], mtime?: Date): Promise<void> {
|
||
const dir = join(wsDir, hash, 'chatSessions')
|
||
await mkdir(dir, { recursive: true })
|
||
if (workspaceJson) await writeFile(join(wsDir, hash, 'workspace.json'), JSON.stringify(workspaceJson))
|
||
for (const file of sessionFiles) {
|
||
const path = join(dir, file)
|
||
const sessionId = file.replace(/\.jsonl?$/, '')
|
||
await writeFile(path, JSON.stringify({ kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId, requests: [] } }) + '\n')
|
||
if (mtime) await utimes(path, mtime, mtime)
|
||
}
|
||
}
|
||
|
||
function chatSpan(spanId: string, conversationId: string, extra: Record<string, string | number> = {}): void {
|
||
insertSpan(dbPath, {
|
||
spanId, traceId: `trace-${spanId}`, operationName: 'chat', startTimeMs: 1780157113020,
|
||
attrs: {
|
||
'gen_ai.conversation.id': conversationId,
|
||
'gen_ai.response.model': 'gpt-4.1',
|
||
'gen_ai.usage.input_tokens': 1000,
|
||
'gen_ai.usage.output_tokens': 100,
|
||
'copilot_chat.repo.head_branch_name': 'main',
|
||
'copilot_chat.repo.head_commit_hash': 'abc123',
|
||
...extra,
|
||
},
|
||
})
|
||
}
|
||
|
||
async function projectsBySpan(): Promise<Record<string, string>> {
|
||
const provider = createCopilotProvider('/nonexistent/jsonl', wsDir, '/nonexistent/global')
|
||
const sources = await provider.discoverSessions()
|
||
const otel = sources.filter(s => s.path === dbPath)
|
||
expect(otel).toHaveLength(1)
|
||
const out: Record<string, string> = {}
|
||
for await (const call of provider.createSessionParser(otel[0]!, new Set()).parse()) {
|
||
out[call.deduplicationKey!.replace('copilot-otel:', '')] = call.project!
|
||
}
|
||
return out
|
||
}
|
||
|
||
it('attributes each conversation to the workspace whose chatSessions hold its id', async () => {
|
||
if (!isSqliteAvailable()) return
|
||
createOtelDb(dbPath)
|
||
|
||
await workspace('h-win', { folder: 'file:///c%3A/Users/dev/wkspace-example' }, ['conv-win.jsonl'])
|
||
await workspace('h-multi', { workspace: 'file:///home/dev/team%20apps.code-workspace' }, ['conv-multi.jsonl'])
|
||
await workspace('h-wsl', { folder: 'vscode-remote://wsl%2Bubuntu/home/dev/wsl-app' }, ['conv-wsl.jsonl'])
|
||
await workspace('h-ssh', { folder: 'vscode-remote://ssh-remote%2Bbox/srv/ssh-app/' }, ['conv-ssh.jsonl'])
|
||
await workspace('h-untitled', { workspace: 'file:///Users/dev/Library/Application%20Support/Code/Workspaces/1780000000000/workspace.json' }, ['conv-untitled.jsonl'])
|
||
await workspace('h-nojson', null, ['conv-nojson.jsonl'])
|
||
await workspace('h-legacy', { folder: 'file:///home/dev/legacy-app' }, ['conv-legacy.json'])
|
||
await workspace('h-repo', { folder: 'file:///home/dev/checkout-dir' }, ['conv-repo.jsonl'])
|
||
|
||
chatSpan('s-win', 'conv-win')
|
||
chatSpan('s-multi', 'conv-multi')
|
||
chatSpan('s-wsl', 'conv-wsl')
|
||
chatSpan('s-ssh', 'conv-ssh')
|
||
chatSpan('s-untitled', 'conv-untitled')
|
||
chatSpan('s-nojson', 'conv-nojson')
|
||
chatSpan('s-legacy', 'conv-legacy')
|
||
chatSpan('s-unknown', 'conv-unknown')
|
||
chatSpan('s-repo', 'conv-repo', { 'github.copilot.git.repository': 'org/real-repo' })
|
||
|
||
expect(await projectsBySpan()).toEqual({
|
||
's-win': 'wkspace-example',
|
||
's-multi': 'team apps',
|
||
's-wsl': 'wsl-app',
|
||
's-ssh': 'ssh-app',
|
||
's-untitled': 'copilot-chat',
|
||
's-nojson': 'copilot-chat',
|
||
's-legacy': 'legacy-app',
|
||
's-unknown': 'copilot-chat',
|
||
's-repo': 'real-repo',
|
||
})
|
||
})
|
||
|
||
it('links through copilot_chat.chat_session_id when the conversation id is a request id', async () => {
|
||
if (!isSqliteAvailable()) return
|
||
createOtelDb(dbPath)
|
||
await workspace('h1', { folder: 'file:///home/dev/app-one' }, ['vscode-session-1.jsonl'])
|
||
// Older Copilot Chat builds set gen_ai.conversation.id on chat spans to the
|
||
// request id; the VS Code session id rides on copilot_chat.chat_session_id.
|
||
chatSpan('s-req', 'request-7f3a', { 'copilot_chat.chat_session_id': 'vscode-session-1' })
|
||
// A subagent's chat span carries its invocation id as chat_session_id and
|
||
// the owning VS Code session as parent_chat_session_id.
|
||
chatSpan('s-sub', 'request-9c1d', {
|
||
'copilot_chat.chat_session_id': 'subagent-invocation-1',
|
||
'copilot_chat.parent_chat_session_id': 'vscode-session-1',
|
||
})
|
||
|
||
expect(await projectsBySpan()).toEqual({ 's-req': 'app-one', 's-sub': 'app-one' })
|
||
})
|
||
|
||
it('picks the most recently written copy when one conversation is in two workspaces', async () => {
|
||
if (!isSqliteAvailable()) return
|
||
createOtelDb(dbPath)
|
||
await workspace('h-old', { folder: 'file:///home/dev/old-name' }, ['conv-moved.jsonl'], new Date('2026-09-01T00:00:00Z'))
|
||
await workspace('h-new', { folder: 'file:///home/dev/new-name' }, ['conv-moved.jsonl'], new Date('2026-09-20T00:00:00Z'))
|
||
chatSpan('s-moved', 'conv-moved')
|
||
|
||
expect(await projectsBySpan()).toEqual({ 's-moved': 'new-name' })
|
||
})
|
||
|
||
it('changes only the project, never the tokens or cost', async () => {
|
||
if (!isSqliteAvailable()) return
|
||
createOtelDb(dbPath)
|
||
chatSpan('s-a', 'conv-a')
|
||
chatSpan('s-b', 'conv-b', { 'gen_ai.usage.cache_read.input_tokens': 5000 })
|
||
|
||
const parse = async (dir: string) => {
|
||
const provider = createCopilotProvider('/nonexistent/jsonl', dir, '/nonexistent/global')
|
||
const [source] = (await provider.discoverSessions()).filter(s => s.path === dbPath)
|
||
const calls: ParsedProviderCall[] = []
|
||
for await (const call of provider.createSessionParser(source!, new Set()).parse()) calls.push(call)
|
||
return calls.sort((x, y) => x.deduplicationKey!.localeCompare(y.deduplicationKey!))
|
||
}
|
||
const before = await parse('/nonexistent/ws')
|
||
await workspace('h-a', { folder: 'file:///home/dev/proj-a' }, ['conv-a.jsonl'])
|
||
const after = await parse(wsDir)
|
||
|
||
expect(before.map(c => c.project)).toEqual(['copilot-chat', 'copilot-chat'])
|
||
expect(after.map(c => c.project)).toEqual(['proj-a', 'copilot-chat'])
|
||
expect(after.map(c => ({ ...c, project: '' }))).toEqual(before.map(c => ({ ...c, project: '' })))
|
||
})
|
||
|
||
it('names a multi-root workspace after its .code-workspace file for chatSessions too', async () => {
|
||
await workspace('h-multi', { workspace: 'file:///home/dev/team.code-workspace' }, ['chat-multi-root.jsonl'])
|
||
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1')
|
||
const provider = createCopilotProvider('/nonexistent/jsonl', wsDir, '/nonexistent/global')
|
||
const sources = await provider.discoverSessions()
|
||
expect(sources.filter(s => s.path.includes('chat-multi-root')).map(s => s.project)).toEqual(['team'])
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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.
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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]!
|
||
// <sid>:<rowId> plus a content discriminator (created_at + token counts +
|
||
// model, hashed) so a same-path DB reset reusing AUTOINCREMENT ids can
|
||
// never alias a different request onto a cached key.
|
||
expect(first.deduplicationKey).toMatch(/^copilot-store:sess-a:1:[0-9a-z]+$/)
|
||
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).toMatch(/^copilot-store:sess-a:2:[0-9a-z]+$/)
|
||
expect(second.inputTokens).toBe(2) // 24793 - 24678 - 113
|
||
expect(second.cacheReadInputTokens).toBe(24678)
|
||
expect(second.cacheCreationInputTokens).toBe(113)
|
||
})
|
||
|
||
it('probeRoots covers every discovery root, with the store contributing its parent directory', async () => {
|
||
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', join(tmpDir, 'store', 'session-store.db'))
|
||
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', join(tmpDir, 'state'))
|
||
vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpDir, 'ws'))
|
||
vi.stubEnv('CODEBURN_COPILOT_GLOBAL_STORAGE_DIR', join(tmpDir, 'global'))
|
||
vi.stubEnv('CODEBURN_COPILOT_JETBRAINS_DIR', join(tmpDir, 'jb'))
|
||
|
||
const roots = await copilot.probeRoots!()
|
||
const paths = roots.map(r => r.path)
|
||
// The PARENT of the store DB, not the file: SQLite appends land in
|
||
// -wal/-shm siblings a file watch would miss — and a resident watcher
|
||
// built from these roots is what entitles the validated-reuse 'clean'
|
||
// verdict to speak for copilot at all.
|
||
expect(paths).toContain(join(tmpDir, 'store'))
|
||
expect(paths).toContain(join(tmpDir, 'state'))
|
||
expect(paths).toContain(join(tmpDir, 'ws'))
|
||
expect(paths).toContain(join(tmpDir, 'global'))
|
||
expect(paths).toContain(join(tmpDir, 'jb'))
|
||
expect(new Set(paths).size).toBe(paths.length)
|
||
})
|
||
|
||
it('captures total_nano_aiu and request_multiplier when the schema has them', async () => {
|
||
// Billing-schema store (newer CLI): the optional columns ride the calls
|
||
// as capture-only metadata — no pricing or display consumes them (#890)
|
||
// — and stay OUT of the dedup-key content hash.
|
||
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,
|
||
total_nano_aiu INTEGER, request_multiplier REAL,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
`)
|
||
db.prepare(`INSERT INTO sessions (id, cwd) VALUES ('sess-aiu', '/home/user/proj')`).run()
|
||
db.prepare(
|
||
`INSERT INTO assistant_usage_events
|
||
(session_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, total_nano_aiu, request_multiplier, created_at)
|
||
VALUES ('sess-aiu', 'claude-sonnet-4-5', 1000, 20, 600, 300, 0, 24594000000, 15.0, '2026-08-07T18:00:00.000Z')`
|
||
).run()
|
||
db.close()
|
||
|
||
const calls = await collectCalls(storeSource(dbPath))
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.nanoAiu).toBe(24594000000)
|
||
expect(calls[0]!.requestMultiplier).toBe(15)
|
||
expect(calls[0]!.inputTokens).toBe(100) // 1000 - 600 - 300
|
||
})
|
||
|
||
it('parses identically when the billing columns are absent (older store schema)', async () => {
|
||
createSessionStoreDb(dbPath) // schema predates total_nano_aiu / request_multiplier
|
||
insertUsageRow(dbPath, {
|
||
sessionId: 'sess-old', model: 'claude-sonnet-4-5',
|
||
inputTokens: 500, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||
createdAt: '2026-08-07T18:00:00.000Z',
|
||
})
|
||
const calls = await collectCalls(storeSource(dbPath))
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.inputTokens).toBe(500)
|
||
expect(calls[0]!.nanoAiu).toBeUndefined()
|
||
expect(calls[0]!.requestMultiplier).toBeUndefined()
|
||
})
|
||
|
||
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<string>()
|
||
const first = await collectCalls(storeSource(dbPath), seen)
|
||
expect(first.map(c => c.deduplicationKey)).toEqual([
|
||
expect.stringMatching(/^copilot-store:sess-grow:1:[0-9a-z]+$/),
|
||
])
|
||
|
||
// Unchanged store re-parsed with the shared dedup set: nothing re-emits —
|
||
// the key (including its content discriminator) is stable across parses.
|
||
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([
|
||
expect.stringMatching(/^copilot-store:sess-grow:2:[0-9a-z]+$/),
|
||
])
|
||
})
|
||
|
||
it('gives a reused row id a NEW key when the DB was recreated with different content', async () => {
|
||
// Same path, same session, same AUTOINCREMENT id — but the store was
|
||
// deleted and recreated, so row id 1 now describes a DIFFERENT request.
|
||
// A bare <sid>:<rowId> key would make the durable union swallow the new
|
||
// row as already-cached, losing its usage; the content discriminator
|
||
// must split the two. A byte-identical re-insert (backup restore) must
|
||
// still collapse to the SAME key.
|
||
createSessionStoreDb(dbPath)
|
||
insertUsageRow(dbPath, {
|
||
sessionId: 'sess-reset', model: 'gpt-5',
|
||
inputTokens: 100, cacheReadTokens: 0, createdAt: '2026-08-07T10:00:00.000Z',
|
||
})
|
||
const before = await collectCalls(storeSource(dbPath))
|
||
expect(before).toHaveLength(1)
|
||
|
||
await rm(dbPath, { force: true })
|
||
createSessionStoreDb(dbPath)
|
||
insertUsageRow(dbPath, {
|
||
sessionId: 'sess-reset', model: 'gpt-5',
|
||
inputTokens: 200, cacheReadTokens: 0, createdAt: '2026-08-08T10:00:00.000Z',
|
||
})
|
||
const after = await collectCalls(storeSource(dbPath))
|
||
expect(after).toHaveLength(1)
|
||
expect(after[0]!.deduplicationKey).not.toBe(before[0]!.deduplicationKey)
|
||
expect(after[0]!.inputTokens).toBe(200)
|
||
|
||
// Identical content re-inserted under the same id: the key must NOT move.
|
||
await rm(dbPath, { force: true })
|
||
createSessionStoreDb(dbPath)
|
||
insertUsageRow(dbPath, {
|
||
sessionId: 'sess-reset', model: 'gpt-5',
|
||
inputTokens: 200, cacheReadTokens: 0, createdAt: '2026-08-08T10:00:00.000Z',
|
||
})
|
||
const restored = await collectCalls(storeSource(dbPath))
|
||
expect(restored[0]!.deduplicationKey).toBe(after[0]!.deduplicationKey)
|
||
})
|
||
|
||
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<string>()
|
||
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([
|
||
expect.stringMatching(/^copilot-store:sess-covered:1:[0-9a-z]+$/),
|
||
])
|
||
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.
|
||
// Windows has no POSIX mode denial: chmod only toggles the read-only
|
||
// attribute, so the store stays readable and there is nothing to observe.
|
||
if (process.platform === 'win32') return
|
||
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.
|
||
// Windows has no POSIX mode denial: chmod only toggles the read-only
|
||
// attribute, so the store stays readable and there is nothing to observe.
|
||
if (process.platform === 'win32') return
|
||
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-'))
|
||
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\\\\\\\":...}\"}"}}
|
||
// JetBrains records VirtualFile URLs: forward slashes throughout, and a
|
||
// leading slash ahead of a Windows drive letter (file:///C:/repo/One.ts).
|
||
function jbFileUrl(filePath: string): string {
|
||
const slashed = filePath.replace(/\\/g, '/')
|
||
return `file://${slashed.startsWith('/') ? '' : '/'}${slashed}`
|
||
}
|
||
|
||
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(jbFileUrl).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))
|
||
})
|
||
})
|
||
|
||
describe('copilot provider - legacy JSON format', () => {
|
||
beforeEach(async () => {
|
||
tmpDir = await mkdtemp(join(tmpdir(), 'copilot-legacy-'))
|
||
})
|
||
afterEach(async () => {
|
||
await rm(tmpDir, { recursive: true, force: true })
|
||
})
|
||
|
||
it('parses legacy JSON session with string message and markdownContent response', async () => {
|
||
const session = {
|
||
sessionId: 'sess-json-001',
|
||
creationDate: 1718000000000,
|
||
requests: [
|
||
{
|
||
requestId: 'req-1',
|
||
message: 'explain quantum physics',
|
||
modelId: 'copilot/gpt-4o',
|
||
response: [
|
||
{
|
||
kind: 'markdownContent',
|
||
content: { value: 'Quantum physics is...' }
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
|
||
const filePath = join(tmpDir, 'session-1.json')
|
||
await writeFile(filePath, JSON.stringify(session))
|
||
|
||
const source = { path: filePath, project: 'test-project', provider: 'copilot', sourceType: 'chatsession' }
|
||
const calls: ParsedProviderCall[] = []
|
||
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.provider).toBe('copilot')
|
||
expect(calls[0]!.model).toBe('gpt-4o')
|
||
expect(calls[0]!.userMessage).toBe('explain quantum physics')
|
||
expect(calls[0]!.inputTokens).toBeGreaterThan(0)
|
||
expect(calls[0]!.outputTokens).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('strips the older github.copilot-chat/ namespace so the model prices', async () => {
|
||
const session = {
|
||
sessionId: 'sess-json-ghcc',
|
||
creationDate: 1718000000000,
|
||
requests: [
|
||
{
|
||
requestId: 'req-1',
|
||
message: 'hi',
|
||
modelId: 'github.copilot-chat/claude-sonnet-4',
|
||
response: [{ kind: 'markdownContent', content: { value: 'hello there' } }]
|
||
}
|
||
]
|
||
}
|
||
|
||
const filePath = join(tmpDir, 'session-ghcc.json')
|
||
await writeFile(filePath, JSON.stringify(session))
|
||
|
||
const source = { path: filePath, project: 'test-project', provider: 'copilot', sourceType: 'chatsession' }
|
||
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('claude-sonnet-4')
|
||
})
|
||
|
||
it('infers model when modelId is missing on the request', async () => {
|
||
const session = {
|
||
requests: [
|
||
{
|
||
requestId: 'req-1',
|
||
message: 'hello',
|
||
response: [
|
||
{
|
||
kind: 'toolInvocationSerialized',
|
||
toolCallId: 'tooluse_abc123',
|
||
toolId: 'read_file'
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
|
||
const filePath = join(tmpDir, 'session-2.json')
|
||
await writeFile(filePath, JSON.stringify(session))
|
||
|
||
const source = { path: filePath, project: 'test-project', provider: 'copilot', sourceType: 'chatsession' }
|
||
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('extracts thinking/reasoning text and reasoning tokens', async () => {
|
||
const session = {
|
||
requests: [
|
||
{
|
||
requestId: 'req-1',
|
||
message: 'solve this',
|
||
modelId: 'copilot/claude-sonnet-4.5',
|
||
response: [
|
||
{
|
||
kind: 'thinking',
|
||
value: 'Let me analyze the problem step by step.'
|
||
},
|
||
{
|
||
kind: 'markdownContent',
|
||
content: { value: 'The solution is 42.' }
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
|
||
const filePath = join(tmpDir, 'session-3.json')
|
||
await writeFile(filePath, JSON.stringify(session))
|
||
|
||
const source = { path: filePath, project: 'test-project', provider: 'copilot', sourceType: 'chatsession' }
|
||
const calls: ParsedProviderCall[] = []
|
||
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.reasoningTokens).toBeGreaterThan(0)
|
||
expect(calls[0]!.outputTokens).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('extracts tool result content from serialized tool invocations', async () => {
|
||
const base64Data = Buffer.from('simulated file read content', 'utf8').toString('base64')
|
||
const session = {
|
||
requests: [
|
||
{
|
||
requestId: 'req-1',
|
||
message: 'read file',
|
||
modelId: 'copilot/gpt-4o',
|
||
response: [
|
||
{
|
||
kind: 'toolInvocationSerialized',
|
||
toolId: 'read_file',
|
||
resultDetails: {
|
||
output: {
|
||
type: 'data',
|
||
mimeType: 'text/plain',
|
||
base64Data: base64Data
|
||
}
|
||
}
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
|
||
const filePath = join(tmpDir, 'session-4.json')
|
||
await writeFile(filePath, JSON.stringify(session))
|
||
|
||
const source = { path: filePath, project: 'test-project', provider: 'copilot', sourceType: 'chatsession' }
|
||
const calls: ParsedProviderCall[] = []
|
||
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||
|
||
expect(calls).toHaveLength(1)
|
||
// inputTokens should include the message + decoded base64 tool result (approx 7 + 7 tokens)
|
||
expect(calls[0]!.inputTokens).toBe(10)
|
||
})
|
||
|
||
it('uses exact root token properties when present', async () => {
|
||
const session = {
|
||
requests: [
|
||
{
|
||
requestId: 'req-1',
|
||
message: 'hello',
|
||
modelId: 'copilot/gpt-4o',
|
||
promptTokens: 100,
|
||
completionTokens: 200
|
||
}
|
||
]
|
||
}
|
||
|
||
const filePath = join(tmpDir, 'session-5.json')
|
||
await writeFile(filePath, JSON.stringify(session))
|
||
|
||
const source = { path: filePath, project: 'test-project', provider: 'copilot', sourceType: 'chatsession' }
|
||
const calls: ParsedProviderCall[] = []
|
||
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.inputTokens).toBe(100)
|
||
expect(calls[0]!.outputTokens).toBe(200)
|
||
expect(calls[0]!.costIsEstimated).toBe(false)
|
||
})
|
||
|
||
it('bills reasoning tokens at output price instead of web search requests ($0.01/search)', async () => {
|
||
// 8,000 chars of thinking is 2,000 reasoning tokens.
|
||
// At $0.01/search (web search price), 2,000 would bill $20.
|
||
// At claude-sonnet-4.6 output price ($15/MTok), 2,000 tokens is ~$0.03.
|
||
const session = {
|
||
sessionId: 'sess-reasoning-001',
|
||
requests: [
|
||
{
|
||
requestId: 'req-reasoning',
|
||
message: 'Solve a hard math problem',
|
||
modelId: 'copilot/claude-sonnet-4.6',
|
||
response: [
|
||
{
|
||
kind: 'thinking',
|
||
value: 'A'.repeat(8000),
|
||
},
|
||
{
|
||
kind: 'markdownContent',
|
||
content: { value: 'Result is 42.' },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
}
|
||
|
||
const filePath = join(tmpDir, 'reasoning.json')
|
||
await writeFile(filePath, JSON.stringify(session))
|
||
|
||
const source = { path: filePath, project: 'test-project', provider: 'copilot', sourceType: 'chatsession' }
|
||
const calls = await collectCalls(source)
|
||
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.model).toBe('claude-sonnet-4.6')
|
||
expect(calls[0]!.reasoningTokens).toBe(2001)
|
||
// Cost must be ~0.03, definitely NOT >= $1 (which would happen if billed as web search)
|
||
expect(calls[0]!.costUSD).toBeLessThan(0.1)
|
||
expect(calls[0]!.costUSD).toBeGreaterThan(0.02)
|
||
})
|
||
|
||
it('extracts split tokens in .jsonl (metadata.promptTokens + root completionTokens) and skips 0-token rows', async () => {
|
||
const filePath = join(tmpDir, 'split-tokens.jsonl')
|
||
await createChatSessionFile(filePath, [
|
||
{
|
||
kind: 0,
|
||
v: {
|
||
version: 3,
|
||
creationDate: 1780157113020,
|
||
sessionId: 'chat-split-tokens',
|
||
requests: [],
|
||
},
|
||
},
|
||
{
|
||
kind: 2,
|
||
k: ['requests'],
|
||
v: [
|
||
// Row 1: Real-data shape with promptTokens in metadata and completionTokens at root (no metadata.outputTokens)
|
||
{
|
||
requestId: 'req-split-1',
|
||
modelId: 'copilot/claude-sonnet-4.6',
|
||
completionTokens: 490,
|
||
result: {
|
||
metadata: {
|
||
promptTokens: 32543,
|
||
resolvedModel: 'claude-sonnet-4-6',
|
||
},
|
||
},
|
||
},
|
||
// Row 2: No token fields at all; must be skipped in .jsonl (never char-estimated)
|
||
{
|
||
requestId: 'req-empty-tokens',
|
||
modelId: 'copilot/claude-sonnet-4.6',
|
||
message: 'A message with no tokens recorded',
|
||
response: [{ kind: 'markdownContent', content: { value: 'Some output text' } }],
|
||
},
|
||
// Row 3: Zero tokens; must be skipped in .jsonl
|
||
{
|
||
requestId: 'req-zero-tokens',
|
||
modelId: 'copilot/claude-sonnet-4.6',
|
||
promptTokens: 0,
|
||
completionTokens: 0,
|
||
},
|
||
// Row 4: metadata.outputTokens is 0, must fall back to root completionTokens (250)
|
||
{
|
||
requestId: 'req-split-2',
|
||
modelId: 'copilot/claude-sonnet-4.6',
|
||
completionTokens: 250,
|
||
result: {
|
||
metadata: {
|
||
promptTokens: 500,
|
||
outputTokens: 0,
|
||
resolvedModel: 'claude-sonnet-4-6',
|
||
},
|
||
},
|
||
},
|
||
],
|
||
},
|
||
])
|
||
|
||
const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' })
|
||
|
||
expect(calls).toHaveLength(2)
|
||
expect(calls[0]!.inputTokens).toBe(32543)
|
||
expect(calls[0]!.outputTokens).toBe(490)
|
||
expect(calls[0]!.costUSD).toBe(calculateCost('claude-sonnet-4-6', 32543, 490, 0, 0, 0))
|
||
|
||
expect(calls[1]!.inputTokens).toBe(500)
|
||
expect(calls[1]!.outputTokens).toBe(250)
|
||
expect(calls[1]!.costUSD).toBe(calculateCost('claude-sonnet-4-6', 500, 250, 0, 0, 0))
|
||
})
|
||
|
||
const roundWithThinking = {
|
||
summary: 'Reading the file',
|
||
phase: 'execute',
|
||
response: 'done',
|
||
toolCalls: [{ name: 'read_file', id: 'call_9f3a2b7c1d', arguments: '{}' }],
|
||
thinking: { id: 'think-1', text: 'x'.repeat(8000) },
|
||
}
|
||
|
||
it('keeps .jsonl tools, cost and userMessage identical to main when thinking and round metadata are present', async () => {
|
||
const filePath = join(tmpDir, 'jsonl-parity.jsonl')
|
||
await createChatSessionFile(filePath, [
|
||
{
|
||
kind: 0,
|
||
v: {
|
||
version: 3,
|
||
creationDate: 1780157113020,
|
||
sessionId: 'chat-jsonl-parity',
|
||
requests: [
|
||
{
|
||
requestId: 'req-parity',
|
||
modelId: 'copilot/claude-sonnet-4.6',
|
||
message: { text: 'y'.repeat(800), parts: [] },
|
||
result: {
|
||
metadata: {
|
||
promptTokens: 32543,
|
||
outputTokens: 60,
|
||
resolvedModel: 'claude-sonnet-4-6',
|
||
toolCallRounds: [roundWithThinking],
|
||
},
|
||
},
|
||
},
|
||
],
|
||
},
|
||
},
|
||
])
|
||
|
||
const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' })
|
||
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.tools).toEqual(['Read'])
|
||
expect(calls[0]!.reasoningTokens).toBe(0)
|
||
expect(calls[0]!.costUSD).toBe(calculateCost('claude-sonnet-4-6', 32543, 60, 0, 0, 0))
|
||
expect(calls[0]!.userMessage).toBe('y'.repeat(800))
|
||
})
|
||
|
||
it('does not treat round summary, phase or tool call ids as tool names in legacy .json', async () => {
|
||
const session = {
|
||
sessionId: 'sess-json-tools',
|
||
requests: [
|
||
{
|
||
requestId: 'req-1',
|
||
modelId: 'copilot/claude-sonnet-4.6',
|
||
message: 'read it',
|
||
promptTokens: 32543,
|
||
completionTokens: 60,
|
||
result: { metadata: { toolCallRounds: [roundWithThinking] } },
|
||
},
|
||
],
|
||
}
|
||
const filePath = join(tmpDir, 'session-tools.json')
|
||
await writeFile(filePath, JSON.stringify(session))
|
||
|
||
const calls = await collectCalls({ path: filePath, project: 'test-project', provider: 'copilot', sourceType: 'chatsession' })
|
||
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.tools).toEqual(['Read'])
|
||
expect(calls[0]!.reasoningTokens).toBe(0)
|
||
expect(calls[0]!.costIsEstimated).toBe(false)
|
||
expect(calls[0]!.costUSD).toBe(calculateCost('claude-sonnet-4-6', 32543, 60, 0, 0, 0))
|
||
})
|
||
|
||
it('preserves dotted model IDs without replacing dots with dashes', async () => {
|
||
const session = {
|
||
sessionId: 'sess-dotted-models',
|
||
requests: [
|
||
{
|
||
requestId: 'req-gemini',
|
||
modelId: 'copilot/gemini-2.0-flash-001',
|
||
promptTokens: 1000,
|
||
completionTokens: 1000,
|
||
},
|
||
{
|
||
requestId: 'req-gpt45',
|
||
modelId: 'copilot/gpt-4.5-preview',
|
||
promptTokens: 1000,
|
||
completionTokens: 1000,
|
||
},
|
||
{
|
||
requestId: 'req-gpt53',
|
||
modelId: 'copilot/gpt-5.3-codex-spark',
|
||
promptTokens: 1000,
|
||
completionTokens: 1000,
|
||
},
|
||
],
|
||
}
|
||
|
||
const filePath = join(tmpDir, 'dotted-models.json')
|
||
await writeFile(filePath, JSON.stringify(session))
|
||
|
||
const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' })
|
||
|
||
expect(calls).toHaveLength(3)
|
||
expect(calls[0]!.model).toBe('gemini-2.0-flash-001')
|
||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||
|
||
expect(calls[1]!.model).toBe('gpt-4.5-preview')
|
||
expect(calls[1]!.costUSD).toBeGreaterThan(0)
|
||
|
||
expect(calls[2]!.model).toBe('gpt-5.3-codex-spark')
|
||
expect(calls[2]!.costUSD).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('defaults unresolvable model to unknown with $0 cost', async () => {
|
||
const session = {
|
||
sessionId: 'sess-unresolvable',
|
||
requests: [
|
||
{
|
||
requestId: 'req-unresolvable',
|
||
message: 'Hello mystery model',
|
||
response: [
|
||
{
|
||
kind: 'markdownContent',
|
||
content: { value: 'Mystery response' },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
}
|
||
|
||
const filePath = join(tmpDir, 'unresolvable.json')
|
||
await writeFile(filePath, JSON.stringify(session))
|
||
|
||
const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' })
|
||
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.model).toBe('unknown')
|
||
expect(calls[0]!.costUSD).toBe(0)
|
||
})
|
||
|
||
it('suppresses transcripts when workspace has only legacy .json chatSessions', async () => {
|
||
const wsDir = join(tmpDir, 'vscode-ws-legacy-json')
|
||
const hashDir = join(wsDir, 'hash-json-only')
|
||
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/legacyapp' }))
|
||
|
||
const session = {
|
||
sessionId: 'sess-json-suppress',
|
||
requests: [
|
||
{
|
||
requestId: 'req-1',
|
||
message: 'test',
|
||
modelId: 'copilot/gpt-4o',
|
||
promptTokens: 50,
|
||
completionTokens: 50,
|
||
},
|
||
],
|
||
}
|
||
await writeFile(join(chatSessionsDir, 'chat.json'), JSON.stringify(session))
|
||
await writeFile(join(transcriptsDir, 'transcript.jsonl'), transcriptSessionStart('trans-1') + '\n')
|
||
|
||
const provider = createCopilotProvider('/nonexistent/legacy', wsDir, '/nonexistent/global')
|
||
const sessions = await provider.discoverSessions()
|
||
|
||
// Must discover only the chatSessions source and suppress the transcript
|
||
expect(sessions).toHaveLength(1)
|
||
expect((sessions[0] as { sourceType?: string }).sourceType).toBe('chatsession')
|
||
expect(sessions[0]!.path).toContain('chat.json')
|
||
})
|
||
|
||
it('deduplicates between .json and .jsonl when sharing sessionId and requestId', async () => {
|
||
const sessionId = 'shared-session-guid-123'
|
||
const requestId = 'req-shared-456'
|
||
|
||
const jsonSession = {
|
||
sessionId,
|
||
requests: [
|
||
{
|
||
requestId,
|
||
message: 'hello from json',
|
||
modelId: 'copilot/gpt-4o',
|
||
promptTokens: 100,
|
||
completionTokens: 200,
|
||
},
|
||
],
|
||
}
|
||
const jsonPath = join(tmpDir, 'session.json')
|
||
await writeFile(jsonPath, JSON.stringify(jsonSession))
|
||
|
||
const jsonlPath = join(tmpDir, 'session.jsonl')
|
||
await createChatSessionFile(jsonlPath, [
|
||
{
|
||
kind: 0,
|
||
v: {
|
||
version: 3,
|
||
creationDate: 1780157113020,
|
||
sessionId,
|
||
requests: [],
|
||
},
|
||
},
|
||
{
|
||
kind: 2,
|
||
k: ['requests'],
|
||
v: [
|
||
{
|
||
requestId,
|
||
modelId: 'copilot/gpt-4o',
|
||
promptTokens: 100,
|
||
completionTokens: 200,
|
||
},
|
||
],
|
||
},
|
||
])
|
||
|
||
const seenKeys = new Set<string>()
|
||
const callsJson = await collectCalls({ path: jsonPath, project: 'proj', provider: 'copilot', sourceType: 'chatsession' }, seenKeys)
|
||
const callsJsonl = await collectCalls({ path: jsonlPath, project: 'proj', provider: 'copilot', sourceType: 'chatsession' }, seenKeys)
|
||
|
||
expect(callsJson).toHaveLength(1)
|
||
expect(callsJsonl).toHaveLength(0) // Second parse is deduplicated against seenKeys
|
||
expect(seenKeys.has(`copilot-chatsession:${sessionId}:${requestId}`)).toBe(true)
|
||
})
|
||
|
||
it('estimates cacheCreation and cacheRead tokens from renderedGlobalContext and renderedUserMessage with CacheBreakpoint and toolCallRounds', async () => {
|
||
const session = {
|
||
sessionId: 'sess-rendered-cache',
|
||
requests: [
|
||
{
|
||
requestId: 'req-cache',
|
||
message: 'Find bugs in the code',
|
||
modelId: 'copilot/gpt-4o',
|
||
result: {
|
||
metadata: {
|
||
renderedGlobalContext: [
|
||
{
|
||
type: ChatCompletionContentPartKind.Text,
|
||
text: 'Global system prompt: you are an AI programming assistant with extensive context.\n'.repeat(10),
|
||
},
|
||
{
|
||
type: ChatCompletionContentPartKind.CacheBreakpoint,
|
||
},
|
||
],
|
||
renderedUserMessage: [
|
||
{
|
||
type: ChatCompletionContentPartKind.Text,
|
||
text: 'Find bugs in the code\nAdditional context and file attachments here.\n'.repeat(10),
|
||
},
|
||
{
|
||
type: ChatCompletionContentPartKind.CacheBreakpoint,
|
||
},
|
||
],
|
||
toolCallRounds: [
|
||
{
|
||
summary: 'Search for references',
|
||
phase: 'Executing',
|
||
toolCalls: [{ id: 'call_grep_1', name: 'grep_search' }],
|
||
thinking: { tokens: 120 },
|
||
},
|
||
],
|
||
},
|
||
},
|
||
response: [
|
||
{
|
||
kind: 'markdownContent',
|
||
content: { value: 'No bugs found in the code.' },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
}
|
||
|
||
const filePath = join(tmpDir, 'rendered-cache.json')
|
||
await writeFile(filePath, JSON.stringify(session))
|
||
|
||
const calls = await collectCalls({ path: filePath, project: 'test-project', provider: 'copilot', sourceType: 'chatsession' })
|
||
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.costIsEstimated).toBe(true)
|
||
expect(calls[0]!.cacheReadInputTokens).toBeGreaterThan(0)
|
||
expect(calls[0]!.cacheCreationInputTokens).toBeGreaterThan(0)
|
||
expect(calls[0]!.reasoningTokens).toBe(120)
|
||
expect(calls[0]!.tools).toContain('Search')
|
||
})
|
||
|
||
it('does not count a binary tool output (screenshot) as input text', async () => {
|
||
const png = Buffer.alloc(200_000, 137).toString('base64')
|
||
const session = {
|
||
sessionId: 'sess-image',
|
||
requests: [
|
||
{
|
||
requestId: 'req-1',
|
||
message: 'screenshot',
|
||
modelId: 'copilot/claude-sonnet-4.5',
|
||
response: [
|
||
{ kind: 'toolInvocationSerialized', toolId: 'browser_screenshot', resultDetails: { output: { type: 'data', mimeType: 'image/png', base64Data: png } } },
|
||
{ kind: 'markdownContent', content: { value: 'Here is the page.' } },
|
||
],
|
||
},
|
||
],
|
||
}
|
||
const filePath = join(tmpDir, 'image.json')
|
||
await writeFile(filePath, JSON.stringify(session))
|
||
|
||
const calls = await collectCalls({ path: filePath, project: 'test-project', provider: 'copilot', sourceType: 'chatsession' })
|
||
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.inputTokens).toBe(3)
|
||
expect(calls[0]!.costUSD).toBeLessThan(0.001)
|
||
})
|
||
|
||
it('uses the recorded thinking token count over the char estimate, summed per round', async () => {
|
||
const session = {
|
||
sessionId: 'sess-thinking-tokens',
|
||
requests: [
|
||
{
|
||
requestId: 'req-1',
|
||
message: 'hi',
|
||
modelId: 'copilot/claude-sonnet-4.5',
|
||
result: {
|
||
metadata: {
|
||
toolCallRounds: [
|
||
{ response: 'ok', toolCalls: [], thinking: { text: 'x'.repeat(4000), tokens: 300 } },
|
||
{ response: 'done', toolCalls: [], thinking: { text: 'y'.repeat(4000), tokens: 200 } },
|
||
{ response: 'end', toolCalls: [], thinking: { text: 'z'.repeat(400) } },
|
||
],
|
||
},
|
||
},
|
||
},
|
||
],
|
||
}
|
||
const filePath = join(tmpDir, 'thinking-tokens.json')
|
||
await writeFile(filePath, JSON.stringify(session))
|
||
|
||
const calls = await collectCalls({ path: filePath, project: 'test-project', provider: 'copilot', sourceType: 'chatsession' })
|
||
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]!.reasoningTokens).toBe(300 + 200 + 101)
|
||
})
|
||
|
||
it('prices the Copilot spellings of the pre-2026 Claude SKUs', async () => {
|
||
const session = {
|
||
sessionId: 'sess-legacy-claude',
|
||
requests: [
|
||
{ requestId: 'r35', modelId: 'copilot/claude-3.5-sonnet', promptTokens: 1_000_000, completionTokens: 0 },
|
||
{ requestId: 'r37', modelId: 'copilot/claude-3.7-sonnet', promptTokens: 1_000_000, completionTokens: 0 },
|
||
{ requestId: 'r37t', modelId: 'copilot/claude-3.7-sonnet-thought', promptTokens: 1_000_000, completionTokens: 0 },
|
||
{ requestId: 'r41', modelId: 'copilot/claude-opus-4.1', promptTokens: 1_000_000, completionTokens: 0 },
|
||
],
|
||
}
|
||
const filePath = join(tmpDir, 'legacy-claude.json')
|
||
await writeFile(filePath, JSON.stringify(session))
|
||
|
||
const calls = await collectCalls({ path: filePath, project: 'test-project', provider: 'copilot', sourceType: 'chatsession' })
|
||
|
||
expect(calls.map(c => [c.model, c.costUSD])).toEqual([
|
||
['claude-3.5-sonnet', calculateCost('claude-3-5-sonnet', 1_000_000, 0, 0, 0, 0)],
|
||
['claude-3.7-sonnet', calculateCost('claude-3-7-sonnet', 1_000_000, 0, 0, 0, 0)],
|
||
['claude-3.7-sonnet-thought', calculateCost('claude-3-7-sonnet', 1_000_000, 0, 0, 0, 0)],
|
||
['claude-opus-4.1', calculateCost('claude-opus-4-1', 1_000_000, 0, 0, 0, 0)],
|
||
])
|
||
for (const c of calls) expect(c.costUSD).toBeGreaterThan(0)
|
||
})
|
||
})
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Dedup-key shapes are a CACHE contract, not an implementation detail
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Copilot is durable: on a parse-version bump the cache is carried forward and
|
||
// the re-parse is UNIONED into it by deduplicationKey (src/parser.ts,
|
||
// getOrCreateProviderSection + the durable merge). A key that changes shape is
|
||
// therefore a key the union cannot recognise, and every call behind it is
|
||
// counted a second time — silently, permanently, on upgrade.
|
||
//
|
||
// So: changing, adding or removing any prefix below is a CACHE_VERSION bump,
|
||
// not a PROVIDER_PARSE_VERSIONS bump. Deliberately a literal list rather than
|
||
// a derived one — the point is that a diff to it is impossible to miss.
|
||
describe('copilot deduplication key prefixes (durable-union contract)', () => {
|
||
it('pins every emitted key shape, read back out of the source', async () => {
|
||
const { readFile } = await import('node:fs/promises')
|
||
const here = dirname(fileURLToPath(import.meta.url))
|
||
const read = async (rel: string): Promise<string> => readFile(join(here, '..', '..', rel), 'utf8')
|
||
|
||
// Every dedup key this provider mints is a template literal starting with
|
||
// `copilot`. Collapse the interpolations so the test pins the SHAPE, not
|
||
// the variable names, and compare the whole set: a new key shape fails
|
||
// this just as loudly as a changed one.
|
||
const shapes = new Set<string>()
|
||
for (const src of [await read('src/providers/copilot.ts'), await read('src/parser.ts')]) {
|
||
for (const m of src.matchAll(/`(copilot[^`]*)`/g)) {
|
||
const raw = m[1]!
|
||
if (!raw.includes('${')) continue
|
||
const shape = raw.replace(/\$\{[^}]*\}/g, '$').replace(/\s+/g, '')
|
||
if (!shape.includes(':')) continue
|
||
shapes.add(shape)
|
||
}
|
||
}
|
||
|
||
expect([...shapes].sort()).toEqual([
|
||
// ── minted keys ────────────────────────────────────────────────────
|
||
'copilot-chatsession:$:$', // VS Code core chatSessions
|
||
'copilot-otel:$', // agent-traces.db span
|
||
'copilot-store:$:$:${fnv1a64(', // session-store row + content hash
|
||
'copilot:$:$', // CLI per-turn assistant.message
|
||
'copilot:$:shutdown-residual:$:$', // parser.ts; <model>:<leg epoch ms>
|
||
'copilot:$:shutdown:$:$', // session.shutdown rollup leg
|
||
'copilot:jb:$:$:$', // JetBrains nitrite conversation
|
||
// ── discriminator prefixes, not keys ───────────────────────────────
|
||
// parseProviderSources tells the representations apart with these, and
|
||
// sync's rollup-vs-reconciled shape split keys off the same boundary.
|
||
// They belong in the pin: a rollup prefix that stopped excluding
|
||
// `shutdown-residual` would subtract a residual from itself locally, and
|
||
// remotely would class reconciled output as the rollup it replaces.
|
||
'copilot:$:shutdown',
|
||
'copilot:$:shutdown:',
|
||
].sort())
|
||
})
|
||
|
||
it('keeps the shapes the reconciliation discriminates on distinguishable', () => {
|
||
// parseProviderSources tells the three copilot representations apart by
|
||
// prefix alone. `:shutdown:` must not also match a residual, and a store
|
||
// row must not match either — otherwise a rollup is dropped as a row, or a
|
||
// residual is subtracted from itself.
|
||
const rollup = 'copilot:sess-1:shutdown:claude-sonnet-4-5:1'
|
||
const residual = 'copilot:sess-1:shutdown-residual:claude-sonnet-4-5:1752000000000'
|
||
const row = 'copilot-store:sess-1:7:abcdef0123456789'
|
||
const perTurn = 'copilot:sess-1:msg-1'
|
||
|
||
const shutdownPrefix = 'copilot:sess-1:shutdown:'
|
||
expect(rollup.startsWith(shutdownPrefix)).toBe(true)
|
||
expect(residual.startsWith(shutdownPrefix)).toBe(false)
|
||
expect(row.startsWith(shutdownPrefix)).toBe(false)
|
||
expect(perTurn.startsWith(shutdownPrefix)).toBe(false)
|
||
expect(row.startsWith('copilot-store:')).toBe(true)
|
||
expect(rollup.startsWith('copilot-store:')).toBe(false)
|
||
// And sync's shape split keys off the same boundary (src/sync/push.ts):
|
||
// the raw rollup is one shape, rows + residuals together are the other.
|
||
expect(rollup.indexOf(':shutdown:')).toBeGreaterThan(0)
|
||
expect(residual.indexOf(':shutdown:')).toBe(-1)
|
||
expect(residual.includes(':shutdown-residual:')).toBe(true)
|
||
// The residual's last segment is the leg's epoch ms, not its index — a
|
||
// position renames under insertion, and a renamed key double-counts at a
|
||
// receiver that was already sent the old name.
|
||
expect(/:shutdown-residual:[^:]+:\d{13}$/.test(residual)).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('copilot provider - JetBrains model token scan', () => {
|
||
// #1530: a token must not capture a newer version — `gpt-5` is not
|
||
// `gpt-5.7-terra`, nor `claude-opus-4` `claude-opus-4.9`. A dot only counts
|
||
// as a version continuation when a digit follows it; an end-of-sentence
|
||
// period or a file extension stays a token boundary.
|
||
it('does not match a token inside a newer dotted version', () => {
|
||
expect(findJetBrainsModelToken('running on gpt-5.7-terra for coding')).toBe('')
|
||
expect(findJetBrainsModelToken('switched to claude-opus-4.9 today')).toBe('')
|
||
expect(findJetBrainsModelToken('gpt-5.4.1 beta')).toBe('')
|
||
})
|
||
|
||
// The ids JetBrains writes today must match their own token, not fall
|
||
// through to the anthropic-auto bucket (#1531 review).
|
||
it('recognises the current dotted ids, longest first', () => {
|
||
expect(findJetBrainsModelToken('"model":"gpt-5.5"')).toBe('gpt-5.5')
|
||
expect(findJetBrainsModelToken('running on gpt-5.6-luna for coding')).toBe('gpt-5.6-luna')
|
||
expect(findJetBrainsModelToken('running on gpt-5.6-terra for coding')).toBe('gpt-5.6-terra')
|
||
expect(findJetBrainsModelToken('"model":"gpt-5.6"')).toBe('gpt-5.6')
|
||
expect(findJetBrainsModelToken('gpt-5.4-mini beta')).toBe('gpt-5.4-mini')
|
||
expect(findJetBrainsModelToken('switched to claude-opus-4.8 today')).toBe('claude-opus-4-8')
|
||
expect(findJetBrainsModelToken('switched to claude-opus-4.7 today')).toBe('claude-opus-4-7')
|
||
expect(findJetBrainsModelToken('switched to claude-opus-4.6 today')).toBe('claude-opus-4-6')
|
||
expect(findJetBrainsModelToken('switched to claude-sonnet-4.6 today')).toBe('claude-sonnet-4-6')
|
||
expect(findJetBrainsModelToken('switched to claude-haiku-4.5 today')).toBe('claude-haiku-4-5')
|
||
})
|
||
|
||
it('prices a JetBrains claude-opus-4.7 chunk at the Opus 4.7 rate', () => {
|
||
const model = findJetBrainsModelToken('"model":"claude-opus-4.7"')
|
||
expect(model).toBe('claude-opus-4-7')
|
||
expect(calculateCost(model, 1_000_000, 1_000_000, 0, 0, 0)).toBeCloseTo(30, 6)
|
||
})
|
||
|
||
it('still matches tokens followed by sentence punctuation or file extensions', () => {
|
||
expect(findJetBrainsModelToken('switched to gpt-5. thanks')).toBe('gpt-5')
|
||
expect(findJetBrainsModelToken('see configs/gpt-5.json for details')).toBe('gpt-5')
|
||
expect(findJetBrainsModelToken('running on claude-opus-4.5.')).toBe('claude-opus-4-5')
|
||
expect(findJetBrainsModelToken('"model":"gpt-5"')).toBe('gpt-5')
|
||
})
|
||
|
||
it('does not match inside words', () => {
|
||
expect(findJetBrainsModelToken('iso3166 codes')).toBe('')
|
||
})
|
||
})
|