Repository navigation
fix(streaming): announce the chat completions assistant message once - #4275
Merged
seratch merged 1 commit intoAug 7, 2026
Merged
Conversation
A Chat Completions stream whose assistant message carries both a text part and a refusal part emitted two response.output_item.added events for that single message, and only one response.output_item.done. Both content-part openers unconditionally announced a fresh in-progress assistant message, even when the sibling part had already opened the same item at the same output_index. Announce the message only from the opener that holds content index 0, which is the part that actually opens it. Text-only and refusal-only streams are unchanged.
4 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Component:
src/agents/models/chatcmpl_stream_handler.py—ChatCmplStreamHandler.handle_stream, the assistant-message content-part branches.Problem. A Chat Completions stream whose assistant message carries both a text part and a refusal part emits two
response.output_item.addedevents for that single message, and only oneresponse.output_item.done. Both content-part openers (delta.contentanddelta.refusal) unconditionally construct a fresh in-progressResponseOutputMessageand announce it, even when the sibling part already opened that same message at the sameoutput_index.The both-parts case is not hypothetical: the handler explicitly supports it — each branch offsets its
content_indexby the sibling part (content_index += 1/refusal_index += 1), and #4236 just landed to make the completed response respect those indexes. Non-OpenAI providers routed through this handler (LiteLLM, any-llm, and other OpenAI-compatible chat backends, which all shareChatCmplStreamHandler) are where acontentdelta and arefusaldelta arrive in the same turn.Impact. Consumers of the raw streamed events (
raw_response_event) that track open output items keyed byoutput_indexsee an item opened twice and closed once. Reconstructing the response from the raw stream yields a duplicate assistant message, or leaves one item permanently open. The Responses API — which this handler emulates — emits exactly oneresponse.output_item.addedper output item.RunResultis unaffected, because it reads the finalresponse.completedpayload.Repro (before this change):
The same duplication happens in the mirror order (content chunk, then refusal chunk).
After: exactly one
response.output_item.added, still emitted before the firstresponse.content_part.added; both content parts are still announced with their existing indexes.Root cause
Each opener owns "announce the message" as well as "announce my content part". The message is only ever opened by whichever content part comes first, so the second opener must announce only its content part.
Fix and why it is minimal
Guard the
ResponseOutputItemAddedEventin both branches on the content index that was just computed two lines above: index0means no sibling part has opened this message yet, so this branch is the one that opens it. No new state field, no new helper, no change tocontent_indexassignment, to thecontent_part.addedevents, to the delta events, or to the completed response. The diff is oneifin each branch plus the re-indentation of the block it now wraps.Deliberately unchanged:
handle_streamalready runs only when neither part opened, so it keeps its single announcement.content_part.doneemission order. In the refusal-then-text case those two events are still emitted text-first (index 1 before index 0). That is a separate ordering question from the duplicate-announcement defect fixed here, and I left it out to keep this change to one behavior. Happy to send it separately if you'd like it.Test plan
New regression test
test_stream_handler_announces_assistant_message_once_for_text_and_refusal, parametrized over both orders (refusal_then_text,text_then_refusal). It asserts one messageadded, one messagedone, matchingoutput_index, that the singleaddedstill precedes the firstcontent_part.added, and that both content parts are still announced at indexes0and1.upstream/mainfor the right reason, both parameters:AssertionError: assert 2 == 1on the count of messageaddedevents.Commands run from the repository root at
8b810bc4:uv run pytest tests/models/test_openai_chatcompletions_stream.py -q— 89 passed (87 pre-existing + the 2 new parameters), no existing assertion changed.uv run pytest -n auto --maxprocesses=9 --dist worksteal -m "not serial" -q— 6819 passed, 29 skipped.make tests-serial— passed.make lint(uv run ruff check) —All checks passed!.make format— no reformatting needed for the changed files.make typecheck(mypy+pyright) — passed.git diff --check— clean.Verification note: an earlier run of
.agents/skills/code-change-verification/scripts/run.shhit one failure intests/extensions/memory/test_advanced_sqlite_session.py::test_branch_allocation_is_serialized_across_processes[1-shared_branch], a multiprocessingEvent.wait(timeout=30)in a test unrelated to this change. It passes in isolation (4 passed) and in the full parallel suite above; it timed out only whilemake lintandmake typecheckwere saturating the machine in parallel. No code in this PR is reachable from that test.Issue number
None — reporting and fixing directly, per the repository's guidance for small fixes. Follows up on the same both-parts scenario addressed by #4236.
Checks
.agents/skills/code-change-verification/scripts/run.sh/reviewbefore submitting this PR