Track agent calls across providers (#340)

This commit is contained in:
ozymandiashh 2026-05-18 15:51:01 +03:00 committed by GitHub
parent 303c9458cb
commit 2013ecbfd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 501 additions and 70 deletions

View file

@ -3,6 +3,15 @@
## Unreleased
### Added (CLI)
- **Agent and subagent tracking coverage.** Gemini sessions now emit one
provider call per assistant message with token usage instead of one aggregate
call per session, preserving per-message tools, bash commands, timestamps,
and nearest user prompts. Existing cached aggregate Gemini entries are
reparsed so the new per-message shape takes effect, and per-tool counts may
increase because repeated tools are now attributed to the specific Gemini
message that used them. Claude discovery also scans direct project-level
`subagents/*.jsonl` files, and Codex agent tool normalization is covered by
regression tests. Addresses #336.
- **Multiple subscription plans can be tracked at the same time.**
`codeburn plan set` now stores plans in a provider-keyed `plans` map, so
setting a Codex custom plan no longer overwrites an existing Claude plan.

View file

@ -1321,18 +1321,24 @@ async function parseSessionFile(
async function collectJsonlFiles(dirPath: string): Promise<string[]> {
const files = await readdir(dirPath).catch(() => [])
const jsonlFiles = files.filter(f => f.endsWith('.jsonl')).map(f => join(dirPath, f))
const jsonlFiles = new Set(files.filter(f => f.endsWith('.jsonl')).map(f => join(dirPath, f)))
const directSubagentsPath = join(dirPath, 'subagents')
const directSubFiles = await readdir(directSubagentsPath).catch(() => [])
for (const sf of directSubFiles) {
if (sf.endsWith('.jsonl')) jsonlFiles.add(join(directSubagentsPath, sf))
}
for (const entry of files) {
if (entry.endsWith('.jsonl')) continue
const subagentsPath = join(dirPath, entry, 'subagents')
const subFiles = await readdir(subagentsPath).catch(() => [])
for (const sf of subFiles) {
if (sf.endsWith('.jsonl')) jsonlFiles.push(join(subagentsPath, sf))
if (sf.endsWith('.jsonl')) jsonlFiles.add(join(subagentsPath, sf))
}
}
return jsonlFiles
return [...jsonlFiles]
}
async function scanProjectDirs(
@ -1639,6 +1645,14 @@ function getOrCreateProviderSection(cache: SessionCache, provider: string): Prov
return section
}
function cachedFileNeedsProviderReparse(providerName: string, cached: CachedFile): boolean {
if (providerName !== 'gemini') return false
return cached.turns.some(turn =>
turn.calls.some(call => call.deduplicationKey === `gemini:${turn.sessionId}`),
)
}
const warnedProviderReadFailures = new Set<string>()
function warnProviderReadFailureOnce(providerName: string, err: unknown): void {
@ -1674,9 +1688,10 @@ async function parseProviderSources(
const fp = await fingerprintFile(source.path)
if (!fp) continue
const action = reconcileFile(fp, section.files[source.path])
if (action.action === 'unchanged') {
unchangedSources.push({ source, cached: section.files[source.path]! })
const cached = section.files[source.path]
const action = reconcileFile(fp, cached)
if (action.action === 'unchanged' && cached && !cachedFileNeedsProviderReparse(providerName, cached)) {
unchangedSources.push({ source, cached })
} else {
changedSources.push({ source, fp })
}

View file

@ -66,84 +66,81 @@ type GeminiSession = {
function parseSession(data: GeminiSession, seenKeys: Set<string>): ParsedProviderCall[] {
const results: ParsedProviderCall[] = []
const geminiMessages = data.messages.filter(m => m.type === 'gemini' && m.tokens && m.model)
if (geminiMessages.length === 0) return results
let lastUserMessage = ''
let geminiOrdinal = 0
const dedupKey = `gemini:${data.sessionId}`
if (seenKeys.has(dedupKey)) return results
seenKeys.add(dedupKey)
for (const msg of data.messages) {
if (msg.type === 'user') {
if (Array.isArray(msg.content)) {
lastUserMessage = msg.content.map(c => c.text).join(' ').slice(0, 500)
} else if (typeof msg.content === 'string') {
lastUserMessage = msg.content.slice(0, 500)
}
continue
}
let totalInput = 0
let totalOutput = 0
let totalCached = 0
let totalThoughts = 0
const allTools: string[] = []
const bashCommands: string[] = []
let model = ''
if (msg.type !== 'gemini' || !msg.tokens || !msg.model) continue
for (const msg of geminiMessages) {
const t = msg.tokens!
totalInput += t.input ?? 0
totalOutput += t.output ?? 0
totalCached += t.cached ?? 0
totalThoughts += t.thoughts ?? 0
if (msg.model && !model) model = msg.model
const t = msg.tokens
const totalInput = t.input ?? 0
const totalOutput = t.output ?? 0
const totalCached = t.cached ?? 0
const totalThoughts = t.thoughts ?? 0
if (totalInput === 0 && totalOutput === 0 && totalCached === 0 && totalThoughts === 0) continue
const messageKey = msg.id || `idx-${geminiOrdinal}`
geminiOrdinal++
const dedupKey = `gemini:${data.sessionId}:${messageKey}`
if (seenKeys.has(dedupKey)) continue
const tools: string[] = []
const bashCommands: string[] = []
if (msg.toolCalls) {
for (const tc of msg.toolCalls) {
const mapped = toolNameMap[tc.displayName ?? ''] ?? toolNameMap[tc.name] ?? tc.displayName ?? tc.name
allTools.push(mapped)
tools.push(mapped)
if (mapped === 'Bash' && tc.args && typeof tc.args.command === 'string') {
bashCommands.push(...extractBashCommands(tc.args.command))
}
}
}
// Gemini's `input` count includes `cached` tokens as a subset, so fresh
// input must subtract cached to avoid double-charging at both rates.
const freshInput = Math.max(0, totalInput - totalCached)
const tsDate = new Date(msg.timestamp || data.startTime)
if (isNaN(tsDate.getTime()) || tsDate.getTime() < 1_000_000_000_000) continue
seenKeys.add(dedupKey)
// Gemini bills thoughts at the output token rate; calculateCost does not
// accept a reasoning parameter, so fold thoughts into the output count for
// pricing while keeping outputTokens / reasoningTokens reported separately.
const costUSD = calculateCost(msg.model, freshInput, totalOutput + totalThoughts, 0, totalCached, 0)
results.push({
provider: 'gemini',
model: msg.model,
inputTokens: freshInput,
outputTokens: totalOutput,
cacheCreationInputTokens: 0,
cacheReadInputTokens: totalCached,
cachedInputTokens: totalCached,
reasoningTokens: totalThoughts,
webSearchRequests: 0,
costUSD,
tools: [...new Set(tools)],
bashCommands: [...new Set(bashCommands)],
timestamp: tsDate.toISOString(),
speed: 'standard',
deduplicationKey: dedupKey,
userMessage: lastUserMessage,
sessionId: data.sessionId,
})
}
if (totalInput === 0 && totalOutput === 0) return results
// Gemini's `input` count includes `cached` tokens as a subset, so fresh input
// must subtract cached to avoid double-charging at both rates.
const freshInput = totalInput - totalCached
let userMessage = ''
const firstUser = data.messages.find(m => m.type === 'user')
if (firstUser) {
if (Array.isArray(firstUser.content)) {
userMessage = firstUser.content.map(c => c.text).join(' ').slice(0, 500)
} else if (typeof firstUser.content === 'string') {
userMessage = firstUser.content.slice(0, 500)
}
}
const tsDate = new Date(data.startTime)
if (isNaN(tsDate.getTime()) || tsDate.getTime() < 1_000_000_000_000) return results
// Gemini bills thoughts at the output token rate; calculateCost does not
// accept a reasoning parameter, so fold thoughts into the output count for
// pricing while keeping outputTokens / reasoningTokens reported separately.
const costUSD = calculateCost(model, freshInput, totalOutput + totalThoughts, 0, totalCached, 0)
results.push({
provider: 'gemini',
model,
inputTokens: freshInput,
outputTokens: totalOutput,
cacheCreationInputTokens: 0,
cacheReadInputTokens: totalCached,
cachedInputTokens: totalCached,
reasoningTokens: totalThoughts,
webSearchRequests: 0,
costUSD,
tools: [...new Set(allTools)],
bashCommands: [...new Set(bashCommands)],
timestamp: tsDate.toISOString(),
speed: 'standard',
deduplicationKey: dedupKey,
userMessage,
sessionId: data.sessionId,
})
return results
}

View file

@ -0,0 +1,134 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
import { CACHE_VERSION, computeEnvFingerprint } from '../src/session-cache.js'
import type { DateRange } from '../src/types.js'
let home: string
let cacheDir: string
let previousHome: string | undefined
let previousCacheDir: string | undefined
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'codeburn-gemini-home-'))
cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-gemini-cache-'))
previousHome = process.env['HOME']
previousCacheDir = process.env['CODEBURN_CACHE_DIR']
process.env['HOME'] = home
process.env['CODEBURN_CACHE_DIR'] = cacheDir
})
afterEach(async () => {
clearSessionCache()
if (previousHome === undefined) delete process.env['HOME']
else process.env['HOME'] = previousHome
if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir
await rm(home, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
})
describe('Gemini session cache migration', () => {
it('reparses cached legacy aggregate Gemini entries into granular calls', async () => {
const chatsDir = join(home, '.gemini', 'tmp', 'project-a', 'chats')
await mkdir(chatsDir, { recursive: true })
const sessionPath = join(chatsDir, 'session-2026-05-16.json')
await writeFile(sessionPath, JSON.stringify({
sessionId: 'gemini-session-1',
startTime: '2026-05-16T10:00:00.000Z',
messages: [
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
{
id: 'g1',
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'first',
model: 'gemini-3.1-pro-preview',
tokens: { input: 10, output: 5 },
},
{
id: 'g2',
timestamp: '2026-05-16T10:00:10.000Z',
type: 'gemini',
content: 'second',
model: 'gemini-3.1-pro-preview',
tokens: { input: 12, output: 6 },
},
],
}))
const fileStat = await stat(sessionPath)
await writeFile(join(cacheDir, 'session-cache.json'), JSON.stringify({
version: CACHE_VERSION,
providers: {
gemini: {
envFingerprint: computeEnvFingerprint('gemini'),
files: {
[sessionPath]: {
fingerprint: {
dev: fileStat.dev,
ino: fileStat.ino,
mtimeMs: fileStat.mtimeMs,
sizeBytes: fileStat.size,
},
mcpInventory: [],
turns: [{
timestamp: '2026-05-16T10:00:00.000Z',
sessionId: 'gemini-session-1',
userMessage: 'work',
calls: [{
provider: 'gemini',
model: 'gemini-3.1-pro-preview',
usage: {
inputTokens: 22,
outputTokens: 11,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
cacheCreationOneHourTokens: 0,
},
speed: 'standard',
timestamp: '2026-05-16T10:00:00.000Z',
tools: [],
bashCommands: [],
skills: [],
deduplicationKey: 'gemini:gemini-session-1',
}],
}],
},
},
},
},
}))
const range: DateRange = {
start: new Date('2026-05-16T00:00:00.000Z'),
end: new Date('2026-05-16T23:59:59.999Z'),
}
const projects = await parseAllSessions(range, 'gemini')
const keys = projects.flatMap(project =>
project.sessions.flatMap(session =>
session.turns.flatMap(turn => turn.assistantCalls.map(call => call.deduplicationKey)),
),
)
expect(projects[0]!.totalApiCalls).toBe(2)
expect(keys).toEqual([
'gemini:gemini-session-1:g1',
'gemini:gemini-session-1:g2',
])
const savedCache = JSON.parse(await readFile(join(cacheDir, 'session-cache.json'), 'utf-8'))
const savedKeys = savedCache.providers.gemini.files[sessionPath].turns.flatMap((turn: { calls: Array<{ deduplicationKey: string }> }) =>
turn.calls.map(call => call.deduplicationKey),
)
expect(savedKeys).toEqual(keys)
})
})

View file

@ -151,6 +151,63 @@ describe('parseAllSessions with large Claude fixture', () => {
expect(sess.apiCalls).toBeGreaterThanOrEqual(1)
})
it('discovers direct Claude subagent JSONL files under a project directory', async () => {
const projectDir = join(home, '.claude', 'projects', 'direct-subagents')
const subagentsDir = join(projectDir, 'subagents')
await mkdir(subagentsDir, { recursive: true })
const lines = [
userLine('subagent-session', '2026-04-10T10:00:00Z', 100),
assistantLine('subagent-session', '2026-04-10T10:01:00Z', 'subagent-msg', {
contentSize: 0,
toolCount: 2,
}),
]
await writeFile(join(subagentsDir, 'worker.jsonl'), lines.join('\n'))
const range: DateRange = {
start: new Date('2026-04-10T00:00:00Z'),
end: new Date('2026-04-10T23:59:59Z'),
}
const projects = await parseAllSessions(range, 'claude')
expect(projects).toHaveLength(1)
const session = projects[0]!.sessions[0]!
expect(session.sessionId).toBe('worker')
expect(session.apiCalls).toBe(1)
expect(session.toolBreakdown['Edit']?.calls).toBe(1)
expect(session.toolBreakdown['Read']?.calls).toBe(1)
})
it('discovers nested Claude subagent JSONL files under a direct subagents directory', async () => {
const projectDir = join(home, '.claude', 'projects', 'nested-subagents')
const nestedSubagentsDir = join(projectDir, 'subagents', 'subagents')
await mkdir(nestedSubagentsDir, { recursive: true })
const lines = [
userLine('nested-subagent-session', '2026-04-10T11:00:00Z', 100),
assistantLine('nested-subagent-session', '2026-04-10T11:01:00Z', 'nested-subagent-msg', {
contentSize: 0,
toolCount: 1,
}),
]
await writeFile(join(nestedSubagentsDir, 'worker.jsonl'), lines.join('\n'))
const range: DateRange = {
start: new Date('2026-04-10T00:00:00Z'),
end: new Date('2026-04-10T23:59:59Z'),
}
const projects = await parseAllSessions(range, 'claude')
expect(projects).toHaveLength(1)
const session = projects[0]!.sessions[0]!
expect(session.sessionId).toBe('worker')
expect(session.apiCalls).toBe(1)
expect(session.toolBreakdown['Edit']?.calls).toBe(1)
})
it('parses huge message-first assistant lines without full JSON.parse expansion', async () => {
const projectDir = join(home, '.claude', 'projects', 'messagefirst')
await mkdir(projectDir, { recursive: true })

View file

@ -278,6 +278,32 @@ describe('codex provider - JSONL parsing', () => {
expect(call.deduplicationKey).toContain('codex:')
})
it('normalizes Codex subagent tool calls to Agent', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-agent.jsonl', [
sessionMeta({ session_id: 'sess-agent', model: 'gpt-5.5' }),
userMessage('delegate the review'),
functionCall('spawn_agent'),
functionCall('wait_agent'),
functionCall('close_agent'),
tokenCount({
timestamp: '2026-04-14T10:01:00Z',
last: { input: 300, output: 100 },
total: { total: 400 },
}),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const parser = provider.createSessionParser(source, new Set())
const calls: ParsedProviderCall[] = []
for await (const call of parser.parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
expect(calls[0]!.tools).toEqual(['Agent', 'Agent', 'Agent'])
})
it('skips duplicate token_count events', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-dedup.jsonl', [
sessionMeta(),

View file

@ -0,0 +1,193 @@
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createGeminiProvider } from '../../src/providers/gemini.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gemini-provider-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
async function parseFixture(messages: unknown[]): Promise<ParsedProviderCall[]> {
const filePath = join(tmpDir, 'session-gemini.json')
await writeFile(filePath, JSON.stringify({
sessionId: 'gemini-session-1',
startTime: '2026-05-16T10:00:00.000Z',
messages,
}))
const provider = createGeminiProvider()
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser({ path: filePath, project: 'gemini-project', provider: 'gemini' }, new Set()).parse()) {
calls.push(call)
}
return calls
}
describe('gemini provider', () => {
it('emits one provider call per Gemini message with token usage', async () => {
const calls = await parseFixture([
{
id: 'u1',
timestamp: '2026-05-16T10:00:00.000Z',
type: 'user',
content: 'inspect the repo',
},
{
id: 'g1',
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'reading files',
model: 'gemini-3.1-pro-preview',
tokens: { input: 120, cached: 20, output: 30, thoughts: 5 },
toolCalls: [{ id: 't1', name: 'read_file', args: { path: 'src/index.ts' } }],
},
{
id: 'u2',
timestamp: '2026-05-16T10:01:00.000Z',
type: 'user',
content: [{ text: 'run tests' }],
},
{
id: 'g2',
timestamp: '2026-05-16T10:01:10.000Z',
type: 'gemini',
content: 'running tests',
model: 'gemini-3.1-pro-preview',
tokens: { input: 80, cached: 10, output: 25 },
toolCalls: [{ id: 't2', name: 'run_command', args: { command: 'npm test' } }],
},
])
expect(calls).toHaveLength(2)
expect(calls.map(c => c.deduplicationKey)).toEqual([
'gemini:gemini-session-1:g1',
'gemini:gemini-session-1:g2',
])
expect(calls.map(c => c.timestamp)).toEqual([
'2026-05-16T10:00:05.000Z',
'2026-05-16T10:01:10.000Z',
])
expect(calls.map(c => c.userMessage)).toEqual(['inspect the repo', 'run tests'])
expect(calls[0]!.inputTokens).toBe(100)
expect(calls[0]!.cacheReadInputTokens).toBe(20)
expect(calls[0]!.reasoningTokens).toBe(5)
expect(calls[0]!.tools).toEqual(['Read'])
expect(calls[1]!.inputTokens).toBe(70)
expect(calls[1]!.cacheReadInputTokens).toBe(10)
expect(calls[1]!.tools).toEqual(['Bash'])
expect(calls[1]!.bashCommands).toEqual(['npm'])
})
it('keeps aggregate token totals when splitting a Gemini session into calls', async () => {
const calls = await parseFixture([
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
{
id: 'g1',
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'first',
model: 'gemini-3.1-pro-preview',
tokens: { input: 120, cached: 20, output: 30, thoughts: 5 },
},
{
id: 'g2',
timestamp: '2026-05-16T10:00:10.000Z',
type: 'gemini',
content: 'second',
model: 'gemini-3.1-pro-preview',
tokens: { input: 80, cached: 10, output: 25, thoughts: 0 },
},
])
expect(calls).toHaveLength(2)
expect(calls.reduce((sum, call) => sum + call.inputTokens, 0)).toBe(170)
expect(calls.reduce((sum, call) => sum + call.cacheReadInputTokens, 0)).toBe(30)
expect(calls.reduce((sum, call) => sum + call.outputTokens, 0)).toBe(55)
expect(calls.reduce((sum, call) => sum + call.reasoningTokens, 0)).toBe(5)
})
it('skips Gemini messages without token usage', async () => {
const calls = await parseFixture([
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
{
id: 'info',
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'tool-only notice',
model: 'gemini-3.1-pro-preview',
},
])
expect(calls).toEqual([])
})
it('uses a deterministic ordinal key when Gemini message ids are missing', async () => {
const messages = [
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
{
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'first',
model: 'gemini-3.1-pro-preview',
tokens: { input: 10, output: 5 },
},
{
timestamp: '2026-05-16T10:00:10.000Z',
type: 'gemini',
content: 'second',
model: 'gemini-3.1-pro-preview',
tokens: { input: 12, output: 6 },
},
]
const first = await parseFixture(messages)
const second = await parseFixture(messages)
expect(first.map(c => c.deduplicationKey)).toEqual([
'gemini:gemini-session-1:idx-0',
'gemini:gemini-session-1:idx-1',
])
expect(second.map(c => c.deduplicationKey)).toEqual(first.map(c => c.deduplicationKey))
})
it('does not poison seenKeys when a Gemini message timestamp is invalid', async () => {
const filePath = join(tmpDir, 'session-gemini.json')
await writeFile(filePath, JSON.stringify({
sessionId: 'gemini-session-1',
startTime: '2026-05-16T10:00:00.000Z',
messages: [
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
{
id: 'g1',
timestamp: 'not-a-date',
type: 'gemini',
content: 'first',
model: 'gemini-3.1-pro-preview',
tokens: { input: 10, output: 5 },
},
],
}))
const provider = createGeminiProvider()
const seenKeys = new Set<string>()
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(
{ path: filePath, project: 'gemini-project', provider: 'gemini' },
seenKeys,
).parse()) {
calls.push(call)
}
expect(calls).toEqual([])
expect(seenKeys.has('gemini:gemini-session-1:g1')).toBe(false)
})
})