Skip to content

fix(responses-api): tool call after a text message collided on the same output_index - #9822

Closed
hartmark wants to merge 1 commit into
cheaper-inference:release/v3.8.50from
hartmark:fix/responses-message-toolcall-index-collision
Closed

hartmark wants to merge 1 commit into
cheaper-inference:release/v3.8.50from
hartmark:fix/responses-message-toolcall-index-collision

Conversation

@hartmark

@hartmark hartmark commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

A text message and a following tool call in the same turn could collide on the
same output_index in the Responses API translation (open-sse/translator/response/openai-responses.ts).

emitToolCall/closeToolCall computed a tool call's output_index as
reasoningIndex + 1 + tcIdx, assuming reasoningIndex + 1 was free for the
first tool call (tcIdx=0). But a text message emitted in the same turn ALSO
claims reasoningIndex + 1 (or index 0 with no reasoning) — so a turn with
reasoning + text content + a tool call collided the tool call's
added/delta/done events onto the same output_index as the just-closed
message.

Live incident (2026-08-08): an agentic coding client sent a short preamble
line ("Kör nu, på riktigt — apply_patch på vibe-scriptet:") followed by an
apply_patch tool call in the same turn. The client only spoke the preamble
and never executed the patch — even though OmniRoute's own recorded
responseBody had a complete, valid tool_calls entry with finish_reason: "tool_calls". A client that tracks response items by output_index (as
expected for the Responses API) saw the tool call's events land on an index
it had already marked complete (the just-closed text message) and silently
dropped them.

Confirmed via the live call log artifact's raw SSE stream: response.output_item.done
for the text message and response.output_item.added for the tool call both
carried output_index=1, 1.84s apart.

Related but distinct from #9183/#9741: #9183 covered a different
output-index-collision class (already closed, superseded by #9741's narrower
reasoning-cache messageIndex sync). This is a separate collision between a
message item and a tool-call item, first found live on 2026-08-08 after
#9183/#9741 had already landed — not something either of those covers.

Fix

Extracted a shared toolCallOutputIndexBase() helper that tracks whether a
message item was actually emitted at the reasoning/base index
(state.msgItemAdded) and, if so, starts tool calls one slot after it.
emitToolCall and closeToolCall previously computed this independently —
that duplication is exactly how they could drift out of sync in the first
place, so the fix removes the duplication rather than just patching both
call sites in place.

⚠️ base-red inherited: #9737

Test plan

  • TDD: new regression test in tests/unit/translator-resp-openai-responses.test.ts
    reproduces the exact live scenario (reasoning + text preamble + apply_patch
    tool call) — confirmed failing (message and tool call must not collide on the same output_index) against the pre-fix code on a clean
    release/v3.8.50 checkout, passing after
  • npm run typecheck:core — clean
  • npm run lint — clean
  • npm run check:file-size — clean (own growth: openai-responses.ts
    1204->1224 rebaselined); the one other violation the gate reports
    (src/sse/handlers/chat.ts) is confirmed pre-existing/untouched by this
    branch (git diff upstream/release/v3.8.50...HEAD -- src/sse/handlers/chat.ts
    is empty)
  • Full translator test suite (translator-resp-openai-responses.test.ts) —
    19/19 passing, no regressions

…me output_index

Live incident (2026-08-08): an OpenClaw agent sent a short preamble line
("Kör nu, på riktigt — apply_patch på vibe-scriptet:") followed by an
apply_patch tool call in the same turn. The client only spoke the preamble
and never executed the patch, even though OmniRoute's own recorded
responseBody had a complete, valid tool_calls entry.

Root cause: emitToolCall/closeToolCall computed a tool call's output_index
as `reasoningIndex + 1 + tcIdx`, assuming reasoningIndex + 1 was free for
the first tool call (tcIdx=0). But a text message emitted in the same turn
ALSO claims reasoningIndex + 1 (or index 0 with no reasoning) — so a
turn with reasoning + text content + a tool call collided the tool call's
added/delta/done events onto the same output_index as the just-closed
message. A client that tracks response items by output_index (as expected
for the Responses API) sees the tool call events land on an index it
already marked complete and can silently drop them.

