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).
This commit is contained in:
reviewer 2026-07-21 02:52:21 +02:00
parent 4b787194d7
commit 384fb5ef0b
8 changed files with 718 additions and 374 deletions

View file

@ -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<string, unknown> =>
!!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<string, unknown>)['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<string>,
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<string, string[]> {
const out: Record<string, string[]> = {}
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}`

View file

@ -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<string, unknown>).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<string, unknown> } {
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<string, unknown> } {
if (!raw || typeof raw !== 'object') return false
const o = raw as Record<string, unknown>
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<SessionCache | null> {
// 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<SessionCache | null> {
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<SessionCache | null> {
}
}
// Adopt the newest prior versioned cache present on disk (v6 before v5).
async function adoptNewestPriorCache(): Promise<SessionCache | null> {
for (const version of PRIOR_CACHE_VERSIONS) {
const adopted = await adoptPriorCache(version)
if (adopted) return adopted
}
return null
}
export async function loadCache(): Promise<SessionCache> {
try {
const raw = await readFile(getCachePath(), 'utf-8')
@ -457,12 +474,13 @@ export async function loadCache(): Promise<SessionCache> {
}
}
// 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<SessionCache> {
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.

View file

@ -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<string, number>
categories: Map<string, number>
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-<hex>`, 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<string, SessionSummary[]> {
const index = new Map<string, SessionSummary[]>()
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<string, SessionSummary[]>, visited: Set<string>): ChildFold {
visited.add(child.sessionId)
const models = new Map<string, number>()
const categories = new Map<string, number>()
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<string, ChildFold[]> {
const index = new Map<string, ChildFold[]>()
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<number, ChildFold[]>; 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<string, string> },
children: ChildFold[],
): TurnFolds {
const byTurnIndex = new Map<number, ChildFold[]>()
const unlinked: ChildFold[] = []
const turns = parent.turns
const spawnTurn = new Map<string, number>()
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<ChildFold> {
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<string, ResolvedChild[]>
// 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<ProjectSummary[], SubagentAttribution>()
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<string>())))
}
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<ChildFold> {
/// 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<string, PrContribution>()
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<string, number>()
const categoryCostInTurn = new Map<string, number>()
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<string, number>()
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<string>; firstStarted: string; lastEnded: string
models: Map<string, number>; categories: Map<string, number>
}>()
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<string, number>, categories: Map<string, number>,
): 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<string>(), firstStarted: firstTs, lastEnded: lastTs,
models: new Map<string, number>(), categories: new Map<string, number>(),
}
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<string>(), firstStarted: session.firstTimestamp, lastEnded: session.lastTimestamp,
models: new Map<string, number>(), categories: new Map<string, number>(),
}
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<string, number>()
for (const [m, mc] of rc.fold.models) models.set(m, mc * share)
const categories = new Map<string, number>()
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 }

View file

@ -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<string, string>
/// 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<string, string[]>
firstTimestamp: string
lastTimestamp: string
totalCostUSD: number

View file

@ -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'])
})
})

View file

@ -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<void> {
const projDir = join(configDir, 'projects', 'anchor-proj')
const subDir = join(projDir, PARENT, 'subagents')
await mkdir(subDir, { recursive: true })
// Parent transcript: a turn that references the PR and spawns AGENT (tool_use
// SPAWN), then the spawn result recording agentId -> SPAWN. All dated just BEFORE
// the report range (within the 24h parse lookback so the spawn is still parsed).
await writeFile(join(projDir, `${PARENT}.jsonl`),
JSON.stringify({ type: 'user', sessionId: PARENT, timestamp: '2026-07-19T23:00:00.000Z', cwd: CWD, message: { role: 'user', content: 'ship the PR and launch a reviewer' } }) + '\n' +
JSON.stringify({ type: 'assistant', sessionId: PARENT, timestamp: '2026-07-19T23:00:01.000Z', cwd: CWD, message: { id: 'm1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [{ type: 'tool_use', id: SPAWN, name: 'Agent', input: {} }], usage: { input_tokens: 10, output_tokens: 5 } } }) + '\n' +
JSON.stringify({ type: 'pr-link', sessionId: PARENT, timestamp: '2026-07-19T23:00:02.000Z', cwd: CWD, prUrl: PR }) + '\n' +
JSON.stringify({ type: 'user', sessionId: PARENT, timestamp: '2026-07-19T23:05:00.000Z', cwd: CWD, message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: SPAWN, content: 'reviewer done' }] }, toolUseResult: { status: 'completed', agentId: AGENT, content: 'reviewer done' } }) + '\n')
// Child transcript: the subagent's own work, dated INSIDE the report range.
await writeFile(join(subDir, `agent-${AGENT}.jsonl`),
JSON.stringify({ type: 'user', isSidechain: true, sessionId: PARENT, agentId: AGENT, timestamp: '2026-07-20T10:00:00.000Z', cwd: CWD, message: { role: 'user', content: 'review this' } }) + '\n' +
JSON.stringify({ type: 'assistant', isSidechain: true, sessionId: PARENT, agentId: AGENT, timestamp: '2026-07-20T10:00:05.000Z', cwd: CWD, message: { id: 'c1', type: 'message', role: 'assistant', model: 'claude-opus-4-8', content: [], usage: { input_tokens: 1000, output_tokens: 500 } } }) + '\n')
}
describe('subagent fold across a date-range boundary', () => {
it('folds an in-range child into its parent PR even though the parent has no in-range turns', async () => {
await loadPricing()
await writeTranscripts()
const range = { start: new Date('2026-07-20T00:00:00Z'), end: new Date('2026-07-20T23:59:59Z') }
const projects = await parseAllSessions(range, 'claude')
// The child session is present (its work is in range) as a standalone session.
const childPresent = projects.some(p => p.sessions.some(s => s.sessionId === `agent-${AGENT}`))
expect(childPresent).toBe(true)
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)
})
})

View file

@ -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<string, unknown> {
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
})
})

View file

@ -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<string, string>; project?: string
id: string; prLinks: string[]; turns: ClassifiedTurn[]; project?: string
agentSpawnLinks?: Record<string, string>; spawnPrSets?: Record<string, string[]>
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<typeof aggregateByPr>, 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
})
})