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

Commit 2f92cb7

Browse files
fix: prevent nested condensing from including previously-condensed content (#10985)
1 parent dd245cc commit 2f92cb7

7 files changed

Lines changed: 284 additions & 107 deletions

File tree

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

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ Line 2
250250
it("should not summarize messages that already contain a recent summary with no new messages", async () => {
251251
const messages: ApiMessage[] = [
252252
{ role: "user", content: "First message with /command" },
253-
{ role: "assistant", content: "Previous summary", isSummary: true },
253+
{ role: "user", content: "Previous summary", isSummary: true },
254254
]
255255

256256
const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, false)
@@ -413,20 +413,5 @@ Line 2
413413
expect(result[1]).toEqual(messages[4])
414414
expect(result[2]).toEqual(messages[5])
415415
})
416-
417-
it("should prepend first user message when summary starts with assistant", () => {
418-
const messages: ApiMessage[] = [
419-
{ role: "user", content: "Original first message" },
420-
{ role: "assistant", content: "Summary content", isSummary: true },
421-
{ role: "user", content: "After summary" },
422-
]
423-
424-
const result = getMessagesSinceLastSummary(messages)
425-
426-
// Should prepend original first message for Bedrock compatibility
427-
expect(result[0]).toEqual(messages[0]) // Original first user message
428-
expect(result[1]).toEqual(messages[1]) // The summary
429-
expect(result[2]).toEqual(messages[2])
430-
})
431416
})
432417
})

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

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -252,38 +252,36 @@ describe("getMessagesSinceLastSummary", () => {
252252
expect(result).toEqual(messages)
253253
})
254254

