Skip to content

fix(providers): reject unfinished Responses tool calls instead of running them - #348

Merged
liuedcson merged 1 commit into
mainfrom
fix/reject-unfinished-responses-tool-calls
Nov 7, 2026
Merged

liuedcson merged 1 commit into
mainfrom
fix/reject-unfinished-responses-tool-calls

Conversation

@liuedcson

Copy link
Copy Markdown
Collaborator

Background

An OpenAI Responses stream can finish while a tool call's output_item.done never arrived. processResponsesStream left 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 by output_index only, 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 with Duplicate 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_index omitted from every event. Every item shares the undefined slot, so the slot map rebuilds calls it already closed.
  • A closed output_index 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 block for the same call.

Captured frame order for the second shape:

added idx1 function_call  A
added idx2 function_call  B
done  idx0 reasoning
added idx1 message        <- reuses idx1
done  idx1 message
done  idx1 function_call  A   <- A had already been dropped from the slot map
done  idx2 function_call  B

Resulting assistant content: [thinking, toolCall A, toolCall B, text "", toolCall A]. The duplicate A, the leftover partialJson on 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 returns 200.

Fix

processResponsesStream now fails the stream when a toolUse result still holds an unfinished tool call:

  • packages/providers/src/api/openai-responses-shared.ts — after the terminal event, scan tool-call blocks for a surviving partialJson or customInput. 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 as 200.

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

  • 3 new cases in packages/providers/test/openai-responses-terminal-event.test.ts, built from the captured frame sequences: a missing output_item.done, parallel calls with no output_index, and a reused output_index.
  • Confirmed the tests fail against the previous implementation (3 failed) and pass with the fix.
  • packages/providers: 484 passed, 6 skipped, 67 files.
  • pnpm run check: passed (biome, type checks, boundary checks).
  • Live gateway replay: 400 before the change, 200 after.

./test.sh reports 112 failures in packages/coding-agent on this machine. That reproduces on the unmodified base commit too: the isolated test home points at a missing git wrapper ($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

  • Playback-side deduplication of already-corrupted sessions is out of scope. Sessions corrupted before this fix still fail; this change prevents new ones.
  • Empty text blocks emitted alongside the reused-index frames are left as-is; removing them changes stopReason derivation for phase: final_answer and belongs in a separate change.

…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
liuedcson merged commit cd321d0 into main Nov 7, 2026
2 checks passed
@liuedcson
liuedcson deleted the fix/reject-unfinished-responses-tool-calls branch November 7, 2026 05:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant