Skip to content

Python: [Bug]: AG-UI: streamed reasoning fragments into one block per delta when the provider sends no per-chunk id #6787

Closed

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;

Activity

  1. added
    pythonUsage: [Issues, PRs], Target: Python
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Jun 28, 2026
  2. added
    reproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow
    on Jun 28, 2026
  3. github-actions commented on Jun 28, 2026

    @github-actions
    Contributor

    🤖 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's flow.reasoning_message_id != message_id to 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.

    • Failing test: python/packages/ag-ui/tests/ag_ui/test_reasoning_coalesce_bug.py
    • Files examined: python/packages/ag-ui/agent_framework_ag_ui/_run_common.py, python/packages/ag-ui/tests/ag_ui/test_run.py, python/packages/core/agent_framework/_types.py, python/packages/ag-ui/pyproject.toml
    • Tests run: python/packages/ag-ui/tests/ag_ui/test_reasoning_coalesce_bug.py::TestReasoningCoalescesWithoutContentId::test_multiple_reasoning_deltas_without_id_share_one_block (FAILED), python/packages/ag-ui/tests/ag_ui/test_reasoning_coalesce_bug.py::TestReasoningCoalescesWithoutContentId::test_text_emitter_coalesces_without_id_for_comparison (PASSED)
    • Reported version: 1.0.0rc5
    • Current version: 1.0.0rc6
  4. removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Jun 30, 2026
  5. antsok commented on Jul 7, 2026

    @antsok
    ContributorAuthor

    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, model z-ai/glm-5.2 (a reasoning model). OpenRouter streams reasoning in delta.reasoning,
    which the client surfaces as text_reasoning Content with id=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_START events, each with a distinct generated messageId that appears exactly once — i.e. _emit_text_reasoning's message_id = content.id or generate_event_id() mints a fresh id per delta and
    _close_reasoning_block fires 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 single REASONING_START with N REASONING_MESSAGE_CONTENT under one id. That pins the trigger to exactly content.id is None, and the id-tolerant emitter proposed above fixes it:

    # _emit_text_reasoning, streaming branch
    message_id = content.id or flow.reasoning_message_id or generate_event_id()
  6. moved this from In Review to Done in Agent Frameworkon Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

ag-uiUsage: [Issues, PRs], Target: AG-UI protocol integrationpythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions