Skip to content

Python: Preserve per-choice message boundaries when streaming chat completions (n>1) - #9780

Open
Wilson_Jiang (Wilson0511) wants to merge 2 commits into
microsoft:mainfrom
Wilson0511:fix/chat-completion-multi-choice-message-boundaries
Open

Wilson_Jiang (Wilson0511) wants to merge 2 commits into
microsoft:mainfrom
Wilson0511:fix/chat-completion-multi-choice-message-boundaries

Conversation

@Wilson0511

@Wilson0511 Wilson_Jiang (Wilson0511) commented Oct 28, 2026 •

Copy link
Copy Markdown

Fixes #9779

Description

With n > 1, chat completion chunks interleave deltas from independent alternative completions. The streaming parser merged every choice of a chunk into one update tagged with the shared chunk id, so ChatResponse.from_updates concatenated the alternatives' texts in arrival order into a single message, while the non-streaming parser emits one assistant Message per choice.

The fix has two halves:

  1. Per-choice updates (agent_framework_openai): _parse_response_updates_from_openai parses one chunk into one update per choice. Choice 0 keeps the provider chunk id as its message_id; the other choices get a {chunk.id}-{index} id so their message boundaries stay distinct. The live stream keeps provider arrival order. The old _parse_response_update_from_openai stays as a thin back-compat wrapper that merges the per-choice updates (its single-choice behavior is unchanged).
  2. Id-aware aggregation (agent_framework_core): _process_update now returns an update to the message that already carries its message_id instead of starting a new message for every interleaved fragment. Author and role boundaries still start a new message regardless of the id, and contiguous id sequences keep the previous behavior, so FunctionInvocationLayer, Agent, and direct from_updates callers all preserve per-choice grouping. Updates keep flowing in arrival order, so response-level usage snapshots apply in arrival order.

RegressionCheck

  • Before, the streamed final response for two choices was ['A1B1A2B2']; after, ['A1A2', 'B1B2'], matching _parse_response_from_openai. Verified through the raw client, the public client (function-invocation path), and ChatResponse/AgentResponse from_updates.
  • n=1 unchanged: single update per chunk, message_id == chunk.id, one message.
  • Usage keeps arrival-order semantics with mixed usage/content chunks followed by a trailing usage-only chunk (last snapshot wins).
  • Per-choice tool-call identity scoping (tool_call_choice_index) unaffected.
  • pytest tests/core/ and pytest tests/openai/ on this branch: failure sets byte-identical to main (pre-existing environment failures only; zero new). New tests: test_streaming_multiple_choices_preserve_message_boundaries, test_public_client_streaming_multiple_choices_preserve_message_boundaries, test_streaming_multiple_choices_keep_usage_in_arrival_order, test_streaming_single_choice_message_id_matches_provider_chunk_id, test_from_updates_rejoins_interleaved_updates_that_share_a_message_id (chat + agent), test_from_updates_keeps_usage_in_arrival_order_across_interleaved_message_ids.

🤖 Generated with ZCode
Co-Authored-By: ZCode noreply@zcode.dev

…mpletions (n>1)

With n>1, chat completion chunks interleave deltas from independent
alternative completions. The streaming parser merged every choice of a
chunk into one update tagged with the shared chunk id, so
ChatResponse.from_updates concatenated the alternatives' texts in
arrival order into a single message, while the non-streaming parser
emits one assistant Message per choice.

Parse each chunk into one update per choice (choice-scoped message id
for choices beyond the first), and group the accumulated updates by
message id at finalize time so each choice aggregates into its own
message. The live stream keeps provider arrival order; n=1 behavior
is unchanged.

🤖 Generated with ZCode
Co-Authored-By: ZCode <noreply@zcode.dev>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Public client and agent finalizers still fail to preserve per-choice message grouping, while regrouping updates can overwrite the latest usage total with an older snapshot.

2 open issues
What changed in this PR

This PR addresses #9779 by preserving separate completion choices in Python’s OpenAI Chat Completions streaming adapter, bringing raw streamed responses closer to non-streaming behavior.

Changes:

  • Emit one update per choice with distinct message IDs while preserving live arrival order.
  • Group updates by message ID during finalization and retain the legacy single-update parser wrapper.
  • Add regressions for multi-choice text boundaries, trailing usage, and single-choice message IDs.
File Description
python/​packages/​openai/​tests/​openai/​test_openai_chat_completion_client.py Adds multi-choice aggregation and single-choice compatibility tests.
python/​packages/​openai/​agent_framework_openai/​_chat_completion_client.py Adds per-choice parsing and message grouping during stream finalization.

Review effort: Balanced

grouped: dict[str, list[ChatResponseUpdate]] = {message_id: [] for message_id in first_seen_order}
for update in updates:
grouped[cast("str", update.message_id)].append(update)
updates = [update for message_id in first_seen_order for update in grouped[message_id]]

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.

Fixed in 0295ecd: the client-side finalize regrouping is removed entirely. Updates now keep flowing into ChatResponse.from_updates in arrival order, so replace-mode usage snapshots apply strictly in arrival order regardless of which choice's chunk carried them. Added test_streaming_multiple_choices_keep_usage_in_arrival_order covering exactly this shape (choice-1 usage+content chunk with total 10, then a usage-only chunk with total 15 → final total is 15), plus the core-level test_from_updates_keeps_usage_in_arrival_order_across_interleaved_message_ids.

Comment on lines +1092 to +1093
updates = [update for message_id in first_seen_order for update in grouped[message_id]]
return super()._finalize_response_updates(updates, response_format=response_format)

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.

Fixed in 0295ecd: the grouping moved into core _process_update — when an update's message_id names a message that already exists in the response, the update returns to that message instead of starting a new one. Author and role boundaries still start a new message regardless of the id, and contiguous id sequences keep today's behavior, so the existing author/role boundary tests are unchanged. Because FunctionInvocationLayer and Agent both aggregate through from_updates, per-choice grouping now survives those outer finalizers. Added test_public_client_streaming_multiple_choices_preserve_message_boundaries (public client, function-invocation path) and test_from_updates_rejoins_interleaved_updates_that_share_a_message_id, parametrized over both ChatResponse and AgentResponse.

@Wilson0511

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

…nt finalizer

Review follow-up on microsoft#9780: grouping updates inside the chat client's
_finalize_response_updates fixed the direct finalizer path only.
FunctionInvocationLayer and Agent re-aggregate the streamed updates with
ChatResponse.from_updates directly, so interleaved choices still
shattered into one message per fragment on the public path, and
regrouping updates reordered response-level usage snapshots so an older
replace-mode total could win.

Move the grouping into _process_update: when an update's message id
names a message that already exists, the update returns to that message
instead of starting a new one. Author and role boundaries still start a
new message regardless of the id, and contiguous id sequences keep
today's behavior. Updates keep flowing in arrival order, so usage
snapshots apply in arrival order again. The client-side finalize
regrouping is dropped; the per-choice updates stay.

🤖 Generated with ZCode
Co-Authored-By: ZCode <noreply@zcode.dev>

This branch was previously deployed

1 inactive deployment
github-app-auth — 0295ecd3 Deployed Oct 28, 2026 by Wilson0511 via add_label #26377
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: Streamed chat completions with n>1 merge all choices' texts into one message

2 participants