codeburn/tests/parser-subagent-range.test.ts
reviewer 384fb5ef0b sessions: address adversarial review of subagent PR attribution
Rework child attribution to resolve each subagent to the PR its launching
turn was working on, using the parent's UNFILTERED turn data, and enforce
that every dollar is counted exactly once.

- Mutual exclusion: a child that referenced its own PR attributes
  standalone and is never folded; a child with no links is folded only.
  Fixes a double-charge where a self-linking child was both folded and
  self-attributed.
- Recursion: a fold aggregates a child plus its non-self-linking
  descendants (depth-first, cycle-guarded), so grandchildren spawned by
  subagents reach the PR report.
- Global linkage: the subagent index keys by parentSessionId alone
  (UUIDs are globally unique), so a child whose worktree resolves to a
  different project still links.
- Date-range correctness: spawn-to-PR sets are built at assembly from the
  full turn list, so a spawn in a pre-range turn attributes to the right
  PR; a PR-linked parent whose own turns fall out of range is kept as a
  0-cost fold anchor so its in-range child is not lost.
- Timestamp fallback compares epoch ms (mixed UTC offsets order right) and
  is end-bounded: a child active after the parent's last turn is unlinked
  (contributes nothing), matching orphan semantics.
- Cache adoption tries the newest prior versioned file (v6 then v5) so the
  preceding build's expired-PR history survives the v7 bump; an invariant
  note requires the list to cover every version that can exist on disk.
- Spawn-result pairing matches the tool_result block that carries the
  agentId, not the first block, when a record batches several results.
- resolveSubagentAttribution is computed once and shared by aggregateByPr
  and prLinkedTotals.

subagentSessions now counts folded subtrees (children plus descendants).
Verified on real data: attributed + unattributed reconciles to cost, and
parent-only cost plus folded-children cost equals the folded total to the
cent (no double-count).
2026-07-21 05:46:36 +02:00

82 lines
4.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { parseAllSessions, clearSessionCache } from '../src/parser.js'
import { loadPricing } from '../src/models.js'
import { aggregateByPr, prLinkedTotals } from '../src/sessions-report.js'
// A parent that spawned an async subagent whose work landed inside the report
// range, while the parent's OWN turns fall just before it. The parent must be kept
// as a 0-cost fold anchor so the in-range child still attributes to the parent's
// PR (finding: a parent with no in-range calls must not drop its child's spend).
let tmpDir: string
let configDir: string
const CWD = '/tmp/anchor-proj'
const PR = 'https://github.com/o/r/pull/1'
const PARENT = '11111111-1111-4111-8111-111111111111'
const AGENT = 'a1234567890abcdef'
const SPAWN = 'toolu_spawn_anchor'
beforeEach(async () => {
clearSessionCache()
tmpDir = await mkdtemp(join(tmpdir(), 'anchor-'))
configDir = join(tmpDir, 'claude')
process.env['CLAUDE_CONFIG_DIR'] = configDir
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
})
afterEach(async () => {
clearSessionCache()
delete process.env['CLAUDE_CONFIG_DIR']
delete process.env['CODEBURN_CACHE_DIR']
await rm(tmpDir, { recursive: true, force: true })
})
async function writeTranscripts(): Promise<void> {
const projDir = join(configDir, 'projects', 'anchor-proj')
const subDir = join(projDir, PARENT, 'subagents')
await mkdir(subDir, { recursive: true })
// Parent transcript: a turn that references the PR and spawns AGENT (tool_use
// SPAWN), then the spawn result recording agentId -> SPAWN. All dated just BEFORE
// the report range (within the 24h parse lookback so the spawn is still parsed).
await writeFile(join(projDir, `${PARENT}.jsonl`),
JSON.stringify({ type: 'user', sessionId: PARENT, timestamp: '2026-07-19T23:00:00.000Z', cwd: CWD, message: { role: 'user', content: 'ship the PR and launch a reviewer' } }) + '\n' +
JSON.stringify({ type: 'assistant', sessionId: PARENT, timestamp: '2026-07-19T23:00:01.000Z', cwd: CWD, message: { id: 'm1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [{ type: 'tool_use', id: SPAWN, name: 'Agent', input: {} }], usage: { input_tokens: 10, output_tokens: 5 } } }) + '\n' +
JSON.stringify({ type: 'pr-link', sessionId: PARENT, timestamp: '2026-07-19T23:00:02.000Z', cwd: CWD, prUrl: PR }) + '\n' +
JSON.stringify({ type: 'user', sessionId: PARENT, timestamp: '2026-07-19T23:05:00.000Z', cwd: CWD, message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: SPAWN, content: 'reviewer done' }] }, toolUseResult: { status: 'completed', agentId: AGENT, content: 'reviewer done' } }) + '\n')
// Child transcript: the subagent's own work, dated INSIDE the report range.
await writeFile(join(subDir, `agent-${AGENT}.jsonl`),
JSON.stringify({ type: 'user', isSidechain: true, sessionId: PARENT, agentId: AGENT, timestamp: '2026-07-20T10:00:00.000Z', cwd: CWD, message: { role: 'user', content: 'review this' } }) + '\n' +
JSON.stringify({ type: 'assistant', isSidechain: true, sessionId: PARENT, agentId: AGENT, timestamp: '2026-07-20T10:00:05.000Z', cwd: CWD, message: { id: 'c1', type: 'message', role: 'assistant', model: 'claude-opus-4-8', content: [], usage: { input_tokens: 1000, output_tokens: 500 } } }) + '\n')
}
describe('subagent fold across a date-range boundary', () => {
it('folds an in-range child into its parent PR even though the parent has no in-range turns', async () => {
await loadPricing()
await writeTranscripts()
const range = { start: new Date('2026-07-20T00:00:00Z'), end: new Date('2026-07-20T23:59:59Z') }
const projects = await parseAllSessions(range, 'claude')
// The child session is present (its work is in range) as a standalone session.
const childPresent = projects.some(p => p.sessions.some(s => s.sessionId === `agent-${AGENT}`))
expect(childPresent).toBe(true)
const rows = aggregateByPr(projects)
const row = rows.find(r => r.url === PR)
expect(row).toBeDefined()
// The parent contributed $0 own spend (turns out of range); the row is entirely
// the folded child, priced from its opus tokens.
expect(row!.cost).toBeGreaterThan(0)
expect(row!.models).toContain('Opus 4.8')
const totals = prLinkedTotals(projects)
expect(totals.subagentSessions).toBe(1)
expect(totals.attributedCost).toBeCloseTo(row!.cost, 6)
})
})