Fix: track whether a message item was actually emitted at that index
(state.msgItemAdded) and, if so, tool calls start one slot after it.
Extracted a shared toolCallOutputIndexBase() helper so emitToolCall and
closeToolCall can no longer compute this independently and drift apart.

Confirmed via the live call log artifact (id 1786223153235-770a1c):
response.output_item.done for the text message and response.output_item.added
for the tool call both carried output_index=1 in the raw SSE stream, 1.84s
apart, exactly matching the reported symptom.
@diegosouzapw

Copy link
Copy Markdown
Collaborator

Thanks for the PR. Please address the mandatory items (tests and/or merge blockers) in this branch, then rerun checks before /merge-prs.

2 similar comments
@diegosouzapw

Copy link
Copy Markdown
Collaborator

Thanks for the PR. Please address the mandatory items (tests and/or merge blockers) in this branch, then rerun checks before /merge-prs.

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Thanks for the PR. Please address the mandatory items (tests and/or merge blockers) in this branch, then rerun checks before /merge-prs.

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Obrigado pelo PR. Mantive a revisão de fix-in-place e não foi possível concluir o ajuste completo aqui:

  • Para os PRs em fork: não consigo aplicar push de correção diretamente na sua branch.
    Por favor, faça um rebase/sync com release/v3.8.50, resolva conflitos se houver, e rode os checks dessa branch.
    Se preferir, posso aplicar a correção na próxima rodada assim que você mandar o branch atualizado ou confirmar que o PR está limpo pra esse merge.

@hartmark

hartmark commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Code landed in #9843

@hartmark hartmark closed this Aug 9, 2026
@hartmark
hartmark deleted the fix/responses-message-toolcall-index-collision branch August 9, 2026 18:28
hartmark added a commit to hartmark/OmniRoute that referenced this pull request Aug 12, 2026
…tput_index

emitToolCallAdded/closeToolCall used the provider's raw Chat Completions
tool_calls[].index directly as the Responses API output_index. That index
is scoped only to the tool_calls array and legitimately restarts at 0 for
the first tool call, but a reasoning item (and/or a text message) emitted
earlier in the same turn may already have claimed output_index 0 (and 1).
A client that tracks response items by output_index (as the Responses API
spec expects) then sees the tool call's added/delta/done events land on an
index it already marked complete, and silently drops the tool call --
producing an "incomplete turn" that never dispatches it.

Reported live: OpenClaw on combo default -> opencode-zen/big-pickle sent a
reasoning block immediately followed by a function call in the same turn
(no text message in between); the function call's output_index collided
with the reasoning item's.

A similar collision (tool call after a *text message*) was already fixed
in open-sse/translator/response/openai-responses.ts (cheaper-inference#9822/cheaper-inference#9843), but
that file is only used by the zed-hosted executor -- the general
/v1/responses path (wired via responsesHandler.ts) goes through this file,
which never received the equivalent fix.

Fix: compute the tool call's output_index once (offset past any reasoning/
message item already emitted this turn) and cache it in
state.funcOutputIndex, so every added/delta/done event for that call --
including ones emitted later from the finish_reason handler or flush() --
shares exactly the same output_index.

TDD: new regression tests in
tests/unit/responses-transformer-tool-call-reasoning-collision.test.ts
reproduce the exact live scenario (reasoning immediately followed by a
tool call, and multiple tool calls after reasoning) -- confirmed failing
against the pre-fix code, passing after. Full transformer test suite
(responses-transformer*.test.ts, responses-replay-fixes.test.ts,
responses-api-truncation.test.ts, responses-request-translation.test.ts)
-- 24/24 passing, no regressions.

⚠️ base-red inherited: cheaper-inference#9985
diegosouzapw pushed a commit that referenced this pull request Aug 13, 2026
…tput_index (#10025)

emitToolCallAdded/closeToolCall used the provider's raw Chat Completions
tool_calls[].index directly as the Responses API output_index. That index
is scoped only to the tool_calls array and legitimately restarts at 0 for
the first tool call, but a reasoning item (and/or a text message) emitted
earlier in the same turn may already have claimed output_index 0 (and 1).
A client that tracks response items by output_index (as the Responses API
spec expects) then sees the tool call's added/delta/done events land on an
index it already marked complete, and silently drops the tool call --
producing an "incomplete turn" that never dispatches it.

Reported live: OpenClaw on combo default -> opencode-zen/big-pickle sent a
reasoning block immediately followed by a function call in the same turn
(no text message in between); the function call's output_index collided
with the reasoning item's.

A similar collision (tool call after a *text message*) was already fixed
in open-sse/translator/response/openai-responses.ts (#9822/#9843), but
that file is only used by the zed-hosted executor -- the general
/v1/responses path (wired via responsesHandler.ts) goes through this file,
which never received the equivalent fix.

Fix: compute the tool call's output_index once (offset past any reasoning/
message item already emitted this turn) and cache it in
state.funcOutputIndex, so every added/delta/done event for that call --
including ones emitted later from the finish_reason handler or flush() --
shares exactly the same output_index.

TDD: new regression tests in
tests/unit/responses-transformer-tool-call-reasoning-collision.test.ts
reproduce the exact live scenario (reasoning immediately followed by a
tool call, and multiple tool calls after reasoning) -- confirmed failing
against the pre-fix code, passing after. Full transformer test suite
(responses-transformer*.test.ts, responses-replay-fixes.test.ts,
responses-api-truncation.test.ts, responses-request-translation.test.ts)
-- 24/24 passing, no regressions.

⚠️ base-red inherited: #9985
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…tput_index (cheaper-inference#10025)

emitToolCallAdded/closeToolCall used the provider's raw Chat Completions
tool_calls[].index directly as the Responses API output_index. That index
is scoped only to the tool_calls array and legitimately restarts at 0 for
the first tool call, but a reasoning item (and/or a text message) emitted
earlier in the same turn may already have claimed output_index 0 (and 1).
A client that tracks response items by output_index (as the Responses API
spec expects) then sees the tool call's added/delta/done events land on an
index it already marked complete, and silently drops the tool call --
producing an "incomplete turn" that never dispatches it.

Reported live: OpenClaw on combo default -> opencode-zen/big-pickle sent a
reasoning block immediately followed by a function call in the same turn
(no text message in between); the function call's output_index collided
with the reasoning item's.

A similar collision (tool call after a *text message*) was already fixed
in open-sse/translator/response/openai-responses.ts (cheaper-inference#9822/cheaper-inference#9843), but
that file is only used by the zed-hosted executor -- the general
/v1/responses path (wired via responsesHandler.ts) goes through this file,
which never received the equivalent fix.

Fix: compute the tool call's output_index once (offset past any reasoning/
message item already emitted this turn) and cache it in
state.funcOutputIndex, so every added/delta/done event for that call --
including ones emitted later from the finish_reason handler or flush() --
shares exactly the same output_index.

TDD: new regression tests in
tests/unit/responses-transformer-tool-call-reasoning-collision.test.ts
reproduce the exact live scenario (reasoning immediately followed by a
tool call, and multiple tool calls after reasoning) -- confirmed failing
against the pre-fix code, passing after. Full transformer test suite
(responses-transformer*.test.ts, responses-replay-fixes.test.ts,
responses-api-truncation.test.ts, responses-request-translation.test.ts)
-- 24/24 passing, no regressions.

⚠️ base-red inherited: cheaper-inference#9985
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.

2 participants