diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 564c32e7cfca..dd69eb6ef19d 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -158,6 +158,7 @@ "_rebaseline_2026_06_20_4389_thinking_toolchoice": "Re-baseline base.ts 1387->1399 (#4389): tool_choice-forced thinking guard at the existing Claude wire-image injection chokepoint (effThinking gate avoids the Anthropic 400 when tool_choice forces a tool). Cohesive guard; structural shrink tracked in #3501.", "_rebaseline_2026_07_18_6979_codex_test": "PR #6979 own growth: executor-codex.test.ts 1340->1347 (+7 = generalized ensureThinkingBudget assertion added to the existing codex thinking-budget cases). antigravity-test bump 942->977 REVERTED here: #7408's test split dropped that file to 888, so this PR's +35 fits under the original 942 frozen cap.", "_rebaseline_2026_07_24_8354_logs_timeline_sidebar": "PR #8354 (hartmark, feature/scrolling-log) own growth: src/shared/constants/sidebarVisibility/sections.ts 812->820 (+8, the single new logs-timeline SidebarItemDefinition entry added to LOGS_GROUP.items for the new /dashboard/logs/timeline scrolling request-timeline page). Irreducible data-literal wiring at the existing sidebar-sections chokepoint, same shape as every other item in the file; not extractable without an ad-hoc single-item exception to the file's otherwise-uniform multi-line item style.", + "_rebaseline_2026_08_08_toolcall_message_index_collision": "fix(responses-api): tool call after a text message collided on the same output_index. own growth: open-sse/translator/response/openai-responses.ts 1204->1224 (+20, extracted toolCallOutputIndexBase() shared helper so emitToolCall/closeToolCall can no longer compute a tool call's output_index independently and collide with a text message emitted in the same turn). Live incident (2026-08-08, OpenClaw agent): a client that tracks response items by output_index saw the tool call's added/delta/done events land on an index it had already marked complete (the just-closed text message), and silently dropped them — the agent spoke its preamble and never executed the tool call, even though OmniRoute's own recorded responseBody had a complete, valid tool_calls entry. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts reproducing the exact live scenario.", "cap": 1000, "testCap": 1000, "testFrozen": { @@ -363,7 +364,7 @@ "open-sse/services/combo.ts": 3648, "open-sse/services/compression/strategySelector.ts": 1060, "open-sse/services/rateLimitManager.ts": 1167, - "open-sse/translator/response/openai-responses.ts": 1204, + "open-sse/translator/response/openai-responses.ts": 1224, "open-sse/utils/cursorAgentProtobuf.ts": 1505, "open-sse/utils/stream.ts": 2889, "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1388, diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index d459350d350e..35571814e750 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -451,11 +451,22 @@ function closeMessage(state, emit, idx) { } } +// Tool calls sit after reasoning (if any) AND after a text message (if one was +// actually emitted this turn) — a model commonly emits a short preamble before +// calling a tool (e.g. "Kör nu, på riktigt — apply_patch..."), and that message +// claims the same reasoningIndex+1 slot the old per-call math (`reasoningIndex +// + 1 + tcIdx`) assumed was free for tcIdx=0. Not accounting for the message +// item collided the tool call's added/delta/done events onto the same +// output_index as the just-closed message, which a client keying per-item +// state by output_index can silently drop (live incident 2026-08-08). +function toolCallOutputIndexBase(state) { + const msgIdx = state.reasoningId ? normalizeOutputIndex(state.reasoningIndex) + 1 : 0; + return state.msgItemAdded[msgIdx] ? msgIdx + 1 : msgIdx; +} + function emitToolCall(state, emit, tc) { const tcIdx = tc.index ?? 0; - const outputIndex = state.reasoningId - ? normalizeOutputIndex(state.reasoningIndex) + 1 + normalizeOutputIndex(tcIdx) - : normalizeOutputIndex(tcIdx); + const outputIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(tcIdx); const newCallId = tc.id; const funcName = tc.function?.name; @@ -536,9 +547,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 = state.reasoningId - ? normalizeOutputIndex(state.reasoningIndex) + 1 + normalizeOutputIndex(idx) - : normalizeOutputIndex(idx); + const normalizedIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(idx); const args = state.funcArgsBuf[idx] || "{}"; const toolName = state.funcNames[idx] || ""; const isCustomTool = diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts index 3eeef460b820..6a2aee49ae89 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -726,3 +726,92 @@ test("OpenAI -> Responses: parallel tool calls with mixed content survive transl const outputFcs = completed.data.response.output.filter((item) => item.type === "function_call"); assert.equal(outputFcs.length, 2, "completed output should have both function_calls"); }); + +// Live incident (2026-08-08): an OpenClaw agent ("Ping") sent a preamble line +// ("Kör nu, på riktigt — apply_patch på vibe-scriptet:") followed by an +// apply_patch tool call in the same turn, with reasoning ahead of both. The +// text message and the tool call both computed to output_index=1 — the tool +// call's own index math (`reasoningIndex + 1 + tcIdx`) never accounted for +// the message item also claiming `reasoningIndex + 1`, so a completed +// message and a freshly-added tool call collided on the same output_index. +// A client that tracks response items by output_index (as Responses-API +// clients are expected to) sees the tool call's added/delta/done events land +// on an index it already marked complete, and can silently drop or ignore +// them — exactly the observed symptom: the agent spoke the preamble and +// never executed the patch. +test("OpenAI -> Responses: a text message and a following tool call in the same turn get distinct output_index values", () => { + const events = collectEvents([ + { + id: "chatcmpl-1", + model: "big-pickle", + choices: [ + { index: 0, delta: { reasoning_content: "thinking about the patch" }, finish_reason: null }, + ], + }, + { + id: "chatcmpl-1", + model: "big-pickle", + choices: [ + { + index: 0, + delta: { content: "Kör nu, på riktigt — apply_patch på vibe-scriptet:" }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-1", + model: "big-pickle", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_apply_patch", + type: "function", + function: { name: "apply_patch", arguments: '{"input":"*** Begin Patch ***"}' }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + }, + null, + ]); + + const itemDoneEvents = events.filter((e) => e.event === "response.output_item.done"); + const messageDone = itemDoneEvents.find((e) => e.data.item?.type === "message"); + const toolCallDone = itemDoneEvents.find( + (e) => e.data.item?.type === "function_call" || e.data.item?.type === "custom_tool_call" + ); + assert.ok(messageDone, "message output_item.done should be present"); + assert.ok(toolCallDone, "tool call output_item.done should be present"); + assert.notEqual( + messageDone.data.output_index, + toolCallDone.data.output_index, + "message and tool call must not collide on the same output_index" + ); + + // The tool call's own added/delta events (what a streaming client actually + // keys its per-item state on) must also use the tool call's real index, + // not the message's. + const toolCallAdded = events.find( + (e) => + e.event === "response.output_item.added" && + (e.data.item?.type === "function_call" || e.data.item?.type === "custom_tool_call") + ); + assert.ok(toolCallAdded, "tool call output_item.added should be present"); + assert.equal(toolCallAdded.data.output_index, toolCallDone.data.output_index); + assert.notEqual(toolCallAdded.data.output_index, messageDone.data.output_index); + + const completed = events.find((e) => e.event === "response.completed"); + const outputTypes = completed.data.response.output.map((item) => item.type); + assert.ok(outputTypes.includes("message"), "completed output must include the message"); + assert.ok( + outputTypes.includes("function_call") || outputTypes.includes("custom_tool_call"), + "completed output must include the tool call" + ); +});