Repository navigation
fix(translator): strip orphaned tool results across request formats - #5805
Conversation
Client-side history truncation/compaction can drop the assistant turn
that issued a tool call while leaving its stale tool result behind.
Strict upstream APIs reject the whole request before model execution
with a 400 ("Messages with role 'tool' must be a response to a
preceding message with 'tool_calls'").
9router already had the two other legs of the tool-call/result pairing
invariant: ensureToolCallIds (well-formed ids) and
fixMissingToolResponses (every call has a result). This adds the
missing reverse invariant: every result has a call.
- open-sse/translator/helpers/toolCallHelper.ts: new
stripOrphanedToolResults(body), handling both OpenAI role:"tool"
messages and Claude-shaped tool_result content blocks; drops a user
message entirely if stripping empties its content array; no-op fast
path returns the same body reference.
- open-sse/translator/index.ts: wire stripOrphanedToolResults
immediately after fixMissingToolResponses at both call sites, so
every route that pivots through the canonical intermediate format is
covered.
The Responses API -> OpenAI direction was checked and is already
covered (see the `allToolCallIds` post-filter in
openai-responses.ts, hardened under #2893) — no change needed there;
added regression tests pinning that existing behavior down explicitly.
Co-authored-by: warelik <54947489+warelik@users.noreply.github.com>
Inspired-by: decolua/9router#2237
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
There was a problem hiding this comment.
Code Review
This pull request introduces the stripOrphanedToolResults helper function to remove tool results that lack a matching tool call in the conversation history, along with comprehensive unit tests. The reviewer pointed out a critical issue where tool messages with missing or empty tool_call_ids would bypass the check and be preserved in the payload, and provided a code suggestion to ensure these are correctly stripped.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if (msg.role === "tool" && msg.tool_call_id) { | ||
| if (knownCallIds.has(msg.tool_call_id)) { | ||
| filteredMessages.push(msg); | ||
| } else { | ||
| changed = true; | ||
| } | ||
| continue; | ||
| } |
There was a problem hiding this comment.
The current check msg.role === "tool" && msg.tool_call_id will skip any tool message that has a missing, null, or empty tool_call_id. This means such invalid/orphaned tool messages will fall through and be preserved in the final payload, which strict upstream APIs will reject with a 400 error.
To align with the hardening previously done in openai-responses.ts (which strips any tool result with a missing/empty ID), we should check msg.role === "tool" first, and then strip it if it lacks a valid matching ID in knownCallIds.
| if (msg.role === "tool" && msg.tool_call_id) { | |
| if (knownCallIds.has(msg.tool_call_id)) { | |
| filteredMessages.push(msg); | |
| } else { | |
| changed = true; | |
| } | |
| continue; | |
| } | |
| if (msg.role === "tool") { | |
| if (msg.tool_call_id && knownCallIds.has(msg.tool_call_id)) { | |
| filteredMessages.push(msg); | |
| } else { | |
| changed = true; | |
| } | |
| continue; | |
| } |
…iegosouzapw#5805) Integrated into release/v3.8.43
Summary
Client-side history truncation/compaction can leave a stale tool result in a request after the matching tool call was dropped from history. Strict upstream APIs reject the whole request before model execution with a 400 (e.g. "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'").
The translator already enforced two thirds of the tool-call/result pairing invariant:
ensureToolCallIds— make call ids well-formedfixMissingToolResponses— repair the inverse case (a call exists but its result is missing)This adds the missing third leg: every tool result must reference a tool call still present in the same request.
Note: the Responses API → OpenAI direction was checked first and is already covered by an existing post-filter in
openai-responses.ts(hardened previously for empty/missing call ids) — no change was needed there. Only the two genuinely missing sites were ported.Changes
open-sse/translator/helpers/toolCallHelper.ts: newstripOrphanedToolResults(body), mirroringfixMissingToolResponseson the result side. Collects known call ids fromassistant.tool_calls[](OpenAI) andtool_usecontent blocks (Claude), then drops:role:"tool"messages with a staletool_call_idtool_resultcontent blocks with a staletool_use_idopen-sse/translator/index.ts: wirestripOrphanedToolResultsimmediately afterfixMissingToolResponsesat both call sites (source-format pass and final target-format pass), so every route that pivots through the canonical intermediate format is covered.Attribution
Thanks to @warelik for the original implementation.
Testing
tests/unit/translator-toolcall-helper.test.ts— matched OpenAItool_calls/role:toolpreserved; orphanrole:toolstripped; zero-call truncation strips all stale results; matched Claude-shapedtool_use/tool_resultpreserved; orphan Claude-shapedtool_resultstripped from mixed content while sibling text is preserved; a user message left with only an orphantool_resultis dropped entirely; no-op returns the same body reference; mixed matched+orphan keeps only the matched result.tests/unit/translator-request-openai-responses.test.ts— regression coverage pinning down the already-correct Responses→OpenAI orphan-stripping behavior (matched preserved, orphan stripped, zero-function-call truncation, mixed matched+orphan).tests/unit/translator-*.test.ts,translator-toolcall-helper,reasoning-cache*,reasoning-replay-big-pickle,xai-translators) — all pass, no regressions.npx eslinton all touched/added files — clean.npm run typecheck:core— clean project-wide.