From 7b79433e0b5b33543d5bdd1375892b441e3169e7 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:19:39 -0300 Subject: [PATCH] fix(compression): keep tool messages through lite redundant-remove (#13429) removeRedundantContent in open-sse/services/compression/lite.ts collapsed any two consecutive messages with matching role and content, without checking tool_call_id. When two role:"tool" results were byte-identical (e.g. both empty, or truncated to the same 2000-char prefix by the tool-compress pass that runs immediately before it), the second was dropped, orphaning a tool_call_id and triggering strict upstream validators to reject the request with a 400 error. Fix: exempt role:"tool" messages from the content-adjacency collapse entirely, since each carries its own protocol-required tool_call_id and must never be treated as redundant by content equality alone. Regression test: tests/unit/issue-13429-lite-redundant-remove-tool-call-id.test.ts --- ...3429-lite-redundant-remove-tool-call-id.md | 1 + open-sse/services/compression/lite.ts | 1 + ...lite-redundant-remove-tool-call-id.test.ts | 96 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 changelog.d/fixes/13429-lite-redundant-remove-tool-call-id.md create mode 100644 tests/unit/issue-13429-lite-redundant-remove-tool-call-id.test.ts diff --git a/changelog.d/fixes/13429-lite-redundant-remove-tool-call-id.md b/changelog.d/fixes/13429-lite-redundant-remove-tool-call-id.md new file mode 100644 index 0000000000..78df114e20 --- /dev/null +++ b/changelog.d/fixes/13429-lite-redundant-remove-tool-call-id.md @@ -0,0 +1 @@ +- **fix(compression):** stop lite compression from dropping a `role:"tool"` message when it is byte-identical to the previous message, which orphaned a `tool_call_id` and triggered upstream 400 errors on parallel tool calls (#13429) — thanks @tolgaaksoy diff --git a/open-sse/services/compression/lite.ts b/open-sse/services/compression/lite.ts index ade5858352..644fd300a8 100644 --- a/open-sse/services/compression/lite.ts +++ b/open-sse/services/compression/lite.ts @@ -157,6 +157,7 @@ export function removeRedundantContent( const contentStr = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content); if ( i > 0 && + msg.role !== "tool" && body.messages[i - 1].role === msg.role && typeof body.messages[i - 1].content === "string" && body.messages[i - 1].content === contentStr diff --git a/tests/unit/issue-13429-lite-redundant-remove-tool-call-id.test.ts b/tests/unit/issue-13429-lite-redundant-remove-tool-call-id.test.ts new file mode 100644 index 0000000000..00bcd19b31 --- /dev/null +++ b/tests/unit/issue-13429-lite-redundant-remove-tool-call-id.test.ts @@ -0,0 +1,96 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { applyLiteCompression } from "../../open-sse/services/compression/lite.ts"; + +// Issue #13429: lite compression's redundant-remove step collapses consecutive +// role:"tool" messages with identical content WITHOUT consulting tool_call_id. +// When two tool results are byte-identical (e.g. both empty strings), the +// second is dropped, orphaning one of the assistant message's tool_call_ids. +// Strict upstream validators (DeepSeek-class) then reject the request with +// "insufficient tool messages". +test("issue #13429: redundant-remove must not drop a tool message that has a distinct tool_call_id", () => { + const body = { + model: "test-model", + messages: [ + { + role: "assistant", + content: "t", + tool_calls: [ + { id: "c1", type: "function", function: { name: "f", arguments: "{}" } }, + { id: "c2", type: "function", function: { name: "g", arguments: "{}" } }, + ], + }, + { role: "tool", tool_call_id: "c1", content: "" }, + { role: "tool", tool_call_id: "c2", content: "" }, + ], + }; + + const result = applyLiteCompression(body); + const messages = (result.body as { messages: Array> }).messages; + + const toolMessages = messages.filter((m) => m.role === "tool"); + const toolCallIds = toolMessages.map((m) => m.tool_call_id); + + assert.equal( + toolMessages.length, + 2, + `expected 2 tool messages to survive redundant-remove, got ${toolMessages.length} (techniques: ${JSON.stringify( + result.stats?.techniquesUsed ?? [] + )})` + ); + assert.deepEqual(new Set(toolCallIds), new Set(["c1", "c2"])); +}); + +// Compound arm: compressToolResults truncates long tool content to a shared +// 2000-char prefix BEFORE redundant-remove runs, so two results that started +// distinct can still collide into the same string. Both must still survive. +test("issue #13429: tool messages that collide only after truncation must both survive", () => { + const longA = "A".repeat(2500); + const longB = "A".repeat(2000) + "B".repeat(500); + const body = { + model: "test-model", + messages: [ + { + role: "assistant", + content: "t", + tool_calls: [ + { id: "c1", type: "function", function: { name: "f", arguments: "{}" } }, + { id: "c2", type: "function", function: { name: "g", arguments: "{}" } }, + ], + }, + { role: "tool", tool_call_id: "c1", content: longA }, + { role: "tool", tool_call_id: "c2", content: longB }, + ], + }; + + const result = applyLiteCompression(body); + const messages = (result.body as { messages: Array> }).messages; + + const toolMessages = messages.filter((m) => m.role === "tool"); + const toolCallIds = toolMessages.map((m) => m.tool_call_id); + + assert.equal( + toolMessages.length, + 2, + "both tool messages must survive despite truncated collision" + ); + assert.deepEqual(new Set(toolCallIds), new Set(["c1", "c2"])); +}); + +// Non-regression: redundant-remove must still collapse adjacent identical +// non-tool messages (e.g. duplicate user turns) — the fix is scoped to the +// "tool" role only, not a blanket disable of the technique. +test("issue #13429: redundant-remove still collapses adjacent identical user messages", () => { + const body = { + model: "test-model", + messages: [ + { role: "user", content: "same text" }, + { role: "user", content: "same text" }, + ], + }; + + const result = applyLiteCompression(body); + const messages = (result.body as { messages: Array> }).messages; + + assert.equal(messages.length, 1, "duplicate non-tool messages should still be collapsed"); +});