Skip to content

bug(chat): streamed AutoBot replies are never persisted — session history stores user turns only, so every reply vanishes on reload #13214

Closed

Description

@mrveiss

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

  1. 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.
  2. Persist on client disconnect too, so a user navigating away mid-reply does not lose the part already generated.
  3. 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.
  4. 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

Activity

  1. mrveiss commented on Aug 2, 2026

    @mrveiss
    OwnerAuthor

    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. The workflow_messages=0 log line in this issue is that writer's success branch reporting an empty batch, not an absent writer.

    Chain:

    1. chat_workflow/models.py:250 — StreamingMessage.to_workflow_message() stamps metadata["streaming"] = True unconditionally.
    2. chat_workflow/graph.py:468-471 and chat_workflow/manager.py:2378-2380 — both accumulators keep only non-streaming messages, and the LLM loop always streams. No assistant message survives into workflow_messages.
    3. chat_workflow/manager.py:2895-2965 — _persist_workflow_messages accepted llm_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).

  2. github-actions commented on Aug 2, 2026

    @github-actions
    Contributor

    PR #13291 (merged to Dev_new_gui) references this issue with a close keyword.

    fix(chat): persist the completed streamed reply to chat history (#13214)

    If this issue is fully resolved, close it manually. If work remains, no action is needed.

  3. mrveiss commented on Aug 2, 2026

    @mrveiss
    OwnerAuthor

    Fixed by PR #13291, squash-merged to Dev_new_gui as 5e0d6ece0.

    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. The workflow_messages=0 line quoted in this issue is that writer's success branch reporting an empty batch.

    Three links in the actual chain:

    1. chat_workflow/models.py:250 — to_workflow_message() stamps metadata["streaming"] = True unconditionally.
    2. chat_workflow/graph.py:468-471 and manager.py:2378-2380 — both accumulators do if not is_streaming: messages.append(...), and the LLM payload always sets "stream": True. No assistant message ever reached workflow_messages. Both sites carried a comment claiming the reply "is persisted in _persist_workflow_messages".
    3. manager.py:2895-2965 — _persist_workflow_messages accepted llm_response and 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_entry builds the assistant entry from llm_response via the same _build_message_dict the batch already uses, appended before the single add_messages_batch write. One writer, one round-trip, no new call sites. Review confirmed no duplicate write path was introduced.

    Two amendments applied in a35260513:

    • The duplicate guard scanned every batch entry, so a terminal_output message whose text matched the reply would have suppressed the assistant turn — the original bug through a narrower door. Now scoped to sender == "assistant".
    • The success log read workflow_messages=%d while passing len(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. _InMemoryChatHistory subclasses the real MessagesMixin, so _build_message_dict/add_messages_batch are production code and only the load/save seam is in-memory; streaming=True comes from real production code, not a test literal. Full suite: 636 passed across chat_workflow/ and tests/unit/chat_workflow/.

    Not delivered here

    Merge verification

    Delta measured against the Dev_new_gui python-suite run on the same day (252 unique failures, 26 errors). python-suite is 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} and services/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 #N only fires on the default branch, and this PR targeted Dev_new_gui.

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions