Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 15 additions & 6 deletions open-sse/translator/response/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 =
Expand Down
89 changes: 89 additions & 0 deletions tests/unit/translator-resp-openai-responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
});
Loading