Skip to content
90 changes: 90 additions & 0 deletions open-sse/translator/helpers/toolCallHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,96 @@ export function fixMissingToolResponses<T extends ToolCallBody>(body: T): T {
return body;
}

/**
* Scan forward from `from` for response messages that resolve the turn's remaining calls,
* stopping at the next assistant message. Returns their indexes and removes the resolved ids
* from `openIds` (mutated in place — callers rely on that to know what is still open).
*/
function collectDisplacedResultIndexes(
messages: ToolMessage[],
from: number,
openIds: Set<string>
): number[] {
const ahead: number[] = [];
for (let j = from; j < messages.length; j++) {
const later = messages[j];
if (later.role === "assistant") break;
const resolved = respondedToolIds(later).filter((id) => openIds.has(id));
if (resolved.length === 0) continue;
ahead.push(j);
for (const id of resolved) openIds.delete(id);
if (openIds.size === 0) break;
}
return ahead;
}

/** Move the messages at `indexes` (ascending, all after `target`) directly above `target`, in place. */
function hoistMessagesAbove(messages: ToolMessage[], indexes: number[], target: number): void {
const hoisted = indexes.map((idx) => messages[idx]);
const hoistedSet = new Set(indexes);
const rebuilt: ToolMessage[] = [];
for (let j = 0; j < messages.length; j++) {
if (j === target) rebuilt.push(...hoisted);
if (!hoistedSet.has(j)) rebuilt.push(messages[j]);
}
messages.length = 0;
messages.push(...rebuilt);
}

/**
* Hoist displaced tool results back into their assistant turn's result run.
*
* Clients that steer mid-turn (queued user messages, task notifications) can
* interleave a non-tool message between an assistant tool_calls message and
* part of its results: [assistant(tc:c1,c2), tool(c1), user, tool(c2)]. Strict
* upstream validators (DeepSeek V4 thinking confirmed live) reject that with
* "insufficient tool messages following tool_calls message". The result
* belongs to the turn, so move it up to the end of the contiguous run.
* Stops at the next assistant message: a later assistant turn's own pairing
* must not be scrambled.
*/
export function repairDisplacedToolResults<T extends ToolCallBody>(body: T): T {
if (!body.messages || !Array.isArray(body.messages)) return body;
const messages = body.messages;

let turnIds: Set<string> | null = null;

for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const ids = getToolCallIds(msg);
if (ids.length > 0) {
turnIds = new Set(ids);
continue;
}
if (!turnIds || turnIds.size === 0) continue;
// Stable non-null alias: `turnIds` is reassigned below, which defeats narrowing in callbacks.
const openIds: Set<string> = turnIds;

if (msg.role === "assistant") {
// A new assistant turn (without tool calls) ends the previous turn's window.
turnIds = null;
continue;
}
if (respondedToolIds(msg).some((id) => openIds.has(id))) {
// Contiguous member of the run — still fine where it is.
continue;
}
// Non-tool message inside the turn's window: hoist the later response messages that
// resolve the turn's remaining calls above this message.
const ahead = collectDisplacedResultIndexes(messages, i + 1, openIds);
if (ahead.length === 0) {
// Nothing ahead belongs to this turn; close the window at this boundary.
turnIds = null;
continue;
}
hoistMessagesAbove(messages, ahead, i);
// Re-scan from the same index: the hoisted results now precede msg.
i -= 1;
}

return body;
}

