Skip to content

Reply plugins: keep each choice of an n > 1 answer apart - #342

Merged
fylorn merged 1 commit into
mainfrom
fix/plugin-reply-choices
Oct 16, 2026
Merged

fylorn merged 1 commit into
mainfrom
fix/plugin-reply-choices

Conversation

@fylorn

@fylorn fylorn commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Problem

Chat Completions with n > 1 and Gemini with candidateCount > 1 carry several choices (candidates) in one answer, told apart by each choice's index. The reply plugin chain only looked at the first element of choices / candidates and sent all text through a single lane:

  • Text of interleaved choices went into one buffer. A block-mode text plugin received hel + wor + lo + ld as one block and wrote it back into choice 0 (HELWORLOLD).
  • Half a secret held back at the end of one choice's text was handed out at the head of the next text piece, which could belong to the other choice.
  • Chat tool call fragments were keyed by the tool call index only, so call 0 of choice 0 and call 0 of choice 1 were glued into one call (unparseable arguments), and choice 1 lost its call.
  • Choices after the first element in a stream frame, and every choice after the first in a whole body, never went through the plugins.

Fix

plugin/reply/chat.rs and plugin/reply/gemini.rs now keep state per choice, keyed by the choice's index field (falling back to the array position when it is missing):

  • each choice has its own text lane (lane number = choice index) and its own open/closed state;
  • Chat: each choice has its own tool call buffer, renumbering and finish_reason rewrite; a finish_reason ends only that choice, while [DONE], error frames and the usage chunk still close every choice;
  • frames that carry several choices are handled choice by choice; a choice emptied by the plugins is removed from the frame, and the frame is dropped only when no choice is left;
  • synthesized frames (held text, rewritten tool calls) carry the right choice index;
  • whole bodies run every choice / candidate through the plugins.

reply/mod.rs is unchanged.

Tests

New tests in plugin/reply/tests (all four fail on main):

  • chat_choices_are_kept_apart_in_a_stream: two interleaved choices, each with text and tool calls; a block-mode text plugin plus a tool call rewriter. Each choice gets its own text and its own rewritten calls.
  • chat_choices_hold_back_apart_in_stream_mode: a stream-mode plugin with a known secret split across two frames of choice 0 and choice 1 text in between. The held half stays with choice 0.
  • gemini_candidates_are_kept_apart_in_a_stream: interleaved candidates, with a final frame carrying both candidates (including a function call in the second one).
  • whole_bodies_run_every_choice_through_the_plugins: Chat and Gemini whole bodies with two choices.

cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings and cargo test --workspace pass locally.

Not covered here: a reply plugin instance is still one per answer and onReplyText / onReplyTextEnd do not say which choice a piece belongs to, so a stream-mode plugin that buffers text in its own state still sees the choices interleaved.

🤖 Generated with Claude Code

Chat Completions with n > 1 and Gemini with candidateCount > 1 stream
several choices (candidates) in one answer, told apart by the `index`
field of each choice. The reply plugin chain read only the first element
of `choices` / `candidates` and sent all text through one lane, so:

- text of interleaved choices went into one buffer: a block-mode plugin
  got "hel" + "wor" + "lo" + "ld" as one block, and half a secret held
  back for one choice was handed out at the head of the other choice;
- Chat tool call fragments were keyed by the tool call index only, so
  call 0 of choice 0 and call 0 of choice 1 were glued into one call;
- choices after the first in a frame, and in a whole body, never went
  through the plugins at all.

Each choice now has its own state, keyed by its `index`: its own text
lane (the lane number is the choice index), its own tool call buffer
and numbering, and a `finish_reason` ends only that choice. `[DONE]`,
error frames and the usage chunk still close every choice. Whole bodies
run every choice through the plugins. Gemini candidates get the same
treatment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fylorn
fylorn force-pushed the fix/plugin-reply-choices branch from 4b45ba5 to 226d9b5 Compare October 16, 2026 09:28
@fylorn
fylorn merged commit 4271fb4 into main Oct 16, 2026
6 checks passed
@fylorn
fylorn deleted the fix/plugin-reply-choices branch October 16, 2026 09:46
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 16, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant