Skip to content

Python: preserve model emission order in AG-UI MESSAGES_SNAPSHOT#7239

Open
he-yufeng wants to merge 3 commits into
microsoft:mainfrom
he-yufeng:fix/ag-ui-snapshot-emission-order
Open

Python: preserve model emission order in AG-UI MESSAGES_SNAPSHOT#7239
he-yufeng wants to merge 3 commits into
microsoft:mainfrom
he-yufeng:fix/ag-ui-snapshot-emission-order

Conversation

@he-yufeng

Copy link
Copy Markdown
Contributor

Fixes #7223.

Problem

_build_messages_snapshot always emitted a turn's assistant text message after its tool-call message and tool results, regardless of the order the model produced them. Lead-in narration ("Let me research...") therefore rendered below the tool chips it introduces, and post-tool summaries only looked right by accident. The rc8 reasoning_messages accumulator had the same fixed-position issue.

Approach

Track segment boundaries as the run streams. FlowState gains a snapshot_segments list that the emit helpers append to: a text segment per opened text message, a tool-call segment per run of consecutive calls (shared by regular, MCP, and confirm_changes entries), and a reasoning segment per reasoning block. _build_messages_snapshot now emits those segments in recorded order, with tool results grouped after the tool-call message they answer.

The separate text/tool-call message split from #3619 is unchanged, and a tool-only turn keeps reusing the streamed message id. Flows that never went through the emit helpers fall back to the previous fixed layout, so nothing depending on it breaks.

Verification

  • pytest packages/ag-ui/tests — 829 passed. (test_endpoint.py / test_handoff_replay.py fail collection on missing optional agent_framework_orchestrations / fastapi deps; identical on the base commit.)
  • New tests cover lead-in before calls, text -> calls -> results -> text, tool-only id reuse, reasoning position, and the legacy fallback.
  • The existing test_snapshot_reasoning_ordering encoded the fixed-position layout this issue reports; updated it to the stream-faithful order.
  • pyright (package config): no new errors vs base. ruff check / format clean.

