Repository navigation
Chat Completions streaming: a reasoning item that arrives after the first text/tool chunk reuses output_index 0 and desynchronizes announced indexes from response.completed.output #5041
Description
Please read this first
- Have you read the docs? Yes — Agents SDK docs, plus the streaming handler source and
tests/models/test_openai_chatcompletions_stream.py. - Have you searched for related issues? Yes; closest prior work is Chat Completions streaming computes assistant output_index with a bool expression #3109 / fix: #3109 stabilize chat completions stream output indexes #3176 and fix(streaming): announce the chat completions assistant message once #4275 (all already merged/closed), which cover the assistant-message slot and the fallback tool-call slot, not a reasoning item created late in the stream. No open issue or PR matches this report.
Describe the bug
_StreamOutputLayout latches every output slot from _reasoning_output_count(state), which reports 1 only if a reasoning item already exists:
@staticmethod
def _reasoning_output_count(state: StreamingState) -> int:
return 1 if state.reasoning_content_index_and_output is not None else 0The reasoning item is created lazily, the first time a thinking block / reasoning_content / reasoning delta arrives (lines 726, 749, 805), and those events hardcode output_index=0. If a text or tool-call chunk has already been streamed, that slot is already taken. Consequences in response.completed.output:
- Two different items are announced with
output_index == 0(the assistant message or a function call, then the reasoning item). _reasoning_output_countflips from0to1after the layout has latched, soassistant_message_output_idx - _reasoning_output_count(state)becomes-1.function_calls_before_message(line 255) then doeslist(state.function_calls.values())[:-1], silently returning all but the last function call instead of an empty prefix, andfunction_calls_after_messagestarts at-1, i.e. at the first call.- The final assembly appends the reasoning item first unconditionally (line 1291), so an item announced at index
1or2lands at list position0.
Only the reasoning-first ordering (reasoning, text, …) is correct today; that is the only ordering currently covered by tests.
Debug information
- Agents SDK version: 0.22.2 (
pyproject.toml;src/agents/version.pyresolvesimportlib.metadata.version("openai-agents")) - Related library versions: not applicable — the repro drives
ChatCmplStreamHandlerwith syntheticChatCompletionChunks, no network and no API key - Python version: 3.13.15 (
uv run python -V) - Operating system: macOS 26.6.2 (Darwin 25.6.0)
- Model and model provider:
model="claude-sonnet-4-5"passed tohandle_stream; the provider delta fields exercised arereasoning_content(DeepSeek-style, lines 739-810) — the Anthropicthinking_blockspath (lines 714-737) and the third-partyreasoningpath (lines 787-810) all create the item the same lazy way - Does the issue reproduce with the latest Agents SDK release? Yes — reproduced on pristine
mainatfbf59a40e9da5adb88d370fefaeaae0478376d4a - Does the issue occur consistently or intermittently? Consistently, for any stream that emits reasoning after the first visible chunk
Reachability: OpenAIChatCompletionsModel.stream_response -> ChatCmplStreamHandler.handle_stream (src/agents/models/openai_chatcompletions.py:489). Any provider that interleaves thinking with text or tool calls is affected — e.g. Anthropic Claude 4 interleaved thinking, and DeepSeek-style reasoning_content streams that emit thinking between tool calls.
Repro steps
Self-contained; no network, no API key. Save as probe_matrix.py at the repository root and run uv run python probe_matrix.py.
"""Check that every announced output_index matches the item's position in response.completed.output."""
import asyncio
import itertools
from typing import Any, AsyncIterator, cast
from openai.types.chat import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice, ChoiceDelta
from openai.types.responses import Response
from openai.types.responses.response_completed_event import ResponseCompletedEvent
from agents.models.chatcmpl_stream_handler import ChatCmplStreamHandler
def _empty_response() -> Response:
return Response(
id="resp-id", created_at=0, model="fake-model", object="response",
output=[], tool_choice="none", tools=[], parallel_tool_calls=False,
)
async def _stream(*chunks: ChatCompletionChunk) -> AsyncIterator[ChatCompletionChunk]:
for chunk in chunks:
yield chunk
def _chunk(payload: dict[str, Any], finish_reason: str | None = None) -> ChatCompletionChunk:
return ChatCompletionChunk(
id="chunk-id", created=1, model="fake-model", object="chat.completion.chunk",
choices=[Choice(index=0, delta=ChoiceDelta.model_validate(payload),
finish_reason=cast(Any, finish_reason))],
)
TOOL = {"tool_calls": [{"index": 0, "id": "call_1", "type": "function",
"function": {"name": "lookup", "arguments": "{}"}}]}
TEXT = {"role": "assistant", "content": "hi"}
REASONING = {"reasoning_content": "think"}
def _key(item: Any) -> str:
return {"message": "text", "function_call": "tool", "reasoning": "reasoning"}.get(
item.type, item.type
)
async def check(order: tuple[str, ...]) -> None:
pieces = {"text": TEXT, "tool": TOOL, "reasoning": REASONING}
chunks = [_chunk(pieces[name]) for name in order]
chunks.append(_chunk({}, finish_reason="tool_calls"))
events = [e async for e in ChatCmplStreamHandler.handle_stream(
_empty_response(), cast(Any, _stream(*chunks)), model="claude-sonnet-4-5")]
announced = [(e.output_index, _key(e.item)) for e in events
if e.type == "response.output_item.added"]
completed = cast(ResponseCompletedEvent, events[-1])
final = [(i, _key(item)) for i, item in enumerate(completed.response.output)]
problems = []
if len(announced) != len(final):
problems.append(f"#added={len(announced)} != #output={len(final)}")
if sorted(announced) != sorted(final):
problems.append(f"announced={announced} final={final}")
print(f"order={order}: added={announced} final={final}"
f"{(' <-- ' + '; '.join(problems)) if problems else ' OK'}")
async def main() -> None:
for order in itertools.permutations(["text", "tool", "reasoning"]):
await check(order)
asyncio.run(main())Actual output
Verbatim from a run on pristine main at fbf59a40e9da5adb88d370fefaeaae0478376d4a (uv run python probe_matrix.py):
order=('text', 'tool', 'reasoning'): added=[(0, 'text'), (1, 'tool'), (0, 'reasoning')] final=[(0, 'reasoning'), (1, 'text'), (2, 'tool')] <-- announced=[(0, 'text'), (1, 'tool'), (0, 'reasoning')] final=[(0, 'reasoning'), (1, 'text'), (2, 'tool')]
order=('text', 'reasoning', 'tool'): added=[(0, 'text'), (0, 'reasoning'), (2, 'tool')] final=[(0, 'reasoning'), (1, 'text'), (2, 'tool')] <-- announced=[(0, 'text'), (0, 'reasoning'), (2, 'tool')] final=[(0, 'reasoning'), (1, 'text'), (2, 'tool')]
order=('tool', 'text', 'reasoning'): added=[(0, 'tool'), (1, 'text'), (0, 'reasoning')] final=[(0, 'reasoning'), (1, 'text'), (2, 'tool')] <-- announced=[(0, 'tool'), (1, 'text'), (0, 'reasoning')] final=[(0, 'reasoning'), (1, 'text'), (2, 'tool')]
order=('tool', 'reasoning', 'text'): added=[(0, 'tool'), (0, 'reasoning'), (2, 'text')] final=[(0, 'reasoning'), (1, 'tool'), (2, 'text')] <-- announced=[(0, 'tool'), (0, 'reasoning'), (2, 'text')] final=[(0, 'reasoning'), (1, 'tool'), (2, 'text')]
order=('reasoning', 'text', 'tool'): added=[(0, 'reasoning'), (1, 'text'), (2, 'tool')] final=[(0, 'reasoning'), (1, 'text'), (2, 'tool')] OK
order=('reasoning', 'tool', 'text'): added=[(0, 'reasoning'), (1, 'tool'), (2, 'text')] final=[(0, 'reasoning'), (1, 'tool'), (2, 'text')] OK
Two further observations from the same probes, on the multi-tool ordering text, reasoning, tool0, tool1:
announced=[(0, 'text'), (0, 'reasoning'), (2, 'tool'), (3, 'tool')]
final =[(0, 'reasoning'), (1, 'tool'), (2, 'text'), (3, 'tool')]
Here index 1 is never announced at all, and the tool call that was announced at 2 ends up at list position 1 — ahead of the assistant message — because the negative function_calls_before_message slice moved it. That reordering is visible in response.completed.output itself, not just in event indexes: a function_call is placed before the assistant message it was streamed after.
Expected behavior
For every response.output_item.added event, output_index should equal the index of that item in response.completed.response.output, and each index should be announced exactly once. All six orderings above should print OK.
Basis for that expectation, from this repository:
- The class's own contract —
_StreamOutputLayoutdocstring,src/agents/models/chatcmpl_stream_handler.py:196: "Tracks output slots that have been exposed to stream consumers." - The maintainer's PR fix: #3109 stabilize chat completions stream output indexes #3176 (seratch), which introduced this layout: "the fix centralizes stream output slot allocation in a small internal layout helper ... so final
response.completed.response.outputorder stays aligned with the emitted stream indexes." - The sibling implementation
src/agents/testing/model.py:841builds its streamed items withfor output_index, output_item in enumerate(output), i.e.output_indexis exactly the item's position in the completed output list. - Existing tests assert the same invariant:
test_fallback_function_call_before_text_uses_final_output_indexand thecontent_index matches its position in the completed responseassertion intests/models/test_openai_chatcompletions_stream.py(e.g.:3138,:3341-3346,:3555), andtests/models/test_reasoning_content.py:486(assert event.output_index == 1, the message's real position).
The Responses streaming protocol makes output_index the item's slot in the final response.output; a consumer that replays the stream into a response object (as this SDK's own testing model does) gets a different ordering than the one announced.
Root cause
src/agents/models/chatcmpl_stream_handler.py:205 (_StreamOutputLayout.assistant_message_output_index, with _reasoning_output_count at :202 and function_calls_before_message at :255).
The layout latches a slot using a count that only reflects whether a reasoning item exists at latch time, while the reasoning item is created lazily mid-stream (state.reasoning_content_index_and_output is assigned with the hardcoded slot 0 at lines 726, 749 and 805). Any reasoning item that appears after the first text or tool-call chunk therefore reuses slot 0, and because _reasoning_output_count then flips from 0 to 1, the derived assistant_message_output_idx - _reasoning_output_count(state) becomes -1; the [:count] slice at line 255 silently degrades to "all but the last" instead of raising. The final assembly at line 1291 appends the reasoning item first unconditionally, which is what makes the completed payload disagree with the announced indexes.
Approach I would take: allocate the reasoning item's index from _StreamOutputLayout at creation time (a latched reasoning_output_index(state)) rather than hardcoding 0 in the added/done/summary events (lines 726/735, 749, 805, 507-512, 575-604) and in the final assembly, and additionally clamp function_calls_before_message / function_calls_after_message with a non-negative count so a stale index can never produce a silent negative slice.
I'm happy to open a PR with that approach; tell me if you'd rather have it assigned or want a different shape for the fix.
Related: #3109 (closed) and #3176 (merged) stabilized the assistant-message/tool-call slots; #4275 (merged) addressed the assistant announcement; #3757 (closed) touched content_index offsetting. None of them cover a reasoning item created after other output. No open issue or PR in this repository matches this report, and no open PR currently touches src/agents/models/chatcmpl_stream_handler.py.
Activity
rygelouv commentedon Sep 15, 2026
I reproduced this locally on clean
mainatfbf59a4, macOS, Python 3.13. Yourprobe_matrix.pygives me byte-identical output — same four broken orderings, same two passing.I also traced the mechanism to be sure it's what you describe. It is. The negative slice is what actually moves items around:
So with two calls,
alphagets treated as "before the message" even though it streamed after.I found one case you didn't list. Reasoning arriving last breaks the same way:
Index
3never gets announced, and index0gets announced twice. Probably worth a test case.I've got a fix working, along the lines you suggested. All six orderings pass now, plus the multi-tool ones.
The clamp and the latched slot aren't enough on their own, though. Once reasoning can hold a slot other than 0, the slot arithmetic for the assistant message and for function calls both need to account for it too, or they collide with reasoning's new slot. And the final assembly has to stop putting reasoning first unconditionally.
I also had to be careful with
test_mixed_function_calls_before_text_keep_tracked_order— a fallback tool call is tracked early but announced last, and it has to keep its tracked position. My first attempt allocated slots purely in announcement order and broke that test.You said you'd be happy to open a PR. Do you want to take it? Happy to hand over what I have — I'd rather not duplicate your work. Otherwise I'll put one up shortly.
seratch commentedon Sep 17, 2026
Closing this for the reason I mentioned at #5042 (comment)