From d4c0eaf6ec0d51bef339a5e3e1cd8bca87abc30c Mon Sep 17 00:00:00 2001 From: hgreene624 Date: Thu, 2 Apr 2026 08:45:48 -0700 Subject: [PATCH 1/2] Fix token overcounting from streaming duplicate JSONL entries Claude Code writes multiple JSONL entries per API call during streaming: intermediate entries have stop_reason: null, and only the final entry has a string value like "end_turn" or "tool_use". The getTokenMetrics function was summing all entries, inflating the total by ~2.5x. Now only counts final entries (those with a truthy stop_reason). Falls back to counting all entries when no stop_reason data is present, for backward compatibility with older transcript formats. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/types/TokenMetrics.ts | 2 +- src/utils/__tests__/jsonl-metrics.test.ts | 105 ++++++++++++++++++++++ src/utils/jsonl-metrics.ts | 48 +++++++--- 3 files changed, 140 insertions(+), 15 deletions(-) diff --git a/src/types/TokenMetrics.ts b/src/types/TokenMetrics.ts index cb63cac3..8ac4aca7 100644 --- a/src/types/TokenMetrics.ts +++ b/src/types/TokenMetrics.ts @@ -6,7 +6,7 @@ export interface TokenUsage { } export interface TranscriptLine { - message?: { usage?: TokenUsage }; + message?: { usage?: TokenUsage; stop_reason?: string | null }; isSidechain?: boolean; timestamp?: string; isApiErrorMessage?: boolean; diff --git a/src/utils/__tests__/jsonl-metrics.test.ts b/src/utils/__tests__/jsonl-metrics.test.ts index 92d9f63f..73f75c7f 100644 --- a/src/utils/__tests__/jsonl-metrics.test.ts +++ b/src/utils/__tests__/jsonl-metrics.test.ts @@ -23,12 +23,14 @@ function makeUsageLine(params: { cacheCreate?: number; isSidechain?: boolean; isApiErrorMessage?: boolean; + stopReason?: string | null; }): string { return JSON.stringify({ timestamp: params.timestamp, isSidechain: params.isSidechain, isApiErrorMessage: params.isApiErrorMessage, message: { + stop_reason: params.stopReason, usage: { input_tokens: params.input, output_tokens: params.output, @@ -159,6 +161,109 @@ describe('jsonl transcript metrics', () => { }); }); + it('skips intermediate streaming entries and only counts final entries per API call', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'streaming.jsonl'); + + // Simulate two API calls, each with intermediate streaming entries (stop_reason: null) + // and a final entry (stop_reason: "tool_use" or "end_turn") + const lines = [ + // API call 1: two intermediates + one final + makeUsageLine({ + timestamp: '2026-01-01T10:00:00.000Z', + input: 1, + output: 30, + cacheRead: 12000, + cacheCreate: 11000, + stopReason: null + }), + makeUsageLine({ + timestamp: '2026-01-01T10:00:00.000Z', + input: 1, + output: 30, + cacheRead: 12000, + cacheCreate: 11000, + stopReason: null + }), + makeUsageLine({ + timestamp: '2026-01-01T10:00:01.000Z', + input: 1, + output: 150, + cacheRead: 12000, + cacheCreate: 11000, + stopReason: 'tool_use' + }), + // API call 2: one intermediate + one final + makeUsageLine({ + timestamp: '2026-01-01T10:00:02.000Z', + input: 1, + output: 25, + cacheRead: 23000, + cacheCreate: 500, + stopReason: null + }), + makeUsageLine({ + timestamp: '2026-01-01T10:00:03.000Z', + input: 1, + output: 400, + cacheRead: 23000, + cacheCreate: 500, + stopReason: 'end_turn' + }) + ]; + + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const metrics = await getTokenMetrics(transcriptPath); + + // Should only count the two final entries, not the three intermediates + expect(metrics).toEqual({ + inputTokens: 2, // 1 + 1 + outputTokens: 550, // 150 + 400 + cachedTokens: 46500, // (12000 + 11000) + (23000 + 500) + totalTokens: 47052, // 2 + 550 + 46500 + contextLength: 23501 // last main-chain final entry: 1 + 23000 + 500 + }); + }); + + it('falls back to counting all entries when no stop_reason data is present', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'legacy.jsonl'); + + // Older transcript format without stop_reason + const lines = [ + makeUsageLine({ + timestamp: '2026-01-01T10:00:00.000Z', + input: 100, + output: 50, + cacheRead: 20, + cacheCreate: 10 + }), + makeUsageLine({ + timestamp: '2026-01-01T11:00:00.000Z', + input: 200, + output: 80, + cacheRead: 30, + cacheCreate: 20 + }) + ]; + + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const metrics = await getTokenMetrics(transcriptPath); + + // Should count all entries since none have stop_reason + expect(metrics).toEqual({ + inputTokens: 300, + outputTokens: 130, + cachedTokens: 80, + totalTokens: 510, + contextLength: 250 + }); + }); + it('returns zeroed token metrics when file is missing', async () => { const metrics = await getTokenMetrics('/tmp/ccstatusline-jsonl-metrics-missing.jsonl'); expect(metrics).toEqual({ diff --git a/src/utils/jsonl-metrics.ts b/src/utils/jsonl-metrics.ts index aacdefb3..dc1c6545 100644 --- a/src/utils/jsonl-metrics.ts +++ b/src/utils/jsonl-metrics.ts @@ -162,26 +162,46 @@ export async function getTokenMetrics(transcriptPath: string): Promise mostRecentTimestamp) { - mostRecentTimestamp = entryTime; - mostRecentMainChainEntry = data; - } + parsedEntries.push(data); + if (data.message.stop_reason) { + hasAnyStopReason = true; + } + } + } + + for (const data of parsedEntries) { + // Skip intermediate streaming entries when stop_reason data is available + if (hasAnyStopReason && !data.message!.stop_reason) { + continue; + } + + inputTokens += data.message!.usage!.input_tokens || 0; + outputTokens += data.message!.usage!.output_tokens || 0; + cachedTokens += data.message!.usage!.cache_read_input_tokens ?? 0; + cachedTokens += data.message!.usage!.cache_creation_input_tokens ?? 0; + + // Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain) + // Also skip API error messages (synthetic messages with 0 tokens) + if (data.isSidechain !== true && data.timestamp && !data.isApiErrorMessage) { + const entryTime = new Date(data.timestamp); + if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) { + mostRecentTimestamp = entryTime; + mostRecentMainChainEntry = data; } } } From e2096e2bc2f9704a0c899353a44d25132f23fd38 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Wed, 8 Apr 2026 13:48:20 -0400 Subject: [PATCH 2/2] fix(jsonl): dedupe live streaming token metrics --- src/utils/__tests__/jsonl-metrics.test.ts | 98 +++++++++++++++++++++++ src/utils/jsonl-metrics.ts | 34 +++++--- 2 files changed, 119 insertions(+), 13 deletions(-) diff --git a/src/utils/__tests__/jsonl-metrics.test.ts b/src/utils/__tests__/jsonl-metrics.test.ts index 73f75c7f..01a97c43 100644 --- a/src/utils/__tests__/jsonl-metrics.test.ts +++ b/src/utils/__tests__/jsonl-metrics.test.ts @@ -227,6 +227,104 @@ describe('jsonl transcript metrics', () => { }); }); + it('counts the latest in-progress streaming entry once when no finalized row exists yet', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'streaming-in-progress.jsonl'); + + const lines = [ + makeUsageLine({ + timestamp: '2026-01-01T10:00:00.000Z', + input: 4, + output: 40, + cacheRead: 1000, + cacheCreate: 200, + stopReason: null + }), + makeUsageLine({ + timestamp: '2026-01-01T10:00:01.000Z', + input: 4, + output: 90, + cacheRead: 1000, + cacheCreate: 200, + stopReason: null + }), + makeUsageLine({ + timestamp: '2026-01-01T10:00:02.000Z', + input: 4, + output: 140, + cacheRead: 1000, + cacheCreate: 200, + stopReason: null + }) + ]; + + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const metrics = await getTokenMetrics(transcriptPath); + + expect(metrics).toEqual({ + inputTokens: 4, + outputTokens: 140, + cachedTokens: 1200, + totalTokens: 1344, + contextLength: 1204 + }); + }); + + it('counts finalized streaming entries plus the latest unfinished one during live updates', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'streaming-live-update.jsonl'); + + const lines = [ + makeUsageLine({ + timestamp: '2026-01-01T10:00:00.000Z', + input: 2, + output: 25, + cacheRead: 100, + cacheCreate: 50, + stopReason: null + }), + makeUsageLine({ + timestamp: '2026-01-01T10:00:01.000Z', + input: 2, + output: 80, + cacheRead: 100, + cacheCreate: 50, + stopReason: 'end_turn' + }), + makeUsageLine({ + timestamp: '2026-01-01T10:00:02.000Z', + input: 3, + output: 30, + cacheRead: 200, + cacheCreate: 25, + stopReason: null + }), + makeUsageLine({ + timestamp: '2026-01-01T10:00:03.000Z', + input: 3, + output: 120, + cacheRead: 200, + cacheCreate: 25, + stopReason: null + }) + ]; + + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const metrics = await getTokenMetrics(transcriptPath); + + expect(metrics).toEqual({ + inputTokens: 5, + outputTokens: 200, + cachedTokens: 375, + totalTokens: 580, + contextLength: 228 + }); + }); + it('falls back to counting all entries when no stop_reason data is present', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); tempRoots.push(root); diff --git a/src/utils/jsonl-metrics.ts b/src/utils/jsonl-metrics.ts index dc1c6545..a21bdd68 100644 --- a/src/utils/jsonl-metrics.ts +++ b/src/utils/jsonl-metrics.ts @@ -165,35 +165,43 @@ export async function getTokenMetrics(transcriptPath: string): Promise { + const stopReason = data.message?.stop_reason; + return Boolean(stopReason) || (stopReason === null && index === parsedEntries.length - 1); + }) + : parsedEntries; + + for (const data of entriesToCount) { + const usage = data.message?.usage; + if (!usage) { continue; } - inputTokens += data.message!.usage!.input_tokens || 0; - outputTokens += data.message!.usage!.output_tokens || 0; - cachedTokens += data.message!.usage!.cache_read_input_tokens ?? 0; - cachedTokens += data.message!.usage!.cache_creation_input_tokens ?? 0; + inputTokens += usage.input_tokens || 0; + outputTokens += usage.output_tokens || 0; + cachedTokens += usage.cache_read_input_tokens ?? 0; + cachedTokens += usage.cache_creation_input_tokens ?? 0; // Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain) // Also skip API error messages (synthetic messages with 0 tokens)