Repository navigation
fix(providers): reject unfinished Responses tool calls instead of running them - #348
Merged
Merged
Conversation
…ning them An OpenAI Responses stream can finish while a tool call's `output_item.done` never arrived. The adapter left that call in the assistant message as runnable, so the agent loop executed it with whatever arguments had accumulated. The trigger is a non-compliant server. Both failure shapes below were captured from a live OpenAI-compatible gateway: - `output_index` is omitted from every event, so every item shares the `undefined` slot and the slot map rebuilds calls it already closed. - A closed `output_index` is reused by a later item, then the earlier call's `output_item.done` arrives. The slot map no longer holds that index, so it creates a second content block for the same call. Either way one assistant message ends up with two tool-call blocks that carry the same `call_id`. The tool runs twice, and every later turn fails replay validation with `Duplicate value for 'tool_call_id'`, so the session cannot be continued. `processResponsesStream` now fails the stream when a `toolUse` result still holds an unfinished call. Finalized calls drop their scratch buffers, so a surviving `partialJson` or `customInput` is the signal. The turn ends as an error, and error turns are skipped on replay, so the next turn starts from a clean history instead of a corrupted one. This mirrors the upstream fix for earendil-works/pi#9974. Verified against a live gateway: the same history that returned 400 `Duplicate 'call_id'` now replays as 200 because the failed turn is dropped.
liuedcson
requested review from
MelodyVAR,
Uking-xxx and
ZouR-Ma
as code owners
November 7, 2026 05:09
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.
Background
An OpenAI Responses stream can finish while a tool call's
output_item.donenever arrived.processResponsesStreamleft that call in the assistant message as runnable, so the agent loop executed it with whatever arguments had accumulated. Because the adapter keys its output slots byoutput_indexonly, a non-compliant server can also produce two content blocks for the same call: the same tool call is executed twice, then every later turn fails replay validation withDuplicate value for 'tool_call_id'and the session cannot be continued.Both failure shapes were captured from a live OpenAI-compatible gateway (14 sample requests, 10 of them malformed):
output_indexomitted from every event. Every item shares theundefinedslot, so the slot map rebuilds calls it already closed.output_indexreused by a later item, then the earlier call'soutput_item.donearrives. The slot map no longer holds that index, so it creates a second block for the same call.Captured frame order for the second shape:
Resulting assistant content:
[thinking, toolCall A, toolCall B, text "", toolCall A]. The duplicateA, the leftoverpartialJsonon the first block, and the empty text block are all produced by the same path.Reproduced against a real gateway: the raw session history returns
400 Duplicate value for 'tool_call_id', and the same history with the duplicate block removed returns200.Fix
processResponsesStreamnow fails the stream when atoolUseresult still holds an unfinished tool call:packages/providers/src/api/openai-responses-shared.ts— after the terminal event, scan tool-call blocks for a survivingpartialJsonorcustomInput. Finalized calls drop those scratch buffers, so a surviving buffer means this call never completed. Throw instead of handing it to the agent loop.The turn now ends as
stopReason: "error". Error turns are skipped on replay (transform-messages.ts), so the next turn starts from a clean history instead of a corrupted one. Confirmed against the live gateway: the previously failing history now replays as200.This mirrors the upstream fix for earendil-works/pi#9974. Playback-side deduplication is intentionally not included here; it would mask the provider defect rather than fail fast on it.
Verification
packages/providers/test/openai-responses-terminal-event.test.ts, built from the captured frame sequences: a missingoutput_item.done, parallel calls with nooutput_index, and a reusedoutput_index.packages/providers: 484 passed, 6 skipped, 67 files.pnpm run check: passed (biome, type checks, boundary checks).400before the change,200after../test.shreports 112 failures inpackages/coding-agenton this machine. That reproduces on the unmodified base commit too: the isolated test home points at a missinggitwrapper ($TMPDIR/step-test.*/home/.local/share/git/bin/git). Not related to this change; the same suites pass when run directly from the package.Boundary
stopReasonderivation forphase: final_answerand belongs in a separate change.