From ac1d172e94fdcef4c278229a77ea3710a742e741 Mon Sep 17 00:00:00 2001 From: dwebster123 Date: Tue, 27 Jan 2026 15:31:28 -0800 Subject: [PATCH] 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 --- src/core/condense/__tests__/index.spec.ts | 128 ++++++++++++++++++++++ src/core/condense/index.ts | 39 ++++++- 2 files changed, 166 insertions(+), 1 deletion(-) diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index c8bc5ee8ef..fff7435906 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -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" diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 313bfcebb6..4cc1ce071e 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -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() + 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) } /**