// Strip tool-result carriers whose id has no matching tool call anywhere in the
// conversation. Mirrors fixMissingToolResponses on the result side: that helper
// ensures every call has a result; this one ensures every result has a call.
Expand Down
3 changes: 3 additions & 0 deletions open-sse/translator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { FORMATS } from "./formats.ts";
import {
ensureToolCallIds,
fixMissingToolResponses,
repairDisplacedToolResults,
stripOrphanedToolResults,
} from "./helpers/toolCallHelper.ts";
import {
Expand Down Expand Up @@ -435,6 +436,7 @@ export function translateRequest(
ensureToolCallIds(result, { use9CharId });

// Fix missing tool responses (insert empty tool_result if needed)
repairDisplacedToolResults(result);
fixMissingToolResponses(result);

// Claude reconciliation preserves orphaned tool output as labelled user text.
Expand Down Expand Up @@ -758,6 +760,7 @@ export function translateRequest(

// Ensure unique tool_call ids on final payload (translators may have introduced duplicates)
ensureToolCallIds(result, { use9CharId });
repairDisplacedToolResults(result);
fixMissingToolResponses(result);
stripOrphanedToolResults(result);

Expand Down
147 changes: 147 additions & 0 deletions tests/unit/translator-displaced-tool-results.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import test from "node:test";
import assert from "node:assert/strict";

// Displaced tool results: clients that steer mid-turn (queued user messages,
// task notifications) can interleave a non-tool message between an assistant
// tool_calls message and part of its results:
// [assistant(tc:c1,c2), tool(c1), user, tool(c2)]
// Strict upstream validators (DeepSeek V4 thinking confirmed live 2026-11-01,
// "insufficient tool messages following tool_calls message") reject the whole
// request. repairDisplacedToolResults hoists the result back into the turn's
// contiguous run; fixMissingToolResponses (per-id coverage) inserts empty
// results for calls nothing resolves.
const { repairDisplacedToolResults, fixMissingToolResponses } = await import(
"../../open-sse/translator/helpers/toolCallHelper.ts"
);

const asst = (ids, text = null) => ({
role: "assistant",
content: text,
tool_calls: ids.map((id) => ({
id,
type: "function",
function: { name: "shell", arguments: "{}" },
})),
});
const tool = (id, content = "result") => ({ role: "tool", tool_call_id: id, content });
const user = (text) => ({ role: "user", content: text });

function runBoth(body) {
return fixMissingToolResponses(repairDisplacedToolResults(body));
}

test("displaced result is hoisted back into the assistant turn's result run", () => {
const body = {
messages: [user("go"), asst(["c1", "c2"]), tool("c1"), user("task notification"), tool("c2"), user("next")],
};
const out = runBoth(body).messages;
// The displaced c2 result moves above the steering user message; the run is
// contiguous, so no synthetic results are injected.
assert.deepEqual(
out.map((m) => m.role),
["user", "assistant", "tool", "tool", "user", "user"]
);
assert.deepEqual(out.slice(2, 4).map((m) => m.tool_call_id), ["c1", "c2"]);
assert.equal(out[2].content, "result");
assert.equal(out[3].content, "result");
});

test("partial coverage: missing id gets an empty result, existing result untouched", () => {
const body = { messages: [user("go"), asst(["c1", "c2"]), tool("c1"), user("next")] };
const out = runBoth(body).messages;
assert.equal(out.length, 5);
assert.equal(out[2].tool_call_id, "c1");
assert.equal(out[2].content, "result");
assert.equal(out[3].tool_call_id, "c2");
assert.equal(out[3].content, "");
assert.equal(out[4].role, "user");
});

test("dangling call mid-conversation still gets an empty result", () => {
const body = { messages: [user("go"), asst(["c1"]), user("next")] };
const out = runBoth(body).messages;
assert.equal(out.length, 4);
assert.equal(out[2].role, "tool");
assert.equal(out[2].tool_call_id, "c1");
assert.equal(out[3].role, "user");
});

test("trailing assistant turn gets its pending call closed with an empty result", () => {
// release/v3.8.53 normalizeTrailingCalls: a trailing pending call gets an
// empty response so the conversation is complete for the next turn.
const body = { messages: [user("go"), asst(["c1"])] };
const out = runBoth(body).messages;
assert.equal(out.length, 3);
assert.equal(out[2].role, "tool");
assert.equal(out[2].tool_call_id, "c1");
assert.equal(out[2].content, "");
});

test("hoisting never crosses the next assistant message", () => {
const body = {
messages: [user("go"), asst(["c1"]), user("steer"), user("later"), asst([], "hi"), tool("c1"), user("next")],
};
const out = runBoth(body).messages;
// The dangling call gets its empty result right after the assistant; the
// stale result beyond the later assistant stays in place (hoisting never
// crosses an assistant boundary).
assert.equal(out[2].role, "tool");
assert.equal(out[2].tool_call_id, "c1");
assert.equal(out[2].content, "");
assert.equal(out[5].role, "assistant");
assert.equal(out[6].role, "tool");
assert.equal(out[6].tool_call_id, "c1");
});

test("multiple displaced results hoist in stable relative order", () => {
const body = {
messages: [
user("go"),
asst(["c1", "c2", "c3"]),
tool("c1"),
user("notif one"),
tool("c3"),
user("notif two"),
tool("c2"),
user("next"),
],
};
const out = runBoth(body).messages;
assert.deepEqual(
out.slice(2, 5).map((m) => (m.role === "tool" ? m.tool_call_id : m.role)),
["c1", "c3", "c2"]
);
assert.equal(out[5].role, "user");
});

test("Claude-format displaced tool_result user message hoists the same way", () => {
const claudeAsst = {
role: "assistant",
content: [
{ type: "tool_use", id: "c1", name: "shell", input: {} },
{ type: "tool_use", id: "c2", name: "shell", input: {} },
],
};
const resultCarrier = (id) => ({
role: "user",
content: [{ type: "tool_result", tool_use_id: id, content: "r" }],
});
const body = {
messages: [user("go"), claudeAsst, resultCarrier("c1"), user("notif"), resultCarrier("c2"), user("next")],
};
const out = runBoth(body).messages;
// The c2 carrier hoists above the notification; no synthetic result needed.
assert.deepEqual(
out.slice(2, 5).map((m) => m.role),
["user", "user", "user"]
);
const ids = out.slice(2, 5).map((m) => m.content?.[0]?.tool_use_id);
assert.deepEqual(ids, ["c1", "c2", undefined]);
});

test("fully-paired conversation is a no-op", () => {
const body = { messages: [user("go"), asst(["c1"]), tool("c1"), user("next")] };
const before = JSON.stringify(body.messages);
const out = runBoth(body).messages;
assert.equal(JSON.stringify(out), before);
});
Loading