Skip to content

fix(passthrough): close a capped streaming turn as truncated, not as an error - #926

Open
materemias wants to merge 7 commits into
rynfar:mainfrom
materemias:fix/passthrough-capped-stream-turn
Open

fix(passthrough): close a capped streaming turn as truncated, not as an error#926
materemias wants to merge 7 commits into
rynfar:mainfrom
materemias:fix/passthrough-capped-stream-turn

Conversation

@materemias

Copy link
Copy Markdown
Contributor

The bug

A passthrough turn runs on a one-turn budget, so max_turns is its ordinary terminal state, not a fault.

When it trips with tool calls captured, the streaming path already recovers them as stop_reason: "tool_use". When it trips with none captured, the turn fell through to the error envelope - and the client had already rendered the turn's text over SSE. So the user saw a finished answer with Reached maximum number of turns (1) stamped on it.

141 such terminations in 24h on one machine, all reason=max_turns turns=1, all unrecovered:

0405add2 sdk_termination reason=max_turns turns=1 model=opus[1m] resume=true deferred=false

The fix

The non-streaming path has degraded honestly since the budget dropped to one: content and no forwardable call is reported as max_tokens (server.ts, passthrough.capped_turn_truncated). Its comment already claimed the streaming path did the same. This makes that true.

max_tokens is the choice on both paths for the same reason: it is the wire's word for a cut-off turn, so a client can continue from what it has. An error frame is a dead end, and end_turn is the silent-turn lie #768 exists to prevent.

Nothing durable moves. With no captured calls there is no checkpoint to publish and no mapping to advance - exactly as on the throwing path.

What stays on the error path

Two shapes, each with a test:

  • Only message_start forwarded. Nothing was delivered, so there is nothing to truncate. eventsForwarded cannot express this: it counts the envelope's own opening frame, so it is already 1 whenever messageStartEmitted is. The gate is nextClientBlockIndex, which counts only content blocks the client received - what the non-streaming branch means by contentBlocks.
  • A tool_use block on the wire with nothing captured. That means the hook refused those calls (forced-single overflow, duplicate abort, early-stop reversion). Closing with max_tokens would dangle a call the client is told neither to run nor to drop.

Verification

src/__tests__/passthrough-early-stop-integration.test.ts gains three cases. The truncation case fails on the parent commit; each boundary case fails when its own clause is removed from the gate. tsc --noEmit clean; full suite clean.

Copilot AI lite review requested due to automatic review settings September 3, 2026 09:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

The behavior change is well-scoped and backed by integration tests, with only minor observability tweaks suggested in review comments.

Pull request overview

Fixes a passthrough streaming edge case where a capped one-turn request (max_turns with turns=1) could be finalized via an SSE event: error even after meaningful content had already streamed to the client, producing a confusing “Reached maximum number of turns (1)” overlay on an otherwise-rendered answer.

Changes:

  • Add a streaming-path “capped turn → truncated” close branch that emits stop_reason: "max_tokens" + message_stop (no error frame) when content blocks were already delivered but no forwardable tool call was produced.
  • Preserve existing error behavior for two boundary cases: (1) only message_start forwarded (no content blocks), and (2) tool_use streamed but not captured (dangling call).
  • Add three integration test cases covering truncation and both boundary conditions.
File summaries
File Description
src/proxy/server.ts Adds a streaming capped-turn truncation close path to avoid emitting an error frame after content already streamed.
src/tests/passthrough-early-stop-integration.test.ts Adds integration tests validating truncation behavior and the two boundary cases that must remain errors.
Review details

Suppressed comments (1)

src/proxy/server.ts:6161

  • This telemetry record uses sdkSessionId: resumeSessionId, which will be empty for non-resume requests; using the observed currentSessionId (when present) improves session-level correlation and matches the main stream success record’s pattern.
                  sdkSessionId: resumeSessionId,
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/proxy/server.ts Outdated
requestSource,
isResume,
hasDeferredTools,
sdkSessionId: resumeSessionId,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in c437a6e: both capped-stream telemetry sites now use currentSessionId || resumeSessionId, matching the normal stream success path and preserving correlation for fresh requests. I also added a regression assertion for streamed result usage. Verification: bun test src/__tests__/passthrough-early-stop-integration.test.ts — 46 pass; bun run typecheck — clean.

