Skip to content

feat(chat): tell the user a quiet response is still working - #907

Merged
philmerrell merged 1 commit into
developfrom
fix/stall-heartbeat
Sep 1, 2026
Merged

feat(chat): tell the user a quiet response is still working#907
philmerrell merged 1 commit into
developfrom
fix/stall-heartbeat

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

The fifth and last change from the 5f34d2b0 post-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:

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 --noEmit clean. No backend changes.

🤖 Generated with Claude Code

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>
@philmerrell
philmerrell merged commit a09f474 into develop Sep 1, 2026
4 checks passed
@philmerrell
philmerrell deleted the fix/stall-heartbeat branch September 1, 2026 23:19
@philmerrell philmerrell mentioned this pull request Sep 2, 2026
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