Skip to content

Commit e4f95cb

Browse files
committed
fix: truncate effective history after condense
1 parent 0e72da2 commit e4f95cb

2 files changed

Lines changed: 72 additions & 14 deletions

File tree

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

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,35 @@ describe("Context Management", () => {
188188
// Last message should NOT be tagged (now at index 4)
189189
expect(result.messages[4].truncationParent).toBeUndefined()
190190
})
191+
192+
it("should truncate from the effective history when earlier messages were condensed", () => {
193+
const condenseId = "prior-condense"
194+
const messages: ApiMessage[] = [
195+
{ role: "user", content: "Hidden user", condenseParent: condenseId },
196+
{ role: "assistant", content: "Hidden assistant", condenseParent: condenseId },
197+
{ role: "user", content: "Earlier summary", isSummary: true, condenseId },
198+
{ role: "assistant", content: "Visible assistant 1" },
199+
{ role: "user", content: "Visible user 1" },
200+
{ role: "assistant", content: "Visible assistant 2" },
201+
{ role: "user", content: "Visible user 2" },
202+
]
203+
204+
const result = truncateConversation(messages, 0.5, taskId)
205+
206+
expect(result.messagesRemoved).toBe(2)
207+
208+
// Messages hidden behind the summary should stay untouched.
209+
expect(result.messages[0].truncationParent).toBeUndefined()
210+
expect(result.messages[1].truncationParent).toBeUndefined()
211+
212+
// The summary remains the anchor, and truncation starts after it.
213+
expect(result.messages[2].isSummary).toBe(true)
214+
expect(result.messages[3].truncationParent).toBe(result.truncationId)
215+
expect(result.messages[4].truncationParent).toBe(result.truncationId)
216+
expect(result.messages[5].isTruncationMarker).toBe(true)
217+
expect(result.messages[6].truncationParent).toBeUndefined()
218+
expect(result.messages[7].truncationParent).toBeUndefined()
219+
})
191220
})
192221

193222
/**
@@ -1722,19 +1751,21 @@ describe("Context Management", () => {
17221751
const totalTokens = 70001
17231752
const condenseId = "prior-condense"
17241753
const hiddenContent = "hidden historical content ".repeat(4000)
1754+
const visibleContent = "visible content that should actually be truncated ".repeat(200)
17251755

17261756
const messages: ApiMessage[] = [
17271757
{ role: "user", content: hiddenContent, condenseParent: condenseId },
17281758
{ role: "assistant", content: hiddenContent, condenseParent: condenseId },
17291759
{ role: "user", content: hiddenContent, condenseParent: condenseId },
17301760
{ role: "user", content: "Earlier summary", isSummary: true, condenseId },
1731-
{ role: "assistant", content: "Recent visible reply" },
1732-
{ role: "user", content: "Recent visible follow-up" },
1733-
{ role: "assistant", content: "Most recent visible reply" },
1761+
{ role: "assistant", content: visibleContent },
1762+
{ role: "user", content: visibleContent },
1763+
{ role: "assistant", content: visibleContent },
17341764
{ role: "user", content: "" },
17351765
]
17361766

17371767
const systemPrompt = "System prompt for truncation recount"
1768+
const effectiveTokensBefore = await countEffectiveHistoryTokens(messages, systemPrompt)
17381769

17391770
const result = await manageContext({
17401771
messages,
@@ -1755,6 +1786,7 @@ describe("Context Management", () => {
17551786

17561787
const expectedEffectiveTokens = await countEffectiveHistoryTokens(result.messages, systemPrompt)
17571788
expect(result.newContextTokensAfterTruncation).toBe(expectedEffectiveTokens)
1789+
expect(result.newContextTokensAfterTruncation).toBeLessThan(effectiveTokensBefore)
17581790
expect(result.newContextTokensAfterTruncation).toBeLessThan(result.prevContextTokens)
17591791
})
17601792
})

src/core/context-management/index.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,42 @@ export type TruncationResult = {
5555
messagesRemoved: number
5656
}
5757

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+
5887
/**
5988
* Truncates a conversation by tagging messages as hidden instead of removing them.
6089
*
61-
* The first message is always retained, and a specified fraction (rounded to an even number)
62-
* of messages from the beginning (excluding the first) is tagged with truncationParent.
63-
* A truncation marker is inserted to track where truncation occurred.
90+
* The first message in the effective API history is always retained, and a specified fraction
91+
* (rounded to an even number) of messages from the beginning of that effective history
92+
* (excluding the first effective message) is tagged with truncationParent. A truncation marker
93+
* is inserted to track where truncation occurred.
6494
*
6595
* This implements non-destructive sliding window truncation, allowing messages to be
6696
* restored if the user rewinds past the truncation point.
@@ -75,14 +105,10 @@ export function truncateConversation(messages: ApiMessage[], fracToRemove: numbe
75105

76106
const truncationId = crypto.randomUUID()
77107

78-
// Filter to only visible messages (those not already truncated)
79-
// We need to track original indices to correctly tag messages in the full array
80-
const visibleIndices: number[] = []
81-
messages.forEach((msg, index) => {
82-
if (!msg.truncationParent && !msg.isTruncationMarker) {
83-
visibleIndices.push(index)
84-
}
85-
})
108+
// Only truncate messages that are still part of the effective API history.
109+
// Prior condensed history remains stored for rewind, but it should not consume
110+
// the fallback truncation slice.
111+
const visibleIndices = getEffectiveVisibleIndices(messages)
86112

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

0 commit comments

Comments
 (0)