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: