claude-agent-ledger 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/parser.ts CHANGED
@@ -5,6 +5,9 @@ import type { SessionTurn, TokenUsage } from './types.ts';
5
5
 
6
6
  const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
7
7
 
8
+ // Limit concurrent file reads so we don't exhaust fd on huge setups.
9
+ const CONCURRENCY = 8;
10
+
8
11
  export interface ParseOptions {
9
12
  from?: Date;
10
13
  to?: Date;
@@ -15,16 +18,9 @@ interface LogFile {
15
18
  path: string;
16
19
  projectId: string;
17
20
  subagentType: string | undefined;
21
+ parentSessionId?: string | undefined; // for subagent logs, the parent session
18
22
  }
19
23
 
20
- // Walk the projects directory and yield every JSONL log.
21
- // Real Claude Code layout (as of 2026-04):
22
- // ~/.claude/projects/<encoded-project>/<session-uuid>.jsonl (main session)
23
- // ~/.claude/projects/<encoded-project>/<session-uuid>/subagents/agent-<hash>.jsonl (subagent)
24
- // ~/.claude/projects/<encoded-project>/<session-uuid>/subagents/agent-<hash>.meta.json
25
- //
26
- // The .meta.json next to each subagent .jsonl carries { "agentType": "...", "description": "..." }.
27
- // We read it to attribute the subagent by its real name (e.g. "Reality Checker") rather than an opaque hash.
28
24
  export async function* findSessionLogs(
29
25
  projectsDir: string = CLAUDE_PROJECTS_DIR,
30
26
  ): AsyncGenerator<LogFile> {
@@ -34,11 +30,22 @@ export async function* findSessionLogs(
34
30
  } catch {
35
31
  return;
36
32
  }
37
- for (const project of projects) {
38
- yield* walkForJsonl(join(projectsDir, project), project);
33
+ // Parallelize the project-level walks; collect LogFiles via streaming queue.
34
+ const walkers = projects.map((project) =>
35
+ collectWalk(join(projectsDir, project), project),
36
+ );
37
+ const settled = await Promise.all(walkers);
38
+ for (const list of settled) {
39
+ for (const lf of list) yield lf;
39
40
  }
40
41
  }
41
42
 
43
+ async function collectWalk(dir: string, projectId: string): Promise<LogFile[]> {
44
+ const out: LogFile[] = [];
45
+ for await (const lf of walkForJsonl(dir, projectId)) out.push(lf);
46
+ return out;
47
+ }
48
+
42
49
  async function* walkForJsonl(dir: string, projectId: string): AsyncGenerator<LogFile> {
43
50
  let entries: string[] = [];
44
51
  try {
@@ -46,6 +53,9 @@ async function* walkForJsonl(dir: string, projectId: string): AsyncGenerator<Log
46
53
  } catch {
47
54
  return;
48
55
  }
56
+ // If this directory IS named 'subagents', its parent session is one level up.
57
+ // e.g. .../<session-uuid>/subagents/agent-XXX.jsonl → parent = <session-uuid>
58
+ const parentSession = basename(dir) === 'subagents' ? basename(dirname(dir)) : undefined;
49
59
  for (const name of entries) {
50
60
  if (name.startsWith('.') || name === 'memory') continue;
51
61
  const full = join(dir, name);
@@ -60,13 +70,18 @@ async function* walkForJsonl(dir: string, projectId: string): AsyncGenerator<Log
60
70
  continue;
61
71
  }
62
72
  if (name.endsWith('.jsonl')) {
63
- yield { path: full, projectId, subagentType: await resolveSubagentType(full) };
73
+ const subagentType = await resolveSubagentType(full);
74
+ yield {
75
+ path: full,
76
+ projectId,
77
+ subagentType,
78
+ parentSessionId: parentSession,
79
+ };
64
80
  }
65
81
  }
66
82
  }
67
83
 
68
84
  async function resolveSubagentType(jsonlPath: string): Promise<string | undefined> {
69
- // Subagent logs live under `.../subagents/agent-<hash>.jsonl` with a sibling `.meta.json`.
70
85
  if (basename(dirname(jsonlPath)) !== 'subagents') return undefined;
71
86
  const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json');
72
87
  try {
@@ -78,26 +93,51 @@ async function resolveSubagentType(jsonlPath: string): Promise<string | undefine
78
93
  }
79
94
  }
80
95
 
81
- // Stream-parse one JSONL file. Skips malformed lines rather than aborting.
96
+ // Streaming line reader — does NOT buffer the whole file.
97
+ // Handles partial lines across chunk boundaries. Skips malformed JSON.
82
98
  export async function* parseFile(
83
99
  path: string,
84
100
  subagentType?: string,
85
101
  projectId?: string,
102
+ parentSessionId?: string,
86
103
  ): AsyncGenerator<SessionTurn> {
87
- const file = Bun.file(path);
88
- const text = await file.text();
89
104
  const sessionId = basename(path).replace('.jsonl', '');
90
-
91
- for (const line of text.split('\n')) {
92
- if (!line.trim()) continue;
93
- let obj: Record<string, unknown>;
94
- try {
95
- obj = JSON.parse(line);
96
- } catch {
97
- continue;
105
+ const stream = Bun.file(path).stream();
106
+ const decoder = new TextDecoder('utf-8', { fatal: false });
107
+ let buffer = '';
108
+ for await (const chunk of stream) {
109
+ buffer += decoder.decode(chunk, { stream: true });
110
+ let nl: number;
111
+ while ((nl = buffer.indexOf('\n')) !== -1) {
112
+ const line = buffer.slice(0, nl);
113
+ buffer = buffer.slice(nl + 1);
114
+ if (!line.trim()) continue;
115
+ const turn = tryParseLine(line, sessionId, subagentType, projectId, parentSessionId);
116
+ if (turn) yield turn;
98
117
  }
99
- yield extractTurn(obj, sessionId, subagentType, projectId);
100
118
  }
119
+ // Flush any trailing partial line.
120
+ buffer += decoder.decode();
121
+ if (buffer.trim()) {
122
+ const turn = tryParseLine(buffer, sessionId, subagentType, projectId, parentSessionId);
123
+ if (turn) yield turn;
124
+ }
125
+ }
126
+
127
+ function tryParseLine(
128
+ line: string,
129
+ sessionId: string,
130
+ subagentType: string | undefined,
131
+ projectId: string | undefined,
132
+ parentSessionId: string | undefined,
133
+ ): SessionTurn | undefined {
134
+ let obj: Record<string, unknown>;
135
+ try {
136
+ obj = JSON.parse(line);
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ return extractTurn(obj, sessionId, subagentType, projectId, parentSessionId);
101
141
  }
102
142
 
103
143
  function extractTurn(
@@ -105,14 +145,29 @@ function extractTurn(
105
145
  sessionId: string,
106
146
  subagentType: string | undefined,
107
147
  projectId: string | undefined,
148
+ parentSessionId: string | undefined,
108
149
  ): SessionTurn {
109
150
  const msg = (obj.message ?? {}) as Record<string, unknown>;
110
151
  const usage = (msg.usage ?? undefined) as TokenUsage | undefined;
111
152
  const model = (msg.model ?? undefined) as string | undefined;
112
- // If the raw turn carries its own subagentType (our fixture format), prefer it;
113
- // otherwise fall back to the directory-derived one.
114
153
  const perTurnSubagent = ((obj.subagentType as string) ?? undefined) || undefined;
115
154
  const perTurnProject = ((obj.projectId as string) ?? undefined) || undefined;
155
+ const perTurnParent = ((obj.parentSessionId as string) ?? undefined) || undefined;
156
+
157
+ // Extract tool_use blocks from the assistant message content (if any).
158
+ // Used by the --by tool aggregator and subagent-graph attribution.
159
+ const toolUses: string[] = [];
160
+ const content = msg.content;
161
+ if (Array.isArray(content)) {
162
+ for (const block of content) {
163
+ if (block && typeof block === 'object') {
164
+ const b = block as Record<string, unknown>;
165
+ if (b.type === 'tool_use' && typeof b.name === 'string') {
166
+ toolUses.push(b.name);
167
+ }
168
+ }
169
+ }
170
+ }
116
171
 
117
172
  return {
118
173
  type: ((obj.type as string) ?? 'assistant') as SessionTurn['type'],
@@ -120,20 +175,43 @@ function extractTurn(
120
175
  sessionId,
121
176
  projectId: perTurnProject ?? projectId,
122
177
  subagentType: perTurnSubagent ?? subagentType,
178
+ parentSessionId: perTurnParent ?? parentSessionId,
123
179
  model,
124
180
  usage,
181
+ toolUses: toolUses.length > 0 ? toolUses : undefined,
125
182
  raw: obj,
126
183
  };
127
184
  }
128
185
 
129
- // Convenience: stream every turn across every log under ~/.claude/projects.
186
+ // Parallelized, streaming. Does not materialize all turns — caller iterates
187
+ // once and dispatches to aggregators. Reads up to CONCURRENCY files at a time.
130
188
  export async function* parseAll(opts: ParseOptions = {}): AsyncGenerator<SessionTurn> {
131
189
  const dir = opts.projectsDir ?? CLAUDE_PROJECTS_DIR;
132
- for await (const file of findSessionLogs(dir)) {
133
- for await (const turn of parseFile(file.path, file.subagentType, file.projectId)) {
134
- if (opts.from && new Date(turn.timestamp) < opts.from) continue;
135
- if (opts.to && new Date(turn.timestamp) > opts.to) continue;
136
- yield turn;
190
+ const files: LogFile[] = [];
191
+ for await (const f of findSessionLogs(dir)) files.push(f);
192
+
193
+ // Fan out file reads in batches of CONCURRENCY, yield turns in arrival order.
194
+ for (let i = 0; i < files.length; i += CONCURRENCY) {
195
+ const batch = files.slice(i, i + CONCURRENCY);
196
+ const streams = batch.map((f) =>
197
+ collectBatch(parseFile(f.path, f.subagentType, f.projectId, f.parentSessionId), opts),
198
+ );
199
+ const results = await Promise.all(streams);
200
+ for (const turns of results) {
201
+ for (const t of turns) yield t;
137
202
  }
138
203
  }
139
204
  }
205
+
206
+ async function collectBatch(
207
+ gen: AsyncGenerator<SessionTurn>,
208
+ opts: ParseOptions,
209
+ ): Promise<SessionTurn[]> {
210
+ const out: SessionTurn[] = [];
211
+ for await (const t of gen) {
212
+ if (opts.from && new Date(t.timestamp) < opts.from) continue;
213
+ if (opts.to && new Date(t.timestamp) > opts.to) continue;
214
+ out.push(t);
215
+ }
216
+ return out;
217
+ }
package/src/tree.ts ADDED
@@ -0,0 +1,107 @@
1
+ // ASCII tree renderer for subagent graph (the MOAT feature).
2
+ // Helicone/Langfuse observe API calls; only Claude Code knows that
3
+ // orchestrator → 3×researcher + 6×swift-dev. We render that tree.
4
+
5
+ import type { SubagentTreeNode } from './types.ts';
6
+
7
+ const fmtUsd = (n: number): string => {
8
+ if (n < 0.01) return '$' + n.toFixed(4);
9
+ return '$' + n.toFixed(2);
10
+ };
11
+
12
+ const dim = (s: string): string =>
13
+ process.env.NO_COLOR || !process.stdout.isTTY ? s : `\x1b[2m${s}\x1b[0m`;
14
+
15
+ const bold = (s: string): string =>
16
+ process.env.NO_COLOR || !process.stdout.isTTY ? s : `\x1b[1m${s}\x1b[0m`;
17
+
18
+ const amber = (s: string): string =>
19
+ process.env.NO_COLOR || !process.stdout.isTTY ? s : `\x1b[38;5;215m${s}\x1b[0m`;
20
+
21
+ export function formatTree(roots: SubagentTreeNode[], totalCost: number): string {
22
+ if (roots.length === 0) return dim('No subagent graphs found in this period.');
23
+
24
+ const lines: string[] = [];
25
+ lines.push(
26
+ bold('Subagent graphs') +
27
+ dim(' · top ') +
28
+ roots.length +
29
+ dim(' orchestrators by total cost · grand-total ') +
30
+ amber(fmtUsd(totalCost)),
31
+ );
32
+ lines.push('');
33
+ for (const root of roots) {
34
+ renderNode(root, '', true, lines, totalCost, true);
35
+ lines.push('');
36
+ }
37
+ return lines.join('\n');
38
+ }
39
+
40
+ function renderNode(
41
+ node: SubagentTreeNode,
42
+ prefix: string,
43
+ isLast: boolean,
44
+ lines: string[],
45
+ grandTotal: number,
46
+ isRoot: boolean,
47
+ ): void {
48
+ const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
49
+ const sessionShort = dim(node.sessionId.slice(0, 8));
50
+ const pct = grandTotal > 0 ? ((node.totalCost / grandTotal) * 100).toFixed(1) + '%' : '—';
51
+ const turns = dim(`${node.turnCount} turns`);
52
+
53
+ if (isRoot) {
54
+ const childCount = countDescendants(node);
55
+ const childInfo = childCount > 0 ? dim(` (${childCount} children)`) : '';
56
+ lines.push(
57
+ `${bold(node.label)} ${sessionShort}${childInfo} ${amber(fmtUsd(node.totalCost))} ${dim(`(${pct})`)}`,
58
+ );
59
+ } else {
60
+ const selfNote =
61
+ node.children.length > 0
62
+ ? dim(` self ${fmtUsd(node.selfCost)}`)
63
+ : '';
64
+ lines.push(
65
+ `${dim(prefix)}${connector}${node.label} ${sessionShort} ${amber(fmtUsd(node.totalCost))}${selfNote} ${turns}`,
66
+ );
67
+ }
68
+
69
+ const childPrefix = prefix + (isRoot ? '' : isLast ? ' ' : '│ ');
70
+ node.children.forEach((child, idx) => {
71
+ const last = idx === node.children.length - 1;
72
+ renderNode(child, childPrefix, last, lines, grandTotal, false);
73
+ });
74
+ }
75
+
76
+ function countDescendants(node: SubagentTreeNode): number {
77
+ let n = node.children.length;
78
+ for (const c of node.children) n += countDescendants(c);
79
+ return n;
80
+ }
81
+
82
+ // Markdown variant — same data, GH-friendly nested list.
83
+ export function formatTreeMarkdown(roots: SubagentTreeNode[], totalCost: number): string {
84
+ const lines: string[] = ['# Subagent graphs', ''];
85
+ lines.push(`Top ${roots.length} orchestrators by total cost. Grand total: **${fmtUsd(totalCost)}**.`);
86
+ lines.push('');
87
+ for (const root of roots) {
88
+ const childCount = countDescendants(root);
89
+ lines.push(
90
+ `## ${root.label} \`${root.sessionId.slice(0, 8)}\` — ${fmtUsd(root.totalCost)} (${childCount} children, ${root.turnCount} turns)`,
91
+ );
92
+ mdNode(root, lines, 0);
93
+ lines.push('');
94
+ }
95
+ return lines.join('\n');
96
+ }
97
+
98
+ function mdNode(node: SubagentTreeNode, lines: string[], depth: number): void {
99
+ for (const child of node.children) {
100
+ const indent = ' '.repeat(depth);
101
+ const self = child.children.length > 0 ? ` _self ${fmtUsd(child.selfCost)}_` : '';
102
+ lines.push(
103
+ `${indent}- **${child.label}** \`${child.sessionId.slice(0, 8)}\` — ${fmtUsd(child.totalCost)} (${child.turnCount} turns)${self}`,
104
+ );
105
+ mdNode(child, lines, depth + 1);
106
+ }
107
+ }
package/src/types.ts CHANGED
@@ -1,8 +1,15 @@
1
- export type ModelId =
2
- | 'claude-opus-4-7'
3
- | 'claude-sonnet-4-6'
4
- | 'claude-haiku-4-5-20251001'
5
- | string;
1
+ // Known model ids — kept narrow for documentation. Use `string` at the boundary.
2
+ export const KNOWN_MODELS = [
3
+ 'claude-opus-4-7',
4
+ 'claude-sonnet-4-6',
5
+ 'claude-haiku-4-5-20251001',
6
+ 'claude-opus-4-6',
7
+ 'claude-opus-4-5',
8
+ 'claude-sonnet-4-5',
9
+ 'claude-haiku-4-0',
10
+ ] as const;
11
+
12
+ export type ModelId = string;
6
13
 
7
14
  export type TurnType = 'user' | 'assistant' | 'summary' | 'system';
8
15
 
@@ -19,12 +26,9 @@ export interface ServerToolUse {
19
26
  export interface TokenUsage {
20
27
  input_tokens: number;
21
28
  output_tokens: number;
22
- // Legacy flat counter. Real logs also carry `cache_creation` with TTL split; if present,
23
- // prefer that for accurate pricing.
24
29
  cache_creation_input_tokens?: number;
25
30
  cache_read_input_tokens?: number;
26
31
  cache_creation?: CacheCreationDetail;
27
- // Anthropic server-side tool use (web_search, web_fetch). Priced per request, not per token.
28
32
  server_tool_use?: ServerToolUse;
29
33
  }
30
34
 
@@ -34,8 +38,10 @@ export interface SessionTurn {
34
38
  sessionId: string;
35
39
  projectId?: string | undefined;
36
40
  subagentType?: string | undefined;
41
+ parentSessionId?: string | undefined;
37
42
  model?: ModelId | undefined;
38
43
  usage?: TokenUsage | undefined;
44
+ toolUses?: string[] | undefined; // tool names invoked in this assistant turn
39
45
  raw: unknown;
40
46
  }
41
47
 
@@ -67,3 +73,15 @@ export interface LedgerReport {
67
73
  rows: AggregatedRow[];
68
74
  total: AggregatedRow;
69
75
  }
76
+
77
+ // Subagent-graph node — the MOAT feature.
78
+ // Represents one parent session's tree-rooted cost attribution:
79
+ // the parent's own cost + all child subagent costs, recursively.
80
+ export interface SubagentTreeNode {
81
+ sessionId: string;
82
+ label: string; // subagentType or '(orchestrator)'
83
+ selfCost: number;
84
+ totalCost: number; // self + children
85
+ children: SubagentTreeNode[];
86
+ turnCount: number;
87
+ }
@@ -0,0 +1,188 @@
1
+ // Property tests — invariants that must hold for ANY input, not just fixtures.
2
+ // (Backend audit recommendation: cover pricing math + aggregator math.)
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+ import * as fc from 'fast-check';
6
+ import { Aggregator, SubagentGraph } from '../src/aggregator.ts';
7
+ import { costFor } from '../src/pricing.ts';
8
+ import type { SessionTurn, TokenUsage } from '../src/types.ts';
9
+
10
+ // Bounded-positive token counter — Anthropic emits non-negative integers.
11
+ const arbTokens = (): fc.Arbitrary<number> => fc.integer({ min: 0, max: 10_000_000 });
12
+
13
+ // Realistic Anthropic models that should price correctly.
14
+ const arbModel = (): fc.Arbitrary<string> =>
15
+ fc.constantFrom(
16
+ 'claude-opus-4-7',
17
+ 'claude-sonnet-4-6',
18
+ 'claude-haiku-4-5-20251001',
19
+ 'claude-opus-4-6',
20
+ 'claude-sonnet-4-5',
21
+ );
22
+
23
+ const arbUsage = (): fc.Arbitrary<TokenUsage> =>
24
+ fc.record({
25
+ input_tokens: arbTokens(),
26
+ output_tokens: arbTokens(),
27
+ cache_read_input_tokens: fc.option(arbTokens(), { nil: undefined }),
28
+ cache_creation: fc.option(
29
+ fc.record({
30
+ ephemeral_5m_input_tokens: fc.option(arbTokens(), { nil: undefined }),
31
+ ephemeral_1h_input_tokens: fc.option(arbTokens(), { nil: undefined }),
32
+ }),
33
+ { nil: undefined },
34
+ ),
35
+ });
36
+
37
+ const arbAssistantTurn = (): fc.Arbitrary<SessionTurn> =>
38
+ fc
39
+ .record({
40
+ usage: arbUsage(),
41
+ model: arbModel(),
42
+ sessionId: fc.string({ minLength: 8, maxLength: 36 }),
43
+ timestamp: fc.integer({ min: 1735689600000, max: 1798800000000 }).map(
44
+ (ms) => new Date(ms).toISOString(),
45
+ ),
46
+ subagentType: fc.option(fc.string({ minLength: 1, maxLength: 20 }), { nil: undefined }),
47
+ projectId: fc.option(fc.string({ minLength: 1, maxLength: 30 }), { nil: undefined }),
48
+ parentSessionId: fc.option(fc.string({ minLength: 8, maxLength: 36 }), { nil: undefined }),
49
+ toolUses: fc.option(fc.array(fc.string({ minLength: 1, maxLength: 10 }), { maxLength: 5 }), {
50
+ nil: undefined,
51
+ }),
52
+ })
53
+ .map((r) => ({
54
+ type: 'assistant' as const,
55
+ ...r,
56
+ raw: r,
57
+ }));
58
+
59
+ describe('costFor() invariants', () => {
60
+ test('cost is non-negative for any valid usage + model', () => {
61
+ fc.assert(
62
+ fc.property(arbUsage(), arbModel(), (usage, model) => {
63
+ const c = costFor(usage, model);
64
+ expect(c.totalCost).toBeGreaterThanOrEqual(0);
65
+ expect(c.inputCost).toBeGreaterThanOrEqual(0);
66
+ expect(c.outputCost).toBeGreaterThanOrEqual(0);
67
+ expect(c.cacheReadCost).toBeGreaterThanOrEqual(0);
68
+ }),
69
+ { numRuns: 200 },
70
+ );
71
+ });
72
+
73
+ test('totalCost equals sum of components', () => {
74
+ fc.assert(
75
+ fc.property(arbUsage(), arbModel(), (usage, model) => {
76
+ const c = costFor(usage, model);
77
+ const sum =
78
+ c.inputCost +
79
+ c.outputCost +
80
+ c.cacheCreation5mCost +
81
+ c.cacheCreation1hCost +
82
+ c.cacheReadCost +
83
+ c.serverToolUseCost;
84
+ // Floating point — allow tiny epsilon.
85
+ expect(Math.abs(c.totalCost - sum)).toBeLessThan(1e-9);
86
+ }),
87
+ { numRuns: 200 },
88
+ );
89
+ });
90
+
91
+ test('cache_read is always cheaper than the same input as fresh tokens', () => {
92
+ fc.assert(
93
+ fc.property(arbTokens(), arbModel(), (n, model) => {
94
+ const fresh = costFor({ input_tokens: n, output_tokens: 0 }, model).totalCost;
95
+ const cached = costFor(
96
+ { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: n },
97
+ model,
98
+ ).totalCost;
99
+ // Cached should be ≤ fresh (90% discount).
100
+ expect(cached).toBeLessThanOrEqual(fresh + 1e-9);
101
+ }),
102
+ { numRuns: 100 },
103
+ );
104
+ });
105
+
106
+ test('cache_creation_1h is always more expensive than cache_creation_5m for same tokens', () => {
107
+ fc.assert(
108
+ fc.property(fc.integer({ min: 1, max: 1_000_000 }), arbModel(), (n, model) => {
109
+ const fiveMin = costFor(
110
+ {
111
+ input_tokens: 0,
112
+ output_tokens: 0,
113
+ cache_creation: { ephemeral_5m_input_tokens: n },
114
+ },
115
+ model,
116
+ ).totalCost;
117
+ const oneH = costFor(
118
+ {
119
+ input_tokens: 0,
120
+ output_tokens: 0,
121
+ cache_creation: { ephemeral_1h_input_tokens: n },
122
+ },
123
+ model,
124
+ ).totalCost;
125
+ expect(oneH).toBeGreaterThanOrEqual(fiveMin - 1e-9);
126
+ }),
127
+ { numRuns: 100 },
128
+ );
129
+ });
130
+ });
131
+
132
+ describe('Aggregator invariants', () => {
133
+ test('total.cost equals sum of row.cost across any group key', () => {
134
+ fc.assert(
135
+ fc.property(fc.array(arbAssistantTurn(), { minLength: 0, maxLength: 50 }), (turns) => {
136
+ const agg = new Aggregator(
137
+ 'subagent',
138
+ new Date('2025-01-01'),
139
+ new Date('2027-01-01'),
140
+ );
141
+ for (const t of turns) agg.add(t);
142
+ const report = agg.finalize();
143
+ const rowSum = report.rows.reduce((s, r) => s + r.cost.totalCost, 0);
144
+ expect(Math.abs(report.total.cost.totalCost - rowSum)).toBeLessThan(1e-6);
145
+ }),
146
+ { numRuns: 100 },
147
+ );
148
+ });
149
+
150
+ test('non-assistant turns are ignored (no cost contribution)', () => {
151
+ fc.assert(
152
+ fc.property(arbUsage(), arbModel(), (usage, model) => {
153
+ const userTurn: SessionTurn = {
154
+ type: 'user',
155
+ usage,
156
+ model,
157
+ sessionId: 'sess',
158
+ timestamp: '2026-04-23T00:00:00Z',
159
+ raw: {},
160
+ };
161
+ const agg = new Aggregator('subagent', new Date('2025-01-01'), new Date('2027-01-01'));
162
+ agg.add(userTurn);
163
+ const report = agg.finalize();
164
+ expect(report.total.cost.totalCost).toBe(0);
165
+ }),
166
+ { numRuns: 50 },
167
+ );
168
+ });
169
+ });
170
+
171
+ describe('SubagentGraph invariants', () => {
172
+ test('parent totalCost ≥ self cost (children only add)', () => {
173
+ fc.assert(
174
+ fc.property(
175
+ fc.array(arbAssistantTurn(), { minLength: 1, maxLength: 30 }),
176
+ (turns) => {
177
+ const graph = new SubagentGraph();
178
+ for (const t of turns) graph.add(t);
179
+ const roots = graph.finalize(50);
180
+ for (const r of roots) {
181
+ expect(r.totalCost).toBeGreaterThanOrEqual(r.selfCost - 1e-9);
182
+ }
183
+ },
184
+ ),
185
+ { numRuns: 50 },
186
+ );
187
+ });
188
+ });