You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
bug(chat): reloaded streamed replies lose their KB citations — persisted assistant turn has empty sources #13292
Discovered while fixing #13214 (streamed replies were never persisted to the GUI-facing store; PR #13291).
The fix persists the completed streamed reply as an assistant turn in chat:session:*, but writes it with sources: [].
Why
KB citations for a streamed reply live only in the metadata of the streaming chunks (_build_stream_chunk_message -> metadata["citations"], built by _build_source_list). Those chunks are deliberately dropped by both accumulators (chat_workflow/graph.py:468-471, chat_workflow/manager.py:2378-2380), so by the time _persist_workflow_messages runs, the citation list is gone.
The existing batch loop already extracts KB-only citations into a top-level sources list (chat_workflow/manager.py, #4448) for the non-streaming messages it does keep. The final streamed turn cannot use that path.
Effect
A knowledge-base-grounded reply renders with its sources while streaming, and reloads without them. Nothing regressed in #13214 — before that fix there was no assistant turn at all — but the reloaded turn is not yet faithful to what the user saw.
Suggested fix
Thread the citations that are already in scope at both persist call sites into _persist_workflow_messages:
graph path: state.get("rag_citations", []) — available at chat_workflow/graph.py:1356
legacy path: ctx.rag_citations — available at chat_workflow/manager.py:3239
Add an optional keyword to _persist_workflow_messages and reuse the existing KB-only filter that builds sources. Note the test double at chat_workflow/internal_prompt_filter_test.py:121 declares the method positionally and needs updating alongside.
Acceptance: a KB-grounded streamed reply reloads with the same sources list it displayed during streaming.
Review of #13291 confirms this issue's diagnosis and its proposed fix (state["rag_citations"] / ctx.rag_citations), including the note about the positional test double at internal_prompt_filter_test.py:121.
Widening the scope by one field — the model badge is lost the same way.
MessageItem.vue:12 renders message.metadata.model for assistant turns. Streamed chunks carry it (chat_workflow/manager.py:453, :628), but the persisted entry's metadata is only {"message_type": "llm_response", "streamed": True} (manager.py:2923). So a reloaded turn shows no model badge where the streamed one did. Same root cause, same one-line-ish fix, same blast radius — worth doing in one pass with the citations rather than as a separate issue. used_knowledge is in the same position.
Also verified while reviewing — the render path itself is fine, but not for the reason #13291's body claimed.
The PR body said MessageItem.vue:359 strips TOOL_CALL markup at render. The regex at MessageItem.vue:360 is /<tool_call[^>]*>.*?<\/tool_call>/gs — case-sensitive and lowercase-only, so it does not match the canonical uppercase <TOOL_CALL …> the backend emits (tool_call_grammar.py:88-97). The conclusion still holds: DOMPurify's ALLOWED_TAGS (utils/sanitize.ts:19-32) strips the unknown element while keeping its inner text, and does so identically for the live stream — so persisted and streamed render the same. Only the stated mechanism was wrong.
That case-sensitivity gap is a genuine latent bug (MessageItem.vue:360 and ChatMessages.vue:908 handle only lowercase; ChatController.ts:553-554 handles both). It is currently masked by DOMPurify and is not worth its own issue while masked — recording it here so it is not rediscovered from scratch if the sanitizer config ever changes.
Fixed by PR #13303, squash-merged to Dev_new_gui as 36c96b9f4.
_build_final_response_entry hardcoded sources: [] and never wrote metadata.model. Both lived only on the streaming chunks that the accumulators discard (manager.py:456, :632, :673), so a reloaded assistant turn had no KB citations and no model badge — while the same turn showed both while it streamed.
Fixed by threading selected_model / rag_citations / used_knowledge from the LLMIterationContext (legacy loop) and from graph state (LangGraph path). Review verified both paths are covered — a fix on only one would have left this half-done, since graph.py:334-337 writes all three into ChatState from the same context.
Also folded in #13296: extracted _kb_sources_from_citations() and _build_workflow_message_batch()/_build_persist_batch() out of _persist_workflow_messages, and decomposed graph.py::persist_conversation.
Verification
460 passed across chat_workflow/, chat_history/ and the chat API tests. Pre-fix red state (production reverted, tests kept): 10 failures, including TypeError: unexpected keyword argument 'selected_model' on every threading test.
What this PR deliberately did NOT close
Review found the first attempt at #13295 was built on a false premise, and the #13293 backfill module had two defects — both caught by the reviewer running the code rather than reading it. Details on those issues; they remain open.
Closing manually with evidence — Closes #N only fires on the default branch, and this PR targeted Dev_new_gui.
Activity
mrveiss commentedon Aug 2, 2026
Review of #13291 confirms this issue's diagnosis and its proposed fix (
state["rag_citations"]/ctx.rag_citations), including the note about the positional test double atinternal_prompt_filter_test.py:121.Widening the scope by one field — the model badge is lost the same way.
MessageItem.vue:12rendersmessage.metadata.modelfor assistant turns. Streamed chunks carry it (chat_workflow/manager.py:453,:628), but the persisted entry's metadata is only{"message_type": "llm_response", "streamed": True}(manager.py:2923). So a reloaded turn shows no model badge where the streamed one did. Same root cause, same one-line-ish fix, same blast radius — worth doing in one pass with the citations rather than as a separate issue.used_knowledgeis in the same position.Also verified while reviewing — the render path itself is fine, but not for the reason #13291's body claimed.
The PR body said
MessageItem.vue:359stripsTOOL_CALLmarkup at render. The regex atMessageItem.vue:360is/<tool_call[^>]*>.*?<\/tool_call>/gs— case-sensitive and lowercase-only, so it does not match the canonical uppercase<TOOL_CALL …>the backend emits (tool_call_grammar.py:88-97). The conclusion still holds: DOMPurify'sALLOWED_TAGS(utils/sanitize.ts:19-32) strips the unknown element while keeping its inner text, and does so identically for the live stream — so persisted and streamed render the same. Only the stated mechanism was wrong.That case-sensitivity gap is a genuine latent bug (
MessageItem.vue:360andChatMessages.vue:908handle only lowercase;ChatController.ts:553-554handles both). It is currently masked by DOMPurify and is not worth its own issue while masked — recording it here so it is not rediscovered from scratch if the sanitizer config ever changes.fix(chat): address review blockers on #13303 - revert bad ordering fi…
fix(chat): reload fidelity for streamed replies — model/sources, turn…
github-actions commentedon Aug 2, 2026
PR #13303 (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 #13303, squash-merged to
Dev_new_guias36c96b9f4._build_final_response_entryhardcodedsources: []and never wrotemetadata.model. Both lived only on the streaming chunks that the accumulators discard (manager.py:456,:632,:673), so a reloaded assistant turn had no KB citations and no model badge — while the same turn showed both while it streamed.Fixed by threading
selected_model/rag_citations/used_knowledgefrom theLLMIterationContext(legacy loop) and from graphstate(LangGraph path). Review verified both paths are covered — a fix on only one would have left this half-done, sincegraph.py:334-337writes all three intoChatStatefrom the same context.Also folded in #13296: extracted
_kb_sources_from_citations()and_build_workflow_message_batch()/_build_persist_batch()out of_persist_workflow_messages, and decomposedgraph.py::persist_conversation.Verification
460 passed across
chat_workflow/,chat_history/and the chat API tests. Pre-fix red state (production reverted, tests kept): 10 failures, includingTypeError: unexpected keyword argument 'selected_model'on every threading test.What this PR deliberately did NOT close
Review found the first attempt at #13295 was built on a false premise, and the #13293 backfill module had two defects — both caught by the reviewer running the code rather than reading it. Details on those issues; they remain open.
Closing manually with evidence —
Closes #Nonly fires on the default branch, and this PR targetedDev_new_gui.