Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit ac1d172

Browse files
committed
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
1 parent 7607c68 commit ac1d172

2 files changed

Lines changed: 166 additions & 1 deletion

File tree

src/core/condense/__tests__/index.spec.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,134 @@ describe("getEffectiveApiHistory", () => {
583583
})
584584
})
585585

586+
describe("getEffectiveApiHistory - orphan tool_result filtering after truncation (no summary)", () => {
587+
it("should filter orphan tool_result blocks when truncation removes assistant tool_use messages", () => {
588+
const truncationId = "trunc-1"
589+
const messages: ApiMessage[] = [
590+
// Truncation marker
591+
{
592+
role: "user",
593+
content: [{ type: "text", text: "[Previous context truncated]" }],
594+
isTruncationMarker: true,
595+
truncationId,
596+
},
597+
// Assistant message with tool_use, hidden by truncation
598+
{
599+
role: "assistant",
600+
content: [{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "test.ts" } }],
601+
truncationParent: truncationId,
602+
},
603+
// User message with tool_result referencing truncated tool_use
604+
{
605+
role: "user",
606+
content: [{ type: "tool_result", tool_use_id: "tool-1", content: "file contents" }],
607+
truncationParent: truncationId,
608+
},
609+
// Visible assistant message with tool_use
610+
{
611+
role: "assistant",
612+
content: [
613+
{ type: "tool_use", id: "tool-2", name: "write_file", input: { path: "out.ts", content: "code" } },
614+
],
615+
},
616+
// Visible user message with tool_result for tool-2
617+
{
618+
role: "user",
619+
content: [{ type: "tool_result", tool_use_id: "tool-2", content: "file written" }],
620+
},
621+
]
622+
623+
const result = getEffectiveApiHistory(messages)
624+
625+
// Should have: truncation marker, visible assistant, visible user (3 messages)
626+
// Truncated assistant and user are filtered by truncationParent
627+
expect(result).toHaveLength(3)
628+
expect(result[0].isTruncationMarker).toBe(true)
629+
expect(result[1].role).toBe("assistant")
630+
expect(result[2].role).toBe("user")
631+
const userContent = result[2].content as any[]
632+
expect(userContent[0].tool_use_id).toBe("tool-2")
633+
})
634+
635+
it("should filter orphan tool_result when user message survives truncation but referenced assistant is truncated", () => {
636+
const truncationId = "trunc-1"
637+
const messages: ApiMessage[] = [
638+
// Truncation marker
639+
{
640+
role: "user",
641+
content: [{ type: "text", text: "[Previous context truncated]" }],
642+
isTruncationMarker: true,
643+
truncationId,
644+
},
645+
// Assistant message with tool_use, hidden by truncation
646+
{
647+
role: "assistant",
648+
content: [{ type: "tool_use", id: "tool-orphan", name: "read_file", input: { path: "test.ts" } }],
649+
truncationParent: truncationId,
650+
},
651+
// User message with orphan tool_result - NOT tagged with truncationParent
652+
// This is the bug scenario: truncation removed the assistant but not the user message
653+
{
654+
role: "user",
655+
content: [{ type: "tool_result", tool_use_id: "tool-orphan", content: "file contents" }],
656+
},
657+
// Visible conversation continues
658+
{
659+
role: "assistant",
660+
content: "Here is the result.",
661+
},
662+
]
663+
664+
const result = getEffectiveApiHistory(messages)
665+
666+
// The orphan tool_result user message should be removed entirely
667+
// Result: truncation marker, assistant text (2 messages)
668+
expect(result).toHaveLength(2)
669+
expect(result[0].isTruncationMarker).toBe(true)
670+
expect(result[1].role).toBe("assistant")
671+
expect(result[1].content).toBe("Here is the result.")
672+
})
673+
674+
it("should keep non-orphan content in mixed user message after truncation", () => {
675+
const truncationId = "trunc-1"
676+
const messages: ApiMessage[] = [
677+
{
678+
role: "user",
679+
content: [{ type: "text", text: "[Previous context truncated]" }],
680+
isTruncationMarker: true,
681+
truncationId,
682+
},
683+
{
684+
role: "assistant",
685+
content: [{ type: "tool_use", id: "tool-orphan", name: "read_file", input: { path: "test.ts" } }],
686+
truncationParent: truncationId,
687+
},
688+
// User message with both orphan tool_result AND text content
689+
{
690+
role: "user",
691+
content: [
692+
{ type: "text", text: "Here's some context" },
693+
{ type: "tool_result", tool_use_id: "tool-orphan", content: "file contents" },
694+
],
695+
},
696+
{
697+
role: "assistant",
698+
content: "Got it.",
699+
},
700+
]
701+
702+
const result = getEffectiveApiHistory(messages)
703+
704+
// Should keep the user message but strip the orphan tool_result
705+
expect(result).toHaveLength(3)
706+
expect(result[0].isTruncationMarker).toBe(true)
707+
const userContent = result[1].content as any[]
708+
expect(userContent).toHaveLength(1)
709+
expect(userContent[0].type).toBe("text")
710+
expect(userContent[0].text).toBe("Here's some context")
711+
})
712+
})
713+
586714
describe("cleanupAfterTruncation", () => {
587715
it("should clear orphaned condenseParent references", () => {
588716
const orphanedCondenseId = "deleted-summary"

src/core/condense/index.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
489489
// Filter out messages whose condenseParent points to an existing summary
490490
// or whose truncationParent points to an existing truncation marker.
491491
// Messages with orphaned parents (summary/marker was deleted) are included.
492-
return messages.filter((msg) => {
492+
const visibleMessages = messages.filter((msg) => {
493493
// Filter out condensed messages if their summary exists
494494
if (msg.condenseParent && existingSummaryIds.has(msg.condenseParent)) {
495495
return false
@@ -500,6 +500,43 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
500500
}
501501
return true
502502
})
503+
504+
// Collect all tool_use IDs from visible assistant messages.
505+
// This is needed to filter out orphan tool_result blocks that reference
506+
// tool_use IDs from messages that were truncated away.
507+
const toolUseIds = new Set<string>()
508+
for (const msg of visibleMessages) {
509+
if (msg.role === "assistant" && Array.isArray(msg.content)) {
510+
for (const block of msg.content) {
511+
if (block.type === "tool_use" && (block as Anthropic.Messages.ToolUseBlockParam).id) {
512+
toolUseIds.add((block as Anthropic.Messages.ToolUseBlockParam).id)
513+
}
514+
}
515+
}
516+
}
517+
518+
// Filter out orphan tool_result blocks from user messages
519+
return visibleMessages
520+
.map((msg) => {
521+
if (msg.role === "user" && Array.isArray(msg.content)) {
522+
const filteredContent = msg.content.filter((block) => {
523+
if (block.type === "tool_result") {
524+
return toolUseIds.has((block as Anthropic.Messages.ToolResultBlockParam).tool_use_id)
525+
}
526+
return true
527+
})
528+
// If all content was filtered out, mark for removal
529+
if (filteredContent.length === 0) {
530+
return null
531+
}
532+
// If some content was filtered, return updated message
533+
if (filteredContent.length !== msg.content.length) {
534+
return { ...msg, content: filteredContent }
535+
}
536+
}
537+
return msg
538+
})
539+
.filter((msg): msg is ApiMessage => msg !== null)
503540
}
504541

505542
/**

0 commit comments

Comments
 (0)