Copilot AI review requested due to automatic review settings July 21, 2026 15:19
@giles17 giles17 added the python Usage: [Issues, PRs], Target: Python label Jul 21, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Preserves the model’s emission order when constructing AG-UI MESSAGES_SNAPSHOT in the Python ag-ui package, so lead-in text, tool calls/results, and reasoning appear in the same sequence the model streamed them (fixing #7223) while keeping the separate assistant text vs tool-call message split from #3619.

Changes:

  • Added FlowState.snapshot_segments and streaming-time helpers to track ordered text/tool-calls/reasoning segments.
  • Updated _build_messages_snapshot to emit messages based on recorded segments (with legacy fallback when no segments exist).
  • Added/updated tests to assert correct snapshot ordering across mixed text/tool/reasoning scenarios.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
python/packages/ag-ui/tests/ag_ui/test_run.py Adds tests validating snapshot ordering, tool-only ID reuse, reasoning placement, and legacy fallback behavior.
python/packages/ag-ui/agent_framework_ag_ui/_run_common.py Introduces snapshot_segments and records segment boundaries during streaming emit helpers.
python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py Emits snapshot messages in recorded segment order via _append_segmented_snapshot_messages, with fallback to legacy behavior.

Comment on lines +1554 to +1564
calls = [
flow.tool_calls_by_id[call_id] for call_id in segment["call_ids"] if call_id in flow.tool_calls_by_id
]
if not calls:
continue
message_id = tool_open_id or generate_event_id()
tool_open_id = None
all_messages.append({"id": message_id, "role": "assistant", "tool_calls": [call.copy() for call in calls]})
segment_call_ids = set(segment["call_ids"])
emitted_call_ids.update(segment_call_ids)
all_messages.extend(result for result in flow.tool_results if result.get("toolCallId") in segment_call_ids)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, fixed in 24eb54f. Only actually-emitted calls get marked emitted now, so a stale segment id that never made it into tool_calls_by_id stays eligible for the leftover fallback instead of vanishing.

Comment on lines +1568 to +1578
leftover_calls = [tc for tc in flow.pending_tool_calls if tc.get("id") not in emitted_call_ids]
if leftover_calls:
all_messages.append(
{
"id": tool_open_id or generate_event_id(),
"role": "assistant",
"tool_calls": [call.copy() for call in leftover_calls],
}
)
emitted_call_ids.update(call_id for call in leftover_calls if (call_id := call.get("id")) is not None)
all_messages.extend(result for result in flow.tool_results if result.get("toolCallId") not in emitted_call_ids)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 24eb54f. The leftover path now appends the results for those calls right after the message, so they are not excluded from the final append and dropped.

@github-actions

Copy link
Copy Markdown
Contributor

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/ag-ui/agent_framework_ag_ui
   _agent_run.py113812788%184–191, 238–239, 246, 355, 359, 361, 378, 405–406, 519, 533, 537, 541, 544, 549, 554, 563, 566, 573–579, 612, 624, 635, 638, 673, 727–731, 796, 811, 814, 816, 842, 868–870, 928, 930, 932, 935–939, 953, 961–966, 975–976, 1025–1028, 1039, 1047, 1079, 1094, 1108, 1120, 1150, 1154, 1157, 1159, 1199–1201, 1265, 1271–1272, 1277, 1281–1282, 1332–1334, 1346–1348, 1446, 1454, 1471, 1475, 1520, 1558, 1570, 1577, 1631, 1661–1662, 1784, 1930, 1990, 2007, 2027–2028, 2035, 2143, 2171, 2179, 2181, 2184, 2190, 2245, 2248, 2258–2259, 2266, 2313
   _run_common.py5894891%76, 139–140, 142, 144, 147, 153, 155, 169, 176, 200–201, 214, 233, 235, 269, 313, 325, 327, 329, 332–336, 514, 785–786, 792–797, 1108–1109, 1114, 1116–1118, 1127, 1135, 1147, 1149–1152, 1215
TOTAL45154509688% 

Python Unit Test Overview

Tests Skipped Failures Errors Time
9213 33 💤 0 ❌ 0 🔥 2m 23s ⏱️

"""Build MessagesSnapshotEvent from current flow state."""
all_messages = list(snapshot_messages)

if flow.snapshot_segments:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this preserve accumulated_text when no text segment exists? A valid async stream can emit a function-call-only update, which preopens flow.message_id without _open_text_segment, then a text update before a tool result. _emit_text streams and accumulates that text but records no segment, so this return omits it from MESSAGES_SNAPSHOT and persisted thread history.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, that path was real. The tool-only detection preopens message_id without going through _emit_text, so the first text had no segment and got dropped. Fixed in 24eb54f: _emit_text now opens a text segment whenever the current message id has none, and there's a regression test for exactly this shape (tool call first, text after).


def _track_tool_call_segment(flow: FlowState, tool_call_id: str) -> None:
"""Record a tool call in the current tool segment, opening one if needed."""
if flow.snapshot_segments and flow.snapshot_segments[-1]["kind"] == "tool_calls":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could a tool result close the current tool-call segment? For call A -> result A -> call B, this appends B to A because emitting A's result does not change snapshot_segments; the snapshot becomes calls(A,B) -> results(A,B). That rewrites a dependent call as preceding A's result, corrupting history replay on later turns.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 24eb54f. A tool result now closes the current tool-call segment, so call A -> result A -> call B snapshots as [calls(A), result(A), calls(B), result(B)] instead of grouping B with A. There's a test asserting that exact ordering.

- Preopened message ids (tool-only path) now open a text segment when
  the first text arrives, so their content can't drop out of the snapshot.
- A tool result closes the current tool-call segment, so
  call A -> result A -> call B snapshots as two pairs in stream order.
- emitted_call_ids only marks calls actually emitted, keeping stale
  segment ids eligible for the leftover fallback.
- The leftover path carries its tool results too instead of dropping them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: AG-UI snapshot places assistant text after tool calls, discarding the model's emission order

4 participants