Repository navigation
bug(chat): streamed AutoBot replies are never persisted — session history stores user turns only, so every reply vanishes on reload #13214
Description
Symptom
AutoBot's reply renders in the chat, then disappears "at random". Reported on a live session; reproduced from that session's stored state.
The replies are not lost — they are never written to the store the GUI reloads from.
Evidence
Two stores hold this conversation. The workflow store has every reply intact:
chat:conversation:<session> 6 turns, keys ['user','assistant']
0 user=Hello auto auto bot. assistant=Hello! I'm AutoBot. How can I help you today?
1 user=hello assistant=Hello! How can I help you today?
3 user=Go out about how are you… assistant=I'm doing well, thank you! How about you?
4 user=It's so nice to finally… assistant=That's very kind of you to say! …
5 user=Can you check your capab… assistant=I can help with: **What I can do:** …
The store the GUI renders has only the user side:
chat:session:<session>.messages total 5
by sender: {'user': 5} <-- zero assistant messages
user 2026-07-30 14:06:48 Hello auto auto bot.
user 2026-07-30 23:32:18 hello
user 2026-08-01 20:48:37 Go out about how are you today?
user 2026-08-01 20:49:54 It's so nice to finally hear your voice.
user 2026-08-01 20:51:05 Can you check your capabilities? Do you have any errors?
So the reply is streamed to the client and shown, and on the next fetch of GET /api/chat/sessions/<id> it is simply not in the payload. That is the "at random" — it is not random, it is every reload, navigation or reconnect.
Root cause: the streaming endpoint never persists the assistant turn
The GUI posts to POST /api/chats/{id}/message -> send_chat_message_by_id, which returns _create_streaming_response(...). That path contains no persistence call for the reply — grepping it for add_messages_batch / _store_and_log_ai_response / _to_persisted_message yields nothing.
The function that does persist the reply, _store_and_log_ai_response (api/chat.py:640), has exactly one caller:
api/chat.py:790 ai_message_id = await _store_and_log_ai_response(...)
-> inside async def process_chat_message (api/chat.py:743)
…which is the non-streaming path. Confirmed it never ran for this session:
chat_response_stored events for this session: 0
response_generated events for this session: 0
The user message is persisted (api/chat.py:449), which is why the user side survives and the assistant side does not. The asymmetry is the bug.
Supporting log line — the workflow-side persist reports nothing to write:
chat_workflow.manager - Persisted conversation to chat history: session=…, workflow_messages=0
chat_workflow.graph - Persisted conversation for session=…, messages=0
Impact
- Every AutoBot reply vanishes from history on reload. The conversation reads as a monologue of user messages.
- Any feature reading session history — context rebuild, export, share links, analytics — sees user turns only.
- Data is recoverable:
chat:conversation:*holds the replies, so a repair could backfill existing sessions rather than accepting the loss.
Proposed fix
- Persist the assistant turn on the streaming path. The reply is fully known once the stream completes, so append it in the stream's completion/finally step — the same
add_messages_batch([...])the non-streaming path already uses. - Persist on client disconnect too, so a user navigating away mid-reply does not lose the part already generated.
- Consider making persistence the responsibility of one place rather than each endpoint. Two endpoints answering the same request with different durability is what produced this, and bug(backend/chat): add_message(session_id, dict) calls silently no-op disk persistence (4 callsites since #3282) #6744 records an earlier bug in the very same call (
add_message(session_id, dict)was a wrong-signature silent no-op) — this code path has now failed to persist twice, for different reasons. - Backfill: existing sessions can be repaired from
chat:conversation:*, which still holds the assistant text.
Verification
redis-cli get "chat:session:<id>" | python3 -c "...count by sender" # user-only today
redis-cli get "chat:conversation:<id>" | python3 -c "...print turns" # replies present
grep -c chat_response_stored /var/log/autobot/backend.log # 0 for streamed sessions
grep -n "_store_and_log_ai_response(" autobot-backend/api/chat.py # single caller, non-streaming
Related
- bug(backend/chat): add_message(session_id, dict) calls silently no-op disk persistence (4 callsites since #3282) #6744 — earlier silent no-op in the same persistence call
- canonical(chat): 'list chats' served by 3 overlapping endpoints — list_chats mounted at both /api/chats and /api/chat/chats, plus /api/chat/sessions as a 3rd envelope #12371 — overlapping chat endpoints serving the same concept
Activity
mrveiss commentedon Aug 2, 2026
PR #13291 opened.
Root cause differs from the proposed fix. Persistence into the GUI-facing store already exists one layer below the SSE endpoint and ran on every turn —
chat_workflow/graph.py:1356->ChatWorkflowManager._persist_workflow_messages. Theworkflow_messages=0log line in this issue is that writer's success branch reporting an empty batch, not an absent writer.Chain:
chat_workflow/models.py:250—StreamingMessage.to_workflow_message()stampsmetadata["streaming"] = Trueunconditionally.chat_workflow/graph.py:468-471andchat_workflow/manager.py:2378-2380— both accumulators keep only non-streaming messages, and the LLM loop always streams. No assistant message survives intoworkflow_messages.chat_workflow/manager.py:2895-2965—_persist_workflow_messagesacceptedllm_response(the complete reply, passed by both call sites for exactly this purpose) and never referenced it.So the reply was fully known at the persist call and dropped. The fix uses that parameter; no new writer is added to the SSE path.
Items 3 and 4 of this issue are carried forward rather than dropped: #13293 (backfill damaged sessions from
chat:conversation:*) and #13292 (citation parity on reload).fix(chat): scope the duplicate guard to assistant turns, label the pe…
fix(chat): persist the completed streamed reply to chat history (#13214…
github-actions commentedon Aug 2, 2026
PR #13291 (merged to
Dev_new_gui) references this issue with a close keyword.If this issue is fully resolved, close it manually. If work remains, no action is needed.
mrveiss commentedon Aug 2, 2026
Fixed by PR #13291, squash-merged to
Dev_new_guias5e0d6ece0.The issue's premise held; its diagnosis did not
The two stores are real (
chat:session:*GUI-facing,chat:conversation:*workflow), but the proposed fix — add a persistence call in the SSE generator — would have duplicated a writer that already ran on every turn:_persist_workflow_messages. Theworkflow_messages=0line quoted in this issue is that writer's success branch reporting an empty batch.Three links in the actual chain:
chat_workflow/models.py:250—to_workflow_message()stampsmetadata["streaming"] = Trueunconditionally.chat_workflow/graph.py:468-471andmanager.py:2378-2380— both accumulators doif not is_streaming: messages.append(...), and the LLM payload always sets"stream": True. No assistant message ever reachedworkflow_messages. Both sites carried a comment claiming the reply "is persisted in_persist_workflow_messages".manager.py:2895-2965—_persist_workflow_messagesacceptedllm_responseand never referenced it. Both call sites threaded the complete reply there for exactly this purpose; it was silently dropped.Write missing, not failing — no swallowed exception. The workflow LLM loop always streams, so the workflow path always dropped the assistant turn; the non-streaming endpoint bypasses the workflow entirely, which is why only SSE showed the bug.
Fix
_build_final_response_entrybuilds the assistant entry fromllm_responsevia the same_build_message_dictthe batch already uses, appended before the singleadd_messages_batchwrite. One writer, one round-trip, no new call sites. Review confirmed no duplicate write path was introduced.Two amendments applied in
a35260513:terminal_outputmessage whose text matched the reply would have suppressed the assistant turn — the original bug through a narrower door. Now scoped tosender == "assistant".workflow_messages=%dwhile passinglen(batch). That log line is the evidence quoted in this issue; it now reports both counts.Verification
chat_workflow/streamed_reply_persistence_test.py— 3 of 8 tests fail against the pre-fix code._InMemoryChatHistorysubclasses the realMessagesMixin, so_build_message_dict/add_messages_batchare production code and only the load/save seam is in-memory;streaming=Truecomes from real production code, not a test literal. Full suite: 636 passed acrosschat_workflow/andtests/unit/chat_workflow/.Not delivered here
chat:conversation:*) is filed separately so it is not lost on close.sources: []; KB citations and the model badge live only on the discarded streaming chunks. Nothing regressed (pre-fix there was no turn at all), but reload is not yet faithful.respond→ prose, inverting what the user watched. Also not a regression, andtest_reply_appended_after_tool_outputcurrently enshrines it.Merge verification
Delta measured against the
Dev_new_guipython-suiterun on the same day (252 unique failures, 26 errors).python-suiteis not a required check (#13162) and is never green, so every merge is gated on a delta against base rather than on a green tick.Four failures appear as "new" on all three PRs merged in this batch —
a2a_security_test.py::TestTaskManagerWithTrace::{test_create_task_assigns_trace, test_create_task_custom_trace_id, test_task_to_dict_includes_trace}andservices/slm_link_probe_test.py::TestProbe::test_probe_is_registered_under_a_known_name. Three independent PRs touching disjoint files cannot share a cause; these are flappers. The remainder are timing-sensitive benchmarks (pii_pipeline p99,system_benchmarks,performance_benchmarks) that also move between runs on base.No "new" failure touches any file these PRs changed.
Closing manually with evidence —
Closes #Nonly fires on the default branch, and this PR targetedDev_new_gui.fix(chat): address review blockers on #13303 - revert bad ordering fi…
fix(chat): reload fidelity for streamed replies — model/sources, turn…