Skip to content

fix(jsonl): dedup cumulative token counts by message.id (#8) - #9

Merged
axisrow merged 2 commits into
mainfrom
fix/cumulative-token-dedup
Sep 26, 2026
Merged

axisrow merged 2 commits into
mainfrom
fix/cumulative-token-dedup

Conversation

@axisrow

@axisrow axisrow commented Sep 25, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #8 — cumulative token over-reporting (upstream sirmalloc#549).

One JSONL entry per content block shares one message.id, and the cumulative token path (Tokens Input / Output / Total) summed every entry, inflating counts by up to ~1.84x. This PR applies the same per-API-call grouping already used by the Tokens Last Turn widget to the cumulative path in getTranscriptAnalysis: entries are grouped by message.id with the max output_tokens kept per group; entries without an id start a new group.

Transcripts without duplicate-id entries produce identical numbers to before — all pre-existing tests pass unchanged.

Before / after (synthetic sirmalloc#549-shaped transcript)

before (sum per entry) after (per API call)
Input 16 12
Output 393 292
Cached 48,868 16,356
Total 49,277 (~49.3k) 16,660 (~16.5k, matches Turn)

Edge cases covered by tests:

  • streaming partial entries sharing an id resolve to the finalized value (output 200, not 25+80 / 30+120);
  • a sidechain group is counted once and keeps its current semantics (included in sums, excluded from contextLength);
  • no-duplicate transcripts unchanged.

Tests

  • bun test: 2377 pass / 0 fail (single final run)
  • bun run lint: clean, no suppressions
  • no new dependencies; dual-runtime Bun + Node untouched

Refs: sirmalloc#549.

🤖 Generated with Claude Code

…c#549)

Claude Code writes one JSONL entry per content block of a single API call,
all sharing one message.id. The cumulative token path summed every entry,
inflating Tokens Input/Output/Total by up to ~1.84x. Mirror the grouping
already used for Tokens Last Turn: consecutive counted entries sharing an
id contribute one usage (prompt side once, output as the running max).

- transcripts without duplicate-id entries are unchanged (existing tests)
- streaming partials resolve to the finalized value, counted once mid-stream
- sidechain/API-error rows keep their current treatment in sums and recency

Added vitest cases on a synthetic sirmalloc#549-shaped transcript asserting exact
cumulative totals (12 in / 292 out / 16660 total vs inflated 16/393/...),
streaming partials with ids, and a sidechain per-content-block group.

Co-Authored-By: Claude Code <[email protected]>

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving - no changes requested. Reviewed the full diff 51a8819..fa9b4e2 by tracing the implementation.

Verified:

  • Grouping mirrors the existing last-turn dedup: one group window, delta-only output for a matching id, full accumulation for a new group. Entries without an id always start a new group, so no-id transcripts behave exactly as before; the existing no-id tests cover the parity claim.
  • The one-time hasStopReasonField reset also resets the new dedup state (countedMessageId/countedOutputTokens), so legacy pre-stop-reason rows cannot spuriously match post-reset ids.
  • The finishTokenMetrics revival path composes correctly: a trailing stop_reason=null entry either matches the last counted group (output delta applied) or forms a new group; no double counting, because stop-null entries are skipped at collect time once hasStopReasonField is set.
  • The delta>0 guard gives max-wins on output, matching the Math.max semantics of the last-turn widget.
  • The trackLastTurnTokens refactor is behavior-preserving: empty or undefined ids normalize to null and can never match.
  • Test expectations trace correctly against the implementation (12/292/16356/16660/16258; 5/200/375/580/228; 910/700/5130/6740/40).

One informational note inline; nothing blocking.

Comment thread src/utils/jsonl-metrics.ts Resolved
Review follow-up on #9: the duplicate early return deliberately keeps the
context-length snapshot from the group's first counted entry. Write the
invariant down at the return so it survives as a conscious assumption.

Co-Authored-By: Claude Code <[email protected]>

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewing the updated head e2db47b (follow-up to review 5319914008 on fa9b4e2). Approving - no changes requested.

The new commit is comment-only (4 lines at the duplicate early return in accumulateCumulativeTokenEntry) and documents exactly the invariant flagged in the previous review: within one message.id group the recency snapshot stays pinned to the first counted entry, which is safe because per-block entries repeat identical prompt-side usage and contextLength never reads output_tokens. No code changes; everything verified in the previous review still applies to this head:

  • one group window, delta-only output for a matching id, full accumulation for a new group; no-id entries always start a new group, so no-id transcripts behave exactly as before;
  • the one-time hasStopReasonField reset also resets the dedup state, so legacy rows cannot spuriously match post-reset ids;
  • the finishTokenMetrics revival path composes without double counting;
  • max-wins on output matches the last-turn widget Math.max semantics;
  • test expectations trace correctly against the implementation.

@axisrow
axisrow merged commit 6ab92fa into main Sep 26, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cumulative token widgets over-report ~1.84x (upstream sirmalloc#549) — fix in our tree

1 participant