feat(chat): tell the user a quiet response is still working - #907
Merged
Conversation
Closes the last gap from the 5f34d2b0 post-mortem. The model_retry event covers the pauses BETWEEN attempts; it cannot cover the failing call itself, which is the longest silence and the one that actually loses sessions. Both dead turns in that incident ran ~95 seconds with no output and the user abandoned both — the second while the request was, as far as the telemetry shows, still in flight, with no Bedrock outcome ever recorded for it. The parser now stamps the arrival time of every event, and the loading indicator says "Still working…" after 30s of silence and "Still working — this is taking longer than usual." after 90s. Thresholds sit past a normal first token (~5-7s) and past most tool calls, so a healthy turn rarely trips them. A known retry outranks the stall notice: one is a fact, the other an inference from elapsed time. Liveness is stamped on EVERY event, including ones the stream-state gate then drops — the question is whether the connection is alive, not whether the payload was useful — but only after the stale-stream guard, so a superseded stream cannot keep its replacement looking alive. DELIBERATELY CLIENT-SIDE. A server-sent heartbeat would have to race the agent stream against a timer, which means either running __anext__ from a fresh task per element (breaks anyio cancel scopes inside the MCP clients — entering and exiting a scope from different tasks is exactly what anyio forbids) or pumping the stream through a queue in a side task (a new task boundary through the cancellation path that owns lease release and interrupted-turn persistence, which #653/#863/#874 have each already had to repair). The SPA holds the one fact that matters — when the last byte arrived — and a dropped connection surfaces through fetch-event-source as an error rather than as silence, so silence on an open stream really does mean the server has not sent anything yet. The ticker runs only while a response is pending; an always-on interval would wake every open conversation to answer a question nobody is asking. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The fifth and last change from the
5f34d2b0post-mortem. It missed the #905 merge window — pushed to that branch ~18 minutes after it merged, so it never reached develop. Same commit, rebased onto develop, no changes.Why
model_retry(shipped in #905) covers the pauses between retry attempts. It cannot cover the failing model call itself — which is the longest silence and the one that actually loses sessions. Both dead turns in the incident ran ~95 seconds with no output and the user abandoned both; the second while the request was, as far as the telemetry shows, still in flight, with no Bedrock outcome ever recorded for it.What it does
The parser stamps the arrival time of every event. The loading indicator says "Still working…" after 30s of silence and "Still working — this is taking longer than usual." after 90s. A known retry outranks the stall notice: one is a fact, the other an inference from elapsed time.
Liveness is stamped on every event, including ones the stream-state gate then drops — the question is whether the connection is alive, not whether the payload was useful — but only after the stale-stream guard, so a superseded stream can't keep its replacement looking alive.
Thresholds sit past a normal first token (~5–7s in this session) and past most tool calls, so a healthy turn rarely trips them. In the incident, both dead turns would have shown the second message about four seconds before the user gave up.
Deliberately client-side — the call most worth challenging
A server-sent heartbeat has to race the agent stream against a timer, and there are only two ways:
__anext__as a fresh task per element. This breaks anyio cancel scopes inside the MCP clients — entering and exiting a scope from different tasks is exactly what anyio forbids, and our MCP sessions stay open across the stream.The SPA holds the one fact that matters (when the last byte arrived), and a dropped connection surfaces through fetch-event-source as an error rather than as silence, so silence on an open stream really does mean the server hasn't sent anything yet.
The honest cost: if a connection dies in a way the browser doesn't notice, "Still working…" is a lie where a server heartbeat would have proven liveness. I judged that less likely and less damaging than destabilising the cancellation path — but say so if you'd rather take the server-side risk and I'll build it.
The ticker runs only while a response is pending; an always-on interval would wake every open conversation to answer a question nobody is asking.
Testing
12 new SPA tests (stream liveness incl. superseded-stream isolation; loader notice rendering, cursor suppression, a11y announcement). Full SPA suite on top of develop: 1977 passed,
tsc --noEmitclean. No backend changes.🤖 Generated with Claude Code