Skip to content

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

Merged
mrveiss merged 6 commits into
Dev_new_guifrom
issue-13214
Aug 2, 2026
Merged

mrveiss merged 6 commits into
Dev_new_guifrom
issue-13214

Conversation

@mrveiss

@mrveiss mrveiss commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Closes #13214

Thinking Path

The issue's premise (two stores; the GUI-facing one holds user turns only) holds, but its diagnosis is incomplete, and its proposed fix targets the wrong layer.

Verified stores:

  • GUI reload path: GET /api/chat/sessions/{id} -> chat_history_manager.get_session_messages (api/chat_sessions.py:329, :244) -> chat:session:* (chat_history/session.py:84).
  • Workflow store: chat:conversation:* (chat_workflow/conversation.py:33), written by _persist_conversation (chat_workflow/conversation.py:293) with the full {user, assistant} pair — which is why the replies survive there.

The issue says the streaming endpoint "contains no persistence call". True at the API layer (api/chat.py:1506 returns _create_streaming_response(_stream_chat_workflow_messages(...)), which only re-emits SSE), but persistence into the GUI-facing store already exists one layer down and runs on every streamed turn: chat_workflow/graph.py:1356 -> ChatWorkflowManager._persist_workflow_messages. The issue's own log evidence proves it ran (workflow_messages=0, messages=0) — it wrote an empty batch. So adding a second writer in the SSE generator would have duplicated an existing responsibility instead of fixing it.

Actual cause, three links:

  1. chat_workflow/models.py:250 — StreamingMessage.to_workflow_message() stamps metadata["streaming"] = True unconditionally, on every chunk.
  2. chat_workflow/graph.py:468-471 (graph path) and chat_workflow/manager.py:2378-2380 (legacy path) — both accumulators keep only non-streaming messages: if not is_streaming: messages.append(...). Because _get_llm_request_payload always sets "stream": True (manager.py:1985), no assistant message ever survives into workflow_messages for a conversational turn. Both sites carry the comment "The final complete response is persisted in _persist_workflow_messages".
  3. chat_workflow/manager.py:2895-2965 — _persist_workflow_messages(session_id, workflow_messages, llm_response) accepted llm_response and never referenced it. The parameter was threaded from both call sites (graph.py:1356, manager.py:3239) for exactly this purpose and silently dropped.

Net: the complete reply was fully known at the persist call and discarded. _persist_user_message (graph.py:226) has no such gap, which is the reported asymmetry.

Not a swallowed exception: the except Exception at manager.py:2960 never fired — the observed log line is the success branch reporting a batch length of 0.

Is streaming the trigger? Yes for the endpoint, no for the mechanism: the workflow's LLM loop always streams, so the workflow path always dropped the assistant turn. The non-streaming endpoint (process_chat_message -> _store_and_log_ai_response, api/chat.py:790) never goes through the workflow and persists correctly — which is why only the SSE endpoint shows the bug.

Backfill of already-damaged sessions from chat:conversation:* (issue item 4) is deliberately not in this PR — it is a data-repair job, separate risk profile from the code fix.

What Changed

autobot-backend/chat_workflow/manager.py

  • New _build_final_response_entry — builds the assistant chat-history entry from llm_response, using the same chat_mgr._build_message_dict the surrounding batch already uses. Returns None for an empty reply, or when an entry with identical normalized text is already in the batch (the respond tool emits a non-streaming turn with the same content).
  • _persist_workflow_messages appends that entry to the batch before the single add_messages_batch write. One writer, one round-trip; no new persistence call sites.

autobot-backend/chat_workflow/streamed_reply_persistence_test.py (new)

  • Regression tests asserting the assistant turn is readable back out of the store.

No change to the SSE endpoint, to chat_history/, or to the frontend.

Verification

Application code was not executed (static analysis only, per the assignment).

Cause chain read directly:

  • models.py:250 "streaming": True — unconditional.
  • graph.py:468-471 / manager.py:2378-2380 — if not is_streaming: messages.append(...).
  • manager.py pre-fix _persist_workflow_messages body — llm_response appears in the signature and nowhere else.
  • Single caller of the working writer: grep -n "_store_and_log_ai_response(" api/chat.py -> api/chat.py:790 only (non-streaming).

Regression test design (why it fails pre-fix):

  • _InMemoryChatHistory subclasses the real chat_history.messages.MessagesMixin, so _build_message_dict and add_messages_batch are production code; only the load/save seam is in-memory. Assertions read the session back via load_session — the same accessor get_session_messages uses. It asserts the turn is readable, not that a write was attempted.
  • test_conversational_streamed_reply_is_persisted — pre-fix the batch is empty, add_messages_batch is skipped, load_session returns [], and the len(assistant) == 1 assertion fails.
  • test_end_to_end_stream_then_reload — drives the real graph._run_llm_iteration with chunks produced by the real StreamingMessage.to_workflow_message() (so streaming=True comes from production code, not a test literal), then persists the result and reads the store back. Pre-fix: no assistant text.
  • test_graph_iteration_accumulates_nothing_from_a_streamed_reply pins the precondition (messages == [] while llm_response holds the full reply).
  • Guards against over-writing: empty reply writes nothing; an identical existing turn is not duplicated; the llm_response="" call from _persist_error_turn (graph.py:1297) is unchanged.

Lint/format on the changed files:

flake8 --max-line-length 120  -> exit 0
black  --line-length 120 --check -> 2 files would be left unchanged
isort  --profile black --line-length 120 --check-only -> exit 0

Not verifiable without executing code: the live Redis round-trip, and that the reloaded turn renders identically in the GUI. MessageItem.vue:359 strips TOOL_CALL markup at render, so a tool-using turn's persisted text renders the same as it streamed.

Known limitation, in the same blast radius as the bug: the persisted final turn carries sources: []. KB citations live only on the discarded streaming chunks' metadata and are not available at the persist call without a signature change. Nothing regresses — pre-fix there was no assistant turn at all — and a follow-up issue is filed for citation parity on reload.

Model Used

claude-opus-5

mrveiss added 2 commits August 2, 2026 14:20
Streamed LLM chunks are stamped metadata.streaming=True by
StreamingMessage.to_workflow_message and are deliberately dropped by both
workflow accumulators (graph._run_llm_iteration and
_collect_llm_iteration_response), whose comments state the complete reply
'is persisted in _persist_workflow_messages'. It was not: that method
accepted llm_response and never referenced it, so a conversational streamed
turn built an empty batch and chat:session:* read back user turns only.

Persist llm_response as the final assistant turn, skipping empty replies and
exact duplicates already in the batch (e.g. the respond tool).
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No hardcoded values detected that have SSOT config equivalents!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant