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):
- Clears the streamed preview synchronously:
#L1180.
- Fires
addInferenceResponse({ content: event.full_response, … }) and, on rejection, only rtLog('chat_done_append_failed', …) then carries on with finishChatDoneTurn: #L1191-L1214.
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.
- 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.
- 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.
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):
- Start a turn that takes a while (a reasoning model, or a prompt that runs several tools).
- 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).
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.
- 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
Related
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_donesocket event, with onethreads_message_appendRPC 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 #5885and#5956in 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_finaland only then announce; interactive turns rely on "a client viewing the thread persists the same reply … from thechat_done":presentation.rs#L184-L210,task_session.rs#L126-L133. Noappend_messagecall exists underweb_chat/.What the client does on
chat_done(ChatRuntimeProvider.tsx#L1173-L1262):#L1180.addInferenceResponse({ content: event.full_response, … })and, on rejection, onlyrtLog('chat_done_append_failed', …)then carries on withfinishChatDoneTurn:#L1191-L1214.addInferenceResponseis not optimistic: the row is added to the cache only in thefulfilledreducer, afteropenhuman.threads_message_appendsucceeds;rejectedis a no-op:threadSlice.ts#L273-L313,#L564-L574,threadApi.ts#L86-L92.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.chat_segmentis appended with a barevoid dispatch(...)and no error handling (#L842-L866), and thechat_donereconcile decides "complete" from which segments were received, not which were persisted (#L198-L209,#L1220-L1262). A failed segment append is never repaired.finishChatDoneTurnrefetches usage, the user snapshot and the turn-state timeline, never the thread's messages:#L477-L503,chatRuntimeSlice.ts#L2408-L2427.loadThreadMessagesruns 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 newclient_id, so only thethread:<id>room can still reach the client. On any non-connectedstatus the provider clears every active-thread marker (#L1442-L1471→threadSlice.ts#L423-L427), and the reconnect handler re-joins onlyactiveThreadIds ∪ selectedThreadId, i.e. just the selected thread by then:socketService.ts#L228-L252. Events emitted while disconnected, or betweenconnectand the server handlingthread:subscribe(socketio.rs#L671-L685), are lost, and for a thread the user navigated away from during a long turn thechat_doneis 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-roundNarrationitems: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 everychat_doneand 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 missedchat_donehere 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):openhuman.threads_message_append, or setCORE_RPC_TIMEOUT_MSto 1000 and holdCONVERSATION_STORE_LOCKbusy (a large source ingest into the same workspace is enough on a slow disk).chat_donearrives: the streamed text disappears, no assistant row is added, the log showschat_done_append_failed, and the reply is absent frommemory/conversations/<thread>.jsonl.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)
task_session::append_finalalready does for system turns (row idagent:<request_id>), and let the client's append be the idempotent mirror it already is forclient_id: "system"turns. This alone removes the data-loss half.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).activeThreadIdson disconnect before the reconnect handler has used it to re-subscribe; re-join every in-flight thread room, and have the server acceptthread:subscribebefore it starts emitting to the new socket.Acceptance criteria
threads_message_appendfailing once during a turn, the reply is still on disk and visible in the thread without re-asking.ChatRuntimeProvidercovers a rejected append (preview stays, retry/refetch lands the row), and a Rust test covers core-side persistence of an interactive reply beforechat_done..github/workflows/ci-lite.yml).Related
chat_done(same event handler, different symptom; closed by fix(chat): preserve and render complete agent turns #5885).