codeburn/tests/copilot-agent-host.test.ts
Resham Joshi 1a7284c39b
fix(copilot): count store-row output for unclosed sessions, price grok-4.6 at xAI rates (#1652)
Session-store rows carried output only for compaction requests; every
other request's output was left to the per-turn assistant.message or the
shutdown rollup residual. CLI 1.0.8x and ACP hosts (JetBrains AI Chat via
copilot --acp) write no per-turn output, and an ACP session usually ends
without session.shutdown, so its whole output was lost. Rows now carry
their own output_tokens; at serve time a row that pairs with a per-turn
call carrying output (older CLIs) serves with output 0, so output counts
once in every shape.

grok-4.6 priced at $1.25/M input: LiteLLM has no bare grok-4.6 row and
the stripped azure_ai/grok-4.6 row claims the bare id ahead of
xai/grok-4.6, in the bundler and the live fetch alike. A builtin price
pins xAI's list rate ($2/$6/$0.50 per M), which is what GitHub bills.

On real data every Copilot model's cost now equals GitHub's
total_nano_aiu to the digit.
2026-10-06 19:15:43 +02:00

317 lines
18 KiB
TypeScript

// VS Code's agent host (and Copilot CLI 1.0.8x) write assistant.message events
// with no outputTokens. The fixture is a real redacted session; its
// session.shutdown rollup is the ground truth.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { fileURLToPath } from 'node:url'
import { createRequire } from 'node:module'
import { copilot } from '../src/providers/copilot.js'
import { calculateCost } from '../src/models.js'
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
import { clearLoadCacheMemo } from '../src/session-cache.js'
import { behavioralCallCount } from '../src/behavioral-weight.js'
import { copilotCreditSpend } from '../src/plan-usage.js'
import { isSqliteAvailable } from '../src/sqlite.js'
import type { ParsedProviderCall } from '../src/providers/types.js'
const SID = 'd700c59c-59d8-4928-bdd6-142587956874'
const FIXTURE = fileURLToPath(new URL('./fixtures/copilot/agent-host-session/events.jsonl', import.meta.url))
const TRUTH = { calls: 10, input: 76_541, output: 2_171, cacheRead: 169_728, cacheWrite: 0, reasoning: 576, nanoAiu: 2_772_045_000 }
let tmp: string
beforeEach(async () => {
tmp = await mkdtemp(join(tmpdir(), 'copilot-agent-host-'))
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', join(tmp, 'no-store.db'))
})
afterEach(async () => {
vi.unstubAllEnvs()
await rm(tmp, { recursive: true, force: true })
})
async function fixtureLines(): Promise<string[]> {
return (await readFile(FIXTURE, 'utf-8')).split('\n').filter(l => l.trim())
}
async function writeSession(lines: string[], sid = SID, cwd = '/home/dev/cb-verify/copilot-test'): Promise<string> {
const dir = join(tmp, 'session-state', sid)
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), `id: ${sid}\ncwd: ${cwd}\n`)
const path = join(dir, 'events.jsonl')
await writeFile(path, lines.join('\n') + '\n')
return path
}
async function parse(lines: string[]): Promise<ParsedProviderCall[]> {
const path = await writeSession(lines)
const calls: ParsedProviderCall[] = []
for await (const c of copilot.createSessionParser({ path, project: 'copilot-test', provider: 'copilot' }, new Set()).parse()) calls.push(c)
return calls
}
const sum = (calls: ParsedProviderCall[], k: keyof ParsedProviderCall) => calls.reduce((s, c) => s + (c[k] as number), 0)
describe('copilot VS Code agent-host sessions', () => {
it('closed session: one call per request, rollup totals equal the ground truth', async () => {
const calls = await parse(await fixtureLines())
const perTurn = calls.filter(c => !c.deduplicationKey.includes(':shutdown:'))
const rollup = calls.filter(c => c.deduplicationKey.includes(':shutdown:'))
expect(perTurn).toHaveLength(TRUTH.calls)
expect(perTurn.every(c => c.model === 'gpt-5-mini' && c.costUSD === 0 && c.outputTokens === 0)).toBe(true)
expect(perTurn.some(c => c.costIsEstimated)).toBe(false)
expect(perTurn.flatMap(c => c.tools)).toHaveLength(8)
expect(rollup).toHaveLength(1)
expect(sum(calls, 'inputTokens')).toBe(TRUTH.input)
expect(sum(calls, 'outputTokens')).toBe(TRUTH.output)
expect(sum(calls, 'cacheReadInputTokens')).toBe(TRUTH.cacheRead)
expect(sum(calls, 'cacheCreationInputTokens')).toBe(TRUTH.cacheWrite)
expect(sum(calls, 'reasoningTokens')).toBe(TRUTH.reasoning)
expect(rollup[0]!.nanoAiu).toBe(TRUTH.nanoAiu)
expect(sum(calls, 'costUSD')).toBeCloseTo(
calculateCost('gpt-5-mini', TRUTH.input, TRUTH.output, 0, TRUTH.cacheRead, 0), 12)
})
it('open session: requests count as calls, no tokens or credits before shutdown', async () => {
const lines = (await fixtureLines()).filter(l => JSON.parse(l).type !== 'session.shutdown')
expect(lines.filter(l => JSON.parse(l).type === 'session.usage_checkpoint')).toHaveLength(2)
const calls = await parse(lines)
expect(calls).toHaveLength(TRUTH.calls)
for (const k of ['inputTokens', 'outputTokens', 'cacheReadInputTokens', 'costUSD'] as const) expect(sum(calls, k)).toBe(0)
expect(calls.some(c => c.nanoAiu !== undefined || c.costIsEstimated)).toBe(false)
})
// Synthetic: no real resumed-and-compacted session was available. Token
// counters reset at compaction; totalNanoAiu keeps running like a bill.
it('resumed + compacted session: token deltas restart, nanoAiu stays a running total', async () => {
const ev = (type: string, timestamp: string, data: Record<string, unknown>) => JSON.stringify({ type, timestamp, data })
const msg = (id: string, ts: string) => ev('assistant.message', ts, { messageId: id, model: 'claude-haiku-4.5', toolRequests: [] })
const shutdown = (ts: string, input: number, output: number, nano: number) => ev('session.shutdown', ts, {
shutdownType: 'routine',
modelMetrics: { 'claude-haiku-4.5': { usage: { inputTokens: input, outputTokens: output, cacheReadTokens: 0, cacheWriteTokens: 0 }, totalNanoAiu: nano } },
})
const calls = await parse([
ev('session.start', '2026-08-31T10:00:00Z', { selectedModel: 'claude-haiku-4.5' }),
msg('m1', '2026-08-31T10:00:10Z'),
shutdown('2026-08-31T10:01:00Z', 1_000, 100, 1_000_000_000),
msg('m2', '2026-08-31T10:02:00Z'),
ev('session.compaction_complete', '2026-08-31T10:03:00Z', { success: true }),
msg('m3', '2026-08-31T10:04:00Z'),
shutdown('2026-08-31T10:05:00Z', 300, 40, 1_500_000_000),
])
const legs = calls.filter(c => c.deduplicationKey.includes(':shutdown:'))
expect(calls.length - legs.length).toBe(3)
expect(legs.map(c => [c.inputTokens, c.outputTokens, c.nanoAiu])).toEqual([[1_000, 100, 1_000_000_000], [300, 40, 500_000_000]])
})
it('older CLI with per-turn outputTokens: the rollup does not count output again', async () => {
let n = 0
const lines = (await fixtureLines()).map(l => {
const e = JSON.parse(l)
if (e.type !== 'assistant.message') return l
e.data.outputTokens = n++ === 0 ? 217 + 1 : 217
return JSON.stringify(e)
})
const calls = await parse(lines)
const rollup = calls.find(c => c.deduplicationKey.includes(':shutdown:'))!
expect(calls.filter(c => c !== rollup)).toHaveLength(TRUTH.calls)
expect(rollup.outputTokens).toBe(0)
expect(sum(calls, 'outputTokens')).toBe(TRUTH.output)
expect(sum(calls, 'inputTokens')).toBe(TRUTH.input)
})
async function serve(lines: string[], sid = SID, cwd?: string) {
await writeSession(lines, sid, cwd)
vi.stubEnv('HOME', tmp)
vi.stubEnv('USERPROFILE', tmp)
vi.stubEnv('CODEBURN_CACHE_DIR', join(tmp, 'cache'))
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', join(tmp, 'session-state'))
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1')
vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmp, 'no-ws'))
vi.stubEnv('CODEBURN_COPILOT_GLOBAL_STORAGE_DIR', join(tmp, 'no-global'))
vi.stubEnv('CODEBURN_COPILOT_JETBRAINS_DIR', join(tmp, 'no-jb'))
clearSessionCache()
clearLoadCacheMemo()
try {
const projects = await parseAllSessions(undefined, 'copilot')
const calls = projects.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
return { projects, calls }
} finally {
clearSessionCache()
clearLoadCacheMemo()
}
}
it('serves 10 calls and exact credits for the closed session', async () => {
const { projects, calls } = await serve(await fixtureLines())
expect(projects.map(p => p.project)).toEqual(['copilot-test'])
expect(behavioralCallCount(calls)).toBe(TRUTH.calls)
expect(calls.reduce((s, c) => s + c.usage.outputTokens, 0)).toBe(TRUTH.output)
expect(calls.reduce((s, c) => s + c.usage.inputTokens, 0)).toBe(TRUTH.input)
expect(calls.reduce((s, c) => s + c.usage.cacheReadInputTokens, 0)).toBe(TRUTH.cacheRead)
expect(copilotCreditSpend(projects)).toMatchObject({ spentCredits: 2.772045, creditRatedCalls: 10, creditUnratedCalls: 0 })
})
it('serves an open session as 10 calls with no tokens', async () => {
const lines = (await fixtureLines()).filter(l => JSON.parse(l).type !== 'session.shutdown')
const { projects, calls } = await serve(lines)
expect(projects.map(p => p.project)).toEqual(['copilot-test'])
expect(behavioralCallCount(calls)).toBe(TRUTH.calls)
expect(calls.reduce((s, c) => s + c.usage.inputTokens + c.usage.outputTokens + c.costUSD, 0)).toBe(0)
})
function writeStore(sid: string, cwd: string, rows: Array<Record<string, string | number | null>>): string {
const dbPath = join(tmp, 'session-store.db')
const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as {
DatabaseSync: new (path: string) => { exec(sql: string): void; prepare(sql: string): { run(...p: unknown[]): void }; close(): void }
}
const db = new DatabaseSync(dbPath)
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, cwd TEXT, repository TEXT, created_at TEXT);
CREATE TABLE IF NOT EXISTS assistant_usage_events (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, model TEXT,
input_tokens INTEGER, output_tokens INTEGER, cache_read_tokens INTEGER, cache_write_tokens INTEGER,
reasoning_tokens INTEGER, total_nano_aiu INTEGER, request_multiplier REAL, initiator TEXT, created_at TEXT);
`)
db.prepare('INSERT INTO sessions (id, cwd) VALUES (?, ?)').run(sid, cwd)
const insert = 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, initiator, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
for (const r of rows) {
insert.run(sid, r['model'], r['input_tokens'], r['output_tokens'], r['cache_read_tokens'], r['cache_write_tokens'],
r['reasoning_tokens'], r['total_nano_aiu'], r['request_multiplier'], r['initiator'], r['created_at'])
}
db.close()
vi.stubEnv('CODEBURN_COPILOT_SESSION_STORE_DB', dbPath)
return dbPath
}
type ServedCall = Awaited<ReturnType<typeof serve>>['calls'][number]
// GitHub's own per-request charge is total_nano_aiu / 1e11 USD at list
// rates, so a model whose tokens are all counted and correctly priced costs
// exactly its rows' nanoAiu.
function expectCostEqualsNanoAiu(calls: ServedCall[], rows: Array<Record<string, string | number | null>>): void {
const models = new Set(rows.map(r => r['model'] as string))
for (const model of models) {
const cost = calls.filter(c => c.model === model).reduce((s, c) => s + c.costUSD, 0)
const nano = rows.filter(r => r['model'] === model).reduce((s, r) => s + (r['total_nano_aiu'] as number), 0)
expect({ model, cost: Number(cost.toFixed(12)) }).toEqual({ model, cost: Number((nano / 1e11).toFixed(12)) })
}
}
// CLI 1.0.82: tokenless messages plus session-store rows. Rollup ground
// truth: inputTokens 310,703 cache-inclusive, output 3,254, 9.334625 credits.
const STORE_SID = 'e61538c6-6e3e-4607-b468-b38972f2ba35'
const STORE_CWD = '/home/dev/codeburn/app'
const storeDir = fileURLToPath(new URL('./fixtures/copilot/cli-store-session/', import.meta.url))
const storeRows = async () => JSON.parse(await readFile(join(storeDir, 'store-rows.json'), 'utf-8')) as Array<Record<string, string | number>>
const storeLines = async () => (await readFile(join(storeDir, 'events.jsonl'), 'utf-8')).split('\n').filter(l => l.trim())
async function serveStore(lines: string[]) {
const rows = await storeRows()
writeStore(STORE_SID, STORE_CWD, rows)
const served = await serve(lines, STORE_SID, STORE_CWD)
const usage = (k: keyof ServedCall['usage']) => served.calls.reduce((s, c) => s + c.usage[k], 0)
expect(behavioralCallCount(served.calls)).toBe(11)
expect(usage('outputTokens')).toBe(3_254)
expect(usage('inputTokens')).toBe(82)
expect(usage('cacheReadInputTokens')).toBe(270_680)
expect(usage('cacheCreationInputTokens')).toBe(39_941)
expect(served.calls.reduce((s, c) => s + c.costUSD, 0)).toBeCloseTo(calculateCost('claude-haiku-4.5', 82, 3_254, 39_941, 270_680, 0), 12)
expectCostEqualsNanoAiu(served.calls, rows)
expect(copilotCreditSpend(served.projects)).toMatchObject({ spentCredits: 9.334625, creditRatedCalls: 11, creditUnratedCalls: 0 })
return served
}
it.skipIf(!isSqliteAvailable())('store rows + shutdown: rows carry the output, the residual carries none', async () => {
const { calls } = await serveStore(await storeLines())
expect(calls.filter(c => c.deduplicationKey.includes(':shutdown-residual:'))).toEqual([])
})
it.skipIf(!isSqliteAvailable())('store rows, no shutdown: exact from the rows alone', async () => {
await serveStore((await storeLines()).filter(l => JSON.parse(l).type !== 'session.shutdown'))
})
it.skipIf(!isSqliteAvailable())('older CLI with per-turn outputTokens and store rows: output counted once', async () => {
const out = (await storeRows()).map(r => r['output_tokens'] as number)
let n = 0
const lines = (await storeLines()).map(l => {
const e = JSON.parse(l)
if (e.type !== 'assistant.message') return l
e.data.outputTokens = out[n++]
return JSON.stringify(e)
})
expect(n).toBe(11)
const { calls } = await serveStore(lines)
expect(calls.filter(c => c.deduplicationKey.startsWith('copilot-store:')).every(c => c.usage.outputTokens === 0)).toBe(true)
})
// Synthetic split of the real session into two legs: a shutdown after the
// sixth request carries the cumulative totals of rows 1-6.
it.skipIf(!isSqliteAvailable())('resumed two-leg session: exact with or without the second leg\'s shutdown', async () => {
const rows = await storeRows()
const first = rows.slice(0, 6)
const total = (k: string) => first.reduce((s, r) => s + (r[k] as number), 0)
const legShutdown = JSON.stringify({
type: 'session.shutdown', timestamp: '2026-08-31T10:02:40.000Z',
data: { shutdownType: 'routine', modelMetrics: { 'claude-haiku-4.5': {
usage: { inputTokens: total('input_tokens'), outputTokens: total('output_tokens'), cacheReadTokens: total('cache_read_tokens'),
cacheWriteTokens: total('cache_write_tokens'), reasoningTokens: total('reasoning_tokens') },
totalNanoAiu: total('total_nano_aiu') } } },
})
const lines = await storeLines()
const resume = lines.findIndex(l => JSON.parse(l).timestamp > '2026-08-31T10:02:40.000Z')
const twoLegs = [...lines.slice(0, resume), legShutdown, ...lines.slice(resume)]
await serveStore(twoLegs)
await rm(join(tmp, 'session-store.db'), { force: true })
await rm(join(tmp, 'cache'), { recursive: true, force: true })
await serveStore(twoLegs.filter((l, i) => i <= resume || JSON.parse(l).type !== 'session.shutdown'))
})
// Real per-request rows (numbers only) from a PyCharm AI Chat session over
// ACP (`copilot --acp`, CLI 1.0.85) that never wrote session.shutdown,
// plus the three grok-4.6 rows of a JetBrains plugin session. Each model's
// cost must equal GitHub's charge for its rows.
it.skipIf(!isSqliteAvailable())('ACP session with no shutdown: every model costs exactly its nanoAiu', async () => {
const sid = '760f62f7-0000-4000-8000-000000000000'
const cols = ['model', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'reasoning_tokens', 'total_nano_aiu', 'initiator', 'created_at'] as const
const rows = ([
['gpt-5.6-terra', 15109, 201, 0, 15106, 39, 4018300000, 'user', '2026-10-06T16:13:10.192Z'],
['gpt-5.6-terra', 15594, 134, 15106, 485, 11, 584770000, 'agent', '2026-10-06T16:13:15.509Z'],
['gpt-5.6-terra', 15967, 118, 15591, 373, 77, 547270000, 'agent', '2026-10-06T16:14:54.382Z'],
['gpt-5.6-terra', 16125, 69, 15964, 158, 0, 442180000, 'agent', '2026-10-06T16:15:18.027Z'],
['kimi-k3', 17124, 124, 0, 0, 19, 5323200000, 'user', '2026-10-06T16:15:32.820Z'],
['kimi-k3', 27977, 257, 4096, 0, 0, 7672680000, 'agent', '2026-10-06T16:15:40.575Z'],
['kimi-k3', 28324, 188, 27976, 0, 0, 1225680000, 'agent', '2026-10-06T16:16:02.145Z'],
['kimi-k3', 28562, 102, 28323, 0, 0, 1074390000, 'agent', '2026-10-06T16:16:19.210Z'],
['kimi-k3', 28722, 66, 28561, 0, 0, 1004130000, 'agent', '2026-10-06T16:16:49.485Z'],
['grok-4.6', 28321, 876, 0, 0, 532, 6189800000, 'user', '2026-10-06T16:17:23.490Z'],
['grok-4.6', 29292, 29, 28288, 0, 5, 1632600000, 'agent', '2026-10-06T16:17:25.372Z'],
['grok-4.6', 29408, 41, 29184, 0, 0, 1528600000, 'agent', '2026-10-06T16:17:35.539Z'],
] as const).map(r => ({ ...Object.fromEntries(cols.map((c, i) => [c, r[i]])), request_multiplier: 1 }))
const ev = (type: string, timestamp: string, data: Record<string, unknown>) => JSON.stringify({ type, timestamp, data })
const lines = [ev('session.start', '2026-10-06T16:13:02.845Z', { producer: 'copilot-agent', copilotVersion: '1.0.85' })]
rows.forEach((r, i) => {
const at = new Date(Date.parse(r.created_at) + 3).toISOString()
if (r.model !== rows[i - 1]?.model) lines.push(ev('session.model_change', r.created_at, { newModel: r.model }))
lines.push(ev('assistant.message', at, { messageId: `m${i}`, model: r.model, toolRequests: [] }))
})
lines.push(ev('session.usage_checkpoint', '2026-10-06T16:17:35.600Z', { totalNanoAiu: 31_243_600_000, totalPremiumRequests: 3 }))
writeStore(sid, '/home/dev/pycharm-demo', rows)
const { projects, calls } = await serve(lines, sid, '/home/dev/pycharm-demo')
expect(behavioralCallCount(calls)).toBe(12)
expect(calls.reduce((s, c) => s + c.usage.outputTokens, 0)).toBe(522 + 737 + 946)
expectCostEqualsNanoAiu(calls, rows)
expect(copilotCreditSpend(projects)).toMatchObject({ spentCredits: 31.2436, creditRatedCalls: 12, creditUnratedCalls: 0 })
})
})