nesquena/hermes-webui

Mid-stream reconnect renders the just-sent user message below the live streaming response (pending_user_message merge order)

Closed

#6,419 opened on Jul 22, 2026

 (4 comments) (0 reactions) (0 assignees)Python (2,309 forks)github user discovery
bughelp wantedsprint-candidatestreaming

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 _live assistant message and splice()s the pending user message before it (falling back to push only when no live message exists).

  • static/ui.jsrefreshSession() (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 end
    

    If 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)

  1. Open a session in an iOS webview/PWA client, send a message that starts a long tool-heavy turn.
  2. While the response is streaming, background the app (or lock the screen) for ~30s so the SSE drops.
  3. Foreground the app during the same turn and let the reconnect probes run.
  4. 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.

Contributor guide