fix: filter orphan tool_result blocks after sliding window truncation (no summary)

When sliding window truncation removes assistant messages containing tool_use
blocks, user messages with tool_result blocks referencing those removed IDs
were being sent to the API, causing Anthropic to reject the request with
'unexpected tool_use_id in tool_result blocks'.

The existing orphan filtering logic only ran in the summary (condensation)
code path. This adds the same filtering to the no-summary code path in
getEffectiveApiHistory(), ensuring tool_use/tool_result pairs remain atomic
regardless of how truncation occurs.

Fixes #11029
This commit is contained in:
dwebster123 2026-01-27 15:31:28 -08:00
parent 7607c684e9
commit ac1d172e94
2 changed files with 166 additions and 1 deletions

View file

@ -583,6 +583,134 @@ describe("getEffectiveApiHistory", () => {
})
})
describe("getEffectiveApiHistory - orphan tool_result filtering after truncation (no summary)", () => {
it("should filter orphan tool_result blocks when truncation removes assistant tool_use messages", () => {
const truncationId = "trunc-1"
const messages: ApiMessage[] = [
// Truncation marker
{
role: "user",
content: [{ type: "text", text: "[Previous context truncated]" }],
isTruncationMarker: true,
truncationId,
},
// Assistant message with tool_use, hidden by truncation
{
role: "assistant",
content: [{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "test.ts" } }],
truncationParent: truncationId,
},
// User message with tool_result referencing truncated tool_use
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "tool-1", content: "file contents" }],
truncationParent: truncationId,
},
// Visible assistant message with tool_use
{
role: "assistant",
content: [
{ type: "tool_use", id: "tool-2", name: "write_file", input: { path: "out.ts", content: "code" } },
],
},
// Visible user message with tool_result for tool-2
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "tool-2", content: "file written" }],
},
]
const result = getEffectiveApiHistory(messages)
// Should have: truncation marker, visible assistant, visible user (3 messages)
// Truncated assistant and user are filtered by truncationParent
expect(result).toHaveLength(3)
expect(result[0].isTruncationMarker).toBe(true)
expect(result[1].role).toBe("assistant")
expect(result[2].role).toBe("user")
const userContent = result[2].content as any[]
expect(userContent[0].tool_use_id).toBe("tool-2")
})
it("should filter orphan tool_result when user message survives truncation but referenced assistant is truncated", () => {
const truncationId = "trunc-1"
const messages: ApiMessage[] = [
// Truncation marker
{
role: "user",
content: [{ type: "text", text: "[Previous context truncated]" }],
isTruncationMarker: true,
truncationId,
},
// Assistant message with tool_use, hidden by truncation
{
role: "assistant",
content: [{ type: "tool_use", id: "tool-orphan", name: "read_file", input: { path: "test.ts" } }],
truncationParent: truncationId,
},
// User message with orphan tool_result - NOT tagged with truncationParent
// This is the bug scenario: truncation removed the assistant but not the user message
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "tool-orphan", content: "file contents" }],
},
// Visible conversation continues
{
role: "assistant",
content: "Here is the result.",
},
]
const result = getEffectiveApiHistory(messages)
// The orphan tool_result user message should be removed entirely
// Result: truncation marker, assistant text (2 messages)
expect(result).toHaveLength(2)
expect(result[0].isTruncationMarker).toBe(true)
expect(result[1].role).toBe("assistant")
expect(result[1].content).toBe("Here is the result.")
})
it("should keep non-orphan content in mixed user message after truncation", () => {
const truncationId = "trunc-1"
const messages: ApiMessage[] = [
{
role: "user",
content: [{ type: "text", text: "[Previous context truncated]" }],
isTruncationMarker: true,
truncationId,
},
{
role: "assistant",
content: [{ type: "tool_use", id: "tool-orphan", name: "read_file", input: { path: "test.ts" } }],
truncationParent: truncationId,
},
// User message with both orphan tool_result AND text content
{
role: "user",
content: [
{ type: "text", text: "Here's some context" },
{ type: "tool_result", tool_use_id: "tool-orphan", content: "file contents" },
],
},
{
role: "assistant",
content: "Got it.",
},
]
const result = getEffectiveApiHistory(messages)
// Should keep the user message but strip the orphan tool_result
expect(result).toHaveLength(3)
expect(result[0].isTruncationMarker).toBe(true)
const userContent = result[1].content as any[]
expect(userContent).toHaveLength(1)
expect(userContent[0].type).toBe("text")
expect(userContent[0].text).toBe("Here's some context")
})
})
describe("cleanupAfterTruncation", () => {
it("should clear orphaned condenseParent references", () => {
const orphanedCondenseId = "deleted-summary"

View file

@ -489,7 +489,7 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
// Filter out messages whose condenseParent points to an existing summary
// or whose truncationParent points to an existing truncation marker.
// Messages with orphaned parents (summary/marker was deleted) are included.
return messages.filter((msg) => {
const visibleMessages = messages.filter((msg) => {
// Filter out condensed messages if their summary exists
if (msg.condenseParent && existingSummaryIds.has(msg.condenseParent)) {
return false
@ -500,6 +500,43 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
}
return true
})
// Collect all tool_use IDs from visible assistant messages.
// This is needed to filter out orphan tool_result blocks that reference
// tool_use IDs from messages that were truncated away.
const toolUseIds = new Set<string>()
for (const msg of visibleMessages) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use" && (block as Anthropic.Messages.ToolUseBlockParam).id) {
toolUseIds.add((block as Anthropic.Messages.ToolUseBlockParam).id)
}
}
}
}
// Filter out orphan tool_result blocks from user messages
return visibleMessages
.map((msg) => {
if (msg.role === "user" && Array.isArray(msg.content)) {
const filteredContent = msg.content.filter((block) => {
if (block.type === "tool_result") {
return toolUseIds.has((block as Anthropic.Messages.ToolResultBlockParam).tool_use_id)
}
return true
})
// If all content was filtered out, mark for removal
if (filteredContent.length === 0) {
return null
}
// If some content was filtered, return updated message
if (filteredContent.length !== msg.content.length) {
return { ...msg, content: filteredContent }
}
}
return msg
})
.filter((msg): msg is ApiMessage => msg !== null)
}
/**