codeburn/packages/cli/tests/provider-turn-grouping.test.ts

199 lines
7.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DateRange } from '../src/types.js'
let home: string
let cacheDir: string
let vibeHome: string
let clearParserCache: (() => void) | undefined
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'codeburn-turn-group-home-'))
cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-turn-group-cache-'))
vibeHome = await mkdtemp(join(tmpdir(), 'codeburn-turn-group-vibe-'))
process.env['HOME'] = home
process.env['CODEBURN_CACHE_DIR'] = cacheDir
process.env['VIBE_HOME'] = vibeHome
})
afterEach(async () => {
clearParserCache?.()
clearParserCache = undefined
vi.resetModules()
await rm(home, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
await rm(vibeHome, { recursive: true, force: true })
})
function dayRange(): DateRange {
return {
start: new Date('2026-05-16T00:00:00.000Z'),
end: new Date('2026-05-16T23:59:59.999Z'),
}
}
async function loadParser() {
vi.resetModules()
const parser = await import('../src/parser.js')
clearParserCache = parser.clearSessionCache
return parser.parseAllSessions
}
describe('provider turn grouping', () => {
it('groups Gemini assistant messages under their user turn so retries are counted', async () => {
const chatsDir = join(home, '.gemini', 'tmp', 'project-a', 'chats')
await mkdir(chatsDir, { recursive: true })
await writeFile(join(chatsDir, 'session-gemini.json'), 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: 'implement parser update in src/parser.ts' },
{
id: 'g1',
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'editing',
model: 'gemini-3.1-pro-preview',
tokens: { input: 100, output: 30 },
toolCalls: [{ id: 't1', name: 'edit_file', args: { path: 'src/parser.ts' } }],
},
{
id: 'g2',
timestamp: '2026-05-16T10:00:10.000Z',
type: 'gemini',
content: 'testing',
model: 'gemini-3.1-pro-preview',
tokens: { input: 80, output: 20 },
toolCalls: [{ id: 't2', name: 'run_command', args: { command: 'npm test' } }],
},
{
id: 'g3',
timestamp: '2026-05-16T10:00:15.000Z',
type: 'gemini',
content: 'fixing after test',
model: 'gemini-3.1-pro-preview',
tokens: { input: 90, output: 25 },
toolCalls: [{ id: 't3', name: 'edit_file', args: { path: 'src/parser.ts' } }],
},
],
}))
const parseAllSessions = await loadParser()
const projects = await parseAllSessions(dayRange(), 'gemini')
const session = projects[0]!.sessions[0]!
const turn = session.turns[0]!
expect(session.turns).toHaveLength(1)
expect(turn.assistantCalls.map(call => call.deduplicationKey)).toEqual([
'gemini:gemini-session-1:g1',
'gemini:gemini-session-1:g2',
'gemini:gemini-session-1:g3',
])
expect(turn.hasEdits).toBe(true)
expect(turn.retries).toBe(1)
expect(session.categoryBreakdown[turn.category].editTurns).toBe(1)
expect(session.categoryBreakdown[turn.category].oneShotTurns).toBe(0)
})
it('groups Mistral Vibe assistant messages and uses Vibe session_cost when present', async () => {
const sessionDir = join(vibeHome, 'logs', 'session', 'session_20260516_100000_vibe')
await mkdir(sessionDir, { recursive: true })
await writeFile(join(sessionDir, 'meta.json'), JSON.stringify({
session_id: 'vibe-session-1',
start_time: '2026-05-16T10:00:00.000Z',
end_time: '2026-05-16T10:01:00.000Z',
environment: { working_directory: '/Users/test/project-a' },
stats: {
session_prompt_tokens: 300,
session_completion_tokens: 90,
session_cost: 0.123456,
input_price_per_million: 100,
output_price_per_million: 100,
},
config: { active_model: 'mistral-medium-3.5', models: [] },
title: 'vibe parser update',
}))
await writeFile(join(sessionDir, 'messages.jsonl'), [
{ role: 'user', content: 'implement parser update in src/providers/mistral-vibe.ts', message_id: 'u1' },
{
role: 'assistant',
content: 'editing',
message_id: 'a1',
tool_calls: [{ id: 't1', type: 'function', function: { name: 'search_replace', arguments: '{"file_path":"src/providers/mistral-vibe.ts"}' } }],
},
{
role: 'assistant',
content: 'testing',
message_id: 'a2',
tool_calls: [{ id: 't2', type: 'function', function: { name: 'bash', arguments: '{"command":"npm test"}' } }],
},
{
role: 'assistant',
content: 'fixing after test',
message_id: 'a3',
tool_calls: [{ id: 't3', type: 'function', function: { name: 'write_file', arguments: '{"path":"src/providers/mistral-vibe.ts"}' } }],
},
].map(message => JSON.stringify(message)).join('\n') + '\n')
const parseAllSessions = await loadParser()
const projects = await parseAllSessions(dayRange(), 'mistral-vibe')
const session = projects[0]!.sessions[0]!
const turn = session.turns[0]!
expect(session.turns).toHaveLength(1)
expect(turn.assistantCalls.map(call => call.deduplicationKey)).toEqual([
'mistral-vibe:vibe-session-1:a1',
'mistral-vibe:vibe-session-1:a2',
'mistral-vibe:vibe-session-1:a3',
])
expect(turn.retries).toBe(1)
expect(session.totalCostUSD).toBeCloseTo(0.123456, 8)
expect(session.totalInputTokens).toBe(300)
expect(session.totalOutputTokens).toBe(90)
expect(session.categoryBreakdown[turn.category].oneShotTurns).toBe(0)
})
it('preserves Kiro credit-based cost through cache conversion instead of re-pricing from tokens', async () => {
const kiroHome = join(home, '.kiro')
const cliDir = join(kiroHome, 'sessions', 'cli')
await mkdir(cliDir, { recursive: true })
process.env['KIRO_HOME'] = kiroHome
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeFile(join(cliDir, `${sessionId}.jsonl`), [
JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'hi' }], meta: { timestamp: 1778925600 } } }),
JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'short reply' }] } }),
].join('\n') + '\n')
await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({
session_id: sessionId,
cwd: '/Users/test/project-a',
created_at: '2026-05-16T10:00:00Z',
updated_at: '2026-05-16T10:01:00Z',
session_state: {
rts_model_state: { model_info: { model_id: 'claude-sonnet-4.6' } },
conversation_metadata: {
user_turn_metadatas: [{
end_timestamp: '2026-05-16T10:00:30Z',
// 2.5 credits × $0.04/credit = $0.10 — far from any token estimate
// of this tiny transcript, so passing means the metered cost was
// preserved through providerCallToCachedCall/cachedCallToApiCall.
metering_usage: [{ value: 2.5, unit: 'credit' }],
}],
},
},
}))
try {
const parseAllSessions = await loadParser()
const projects = await parseAllSessions(dayRange(), 'kiro')
const session = projects[0]!.sessions[0]!
expect(session.totalCostUSD).toBeCloseTo(2.5 * 0.04, 8)
} finally {
delete process.env['KIRO_HOME']
}
})
})