fix(sse): filter orphaned tool results after context compaction

When Claude Code compacts conversation context to fit within token
limits, it may remove assistant messages containing tool_use/tool_calls
while leaving the corresponding tool_result/function_call_output
messages intact. This creates orphaned tool results that cause
providers to reject requests with errors like "tool result's tool id
not found" or "No tool call found for function call output".
This commit is contained in:
Prakersh Maheshwari 2026-03-17 01:59:40 +05:30
parent 9b255e643a
commit 8b2081837e
3 changed files with 244 additions and 0 deletions

View file

@ -201,6 +201,24 @@ export function openaiResponsesToOpenAIRequest(
});
}
// Filter orphaned tool results (no matching tool_call in any assistant message)
const allToolCallIds = new Set<string>();
for (const m of messages) {
const rec = toRecord(m);
if (Array.isArray(rec.tool_calls)) {
for (const tc of rec.tool_calls as any[]) {
if (tc.id) allToolCallIds.add(String(tc.id));
}
}
}
result.messages = messages.filter((m) => {
const rec = toRecord(m);
if (rec.role === "tool" && rec.tool_call_id) {
return allToolCallIds.has(String(rec.tool_call_id));
}
return true;
});
// Cleanup Responses API specific fields
delete result.input;
delete result.instructions;
@ -339,6 +357,20 @@ export function openaiToOpenAIResponsesRequest(
}
}
// Filter orphaned function_call_output items (no matching function_call)
// This happens when Claude Code compaction removes messages but leaves tool results
const knownCallIds = new Set(
input
.filter((item: any) => item.type === "function_call" && item.call_id)
.map((item: any) => item.call_id),
);
result.input = input.filter((item: any) => {
if (item.type === "function_call_output" && item.call_id) {
return knownCallIds.has(item.call_id);
}
return true;
});
// If no system message, keep empty instructions
if (!hasSystemMessage) {
result.instructions = "";

View file

@ -123,6 +123,43 @@ export function openaiToClaudeRequest(model, body, stream) {
flushCurrentMessage();
// Remove assistant messages with empty content (can happen when all tool_use blocks were skipped)
result.messages = result.messages.filter((msg) => {
if (msg.role === "assistant" && Array.isArray(msg.content) && msg.content.length === 0) {
return false;
}
return true;
});
// Filter orphaned tool_result blocks whose tool_use_id has no matching tool_use
const allToolUseIds = new Set<string>();
for (const msg of result.messages) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use" && block.id) {
allToolUseIds.add(String(block.id));
}
}
}
}
for (const msg of result.messages) {
if (msg.role === "user" && Array.isArray(msg.content)) {
msg.content = msg.content.filter((block) => {
if (block.type === "tool_result" && block.tool_use_id) {
return allToolUseIds.has(String(block.tool_use_id));
}
return true;
});
}
}
// Remove user messages that became empty after orphan filtering
result.messages = result.messages.filter((msg) => {
if (msg.role === "user" && Array.isArray(msg.content) && msg.content.length === 0) {
return false;
}
return true;
});
// Add cache_control to last assistant message
for (let i = result.messages.length - 1; i >= 0; i--) {
const message = result.messages[i];

View file

@ -0,0 +1,175 @@
import test from "node:test";
import assert from "node:assert/strict";
const { openaiResponsesToOpenAIRequest, openaiToOpenAIResponsesRequest } = await import(
"../../open-sse/translator/request/openai-responses.ts"
);
const { openaiToClaudeRequest } = await import(
"../../open-sse/translator/request/openai-to-claude.ts"
);
test("openaiResponsesToOpenAIRequest: filters orphaned tool messages", () => {
const body = {
model: "gpt-4",
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] },
{ type: "function_call_output", call_id: "call_orphan_1", output: "stale result" },
{ type: "function_call", call_id: "call_valid_1", name: "read_file", arguments: "{}" },
{ type: "function_call_output", call_id: "call_valid_1", output: "file contents" },
],
};
const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, null);
const toolMessages = result.messages.filter((m) => m.role === "tool");
assert.equal(toolMessages.length, 1, "should have exactly 1 tool message");
assert.equal(toolMessages[0].tool_call_id, "call_valid_1");
});
test("openaiResponsesToOpenAIRequest: preserves all messages when no orphans", () => {
const body = {
model: "gpt-4",
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] },
{ type: "function_call", call_id: "call_1", name: "read_file", arguments: "{}" },
{ type: "function_call_output", call_id: "call_1", output: "ok" },
{ type: "function_call", call_id: "call_2", name: "write_file", arguments: "{}" },
{ type: "function_call_output", call_id: "call_2", output: "done" },
],
};
const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, null);
const toolMessages = result.messages.filter((m) => m.role === "tool");
assert.equal(toolMessages.length, 2, "both valid tool results should be preserved");
});
test("openaiToOpenAIResponsesRequest: filters orphaned function_call_output", () => {
const body = {
messages: [
{ role: "system", content: "You are helpful" },
{ role: "user", content: "hello" },
{ role: "tool", tool_call_id: "call_orphan_2", content: "stale" },
{
role: "assistant",
content: null,
tool_calls: [
{ id: "call_valid_2", type: "function", function: { name: "ls", arguments: "{}" } },
],
},
{ role: "tool", tool_call_id: "call_valid_2", content: "files" },
],
};
const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null);
const outputs = result.input.filter((i) => i.type === "function_call_output");
assert.equal(outputs.length, 1, "should have exactly 1 function_call_output");
assert.equal(outputs[0].call_id, "call_valid_2");
});
test("openaiToOpenAIResponsesRequest: preserves all items when no orphans", () => {
const body = {
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
content: null,
tool_calls: [
{ id: "call_a", type: "function", function: { name: "ls", arguments: "{}" } },
],
},
{ role: "tool", tool_call_id: "call_a", content: "result" },
],
};
const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null);
const outputs = result.input.filter((i) => i.type === "function_call_output");
assert.equal(outputs.length, 1, "valid function_call_output should be preserved");
});
test("openaiToClaudeRequest: filters orphaned tool_result blocks", () => {
const body = {
_disableToolPrefix: true,
messages: [
{ role: "user", content: "hello" },
{ role: "tool", tool_call_id: "tu_orphan_1", content: "stale result" },
{
role: "assistant",
content: [{ type: "tool_use", id: "tu_valid_1", name: "read_file", input: {} }],
},
{ role: "tool", tool_call_id: "tu_valid_1", content: "file contents" },
],
};
const result = openaiToClaudeRequest("claude-3", body, true);
const toolResults = [];
for (const msg of result.messages) {
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_result") toolResults.push(block);
}
}
}
assert.equal(toolResults.length, 1, "should have exactly 1 tool_result");
assert.equal(toolResults[0].tool_use_id, "tu_valid_1");
});
test("openaiToClaudeRequest: removes empty user messages after orphan filtering", () => {
const body = {
_disableToolPrefix: true,
messages: [
{ role: "user", content: "hello" },
{ role: "tool", tool_call_id: "tu_orphan_only", content: "stale" },
{ role: "assistant", content: "I can help" },
],
};
const result = openaiToClaudeRequest("claude-3", body, true);
for (const msg of result.messages) {
if (msg.role === "user" && Array.isArray(msg.content)) {
assert.ok(msg.content.length > 0, "user message should not have empty content");
}
}
});
test("openaiToClaudeRequest: removes empty assistant messages", () => {
const body = {
_disableToolPrefix: true,
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
content: [{ type: "tool_use", id: "tu_1", name: "", input: {} }],
},
{ role: "assistant", content: "actual response" },
],
};
const result = openaiToClaudeRequest("claude-3", body, true);
for (const msg of result.messages) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
assert.ok(msg.content.length > 0, "assistant message should not have empty content");
}
}
});
test("openaiToClaudeRequest: preserves valid tool pairs unchanged", () => {
const body = {
_disableToolPrefix: true,
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
content: [{ type: "tool_use", id: "tu_1", name: "read_file", input: {} }],
},
{ role: "tool", tool_call_id: "tu_1", content: "file data" },
{
role: "assistant",
content: [{ type: "tool_use", id: "tu_2", name: "write_file", input: {} }],
},
{ role: "tool", tool_call_id: "tu_2", content: "written" },
],
};
const result = openaiToClaudeRequest("claude-3", body, true);
const toolResults = [];
for (const msg of result.messages) {
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_result") toolResults.push(block);
}
}
}
assert.equal(toolResults.length, 2, "both valid tool_results should be preserved");
});