Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/agents/models/chatcmpl_stream_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Comment on lines +759 to 761

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep refusal indexes aligned when text arrives later

When the stream order is reasoning -> refusal -> text, removing the reasoning offset here makes the refusal part emit at content_index=0; the later text branch then emits text at content_index=1 because a refusal already exists. However response.completed is still assembled with text before refusal (assistant_msg.content.append(text) then append(refusal)), so raw stream consumers reconstruct the parts swapped relative to the final message. This affects exactly the refusal-before-text shape the handler already supports, now with a preceding reasoning item.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I checked this and the text/refusal swap is pre-existing, not introduced here. This PR only removes the reasoning-item offset; the text <-> refusal cross-increment in both branches is untouched. In the reasoning -> refusal -> text order: on main refusal streams at content_index=1 and text at 2; on this branch refusal at 0 and text at 1. In both cases the streamed order is refusal-then-text while the final message assembles text-then-refusal (lines 1044-1047 append text before refusal unconditionally), so the parts are swapped relative to the stream either way. This change only shifts the absolute indices down by one; it doesn't create or widen that gap.

It's worth fixing, but it's a separate concern (the final-assembly order vs arrival order), so I've kept this PR to the reasoning offset. Happy to open a follow-up to align the both-text-and-refusal case if you'd like.

if state.reasoning_content_index_and_output:
refusal_index += 1
if state.text_content_index_and_output:
refusal_index += 1

Expand Down
62 changes: 62 additions & 0 deletions tests/models/test_openai_chatcompletions_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down