Reloaded transcript inverts prose/tool-output order on tool-using turns #13295
Description
Symptom
On a tool-using turn, the reloaded transcript shows the assistant's prose after the tool output it introduced, inverting what the user watched stream.
Live order: LLM prose → tool output → respond.
Reload order: tool output → respond → LLM prose.
Cause
state["workflow_messages"] holds only the non-streaming messages (tool output, respond, approval — chat_workflow/graph.py:1092-1096). The completed streamed reply is synthesized separately and appended after that list in chat_workflow/manager.py:2988-2990, so it always sorts last regardless of when it was actually produced.
Not a regression
Before #13214 the prose was absent from chat:session:* entirely, so there was no ordering to get wrong. This is a gap exposed by the fix, not caused by it — and streamed_reply_persistence_test.py:155-165 (test_reply_appended_after_tool_output) currently enshrines append-last as intended behaviour. That test must be updated alongside any fix, or it will lock the inversion in.
Fix sketch
The streamed reply needs an ordering key. Options:
- Have the accumulator record the position at which the streamed reply would have landed, and insert there rather than append.
- Carry a monotonic sequence number on every
WorkflowMessage(streaming and not) and sort the batch before the singleadd_messages_batchwrite.
Option 2 is more work but fixes the class rather than this instance — approvals and terminal-interpretation turns take other paths into the same store and have the same latent problem.
Related
- bug(chat): streamed AutoBot replies are never persisted — session history stores user turns only, so every reply vanishes on reload #13214 — persist the streamed reply (the fix that exposed this)
- bug(chat): reloaded streamed replies lose their KB citations — persisted assistant turn has empty sources #13292 — the same persisted turn loses
sources/citations
Activity
fix(chat): address review blockers on #13303 - revert bad ordering fi…
fix(chat): reload fidelity for streamed replies — model/sources, turn…
fix(chat): tag every workflow message on the graph path, not only ter…
fix(chat): carry model and sources when no prose entry exists, drop b…
fix(chat): interleave per-iteration prose with tool output on reload (#…
github-actions commentedon Aug 3, 2026
PR #13334 (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 3, 2026
Fixed by PR #13334, squash-merged to
Dev_new_guias53d455ea6.This took three attempts, each blocked for a different false premise. Recording all three so a fourth does not repeat them.
manager.py:2841;_check_continuation_decisionat:2515-2555returns True after a normal tool execution), each appending its own prose, all collapsed by"\n\n".join(all_llm_responses). Front-insertion put the final answer before the tool output it was derived from.metadata["iteration"]only for_TERMINAL_MESSAGE_TYPES. But the production path is LangGraph (manager.py:3637-3645— legacy is only anexceptfallback), and the graph accumulates every non-streaming message. Nine other types went untagged, fell through toleftover, and landed after the answer — worse than base — while the untaggedrespondmessage became invisible to the dedup scan and produced a duplicated assistant turn, regressing bug(chat): streamed AutoBot replies are never persisted — session history stores user turns only, so every reply vanishes on reload #13214's single-reply invariant.respond-tool turn every iteration's prose is deduped away, so no entry carried them — silently regressing bug(chat): reloaded streamed replies lose their KB citations — persisted assistant turn has empty sources #13292 on exactly the path the PR's own new test exercises.What landed
Per-iteration interleaving:
_build_persist_batchwalks iterations 1..N emitting prose first, then that iteration's own tool entries, matching the live order (_run_continuation_iterationfully drains the prose generator before dispatching tool calls).Tagging is now unconditional at each
yieldin_process_tool_resultsand_collect_llm_iteration_response, plusgraph.execute_tools— which callsmanager._process_tool_callsdirectly and bypasses_handle_tool_message_typesentirely — withmetadatakeys added todeny_msgandabort_msg, which are plain dicts.Two knock-on fixes the review surfaced:
all_responses.append(llm_response)was guarded byif llm_response:whileiterationincremented unconditionally, so one falsy prose shifted every later iteration and filed iteration i+1's tool output under iteration i's prose.all_llm_responsescarry"", which would otherwise put a leading or interior"\n\n"intoconversation_history— which feeds the next turn's LLM context — plus the transcript file and two hooks.Verification
The decisive change was the end-to-end test: it monkeypatches only the innermost LLM-call and tool-dispatch primitives and lets
_process_tool_results,_handle_tool_message_types,_run_llm_iteration,_build_workflow_messages_from_stateand_persist_workflow_messagesall run for real.That matters because the earlier versions' tests hand-wrote
metadata={"iteration": 1}on aresponsemessage — a tag production never applies — so they asserted the implementation back to itself, and both blockers survived review.Independent probe pushing all 15 message types through the real accumulator:
TOTAL accumulated: 16 UNTAGGED: [].respond-tool turn end to end: exactly 2 assistant entries, no duplicate. Falsy iteration-1 prose: tool output correctly filed under iteration 1.443 passed across
chat_workflow/andchat_history/. Exactly oneadd_messages_batchcall site remains, preserving #13214's single-writer property.Related, not fixed
_filter_internal_prompts(#716/#11867) runs only on the legacy path;graph.py::persist_conversationnever filters, so the internal-prompt strip has never applied to the production path's persisted prose. Andmanager.py:2878-2882appends toall_llm_responsesbefore_emit_after_continuation, discarding that hook's edit on the legacy path.Closing manually with evidence —
Closes #Nonly fires on the default branch, and this PR targetedDev_new_gui.