Mid-stream reconnect renders the just-sent user message below the live streaming response (pending_user_message merge order)
#6,419 opened on Jul 22, 2026
Repository metrics
- Stars
- (16,904 stars)
- PR merge metrics
- (Avg merge 14h 31m) (314 merged PRs in 30d)
Description
Summary
When the chat SSE stream drops mid-turn (typical on iOS: the WKWebView/PWA suspends all connections the moment the app is backgrounded or the screen locks) and the client goes through its reconnect/recovery path, the transcript can re-render with the just-sent user message below the live streaming assistant response it triggered.
Visible order during the affected turn:
[previous turn's settled answer]
[live worklog: Thinking card, "Activity: N tools", streaming prose] <- current turn's response
[user bubble with the prompt that started this turn] <- renders BELOW it
The flip is display-only: once the turn completes, the persisted session JSON has the correct order (user message before the assistant/tool messages), and reloading the session renders correctly. But while the turn is streaming — which for tool-heavy turns can be minutes — the conversation reads response-first, prompt-second.
Environment
- hermes-webui
exp-v0.52.137(master @4fd314d0) - Server on Linux, accessed from an iOS webview wrapper over a VPN; any client that drops the SSE mid-turn should be able to hit it, iOS just makes the drop happen on every app switch / screen lock
Timeline from the server request log (one affected turn)
t+0s POST /api/chat/start (turn submitted)
t+0s GET /api/chat/stream?stream_id=... (SSE attaches)
... app backgrounded -> iOS suspends the webview -> SSE drops ...
t+34s GET /api/chat/stream/status?stream_id=... (reconnect probe)
t+46s GET /api/chat/stream/status?stream_id=... (reconnect probe)
No re-GET /api/chat/stream and no /api/session fetch reached the server between the drop and the probes — consistent with the suspended webview deferring/failing requests until foregrounded. The mis-ordered layout was observed on screen during this reconnect window. Server-side _turn_journal / _run_journal and the session file were verified correct for the same turn.
Analysis
During a live turn the server intentionally keeps the prompt in session.pending_user_message / pending_started_at; it is only merged into messages[] when the turn settles. So any client-side recovery re-render that rebuilds from server truth has to re-insert the pending message itself — and the code paths disagree on where it goes:
-
static/sessions.js—_mergePendingSessionMessage()does it right: it finds the first_liveassistant message andsplice()s the pending user message before it (falling back topushonly when no live message exists). -
static/ui.js—refreshSession()(called from_recoverFromOfflineSoftly()on resume, i.e. exactly the iOS background/resume path) does:S.messages = data.session.messages || []; const pendingMsg=getPendingSessionMessage(data.session,S.messages); if(pendingMsg) S.messages.push(pendingMsg); // always appended at the endIf the DOM/state at that moment still carries the live turn (preserved
#liveAssistantTurn, INFLIGHT tail, or a runtime-journal anchor scene rendered for the active stream), the pending user bubble ends up ordered after the live worklog.
The observed flip is consistent with one of these recovery/refresh paths running mid-stream after the SSE drop. The settled path is unaffected, which matches the "corrects itself after the turn completes" behavior.
Suggested direction
Route every recovery path that re-inserts pending_user_message through the same placement logic as _mergePendingSessionMessage() (insert before the first live assistant message / live turn anchor, append only when the turn has no live tail), instead of push()ing at the end in refreshSession().
Steps to reproduce (best effort)
- Open a session in an iOS webview/PWA client, send a message that starts a long tool-heavy turn.
- While the response is streaming, background the app (or lock the screen) for ~30s so the SSE drops.
- Foreground the app during the same turn and let the reconnect probes run.
- Observe the transcript: the live worklog (thinking/activity/streaming text) renders above the user bubble that started the turn. Reload after the turn settles → order is correct.