You watched the reply stream out — then it vanished: 10 delivery bugs Hermes fixed in one day


You are watching a reply stream out, word by word, and then it stops. A few seconds later it starts again with different phrasing — and the half you already read is gone. There is a more irritating version: you switch to another session for a second, switch back, and the same reply appears twice, one frozen copy and one still growing. And a third: the context gauge in the corner never moves during a turn, so you have no idea how close you are to the limit.

None of that is the model getting dumber. It is the delivery layer: the text reached your screen, but the finishing logic decided it did not count. On September 24, 2026, Hermes merged a run of fixes for exactly this class — ten PRs from morning to evening. Here they are in the order you are likely to hit them.

1. A partial reply already delivered should not be taken back

Symptom: a stream hits a 429 or a network error, retries run out, and the part you had already seen disappears with the failure. History is left with fragments that look like orphan messages.

Mechanism: when retries were exhausted, the final error builder constructed the error response without considering what had already been delivered into the stream.

Fix (PR #121628): settle_delivered_partial now runs once before each of the two final error builders, so the delivered text is kept in the error response, while collapse_continuation_trail merges earlier-attempt fragments into one message (it only looks at messages after the current user message, and does nothing when there is no valid turn index). The same batch handles the other half: when a transform_llm_output plugin rewrites the final reply, the streamed bubble now shows the rewritten text, matching what is stored in state.db — previously the bubble kept the pre-rewrite text until a refresh.

2. An interrupted reply should not be rendered a second time

Symptom (classic CLI): you interject mid-reply, or the reply transitions into a tool call, and text you already watched stream by gets printed again as one solid Panel.

Mechanism: a tool-call transition reset the stream state, and “how much of this turn’s final response was already streamed” did not survive the reset.

Fix (PR #121601): _streamed_text_this_turn records the text that actually streamed. It survives _reset_stream_state and is reset once per user turn; the Panel is suppressed only when the turn really was interrupted and the final response is contained in that streamed text (ignoring whitespace). Status messages that never streamed — “Operation interrupted: waiting for model response”, retry/backoff notices — still get their Panel.

The same PR fixes two related long-standing complaints: with show_reasoning: true, answers from separate-field reasoning models now stream instead of being dumped in one block at turn end (the reasoning box closes on the first content token), and piped, TTY-less stdout is line-buffered now — Python’s block buffering used to make headless runs look like a stall for minutes.

3. The desktop transcript stops drifting, and switching sessions mid-stream stops duplicating a reply

Symptom: you sit at the bottom of a growing reply, the view drifts up as content arrives and snaps back on the next scroll update; or you switch away and back mid-stream and the same reply shows up twice.

Mechanism: the first was unpinned growth during streaming. The second was activation rebuilding the transcript from the committed partial while also keeping the local live row.

Fix (PR #121602): a ResizeObserver keeps a bottom-pinned reader pinned as streamed content grows (skipped while an inline edit is open, bottom check via threadScrollStateFromMetrics, metrics from a shared readThreadScrollResizeMetrics() with the clearance element read through a ref, so no DOM walk per scroll event). For the duplicate: a still-streaming dump folds into the same turn’s committed partial, and the fold only happens when that partial really was committed during this turn (both timestamps present and the partial not older than turn start).

4. The context gauge should move during a turn

Symptom: during a turn the context-usage gauge stays frozen at the turn-start value and jumps only at completion; after the turn it sometimes falls back to the previous turn’s breakdown.

Mechanism: while the agent was busy the gauge preferred the idle /context snapshot taken before the turn, so streamed session.usage ticks never reached it; afterwards, a failed or slow idle refresh could restore the pre-turn snapshot.

Fix (PR #121638): while busy, the gauge uses the live streamed $currentUsage; the idle snapshot is invalidated when a turn starts, and the “is this snapshot stale” decision lives in one place (use-context-breakdown) which returns a breakdown only when not busy and for the same session — so ContextUsagePanel cannot paint the pre-turn categories on the first busy render either. This covers the frozen-gauge half of #70871; the TUI/desktop busy-hang half is out of scope.

5. Probe an opaque 5xx once, and classify in-stream errors that carry no status

Symptom A: a provider answers your streaming request with a bare 500/502/503 and nothing else, while the same request non-streaming returns a 4xx with an actionable message. You only ever see the useless 5xx.

Fix A (PR #121541): on a streaming 5xx, _StreamingCall makes one non-streaming probe with the same kwargs, at most once per 60-second window (time.monotonic()). If it returns a 4xx, that error is raised in place; if it succeeds, the response is replayed through a pure _replay_final_response. The session is not switched to non-streaming and nothing is latched. The probe suspends the stream stale watchdog (_stream_stale_timeout = inf in try/finally) so it cannot be stale-killed or counted as a strike, and it is skipped once /stop is pending. The same PR reclassifies the jiter parser’s ValueError on a corrupted SSE chunk (“EOF while parsing…”) as a transient provider error to retry, instead of a local bug that ends the turn.

Symptom B: the error arrives inside the stream with HTTP 200, so the classifier treats it as unknown/retryable and retries where it should fail over.

Fix B (PR #121547): an in-stream SSE error object’s numeric code (e.g. 403) is now read as the HTTP status — only integers 400–599 count, so {code: 200} and symbolic codes stay out — and an upstream ban is classified as auth (no retry) rather than “temporarily unavailable”. Status-less template render errors (LM Studio’s Jinja failures, for instance) become format_error, so the turn fails over instead of dying, and the auxiliary client now falls back to the next provider on status-less structured stream errors too.

6. The repetition guard now fires on every completion path

Symptom: the model loops, emits roughly 350,000 characters of repetition, and then stops normally — so the loop is delivered to you as the answer.

Mechanism: is_repetition_dominated’s window counting missed almost every real loop when the loop’s period did not line up with the window, and it only ran on the finish_reason=length path.

Fix (PR #121600): a periodic-run detector is OR’d with the existing window-count scan, so loops with counters, noise or interleaving are flagged. Distinct-row content (tables, numbered lists, CSV) is still not flagged. It stays linear: a 500k-character worst case takes 0.16s versus main’s 0.26s. The stop path aborts only when the reply is at least 16,000 characters (STOP_PATH_MIN_CHARS = 16_000) and looks like a runaway loop, so repetition you asked for (“Hello world…” fifty times, templated YAML, repeated log lines in a code block) is still delivered, with the failure reason stamped.

The same batch closes a nastier hole: tool arguments reconstructed from a dropped stream now go through the same guard — degenerate model output that could previously be executed as a terminal command is now rejected.

7. When the model writes its reasoning inline, both halves have to be right

Tag aliases (PR #121659): MiniMax-M3 wraps its reasoning in Chinese tag names (思考, 反思, 推理, 推敲). THINK_TAG_NAMES only knew the English ones, so the reasoning text showed up in the reply. All four are now in the shared list that the streaming scrubber, the CLI and gateway filters, and the final strip_think_blocks all build from. The same PR fixes a truncated-tail problem: interim commentary cut off at a tool-call boundary used to be marked “already streamed” on a prefix match, dropping the last chunk; it is delivered now.

Inline reasoning into the live pane (PR #121770): for models that put reasoning inside <think> blocks, the desktop reasoning pane stayed empty and reasoning_content was blank. The scrubber now exposes the text it stripped and it is forwarded as a reasoning delta — only when the provider has not streamed native reasoning in the same request, so reasoning is never shown twice. For the final field, when no reasoning delta arrives at all, _finish_chat_stream fills reasoning_content from the raw content with the existing extract_reasoning, reading only the current response so one API call’s reasoning cannot leak into the next.

8. Codex and Responses watchdogs get the same treatment

Fix (PR #121542): three things. Codex app-server progress notifications streamed to the UI but never refreshed turn liveness, so a long, healthy Codex turn was killed by the 600s liveness watchdog — now non-empty text/reasoning/output deltas and tool/message item start-complete refresh activity, while empty deltas and keepalives are ignored. xAI and other hosted codex_responses transports now get the large-context stale/TTFB accommodations openai-codex already had, which is what was killing healthy ~90K-token xAI Responses requests. And the cron max-iteration summary, which called _run_codex_stream directly, is now bounded by the same interruptible watchdog as every other call — previously an open stream that never produced data was never stale-killed, so the cron job simply hung.

Release status — worth being precise about

These PRs merged into main on the afternoon of September 24, 2026 (roughly 16:56–17:23 UTC), while the v0.21.5 tag (v2026.9.24) was published that morning at 10:09 UTC. So they are not in v0.21.5; they live on main only. Getting them means a git install tracking main, or waiting for the next tag.

That distinction is worth stating on its own, because “when was it fixed” and “which version can I get it from” are different questions — and on this project they regularly differ by only a few hours, since patch tags exist for downstream distribution rather than for feature announcements.

What to check on your own install

  • If you have seen the same reply appear twice in one session, check whether it happened right after switching sessions mid-stream — that is the #121602 case.
  • If you routinely lose text you had already read after a 429, #121628 is the one that matters most to you.
  • If one provider keeps answering with an uninformative 5xx, retry the same request on a newer build: you should now see its real 4xx error instead.
  • If your model is MiniMax-M3 and reasoning leaks into the reply body, check that the body is clean after upgrading.

For the neighboring mechanisms, two existing posts are worth reading side by side: the duplicate-reply delivery ledger covers a reply delivered twice, and the context-usage anchor guide explains what the numbers on that gauge actually mean. For where this window sits in a release, see the v0.21.5 release page and the v0.21.5 window self-check guide.

The short version

Ten fixes, one theme: anything that already appeared on screen has to be acknowledged by the finishing logic. Streaming delivery is hard in a place that does not look hard — not in printing the characters, but in the four ways a turn can end (disconnect, retry, interruption, session switch), each of which used to handle delivered text differently. This batch lines them up.