Skip to content

Commit 5933236

Browse files
committed
refactor(scripts): decompose runSession in token-benchmark.ts
runSession exceeded codegraph's cognitive/cyclomatic/maxNesting thresholds (27/25/4). Extracts the pure, non-orchestration pieces into scripts/lib/session-metrics.ts, matching the existing scripts/lib/ convention: buildSessionOptions (mode-dependent options), the usage/turn field-fallback logic (extractUsageMetrics + firstTruthy), and the tool_use-block scan (collectToolUseBlocks + tallyToolCalls). Pure behavior-preserving decomposition, same pattern as #1775's runBuildBenchmarks/runQueryBenchmarks extraction. runSession is now 2/3/0. Adds tests/unit/session-metrics.test.ts covering the extracted helpers, including firstTruthy's `||`-chain (not `??`) falsy-0 semantics.
1 parent 0a07066 commit 5933236

3 files changed

Lines changed: 277 additions & 44 deletions

File tree

scripts/lib/session-metrics.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* Pure helpers for parsing Claude Agent SDK session results.
3+
*
4+
* Extracted out of `runSession` in token-benchmark.ts (#1906), which
5+
* exceeded codegraph's cognitive/cyclomatic/maxNesting complexity
6+
* thresholds. The usage-metric field-fallback logic (the SDK returns
7+
* snake_case or camelCase field names depending on version) and the
8+
* nested tool_use-block scan were the biggest contributors — both are
9+
* pure functions of the SDK result, so they're unit-testable here without
10+
* mocking the Agent SDK itself.
11+
*/
12+
13+
/**
14+
* First truthy value among `values` (falsy values, including 0, are
15+
* skipped), else the last value. Equivalent to `a || b || ... || z`.
16+
*/
17+
export function firstTruthy<T>(...values: T[]): T {
18+
for (const v of values) {
19+
if (v) return v;
20+
}
21+
return values[values.length - 1];
22+
}
23+
24+
export interface UsageMetrics {
25+
inputTokens: number;
26+
outputTokens: number;
27+
cacheReadInputTokens: number;
28+
totalCostUsd: number;
29+
numTurns: number;
30+
}
31+
32+
export interface SessionResult {
33+
usage?: Record<string, number>;
34+
num_turns?: number;
35+
numTurns?: number;
36+
}
37+
38+
/**
39+
* Extract usage/turn metrics from an Agent SDK query result, tolerating
40+
* both snake_case (raw API) and camelCase (SDK-normalized) field names.
41+
*/
42+
export function extractUsageMetrics(result: SessionResult): UsageMetrics {
43+
const usage = result.usage || {};
44+
return {
45+
inputTokens: firstTruthy(usage.input_tokens, usage.inputTokens, 0),
46+
outputTokens: firstTruthy(usage.output_tokens, usage.outputTokens, 0),
47+
cacheReadInputTokens: firstTruthy(
48+
usage.cache_read_input_tokens,
49+
usage.cacheReadInputTokens,
50+
0,
51+
),
52+
totalCostUsd:
53+
Math.round(firstTruthy(usage.total_cost_usd, usage.totalCostUsd, 0) * 100) / 100,
54+
numTurns: firstTruthy(result.num_turns, result.numTurns, 0),
55+
};
56+
}
57+
58+
export interface ToolUseBlock {
59+
type: string;
60+
name?: string;
61+
input?: { file_path?: string };
62+
}
63+
64+
export interface AssistantMessage {
65+
role: string;
66+
content?: unknown;
67+
}
68+
69+
/**
70+
* Collect all `tool_use` content blocks from a session's assistant
71+
* messages, in message order.
72+
*/
73+
export function collectToolUseBlocks(messages: AssistantMessage[]): ToolUseBlock[] {
74+
const blocks: ToolUseBlock[] = [];
75+
for (const msg of messages) {
76+
if (msg.role !== 'assistant') continue;
77+
const msgBlocks = Array.isArray(msg.content) ? (msg.content as ToolUseBlock[]) : [];
78+
for (const block of msgBlocks) {
79+
if (block.type === 'tool_use') blocks.push(block);
80+
}
81+
}
82+
return blocks;
83+
}
84+
85+
export interface ToolCallTally {
86+
toolCalls: Record<string, number>;
87+
uniqueFilesRead: number;
88+
}
89+
90+
/**
91+
* Tally tool-call counts by name and the set of unique files read, from a
92+
* session's `tool_use` blocks.
93+
*/
94+
export function tallyToolCalls(messages: AssistantMessage[]): ToolCallTally {
95+
const toolCalls: Record<string, number> = {};
96+
const uniqueFilesRead = new Set<string>();
97+
98+
for (const block of collectToolUseBlocks(messages)) {
99+
const name = block.name || 'unknown';
100+
toolCalls[name] = (toolCalls[name] || 0) + 1;
101+
if (name === 'Read' && block.input?.file_path) {
102+
uniqueFilesRead.add(block.input.file_path);
103+
}
104+
}
105+
106+
return { toolCalls, uniqueFilesRead: uniqueFilesRead.size };
107+
}

scripts/token-benchmark.ts

Lines changed: 30 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { parseArgs } from 'node:util';
2828
import { ISSUES, extractAgentOutput, validateResult } from './token-benchmark-issues.js';
2929
import { getBenchmarkVersion } from './bench-version.js';
3030
import { median, round1, timeMedian } from './lib/bench-timing.js';
31+
import { extractUsageMetrics, tallyToolCalls } from './lib/session-metrics.js';
3132

3233
const __dirname = path.dirname(fileURLToPath(import.meta.url));
3334
const root = path.resolve(__dirname, '..');
@@ -167,21 +168,15 @@ async function buildCodegraph(nextjsDir) {
167168
// ── Session runner ────────────────────────────────────────────────────────
168169

169170
/**
170-
* Run a single agent session using the Claude Agent SDK.
171+
* Build the Agent SDK `options` for a single session.
172+
* Codegraph mode additionally wires up the codegraph MCP server against
173+
* the graph already built for `nextjsDir`.
171174
*
172175
* @param {'baseline'|'codegraph'} mode
173-
* @param {import('./token-benchmark-issues.js').BenchmarkIssue} issue
174176
* @param {string} nextjsDir
175-
* @returns {Promise<object>} session metrics
177+
* @returns {object} Agent SDK options
176178
*/
177-
async function runSession(mode, issue, nextjsDir) {
178-
// Lazy-load the SDK
179-
const { query } = await import('@anthropic-ai/claude-agent-sdk');
180-
181-
const dbPath = path.join(nextjsDir, '.codegraph', 'graph.db');
182-
const cliPath = path.join(root, 'src', 'cli.js');
183-
const issuePrompt = makeIssuePrompt(issue);
184-
179+
function buildSessionOptions(mode, nextjsDir) {
185180
const options = {
186181
cwd: nextjsDir,
187182
model: MODEL,
@@ -193,6 +188,8 @@ async function runSession(mode, issue, nextjsDir) {
193188
};
194189

195190
if (mode === 'codegraph') {
191+
const dbPath = path.join(nextjsDir, '.codegraph', 'graph.db');
192+
const cliPath = path.join(root, 'src', 'cli.js');
196193
options.mcpServers = {
197194
codegraph: {
198195
type: 'stdio',
@@ -202,53 +199,42 @@ async function runSession(mode, issue, nextjsDir) {
202199
};
203200
}
204201

202+
return options;
203+
}
204+
205+
/**
206+
* Run a single agent session using the Claude Agent SDK.
207+
*
208+
* @param {'baseline'|'codegraph'} mode
209+
* @param {import('./token-benchmark-issues.js').BenchmarkIssue} issue
210+
* @param {string} nextjsDir
211+
* @returns {Promise<object>} session metrics
212+
*/
213+
async function runSession(mode, issue, nextjsDir) {
214+
// Lazy-load the SDK
215+
const { query } = await import('@anthropic-ai/claude-agent-sdk');
216+
217+
const options = buildSessionOptions(mode, nextjsDir);
218+
const issuePrompt = makeIssuePrompt(issue);
219+
205220
const start = performance.now();
206221
const result = await query({ prompt: issuePrompt, options });
207222
const durationMs = Math.round(performance.now() - start);
208223

209-
// Extract metrics from the SDK result
210-
const usage = result.usage || {};
211-
const inputTokens = usage.input_tokens || usage.inputTokens || 0;
212-
const outputTokens = usage.output_tokens || usage.outputTokens || 0;
213-
const cacheReadInputTokens =
214-
usage.cache_read_input_tokens || usage.cacheReadInputTokens || 0;
215-
const totalCostUsd = usage.total_cost_usd || usage.totalCostUsd || 0;
216-
const numTurns = result.num_turns || result.numTurns || 0;
217-
218-
// Count tool calls by type
224+
const usage = extractUsageMetrics(result);
219225
const messages = result.messages || [];
220-
const toolCalls = {};
221-
let uniqueFilesRead = new Set();
222-
223-
for (const msg of messages) {
224-
if (msg.role !== 'assistant') continue;
225-
const blocks = Array.isArray(msg.content) ? msg.content : [];
226-
for (const block of blocks) {
227-
if (block.type === 'tool_use') {
228-
const name = block.name || 'unknown';
229-
toolCalls[name] = (toolCalls[name] || 0) + 1;
230-
// Track unique files read
231-
if (name === 'Read' && block.input?.file_path) {
232-
uniqueFilesRead.add(block.input.file_path);
233-
}
234-
}
235-
}
236-
}
226+
const { toolCalls, uniqueFilesRead } = tallyToolCalls(messages);
237227

238228
// Extract identified files from agent output
239229
const agentOutput = extractAgentOutput(messages);
240230
const filesIdentified = agentOutput?.files || [];
241231
const validation = validateResult(issue.id, filesIdentified);
242232

243233
return {
244-
inputTokens,
245-
outputTokens,
246-
cacheReadInputTokens,
247-
totalCostUsd: round2(totalCostUsd),
248-
numTurns,
234+
...usage,
249235
durationMs,
250236
toolCalls,
251-
uniqueFilesRead: uniqueFilesRead.size,
237+
uniqueFilesRead,
252238
filesIdentified,
253239
hitRate: validation.hitRate,
254240
matched: validation.matched,

tests/unit/session-metrics.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* Unit tests for scripts/lib/session-metrics.ts
3+
*
4+
* Regression coverage for #1906: `runSession` in scripts/token-benchmark.ts
5+
* exceeded codegraph's cognitive/cyclomatic/maxNesting complexity
6+
* thresholds. These pure helpers were extracted out of it; this suite pins
7+
* their behavior (including the snake_case/camelCase field-fallback
8+
* semantics of `firstTruthy`, which must match `a || b || ... || z`, not
9+
* `??`) so a future refactor can't silently change it.
10+
*/
11+
12+
import { describe, expect, it } from 'vitest';
13+
import {
14+
collectToolUseBlocks,
15+
extractUsageMetrics,
16+
firstTruthy,
17+
tallyToolCalls,
18+
} from '../../scripts/lib/session-metrics.js';
19+
20+
describe('firstTruthy', () => {
21+
it('returns the first truthy value', () => {
22+
expect(firstTruthy(0, 5, 10)).toBe(5);
23+
expect(firstTruthy(undefined, undefined, 3)).toBe(3);
24+
});
25+
26+
it('treats 0 as falsy, matching `||` chain semantics (not `??`)', () => {
27+
expect(firstTruthy(0, 0, 7)).toBe(7);
28+
});
29+
30+
it('falls back to the last value when nothing is truthy', () => {
31+
expect(firstTruthy(0, undefined, 0)).toBe(0);
32+
});
33+
});
34+
35+
describe('extractUsageMetrics', () => {
36+
it('prefers snake_case (raw API) field names', () => {
37+
const metrics = extractUsageMetrics({
38+
usage: {
39+
input_tokens: 100,
40+
output_tokens: 50,
41+
cache_read_input_tokens: 10,
42+
total_cost_usd: 0.1234,
43+
},
44+
num_turns: 3,
45+
});
46+
expect(metrics).toEqual({
47+
inputTokens: 100,
48+
outputTokens: 50,
49+
cacheReadInputTokens: 10,
50+
totalCostUsd: 0.12,
51+
numTurns: 3,
52+
});
53+
});
54+
55+
it('falls back to camelCase (SDK-normalized) field names', () => {
56+
const metrics = extractUsageMetrics({
57+
usage: { inputTokens: 200, outputTokens: 75, cacheReadInputTokens: 5, totalCostUsd: 0.5 },
58+
numTurns: 4,
59+
});
60+
expect(metrics).toEqual({
61+
inputTokens: 200,
62+
outputTokens: 75,
63+
cacheReadInputTokens: 5,
64+
totalCostUsd: 0.5,
65+
numTurns: 4,
66+
});
67+
});
68+
69+
it('defaults every field to 0 when usage/turns are absent', () => {
70+
expect(extractUsageMetrics({})).toEqual({
71+
inputTokens: 0,
72+
outputTokens: 0,
73+
cacheReadInputTokens: 0,
74+
totalCostUsd: 0,
75+
numTurns: 0,
76+
});
77+
});
78+
79+
it('rounds totalCostUsd to 2 decimal places', () => {
80+
const metrics = extractUsageMetrics({ usage: { total_cost_usd: 1.23456 } });
81+
expect(metrics.totalCostUsd).toBe(1.23);
82+
});
83+
});
84+
85+
describe('collectToolUseBlocks', () => {
86+
it('collects only tool_use blocks from assistant messages, in order', () => {
87+
const messages = [
88+
{ role: 'user', content: [{ type: 'tool_use', name: 'Read' }] },
89+
{
90+
role: 'assistant',
91+
content: [
92+
{ type: 'text', text: 'thinking...' },
93+
{ type: 'tool_use', name: 'Glob' },
94+
{ type: 'tool_use', name: 'Read', input: { file_path: '/a.ts' } },
95+
],
96+
},
97+
{ role: 'assistant', content: [{ type: 'tool_use', name: 'Bash' }] },
98+
];
99+
const blocks = collectToolUseBlocks(messages as never);
100+
expect(blocks.map((b) => b.name)).toEqual(['Glob', 'Read', 'Bash']);
101+
});
102+
103+
it('tolerates non-array content', () => {
104+
const messages = [{ role: 'assistant', content: 'plain text' }];
105+
expect(collectToolUseBlocks(messages as never)).toEqual([]);
106+
});
107+
108+
it('returns an empty array for no messages', () => {
109+
expect(collectToolUseBlocks([])).toEqual([]);
110+
});
111+
});
112+
113+
describe('tallyToolCalls', () => {
114+
it('counts tool calls by name and dedupes files read', () => {
115+
const messages = [
116+
{
117+
role: 'assistant',
118+
content: [
119+
{ type: 'tool_use', name: 'Read', input: { file_path: '/a.ts' } },
120+
{ type: 'tool_use', name: 'Read', input: { file_path: '/a.ts' } },
121+
{ type: 'tool_use', name: 'Read', input: { file_path: '/b.ts' } },
122+
{ type: 'tool_use', name: 'Grep' },
123+
],
124+
},
125+
];
126+
const { toolCalls, uniqueFilesRead } = tallyToolCalls(messages as never);
127+
expect(toolCalls).toEqual({ Read: 3, Grep: 1 });
128+
expect(uniqueFilesRead).toBe(2);
129+
});
130+
131+
it('names unnamed tool_use blocks "unknown"', () => {
132+
const messages = [{ role: 'assistant', content: [{ type: 'tool_use' }] }];
133+
const { toolCalls } = tallyToolCalls(messages as never);
134+
expect(toolCalls).toEqual({ unknown: 1 });
135+
});
136+
137+
it('returns zero tallies for no messages', () => {
138+
expect(tallyToolCalls([])).toEqual({ toolCalls: {}, uniqueFilesRead: 0 });
139+
});
140+
});

0 commit comments

Comments
 (0)