Skip to content

bug(chat): reloaded streamed replies lose their KB citations — persisted assistant turn has empty sources #13292

Closed

Description

@mrveiss

Context

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.

Related

Activity

  1. mrveiss commented on Aug 2, 2026

    @mrveiss
    OwnerAuthor

    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.

  2. github-actions commented on Aug 2, 2026

    @github-actions
    Contributor

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

    fix(chat): reload fidelity for streamed replies — model/sources, turn order, backfill (#13292, #13293, #13295, #13296)

    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 #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.

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