Python: Stop emitting duplicate reasoning content from OpenAI response.reasoning_text.done and response.reasoning_summary_text.done events (#5162)

* Fix reasoning text done events duplicating streamed delta content (#5157)

The OpenAI Responses API sends both reasoning_text.delta (incremental
chunks) and reasoning_text.done (full accumulated text) events. The
chat client was emitting Content for both, causing ag-ui to append the
full done text onto already-accumulated delta text, producing
duplicated reasoning output.

Stop emitting Content for reasoning_text.done and
reasoning_summary_text.done events, matching how output_text.done is
already handled (not emitted). The deltas contain all the content;
the done event is redundant.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(openai): emit reasoning done content as fallback when no deltas observed (#5157)

Address PR review feedback:
- Track item_ids that received reasoning deltas via seen_reasoning_delta_item_ids set
- Emit content from done events only when no deltas were received for the
  item_id, preventing silent content loss on stream resumption
- Add comment documenting code_interpreter done event asymmetry
- Replace redundant ag-ui test with deduplication-focused test
- Add integration test for delta+done sequence in OpenAI chat client tests
- Add fallback path tests for done events without preceding deltas

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review feedback for #5157: Python: [Bug]: "type": "response.reasoning_text.delta" and "response.reasoning_text.done" both get exposed as "text_reasoning"

* Fix AG-UI reasoning streaming to use proper Start/End pattern (#5157)

_emit_text_reasoning now follows the same streaming pattern as _emit_text:
- Emits ReasoningStartEvent/ReasoningMessageStartEvent only on the first
  delta for a given message_id
- Emits only ReasoningMessageContentEvent for subsequent deltas
- Defers ReasoningMessageEndEvent/ReasoningEndEvent until
  _close_reasoning_block is called (on content type switch or end-of-run)

This produces the correct protocol pattern:
  ReasoningStartEvent
    ReasoningMessageStartEvent
    ReasoningMessageContentEvent(delta1)
    ReasoningMessageContentEvent(delta2)
    ReasoningMessageEndEvent
  ReasoningEndEvent

Instead of wrapping every delta in a full Start→End sequence.

Backward compatibility is preserved: calling _emit_text_reasoning without
a flow argument still produces the full sequence per call.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix import ordering lint error in AG-UI test file (#5157)

Move inline import of TextMessageContentEvent to the top-level import
block and ensure alphabetical ordering to satisfy ruff I001 rule.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix mypy error: rename loop variable to avoid type conflict with WorkflowEvent

The 'event' variable was already typed as WorkflowEvent[Any] from the
async for loop at line 590. Reusing it in the _close_reasoning_block
loop (which returns list[BaseEvent]) caused an incompatible assignment
error. Renamed to 'reasoning_evt' to avoid the conflict.

Fixes #5162

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review feedback for #5157: review comment fixes

* narrow test result reporting to explicit pytest JUnit XML

* Fix test args

* Fix pytest-results-action in merge workflow and remove committed test artifacts

Apply the same JUnit XML fix from python-tests.yml to python-merge-tests.yml:
add --junitxml=pytest.xml to all test commands and narrow the results action
path from ./python/**.xml to ./python/pytest.xml. Also remove accidentally
committed pytest.xml and python-coverage.xml and add them to .gitignore.

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Evan Mattson
2026-04-10 07:44:59 +09:00
committed by GitHub
Unverified
parent 1dd828d255
commit 5e8fe0be1f
15 changed files with 412 additions and 89 deletions
@@ -46,6 +46,7 @@ from ._orchestration._tooling import collect_server_tools, merge_tools, register
from ._run_common import (
FlowState,
_build_run_finished_event, # type: ignore
_close_reasoning_block, # type: ignore
_emit_content, # type: ignore
_extract_resume_payload, # type: ignore
_has_only_tool_calls, # type: ignore
@@ -1058,6 +1059,10 @@ async def run_agent_stream(
}
)
# Close any open reasoning block
for event in _close_reasoning_block(flow):
yield event
# Close any open message
if flow.message_id:
logger.debug(f"End of run: closing text message message_id={flow.message_id}")
@@ -128,6 +128,7 @@ class FlowState:
interrupts: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
reasoning_messages: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
accumulated_reasoning: dict[str, str] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
reasoning_message_id: str | None = None
def get_tool_name(self, call_id: str | None) -> str | None:
"""Get tool name by call ID."""
@@ -462,12 +463,39 @@ def _emit_mcp_tool_result(
return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler)
def _close_reasoning_block(flow: FlowState) -> list[BaseEvent]:
"""Close an open reasoning block, emitting end events.
Should be called when the reasoning block is complete -- e.g. when
non-reasoning content arrives or at end of a run.
"""
if not flow.reasoning_message_id:
return []
message_id = flow.reasoning_message_id
flow.reasoning_message_id = None
return [
ReasoningMessageEndEvent(message_id=message_id),
ReasoningEndEvent(message_id=message_id),
]
def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> list[BaseEvent]:
"""Emit AG-UI reasoning events for text_reasoning content.
Uses the protocol-defined reasoning event types so that AG-UI consumers
such as CopilotKit can render reasoning natively.
When *flow* is provided the function follows the streaming pattern: it
emits ``ReasoningStartEvent`` / ``ReasoningMessageStartEvent`` only on
the first delta for a given ``message_id`` and just
``ReasoningMessageContentEvent`` for subsequent deltas. The matching
``ReasoningMessageEndEvent`` / ``ReasoningEndEvent`` are deferred until
``_close_reasoning_block`` is called (e.g. when non-reasoning content
arrives or at end-of-run).
Without *flow* (backward-compat) the full Start→Content→End sequence is
emitted for every call.
Only ``content.text`` is used for the visible reasoning message. If
``content.protected_data`` is present it is emitted as a
``ReasoningEncryptedValueEvent`` so that consumers can persist encrypted
@@ -483,26 +511,49 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis
message_id = content.id or generate_event_id()
events: list[BaseEvent] = [
ReasoningStartEvent(message_id=message_id),
ReasoningMessageStartEvent(message_id=message_id, role="assistant"),
]
events: list[BaseEvent] = []
if text:
events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text))
if flow is not None:
# Streaming mode: track open reasoning block in flow state.
if flow.reasoning_message_id != message_id:
# Close any previously open reasoning block (different message_id).
events.extend(_close_reasoning_block(flow))
# Open new reasoning block.
events.append(ReasoningStartEvent(message_id=message_id))
events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant"))
flow.reasoning_message_id = message_id
events.append(ReasoningMessageEndEvent(message_id=message_id))
if text:
events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text))
if content.protected_data is not None:
events.append(
ReasoningEncryptedValueEvent(
subtype="message",
entity_id=message_id,
encrypted_value=content.protected_data,
if content.protected_data is not None:
events.append(
ReasoningEncryptedValueEvent(
subtype="message",
entity_id=message_id,
encrypted_value=content.protected_data,
)
)
)
else:
# No flow -- backward-compatible full sequence per call.
events.append(ReasoningStartEvent(message_id=message_id))
events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant"))
events.append(ReasoningEndEvent(message_id=message_id))
if text:
events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text))
events.append(ReasoningMessageEndEvent(message_id=message_id))
if content.protected_data is not None:
events.append(
ReasoningEncryptedValueEvent(
subtype="message",
entity_id=message_id,
encrypted_value=content.protected_data,
)
)
events.append(ReasoningEndEvent(message_id=message_id))
# Persist reasoning into flow state for MESSAGES_SNAPSHOT.
# Accumulate reasoning text per message_id, similar to flow.accumulated_text,
@@ -546,23 +597,30 @@ def _emit_content(
) -> list[BaseEvent]:
"""Emit appropriate events for any content type."""
content_type = getattr(content, "type", None)
# Close open reasoning block when switching to non-reasoning content.
if content_type != "text_reasoning":
events = _close_reasoning_block(flow)
else:
events = []
if content_type == "text":
return _emit_text(content, flow, skip_text)
return events + _emit_text(content, flow, skip_text)
if content_type == "function_call":
return _emit_tool_call(content, flow, predictive_handler)
return events + _emit_tool_call(content, flow, predictive_handler)
if content_type == "function_result":
return _emit_tool_result(content, flow, predictive_handler)
return events + _emit_tool_result(content, flow, predictive_handler)
if content_type == "function_approval_request":
return _emit_approval_request(content, flow, predictive_handler, require_confirmation)
return events + _emit_approval_request(content, flow, predictive_handler, require_confirmation)
if content_type == "usage":
return _emit_usage(content)
return events + _emit_usage(content)
if content_type == "oauth_consent_request":
return _emit_oauth_consent(content)
return events + _emit_oauth_consent(content)
if content_type == "mcp_server_tool_call":
return _emit_mcp_tool_call(content, flow)
return events + _emit_mcp_tool_call(content, flow)
if content_type == "mcp_server_tool_result":
return _emit_mcp_tool_result(content, flow, predictive_handler)
return events + _emit_mcp_tool_result(content, flow, predictive_handler)
if content_type == "text_reasoning":
return _emit_text_reasoning(content, flow)
logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type)
return []
return events
@@ -29,6 +29,7 @@ from ._message_adapters import normalize_agui_input_messages
from ._run_common import (
FlowState,
_build_run_finished_event,
_close_reasoning_block,
_emit_content,
_extract_resume_payload,
_normalize_resume_interrupts,
@@ -729,6 +730,9 @@ async def run_workflow_stream(
run_error_emitted = True
terminal_emitted = True
for reasoning_evt in _close_reasoning_block(flow):
yield reasoning_evt
for end_event in _drain_open_message():
yield end_event