From 4f21ed2acf5d5a233efdc4fadad846be18e22a06 Mon Sep 17 00:00:00 2001 From: Jamkris Date: Fri, 15 May 2026 15:04:14 +0900 Subject: [PATCH] fix(hooks): use last cumulative row for session cost in metrics bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ecc-metrics-bridge.js#readSessionCost` summed the `estimated_cost_usd`, `input_tokens`, and `output_tokens` of every matching row in `~/.claude/metrics/costs.jsonl`. That breaks the documented contract of `scripts/hooks/cost-tracker.js`, which explicitly states (in its module docblock): Cumulative behavior: Stop fires per assistant response, not per session. 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 N cumulative rows over-counts by roughly (N+1)/2 ×. For a session with 3 rows at 0.01, 0.02, 0.03 USD (true running total 0.03), the bridge today reports 0.06 USD. The over-counted value feeds `ecc-context-monitor.js`, which then trips its COST_NOTICE_USD / COST_WARNING_USD / COST_CRITICAL_USD thresholds on phantom spend AND injects the inflated number as `additionalContext` into the live model turn — so the agent itself is told a wrong cost. Reproduced on `main` before this commit: $ cat > /tmp/eccc/.claude/metrics/costs.jsonl < { + // 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();