diff --git a/scripts/hooks/ecc-metrics-bridge.js b/scripts/hooks/ecc-metrics-bridge.js index d7f97cfa..f446dd24 100644 --- a/scripts/hooks/ecc-metrics-bridge.js +++ b/scripts/hooks/ecc-metrics-bridge.js @@ -91,6 +91,13 @@ function readSessionCost(sessionId) { fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize)); const lines = buf.toString('utf8').split('\n').filter(Boolean); + // Each row in costs.jsonl is *already* a cumulative session total — see + // scripts/hooks/cost-tracker.js: "Each row therefore represents the + // cumulative session total up to that point. To get per-session cost, + // take the last row per session_id." Summing every matching row + // therefore double-counts: for N rows of the same session it over- + // reports by roughly N(N+1)/2 / N = (N+1)/2 ×. Take the last matching + // row instead. let totalCost = 0; let totalIn = 0; let totalOut = 0; @@ -98,9 +105,9 @@ function readSessionCost(sessionId) { try { const row = JSON.parse(line); if (row.session_id === sessionId) { - totalCost += toNumber(row.estimated_cost_usd); - totalIn += toNumber(row.input_tokens); - totalOut += toNumber(row.output_tokens); + totalCost = toNumber(row.estimated_cost_usd); + totalIn = toNumber(row.input_tokens); + totalOut = toNumber(row.output_tokens); } } catch { /* skip malformed lines */ diff --git a/tests/hooks/ecc-metrics-bridge.test.js b/tests/hooks/ecc-metrics-bridge.test.js index ac7631ca..bf1cc72c 100644 --- a/tests/hooks/ecc-metrics-bridge.test.js +++ b/tests/hooks/ecc-metrics-bridge.test.js @@ -145,6 +145,45 @@ function runTests() { passed++; else failed++; + if ( + test('readSessionCost returns the LAST cumulative row, not the sum (cost-tracker contract)', () => { + // cost-tracker.js writes one row per Stop event; each row is already + // a cumulative session total ("To get per-session cost, take the + // last row per session_id."). Summing across rows over-counts: + // 0.01 + 0.02 + 0.03 = 0.06, but the correct answer is 0.03. + const tmpHome = makeTempHome(); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + try { + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + const metricsDir = path.join(tmpHome, '.claude', 'metrics'); + fs.mkdirSync(metricsDir, { recursive: true }); + fs.writeFileSync( + path.join(metricsDir, 'costs.jsonl'), + [ + JSON.stringify({ session_id: 'S1', estimated_cost_usd: 0.01, input_tokens: 333, output_tokens: 166 }), + JSON.stringify({ session_id: 'S1', estimated_cost_usd: 0.02, input_tokens: 666, output_tokens: 333 }), + JSON.stringify({ session_id: 'S1', estimated_cost_usd: 0.03, input_tokens: 1000, output_tokens: 500 }) + ].join('\n') + '\n', + 'utf8' + ); + const result = readSessionCost('S1'); + assert.strictEqual(result.totalCost, 0.03, `expected last-row 0.03, got ${result.totalCost} (was the bug: 0.06)`); + assert.strictEqual(result.totalIn, 1000); + assert.strictEqual(result.totalOut, 500); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + if ( test('readSessionCost does not include unrelated default-session rows', () => { const tmpHome = makeTempHome();