…an error

A passthrough turn runs on a one-turn budget, so `max_turns` is its ordinary
terminal state, not a fault. When it trips with tool calls captured, the
streaming path already recovers them as `stop_reason:"tool_use"`. When it trips
with none captured, the turn fell through to the error envelope - and the client
had already rendered the turn's text, so it surfaced a finished answer with
"Reached maximum number of turns (1)" stamped on it. 141 such terminations in
24h on this machine, all `reason=max_turns turns=1`, all unrecovered.

The non-streaming path has degraded honestly since the budget dropped to one:
content and no forwardable call is reported as `max_tokens`. Its comment already
claimed the streaming path did the same. This makes that true.

`max_tokens` is the choice on both paths for the same reason: it is the wire's
word for a cut-off turn, so a client can continue from what it has, where an
error frame is a dead end and `end_turn` is the silent-turn lie rynfar#768 exists to
prevent. Nothing durable moves - with no captured calls there is no checkpoint
to publish and no mapping to advance, exactly as on the throwing path.

Two shapes stay on the error path, and each has a test:

- A turn that forwarded only `message_start` delivered nothing to truncate.
  `eventsForwarded` cannot express that: it counts the envelope's own opening
  frame, so it is already 1 whenever `messageStartEmitted` is. The gate is
  `nextClientBlockIndex`, which counts only content blocks the client received -
  what the non-streaming branch means by `contentBlocks`.
- A tool_use block on the wire with nothing captured means the hook refused
  those calls (forced-single overflow, duplicate abort, early-stop reversion).
  Closing that with `max_tokens` would dangle a call the client is told neither
  to run nor to drop.

Verified: the new truncation test fails on the parent commit; each boundary test
fails when its own clause is removed from the gate. Full suite clean.
A single-turn-capped passthrough turn that terminates `max_turns` without
yielding anything — no wire event, no captured tool call — never reached
the tool boundary the cap exists to stop at, so the turn bought nothing
and cost the whole request. Production shape: a resumed opus[1m] stream
that ran 108s, recorded 0 content blocks and 0 text events, and answered
500; the client's own identical retry succeeded.

Reissue that turn once with the cap lifted, on both the stream and the
non-stream path. Safe by the existing retry guards: the branch sits below
`didYieldClientEvent` / `didYieldContent` and the committed
priority-exposure check, so no envelope can be duplicated.

Eligibility is the budget the attempt actually asked for, read off the
options it built (`attemptMaxTurns === 1`) rather than parsed out of the
SDK's "Reached maximum number of turns (N)" wording — that parse is
optional, and an uncapped budget is a different failure that a reissue
would only repeat. An operator-pinned `PASSTHROUGH_MAX_TURNS` is left
alone, decided by `singleTurnCapLiftRaisesBudget`, which compares the real
computation against itself instead of copying its conditions.

Regressions cover the fresh and resumed shapes (the reissue keeps the
resume target and takes its own fork target), the lift firing exactly
once, and a pinned budget reporting the failure unchanged.

The stream error log now also reports what the client received, so an
error over rendered text is distinguishable from one that delivered
nothing without reconstructing it from a null TTFB.
@materemias
materemias force-pushed the fix/passthrough-capped-stream-turn branch from 1b46386 to 514caca Compare September 4, 2026 15:23
@rynfar

rynfar commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Your test job is red for a reason that has nothing to do with this PR — please rebase onto main and it should clear.

The 11 failures here (Retry-After (#901) ×5, Empty messages array (#450), Passthrough tool_use ×5) are #917, which was diagnosed and fixed earlier today in #935. The transcript ownership ceiling is bounded per session root, and the test preload pointed all ~179 files at a single root — so prepared transcripts accumulated until it filled, after which every request in the process returned 500 session transcript ownership backlog is full regardless of which file issued it. That is why unrelated tests fail with impossible statuses, like a 429 test receiving 500.

This branch predates that fix, so it is still running against the broken harness. git rebase origin/main and push, and the cluster should disappear.

Apologies for the delay in getting you CI signal on this — the approval was held while the flake made every result untrustworthy.

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.

3 participants