From 5e4d6e02fc2212a9578e9fcfd740d81b10d14b13 Mon Sep 17 00:00:00 2001 From: AmirF194 Date: Wed, 8 Jul 2026 13:28:59 -0600 Subject: [PATCH] fix(models): don't offset streamed content_index by the reasoning item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chat Completions stream handler computed each assistant content part's content_index by counting the reasoning item, but reasoning is emitted as a separate output item (its own output_index), not a content part of the assistant message. So when a response streams reasoning followed by text (or a refusal) — e.g. reasoning models routed through the Chat Completions adapter such as deepseek-reasoner — the text part was streamed with content_index=1 while it lands at message.content[0] in the final message and in what the OpenAI Responses API emits for the same output. A consumer reconstructing the message via message.content[event.content_index] writes to the wrong slot. Drop the reasoning increment in both the text and refusal branches. The text<->refusal cross-increment (both are real content parts of the message) is unchanged, and the final message assembly appends parts in order without using these indices, so only the streamed events change. Regression test fails on main (content_index=1) and passes with the fix; the stream + reasoning suites stay green (62 passed). --- src/agents/models/chatcmpl_stream_handler.py | 9 +-- .../test_openai_chatcompletions_stream.py | 62 +++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/agents/models/chatcmpl_stream_handler.py b/src/agents/models/chatcmpl_stream_handler.py index 052ecfc568..59410b86db 100644 --- a/src/agents/models/chatcmpl_stream_handler.py +++ b/src/agents/models/chatcmpl_stream_handler.py @@ -679,9 +679,10 @@ async def handle_stream( # Handle regular content if delta.content is not None: if not state.text_content_index_and_output: + # content_index is the part's position within this message's content + # list. The reasoning item is a separate output item (its own + # output_index), so it must not shift the content index here. content_index = 0 - if state.reasoning_content_index_and_output: - content_index += 1 if state.refusal_content_index_and_output: content_index += 1 @@ -755,9 +756,9 @@ async def handle_stream( # This is always set by the OpenAI API, but not by others e.g. LiteLLM if hasattr(delta, "refusal") and delta.refusal: if not state.refusal_content_index_and_output: + # The reasoning item is a separate output item, so it must not shift + # this content index (see the text branch above). refusal_index = 0 - if state.reasoning_content_index_and_output: - refusal_index += 1 if state.text_content_index_and_output: refusal_index += 1 diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index 81e7242855..871e92d73f 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -804,6 +804,68 @@ async def test_stream_handler_places_text_after_existing_refusal_part() -> None: assert assistant_item.content[1].refusal == "blocked" +@pytest.mark.asyncio +async def test_stream_handler_content_index_excludes_reasoning_item() -> None: + # Reasoning is emitted as its own output item (with its own output_index), not as a + # content part of the assistant message. So the message's text/refusal parts must be + # streamed at the content_index they occupy in message.content (0 and 1), independent + # of any preceding reasoning item. Regression: the streamed content_index used to be + # bumped by the reasoning item, diverging from both the final message and the index the + # OpenAI Responses API emits for the same output. + chunks = [ + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[ + Choice(index=0, delta=ChoiceDelta.model_construct(reasoning_content="thinking")) + ], + ), + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="answer"))], + ), + ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta.model_construct(refusal="blocked"))], + ), + ] + + events = await _collect_handler_events(*chunks) + + text_part_added = next( + event + for event in events + if event.type == "response.content_part.added" + and isinstance(event.part, ResponseOutputText) + ) + refusal_part_added = next( + event + for event in events + if event.type == "response.content_part.added" + and isinstance(event.part, ResponseOutputRefusal) + ) + + # The reasoning item lives at output[0]; the assistant message at output[1]. + completed_event = next(event for event in events if event.type == "response.completed") + assistant_item = completed_event.response.output[1] + assert isinstance(assistant_item, ResponseOutputMessage) + assert isinstance(assistant_item.content[0], ResponseOutputText) + assert isinstance(assistant_item.content[1], ResponseOutputRefusal) + + # The streamed content_index must match each part's position in message.content, + # unaffected by the separate reasoning output item. + assert text_part_added.content_index == 0 + assert refusal_part_added.content_index == 1 + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_stream_response_passes_strict_validation_to_stream_handler(monkeypatch) -> None: