Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions src/core/condense/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
39 changes: 38 additions & 1 deletion src/core/condense/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}

/**
Expand Down
Loading