From 4ec4ce410e7b6ef0776054222539f89fc0c9b2eb Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Thu, 3 Sep 2026 18:01:03 +0200 Subject: [PATCH] fix(sse): remap non-contiguous upstream tool_calls index to a gap-free output_index (#12445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem. O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. Obrigado, @hartmark. --- .../translator/response/openai-responses.ts | 6 +- .../openai-responses/toolCallLocalIndex.ts | 35 +++++ .../translator-resp-openai-responses.test.ts | 140 ++++++++++++++++++ 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 open-sse/translator/response/openai-responses/toolCallLocalIndex.ts diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 4d255d40be..a2244f01f5 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -23,12 +23,12 @@ import { import { createEventEmitter } from "./openai-responses/eventEmitter.ts"; import { buildResponsesToolCallItem } from "./responsesToolItem.ts"; import { resolveRequestToolIdentity } from "./openai-responses/requestToolIdentity.ts"; +import { resolveLocalToolCallIndex } from "./openai-responses/toolCallLocalIndex.ts"; import { synthesizeCompletedToolCalls, computeFinishReason, withAssistantRoleOnFirstDelta, } from "./openai-responses/synthesizeCompletedToolCalls.ts"; - // normalizeUpstreamFailure is re-exported for external importers (tests). export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts"; @@ -506,7 +506,7 @@ function toolCallOutputIndexBase(state) { function emitToolCall(state, emit, tc) { const tcIdx = tc.index ?? 0; - const outputIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(tcIdx); + const outputIndex = toolCallOutputIndexBase(state) + resolveLocalToolCallIndex(state, tcIdx); const newCallId = tc.id; const funcName = tc.function?.name; @@ -609,7 +609,7 @@ function emitToolCall(state, emit, tc) { function closeToolCall(state, emit, idx, recordAsCompleted = true) { const callId = state.funcCallIds[idx]; if (callId && !state.funcItemDone[idx]) { - const normalizedIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(idx); + const normalizedIndex = toolCallOutputIndexBase(state) + resolveLocalToolCallIndex(state, idx); const args = state.funcArgsBuf[idx] || "{}"; const toolName = state.funcNames[idx] || ""; // See emitToolCall()'s isCustomTool comment — must stay in sync (both compute the diff --git a/open-sse/translator/response/openai-responses/toolCallLocalIndex.ts b/open-sse/translator/response/openai-responses/toolCallLocalIndex.ts new file mode 100644 index 0000000000..645278e1fd --- /dev/null +++ b/open-sse/translator/response/openai-responses/toolCallLocalIndex.ts @@ -0,0 +1,35 @@ +/** + * Remap a turn's raw upstream tool_calls delta `index` onto a local, + * contiguous, 0-based sequence in first-seen order. + * + * Live incident (2026-09-02, minimax-m3:free via OpenRouter/GMICloud): the + * upstream's own `index` doesn't reliably start at 0 or stay contiguous per + * turn — this turn's two calls arrived with raw index 1 and 2 (never 0). + * Adding that raw index straight onto toolCallOutputIndexBase() left a GAP + * in the emitted output_index sequence (0 for the message, then 2 and 3 for + * the calls — index 1 never used). A client that reads response.completed's + * final `output[]` array by ARRAY POSITION and expects position to equal + * output_index (the Responses API's own contract) reads output[1] (this + * turn's first call, real output_index 2) while looking it up under + * output_index 1, misses it, then reads output[2] (the second call, real + * output_index 3) under output_index 2 — landing on the FIRST call's tracked + * slot with a different call_id, which a spec-following client correctly + * treats as "stream changed output item identity" and aborts. + */ + +export type ToolCallLocalIndexState = { + toolCallLocalIndex?: Record; + toolCallLocalIndexNext?: number; +}; + +export function resolveLocalToolCallIndex( + state: ToolCallLocalIndexState, + tcIdx: string | number +): number { + if (!state.toolCallLocalIndex) state.toolCallLocalIndex = {}; + if (state.toolCallLocalIndex[tcIdx] === undefined) { + state.toolCallLocalIndex[tcIdx] = state.toolCallLocalIndexNext ?? 0; + state.toolCallLocalIndexNext = state.toolCallLocalIndex[tcIdx] + 1; + } + return state.toolCallLocalIndex[tcIdx]; +} diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts index 2253c655ac..3a42eca624 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -994,3 +994,143 @@ test("OpenAI -> Responses: a text message and a following tool call in the same "completed output must include the tool call" ); }); + +// Live incident (2026-09-02): a free-tier streaming model, after a short text +// preamble, opened two tool calls whose upstream `tool_calls[].index` was 1 +// and 2 -- never 0. toolCallOutputIndexBase()+index therefore emitted +// output_index 0 (message), 2, 3 -- skipping 1 entirely. A spec-following +// Responses-API client reads response.completed's final `output[]` array by +// ARRAY POSITION and expects position === output_index (the API's own +// contract): output[1] (this turn's first call, real output_index 2) gets +// looked up under output_index 1 and missed, then output[2] (the second +// call, real output_index 3) gets looked up under output_index 2 and +// collides with the FIRST call's tracked slot -- two different call_ids on +// what the client thinks is one identity, which it correctly refuses to +// treat as anything but a broken stream. Reproduced verbatim (anonymized +// content, same index/id shape) against OpenClaw's own +// createResponsesOutputTracker before this fix; content and tool/model names +// below are placeholders, not the real incident's. +test("OpenAI -> Responses: tool-call output_index stays gap-free when the upstream's own index doesn't start at 0", () => { + const events = collectEvents([ + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { index: 0, delta: { content: "Status:", role: "assistant" }, finish_reason: null }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { index: 0, delta: { content: " all clear.", role: "assistant" }, finish_reason: null }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { + index: 0, + delta: { + content: null, + role: "assistant", + tool_calls: [ + { + index: 1, + id: "call_stub_1", + type: "function", + function: { name: "notify", arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { + index: 0, + delta: { + content: null, + role: "assistant", + tool_calls: [{ index: 1, function: { arguments: '{"a":1}' } }], + }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { + index: 0, + delta: { + content: null, + role: "assistant", + tool_calls: [ + { + index: 2, + id: "call_stub_2", + type: "function", + function: { name: "notify", arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { + index: 0, + delta: { + content: null, + role: "assistant", + tool_calls: [{ index: 2, function: { arguments: '{"a":2}' } }], + }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { index: 0, delta: { content: "", role: "assistant" }, finish_reason: "tool_calls" }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }, + null, + ]); + + const addedEvents = events.filter((e) => e.event === "response.output_item.added"); + const indexes = addedEvents.map((e) => e.data.output_index).sort((a, b) => a - b); + const sequential = indexes.map((_, i) => i); + assert.deepEqual( + indexes, + sequential, + `output_index values must be a gap-free 0..n-1 sequence (position === output_index is the Responses API's own contract); got ${JSON.stringify(indexes)}` + ); + + // The exact client-observable symptom: response.completed's output[] + // array, read by array position, must match each item's own tracked + // output_index -- otherwise a client keying by array position resolves + // the wrong item. + const completedGap = events.find((e) => e.event === "response.completed"); + completedGap.data.response.output.forEach((item, position) => { + const addedEvent = addedEvents.find((e) => e.data.item?.id === item.id); + assert.equal( + addedEvent?.data.output_index, + position, + `item ${item.id} (type ${item.type}) streamed at output_index ${addedEvent?.data.output_index} but sits at array position ${position} in the completed output` + ); + }); +});