codeburn/tests/provider-turn-grouping.test.ts
Rick Culpepper (claude) 448d470049 feat(providers): add cline-cli provider for Cline CLI sessions
The Cline CLI (npm `cline`, 3.x) stores sessions as
<sessions>/<id>/<id>.json + <id>.messages.json. The existing `cline`
provider only discovers tasks/<id>/ui_messages.json, so every CLI session
was silently reported as $0.00 — no warning, not even under --verbose.

Adds `cline-cli` as its own provider rather than a third root on `cline`,
leaving the shared Cline-family parser (Roo Code, KiloCode, IBM Bob)
untouched. It mirrors the CLI's own root resolution
(CLINE_SESSION_DATA_DIR -> CLINE_DATA_DIR -> CLINE_DIR -> ~/.cline),
implements probeRoots() so `doctor` can tell "not installed" from "wrong
override", emits one call per assistant message's `metrics` block, and
falls back to the session rollup when a session carries none. The
fallback reads `usage`, not `aggregateUsage`, which folds in spawned
subagents that are themselves separate session directories.

Two supporting changes, both required for CLI costs to report correctly:

- parser.ts re-priced cline-cli calls from tokens because the provider
  was not on the reported-cost allowlist, inflating a real 12-session
  local sample from $1.11 to $3.92.
- session-cache.ts gains the matching PROVIDER_ENV_VARS entry (so a
  changed override invalidates) and a `reported-cost-v1` parse version
  (so sessions cached before the allowlist fix re-parse once instead of
  being re-priced forever).

Cost is treated as metered only when actually present and non-negative,
so a metered $0 stays reported while a missing or negative cost falls
back to token pricing — applied identically on the per-message and
rollup paths. Timestamps promote a seconds-resolution value rather than
silently landing in 1970, matching the guard kiro.ts uses.

CLINE_DIR / CLINE_DATA_DIR / CLINE_SESSION_DATA_DIR are added to the test
env-isolation list so a developer's real sessions cannot bleed into
fixtures.

The VS Code variant discovery bug reported alongside this in #874 is
deliberately NOT fixed here — it shipped in #882.

Verified against 18 real local sessions: 142 calls, 4,934,762 input /
224,561 output tokens, and a cost matching the CLI's own metered total to
the cent. `codeburn doctor` reports "Cline CLI  OK".

Refs: #874
2026-08-04 02:45:16 +03:00

