mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-02 21:04:57 +00:00
111 lines
6.2 KiB
TypeScript
111 lines
6.2 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, 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<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)
|
|
// 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)
|
|
})
|
|
})
|