fix(sse): remap non-contiguous upstream tool_calls index to a gap-free output_index (#12445)

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.
This commit is contained in:
Markus Hartung
2026-09-03 18:01:03 +02:00
committed by GitHub
parent 7881e7eb72
commit 4ec4ce410e
3 changed files with 178 additions and 3 deletions

View File

@@ -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

View File

@@ -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<string, number>;
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];
}

View File

@@ -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`
);
});
});