codeburn/tests/sessions-subagent-fold.test.ts
Resham Joshi 8e1caae33a
Some checks are pending
CI / semgrep (push) Waiting to run
sessions: fold subagent runs into PR attribution (#791)
* sessions: fold subagent runs into PR attribution

Sidechain (subagent) session cost never reached the by-PR view, so a
session orchestrated on one model with subagent lanes on another showed
only the parent model on every PR row. Fold each sidechain's cost, calls,
models, and categories into the parent turn that spawned it, so it
inherits that turn's PR set under the existing turn-level state machine.

Linkage, in priority order: the spawn result's toolUseResult.agentId
pairs the child's agent id with the Agent/Task tool_use id that launched
it (recorded per turn), which is the true launch point and wins even when
the child's first activity landed during a later turn; else the child's
first-activity timestamp is bucketed into the containing turn span; else
the child folds into the parent's unattributed spend. Children of parents
that referenced no PR, and orphans whose parent is absent from the scan,
contribute nothing, unchanged.

Cache v6 to v7 (neither shipped, so one combined bump from v5): per-turn
spawnToolUseIds, per-file parentSessionId and agentSpawnLinks; the
validator and the append/compact paths thread them like prRefs. By-PR
footers now count parents plus folded subagent runs and the payload gains
an additive subagentSessions field. distinctCost now includes folded
subagent spend, documented in the payload comment.

* 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).

* sessions: round-2 hardening of subagent PR attribution

Address a second adversarial review of the new machinery.

- ID collision: parents and a child's parent reference are keyed by
  provider + sessionId, not bare sessionId. When two distinct parents
  still share a key (true duplicate/imported data), the child folds into
  NEITHER (deterministic skip, stays standalone): correctness over
  coverage.
- Recursion dedup is global: one claimed-set spans all of a parent's
  direct children, so a descendant reachable through two paths (a diamond
  or duplicate id) folds exactly once and a parent-link cycle terminates.
- Cache adoption migrates every prior version oldest-to-newest and MERGES
  per source path (newer wins per entry), so a sparse or partial newer
  file no longer masks older-only expired-PR orphans.
- Fold anchors (0-cost PR-linked parents kept only for attribution) live
  in a new ProjectSummary.subagentAnchors, never in `sessions`, so they no
  longer contaminate session counts, averages, or any per-session report.
  Folded PR rows take their date span from the contributing child activity
  rather than the anchor's empty timestamps.
- One-pass buildPrAttribution computes rows and totals together; the
  payload builder and CLI call it once. Drops the identity-keyed
  memoization, which could return stale folds if the array was mutated.
- Ambiguous multi-block spawn-result pairing leaves the spawn link unset
  on purpose; the child then folds via the timestamp fallback rather than
  pairing with the wrong id or disappearing.

Every fix is mutation-verified. A fresh real-data drive re-proves the
no-double-count identity to the cent (parent-only cost plus folded cost
equals the folded total) and that the PR rows sum to attributedCost.

* sessions: round-3 hardening of subagent PR attribution

Third adversarial review pass.

- Ambiguity counts ALL candidate parents (and anchors) sharing a
  provider+sessionId key, not just PR-bearing ones, and uses a
  per-record fingerprint: a key carried by more than one DISTINCT record
  folds its child/subtree into NEITHER (identical duplicates still fold
  once). This unifies the parent-collision and duplicate-descendant rules
  and is deterministic across input order.
- Project-rebuilding filters (by day, by date range, by config source)
  now carry subagentAnchors through, and a date filter CONVERTS a spawn
  parent whose in-range turns are all filtered out into an anchor so a
  surviving in-range child still folds. Rebuilt sessions also keep their
  PR + subagent-linkage metadata (prLinks, parentSessionId, spawnPrSets,
  ...), which buildSessionSummary otherwise drops, so by-PR and folding
  work on a filtered slice (menubar/dashboard flow).
- Fold anchors leave ProjectSummary.sessions entirely and folded PR rows
  take their span from the child, so 0-cost anchors never touch session
  counts or averages.
