Skip to content

Commit 0755fa5

Browse files
committed
fix: align truncation with effective history filtering
1 parent e4f95cb commit 0755fa5

3 files changed

Lines changed: 87 additions & 49 deletions

File tree

src/core/condense/index.ts

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -544,19 +544,33 @@ export function getMessagesSinceLastSummary(messages: ApiMessage[]): ApiMessage[
544544
* @returns The filtered history that should be sent to the API
545545
*/
546546
export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
547+
const effectiveHistoryWithIndices = getEffectiveApiHistoryWithIndices(messages)
548+
return effectiveHistoryWithIndices.map(({ message }) => message)
549+
}
550+
551+
export function getEffectiveApiHistoryIndices(messages: ApiMessage[]): number[] {
552+
const effectiveHistoryWithIndices = getEffectiveApiHistoryWithIndices(messages)
553+
return effectiveHistoryWithIndices.map(({ index }) => index)
554+
}
555+
556+
function getEffectiveApiHistoryWithIndices(messages: ApiMessage[]): Array<{ message: ApiMessage; index: number }> {
547557
// Find the most recent summary message
548558
const lastSummary = findLast(messages, (msg) => msg.isSummary === true)
549559

550560
if (lastSummary) {
551561
// Fresh start model: return only messages from the summary onwards
552562
const summaryIndex = messages.indexOf(lastSummary)
553-
let messagesFromSummary = messages.slice(summaryIndex)
563+
let messagesFromSummary = messages.slice(summaryIndex).map((message, offset) => ({
564+
message,
565+
index: summaryIndex + offset,
566+
}))
554567

555568
// Collect all tool_use IDs from assistant messages in the result
556569
// This is needed to filter out orphan tool_result blocks that reference
557570
// tool_use IDs from messages that were condensed away
558571
const toolUseIds = new Set<string>()
559-
for (const msg of messagesFromSummary) {
572+
for (const { message } of messagesFromSummary) {
573+
const msg = message
560574
if (msg.role === "assistant" && Array.isArray(msg.content)) {
561575
for (const block of msg.content) {
562576
if (block.type === "tool_use" && (block as Anthropic.Messages.ToolUseBlockParam).id) {
@@ -568,7 +582,8 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
568582

569583
// Filter out orphan tool_result blocks from user messages
570584
messagesFromSummary = messagesFromSummary
571-
.map((msg) => {
585+
.map(({ message, index }) => {
586+
const msg = message
572587
if (msg.role === "user" && Array.isArray(msg.content)) {
573588
const filteredContent = msg.content.filter((block) => {
574589
if (block.type === "tool_result") {
@@ -582,22 +597,24 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
582597
}
583598
// If some content was filtered, return updated message
584599
if (filteredContent.length !== msg.content.length) {
585-
return { ...msg, content: filteredContent }
600+
return { message: { ...msg, content: filteredContent }, index }
586601
}
587602
}
588-
return msg
603+
return { message: msg, index }
589604
})
590-
.filter((msg): msg is ApiMessage => msg !== null)
605+
.filter((entry): entry is { message: ApiMessage; index: number } => entry !== null)
591606

592607
// Still need to filter out any truncated messages within this range
593608
const existingTruncationIds = new Set<string>()
594-
for (const msg of messagesFromSummary) {
609+
for (const { message } of messagesFromSummary) {
610+
const msg = message
595611
if (msg.isTruncationMarker && msg.truncationId) {
596612
existingTruncationIds.add(msg.truncationId)
597613
}
598614
}
599615

600-
return messagesFromSummary.filter((msg) => {
616+
return messagesFromSummary.filter(({ message }) => {
617+
const msg = message
601618
// Filter out truncated messages if their truncation marker exists
602619
if (msg.truncationParent && existingTruncationIds.has(msg.truncationParent)) {
603620
return false
@@ -626,17 +643,20 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
626643
// Filter out messages whose condenseParent points to an existing summary
627644
// or whose truncationParent points to an existing truncation marker.
628645
// Messages with orphaned parents (summary/marker was deleted) are included.
629-
return messages.filter((msg) => {
630-
// Filter out condensed messages if their summary exists
631-
if (msg.condenseParent && existingSummaryIds.has(msg.condenseParent)) {
632-
return false
633-
}
634-
// Filter out truncated messages if their truncation marker exists
635-
if (msg.truncationParent && existingTruncationIds.has(msg.truncationParent)) {
636-
return false
637-
}
638-
return true
639-
})
646+
return messages
647+
.map((message, index) => ({ message, index }))
648+
.filter(({ message }) => {
649+
const msg = message
650+
// Filter out condensed messages if their summary exists
651+
if (msg.condenseParent && existingSummaryIds.has(msg.condenseParent)) {
652+
return false
653+
}
654+
// Filter out truncated messages if their truncation marker exists
655+
if (msg.truncationParent && existingTruncationIds.has(msg.truncationParent)) {
656+
return false
657+
}
658+
return true
659+
})
640660
}
641661

642662
/**

src/core/context-management/__tests__/truncation.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,50 @@ describe("Non-Destructive Sliding Window Truncation", () => {
9494
const result = truncateConversation(manyMessages, 0.5, "test-task-id")
9595
expect(result.messagesRemoved).toBe(4)
9696
})
97+
98+
it("should ignore orphan tool_result-only messages when truncating fresh-start history", () => {
99+
const condenseId = "condense-1"
100+
const freshStartMessages: ApiMessage[] = [
101+
{ role: "user", content: "Original task", ts: 1000, condenseParent: condenseId },
102+
{
103+
role: "assistant",
104+
content: [{ type: "tool_use", id: "tool-orphan", name: "read_file", input: { path: "a.ts" } }],
105+
ts: 1100,
106+
condenseParent: condenseId,
107+
},
108+
{
109+
role: "user",
110+
content: [{ type: "text", text: "Summary" }],
111+
ts: 1200,
112+
isSummary: true,
113+
condenseId,
114+
},
115+
{
116+
role: "user",
117+
content: [{ type: "tool_result", tool_use_id: "tool-orphan", content: "orphan result" }],
118+
ts: 1300,
119+
},
120+
{ role: "assistant", content: "Visible assistant 1", ts: 1400 },
121+
{ role: "user", content: "Visible user 1", ts: 1500 },
122+
{ role: "assistant", content: "Visible assistant 2", ts: 1600 },
123+
{ role: "user", content: "Visible user 2", ts: 1700 },
124+
]
125+
126+
const effectiveBefore = getEffectiveApiHistory(freshStartMessages)
127+
expect(effectiveBefore).toHaveLength(5)
128+
129+
const result = truncateConversation(freshStartMessages, 0.5, "test-task-id")
130+
131+
expect(result.messagesRemoved).toBe(2)
132+
expect(result.messages[3].truncationParent).toBeUndefined()
133+
expect(result.messages[4].truncationParent).toBe(result.truncationId)
134+
expect(result.messages[5].truncationParent).toBe(result.truncationId)
135+
136+
const effectiveAfter = getEffectiveApiHistory(result.messages)
137+
expect(effectiveAfter).toHaveLength(4)
138+
expect(effectiveAfter[0].isSummary).toBe(true)
139+
expect(effectiveAfter[1].isTruncationMarker).toBe(true)
140+
})
97141
})
98142

99143
describe("getEffectiveApiHistory()", () => {

src/core/context-management/index.ts

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
MAX_CONDENSE_THRESHOLD,
99
MIN_CONDENSE_THRESHOLD,
1010
getEffectiveApiHistory,
11+
getEffectiveApiHistoryIndices,
1112
summarizeConversation,
1213
SummarizeResponse,
1314
} from "../condense"
@@ -55,35 +56,6 @@ export type TruncationResult = {
5556
messagesRemoved: number
5657
}
5758

58-
function getEffectiveVisibleIndices(messages: ApiMessage[]): number[] {
59-
const lastSummaryIndexReverse = [...messages].reverse().findIndex((message) => message.isSummary)
60-
const firstEffectiveIndex = lastSummaryIndexReverse === -1 ? 0 : messages.length - lastSummaryIndexReverse - 1
61-
62-
const existingTruncationIds = new Set<string>()
63-
for (const message of messages.slice(firstEffectiveIndex)) {
64-
if (message.isTruncationMarker && message.truncationId) {
65-
existingTruncationIds.add(message.truncationId)
66-
}
67-
}
68-
69-
const visibleIndices: number[] = []
70-
for (let index = firstEffectiveIndex; index < messages.length; index++) {
71-
const message = messages[index]
72-
73-
if (message.isTruncationMarker) {
74-
continue
75-
}
76-
77-
if (message.truncationParent && existingTruncationIds.has(message.truncationParent)) {
78-
continue
79-
}
80-
81-
visibleIndices.push(index)
82-
}
83-
84-
return visibleIndices
85-
}
86-
8759
/**
8860
* Truncates a conversation by tagging messages as hidden instead of removing them.
8961
*
@@ -108,7 +80,9 @@ export function truncateConversation(messages: ApiMessage[], fracToRemove: numbe
10880
// Only truncate messages that are still part of the effective API history.
10981
// Prior condensed history remains stored for rewind, but it should not consume
11082
// the fallback truncation slice.
111-
const visibleIndices = getEffectiveVisibleIndices(messages)
83+
const visibleIndices = getEffectiveApiHistoryIndices(messages).filter(
84+
(index) => !messages[index].isTruncationMarker,
85+
)
11286

11387
// Calculate how many visible messages to truncate (excluding first visible message)
11488
const visibleCount = visibleIndices.length

0 commit comments

Comments
 (0)