mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-11 09:34:45 +00:00
623 lines
35 KiB
TypeScript
623 lines
35 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
aggregateByPr,
|
|
buildSubagentIndex,
|
|
prLinkedTotals,
|
|
resolveSubagentAttribution,
|
|
} from '../src/sessions-report.js'
|
|
import { filterProjectsByDateRange, filterProjectsByDays } from '../src/parser.js'
|
|
import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary, TokenUsage } from '../src/types.js'
|
|
|
|
const A = 'https://github.com/o/r/pull/1'
|
|
const B = 'https://github.com/o/r/pull/2'
|
|
|
|
const ZERO_USAGE: TokenUsage = {
|
|
inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0,
|
|
cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
|
|
}
|
|
|
|
let seq = 0
|
|
function call(cost: number, model: string, ts: string): ParsedApiCall {
|
|
return {
|
|
provider: 'claude', model, usage: ZERO_USAGE, costUSD: cost,
|
|
tools: [], mcpTools: [], skills: [], subagentTypes: [],
|
|
hasAgentSpawn: false, hasPlanMode: false, speed: 'standard',
|
|
timestamp: ts, bashCommands: [], deduplicationKey: `k${seq++}`,
|
|
}
|
|
}
|
|
|
|
function turn(opts: { cost: number; model?: string; ts: string; prRefs?: string[]; category?: ClassifiedTurn['category'] }): ClassifiedTurn {
|
|
return {
|
|
userMessage: '', timestamp: opts.ts, sessionId: 's',
|
|
category: opts.category ?? 'coding', retries: 0, hasEdits: false,
|
|
assistantCalls: [call(opts.cost, opts.model ?? 'claude-sonnet-4-5', opts.ts)],
|
|
...(opts.prRefs ? { prRefs: opts.prRefs } : {}),
|
|
}
|
|
}
|
|
|
|
const BASE = {
|
|
totalSavingsUSD: 0, totalEstimatedCostUSD: 0,
|
|
totalInputTokens: 0, totalOutputTokens: 0, totalReasoningTokens: 0,
|
|
totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
|
|
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
|
|
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
|
|
skillBreakdown: {} as SessionSummary['skillBreakdown'],
|
|
subagentBreakdown: {} as SessionSummary['subagentBreakdown'],
|
|
}
|
|
|
|
function parent(opts: {
|
|
id: string; prLinks: string[]; turns: ClassifiedTurn[]; project?: string
|
|
agentSpawnLinks?: Record<string, string>; spawnPrSets?: Record<string, string[]>
|
|
prRefsAtRangeStart?: string[]; first?: string; last?: string; ambiguousSpawnAgentIds?: string[]
|
|
}): SessionSummary {
|
|
return {
|
|
...BASE,
|
|
sessionId: opts.id, project: opts.project ?? 'p',
|
|
firstTimestamp: opts.first ?? '2026-07-01T10:00:00Z', lastTimestamp: opts.last ?? '2026-07-01T12:00:00Z',
|
|
totalCostUSD: opts.turns.reduce((n, t) => n + t.assistantCalls.reduce((s, c) => s + c.costUSD, 0), 0),
|
|
apiCalls: opts.turns.reduce((n, t) => n + t.assistantCalls.length, 0),
|
|
turns: opts.turns, prLinks: opts.prLinks,
|
|
...(opts.agentSpawnLinks ? { agentSpawnLinks: opts.agentSpawnLinks } : {}),
|
|
...(opts.spawnPrSets ? { spawnPrSets: opts.spawnPrSets } : {}),
|
|
...(opts.prRefsAtRangeStart ? { prRefsAtRangeStart: opts.prRefsAtRangeStart } : {}),
|
|
...(opts.ambiguousSpawnAgentIds ? { ambiguousSpawnAgentIds: opts.ambiguousSpawnAgentIds } : {}),
|
|
}
|
|
}
|
|
|
|
function child(opts: {
|
|
agentId: string; parentId: string; cost: number; model?: string
|
|
category?: ClassifiedTurn['category']; firstTs: string; last?: string
|
|
project?: string; calls?: number; prLinks?: string[]
|
|
}): SessionSummary {
|
|
const n = opts.calls ?? 1
|
|
const t: ClassifiedTurn = {
|
|
userMessage: '', timestamp: opts.firstTs, sessionId: `agent-${opts.agentId}`,
|
|
category: opts.category ?? 'debugging', retries: 0, hasEdits: false,
|
|
assistantCalls: Array.from({ length: n }, () => call(opts.cost / n, opts.model ?? 'claude-opus-4-8', opts.firstTs)),
|
|
...(opts.prLinks ? { prRefs: opts.prLinks } : {}),
|
|
}
|
|
return {
|
|
...BASE,
|
|
sessionId: `agent-${opts.agentId}`, project: opts.project ?? 'p',
|
|
parentSessionId: opts.parentId, agentId: opts.agentId,
|
|
firstTimestamp: opts.firstTs, lastTimestamp: opts.last ?? opts.firstTs,
|
|
totalCostUSD: opts.cost, apiCalls: n, turns: [t],
|
|
...(opts.prLinks ? { prLinks: opts.prLinks } : {}),
|
|
}
|
|
}
|
|
|
|
function project(sessions: SessionSummary[], name = 'p', anchors?: SessionSummary[]): ProjectSummary {
|
|
return { project: name, projectPath: `/${name}`, sessions, totalCostUSD: 0, totalSavingsUSD: 0, totalApiCalls: 0, totalProxiedCostUSD: 0, ...(anchors ? { subagentAnchors: anchors } : {}) }
|
|
}
|
|
|
|
function rowFor(rows: ReturnType<typeof aggregateByPr>, url: string) {
|
|
return rows.find(r => r.url === url)
|
|
}
|
|
|
|
// Sum of the standalone cost of every subagent session, for the double-count check.
|
|
function subagentCostTotal(projects: ProjectSummary[]): number {
|
|
let t = 0
|
|
for (const p of projects) for (const s of p.sessions) if (s.parentSessionId) t += s.totalCostUSD
|
|
return t
|
|
}
|
|
|
|
describe('buildSubagentIndex', () => {
|
|
it('keys children by parentSessionId alone (global, cross-project)', () => {
|
|
const idx = buildSubagentIndex([
|
|
project([parent({ id: 'P', prLinks: [A], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })] })], 'projA'),
|
|
// Child lives in a DIFFERENT project than its parent (worktree cwd resolved elsewhere).
|
|
project([child({ agentId: 'c1', parentId: 'P', cost: 50, firstTs: '2026-07-01T10:05:00Z', project: 'projB' })], 'projB'),
|
|
])
|
|
// One key (parentSessionId, provider-prefixed), holding the single child.
|
|
expect(idx.size).toBe(1)
|
|
expect([...idx.values()].flat().map(s => s.agentId)).toEqual(['c1'])
|
|
})
|
|
})
|
|
|
|
describe('spawn-link resolution (async edge: spawn PR wins over first-timestamp)', () => {
|
|
const projects = () => [project([
|
|
parent({
|
|
id: 'P', prLinks: [A, B],
|
|
// Turn 0 works on A and spawns the child; turn 1 works on B. The child's
|
|
// first activity lands during turn 1, but the spawn happened under A.
|
|
turns: [
|
|
turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] }),
|
|
turn({ cost: 10, ts: '2026-07-01T10:30:00Z', prRefs: [B] }),
|
|
],
|
|
agentSpawnLinks: { c1: 'toolu_x' },
|
|
spawnPrSets: { toolu_x: [A] },
|
|
}),
|
|
child({ agentId: 'c1', parentId: 'P', cost: 100, model: 'claude-opus-4-8', firstTs: '2026-07-01T10:45:00Z' }),
|
|
])]
|
|
|
|
it('folds the child under the spawn PR (A), not the first-timestamp PR (B)', () => {
|
|
const rows = aggregateByPr(projects())
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(110, 6) // turn A ($10) + child ($100)
|
|
expect(rowFor(rows, B)!.cost).toBeCloseTo(10, 6)
|
|
expect(rowFor(rows, A)!.models).toContain('Opus 4.8')
|
|
})
|
|
|
|
it('counts the child once and keeps it a standalone session', () => {
|
|
const p = projects()
|
|
const totals = prLinkedTotals(p)
|
|
expect(totals.subagentSessions).toBe(1)
|
|
expect(totals.attributedCost).toBeCloseTo(120, 6)
|
|
// No double-count: folded total minus the child's own cost equals the parents' own spend.
|
|
expect(totals.cost - subagentCostTotal(p)).toBeCloseTo(20, 6)
|
|
expect(p[0]!.sessions.some(s => s.sessionId === 'agent-c1')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('CRITICAL: a self-linking child is NOT folded (mutual exclusion, no double-charge)', () => {
|
|
it('a child with its own prLinks attributes standalone only', () => {
|
|
const projects = [project([
|
|
parent({
|
|
id: 'P', prLinks: [A],
|
|
turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })],
|
|
agentSpawnLinks: { c1: 'toolu_x' }, spawnPrSets: { toolu_x: [A] },
|
|
}),
|
|
// The child references its OWN PR (B). It must attribute standalone to B and
|
|
// NOT also fold into the parent's A -- that would double-charge it.
|
|
child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:05:00Z', prLinks: [B] }),
|
|
])]
|
|
const rows = aggregateByPr(projects)
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(10, 6) // parent only, child NOT folded here
|
|
expect(rowFor(rows, B)!.cost).toBeCloseTo(100, 6) // child self-attributes to B
|
|
const totals = prLinkedTotals(projects)
|
|
expect(totals.subagentSessions).toBe(0) // nothing was folded
|
|
// distinctCost / prLinkedTotals consistency: every dollar counted exactly once.
|
|
const rowsSum = rows.reduce((s, r) => s + r.cost, 0)
|
|
expect(rowsSum).toBeCloseTo(totals.attributedCost, 6)
|
|
expect(totals.attributedCost).toBeCloseTo(110, 6) // 10 + 100, no double
|
|
})
|
|
|
|
it('a child with NO links folds only (the complementary direction)', () => {
|
|
const projects = [project([
|
|
parent({
|
|
id: 'P', prLinks: [A],
|
|
turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })],
|
|
agentSpawnLinks: { c1: 'toolu_x' }, spawnPrSets: { toolu_x: [A] },
|
|
}),
|
|
child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:05:00Z' }),
|
|
])]
|
|
const rows = aggregateByPr(projects)
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(110, 6) // folded
|
|
expect(rowFor(rows, B)).toBeUndefined()
|
|
expect(prLinkedTotals(projects).subagentSessions).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('MAJOR: nested subagents fold recursively', () => {
|
|
it('parent > child ($100) > grandchild ($50) lands $150 on the parent PR', () => {
|
|
const projects = [project([
|
|
parent({
|
|
id: 'P', prLinks: [A],
|
|
turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })],
|
|
agentSpawnLinks: { c1: 'toolu_x' }, spawnPrSets: { toolu_x: [A] },
|
|
}),
|
|
// Middle child (no PR of its own) spawned the grandchild; grandchild's parent
|
|
// is the middle child, which has no prLinks -> only recursion reaches it.
|
|
child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:05:00Z' }),
|
|
child({ agentId: 'gc', parentId: 'agent-c1', cost: 50, firstTs: '2026-07-01T10:06:00Z' }),
|
|
])]
|
|
const rows = aggregateByPr(projects)
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(160, 6) // 10 + 100 + 50
|
|
const totals = prLinkedTotals(projects)
|
|
expect(totals.subagentSessions).toBe(2) // child + grandchild
|
|
expect(totals.attributedCost).toBeCloseTo(160, 6)
|
|
})
|
|
|
|
it('a self-linking grandchild is excluded from the recursive fold', () => {
|
|
const projects = [project([
|
|
parent({ id: 'P', prLinks: [A], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })], agentSpawnLinks: { c1: 'toolu_x' }, spawnPrSets: { toolu_x: [A] } }),
|
|
child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:05:00Z' }),
|
|
child({ agentId: 'gc', parentId: 'agent-c1', cost: 50, firstTs: '2026-07-01T10:06:00Z', prLinks: [B] }),
|
|
])]
|
|
const rows = aggregateByPr(projects)
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(110, 6) // 10 + 100 (grandchild NOT folded)
|
|
expect(rowFor(rows, B)!.cost).toBeCloseTo(50, 6) // grandchild self-attributes
|
|
})
|
|
|
|
it('a cycle in parent links terminates (visited guard)', () => {
|
|
// Two sessions each claim the other as parent; the visited set must break it.
|
|
const a = child({ agentId: 'x', parentId: 'agent-y', cost: 30, firstTs: '2026-07-01T10:06:00Z' })
|
|
const b = child({ agentId: 'y', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:05:00Z' })
|
|
// Make x's child be y (cycle: y -> x -> y).
|
|
const projects = [project([
|
|
parent({ id: 'P', prLinks: [A], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })], agentSpawnLinks: { y: 'toolu_x' }, spawnPrSets: { toolu_x: [A] } }),
|
|
b, a,
|
|
])]
|
|
// y folds into P (100), x folds into y (30); the y<->x cycle must not loop.
|
|
const rows = aggregateByPr(projects)
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(140, 6) // 10 + 100 + 30, counted once
|
|
})
|
|
})
|
|
|
|
describe('MAJOR: date-range correctness', () => {
|
|
it('(b) a spawn in a pre-range turn resolves to the spawn PR, not an in-range one', () => {
|
|
// The parent's in-range turns only reference B, but the child was spawned in a
|
|
// pre-range turn working on A (captured in spawnPrSets from the full history).
|
|
const projects = [project([
|
|
parent({
|
|
id: 'P', prLinks: [A, B],
|
|
turns: [turn({ cost: 10, ts: '2026-07-20T10:00:00Z', prRefs: [B] })], // only in-range turn
|
|
prRefsAtRangeStart: [B],
|
|
agentSpawnLinks: { c1: 'toolu_pre' }, spawnPrSets: { toolu_pre: [A] },
|
|
}),
|
|
child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-20T10:05:00Z' }),
|
|
])]
|
|
const rows = aggregateByPr(projects)
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(100, 6) // child follows the spawn PR
|
|
expect(rowFor(rows, B)!.cost).toBeCloseTo(10, 6) // parent's in-range turn only
|
|
})
|
|
|
|
it('(a) an in-range child of an anchor parent (no in-range turns) folds, anchor uncounted', () => {
|
|
// The parent is a 0-cost fold ANCHOR: it carries prLinks + spawnPrSets but has
|
|
// no in-range turns, so it lives in subagentAnchors, NOT sessions. Its in-range
|
|
// child must still reach the PR, and the anchor must not inflate session counts.
|
|
const anchor = parent({ id: 'P', prLinks: [A], turns: [], last: '', first: '', agentSpawnLinks: { c1: 'toolu_x' }, spawnPrSets: { toolu_x: [A] } })
|
|
const projects = [project([
|
|
child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-20T10:05:00Z', last: '2026-07-20T10:30:00Z' }),
|
|
], 'p', [anchor])]
|
|
const rows = aggregateByPr(projects)
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(100, 6)
|
|
const totals = prLinkedTotals(projects)
|
|
expect(totals.subagentSessions).toBe(1)
|
|
expect(totals.sessions).toBe(0) // the anchor is NOT counted as a PR-linked session
|
|
// The PR row's date span comes from the CHILD, not the anchor's empty timestamps.
|
|
expect(rowFor(rows, A)!.firstStarted).toBe('2026-07-20T10:05:00Z')
|
|
expect(rowFor(rows, A)!.lastEnded).toBe('2026-07-20T10:30:00Z')
|
|
})
|
|
})
|
|
|
|
describe('MAJOR: timestamp fallback (epoch, end-bounded)', () => {
|
|
it('compares epoch not lexical: a 12:00Z child does NOT fold into a later 15:00Z (UTC) turn', () => {
|
|
// Parent turn at 2026-07-01T10:00:00-05:00 == 15:00Z; child at 12:00Z is BEFORE it.
|
|
const projects = [project([
|
|
parent({
|
|
id: 'P', prLinks: [A], last: '2026-07-01T16:00:00Z',
|
|
turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00-05:00', prRefs: [A] })],
|
|
}),
|
|
// No spawn link -> timestamp fallback. 12:00Z < 15:00Z, so it must NOT land on A.
|
|
child({ agentId: 'early', parentId: 'P', cost: 40, firstTs: '2026-07-01T12:00:00Z' }),
|
|
])]
|
|
const rows = aggregateByPr(projects)
|
|
// The only turn (A) starts AFTER the child, so nothing carries -> unattributed, no A row from the child.
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(10, 6) // parent turn only
|
|
const totals = prLinkedTotals(projects)
|
|
expect(totals.attributedCost).toBeCloseTo(10, 6)
|
|
expect(totals.unattributedCost).toBeCloseTo(40, 6) // child fell before the turn -> unattributed
|
|
})
|
|
|
|
it('a child whose spawn link was omitted (ambiguous pairing) still folds via timestamp', () => {
|
|
// The parent has NO agentSpawnLinks entry for this child (the spawn-result
|
|
// pairing was ambiguous and omitted). The child must still fold via the
|
|
// timestamp bucket, not disappear.
|
|
const projects = [project([
|
|
parent({ id: 'P', prLinks: [A], last: '2026-07-01T12:00:00Z', turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })] }),
|
|
child({ agentId: 'noLink', parentId: 'P', cost: 40, firstTs: '2026-07-01T10:30:00Z' }),
|
|
])]
|
|
const rows = aggregateByPr(projects)
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(50, 6) // 10 parent + 40 child via timestamp bucket
|
|
expect(prLinkedTotals(projects).subagentSessions).toBe(1)
|
|
})
|
|
|
|
it('a child active after the parent last timestamp is UNLINKED (contributes nothing)', () => {
|
|
const projects = [project([
|
|
parent({
|
|
id: 'P', prLinks: [A], last: '2026-07-01T11:00:00Z',
|
|
turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })],
|
|
}),
|
|
// No spawn link, and first activity is AFTER the parent's last timestamp.
|
|
child({ agentId: 'late', parentId: 'P', cost: 40, firstTs: '2026-07-01T23:00:00Z' }),
|
|
])]
|
|
const totals = prLinkedTotals(projects)
|
|
expect(totals.subagentSessions).toBe(0) // unlinked -> not counted
|
|
expect(totals.attributedCost).toBeCloseTo(10, 6)
|
|
expect(totals.unattributedCost).toBeCloseTo(0, 6) // contributes nothing at all
|
|
})
|
|
})
|
|
|
|
describe('orphans and non-PR parents contribute nothing', () => {
|
|
it('an orphan child whose parent is absent from the scan is ignored', () => {
|
|
const projects = [project([child({ agentId: 'c1', parentId: 'MISSING', cost: 100, firstTs: '2026-07-01T10:05:00Z' })])]
|
|
expect(aggregateByPr(projects)).toHaveLength(0)
|
|
expect(prLinkedTotals(projects).subagentSessions).toBe(0)
|
|
})
|
|
|
|
it('a child of a parent that referenced no PR is not folded', () => {
|
|
const projects = [project([
|
|
parent({ id: 'Q', prLinks: [], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z' })] }),
|
|
child({ agentId: 'c9', parentId: 'Q', cost: 100, firstTs: '2026-07-01T10:05:00Z' }),
|
|
])]
|
|
expect(aggregateByPr(projects)).toHaveLength(0)
|
|
expect(prLinkedTotals(projects).subagentSessions).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('resolveSubagentAttribution', () => {
|
|
it('resolves each PR-bearing parent to its resolved children', () => {
|
|
const projects = [project([
|
|
parent({ id: 'P', prLinks: [A], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })], agentSpawnLinks: { c1: 'toolu_x' }, spawnPrSets: { toolu_x: [A] } }),
|
|
child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:05:00Z' }),
|
|
])]
|
|
const resolved = [...resolveSubagentAttribution(projects).values()]
|
|
expect(resolved).toHaveLength(1)
|
|
expect(resolved[0]!).toHaveLength(1)
|
|
expect(resolved[0]![0]!.prSet).toEqual([A])
|
|
expect(resolved[0]![0]!.fold.cost).toBe(100)
|
|
})
|
|
})
|
|
|
|
describe('MAJOR: id-collision contamination', () => {
|
|
it('folds a child into NEITHER of two distinct parents that share a session id', () => {
|
|
// Two DISTINCT parent sessions both have id "P" (duplicate/imported data). A
|
|
// child pointing at "P" is ambiguous: it must fold nowhere and stay standalone,
|
|
// while both parents attribute their OWN spend only.
|
|
const projects = [project([
|
|
parent({ id: 'P', prLinks: [A], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })], agentSpawnLinks: { c1: 'toolu_x' }, spawnPrSets: { toolu_x: [A] } }),
|
|
parent({ id: 'P', prLinks: [B], turns: [turn({ cost: 20, ts: '2026-07-01T11:00:00Z', prRefs: [B] })], agentSpawnLinks: { c1: 'toolu_y' }, spawnPrSets: { toolu_y: [B] } }),
|
|
child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:05:00Z' }),
|
|
])]
|
|
const rows = aggregateByPr(projects)
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(10, 6) // parent 1 own spend only
|
|
expect(rowFor(rows, B)!.cost).toBeCloseTo(20, 6) // parent 2 own spend only
|
|
const totals = prLinkedTotals(projects)
|
|
expect(totals.subagentSessions).toBe(0) // the ambiguous child folds nowhere
|
|
expect(totals.attributedCost).toBeCloseTo(30, 6) // no child double-charge
|
|
})
|
|
|
|
it('folds nowhere when two parents share id + headline stats but map the child to different spawns/PRs', () => {
|
|
// Same cost/calls/prLinks/turn-refs, but P1 maps c1 -> spawn x (PR A) and P2 maps
|
|
// c1 -> spawn y (PR B). A fingerprint over headline stats alone would miss this
|
|
// and first-wins by input order; the full linkage fingerprint makes it ambiguous.
|
|
const mk = (order: 'p1' | 'p2') => {
|
|
const p1 = parent({ id: 'P', prLinks: [A, B], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })], agentSpawnLinks: { c1: 'x' }, spawnPrSets: { x: [A] } })
|
|
const p2 = parent({ id: 'P', prLinks: [A, B], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })], agentSpawnLinks: { c1: 'y' }, spawnPrSets: { y: [B] } })
|
|
const c = child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:05:00Z' })
|
|
return [project(order === 'p1' ? [p1, p2, c] : [p2, p1, c])]
|
|
}
|
|
for (const order of ['p1', 'p2'] as const) {
|
|
const totals = prLinkedTotals(mk(order))
|
|
expect(totals.subagentSessions).toBe(0) // ambiguous identity -> child folds nowhere
|
|
expect(totals.attributedCost).toBeCloseTo(20, 6) // only the two parents own $10 turns
|
|
}
|
|
})
|
|
|
|
it('folds nowhere when a PR-bearing parent shares its id with a PR-LESS parent', () => {
|
|
// Only ONE of the two colliding parents has prLinks, but the identity is still
|
|
// ambiguous: count ALL candidates, not just PR-bearing ones.
|
|
const projects = [project([
|
|
parent({ id: 'P', prLinks: [A], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })], agentSpawnLinks: { c1: 'toolu_x' }, spawnPrSets: { toolu_x: [A] } }),
|
|
// A PR-less session that happens to share id "P" (imported/duplicate data).
|
|
parent({ id: 'P', prLinks: [], turns: [turn({ cost: 20, ts: '2026-07-01T11:00:00Z' })] }),
|
|
child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:05:00Z' }),
|
|
])]
|
|
const rows = aggregateByPr(projects)
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(10, 6) // parent own spend; child NOT folded
|
|
expect(prLinkedTotals(projects).subagentSessions).toBe(0)
|
|
})
|
|
|
|
it('distinguishes records whose only difference is a per-turn TIMESTAMP', () => {
|
|
// Same PR-ref sequence [A then B] and same linkage, but P2 switches to B at a
|
|
// different time. A fingerprint over the ref SEQUENCE alone would miss this; the
|
|
// complete per-turn (timestamp/cost/...) fingerprint makes them ambiguous.
|
|
const mk = (order: 'p1' | 'p2') => {
|
|
const p1 = parent({ id: 'P', prLinks: [A, B], agentSpawnLinks: { c1: 'x' }, spawnPrSets: { x: [A] }, turns: [turn({ cost: 5, ts: '2026-07-01T10:00:00Z', prRefs: [A] }), turn({ cost: 5, ts: '2026-07-01T10:05:00Z', prRefs: [B] })] })
|
|
const p2 = parent({ id: 'P', prLinks: [A, B], agentSpawnLinks: { c1: 'x' }, spawnPrSets: { x: [A] }, turns: [turn({ cost: 5, ts: '2026-07-01T10:00:00Z', prRefs: [A] }), turn({ cost: 5, ts: '2026-07-01T10:15:00Z', prRefs: [B] })] })
|
|
const c = child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:20:00Z' })
|
|
return [project(order === 'p1' ? [p1, p2, c] : [p2, p1, c])]
|
|
}
|
|
for (const order of ['p1', 'p2'] as const) {
|
|
expect(prLinkedTotals(mk(order)).subagentSessions).toBe(0) // ambiguous -> child folds nowhere
|
|
}
|
|
})
|
|
|
|
it('does NOT create false ambiguity from set-array ORDER (spawnPrSets / prRefs)', () => {
|
|
// Two records identical up to array ORDER of set-semantic fields fingerprint EQUAL
|
|
// (canonical sort), so they are one logical parent and the child folds once (not
|
|
// dropped as falsely-ambiguous). Cost-0 parent turns isolate the child fold.
|
|
const mk = (order: 'q1' | 'q2') => {
|
|
const q1 = parent({ id: 'Q', prLinks: [A, B], agentSpawnLinks: { c1: 'x' }, spawnPrSets: { x: [A, B] }, turns: [turn({ cost: 0, ts: '2026-07-01T10:00:00Z', prRefs: [A, B] })] })
|
|
const q2 = parent({ id: 'Q', prLinks: [B, A], agentSpawnLinks: { c1: 'x' }, spawnPrSets: { x: [B, A] }, turns: [turn({ cost: 0, ts: '2026-07-01T10:00:00Z', prRefs: [B, A] })] })
|
|
const c = child({ agentId: 'c1', parentId: 'Q', cost: 100, firstTs: '2026-07-01T10:05:00Z' })
|
|
return [project(order === 'q1' ? [q1, q2, c] : [q2, q1, c])]
|
|
}
|
|
for (const order of ['q1', 'q2'] as const) {
|
|
expect(prLinkedTotals(mk(order)).subagentSessions).toBe(1) // NOT ambiguous: folds once
|
|
const rows = aggregateByPr(mk(order))
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(50, 6) // child $100 split A/B
|
|
expect(rowFor(rows, B)!.cost).toBeCloseTo(50, 6)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('MAJOR: ambiguous pairing + late child grace window', () => {
|
|
const makeProjects = (childFirstTs: string) => [project([
|
|
// No spawn link for c1, but the parent recorded it as an AMBIGUOUS pairing.
|
|
parent({
|
|
id: 'P', prLinks: [A], last: '2026-07-01T11:00:00Z',
|
|
turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })],
|
|
ambiguousSpawnAgentIds: ['c1'],
|
|
}),
|
|
child({ agentId: 'c1', parentId: 'P', cost: 40, firstTs: childFirstTs }),
|
|
])]
|
|
|
|
it('folds a within-grace late child to the last turn', () => {
|
|
// Child starts 20 min after the parent's last timestamp (11:00Z) -> within 30 min.
|
|
const rows = aggregateByPr(makeProjects('2026-07-01T11:20:00Z'))
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(50, 6) // 10 parent + 40 child, folded to last turn
|
|
expect(prLinkedTotals(makeProjects('2026-07-01T11:20:00Z')).subagentSessions).toBe(1)
|
|
})
|
|
|
|
it('leaves a beyond-grace late child unlinked', () => {
|
|
// Child starts 2 hours after the parent's last timestamp -> beyond the window.
|
|
const totals = prLinkedTotals(makeProjects('2026-07-01T13:00:00Z'))
|
|
expect(totals.subagentSessions).toBe(0)
|
|
expect(totals.attributedCost).toBeCloseTo(10, 6) // parent only; child unlinked
|
|
expect(totals.unattributedCost).toBeCloseTo(0, 6)
|
|
})
|
|
|
|
it('does NOT grace a late child whose pairing was merely ABSENT (not ambiguous)', () => {
|
|
// Same timing, but the parent never recorded this agent id -> no grace.
|
|
const projects = [project([
|
|
parent({ id: 'P', prLinks: [A], last: '2026-07-01T11:00:00Z', turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })] }),
|
|
child({ agentId: 'c1', parentId: 'P', cost: 40, firstTs: '2026-07-01T11:20:00Z' }),
|
|
])]
|
|
expect(prLinkedTotals(projects).subagentSessions).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('MAJOR: range-start PR state is recomputed at a filter boundary', () => {
|
|
const A_TURN = () => turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })
|
|
const B_TURN = () => turn({ cost: 10, ts: '2026-07-10T10:00:00Z', prRefs: [B] })
|
|
const LATE_TURN = () => turn({ cost: 10, ts: '2026-07-20T10:00:00Z' }) // no refs, carries the active PR
|
|
// The wide parse recorded PR A as active entering the range; a switch to B lands
|
|
// July 10. A slice starting July 20 must attribute the ref-less July 20 turn to B,
|
|
// NOT the stale A. Tested under both turn orderings.
|
|
const build = (turns: ClassifiedTurn[]) => [project([parent({ id: 'S', prLinks: [A, B], prRefsAtRangeStart: [A], turns })])]
|
|
|
|
it('recomputes to B (July 1 A -> July 10 B, slice July 20), both turn orders', () => {
|
|
const range = { start: new Date('2026-07-20T00:00:00Z'), end: new Date('2026-07-21T23:59:59Z') }
|
|
for (const turns of [[A_TURN(), B_TURN(), LATE_TURN()], [LATE_TURN(), B_TURN(), A_TURN()]]) {
|
|
const rows = aggregateByPr(filterProjectsByDateRange(build(turns), range))
|
|
expect(rowFor(rows, B)?.cost ?? 0).toBeCloseTo(10, 6) // ref-less July 20 turn -> B
|
|
expect(rowFor(rows, A)).toBeUndefined() // NOT the stale range-start A
|
|
}
|
|
})
|
|
|
|
it('a slice starting exactly ON the switch turn attributes that turn to the new PR', () => {
|
|
// Switch to B lands July 10 10:00; slice starts July 10 00:00. The July 10 turn is
|
|
// inside the slice and applies B; the recomputed seed (from before) is A.
|
|
const range = { start: new Date('2026-07-10T00:00:00Z'), end: new Date('2026-07-21T23:59:59Z') }
|
|
const rows = aggregateByPr(filterProjectsByDateRange(build([A_TURN(), B_TURN(), LATE_TURN()]), range))
|
|
expect(rowFor(rows, B)!.cost).toBeCloseTo(20, 6) // July 10 (B) + July 20 (carried B)
|
|
expect(rowFor(rows, A)).toBeUndefined() // July 1 A turn is out of slice
|
|
})
|
|
|
|
it('day filter recomputes the same way (menubar path)', () => {
|
|
const rows = aggregateByPr(filterProjectsByDays(build([A_TURN(), B_TURN(), LATE_TURN()]), new Set(['2026-07-20'])))
|
|
expect(rowFor(rows, B)?.cost ?? 0).toBeCloseTo(10, 6)
|
|
expect(rowFor(rows, A)).toBeUndefined()
|
|
})
|
|
|
|
it('breaks an exact-timestamp seed tie deterministically across both turn orders', () => {
|
|
// Two PR-bearing turns at the SAME pre-slice millisecond (A and B). The seed must
|
|
// be the same regardless of array order (lexicographically-last ref key wins -> B).
|
|
const sameMsA = turn({ cost: 5, ts: '2026-07-10T10:00:00Z', prRefs: [A] })
|
|
const sameMsB = turn({ cost: 5, ts: '2026-07-10T10:00:00Z', prRefs: [B] })
|
|
const range = { start: new Date('2026-07-20T00:00:00Z'), end: new Date('2026-07-21T23:59:59Z') }
|
|
for (const turns of [[sameMsA, sameMsB, LATE_TURN()], [sameMsB, sameMsA, LATE_TURN()]]) {
|
|
const rows = aggregateByPr(filterProjectsByDateRange(build(turns), range))
|
|
expect(rowFor(rows, B)?.cost ?? 0).toBeCloseTo(10, 6) // ref-less July 20 -> B (stable tie-break)
|
|
expect(rowFor(rows, A)).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
it('per-day seeding handles a NON-CONTIGUOUS selection: a switch on an unselected day carries', () => {
|
|
// July 1 A (selected), July 2 B (UNSELECTED gap), July 3 ref-less (selected). The
|
|
// July 3 turn must carry B (the switch on the skipped July 2), not the stale A.
|
|
const jul1 = turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })
|
|
const jul2 = turn({ cost: 10, ts: '2026-07-02T10:00:00Z', prRefs: [B] })
|
|
const jul3 = turn({ cost: 10, ts: '2026-07-03T10:00:00Z' }) // ref-less
|
|
const projects = [project([parent({ id: 'S', prLinks: [A, B], turns: [jul1, jul2, jul3] })])]
|
|
const rows = aggregateByPr(filterProjectsByDays(projects, new Set(['2026-07-01', '2026-07-03'])))
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(10, 6) // July 1 -> A
|
|
expect(rowFor(rows, B)!.cost).toBeCloseTo(10, 6) // July 3 -> B (carried across the gap)
|
|
})
|
|
|
|
it('contiguous control: the same three days selected still attribute July 3 to B', () => {
|
|
const jul1 = turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })
|
|
const jul2 = turn({ cost: 10, ts: '2026-07-02T10:00:00Z', prRefs: [B] })
|
|
const jul3 = turn({ cost: 10, ts: '2026-07-03T10:00:00Z' })
|
|
const projects = [project([parent({ id: 'S', prLinks: [A, B], turns: [jul1, jul2, jul3] })])]
|
|
const rows = aggregateByPr(filterProjectsByDays(projects, new Set(['2026-07-01', '2026-07-02', '2026-07-03'])))
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(10, 6) // July 1
|
|
expect(rowFor(rows, B)!.cost).toBeCloseTo(20, 6) // July 2 (B) + July 3 (carried B)
|
|
})
|
|
|
|
it('does NOT drop a same-id anchor of a DIFFERENT provider (fold preserved)', () => {
|
|
// A surviving Codex session shares the raw id "X" with a Claude fold anchor;
|
|
// provider-aware identity keeps them distinct, so the anchor and its fold survive.
|
|
const codexX = parent({ id: 'X', prLinks: [], turns: [turn({ cost: 10, ts: '2026-07-20T10:00:00Z' })] })
|
|
codexX.turns[0]!.assistantCalls[0]!.provider = 'codex'
|
|
const anchorX = parent({ id: 'X', prLinks: [A], turns: [], agentSpawnLinks: { c1: 'sx' }, spawnPrSets: { sx: [A] } })
|
|
const childC1 = child({ agentId: 'c1', parentId: 'X', cost: 100, firstTs: '2026-07-20T10:05:00Z' })
|
|
const filtered = filterProjectsByDays([project([codexX, childC1], 'p', [anchorX])], new Set(['2026-07-20']))
|
|
expect(filtered.some(p => (p.subagentAnchors ?? []).some(a => a.sessionId === 'X'))).toBe(true) // Claude anchor kept
|
|
expect(prLinkedTotals(filtered).subagentSessions).toBe(1) // its child still folds
|
|
})
|
|
|
|
it('drops a genuinely identical same-provider duplicate anchor', () => {
|
|
// The surviving session and the anchor are the SAME provider AND record (identical
|
|
// fingerprint): a proven duplicate, so the anchor is dropped.
|
|
const make = () => parent({ id: 'Y', prLinks: [A], turns: [turn({ cost: 10, ts: '2026-07-20T10:00:00Z', prRefs: [A] })], agentSpawnLinks: { c1: 'sx' }, spawnPrSets: { sx: [A] } })
|
|
const filtered = filterProjectsByDays([project([make()], 'p', [make()])], new Set(['2026-07-20']))
|
|
expect(filtered.flatMap(p => p.subagentAnchors ?? []).some(a => a.sessionId === 'Y')).toBe(false)
|
|
})
|
|
|
|
it('keeps a same-id/different-record anchor so the ambiguity guard folds neither', () => {
|
|
// Same provider + id "Z" but a DIFFERENT record: not a proven duplicate, so the
|
|
// anchor is kept; both records then make the key ambiguous and a child folds nowhere.
|
|
const surviving = parent({ id: 'Z', prLinks: [A], turns: [turn({ cost: 10, ts: '2026-07-20T10:00:00Z', prRefs: [A] })], agentSpawnLinks: { c1: 'sx' }, spawnPrSets: { sx: [A] } })
|
|
const anchorZ = parent({ id: 'Z', prLinks: [B], turns: [], agentSpawnLinks: { c1: 'sy' }, spawnPrSets: { sy: [B] } }) // different record
|
|
const childC1 = child({ agentId: 'c1', parentId: 'Z', cost: 100, firstTs: '2026-07-20T10:05:00Z' })
|
|
const filtered = filterProjectsByDays([project([surviving, childC1], 'p', [anchorZ])], new Set(['2026-07-20']))
|
|
expect(filtered.flatMap(p => p.subagentAnchors ?? []).some(a => a.sessionId === 'Z')).toBe(true) // anchor NOT dropped
|
|
expect(prLinkedTotals(filtered).subagentSessions).toBe(0) // ambiguous -> child folds nowhere
|
|
})
|
|
})
|
|
|
|
describe('MINOR: row session-key delimiter does not collide on names with spaces', () => {
|
|
it('counts two sessions whose space-joined keys would collide as distinct', () => {
|
|
// "a b" + "c" and "a" + "b c" both become "a b c" under a space delimiter,
|
|
// undercounting the PR row's session count. A NUL delimiter keeps them distinct.
|
|
const s1 = parent({ id: 'c', project: 'a b', prLinks: [A], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })] })
|
|
const s2 = parent({ id: 'b c', project: 'a', prLinks: [A], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })] })
|
|
const rows = aggregateByPr([project([s1], 'a b'), project([s2], 'a')])
|
|
expect(rowFor(rows, A)!.sessions).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('MAJOR: recursion dedup and conflicting duplicates', () => {
|
|
// Parent P > c1, c2; both reach a grandchild id "agent-gc" (duplicate data).
|
|
const diamond = (gc1Cost: number, gc2Cost: number, order: 'c1first' | 'c2first') => {
|
|
const base = [
|
|
parent({ id: 'P', prLinks: [A], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })], agentSpawnLinks: { c1: 'x1', c2: 'x2' }, spawnPrSets: { x1: [A], x2: [A] } }),
|
|
child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:05:00Z' }),
|
|
child({ agentId: 'c2', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:06:00Z' }),
|
|
]
|
|
const gc1 = child({ agentId: 'gc', parentId: 'agent-c1', cost: gc1Cost, firstTs: '2026-07-01T10:07:00Z' })
|
|
const gc2 = child({ agentId: 'gc', parentId: 'agent-c2', cost: gc2Cost, firstTs: '2026-07-01T10:07:00Z' })
|
|
return [project([...base, ...(order === 'c1first' ? [gc1, gc2] : [gc2, gc1])])]
|
|
}
|
|
|
|
it('conflicting duplicate ids ($50 vs $500) fold into NEITHER, deterministic across input order', () => {
|
|
for (const order of ['c1first', 'c2first'] as const) {
|
|
const rows = aggregateByPr(diamond(50, 500, order))
|
|
// 10 (parent) + 100 (c1) + 100 (c2); the conflicting grandchild folds nowhere.
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(210, 6)
|
|
expect(prLinkedTotals(diamond(50, 500, order)).subagentSessions).toBe(2) // c1, c2 only
|
|
}
|
|
})
|
|
|
|
it('a truly identical duplicate child (same parent, same record) folds exactly once', () => {
|
|
// Two copies of the SAME child under the SAME parent, identical in every
|
|
// fingerprinted field: one logical session, folds once (not doubled).
|
|
const mk = () => [project([
|
|
parent({ id: 'P', prLinks: [A], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })], agentSpawnLinks: { c1: 'x' }, spawnPrSets: { x: [A] } }),
|
|
child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:05:00Z' }),
|
|
child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:05:00Z' }), // exact duplicate
|
|
])]
|
|
expect(rowFor(aggregateByPr(mk()), A)!.cost).toBeCloseTo(110, 6) // 10 + 100 (once, not 210)
|
|
expect(prLinkedTotals(mk()).subagentSessions).toBe(1)
|
|
})
|
|
|
|
it('the SAME id under DIFFERENT parents is ambiguous even at equal cost (distinct identity)', () => {
|
|
// Different parentSessionId is part of the fingerprint, so these are NOT the
|
|
// same logical session: fold neither.
|
|
const rows = aggregateByPr(diamond(50, 50, 'c1first'))
|
|
expect(rowFor(rows, A)!.cost).toBeCloseTo(210, 6) // grandchild folds nowhere
|
|
})
|
|
})
|