diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 94094155ba..63a10b6888 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -14,7 +14,11 @@ import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestD import { remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts"; import { obfuscateInBody } from "../services/claudeCodeObfuscation.ts"; import { applySystemTransformPipeline, PROVIDER_CLAUDE } from "../services/systemTransforms.ts"; -import { fixToolPairs, stripTrailingAssistantOrphanToolUse } from "../services/contextManager.ts"; +import { + fixToolPairs, + fixToolAdjacency, + stripTrailingAssistantOrphanToolUse, +} from "../services/contextManager.ts"; import { randomUUID } from "node:crypto"; import { CLAUDE_CODE_VERSION, @@ -874,7 +878,8 @@ export class BaseExecutor { const tb = transformedBody as Record; if (Array.isArray(tb?.messages)) { const fixed = fixToolPairs(tb.messages as Record[]); - tb.messages = stripTrailingAssistantOrphanToolUse(fixed); + const adjacent = fixToolAdjacency(fixed); + tb.messages = stripTrailingAssistantOrphanToolUse(adjacent); } } let bodyString = JSON.stringify(transformedBody); diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index fb87595688..1e41b03843 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -13,7 +13,11 @@ import { } from "./claudeCodeConstraints.ts"; import { obfuscateInBody } from "./claudeCodeObfuscation.ts"; import { applySystemTransformPipeline, PROVIDER_CC_BRIDGE } from "./systemTransforms.ts"; -import { fixToolPairs, stripTrailingAssistantOrphanToolUse } from "./contextManager.ts"; +import { + fixToolPairs, + fixToolAdjacency, + stripTrailingAssistantOrphanToolUse, +} from "./contextManager.ts"; /** * `anthropic-compatible-cc-*` targets Anthropic relay gateways that only accept @@ -373,7 +377,8 @@ export async function buildAndSignClaudeCodeRequest( const b = body as Record; if (Array.isArray(b.messages)) { const fixed = fixToolPairs(b.messages as Record[]); - b.messages = stripTrailingAssistantOrphanToolUse(fixed); + const adjacent = fixToolAdjacency(fixed); + b.messages = stripTrailingAssistantOrphanToolUse(adjacent); } } diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index 338afa149a..d4d48292ea 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -401,6 +401,69 @@ export function fixToolPairs(messages: Record[]) { .filter(Boolean) as Record[]; } +/** + * Adjacency guard: Claude requires `tool_result` in the IMMEDIATELY NEXT + * message after `tool_use`, not just somewhere later in the array. + * + * `fixToolPairs` checks global ID presence but not adjacency. This function + * runs after `fixToolPairs` and removes `tool_use` blocks from assistant + * messages where the next message does not contain a matching `tool_result`. + */ +export function fixToolAdjacency(messages: Record[]): Record[] { + if (messages.length <= 1) return messages; + + const result: Record[] = []; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + const nextMsg = messages[i + 1]; + + if (msg.role === "assistant" && Array.isArray(msg.content) && nextMsg) { + // Collect tool_result IDs from the NEXT message + const nextToolResultIds = new Set(); + if (nextMsg.role === "tool" && nextMsg.tool_call_id) { + nextToolResultIds.add(String(nextMsg.tool_call_id)); + } + if (nextMsg.role === "user" && Array.isArray(nextMsg.content)) { + for (const block of nextMsg.content as Record[]) { + if (block.type === "tool_result" && block.tool_use_id) { + nextToolResultIds.add(String(block.tool_use_id)); + } + } + } + + // Filter tool_use blocks: only keep if next message has matching tool_result + const filteredContent = (msg.content as Record[]).filter( + (block) => block.type !== "tool_use" || !block.id || nextToolResultIds.has(String(block.id)) + ); + + if (filteredContent.length !== (msg.content as unknown[]).length) { + // Also filter tool_calls array if present + const newMsg: Record = { ...msg, content: filteredContent }; + if (Array.isArray(newMsg.tool_calls)) { + newMsg.tool_calls = (newMsg.tool_calls as Record[]).filter( + (tc: Record) => !tc.id || nextToolResultIds.has(String(tc.id)) + ); + } + // Drop assistant message if it became empty + const hasContent = + typeof newMsg.content === "string" + ? (newMsg.content as string).trim().length > 0 + : Array.isArray(newMsg.content) && (newMsg.content as unknown[]).length > 0; + const hasToolCalls = Array.isArray(newMsg.tool_calls) && newMsg.tool_calls.length > 0; + if (!hasContent && !hasToolCalls) continue; + result.push(newMsg); + } else { + result.push(msg); + } + } else { + result.push(msg); + } + } + + return result; +} + /** * Upstream-send guard: after `fixToolPairs`, strip a trailing assistant * message whose only/remaining content is an orphan `tool_use` block. diff --git a/tests/unit/fix-tool-adjacency.test.ts b/tests/unit/fix-tool-adjacency.test.ts new file mode 100644 index 0000000000..c0c07d91c4 --- /dev/null +++ b/tests/unit/fix-tool-adjacency.test.ts @@ -0,0 +1,142 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { fixToolPairs, fixToolAdjacency, stripTrailingAssistantOrphanToolUse } = + await import("../../open-sse/services/contextManager.ts"); + +// ─── fixToolAdjacency ─────────────────────────────────────────────────────── + +test("fixToolAdjacency: removes tool_use when next message has no matching tool_result", () => { + const messages = [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "text", text: "let me check" }, + { type: "tool_use", id: "toolu_abc", name: "search", input: {} }, + ], + }, + // Next message is user with tool_result for DIFFERENT id + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_xyz", content: "result for xyz" }], + }, + ]; + + const fixed = fixToolAdjacency(messages); + // toolu_abc should be removed because next message doesn't have tool_result for it + const assistantContent = fixed[1].content; + const toolUseBlocks = assistantContent.filter((b) => b.type === "tool_use"); + assert.equal(toolUseBlocks.length, 0); + assert.equal(assistantContent.length, 1); // only text remains +}); + +test("fixToolAdjacency: keeps tool_use when next message has matching tool_result", () => { + const messages = [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "text", text: "let me check" }, + { type: "tool_use", id: "toolu_abc", name: "search", input: {} }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_abc", content: "found it" }], + }, + ]; + + const fixed = fixToolAdjacency(messages); + const assistantContent = fixed[1].content; + const toolUseBlocks = assistantContent.filter((b) => b.type === "tool_use"); + assert.equal(toolUseBlocks.length, 1); + assert.equal(toolUseBlocks[0].id, "toolu_abc"); +}); + +test("fixToolAdjacency: drops empty assistant message after removing orphan tool_use", () => { + const messages = [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_abc", name: "search", input: {} }], + }, + // Next message has no tool_result at all + { role: "user", content: "what's up?" }, + ]; + + const fixed = fixToolAdjacency(messages); + // assistant message should be dropped since it only had orphan tool_use + assert.equal(fixed.length, 2); + assert.equal(fixed[0].role, "user"); + assert.equal(fixed[1].role, "user"); +}); + +test("fixToolAdjacency: handles OpenAI format tool role messages", () => { + const messages = [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_abc", name: "search", input: {} }], + }, + // OpenAI format: role=tool with tool_call_id + { role: "tool", tool_call_id: "toolu_abc", content: "found it" }, + ]; + + const fixed = fixToolAdjacency(messages); + const assistantContent = fixed[1].content; + const toolUseBlocks = assistantContent.filter((b) => b.type === "tool_use"); + assert.equal(toolUseBlocks.length, 1); // kept because next message matches +}); + +test("fixToolAdjacency: reproduces the exact bug - messages.26 orphan", () => { + // Exact scenario: fixToolPairs keeps tool_use because result ID exists globally, + // but tool_result is NOT in the immediately next message. + // + // setup: assistant(tool_use:abc) → user(text) → user(tool_result:abc) + // fixToolPairs: keeps tool_use (ID exists in toolResultIds from msg[3]) + // Claude rejects: msg[2] has no tool_result for abc (adjacency violation) + const messages = [ + { role: "user", content: "do something" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tooluse_hX96f1h1ZrkVoLpLI0szxn", + name: "bash", + input: { command: "ls" }, + }, + ], + }, + // Next message is plain user text — no tool_result for the tool_use above + { role: "user", content: "what's next?" }, + // Later message has the matching tool_result (not adjacent!) + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tooluse_hX96f1h1ZrkVoLpLI0szxn", + content: "ls output", + }, + ], + }, + ]; + + // fixToolPairs keeps it (global check: ID exists in toolResultIds from msg[3]) + const pairsFixed = fixToolPairs(messages); + const pairsAssistant = pairsFixed[1]; + const pairsToolUse = Array.isArray(pairsAssistant.content) + ? (pairsAssistant.content as any[]).filter((b: any) => b.type === "tool_use") + : []; + assert.equal(pairsToolUse.length, 1, "fixToolPairs keeps orphan (global check)"); + + // fixToolAdjacency removes it (adjacency check: next msg has no matching tool_result) + const adjacencyFixed = fixToolAdjacency(pairsFixed); + const adjacencyAssistant = adjacencyFixed[1]; + const adjacencyToolUse = Array.isArray(adjacencyAssistant.content) + ? (adjacencyAssistant.content as any[]).filter((b: any) => b.type === "tool_use") + : []; + assert.equal(adjacencyToolUse.length, 0, "fixToolAdjacency removes orphan (adjacency check)"); +});