Skip to content

fix: BA-17 — native (claude-mcp) turn is one assistant message, not one stream event (v0.33.1)#25

Merged
hamr0 merged 1 commit into
mainfrom
fix/ba17-native-turn-unit
Jul 23, 2026
Merged

fix: BA-17 — native (claude-mcp) turn is one assistant message, not one stream event (v0.33.1)#25
hamr0 merged 1 commit into
mainfrom
fix/ba17-native-turn-unit

Conversation

@hamr0

@hamr0 hamr0 commented Jul 23, 2026

Copy link
Copy Markdown
Owner

BA-17 — the native turn-unit fix (bareloop upstream ask)

The claude CLI emits one assistant stream event per content BLOCK, each repeating that message's usage. createSessionStream fired one onTurn per event, corrupting both metering axes:

  • Turn axis: a caller whose attempt bound is an LLM-turn count saw 14 "turns" for 2 real ones (7×; 4.4× on the adopter's failing job — 35 events for 8 turns). Its net guillotined the session at ~half the advertised allowance, and on native that routed to humanChannel → terminate, discarding the worker's output.
  • Token axis: the same message's usage summed once per block — 5.04× inflated vs the CLI's own session total.

A run of consecutive events sharing message.id is now ONE turn (adjacent-run dedup; no id ⇒ per-event, never a collapse).

--max-turns itself was never the defect — the ask filed it as "does not enforce, counts tool-calling turns"; both measured false on the wire (enforces at 4; 12 tool calls across 2 turns inside --max-turns 3). The symptom was the mis-count.

Also in this fix

  • The turn bound is now bare-agent's guarantee: --max-turns still maps through (clean stop + cost-bearing result event), plus a parent-side counter that kills only on an overrun (> not >=) — the flag is undocumented in claude --help.
  • BA-5 on native: a bounded session returns the last turn's text (the CLI reports result:null) and reports stopReason:'max_turns' via an own-property lookup (proto-key guard).
  • The closing kind:'session' event reconciles the token residual — a turn's usage is an unrevised first-block snapshot, so the streamed sum is short; residual (floored at 0) makes a gate's tiers sum to the CLI's own total.
  • GenerateResult.model/costUsd on native read from result.modelUsage via mapClaudeMeta.

No new public surface (patch). Adopters bounding by LLM turns get fewer onTurn events and smaller, correct token numbers.

Verification

  • 1000 tests / 998 pass / 0 fail (+34). Every new guard mutation-proved in both directions (19 mutations, all red).
  • typecheck 0 · build:types 0 · /ship · /security · /diff-review all clean.
  • Live verify-shipped against this commit, both cases green incl. token reconciliation (streamed + residual = 825 = the CLI's own output total). Evidence: poc/ba17-turn-unit.mjs, poc/ba17-unit-parallel.mjs, poc/ba17-verify-shipped.mjs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QxUb57y7xtJcaviVP9avJ6

… one stream event

The claude CLI emits a separate `assistant` stream event per content BLOCK, each
repeating that message's `usage`. `createSessionStream` fired one `onTurn` per
event, which corrupted both metering axes:

- Turn axis: a caller whose attempt bound is an LLM-turn count saw 14 "turns" for
  2 real ones (7x; 4.4x on the adopter's failing job — 35 events for 8 turns). Its
  net guillotined the session at ~half the advertised allowance, and on native
  that routed to humanChannel -> terminate, discarding the worker's output.
- Token axis: the same message's usage was summed once per block — 5.04x inflated
  vs the CLI's own session total, so a budget cap fired on tokens never spent.

A run of consecutive events sharing message.id is now ONE turn (adjacent-run
dedup — not a Set, so a recurring id still counts; no id degrades to per-event,
never collapses the session into one turn).

`--max-turns` itself was never the defect. The ask filed it as "does not enforce,
counts tool-calling turns" — both measured false on the wire: it enforces (12-step
task under --max-turns 4 stopped at 4, named error_max_turns) and counts assistant
turns (12 tool calls across 2 turns inside --max-turns 3).

Also in this fix:
- The turn bound is now bare-agent's guarantee. --max-turns still maps through
  (it stops cleanly and emits the cost-bearing result event); a parent-side
  counter kills the session only on an OVERRUN (> not >=), because --max-turns is
  undocumented in `claude --help` and a rename would silently unbound it.
- BA-5 on native: a bounded session returns the last turn's text (the CLI reports
  result:null on a bound), and the imposed terminal reports stopReason:'max_turns'
  via an own-property lookup (proto-key guard), not null.
- The closing kind:'session' event reconciles the TOKEN axis: a turn's usage is an
  unrevised first-block snapshot, so the streamed sum is short of the total. The
  closing event carries the per-tier residual (floored at 0), so a wired gate's
  tokens sum to exactly the CLI's own total. Verified live (streamed + residual =
  821 = the CLI's output total).
- GenerateResult.model on native is read from result.modelUsage via mapClaudeMeta
  (the result event has no `model` key), so model/costUsd stop being null.

No new public surface (patch). Adopters bounding by LLM turns get fewer onTurn
events and smaller, correct token numbers than 0.33.0 reported.

Evidence: poc/ba17-turn-unit.mjs, poc/ba17-unit-parallel.mjs (the flag's real
unit), poc/ba17-verify-shipped.mjs (shipped code on a live session, both cases
green incl. token reconciliation). +34 tests; every new guard mutation-proved in
both directions (19 mutations, all red).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxUb57y7xtJcaviVP9avJ6
@hamr0
hamr0 merged commit b9ba051 into main Jul 23, 2026
2 checks passed
@hamr0
hamr0 deleted the fix/ba17-native-turn-unit branch July 23, 2026 08:23
@hamr0
hamr0 restored the fix/ba17-native-turn-unit branch July 23, 2026 08:36
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.

1 participant