Description
With an AG-UI snapshot store enabled, a Workflow run's intermediate reasoning renders live and then disappears when the thread is hydrated. The same run through the agent path keeps it. Raised by Evan Mattson (@moonbox3) in review on #8003 and confirmed there; opening it separately because the fix touches the snapshot/hydration path rather than the event-emission path that PR corrects.
All line references below are against main @ 48032eb.
The reasoning is recorded but never read. _emit_text_reasoning in _run_common.py:1051 — which _emit_content delegates to for text_reasoning content — persists each reasoning message into flow.reasoning_messages. Its docstring states the purpose (_run_common.py:1073-1075):
When flow is provided the reasoning message is persisted into flow.reasoning_messages so that _build_messages_snapshot can include it in the final MESSAGES_SNAPSHOT.
The agent runner honours that contract. _build_messages_snapshot (_agent_run.py:2100-2102) ends with:
# Add reasoning messages so frontends that reconcile state from
# MESSAGES_SNAPSHOT retain reasoning content after streaming ends.
all_messages.extend(flow.reasoning_messages)
and emits it terminally (_agent_run.py:3202-3212) whenever flow.reasoning_messages is non-empty, among other conditions.
The workflow runner does not. _workflow_run.py calls the same _emit_content (lines 1276 and 1283), so it populates flow.reasoning_messages — but it contains no reference to MessagesSnapshotEvent or _build_messages_snapshot at all. The list is filled and never consumed.
There are two independent gaps, and both drop reasoning:
- No authoritative snapshot.
run_workflow_stream never emits a MessagesSnapshotEvent on any of its terminal paths, so there is nothing for the store to persist as the reconciled message list.
- The synthesized fallback ignores reasoning.
_WorkflowSnapshotBuilder.observe (_workflow.py:130-167) folds StateSnapshotEvent, MessagesSnapshotEvent, RunFinishedEvent, then TextMessage* and ToolCall* events. Reasoning events are not handled in any branch, so the synthesized path drops them even when the streamed events carried them.
Because of (1) the builder always falls through to the synthesized path, and because of (2) that path has no reasoning. Live output and replayed output therefore disagree.
Code Sample
Host a workflow whose executor yields reasoning content, with a snapshot store attached:
from fastapi import FastAPI
from agent_framework.ag_ui import InMemoryAGUIThreadSnapshotStore, add_agent_framework_fastapi_endpoint
app = FastAPI()
add_agent_framework_fastapi_endpoint(app, workflow, "/", snapshot_store=InMemoryAGUIThreadSnapshotStore())
Run a turn that produces intermediate reasoning, then reload the thread. The reasoning is present in the streamed events and absent from the hydrated snapshot. The same workflow exposed through AgentFrameworkAgent(agent=workflow.as_agent()) retains it, because that path runs the agent runner.
Package Versions
agent-framework-ag-ui on main (verified at 48032eb).
Python Version
3.13, though the gap is version-independent — it is a missing branch, not a runtime behaviour.
Additional Context
I am going to take this on, with Evan Mattson (@moonbox3)'s go-ahead. In the #8003 review they named the two possible shapes — "teaching _WorkflowSnapshotBuilder to fold these events, or emitting the same terminal snapshot as the agent runner" — and on 2026-09-04 confirmed a follow-up PR is the right home for it: "Follow up is fine, thanks."
Those two suggestions map exactly onto the two gaps above: folding reasoning events addresses (2), emitting a terminal snapshot addresses (1). I would like a steer on which shape you want before writing the code, because one finding changes the trade-off between them.
Finding: for workflows, the observed event stream is strictly more complete than flow. This is the part that makes the choice non-obvious, so to be precise about what does and does not carry over:
What would carry over: on the workflow path _emit_content still routes through _emit_text and _emit_text_reasoning, which populate flow.snapshot_segments (_run_common.py:538-541, :579-582). Segments are self-contained — _emit_text stores segment["text"] = flow.accumulated_text on each delta (_run_common.py:626) — so they survive _drain_open_message (_workflow_run.py:1109-1116) clearing flow.message_id / flow.accumulated_text at each superstep boundary. _append_segmented_snapshot_messages already has a kind == "reasoning" branch (_agent_run.py:2041-2042). So _build_messages_snapshot would in fact reproduce this turn's text and reasoning correctly.
What would not: events the workflow runner yields directly, never passing them through _emit_content, and which therefore never touch flow at all.
- request_info / interrupt tool calls —
_workflow_run.py:1094-1096 and :1252-1254 yield raw ToolCall* events, so they never reach flow.tool_calls_by_id or flow.pending_tool_calls.
- Executor-produced passthrough events (
_workflow_run.py:1263-1273) are yielded as-is.
_WorkflowSnapshotBuilder captures both via observe; _build_messages_snapshot would not. That matters more than it first looks, because an emitted MessagesSnapshotEvent is treated as authoritative and short-circuits the synthesized path that does capture them — so emitting one built from flow alone would fix reasoning while regressing request_info tool calls in the hydrated thread.
So the three options are:
(a) Fold reasoning into _WorkflowSnapshotBuilder.observe. Add Reasoning* branches alongside the existing TextMessage* / ToolCall* handling, with snapshot entries shaped as the agent path already produces them — {"id", "role": "reasoning", "content", ["encryptedValue"]} (_run_common.py:1148-1161). Smallest change, and correct regardless of which flow fields the workflow runner happens to populate, because it reads the real event stream.
(b) Emit a terminal MessagesSnapshotEvent from the workflow runner. Keeps the two runners symmetrical, but per the finding above it cannot just call _build_messages_snapshot — it would need to fold in the events that bypass flow, or it trades one dropped-content bug for another. observe latches on MessagesSnapshotEvent (_workflow.py:138-140, :153-154) and build() prefers it (:172), so whatever is emitted becomes the whole hydrated thread.
(c) Both. Closes both gaps; most thorough, most work.
My recommendation is (a) — it fixes the user-visible symptom with the least surface area and no new duplication of the agent runner's message-assembly logic. Happy to do (c) if you would rather close gap (1) as well, in which case I would land (a) first so the synthesized path is correct on its own.
One aside, unrelated to reasoning and not something I plan to touch here unless you want it: built.session_state is always None, because _WorkflowSnapshotBuilder.build() (_workflow.py:169-173) constructs AGUIThreadSnapshot without it while the save call passes it through (_workflow.py:486-491).
Description
With an AG-UI snapshot store enabled, a
Workflowrun's intermediate reasoning renders live and then disappears when the thread is hydrated. The same run through the agent path keeps it. Raised by Evan Mattson (@moonbox3) in review on #8003 and confirmed there; opening it separately because the fix touches the snapshot/hydration path rather than the event-emission path that PR corrects.All line references below are against
main@ 48032eb.The reasoning is recorded but never read.
_emit_text_reasoningin_run_common.py:1051— which_emit_contentdelegates to fortext_reasoningcontent — persists each reasoning message intoflow.reasoning_messages. Its docstring states the purpose (_run_common.py:1073-1075):The agent runner honours that contract.
_build_messages_snapshot(_agent_run.py:2100-2102) ends with:and emits it terminally (
_agent_run.py:3202-3212) wheneverflow.reasoning_messagesis non-empty, among other conditions.The workflow runner does not.
_workflow_run.pycalls the same_emit_content(lines 1276 and 1283), so it populatesflow.reasoning_messages— but it contains no reference toMessagesSnapshotEventor_build_messages_snapshotat all. The list is filled and never consumed.There are two independent gaps, and both drop reasoning:
run_workflow_streamnever emits aMessagesSnapshotEventon any of its terminal paths, so there is nothing for the store to persist as the reconciled message list._WorkflowSnapshotBuilder.observe(_workflow.py:130-167) foldsStateSnapshotEvent,MessagesSnapshotEvent,RunFinishedEvent, thenTextMessage*andToolCall*events. Reasoning events are not handled in any branch, so the synthesized path drops them even when the streamed events carried them.Because of (1) the builder always falls through to the synthesized path, and because of (2) that path has no reasoning. Live output and replayed output therefore disagree.
Code Sample
Host a workflow whose executor yields reasoning content, with a snapshot store attached:
Run a turn that produces intermediate reasoning, then reload the thread. The reasoning is present in the streamed events and absent from the hydrated snapshot. The same workflow exposed through
AgentFrameworkAgent(agent=workflow.as_agent())retains it, because that path runs the agent runner.Package Versions
agent-framework-ag-ui on
main(verified at 48032eb).Python Version
3.13, though the gap is version-independent — it is a missing branch, not a runtime behaviour.
Additional Context
I am going to take this on, with Evan Mattson (@moonbox3)'s go-ahead. In the #8003 review they named the two possible shapes — "teaching
_WorkflowSnapshotBuilderto fold these events, or emitting the same terminal snapshot as the agent runner" — and on 2026-09-04 confirmed a follow-up PR is the right home for it: "Follow up is fine, thanks."Those two suggestions map exactly onto the two gaps above: folding reasoning events addresses (2), emitting a terminal snapshot addresses (1). I would like a steer on which shape you want before writing the code, because one finding changes the trade-off between them.
Finding: for workflows, the observed event stream is strictly more complete than
flow. This is the part that makes the choice non-obvious, so to be precise about what does and does not carry over:What would carry over: on the workflow path
_emit_contentstill routes through_emit_textand_emit_text_reasoning, which populateflow.snapshot_segments(_run_common.py:538-541,:579-582). Segments are self-contained —_emit_textstoressegment["text"] = flow.accumulated_texton each delta (_run_common.py:626) — so they survive_drain_open_message(_workflow_run.py:1109-1116) clearingflow.message_id/flow.accumulated_textat each superstep boundary._append_segmented_snapshot_messagesalready has akind == "reasoning"branch (_agent_run.py:2041-2042). So_build_messages_snapshotwould in fact reproduce this turn's text and reasoning correctly.What would not: events the workflow runner yields directly, never passing them through
_emit_content, and which therefore never touchflowat all._workflow_run.py:1094-1096and:1252-1254yield rawToolCall*events, so they never reachflow.tool_calls_by_idorflow.pending_tool_calls._workflow_run.py:1263-1273) are yielded as-is._WorkflowSnapshotBuildercaptures both viaobserve;_build_messages_snapshotwould not. That matters more than it first looks, because an emittedMessagesSnapshotEventis treated as authoritative and short-circuits the synthesized path that does capture them — so emitting one built fromflowalone would fix reasoning while regressing request_info tool calls in the hydrated thread.So the three options are:
(a) Fold reasoning into
_WorkflowSnapshotBuilder.observe. AddReasoning*branches alongside the existingTextMessage*/ToolCall*handling, with snapshot entries shaped as the agent path already produces them —{"id", "role": "reasoning", "content", ["encryptedValue"]}(_run_common.py:1148-1161). Smallest change, and correct regardless of whichflowfields the workflow runner happens to populate, because it reads the real event stream.(b) Emit a terminal
MessagesSnapshotEventfrom the workflow runner. Keeps the two runners symmetrical, but per the finding above it cannot just call_build_messages_snapshot— it would need to fold in the events that bypassflow, or it trades one dropped-content bug for another.observelatches onMessagesSnapshotEvent(_workflow.py:138-140,:153-154) andbuild()prefers it (:172), so whatever is emitted becomes the whole hydrated thread.(c) Both. Closes both gaps; most thorough, most work.
My recommendation is (a) — it fixes the user-visible symptom with the least surface area and no new duplication of the agent runner's message-assembly logic. Happy to do (c) if you would rather close gap (1) as well, in which case I would land (a) first so the synthesized path is correct on its own.
One aside, unrelated to reasoning and not something I plan to touch here unless you want it:
built.session_stateis alwaysNone, because_WorkflowSnapshotBuilder.build()(_workflow.py:169-173) constructsAGUIThreadSnapshotwithout it while the save call passes it through (_workflow.py:486-491).