- Ambiguous spawn pairing (parent named the agent but its exact launching
  tool_use could not be paired) is recorded per parent; a late child of
  such a pairing folds to the parent's last turn within a 30 minute grace
  window, else stays unlinked. A truly-absent pairing gets no grace.
- Row session key is NUL-delimited and provider-prefixed, so a project
  name or session id containing a space no longer collides and
  undercounts distinct sessions.

Every fix mutation-verified. A fresh real-data drive re-proves the
no-double-count identity to the cent, and a day-filtered drive proves the
filter fix end to end (anchors created, subagents fold, identity holds).

* sessions: round-4 hardening of subagent PR attribution

Fourth adversarial review pass.

- sessionFingerprint now covers the COMPLETE linkage-relevant payload,
  not just headline stats: a canonical (sorted-key) serialization of
  agentSpawnLinks, spawnPrSets, prRefsAtRangeStart, ambiguousSpawnAgentIds,
  parent/agent identity, and the per-turn prRefs timeline. Two records
  that share an id and headline stats but map the child to different
  spawns/PRs now fingerprint DISTINCT, so the ambiguity rule fires and
  they fold into neither, deterministically rather than order-dependent
  first-wins.
- A date/day filter recomputes prRefsAtRangeStart at the new slice
  boundary by replaying the original full turn sequence, instead of
  copying the wide range's value. A PR switch between the wide start and
  the slice start (July 1 A, July 10 B, slice July 20) now carries B, not
  a stale A; a turn exactly on the boundary stays in-slice and applies its
  own refs. The recompute selects by timestamp, so it is order-independent.
  Non-contiguous day selections are documented as treated contiguous from
  the earliest selected day (a single session-level seed cannot represent
  multiple segments; the menubar selection is a single day or a run).
- The anchor-carry path drops an anchor that duplicates a surviving
  session id, so malformed merged input cannot double-count.

Also: rebuilt filtered sessions were losing their PR/subagent-linkage
metadata (a child its parentSessionId, a parent its prLinks), which
carryLinkageFields now restores, so by-PR and folding work on any filtered
slice.

Every fix mutation-verified. A fresh real-data drive re-proves the
no-double-count identity and reconciliation to the cent for both a
lifetime scan and a day-filtered slice (anchors created, subagents fold
through the filter).

* sessions: round-5 hardening of subagent PR attribution

Fifth adversarial review pass; closed-form fixes.

- sessionFingerprint serializes the COMPLETE fold-determining state via a
  real recursive canonical encoder: session-level linkage AND, per turn in
  sequence, timestamp, prRefs, cost, calls, savings, and per-model cost.
  Object keys are sorted recursively and set-semantic arrays (PR-ref lists,
  ambiguous ids, spawnPrSets values) are sorted, while the turn list keeps
  order; the structure is emitted through JSON.stringify (no delimiter
  concatenation). Two same-id parents that differ only in a turn timestamp
  now fingerprint DISTINCT (fold neither), and records differing only in
  set-array order fingerprint EQUAL (no false ambiguity).
- recomputeRangeStartPrRefs breaks an exact-same-millisecond tie
  deterministically by the lexicographically-last sorted-ref key, so the
  recomputed seed is stable regardless of turn order.
- A day filter seeds EACH selected day's first ref-less turn by replaying
  the original full turn sequence up to that day's start (per-day seeding),
  so a PR switch on an UNSELECTED day between two selected days carries to
  the later day. Contiguous and non-contiguous selections are both correct.
- The anchor dedupe drops an anchor only when a surviving session shares
  the full provider-aware, fingerprint-qualified identity (a proven
  duplicate): a different-provider or different-record same-id session no
  longer wrongly drops the anchor.

Also fixed a double-count the fingerprint test exposed: two duplicate
parent sessions share a key and the SAME resolved children, so folding is
now done once per parent key.

Every fix mutation-verified. Fresh real-data drives (lifetime, single-day,
and a NON-CONTIGUOUS day selection) re-prove no-double-count and
reconciliation to the cent.

---------

Co-authored-by: reviewer <review@local>
2026-07-20 20:47:23 -07:00

623 lines
35 KiB
TypeScript

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