Repository navigation
fix(chat): persist the completed streamed reply to chat history (#13214) - #13291
Merged
Merged
Conversation
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).
Contributor
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
This was referenced Aug 2, 2026
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
GET /api/chat/sessions/{id}->chat_history_manager.get_session_messages(api/chat_sessions.py:329,:244) ->chat:session:*(chat_history/session.py:84).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:1506returns_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:
chat_workflow/models.py:250—StreamingMessage.to_workflow_message()stampsmetadata["streaming"] = Trueunconditionally, on every chunk.chat_workflow/graph.py:468-471(graph path) andchat_workflow/manager.py:2378-2380(legacy path) — both accumulators keep only non-streaming messages:if not is_streaming: messages.append(...). Because_get_llm_request_payloadalways sets"stream": True(manager.py:1985), no assistant message ever survives intoworkflow_messagesfor a conversational turn. Both sites carry the comment "The final complete response is persisted in_persist_workflow_messages".chat_workflow/manager.py:2895-2965—_persist_workflow_messages(session_id, workflow_messages, llm_response)acceptedllm_responseand 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 Exceptionatmanager.py:2960never 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_build_final_response_entry— builds the assistant chat-history entry fromllm_response, using the samechat_mgr._build_message_dictthe surrounding batch already uses. ReturnsNonefor an empty reply, or when an entry with identical normalized text is already in the batch (therespondtool emits a non-streaming turn with the same content)._persist_workflow_messagesappends that entry to the batch before the singleadd_messages_batchwrite. One writer, one round-trip; no new persistence call sites.autobot-backend/chat_workflow/streamed_reply_persistence_test.py(new)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.pypre-fix_persist_workflow_messagesbody —llm_responseappears in the signature and nowhere else.grep -n "_store_and_log_ai_response(" api/chat.py->api/chat.py:790only (non-streaming).Regression test design (why it fails pre-fix):
_InMemoryChatHistorysubclasses the realchat_history.messages.MessagesMixin, so_build_message_dictandadd_messages_batchare production code; only the load/save seam is in-memory. Assertions read the session back viaload_session— the same accessorget_session_messagesuses. 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_batchis skipped,load_sessionreturns[], and thelen(assistant) == 1assertion fails.test_end_to_end_stream_then_reload— drives the realgraph._run_llm_iterationwith chunks produced by the realStreamingMessage.to_workflow_message()(sostreaming=Truecomes 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_replypins the precondition (messages == []whilellm_responseholds the full reply).llm_response=""call from_persist_error_turn(graph.py:1297) is unchanged.Lint/format on the changed files:
Not verifiable without executing code: the live Redis round-trip, and that the reloaded turn renders identically in the GUI.
MessageItem.vue:359stripsTOOL_CALLmarkup 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