mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-20 00:57:09 +00:00
Track agent calls across providers (#340)
This commit is contained in:
parent
303c9458cb
commit
2013ecbfd9
7 changed files with 501 additions and 70 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
134
tests/parser-gemini-cache.test.ts
Normal file
134
tests/parser-gemini-cache.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
193
tests/providers/gemini.test.ts
Normal file
193
tests/providers/gemini.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue