Skip to content

Reloaded transcript inverts prose/tool-output order on tool-using turns #13295

Closed

Description

@mrveiss

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:

  1. Have the accumulator record the position at which the streamed reply would have landed, and insert there rather than append.
  2. Carry a monotonic sequence number on every WorkflowMessage (streaming and not) and sort the batch before the single add_messages_batch write.

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

Activity

  1. github-actions commented on Aug 3, 2026

    @github-actions
    Contributor

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

    fix(chat): interleave per-iteration prose with tool output on reload (#13295)

    If this issue is fully resolved, close it manually. If work remains, no action is needed.

  2. mrveiss commented on Aug 3, 2026

    @mrveiss
    OwnerAuthor

    Fixed by PR #13334, squash-merged to Dev_new_gui as 53d455ea6.

    This took three attempts, each blocked for a different false premise. Recording all three so a fourth does not repeat them.

    1. PR fix(chat): reload fidelity for streamed replies — model/sources, turn order, backfill (#13292, #13293, #13295, #13296) #13303 inserted the reply at the front of the batch, assuming it is chronologically first. False for the common case: a tool-using turn normally runs two iterations (manager.py:2841; _check_continuation_decision at :2515-2555 returns 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.
    2. The first version of fix(chat): interleave per-iteration prose with tool output on reload (#13295) #13334 stamped metadata["iteration"] only for _TERMINAL_MESSAGE_TYPES. But the production path is LangGraph (manager.py:3637-3645 — legacy is only an except fallback), and the graph accumulates every non-streaming message. Nine other types went untagged, fell through to leftover, and landed after the answer — worse than base — while the untagged respond message 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.
    3. The second version attached the model badge and KB sources only to the final iteration's prose entry. On a 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_batch walks iterations 1..N emitting prose first, then that iteration's own tool entries, matching the live order (_run_continuation_iteration fully drains the prose generator before dispatching tool calls).

    Tagging is now unconditional at each yield in _process_tool_results and _collect_llm_iteration_response, plus graph.execute_tools — which calls manager._process_tool_calls directly and bypasses _handle_tool_message_types entirely — with metadata keys added to deny_msg and abort_msg, which are plain dicts.

    Two knock-on fixes the review surfaced:

    • Iteration↔index desync. all_responses.append(llm_response) was guarded by if llm_response: while iteration incremented unconditionally, so one falsy prose shifted every later iteration and filed iteration i+1's tool output under iteration i's prose.
    • Blank segments leaking into the joined prose. Fixing the desync makes all_llm_responses carry "", which would otherwise put a leading or interior "\n\n" into conversation_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_state and _persist_workflow_messages all run for real.

    That matters because the earlier versions' tests hand-wrote metadata={"iteration": 1} on a response message — 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/ and chat_history/. Exactly one add_messages_batch call 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_conversation never filters, so the internal-prompt strip has never applied to the production path's persisted prose. And manager.py:2878-2882 appends to all_llm_responses before _emit_after_continuation, discarding that hook's edit on the legacy path.

    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

    Labels

    bugSomething isn't workingtesting

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions