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 }) })