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, filterProjectsByDays, 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 { 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) // The anchor parent (0 in-range turns) must NOT contaminate the sessions list; // it lives in subagentAnchors only. const anchorInSessions = projects.some(p => p.sessions.some(s => s.sessionId === PARENT)) expect(anchorInSessions).toBe(false) const anchorHeldSeparately = projects.some(p => (p.subagentAnchors ?? []).some(s => s.sessionId === PARENT)) expect(anchorHeldSeparately).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) }) it('survives a day filter: a parent whose in-range turns are day-filtered out becomes an anchor (menubar flow)', async () => { await loadPricing() await writeTranscripts() // Wide parse: BOTH the parent (2026-07-19) and child (2026-07-20) are in range, // so the parent is a normal session (no anchor yet). const wide = { start: new Date('2026-07-18T00:00:00Z'), end: new Date('2026-07-21T23:59:59Z') } const projects = await parseAllSessions(wide, 'claude') expect(projects.some(p => p.sessions.some(s => s.sessionId === PARENT))).toBe(true) // parent is a real session here // Now narrow to the child's day only. The parent's 2026-07-19 turns are filtered // out, so it must be CONVERTED to a fold anchor rather than dropped. const dayFiltered = filterProjectsByDays(projects, new Set(['2026-07-20'])) expect(dayFiltered.some(p => p.sessions.some(s => s.sessionId === PARENT))).toBe(false) // parent no longer a session expect(dayFiltered.some(p => (p.subagentAnchors ?? []).some(s => s.sessionId === PARENT))).toBe(true) // kept as anchor // The child's spend still folds to the PR through the anchor. const row = aggregateByPr(dayFiltered).find(r => r.url === PR) expect(row).toBeDefined() expect(row!.cost).toBeGreaterThan(0) expect(prLinkedTotals(dayFiltered).subagentSessions).toBe(1) }) })