255-
it("should return messages since the last summary (preserves original first user message when needed)", () => {
255+
it("should return messages since the last summary", () => {
256256
const messages: ApiMessage[] = [
257257
{ role: "user", content: "Hello", ts: 1 },
258258
{ role: "assistant", content: "Hi there", ts: 2 },
259-
{ role: "assistant", content: "Summary of conversation", ts: 3, isSummary: true },
260-
{ role: "user", content: "How are you?", ts: 4 },
261-
{ role: "assistant", content: "I'm good", ts: 5 },
259+
{ role: "user", content: "Summary of conversation", ts: 3, isSummary: true },
260+
{ role: "assistant", content: "How are you?", ts: 4 },
261+
{ role: "user", content: "I'm good", ts: 5 },
262262
]
263263

264264
const result = getMessagesSinceLastSummary(messages)
265265
expect(result).toEqual([
266-
{ role: "user", content: "Hello", ts: 1 },
267-
{ role: "assistant", content: "Summary of conversation", ts: 3, isSummary: true },
268-
{ role: "user", content: "How are you?", ts: 4 },
269-
{ role: "assistant", content: "I'm good", ts: 5 },
266+
{ role: "user", content: "Summary of conversation", ts: 3, isSummary: true },
267+
{ role: "assistant", content: "How are you?", ts: 4 },
268+
{ role: "user", content: "I'm good", ts: 5 },
270269
])
271270
})
272271

273272
it("should handle multiple summary messages and return since the last one", () => {
274273
const messages: ApiMessage[] = [
275274
{ role: "user", content: "Hello", ts: 1 },
276-
{ role: "assistant", content: "First summary", ts: 2, isSummary: true },
277-
{ role: "user", content: "How are you?", ts: 3 },
278-
{ role: "assistant", content: "Second summary", ts: 4, isSummary: true },
279-
{ role: "user", content: "What's new?", ts: 5 },
275+
{ role: "user", content: "First summary", ts: 2, isSummary: true },
276+
{ role: "assistant", content: "How are you?", ts: 3 },
277+
{ role: "user", content: "Second summary", ts: 4, isSummary: true },
278+
{ role: "assistant", content: "What's new?", ts: 5 },
280279
]
281280

282281
const result = getMessagesSinceLastSummary(messages)
283282
expect(result).toEqual([
284-
{ role: "user", content: "Hello", ts: 1 },
285-
{ role: "assistant", content: "Second summary", ts: 4, isSummary: true },
286-
{ role: "user", content: "What's new?", ts: 5 },
283+
{ role: "user", content: "Second summary", ts: 4, isSummary: true },
284+
{ role: "assistant", content: "What's new?", ts: 5 },
287285
])
288286
})
289287

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { describe, it, expect } from "vitest"
2+
import { ApiMessage } from "../../task-persistence/apiMessages"
3+
import { getEffectiveApiHistory, getMessagesSinceLastSummary } from "../index"
4+
5+
describe("nested condensing scenarios", () => {
6+
describe("fresh-start model (user-role summaries)", () => {
7+
it("should return only the latest summary and messages after it", () => {
8+
const condenseId1 = "condense-1"
9+
const condenseId2 = "condense-2"
10+
11+
// Simulate history after two nested condenses with user-role summaries
12+
const history: ApiMessage[] = [
13+
// Original task - condensed in first condense
14+
{ role: "user", content: "Build an app", ts: 100, condenseParent: condenseId1 },
15+
// Messages from first condense
16+
{ role: "assistant", content: "Starting...", ts: 200, condenseParent: condenseId1 },
17+
{ role: "user", content: "Add auth", ts: 300, condenseParent: condenseId1 },
18+
// First summary (user role, fresh-start model) - then condensed in second condense
19+
{
20+
role: "user",
21+
content: [{ type: "text", text: "## Summary 1" }],
22+
ts: 399,
23+
isSummary: true,
24+
condenseId: condenseId1,
25+
condenseParent: condenseId2, // Tagged during second condense
26+
},
27+
// Messages after first condense but before second
28+
{ role: "assistant", content: "Auth added", ts: 400, condenseParent: condenseId2 },
29+
{ role: "user", content: "Add database", ts: 500, condenseParent: condenseId2 },
30+
// Second summary (user role, fresh-start model)
31+
{
32+
role: "user",
33+
content: [{ type: "text", text: "## Summary 2" }],
34+
ts: 599,
35+
isSummary: true,
36+
condenseId: condenseId2,
37+
},
38+
// Messages after second condense (kept messages)
39+
{ role: "assistant", content: "Database added", ts: 600 },
40+
{ role: "user", content: "Now test it", ts: 700 },
41+
]
42+
43+
// Step 1: Get effective history
44+
const effectiveHistory = getEffectiveApiHistory(history)
45+
46+
// Should only contain: Summary2, and messages after it
47+
expect(effectiveHistory.length).toBe(3)
48+
expect(effectiveHistory[0].isSummary).toBe(true)
49+
expect(effectiveHistory[0].condenseId).toBe(condenseId2) // Latest summary
50+
expect(effectiveHistory[1].content).toBe("Database added")
51+
expect(effectiveHistory[2].content).toBe("Now test it")
52+
53+
// Verify NO condensed messages are included
54+
const hasCondensedMessages = effectiveHistory.some(
55+
(msg) => msg.condenseParent && history.some((m) => m.isSummary && m.condenseId === msg.condenseParent),
56+
)
57+
expect(hasCondensedMessages).toBe(false)
58+
59+
// Step 2: Get messages since last summary (on effective history)
60+
const messagesSinceLastSummary = getMessagesSinceLastSummary(effectiveHistory)
61+
62+
// Should be the same as effective history since Summary2 is already at the start
63+
expect(messagesSinceLastSummary.length).toBe(3)
64+
expect(messagesSinceLastSummary[0].isSummary).toBe(true)
65+
expect(messagesSinceLastSummary[0].condenseId).toBe(condenseId2)
66+
67+
// CRITICAL: No previous history (Summary1 or original task) should be included
68+
const hasSummary1 = messagesSinceLastSummary.some((m) => m.condenseId === condenseId1)
69+
expect(hasSummary1).toBe(false)
70+
71+
const hasOriginalTask = messagesSinceLastSummary.some((m) => m.content === "Build an app")
72+
expect(hasOriginalTask).toBe(false)
73+
})
74+
75+
it("should handle triple nested condense correctly", () => {
76+
const condenseId1 = "condense-1"
77+
const condenseId2 = "condense-2"
78+
const condenseId3 = "condense-3"
79+
80+
const history: ApiMessage[] = [
81+
// First condense content
82+
{ role: "user", content: "Task", ts: 100, condenseParent: condenseId1 },
83+
{
84+
role: "user",
85+
content: [{ type: "text", text: "## Summary 1" }],
86+
ts: 199,
87+
isSummary: true,
88+
condenseId: condenseId1,
89+
condenseParent: condenseId2,
90+
},
91+
// Second condense content
92+
{ role: "assistant", content: "After S1", ts: 200, condenseParent: condenseId2 },
93+
{
94+
role: "user",
95+
content: [{ type: "text", text: "## Summary 2" }],
96+
ts: 299,
97+
isSummary: true,
98+
condenseId: condenseId2,
99+
condenseParent: condenseId3,
100+
},
101+
// Third condense content
102+
{ role: "assistant", content: "After S2", ts: 300, condenseParent: condenseId3 },
103+
{
104+
role: "user",
105+
content: [{ type: "text", text: "## Summary 3" }],
106+
ts: 399,
107+
isSummary: true,
108+
condenseId: condenseId3,
109+
},
110+
// Current messages
111+
{ role: "assistant", content: "Current work", ts: 400 },
112+
]
113+
114+
const effectiveHistory = getEffectiveApiHistory(history)
115+
116+
// Should only contain Summary3 and current work
117+
expect(effectiveHistory.length).toBe(2)
118+
expect(effectiveHistory[0].condenseId).toBe(condenseId3)
119+
expect(effectiveHistory[1].content).toBe("Current work")
120+
121+
const messagesSinceLastSummary = getMessagesSinceLastSummary(effectiveHistory)
122+
expect(messagesSinceLastSummary.length).toBe(2)
123+
124+
// No previous summaries should be included
125+
const hasPreviousSummaries = messagesSinceLastSummary.some(
126+
(m) => m.condenseId === condenseId1 || m.condenseId === condenseId2,
127+
)
128+
expect(hasPreviousSummaries).toBe(false)
129+
})
130+
})
131+
132+
describe("getMessagesSinceLastSummary behavior with full vs effective history", () => {
133+
it("should return consistent results when called with full history vs effective history", () => {
134+
const condenseId = "condense-1"
135+
136+
const fullHistory: ApiMessage[] = [
137+
{ role: "user", content: "Original task", ts: 100, condenseParent: condenseId },
138+
{ role: "assistant", content: "Response", ts: 200, condenseParent: condenseId },
139+
{
140+
role: "user",
141+
content: [{ type: "text", text: "Summary" }],
142+
ts: 299,
143+
isSummary: true,
144+
condenseId,
145+
},
146+
{ role: "assistant", content: "After summary", ts: 300 },
147+
]
148+
149+
// Called with FULL history (as in summarizeConversation)
150+
const fromFullHistory = getMessagesSinceLastSummary(fullHistory)
151+
152+
// Called with EFFECTIVE history (as in attemptApiRequest)
153+
const effectiveHistory = getEffectiveApiHistory(fullHistory)
154+
const fromEffectiveHistory = getMessagesSinceLastSummary(effectiveHistory)
155+
156+
// Both should return the same messages when summary is user role
157+
expect(fromFullHistory.length).toBe(fromEffectiveHistory.length)
158+
159+
// Both should start with the summary
160+
expect(fromFullHistory[0].isSummary).toBe(true)
161+
expect(fromEffectiveHistory[0].isSummary).toBe(true)
162+
})
163+
164+
it("should not include condensed original task in effective history", () => {
165+
const condenseId1 = "condense-1"
166+
const condenseId2 = "condense-2"
167+
168+
// Scenario: Two nested condenses with user-role summaries
169+
const fullHistory: ApiMessage[] = [
170+
{ role: "user", content: "Original task - should NOT appear", ts: 100, condenseParent: condenseId1 },
171+
{ role: "assistant", content: "Old response", ts: 200, condenseParent: condenseId1 },
172+
// First summary (user role, fresh-start model), then condensed again
173+
{
174+
role: "user",
175+
content: [{ type: "text", text: "Summary 1" }],
176+
ts: 299,
177+
isSummary: true,
178+
condenseId: condenseId1,
179+
condenseParent: condenseId2,
180+
},
181+
{ role: "assistant", content: "After S1", ts: 300, condenseParent: condenseId2 },
182+
// Second summary (user role, fresh-start model)
183+
{
184+
role: "user",
185+
content: [{ type: "text", text: "Summary 2" }],
186+
ts: 399,
187+
isSummary: true,
188+
condenseId: condenseId2,
189+
},
190+
{ role: "assistant", content: "Current message", ts: 400 },
191+
]
192+
193+
const effectiveHistory = getEffectiveApiHistory(fullHistory)
194+
expect(effectiveHistory.length).toBe(2) // Summary2 + Current message
195+
196+
const messagesSinceLastSummary = getMessagesSinceLastSummary(effectiveHistory)
197+
198+
// The original task should NOT be included
199+
const hasOriginalTask = messagesSinceLastSummary.some((m) =>
200+
typeof m.content === "string"
201+
? m.content.includes("Original task")
202+
: JSON.stringify(m.content).includes("Original task"),
203+
)
204+
expect(hasOriginalTask).toBe(false)
205+
206+
// Summary1 should not be included (it was condensed)
207+
const hasSummary1 = messagesSinceLastSummary.some((m) => m.condenseId === condenseId1)
208+
expect(hasSummary1).toBe(false)
209+
})
210+
})
211+
})

0 commit comments

Comments
 (0)