Skip to content

Completed reply can vanish from chat: the frontend is the only persister of an interactive reply, from one unacknowledged chat_done, with no recovery path #6034

Description

@YellowSnnowmann

Summary

An interactive chat reply that the agent completed can never appear in the thread. The core does not persist interactive replies; the frontend does, from the single chat_done socket event, with one threads_message_append RPC whose failure is only logged. The streaming preview is cleared before that RPC runs, the event is never acknowledged or replayed, a reconnect drops the thread rooms for every non-selected in-flight thread, and nothing ever refetches the thread. When any link in that chain fails the reply is gone from the screen and from disk while the agent's own session history still contains it, which is exactly the "turn completed on the agent's side, nothing rendered on mine" the reporter describes.

Problem

User report (Discord, high severity, blocks core workflow). Completed replies intermittently do not render; more often on long or reasoning-heavy turns. In one session the agent finished an email edit and stated it was done, but the reply never showed; asking again made it re-post. The reporter also saw "the renderer seems to have failed again" earlier in the same session. Sub-agent / subtask completions also failed to reach the renderer. Re-asking makes the reply reappear; the turn is already complete on the agent side.

What I expected. Once the core has a final reply, it is durably stored and the thread shows it, regardless of what the webview was doing at that instant.

Mechanism (main @ f5c5ba8; PR #5885 and #5956 in v0.63.20+ change adjacent code but none of the paths below).

Who persists an interactive reply. Only the client. Turns the core runs on its own behalf persist first via task_session::append_final and only then announce; interactive turns rely on "a client viewing the thread persists the same reply … from the chat_done": presentation.rs#L184-L210, task_session.rs#L126-L133. No append_message call exists under web_chat/.

What the client does on chat_done (ChatRuntimeProvider.tsx#L1173-L1262):

  1. Clears the streamed preview synchronously: #L1180.
  2. Fires addInferenceResponse({ content: event.full_response, … }) and, on rejection, only rtLog('chat_done_append_failed', …) then carries on with finishChatDoneTurn: #L1191-L1214.
  3. addInferenceResponse is not optimistic: the row is added to the cache only in the fulfilled reducer, after openhuman.threads_message_append succeeds; rejected is a no-op: threadSlice.ts#L273-L313, #L564-L574, threadApi.ts#L86-L92.
  4. That RPC has the global 30 s timeout and no retry: coreRpcClient.ts#L23-L24, config.ts#L47. The store side serialises every append behind one process-wide lock (store.rs#L70) at the same moment the post-turn hooks (archivist, learning, cost log) start, which is where a long turn's bigger payload is most likely to time out.
  5. Segmented replies are worse: each chat_segment is appended with a bare void dispatch(...) and no error handling (#L842-L866), and the chat_done reconcile decides "complete" from which segments were received, not which were persisted (#L198-L209, #L1220-L1262). A failed segment append is never repaired.
  6. finishChatDoneTurn refetches usage, the user snapshot and the turn-state timeline, never the thread's messages: #L477-L503, chatRuntimeSlice.ts#L2408-L2427. loadThreadMessages runs only on thread selection and navigation.

What happens if the event never arrives. Delivery is a plain room emit with no buffering, ack or replay: socketio.rs#L1377-L1395, #L1411-L1428. A reconnect gets a new client_id, so only the thread:<id> room can still reach the client. On any non-connected status the provider clears every active-thread marker (#L1442-L1471 → threadSlice.ts#L423-L427), and the reconnect handler re-joins only activeThreadIds ∪ selectedThreadId, i.e. just the selected thread by then: socketService.ts#L228-L252. Events emitted while disconnected, or between connect and the server handling thread:subscribe (socketio.rs#L671-L685), are lost, and for a thread the user navigated away from during a long turn the chat_done is lost outright. A webview reload ("the renderer seems to have failed again") is the same case with an empty store.

Why it reads as "the agent thinks it replied". The agent's own session transcript and the completed turn-state snapshot (streaming_text, per-round Narration items: turn_state/types.rs#L293-L345) do hold the reply, so a follow-up question is answered from a history the thread store never received. The client already fetches that snapshot after every chat_done and does not reconcile the message list against it.

Long turns specifically. The 120 s silence watchdog wipes the streamed partial (clearRuntimeForThread) and shows the safety-timeout error (Conversations.tsx#L811-L827); the 20 s inference heartbeat re-arms it, except for parallel lanes (ChatRuntimeProvider.tsx#L520-L533). It does not block a later append, but it removes the only visible copy of the text before the append has succeeded, so on the reported turns the user sees the partial vanish and then nothing.

Sub-agent completions. Background results are delivered only while the session is idle, re-queued on failure and dropped when headless: background_delivery.rs#L106-L115, #L138-L188. Since #5956 the core persists that row first, so a missed chat_done here reappears on the next thread load; but the same "no refetch while you stay on the thread" gap means it looks lost until the user re-selects the thread or re-asks.

Steps to reproduce (dev build from main, macOS):

  1. Start a turn that takes a while (a reasoning model, or a prompt that runs several tools).
  2. While it runs, make the append fail once: temporarily return an error from openhuman.threads_message_append, or set CORE_RPC_TIMEOUT_MS to 1000 and hold CONVERSATION_STORE_LOCK busy (a large source ingest into the same workspace is enough on a slow disk).
  3. chat_done arrives: the streamed text disappears, no assistant row is added, the log shows chat_done_append_failed, and the reply is absent from memory/conversations/<thread>.jsonl.
  4. Ask "what did you just say?" — the agent repeats its answer from session history.

Variant without touching the RPC: start the turn, switch to another thread, toggle the network or restart the core so the socket reconnects, switch back. The finished turn's reply is not in the thread and never arrives.

Impact. Lost work product the user already paid tokens and time for, duplicate requests, and loss of trust in whether a request ran at all. Data loss is silent: the only trace is a debug rtLog.

Solution (optional)

  • Persist interactive replies in the core before announcing them, the way task_session::append_final already does for system turns (row id agent:<request_id>), and let the client's append be the idempotent mirror it already is for client_id: "system" turns. This alone removes the data-loss half.
  • Keep the streamed preview on screen until the append (or the refetch below) has succeeded; on rejection, keep the text visible with a retry affordance instead of a debug log.
  • After chat_done, on reconnect, and on the silence timeout, refetch the thread's messages (or reconcile against the completed turn-state snapshot the client already fetches).
  • Do not clear activeThreadIds on disconnect before the reconnect handler has used it to re-subscribe; re-join every in-flight thread room, and have the server accept thread:subscribe before it starts emitting to the new socket.
  • Decide segment completeness from persisted segments, not received ones.

Acceptance criteria

  • Repro gone — With threads_message_append failing once during a turn, the reply is still on disk and visible in the thread without re-asking.
  • Reconnect safe — A socket reconnect during a turn on a non-selected thread does not lose that turn's reply; switching back shows it without a manual reload.
  • Regression safety — A Vitest case for ChatRuntimeProvider covers a rejected append (preview stays, retry/refetch lands the row), and a Rust test covers core-side persistence of an interactive reply before chat_done.
  • Diff coverage ≥ 80% — the fix PR meets the changed-lines coverage gate (Vitest + cargo-llvm-cov, enforced by .github/workflows/ci-lite.yml).
  • Observable — A failed or missed final delivery surfaces as a user-visible state, not a debug log.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

agent-reliabilityAgent reliability / behaviorbugpriority: highBreaks a specific featurereact-uiReact app work in app/src: pages, components, providers, store, and UX.rust-coreCore Rust runtime in src/: CLI, core_server, shared infrastructure.

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions