From 384fb5ef0ba74aeced79b74f03cd0d947d9d2ef1 Mon Sep 17 00:00:00 2001 From: reviewer Date: Tue, 21 Jul 2026 02:52:21 +0200 Subject: [PATCH] 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). --- src/parser.ts | 52 ++- src/session-cache.ts | 82 +++-- src/sessions-report.ts | 351 ++++++++++--------- src/types.ts | 11 +- tests/parser-rich-capture.test.ts | 36 ++ tests/parser-subagent-range.test.ts | 82 +++++ tests/session-cache-v5-adoption.test.ts | 50 +++ tests/sessions-subagent-fold.test.ts | 428 ++++++++++++++---------- 8 files changed, 718 insertions(+), 374 deletions(-) create mode 100644 tests/parser-subagent-range.test.ts diff --git a/src/parser.ts b/src/parser.ts index 6df4a48..028a076 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1275,11 +1275,21 @@ export function collectSessionMeta(entry: JournalEntry, meta: SessionMeta): void const msg = entry.message const content = msg && typeof msg === 'object' ? (msg as { content?: unknown }).content : undefined if (Array.isArray(content)) { - for (const b of content) { - if (!b || typeof b !== 'object' || (b as { type?: unknown }).type !== 'tool_result') continue - const id = (b as { tool_use_id?: unknown }).tool_use_id - if (typeof id === 'string' && id) { meta.agentSpawnLinks[agentId] = id; break } + const results = content.filter((b): b is Record => + !!b && typeof b === 'object' && (b as { type?: unknown }).type === 'tool_result' + && typeof (b as { tool_use_id?: unknown }).tool_use_id === 'string' && !!(b as { tool_use_id?: unknown }).tool_use_id) + let spawnId: string | undefined + if (results.length === 1) { + spawnId = results[0]!['tool_use_id'] as string + } else if (results.length > 1) { + // Several batched tool results share one entry: pair the agentId with the + // block whose `content` is the spawn result (equals `toolUseResult.content`), + // so an unrelated sibling block cannot capture the id. Skip if ambiguous. + const turContent = JSON.stringify((tur as Record)['content']) + const matches = results.filter(b => JSON.stringify(b['content']) === turContent) + if (matches.length === 1) spawnId = matches[0]!['tool_use_id'] as string } + if (spawnId) meta.agentSpawnLinks[agentId] = spawnId } } } @@ -1551,6 +1561,22 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set, return turns } +// Map each subagent-spawn `tool_use` id to the PR set active at the turn that +// emitted it, walking the FULL turn list in order. A turn's own `prRefs` apply to +// spawns within it; otherwise the carried set does. First occurrence of a spawn id +// wins deterministically (tool_use ids are unique in practice; this only guards a +// pathological restatement). Drives cross-range subagent PR attribution. +export function buildSpawnPrSets(turns: Array<{ prRefs?: string[]; spawnToolUseIds?: string[] }>): Record { + const out: Record = {} + let cur: string[] = [] + for (const turn of turns) { + const active = turn.prRefs?.length ? turn.prRefs : cur + for (const id of turn.spawnToolUseIds ?? []) if (!(id in out)) out[id] = active + if (turn.prRefs?.length) cur = turn.prRefs + } + return out +} + /** * Extract MCP tool inventory observed across a session's JSONL entries. * @@ -2144,6 +2170,12 @@ async function scanProjectDirs( // session's in-range unbranched spend as `null` instead of discarding it. const everHadBranch = carriedBranch !== undefined + // Built from the FULL (pre-slice) turn list: each subagent-spawn tool_use id -> + // the PR set active at the turn that emitted it. Lets a subagent fold into the + // right PR even when its launching turn is later sliced out of range. Only for + // sessions that both spawned subagents and referenced a PR. + const spawnPrSets = cachedFile.prLinks?.length ? buildSpawnPrSets(cachedFile.turns) : {} + if (dateRange) { classifiedTurns = classifiedTurns.filter(turn => { if (turn.assistantCalls.length === 0) return false @@ -2154,7 +2186,12 @@ async function scanProjectDirs( }) } - if (classifiedTurns.length === 0) continue + // A PR-linked parent that spawned subagents is kept even when its OWN turns all + // fall out of range, as a 0-cost fold anchor: an in-range child (an async agent + // that outlived the parent's last in-range turn) still needs the parent's + // `prLinks` / `spawnPrSets` to attribute. It contributes no spend of its own. + const isSpawnAnchor = Object.keys(spawnPrSets).length > 0 && cachedFile.isSidechain !== true + if (classifiedTurns.length === 0 && !isSpawnAnchor) continue const sessionId = basename(filePath, '.jsonl') const projectPath = cachedFile.canonicalCwd ?? claudeSlugFallbackPath(dirName) @@ -2174,12 +2211,13 @@ async function scanProjectDirs( if (cachedFile.parentSessionId) session.parentSessionId = cachedFile.parentSessionId session.agentId = sessionId.startsWith('agent-') ? sessionId.slice('agent-'.length) : sessionId } - // Parent linkage map (only present on sessions that spawned subagents). + // Parent linkage maps (only present on sessions that spawned subagents). if (cachedFile.agentSpawnLinks && Object.keys(cachedFile.agentSpawnLinks).length > 0) { session.agentSpawnLinks = cachedFile.agentSpawnLinks } + if (Object.keys(spawnPrSets).length > 0) session.spawnPrSets = spawnPrSets - if (session.apiCalls > 0) { + if (session.apiCalls > 0 || isSpawnAnchor) { const projectKey = cachedFile.canonicalCwd ? normalizeProjectPathKey(cachedFile.canonicalCwd) : `slug:${dirName}` diff --git a/src/session-cache.ts b/src/session-cache.ts index e4a4b70..34f1ea9 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -141,10 +141,12 @@ export type SessionCache = { // v6: per-turn `prRefs` capture for turn-level PR spend attribution. Existing // cache turns carry no prRefs; bumping forces a one-time re-parse so surviving // transcripts populate the field. (Daily-cache versioning is untouched.) -// v7: sidechain->parent linkage — per-turn `spawnToolUseIds`, per-file -// `parentSessionId` / `agentSpawnLinks` — so subagent spend folds into the parent -// turn's PR set. v6 never shipped, so users cross v5->v7 in a single combined -// bump; the v5 adoption path below still rescues expired-PR orphans. +// v7: sidechain->parent linkage - per-turn `spawnToolUseIds`, per-file +// `parentSessionId` / `agentSpawnLinks` - so subagent spend folds into the parent +// turn's PR set. v6 never shipped, so users cross v5->v7 in a single combined bump. +// INVARIANT: a version bump must extend `PRIOR_CACHE_VERSIONS` (the adoption path +// below) to EVERY prior version that can still exist on disk, or expired-PR +// history from the immediately preceding build silently vanishes. export const CACHE_VERSION = 7 // The cache filename is version-suffixed so different binaries (e.g. an old @@ -392,37 +394,43 @@ function validateCache(raw: unknown): raw is SessionCache { return Object.values(o['providers'] as Record).every(validateProviderSection) } -// The immediately-prior versioned file. On the 5 -> 6 bump we adopt its -// still-relevant entries (see adoptV5Cache) rather than abandoning them; the -// file itself is never written or deleted (old binaries still own it). -const V5_CACHE_FILE = 'session-cache.v5.json' +// Every prior versioned cache file that can still exist on disk from a shipped or +// dev build, NEWEST first. On a bump we adopt the newest one present: its +// expired-source PR orphans (transcripts since deleted) hold attributable spend +// that can never be re-parsed, and each newer version already carried the older +// versions' orphans forward, so the newest is a superset. INVARIANT: a +// CACHE_VERSION bump MUST extend this list to every prior version that can still +// exist on disk, or that history silently vanishes. (v5 was missed on the 5->6 +// bump; v6 on the 6->7 bump; both are listed here.) +const PRIOR_CACHE_VERSIONS = [6, 5] as const -// Lightweight top-level check: a version-5 cache with a providers object. The -// individual files are validated per-entry in adoptV5Cache so one corrupt entry -// cannot drop every valid expired-transcript PR session along with it. -function isV5CacheEnvelope(raw: unknown): raw is { version: number; providers: Record } { +function priorCacheFile(version: number): string { + return `session-cache.v${version}.json` +} + +// Lightweight top-level check: a specific prior-version cache envelope with a +// providers object. Files are validated per-entry in adoptPriorCache so one +// corrupt entry cannot drop every valid expired-transcript PR session. +function isCacheEnvelope(raw: unknown, version: number): raw is { version: number; providers: Record } { if (!raw || typeof raw !== 'object') return false const o = raw as Record - return o['version'] === 5 + return o['version'] === version && !!o['providers'] && typeof o['providers'] === 'object' && !Array.isArray(o['providers']) } -// One-time migration for the 5 -> 6 bump (per-turn prRefs capture). A fresh v6 -// cache would abandon v5 wholesale, so any PR-linked session whose transcript was -// since deleted would vanish instead of taking the by-PR legacy even-split path. -// Carry forward exactly the v5 entries whose source no longer exists AND that -// carry prLinks (they can never re-parse, but they hold attributable PR spend); -// present sources are intentionally dropped so they re-parse fresh under v6 and -// gain per-turn refs. Each file is validated individually, so a single corrupt -// entry is skipped rather than discarding the whole cache. Each carried section -// takes the CURRENT envFingerprint so the scan reuses it and appends the -// freshly-parsed present sources. The daily cache (durable cost history) is not -// touched. -async function adoptV5Cache(): Promise { +// One-time migration on a version bump: carry forward exactly the prior-version +// entries whose source no longer exists AND that carry prLinks (they can never +// re-parse, but they hold attributable PR spend); present sources are dropped so +// they re-parse fresh under the new version and gain the new fields. Each file is +// validated individually, so a single corrupt entry is skipped rather than +// discarding the whole cache. Each carried section takes the CURRENT +// envFingerprint so the scan reuses it and appends the freshly-parsed present +// sources. The daily cache (durable cost history) is not touched. +async function adoptPriorCache(version: number): Promise { try { - const raw = await readFile(join(getCacheDir(), V5_CACHE_FILE), 'utf-8') + const raw = await readFile(join(getCacheDir(), priorCacheFile(version)), 'utf-8') const parsed = JSON.parse(raw) - if (!isV5CacheEnvelope(parsed)) return null + if (!isCacheEnvelope(parsed, version)) return null const migrated: SessionCache = { version: CACHE_VERSION, providers: {}, complete: false } for (const [provider, section] of Object.entries(parsed.providers)) { if (!section || typeof section !== 'object') continue @@ -446,6 +454,15 @@ async function adoptV5Cache(): Promise { } } +// Adopt the newest prior versioned cache present on disk (v6 before v5). +async function adoptNewestPriorCache(): Promise { + for (const version of PRIOR_CACHE_VERSIONS) { + const adopted = await adoptPriorCache(version) + if (adopted) return adopted + } + return null +} + export async function loadCache(): Promise { try { const raw = await readFile(getCachePath(), 'utf-8') @@ -457,12 +474,13 @@ export async function loadCache(): Promise { } } -// The versioned (v6) file is absent/unreadable. Prefer adopting the prior v5 -// file's expired-source PR orphans; failing that, fall back to the legacy -// unversioned file. Either way the versioned file is minted on the next save. +// The current versioned file is absent/unreadable. Prefer adopting the newest +// prior versioned file's expired-source PR orphans (v6 before v5); failing that, +// fall back to the legacy unversioned file. Either way the versioned file is +// minted on the next save. async function afterMissingVersionedCache(): Promise { - const v5 = await adoptV5Cache() - if (v5) return v5 + const prior = await adoptNewestPriorCache() + if (prior) return prior // validateCache requires version === CACHE_VERSION, so a different-version // legacy file is ignored (left intact). We copy it into the versioned file once // via saveCache; the legacy file is never modified. diff --git a/src/sessions-report.ts b/src/sessions-report.ts index d84e103..fece1d3 100644 --- a/src/sessions-report.ts +++ b/src/sessions-report.ts @@ -172,23 +172,62 @@ export function allocateEven(total: number, n: number): number[] { return Array.from({ length: n }, (_, i) => base + (i < extra ? 1 : 0)) } -/// A subagent (sidechain) session's spend, pre-aggregated for folding into the -/// parent turn that launched it. `models` is keyed by RAW model name (the row -/// builder collapses it to short names, exactly like a turn's own calls); -/// `categories` by TaskCategory. Both maps sum to `cost` (all three derive from -/// the same child turns), so folding a child in never introduces a rounding gap. +/// A subagent (sidechain) session's spend plus its non-self-linking descendants, +/// pre-aggregated for folding into the PR its launching parent turn was working +/// on. `models` keys are RAW model names (the row builder collapses them to short +/// names, exactly like a turn's own calls); `categories` are TaskCategory. All +/// three sum to `cost` (derived from the same sessions), so folding never adds a +/// rounding gap. `foldedSessions` counts the subtree (self plus folded +/// descendants); `spawnAtMs` is the TOP child's first-activity epoch (the whole +/// subtree resolves against the top parent through the top child's spawn). export type ChildFold = { agentId: string cost: number calls: number savingsUSD: number - /// The child's first-activity timestamp, used by the timestamp-bucket fallback. - spawnAt: string + spawnAtMs: number models: Map categories: Map + foldedSessions: number } -function childFoldFromSession(child: SessionSummary): ChildFold { +function parseMs(ts: string | undefined): number { + return ts ? Date.parse(ts) : NaN +} + +// A child "self-links" when it referenced its own PR: it then attributes +// standalone (its own turn-level attribution is more precise) and is NEVER folded +// into a parent, so its spend is counted exactly once. A child with no links is +// folded only. This mutual exclusion is what prevents a double-charge. +function selfLinks(session: { prLinks?: string[] }): boolean { + return !!session.prLinks?.length +} + +/// Index every sidechain (subagent) session by the parent that spawned it, keyed +/// by `parentSessionId` ALONE. Parent ids are UUIDs and subagent ids are +/// `agent-`, both globally unique, so a composite project key is unnecessary +/// and would drop a child whose worktree cwd resolves to a different +/// ProjectSummary than its parent. (The base PR's project+sessionId composite key +/// guards distinct-session COUNTING, a separate concern.) A child whose parent is +/// absent from the scan is never looked up, so it stays a standalone orphan. +export function buildSubagentIndex(projects: ProjectSummary[]): Map { + const index = new Map() + for (const project of projects) + for (const session of project.sessions) { + if (!session.parentSessionId) continue + const list = index.get(session.parentSessionId) + if (list) list.push(session) + else index.set(session.parentSessionId, [session]) + } + return index +} + +// Aggregate a child session and its non-self-linking descendants, depth-first, +// guarded by `visited` against cycles (and against a session being pulled twice). +// A self-linking descendant is skipped: it attributes standalone. `spawnAtMs` +// stays the TOP child's, since the whole subtree resolves against the top parent. +function buildChildFold(child: SessionSummary, index: Map, visited: Set): ChildFold { + visited.add(child.sessionId) const models = new Map() const categories = new Map() for (const turn of child.turns) { @@ -199,97 +238,94 @@ function childFoldFromSession(child: SessionSummary): ChildFold { } if (turn.category) addToMap(categories, turn.category, turnCost) } - return { + const fold: ChildFold = { agentId: child.agentId ?? child.sessionId, - cost: child.totalCostUSD, - calls: child.apiCalls, - savingsUSD: child.totalSavingsUSD, - spawnAt: child.firstTimestamp, - models, - categories, + cost: child.totalCostUSD, calls: child.apiCalls, savingsUSD: child.totalSavingsUSD, + spawnAtMs: parseMs(child.firstTimestamp), models, categories, foldedSessions: 1, } -} - -// A sessionId can repeat across projects, so the parent index is keyed on -// project + parentSessionId (a child and its parent share a project). -function subagentKey(project: string, parentSessionId: string): string { - return `${project}${parentSessionId}` -} - -/// Index every sidechain (subagent) session by the parent that spawned it. Keyed -/// on project + `parentSessionId`. Children whose parent id was never captured -/// (or is absent from the scan) are simply never looked up, so they stay -/// standalone sessions contributing nothing to by-PR — the orphan behavior. -export function buildSubagentIndex(projects: ProjectSummary[]): Map { - const index = new Map() - for (const project of projects) { - for (const session of project.sessions) { - if (!session.parentSessionId) continue - const key = subagentKey(session.project, session.parentSessionId) - const list = index.get(key) - if (list) list.push(childFoldFromSession(session)) - else index.set(key, [childFoldFromSession(session)]) - } + for (const gc of index.get(child.sessionId) ?? []) { + if (visited.has(gc.sessionId) || selfLinks(gc)) continue + const gcf = buildChildFold(gc, index, visited) + fold.cost += gcf.cost; fold.calls += gcf.calls; fold.savingsUSD += gcf.savingsUSD + fold.foldedSessions += gcf.foldedSessions + for (const [m, c] of gcf.models) addToMap(fold.models, m, c) + for (const [cat, c] of gcf.categories) addToMap(fold.categories, cat, c) } - return index + return fold } -/// Children of one parent, resolved to the turn that launched each. `byTurnIndex` -/// keys align with the parent's `turns` array indices; `unlinked` children -/// resolved to no turn and fold into the parent's unattributed spend. -export type TurnFolds = { byTurnIndex: Map; unlinked: ChildFold[] } +/// A folded child resolved against its parent: to a PR set (`prSet` non-empty), to +/// the parent's unattributed spend (`prSet` null, `unlinked` false), or unlinked +/// (`unlinked` true, contributes NOTHING to by-PR, the orphan semantics). +export type ResolvedChild = { fold: ChildFold; prSet: string[] | null; unlinked: boolean } function turnStartTs(turn: { timestamp?: string; assistantCalls: Array<{ timestamp?: string }> }): string { return turn.assistantCalls[0]?.timestamp || turn.timestamp || '' } -/// Resolve each of a parent's children to the turn that launched it: -/// (a) the child's spawn `tool_use` id (`parent.agentSpawnLinks[agentId]`) -/// matched against a turn's `spawnToolUseIds` — the true launch point, so -/// it WINS even when the child's first activity landed during a later turn -/// (an async/background agent whose result returned under a different PR); -/// (b) else the turn whose [start, next-start) span contains the child's first -/// timestamp; -/// (c) else unlinked (folded into the parent's unattributed spend). -export function resolveChildFolds( - parent: { turns: Array<{ spawnToolUseIds?: string[]; timestamp?: string; assistantCalls: Array<{ timestamp?: string }> }>; agentSpawnLinks?: Record }, - children: ChildFold[], -): TurnFolds { - const byTurnIndex = new Map() - const unlinked: ChildFold[] = [] - const turns = parent.turns - const spawnTurn = new Map() - turns.forEach((turn, i) => { - for (const id of turn.spawnToolUseIds ?? []) if (!spawnTurn.has(id)) spawnTurn.set(id, i) - }) - const push = (i: number, child: ChildFold): void => { - const list = byTurnIndex.get(i) - if (list) list.push(child) - else byTurnIndex.set(i, [child]) +/// Resolve a fold to the PR its launching parent turn was working on, using the +/// parent's UNFILTERED turn data so a date range cannot misattribute: +/// 1. spawn `tool_use` id to `parent.spawnPrSets` (built at assembly from the +/// FULL turn list), so a spawn in a pre-range turn still yields the right PR; +/// an empty set there means the spawn had no active PR, so unattributed. +/// 2. else the child's first-activity epoch bucketed into the parent's turn PR +/// carry (seeded from `prRefsAtRangeStart`). Timestamps compare as epoch ms +/// (mixed UTC offsets order correctly). Activity strictly AFTER the parent's +/// last timestamp is unlinked (contributes nothing); before the first turn it +/// carries the pre-range set (or unattributed). +function resolveChild(parent: SessionSummary, fold: ChildFold): ResolvedChild { + const spawnId = parent.agentSpawnLinks?.[fold.agentId] + if (spawnId !== undefined && parent.spawnPrSets && Object.prototype.hasOwnProperty.call(parent.spawnPrSets, spawnId)) { + const prs = parent.spawnPrSets[spawnId]! + return { fold, prSet: prs.length ? prs : null, unlinked: false } } - for (const child of children) { - const spawnId = parent.agentSpawnLinks?.[child.agentId] - if (spawnId !== undefined && spawnTurn.has(spawnId)) { push(spawnTurn.get(spawnId)!, child); continue } - const ts = child.spawnAt - let idx = -1 - if (ts) { - for (let i = 0; i < turns.length; i++) { - const start = turnStartTs(turns[i]!) - if (!start) continue - if (start <= ts) idx = i - else break - } - } - if (idx >= 0) push(idx, child) - else unlinked.push(child) + const ms = fold.spawnAtMs + if (Number.isNaN(ms)) return { fold, prSet: null, unlinked: true } + const lastMs = parseMs(parent.lastTimestamp) + if (!Number.isNaN(lastMs) && ms > lastMs) return { fold, prSet: null, unlinked: true } + let current: string[] | null = parent.prRefsAtRangeStart?.length ? parent.prRefsAtRangeStart : null + for (const turn of parent.turns) { + const tMs = parseMs(turnStartTs(turn)) + if (Number.isNaN(tMs)) continue + if (tMs <= ms) { if (turn.prRefs?.length) current = turn.prRefs } + else break } - return { byTurnIndex, unlinked } + return { fold, prSet: current, unlinked: false } } -function* allFolds(folds?: TurnFolds): Iterable { - if (!folds) return - for (const list of folds.byTurnIndex.values()) yield* list - yield* folds.unlinked +/// Resolve every folded child to its parent's PR set, ONCE. Both `aggregateByPr` +/// and `prLinkedTotals` consume this instead of each rebuilding the index and +/// re-resolving. Keyed by parent sessionId; only PR-bearing parents (the ones the +/// consumers iterate) are resolved. A self-linking direct child is skipped (it +/// attributes standalone); its own non-self-linking descendants fold into it when +/// it is itself iterated as a PR-bearing parent. +export type SubagentAttribution = Map + +// Memoize by the `projects` array identity so `aggregateByPr` and `prLinkedTotals` +// (called with the same array in one report render) resolve the subagent tree +// once, not twice. Keyed weakly, so it is released with the array; a fresh report +// builds a new array and recomputes. Public signature is unchanged. +const attributionCache = new WeakMap() + +export function resolveSubagentAttribution(projects: ProjectSummary[]): SubagentAttribution { + const memo = attributionCache.get(projects) + if (memo) return memo + const index = buildSubagentIndex(projects) + const out: SubagentAttribution = new Map() + for (const project of projects) + for (const parent of project.sessions) { + if (!parent.prLinks?.length) continue + const direct = index.get(parent.sessionId) + if (!direct?.length) continue + const resolved: ResolvedChild[] = [] + for (const child of direct) { + if (selfLinks(child)) continue + resolved.push(resolveChild(parent, buildChildFold(child, index, new Set()))) + } + if (resolved.length) out.set(parent.sessionId, resolved) + } + attributionCache.set(projects, out) + return out } /// Attribute a session's spend to the PRs it referenced, at TURN granularity. @@ -308,15 +344,7 @@ function* allFolds(folds?: TurnFolds): Iterable { /// to attribute by, split the whole session evenly across its prLinks, mark every /// portion `approx`, and carry the session's model union (its calls still name /// their models) but NO category breakdown, since none can be honestly assigned. -/// -/// Subagent folding: `folds` carries the spend of the sidechain sessions this one -/// spawned, resolved to the launching turn (see resolveChildFolds). Each child's -/// cost/calls/savings/models/categories are added into that turn BEFORE the PR -/// split, so the child inherits the turn's PR set; a child on a pre-reference turn -/// (or resolved to no turn) folds into `unattributed`. Children are folded exactly -/// once here and never self-attribute (their own `prLinks` is empty), so no spend -/// is double-counted. -export function attributeSessionPrSpend(session: AttributableSession, folds?: TurnFolds): SessionPrAttribution { +export function attributeSessionPrSpend(session: AttributableSession): SessionPrAttribution { const perUrl = new Map() const unattributed = { cost: 0, calls: 0, savingsUSD: 0 } @@ -330,21 +358,13 @@ export function attributeSessionPrSpend(session: AttributableSession, folds?: Tu if (call.model) addToMap(legacyModels, call.model, call.costUSD) } } - // Fold subagent spend into the same even split so it is not dropped. Rare: - // a legacy (expired-transcript) parent almost never has live children, but - // if it does their spend must still land somewhere honest. - let extraCost = 0, extraCalls = 0, extraSavings = 0 - for (const child of allFolds(folds)) { - extraCost += child.cost; extraCalls += child.calls; extraSavings += child.savingsUSD - for (const [m, mc] of child.models) addToMap(legacyModels, m, mc) - } const share = 1 / links.length - const callAlloc = allocateEven(session.apiCalls + extraCalls, links.length) + const callAlloc = allocateEven(session.apiCalls, links.length) links.forEach((url, i) => { const e = ensureContribution(perUrl, url) - e.cost += (session.totalCostUSD + extraCost) * share + e.cost += session.totalCostUSD * share e.calls += callAlloc[i]! - e.savingsUSD += (session.totalSavingsUSD + extraSavings) * share + e.savingsUSD += session.totalSavingsUSD * share e.approx = true for (const [m, mc] of legacyModels) addToMap(e.models, m, mc * share) }) @@ -353,27 +373,11 @@ export function attributeSessionPrSpend(session: AttributableSession, folds?: Tu } let current: string[] | null = session.prRefsAtRangeStart?.length ? session.prRefsAtRangeStart : null - for (let index = 0; index < session.turns.length; index++) { - const turn = session.turns[index]! + for (const turn of session.turns) { if (turn.prRefs?.length) current = turn.prRefs - let cost = 0, calls = 0, savings = 0 - let ownCost = 0 - const modelCostInTurn = new Map() - const categoryCostInTurn = new Map() - for (const call of turn.assistantCalls) { - cost += call.costUSD; ownCost += call.costUSD; calls += 1; savings += call.savingsUSD ?? 0 - if (call.model) addToMap(modelCostInTurn, call.model, call.costUSD) - } - // The turn's own spend lands under its single classified category; each folded - // child contributes its OWN per-turn category breakdown (cheaply available - // from the child's turns), so opus/haiku work shows under the categories the - // subagent actually did rather than the parent turn's label. - if (turn.category) addToMap(categoryCostInTurn, turn.category, ownCost) - for (const child of folds?.byTurnIndex.get(index) ?? []) { - cost += child.cost; calls += child.calls; savings += child.savingsUSD - for (const [m, mc] of child.models) addToMap(modelCostInTurn, m, mc) - for (const [cat, cc] of child.categories) addToMap(categoryCostInTurn, cat, cc) - } + const cost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0) + const calls = turn.assistantCalls.length + const savings = turn.assistantCalls.reduce((s, c) => s + (c.savingsUSD ?? 0), 0) if (cost === 0 && calls === 0 && savings === 0) continue if (current === null) { unattributed.cost += cost @@ -381,6 +385,10 @@ export function attributeSessionPrSpend(session: AttributableSession, folds?: Tu unattributed.savingsUSD += savings continue } + const modelCostInTurn = new Map() + for (const call of turn.assistantCalls) { + if (call.model) addToMap(modelCostInTurn, call.model, call.costUSD) + } const share = 1 / current.length const callAlloc = allocateEven(calls, current.length) current.forEach((url, i) => { @@ -388,17 +396,10 @@ export function attributeSessionPrSpend(session: AttributableSession, folds?: Tu e.cost += cost * share e.calls += callAlloc[i]! e.savingsUSD += savings * share - for (const [cat, cc] of categoryCostInTurn) addToMap(e.categories, cat, cc * share) + if (turn.category) addToMap(e.categories, turn.category, cost * share) for (const [m, mc] of modelCostInTurn) addToMap(e.models, m, mc * share) }) } - // A child that resolved to no turn (no spawn link and its first activity fell - // outside every turn span) is genuine parent-session overhead: unattributed. - for (const child of folds?.unlinked ?? []) { - unattributed.cost += child.cost - unattributed.calls += child.calls - unattributed.savingsUSD += child.savingsUSD - } return { perUrl, unattributed } } @@ -414,35 +415,59 @@ export function aggregateByPr(projects: ProjectSummary[]): PrRow[] { sessions: Set; firstStarted: string; lastEnded: string models: Map; categories: Map }>() - const subagentIndex = buildSubagentIndex(projects) + const attribution = resolveSubagentAttribution(projects) + // Add one contribution (a parent turn's share, or a folded child's share) to a + // PR row. `sessionKey` is the PARENT session identity, so a folded child does + // not inflate the row's distinct-session count beyond its parent. + const addTo = ( + url: string, sessionKey: string, firstTs: string, lastTs: string, + cost: number, savings: number, calls: number, approx: boolean, + models: Map, categories: Map, + ): void => { + if (cost === 0 && calls === 0 && savings === 0) return + const row = byUrl.get(url) ?? { + cost: 0, savingsUSD: 0, calls: 0, approx: false, legacyCost: 0, + sessions: new Set(), firstStarted: firstTs, lastEnded: lastTs, + models: new Map(), categories: new Map(), + } + row.cost += cost + row.savingsUSD += savings + row.calls += calls + row.sessions.add(sessionKey) + if (approx) { row.approx = true; row.legacyCost += cost } + for (const [m, mc] of models) addToMap(row.models, m, mc) + for (const [cat, cc] of categories) addToMap(row.categories, cat, cc) + if (firstTs < row.firstStarted) row.firstStarted = firstTs + if (lastTs > row.lastEnded) row.lastEnded = lastTs + byUrl.set(url, row) + } for (const project of projects) { for (const session of project.sessions) { if (!session.prLinks?.length) continue // Key on project + sessionId: a transcript basename (sessionId) can repeat // across projects, so sessionId alone would undercount distinct sessions. const sessionKey = `${session.project}${session.sessionId}` - const children = subagentIndex.get(subagentKey(session.project, session.sessionId)) - const folds = children?.length ? resolveChildFolds(session, children) : undefined - const { perUrl } = attributeSessionPrSpend(session, folds) + const { perUrl } = attributeSessionPrSpend(session) for (const [url, c] of perUrl) { - if (c.cost === 0 && c.calls === 0 && c.savingsUSD === 0) continue - const row = byUrl.get(url) ?? { - cost: 0, savingsUSD: 0, calls: 0, approx: false, legacyCost: 0, - sessions: new Set(), firstStarted: session.firstTimestamp, lastEnded: session.lastTimestamp, - models: new Map(), categories: new Map(), - } - row.cost += c.cost - row.savingsUSD += c.savingsUSD - row.calls += c.calls - row.sessions.add(sessionKey) - // A legacy (approx) contribution carries no per-turn categories; track its - // cost so a mixed row can reconcile its category breakdown to the total. - if (c.approx) { row.approx = true; row.legacyCost += c.cost } - for (const [m, mc] of c.models) addToMap(row.models, m, mc) - for (const [cat, cc] of c.categories) addToMap(row.categories, cat, cc) - if (session.firstTimestamp < row.firstStarted) row.firstStarted = session.firstTimestamp - if (session.lastTimestamp > row.lastEnded) row.lastEnded = session.lastTimestamp - byUrl.set(url, row) + addTo(url, sessionKey, session.firstTimestamp, session.lastTimestamp, c.cost, c.savingsUSD, c.calls, c.approx, c.models, c.categories) + } + // Fold this session's resolved subagent children into the PR each was + // working on. A child resolved to no PR (unattributed) or after the parent's + // last turn (unlinked) creates no row; it surfaces only in prLinkedTotals. + // Folded children carry no `approx` (they have genuine per-turn categories). + for (const rc of attribution.get(session.sessionId) ?? []) { + if (rc.unlinked || !rc.prSet?.length) continue + const prs = rc.prSet + const share = 1 / prs.length + const callAlloc = allocateEven(rc.fold.calls, prs.length) + prs.forEach((url, i) => { + const models = new Map() + for (const [m, mc] of rc.fold.models) models.set(m, mc * share) + const categories = new Map() + for (const [cat, cc] of rc.fold.categories) categories.set(cat, cc * share) + addTo(url, sessionKey, session.firstTimestamp, session.lastTimestamp, + rc.fold.cost * share, rc.fold.savingsUSD * share, callAlloc[i]!, false, models, categories) + }) } } } @@ -494,17 +519,23 @@ export function prLinkedTotals(projects: ProjectSummary[]): { cost: number; sess let unattributedCost = 0 let sessions = 0 let subagentSessions = 0 - const subagentIndex = buildSubagentIndex(projects) + const attribution = resolveSubagentAttribution(projects) for (const project of projects) { for (const session of project.sessions) { if (!session.prLinks?.length) continue sessions += 1 - const children = subagentIndex.get(subagentKey(session.project, session.sessionId)) - const folds = children?.length ? resolveChildFolds(session, children) : undefined - if (children?.length) subagentSessions += children.length - const { perUrl, unattributed } = attributeSessionPrSpend(session, folds) + const { perUrl, unattributed } = attributeSessionPrSpend(session) for (const c of perUrl.values()) attributedCost += c.cost unattributedCost += unattributed.cost + // Fold resolved children: an unlinked child contributes nothing; otherwise + // count its whole folded subtree and add its spend to attributed (resolved to + // a PR) or unattributed (no active PR at its spawn). + for (const rc of attribution.get(session.sessionId) ?? []) { + if (rc.unlinked) continue + subagentSessions += rc.fold.foldedSessions + if (rc.prSet?.length) attributedCost += rc.fold.cost + else unattributedCost += rc.fold.cost + } } } return { cost: attributedCost + unattributedCost, sessions, subagentSessions, attributedCost, unattributedCost } diff --git a/src/types.ts b/src/types.ts index 99e699e..9b1a8b1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -210,9 +210,16 @@ export type SessionSummary = { agentId?: string /// Claude Code only: on a PARENT session, maps each spawned subagent's id to the /// `tool_use` id of the `Agent`/`Task` block that launched it (from the spawn's - /// `toolUseResult.agentId`). Resolves a child to the exact parent turn. Absent - /// when the session spawned no subagent that recorded a result. + /// `toolUseResult.agentId`). Combined with `spawnPrSets` it resolves a child to + /// the PR the launching turn was working on. Absent when the session spawned no + /// subagent that recorded a result. agentSpawnLinks?: Record + /// Claude Code only: on a PARENT session, maps each spawn `tool_use` id to the PR + /// set active at the turn that emitted it, computed from the FULL (pre-date-slice) + /// turn list. This lets a subagent fold into the right PR even when its launching + /// turn falls outside the report's range. Empty array = spawn had no active PR + /// (the child is then unattributed). Absent when the session spawned no subagent. + spawnPrSets?: Record firstTimestamp: string lastTimestamp: string totalCostUSD: number diff --git a/tests/parser-rich-capture.test.ts b/tests/parser-rich-capture.test.ts index 4466e83..ab6777d 100644 --- a/tests/parser-rich-capture.test.ts +++ b/tests/parser-rich-capture.test.ts @@ -8,6 +8,7 @@ import { parseApiCall, groupIntoTurns, parsedTurnsToCachedTurns, + buildSpawnPrSets, type ToolResultMeta, } from '../src/parser.js' import type { JournalEntry } from '../src/types.js' @@ -280,6 +281,21 @@ describe('collectSessionMeta subagent linkage', () => { } as JournalEntry, meta) expect(meta.agentSpawnLinks).toEqual({ a17e80ec626c9de38: 'toolu_spawn1' }) }) + + it('pairs the agentId with the matching block when a record batches several results (unrelated block first)', () => { + const meta = emptySessionMeta() + collectSessionMeta({ + type: 'user', + message: { role: 'user', content: [ + { type: 'tool_result', tool_use_id: 'toolu_unrelated', content: 'a bash result' }, + { type: 'tool_result', tool_use_id: 'toolu_spawn', content: 'agent output' }, + ] }, + // The result's content identifies the spawn block, so the FIRST (unrelated) + // block must not capture the agentId. + toolUseResult: { status: 'completed', agentId: 'a999', content: 'agent output' }, + } as JournalEntry, meta) + expect(meta.agentSpawnLinks).toEqual({ a999: 'toolu_spawn' }) + }) }) // ── per-turn subagent spawn ids (spawnToolUseIds) ────────────────────── @@ -314,3 +330,23 @@ describe('per-turn spawnToolUseIds capture', () => { expect(cached[1]!.spawnToolUseIds).toBeUndefined() }) }) + +describe('buildSpawnPrSets', () => { + it('maps each spawn id to the PR set active at its turn, carrying refs forward', () => { + const sets = buildSpawnPrSets([ + { spawnToolUseIds: ['s0'] }, // before any PR -> empty + { prRefs: ['pr/A'], spawnToolUseIds: ['s1'] }, // spawn under A + { spawnToolUseIds: ['s2'] }, // carries A + { prRefs: ['pr/B'], spawnToolUseIds: ['s3'] }, // spawn under B + ]) + expect(sets).toEqual({ s0: [], s1: ['pr/A'], s2: ['pr/A'], s3: ['pr/B'] }) + }) + + it('first occurrence of a spawn id wins deterministically', () => { + const sets = buildSpawnPrSets([ + { prRefs: ['pr/A'], spawnToolUseIds: ['dup'] }, + { prRefs: ['pr/B'], spawnToolUseIds: ['dup'] }, // restatement must not overwrite + ]) + expect(sets['dup']).toEqual(['pr/A']) + }) +}) diff --git a/tests/parser-subagent-range.test.ts b/tests/parser-subagent-range.test.ts new file mode 100644 index 0000000..940dbab --- /dev/null +++ b/tests/parser-subagent-range.test.ts @@ -0,0 +1,82 @@ +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 { + 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) + }) +}) diff --git a/tests/session-cache-v5-adoption.test.ts b/tests/session-cache-v5-adoption.test.ts index b8617a9..dd10619 100644 --- a/tests/session-cache-v5-adoption.test.ts +++ b/tests/session-cache-v5-adoption.test.ts @@ -183,3 +183,53 @@ describe('v5 -> v6 cache adoption of expired PR sessions', () => { expect(urls).not.toContain('https://github.com/o/r/pull/8') // failed: 'yes' is corrupt, skipped }) }) + +function expiredPrEntry(cwd: string, name: string, prUrl: string): Record { + return { + fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, + mcpInventory: [], canonicalCwd: cwd, canonicalProjectName: name, + prLinks: [prUrl], + turns: [{ timestamp: '2026-07-20T10:00:00.000Z', sessionId: 'gone', userMessage: 'shipped', calls: [cachedCall('kk', 40)] }], + } +} + +// The immediately-preceding versioned cache (v6) must also be adopted on the v7 +// bump, or the last build's expired-PR history vanishes (the same bug class that +// dropped v5 on the 5->6 bump). +describe('newest-prior cache adoption (v6 then v5)', () => { + it('adopts an expired PR entry from a v6-only file into v7 as legacy attribution', async () => { + await loadPricing() + const gonePath = join(configDir, 'projects', 'gone6', 'gone6.jsonl') + const v6 = { + version: 6, complete: true, + providers: { claude: { envFingerprint: 'stale-v6', files: { [gonePath]: expiredPrEntry('/gone6', 'gone6', 'https://github.com/o/r/pull/6') } } }, + } + await writeFile(join(cacheDir, 'session-cache.v6.json'), JSON.stringify(v6)) + + const range = { start: new Date('2026-07-20T00:00:00Z'), end: new Date('2026-07-20T23:59:59Z') } + const rows = aggregateByPr(await parseAllSessions(range, 'claude')) + const row = rows.find(r => r.url === 'https://github.com/o/r/pull/6') + expect(row).toBeDefined() + expect(row!.approx).toBe(true) + expect(row!.cost).toBeCloseTo(40, 6) + }) + + it('prefers the newest prior file: v6 orphan is adopted, v5-only orphan is not', async () => { + await loadPricing() + const v6Path = join(configDir, 'projects', 'in6', 'in6.jsonl') + const v5Path = join(configDir, 'projects', 'in5', 'in5.jsonl') + await writeFile(join(cacheDir, 'session-cache.v6.json'), JSON.stringify({ + version: 6, complete: true, + providers: { claude: { envFingerprint: 'v6', files: { [v6Path]: expiredPrEntry('/in6', 'in6', 'https://github.com/o/r/pull/60') } } }, + })) + await writeFile(join(cacheDir, 'session-cache.v5.json'), JSON.stringify({ + version: 5, complete: true, + providers: { claude: { envFingerprint: 'v5', files: { [v5Path]: expiredPrEntry('/in5', 'in5', 'https://github.com/o/r/pull/50') } } }, + })) + + const range = { start: new Date('2026-07-20T00:00:00Z'), end: new Date('2026-07-20T23:59:59Z') } + const urls = aggregateByPr(await parseAllSessions(range, 'claude')).map(r => r.url) + expect(urls).toContain('https://github.com/o/r/pull/60') // v6 (newest) adopted + expect(urls).not.toContain('https://github.com/o/r/pull/50') // v5 superseded by v6 + }) +}) diff --git a/tests/sessions-subagent-fold.test.ts b/tests/sessions-subagent-fold.test.ts index ea532a4..f72efce 100644 --- a/tests/sessions-subagent-fold.test.ts +++ b/tests/sessions-subagent-fold.test.ts @@ -2,10 +2,9 @@ import { describe, expect, it } from 'vitest' import { aggregateByPr, - attributeSessionPrSpend, buildSubagentIndex, prLinkedTotals, - resolveChildFolds, + resolveSubagentAttribution, } from '../src/sessions-report.js' import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary, TokenUsage } from '../src/types.js' @@ -27,66 +26,62 @@ function call(cost: number, model: string, ts: string): ParsedApiCall { } } -// A parent turn: one call of `cost` under `model`, at `ts`, optionally carrying -// `prRefs` and the `spawnToolUseIds` of subagents launched in the turn. -function turn(opts: { - cost: number; model?: string; ts: string; category?: ClassifiedTurn['category'] - prRefs?: string[]; spawnToolUseIds?: string[] -}): ClassifiedTurn { +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 } : {}), - ...(opts.spawnToolUseIds ? { spawnToolUseIds: opts.spawnToolUseIds } : {}), } } +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[] - agentSpawnLinks?: Record; project?: string + id: string; prLinks: string[]; turns: ClassifiedTurn[]; project?: string + agentSpawnLinks?: Record; spawnPrSets?: Record + prRefsAtRangeStart?: string[]; first?: string; last?: string }): SessionSummary { return { + ...BASE, sessionId: opts.id, project: opts.project ?? 'p', - firstTimestamp: '2026-07-01T10:00:00Z', lastTimestamp: '2026-07-01T12:00:00Z', - totalCostUSD: 0, totalSavingsUSD: 0, totalEstimatedCostUSD: 0, - totalInputTokens: 0, totalOutputTokens: 0, totalReasoningTokens: 0, - totalCacheReadTokens: 0, totalCacheWriteTokens: 0, + 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, - modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, - categoryBreakdown: {} as SessionSummary['categoryBreakdown'], - skillBreakdown: {} as SessionSummary['skillBreakdown'], - subagentBreakdown: {} as SessionSummary['subagentBreakdown'], - prLinks: opts.prLinks, + turns: opts.turns, prLinks: opts.prLinks, ...(opts.agentSpawnLinks ? { agentSpawnLinks: opts.agentSpawnLinks } : {}), + ...(opts.spawnPrSets ? { spawnPrSets: opts.spawnPrSets } : {}), + ...(opts.prRefsAtRangeStart ? { prRefsAtRangeStart: opts.prRefsAtRangeStart } : {}), } } -// A sidechain (subagent) session: one turn of `cost` under `model`/`category`, -// linked to `parentId` via `agentId`, first active at `firstTs`. function child(opts: { agentId: string; parentId: string; cost: number; model?: string - category?: ClassifiedTurn['category']; firstTs: string; project?: string; calls?: number + 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.firstTs, - totalCostUSD: opts.cost, totalSavingsUSD: 0, totalEstimatedCostUSD: 0, - totalInputTokens: 0, totalOutputTokens: 0, totalReasoningTokens: 0, - totalCacheReadTokens: 0, totalCacheWriteTokens: 0, - apiCalls: n, turns: [t], - modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, - categoryBreakdown: {} as SessionSummary['categoryBreakdown'], - skillBreakdown: {} as SessionSummary['skillBreakdown'], - subagentBreakdown: {} as SessionSummary['subagentBreakdown'], + firstTimestamp: opts.firstTs, lastTimestamp: opts.last ?? opts.firstTs, + totalCostUSD: opts.cost, apiCalls: n, turns: [t], + ...(opts.prLinks ? { prLinks: opts.prLinks } : {}), } } @@ -94,161 +89,248 @@ function project(sessions: SessionSummary[], name = 'p'): ProjectSummary { return { project: name, projectPath: `/${name}`, sessions, totalCostUSD: 0, totalSavingsUSD: 0, totalApiCalls: 0, totalProxiedCostUSD: 0 } } -describe('buildSubagentIndex', () => { - it('indexes sidechains by project + parentSessionId; skips non-children', () => { - const idx = buildSubagentIndex([project([ - parent({ id: 'P', prLinks: [A], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })] }), - child({ agentId: 'c1', parentId: 'P', cost: 50, firstTs: '2026-07-01T10:05:00Z' }), - child({ agentId: 'c2', parentId: 'P', cost: 30, firstTs: '2026-07-01T10:06:00Z' }), - ])]) - expect(idx.size).toBe(1) // one parent key - expect([...idx.values()].flat().map(c => c.agentId).sort()).toEqual(['c1', 'c2']) - }) -}) - -describe('resolveChildFolds', () => { - const p = parent({ - id: 'P', prLinks: [A, B], - turns: [ - turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A], spawnToolUseIds: ['toolu_x'] }), - turn({ cost: 10, ts: '2026-07-01T10:30:00Z', prRefs: [B] }), - ], - agentSpawnLinks: { c1: 'toolu_x' }, - }) - - it('resolves via spawn tool_use id (true launch point)', () => { - const c = child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:45:00Z' }) - const folds = resolveChildFolds(p, [buildFold(c)]) - // firstTs 10:45 sits in turn1's span, but the spawn id lives in turn0 -> turn0 wins. - expect(folds.byTurnIndex.get(0)?.[0]?.agentId).toBe('c1') - expect(folds.byTurnIndex.has(1)).toBe(false) - expect(folds.unlinked).toHaveLength(0) - }) - - it('falls back to timestamp bucketing when there is no spawn link', () => { - const c = child({ agentId: 'unknown', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:45:00Z' }) - const folds = resolveChildFolds(p, [buildFold(c)]) - // No agentSpawnLinks entry -> bucket by firstTs 10:45 into the last turn started <= it. - expect(folds.byTurnIndex.get(1)?.[0]?.agentId).toBe('unknown') - expect(folds.byTurnIndex.has(0)).toBe(false) - }) - - it('marks a child before the first turn as unlinked', () => { - const c = child({ agentId: 'early', parentId: 'P', cost: 100, firstTs: '2026-07-01T09:00:00Z' }) - const folds = resolveChildFolds(p, [buildFold(c)]) - expect(folds.byTurnIndex.size).toBe(0) - expect(folds.unlinked.map(f => f.agentId)).toEqual(['early']) - }) -}) - -// Round-trip a child SessionSummary through the public index so tests exercise the -// same ChildFold the report builds (agentId/cost/models/categories derivation). -function buildFold(c: SessionSummary) { - return [...buildSubagentIndex([project([c])]).values()][0]![0]! +function rowFor(rows: ReturnType, url: string) { + return rows.find(r => r.url === url) } -describe('attributeSessionPrSpend with folds', () => { - it('folds a child into its spawn turn and surfaces its model in that PR', () => { - const p = parent({ - id: 'P', prLinks: [A, B], - turns: [ - turn({ cost: 10, model: 'claude-sonnet-4-5', ts: '2026-07-01T10:00:00Z', prRefs: [A], spawnToolUseIds: ['toolu_x'] }), - turn({ cost: 10, model: 'claude-sonnet-4-5', ts: '2026-07-01T10:30:00Z', prRefs: [B] }), - ], - agentSpawnLinks: { c1: 'toolu_x' }, - }) - const c = buildFold(child({ agentId: 'c1', parentId: 'P', cost: 100, model: 'claude-opus-4-8', category: 'debugging', firstTs: '2026-07-01T10:45:00Z' })) - const folds = resolveChildFolds(p, [c]) - const { perUrl } = attributeSessionPrSpend(p, folds) - // A owns turn0's own $10 plus the child's $100. - expect(perUrl.get(A)!.cost).toBeCloseTo(110, 6) - expect(perUrl.get(B)!.cost).toBeCloseTo(10, 6) - // The child's opus spend reaches PR A's model + category breakdown. - expect(perUrl.get(A)!.models.get('claude-opus-4-8')).toBeCloseTo(100, 6) - expect(perUrl.get(A)!.categories.get('debugging')).toBeCloseTo(100, 6) - expect(perUrl.get(A)!.categories.get('coding')).toBeCloseTo(10, 6) - }) +// 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 +} - it('lands a child spawned before any PR reference in unattributed', () => { - const p = parent({ - id: 'P', prLinks: [A], - turns: [ - // turn0 references no PR (current === null); a child folded here is overhead. - turn({ cost: 5, ts: '2026-07-01T10:00:00Z', spawnToolUseIds: ['toolu_pre'] }), - turn({ cost: 10, ts: '2026-07-01T10:30:00Z', prRefs: [A] }), - ], - agentSpawnLinks: { c1: 'toolu_pre' }, - }) - const c = buildFold(child({ agentId: 'c1', parentId: 'P', cost: 100, firstTs: '2026-07-01T10:05:00Z' })) - const { perUrl, unattributed } = attributeSessionPrSpend(p, resolveChildFolds(p, [c])) - expect(perUrl.get(A)!.cost).toBeCloseTo(10, 6) - expect(unattributed.cost).toBeCloseTo(105, 6) // turn0's $5 + child $100 - }) - - it('folds an unlinked child into unattributed', () => { - const p = parent({ id: 'P', prLinks: [A], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })] }) - const c = buildFold(child({ agentId: 'orphanTurn', parentId: 'P', cost: 40, firstTs: '2026-07-01T09:00:00Z' })) - const { perUrl, unattributed } = attributeSessionPrSpend(p, resolveChildFolds(p, [c])) - expect(perUrl.get(A)!.cost).toBeCloseTo(10, 6) - expect(unattributed.cost).toBeCloseTo(40, 6) - }) - - it('is a no-op when no folds are supplied (regression guard)', () => { - const p = parent({ id: 'P', prLinks: [A], turns: [turn({ cost: 10, ts: '2026-07-01T10:00:00Z', prRefs: [A] })] }) - const { perUrl, unattributed } = attributeSessionPrSpend(p) - expect(perUrl.get(A)!.cost).toBeCloseTo(10, 6) - expect(unattributed.cost).toBe(0) +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'), + ]) + expect(idx.get('P')?.map(s => s.agentId)).toEqual(['c1']) }) }) -describe('aggregateByPr / prLinkedTotals fold subagents end to end', () => { +describe('spawn-link resolution (async edge: spawn PR wins over first-timestamp)', () => { const projects = () => [project([ parent({ - id: 'P', prLinks: [A], - turns: [turn({ cost: 20, model: 'claude-sonnet-4-5', ts: '2026-07-01T10:00:00Z', prRefs: [A], spawnToolUseIds: ['toolu_x'] })], + 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:05:00Z', calls: 5 }), + child({ agentId: 'c1', parentId: 'P', cost: 100, model: 'claude-opus-4-8', firstTs: '2026-07-01T10:45:00Z' }), ])] - it('counts the child cost exactly once in by-PR while it stays a standalone session', () => { + it('folds the child under the spawn PR (A), not the first-timestamp PR (B)', () => { const rows = aggregateByPr(projects()) - const rowA = rows.find(r => r.url === A)! - expect(rowA.cost).toBeCloseTo(120, 6) // parent $20 + child $100, once - expect(rowA.calls).toBe(6) // parent 1 + child 5 - expect(rowA.models).toContain('Opus 4.8') // short name of claude-opus appears beside the parent model - // The child is still present as its own session (not removed from projects). - expect(projects()[0]!.sessions.some(s => s.sessionId === 'agent-c1')).toBe(true) + 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('reports subagentSessions alongside PR-linked parent sessions', () => { - const totals = prLinkedTotals(projects()) - expect(totals.sessions).toBe(1) // one PR-linked parent - expect(totals.subagentSessions).toBe(1) // one folded child + 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) - expect(totals.cost).toBeCloseTo(120, 6) - }) - - it('ignores an orphan child whose parent is absent from the scan', () => { - const rows = aggregateByPr([project([ - // No parent 'P' here; the child references a parent that is not in the scan. - child({ agentId: 'c1', parentId: 'MISSING', cost: 100, firstTs: '2026-07-01T10:05:00Z' }), - ])]) - // Nothing PR-linked, so no rows and no folded spend. - expect(rows).toHaveLength(0) - const totals = prLinkedTotals([project([ - child({ agentId: 'c1', parentId: 'MISSING', cost: 100, firstTs: '2026-07-01T10:05:00Z' }), - ])]) - expect(totals.subagentSessions).toBe(0) - expect(totals.cost).toBe(0) - }) - - it('does not fold children of a parent that referenced no PR', () => { - const rows = aggregateByPr([project([ - // Parent has turns but NO prLinks -> skipped entirely; its child folds nowhere. - 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(rows).toHaveLength(0) + // 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 a parent with no in-range turns still folds', () => { + // The parent has zero in-range turns (a 0-cost fold anchor) but carries prLinks + // and spawnPrSets; its in-range child must still reach the PR. + const projects = [project([ + parent({ id: 'P', prLinks: [A], turns: [], last: '', agentSpawnLinks: { c1: 'toolu_x' }, spawnPrSets: { toolu_x: [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) + expect(prLinkedTotals(projects).subagentSessions).toBe(1) + }) +}) + +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 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 is computed once and shared', () => { + 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 attribution = resolveSubagentAttribution(projects) + expect(attribution.get('P')).toHaveLength(1) + expect(attribution.get('P')![0]!.prSet).toEqual([A]) + expect(attribution.get('P')![0]!.fold.cost).toBe(100) + }) + + it('memoizes by projects identity so the tree is resolved once per render', () => { + 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' }), + ])] + expect(resolveSubagentAttribution(projects)).toBe(resolveSubagentAttribution(projects)) // same array -> same object + expect(resolveSubagentAttribution(projects)).not.toBe(resolveSubagentAttribution([...projects])) // different array -> recomputed }) })