334 lines
15 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('classifies a range-sliced turn from the whole turn, not the surviving calls (#852)', async () => {
const chatsDir = join(home, '.gemini', 'tmp', 'project-b', 'chats')
await mkdir(chatsDir, { recursive: true })
await writeFile(join(chatsDir, 'session-slice.json'), JSON.stringify({
sessionId: 'gemini-slice-1',
startTime: '2026-05-16T10:00:00.000Z',
messages: [
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'read then edit src/parser.ts' },
{
id: 'g1', timestamp: '2026-05-16T10:00:00.000Z', type: 'gemini', content: 'reading',
model: 'gemini-3.1-pro-preview', tokens: { input: 100, output: 30 },
toolCalls: [{ id: 't1', name: 'read_file', args: { path: 'src/parser.ts' } }],
},
{
id: 'g2', timestamp: '2026-05-16T11:00:00.000Z', type: 'gemini', content: 'editing',
model: 'gemini-3.1-pro-preview', tokens: { input: 90, output: 25 },
toolCalls: [{ id: 't2', name: 'edit_file', args: { path: 'src/parser.ts' } }],
},
],
}))
const parseAllSessions = await loadParser()
// A range that keeps the 10:00 Read call but excludes the 11:00 Edit call,
// so the turn is sliced. `turnSlicedToRange`/`callsInRange` compare absolute
// times, so this is timezone-independent.
const sliceRange: DateRange = {
start: new Date('2026-05-16T10:00:00.000Z'),
end: new Date('2026-05-16T10:30:00.000Z'),
}
const projects = await parseAllSessions(sliceRange, 'gemini')
const turn = projects[0]!.sessions[0]!.turns[0]!
// Cost/calls are sliced to the range: only the Read call survives.
expect(turn.assistantCalls.map(c => c.deduplicationKey)).toEqual(['gemini:gemini-slice-1:g1'])
// But category/hasEdits are whole-turn judgments — the Edit is part of the
// exchange — so they stay classified from the FULL turn, matching the Claude
// path rather than being re-derived from the partial slice (which alone reads
// as a no-edit exploration turn).
expect(turn.hasEdits).toBe(true)
})
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']
}
})
it('preserves Cline CLI reported cost through cache conversion instead of re-pricing from tokens', async () => {
const sessionsDir = join(home, '.cline', 'data', 'sessions')
const sessionId = '1785701058566_vnwtz'
const dir = join(sessionsDir, sessionId)
await mkdir(dir, { recursive: true })
process.env['CLINE_SESSION_DATA_DIR'] = sessionsDir
// A large token count paired with a deliberately tiny reported cost: any
// token-based re-pricing would land orders of magnitude above $0.0123,
// so passing means the CLI's own per-message cost survived the round trip.
await writeFile(join(dir, `${sessionId}.json`), JSON.stringify({
version: 1,
session_id: sessionId,
source: 'cli',
status: 'completed',
provider: 'cline-pass',
model: 'z-ai/glm-5.2',
cwd: '/Users/test/project-a',
workspace_root: '/Users/test/project-a',
started_at: '2026-05-16T10:00:00.000Z',
ended_at: '2026-05-16T10:01:00.000Z',
metadata: {},
}))
await writeFile(join(dir, `${sessionId}.messages.json`), JSON.stringify({
version: 1,
agent: 'lead',
sessionId,
messages: [
{ id: 'u1', role: 'user', content: [{ type: 'text', text: 'do the thing' }], ts: Date.parse('2026-05-16T10:00:00.000Z') },
{
id: 'a1',
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
ts: Date.parse('2026-05-16T10:00:30.000Z'),
modelInfo: { id: 'z-ai/glm-5.2', provider: 'cline-pass' },
metrics: { inputTokens: 500000, outputTokens: 20000, cacheReadTokens: 100000, cacheWriteTokens: 0, cost: 0.0123 },
},
],
}))
try {
const parseAllSessions = await loadParser()
const projects = await parseAllSessions(dayRange(), 'cline-cli')
const session = projects[0]!.sessions[0]!
expect(session.totalCostUSD).toBeCloseTo(0.0123, 8)
} finally {
delete process.env['CLINE_SESSION_DATA_DIR']
}
})
})
describe('provider turn range filtering', () => {
it('keeps the in-range calls of a codex turn that spans midnight instead of dropping the whole turn', async () => {
// Regression test for #852: the range filter keyed on the turn's FIRST
// call timestamp, so a long autonomous turn starting 23:59 the previous
// day was excluded from the next day's view entirely, losing every
// post-midnight call. One turn (t1) here has two token_count events
// straddling midnight; only the post-midnight call may survive.
const codexHome = join(home, 'codex')
const sessionDir = join(codexHome, 'sessions', '2026', '05', '15')
await mkdir(sessionDir, { recursive: true })
const lines = [
JSON.stringify({ type: 'session_meta', timestamp: '2026-05-15T23:55:00Z', payload: { session_id: 'sess-span', model: 'gpt-5.5', cwd: '/Users/test/project-a', originator: 'codex_cli_rs' } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-05-15T23:57:00Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'run the long task' }] } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-05-15T23:58:00Z', payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command: 'npm test' }) } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-05-15T23:59:00Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 100, output_tokens: 30 }, total_token_usage: { total_tokens: 130 } } } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-05-16T00:10:00Z', payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command: 'npm run build' }) } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-05-16T00:15:00Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 80, output_tokens: 20 }, total_token_usage: { total_tokens: 230 } } } }),
]
await writeFile(join(sessionDir, 'rollout-span.jsonl'), lines.join('\n') + '\n')
process.env['CODEX_HOME'] = codexHome
try {
const parseAllSessions = await loadParser()
const projects = await parseAllSessions(dayRange(), 'codex')
const session = projects[0]!.sessions[0]!
const turn = session.turns[0]!
expect(session.turns).toHaveLength(1)
expect(turn.assistantCalls.map(call => new Date(call.timestamp).toISOString())).toEqual([
'2026-05-16T00:15:00.000Z',
])
// The slice re-anchors the turn's timestamp from the user-message time
// (2026-05-15T23:57Z) to the first surviving call, so turn-anchored
// bucketing lands the slice on the day its calls actually fall in.
expect(new Date(turn.timestamp).toISOString()).toBe('2026-05-16T00:15:00.000Z')
expect(session.totalInputTokens).toBe(80)
expect(session.totalOutputTokens).toBe(20)
} finally {
delete process.env['CODEX_HOME']
}
})
})