Skip to content

fix(claude,web): surface assistant content lost to a mid-stream API retry - #6

Open
Antisophy wants to merge 4 commits into
CyberShadow:masterfrom
Antisophy:fix/assistant-content-after-api-retry
Open

fix(claude,web): surface assistant content lost to a mid-stream API retry#6
Antisophy wants to merge 4 commits into
CyberShadow:masterfrom
Antisophy:fix/assistant-content-after-api-retry

Conversation

@Antisophy

Copy link
Copy Markdown
Contributor

Supersedes #4 (GitHub would not let me reopen it after the branch was rebased onto current master).

Problem

When the Anthropic API retries a request mid-stream, the Claude CLI can stop emitting stream_event partials for the rest of that run. cydo builds the visible message only from those content_block_* stream events, so when they stop, the assistant turn can end up rendering as nothing, leaving the reply only inside the session-complete result block, which itself shows collapsed as a bare checkmark divider, leaving discovery of the full response very unlikely. When it happened to me, the only way I discovered that I actually was receiving responses was through a desktop notification I was lucky to catch when cydo wasn't focused, leading me to click around to try to find the response I'd seen in the notification and finally expand the checkmark divider, which I'd previously dismissed as uninteractive.

The gap has two shapes. In the total case, nothing streams and the whole turn is blank. In the partial case, some blocks stream and others do not, for example a thinking block streams while the following text block's stream events never arrive, so the reply text is missing while the thinking is shown. Both leave the final text alive only in the result block.

Fix

Two complementary layers:

  • fix(claude): promote assistant content when stream events are missing: when a complete assistant event arrives with no active stream blocks, promote its content blocks straight to item/started + item/completed (the same way the sub-agent path already does) so the turn renders. Promoted item ids use a session-wide counter (these per-block events carry no stream index), and an api_retry system event resets stream tracking so a half-opened block from the aborted attempt cannot leave stale state that suppresses the fallback. This recovers the total case inline.
  • fix(web): keep session result expanded when its text never rendered: the result event always carries a copy of the reply; when that copy is the only surviving record, compare it to the last main-turn assistant message and, if it is not already on screen, start the result block expanded (like error results already do) instead of a bare checkmark. This surfaces the reply in any case the text did not render, including the partial case the backend layer intentionally stays out of because some blocks did stream.

Tests

  • Backend unit test: a stream-less content block promotes to item/started + item/completed, and a tool_use block keeps its own id.
  • Web reducer test (result text visibility): covers the streamed-and-redundant, no-message, and differing-text cases, plus the partial-stream shape, a thinking block renders while the text block never streams, so the result text is unseen and the block stays expanded.

The partial-stream case is the one seen in practice (a real turn where a thinking block rendered but the reply text did not), distinct from the total blackout, and it is what the web reducer test now pins down.

Antisophy added 4 commits July 1, 2026 11:41
The result event always carries a copy of the final reply text,
normally redundant with the streamed assistant message. When the turn's
content is lost (e.g. the CLI stops emitting stream events after API
retries), that copy is the only surviving record of the reply, yet it
rendered collapsed as a bare checkmark divider, indistinguishable from
"no response".

Compare the result text against the last main-turn assistant message
when the result arrives; if it isn't already on screen, flag it and
start the result block expanded, like error results already do.
After an API retry, Claude CLI stops emitting
stream_event partials for the rest of the process run. Live translation
promoted content to visible items only from content_block_* stream
events, treating complete assistant events as metadata-only turn/delta,
so entire turns rendered as nothing; the final reply text survived only
inside the collapsed session-complete result block.

When an assistant event arrives and no stream blocks are active, promote
its content blocks directly (item/started + item/completed), the same
way the sub-agent path already does. Generated item ids use a
session-wide counter since per-block assistant events carry no stream
index. Also reset stream tracking when an api_retry system event passes
through, so a mid-stream abort can't leave stale state that suppresses
the fallback.
translateAssistantLive lives on the process-spawning ClaudeCodeSession, so the promotion fallback cannot be driven from a unit test directly. Extract the per-block promotion into a pure static promoteContentBlocks (hoisting the ClaudeBlock parse struct to class scope) and unit-test that a stream-less text block becomes item/started + item/completed and a tool_use block keeps its own id. No behavior change.
The existing result-visibility tests cover the total-blackout shapes (nothing streamed, or streamed text differs). Add the partial case: a thinking block renders while the following text block never streams, so the final reply text is not among the rendered text blocks and the result stays expanded. This is the condition observed in practice, distinct from the total blackout.
@Antisophy

