Repository navigation
Description
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;
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
github-actions commentedon Jun 28, 2026
🤖 Automated triage reproduction notes (agent-authored — trust but verify)
Agent analysis
Repro: _emit_text_reasoning() in python/packages/ag-ui/agent_framework_ag_ui/_run_common.py line 622 uses
content.id or generate_event_id()which generates a fresh UUID per delta when content.id is None, causing line 628'sflow.reasoning_message_id != message_idto always be True and reopening the reasoning block each call. Minimal repro: call _emit_text_reasoning three times with Content.from_text_reasoning(text=...) (no id) sharing one FlowState; observe 3 ReasoningStartEvents instead of 1. Fix should mirror _emit_text's pattern: fall back to flow.reasoning_message_id when content.id is None, only generating a new ID on the first delta of a turn.python/packages/ag-ui/tests/ag_ui/test_reasoning_coalesce_bug.py1.0.0rc51.0.0rc6antsok commentedon Jul 7, 2026
Also reproduces via
OpenAIChatCompletionClient(not just Ollama)Confirmed on
agent-framework-ag-ui 1.0.0rc7+agent-framework-core 1.10.0, using the OpenAI-compatible chat client (openai 2.44.0) pointed at OpenRouter, modelz-ai/glm-5.2(a reasoning model). OpenRouter streams reasoning indelta.reasoning,which the client surfaces as
text_reasoningContentwithid=None— the same trigger described above. So this isn't Ollama-specific; it hits any provider/path that streams reasoning without per-chunk ids, the OpenAI-compatible client included.Observed in a single conversation: hundreds of
REASONING_STARTevents, each with a distinct generatedmessageIdthat appears exactly once — i.e._emit_text_reasoning'smessage_id = content.id or generate_event_id()mints a fresh id per delta and_close_reasoning_blockfires between every token. A CopilotKit frontend then renders a wall of one-token "Thought for a few seconds" accordions; the reasoning text is intact but shattered one token per block, so each block looks empty.Isolating detail: in the same runs, the minority of reasoning deltas that do carry a provider id (
rs_…) coalesce correctly — a singleREASONING_STARTwith NREASONING_MESSAGE_CONTENTunder one id. That pins the trigger to exactlycontent.id is None, and the id-tolerant emitter proposed above fixes it: