nesquena/hermes-webui

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

Fermée

#6 419 ouverte le 22 juil. 2026

 (4 commentaires) (0 réaction) (0 personne assignée)Python (2 386 forks)github user discovery
bughelp wantedsprint-candidatestreaming

Métriques du dépôt

Stars
 (17 368 étoiles)
Métriques de merge PR
 (Merge moyen 14h 31m) (314 PRs mergées en 30 j)

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.

Guide contributeur