Copy link
Copy Markdown
Contributor Author

Rationale for reopening this as a fresh PR: #4 was closed pending a valid test of the condition, and GitHub blocked reopening it once the branch was rebased onto current master, so this supersedes it with the fix unchanged plus that test.

Since #4 was closed, the same failure showed up live in a real session: a turn where the thinking block streamed but the following text block's stream events never arrived, so the reply text rendered as nothing and survived only in the result block, which the web layer then kept expanded. That is the partial-stream shape, distinct from the total blackout (nothing streamed) case, and it is likely why an api_retry-only integration test did not reproduce it: the trigger is not limited to api_retry, it is any turn where some content blocks stream and others do not.

The new test(web) commit pins that down at the reducer level: a rendered thinking block plus a result whose text was never streamed leaves resultUnseen true, so the result stays expanded. Combined with the existing backend promotion test, both layers of the fix are covered.

@CyberShadow

Copy link
Copy Markdown
Owner

After an API retry, Claude CLI stops emitting stream_event partials for the rest of the process run.

Could we please have an e2e test which demonstrates this Claude CLI behavior (and tests this change against that situation)?

@CyberShadow

Copy link
Copy Markdown
Owner

Looks like the claim is not quite accurate:

Verdict: the claim is false as stated — but it points at a real bug with a different mechanism and a much narrower scope.

I reproduced this empirically by driving the real Claude CLI 2.1.251 (same flags as claude.d:1218-1225, including --include-partial-messages) against a purpose-built mock Anthropic endpoint, two user turns per process run. Artifacts in /tmp/retry-probe/.

What actually happens

Failure injected on request #1 system/api_retry emitted? Retry request Partials for the retried content? Later requests
529 overloaded before any response body yes stream=true yes, full full streaming
Mid-stream abort before any content_block_start no stream=true yes, full full streaming
Mid-stream abort after content had begun streaming no stream=false no — only the aborted attempt's stale partial full streaming

Where the claim goes wrong

  1. The trigger is not "an API retry." The one case that genuinely emits system/api_retry (pre-stream 529) streams perfectly afterwards. The case that loses partials emits no api_retry event at all (out-mid2.ndjson, out-midtool.ndjson: api_retry=0).

  2. The cause is a non-streaming retry, not a broken stream. When a streaming request dies after content has arrived, the CLI re-issues it with stream: false. The mock logged req#2 stream=false. There are no content_block_* events for that reply because the CLI never asked for a stream — the content arrives only in the complete assistant event, exactly the symptom the PR describes.

  3. "For the rest of the process run" is wrong — it's one request. In out-midtool.ndjson the non-streaming retry returned a tool_use; the very next request in the same turn (req#3, after the tool result) came back stream=true with full partials, as did turn two (req#4). Streaming resumes immediately, not after the process restarts.

Consequence for the PR

The primary fix — promoting complete assistant content when no stream blocks are active — is correct and is what actually recovers this case.

The secondary half is aimed at the wrong signal: resetting stream tracking on api_retry cannot fire in the scenario that loses partials, since no api_retry is emitted there. It only fires in the pre-stream-529 case, which never needed it.

The stale-state worry behind that reset is nevertheless real: in out-mid2.ndjson the aborted attempt's block (message_start, content_block_start, one delta) was never closed — no content_block_stop/message_stop before the next turn's message_start. So the reset guards a genuine hazard, just not one api_retry announces. A trigger on the complete assistant event itself, or on message_start for a new message, would cover it.

One caveat: this is 2.1.251 behavior. If the "rest of the process run" observation came from an older CLI, that stickiness is gone now — but the per-request blackout persists, so the fallback is still warranted.

@Antisophy

Copy link
Copy Markdown
Contributor Author

I have observed each change in this PR helping multiple times when Claude was not responding normally. The session-end being expanded automatically helped me realize the usual messages, and when the messages were not being streamed in, I've seen the other change post full blocks automatically without the token-by-token stream appearing. I don't understand what you've tested, but it doesn't match what I've witnessed.

@CyberShadow

Copy link
Copy Markdown
Owner

I understand, and I don't deny that there is an improvement to be made here, but the technical claims of this PR do not add up. It cannot be merged as is, as that would imply permanently codifying untruths.

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.

2 participants