Description
agent-framework-ag-ui renders streamed model reasoning ("thinking") as a separate AG-UI reasoning block per delta whenever the chat client emits reasoning content without a stable id. The reasoning emitter keys each block on content.id or generate_event_id(), so id=None deltas each get a fresh id, closing and reopening the block on every chunk.
A CopilotKit (or any AG-UI) frontend then shows one "Thought" accordion per token batch instead of a single expandable reasoning block for the turn.
This is an internal inconsistency in ag-ui: its text emitter already tolerates id=None (it keys on a turn-level flow.message_id and ignores content.id), so streamed answer text correctly coalesces into one message — but the reasoning emitter does not, even though it already tracks an equivalent turn-level flow.reasoning_message_id.
Reproduced with agent-framework-ollama (whose streamed text_reasoning chunks carry id=None), but it affects any provider that streams reasoning without per-chunk ids.
Code Sample
1. Wrap a harness agent backed by a streaming "thinking" Ollama model in `AgentFrameworkAgent` and expose it via `add_agent_framework_fastapi_endpoint`.
2. Stream a run that makes the model think for more than one chunk (any non-trivial prompt).
3. Observe the AG-UI event stream (or a CopilotKit client).
Error Messages / Stack Traces
**Observed events** — the `Start → Content → End` triplet repeats once per reasoning delta, each with a different `message_id`:
REASONING_START message_id=r1
REASONING_MESSAGE_START message_id=r1 role=reasoning
REASONING_MESSAGE_CONTENT message_id=r1 delta="The user wants"
REASONING_MESSAGE_END message_id=r1
REASONING_END message_id=r1
REASONING_START message_id=r2 # next delta -> brand-new block
REASONING_MESSAGE_CONTENT message_id=r2 delta=" to deploy an AI landing zone…"
REASONING_MESSAGE_END message_id=r2
…
The matching answer **text** in the same turn streams under a single `message_id` — the asymmetry that pinpoints the bug.
Package Versions
agent-framework-core:1.9.0, agent-framework-ag-ui:1.0.0rc5, agent-framework-ollama: 1.0.0b260521, ollama: 0.5.3
Python Version
Python 3.12
Additional Context
Expected
All reasoning deltas of a single assistant turn share one message_id, producing one REASONING_START → (many) REASONING_MESSAGE_CONTENT → REASONING_END block — mirroring how _emit_text coalesces streamed text. A subsequent turn (e.g. after a tool call) opens its own block.
Actual
One block per delta → the frontend renders a separate "Thought for a few seconds" accordion per chunk, with the reasoning text chopped mid-sentence across them.
Root cause
In python/packages/ag_ui/agent_framework_ag_ui/_run_common.py, the two emitters treat a missing content.id differently.
Text (tolerant — keys on turn-level id, ignores content.id):
def _emit_text(content, flow, skip_text=False):
...
if not flow.message_id:
flow.message_id = generate_event_id() # one id per turn
...
events.append(TextMessageContentEvent(message_id=flow.message_id, delta=content.text))
Reasoning (fragments — keys on per-content id):
def _emit_text_reasoning(content, flow=None):
...
message_id = content.id or generate_event_id() # <-- new id every delta when content.id is None
if flow is not None:
if flow.reasoning_message_id != message_id: # always true -> close + reopen each delta
events.extend(_close_reasoning_block(flow))
events.append(ReasoningStartEvent(message_id=message_id))
events.append(ReasoningMessageStartEvent(message_id=message_id, role="reasoning"))
flow.reasoning_message_id = message_id
...
When content.id is None, generate_event_id() returns a fresh value per call, so flow.reasoning_message_id != message_id is true on every delta and the block is closed/reopened each time. The streaming machinery (flow.reasoning_message_id, _close_reasoning_block) is already there to keep one block per turn; the emitter just discards it by re-keying on content.id.
Impact
- Any AG-UI consumer (CopilotKit, etc.) shows fragmented per-chunk reasoning cards instead of one expandable block — a visible UX regression for "thinking" models.
- Triggers for any provider whose streamed reasoning content lacks per-chunk ids;
Description
agent-framework-ag-uirenders streamed model reasoning ("thinking") as a separate AG-UI reasoning block per delta whenever the chat client emits reasoning content without a stableid. The reasoning emitter keys each block oncontent.id or generate_event_id(), soid=Nonedeltas each get a fresh id, closing and reopening the block on every chunk.A CopilotKit (or any AG-UI) frontend then shows one "Thought" accordion per token batch instead of a single expandable reasoning block for the turn.
This is an internal inconsistency in ag-ui: its text emitter already tolerates
id=None(it keys on a turn-levelflow.message_idand ignorescontent.id), so streamed answer text correctly coalesces into one message — but the reasoning emitter does not, even though it already tracks an equivalent turn-levelflow.reasoning_message_id.Reproduced with
agent-framework-ollama(whose streamedtext_reasoningchunks carryid=None), but it affects any provider that streams reasoning without per-chunk ids.Code Sample
Error Messages / Stack Traces
Package Versions
agent-framework-core:1.9.0, agent-framework-ag-ui:1.0.0rc5, agent-framework-ollama: 1.0.0b260521, ollama: 0.5.3
Python Version
Python 3.12
Additional Context
Expected
All reasoning deltas of a single assistant turn share one
message_id, producing oneREASONING_START → (many) REASONING_MESSAGE_CONTENT → REASONING_ENDblock — mirroring how_emit_textcoalesces streamed text. A subsequent turn (e.g. after a tool call) opens its own block.Actual
One block per delta → the frontend renders a separate "Thought for a few seconds" accordion per chunk, with the reasoning text chopped mid-sentence across them.
Root cause
In
python/packages/ag_ui/agent_framework_ag_ui/_run_common.py, the two emitters treat a missingcontent.iddifferently.Text (tolerant — keys on turn-level id, ignores
content.id):Reasoning (fragments — keys on per-content id):
When
content.id is None,generate_event_id()returns a fresh value per call, soflow.reasoning_message_id != message_idis true on every delta and the block is closed/reopened each time. The streaming machinery (flow.reasoning_message_id,_close_reasoning_block) is already there to keep one block per turn; the emitter just discards it by re-keying oncontent.id.Impact