fix: restore context when rewinding after condense (#8295) (#9665)

This commit is contained in:
Hannes Rudolph 2025-12-03 09:40:39 -07:00 committed by GitHub
parent a4f4f35b46
commit 23605bed93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1794 additions and 124 deletions

View file

@ -175,6 +175,7 @@ export const clineSays = [
"diff_error",
"condense_context",
"condense_context_error",
"sliding_window_truncation",
"codebase_search_result",
"user_edit_todos",
] as const
@ -203,10 +204,25 @@ export const contextCondenseSchema = z.object({
prevContextTokens: z.number(),
newContextTokens: z.number(),
summary: z.string(),
condenseId: z.string().optional(),
})
export type ContextCondense = z.infer<typeof contextCondenseSchema>
/**
* ContextTruncation
*
* Used to track sliding window truncation events for the UI.
*/
export const contextTruncationSchema = z.object({
truncationId: z.string(),
messagesRemoved: z.number(),
prevContextTokens: z.number(),
})
export type ContextTruncation = z.infer<typeof contextTruncationSchema>
/**
* ClineMessage
*/
@ -224,6 +240,7 @@ export const clineMessageSchema = z.object({
checkpoint: z.record(z.string(), z.unknown()).optional(),
progressStatus: toolProgressStatusSchema.optional(),
contextCondense: contextCondenseSchema.optional(),
contextTruncation: contextTruncationSchema.optional(),
isProtected: z.boolean().optional(),
apiProtocol: z.union([z.literal("openai"), z.literal("anthropic")]).optional(),
isAnswered: z.boolean().optional(),

View file

@ -6,7 +6,12 @@ import { TelemetryService } from "@roo-code/telemetry"
import { BaseProvider } from "../../../api/providers/base-provider"
import { ApiMessage } from "../../task-persistence/apiMessages"
import { summarizeConversation, getMessagesSinceLastSummary, N_MESSAGES_TO_KEEP } from "../index"
import {
summarizeConversation,
getMessagesSinceLastSummary,
getEffectiveApiHistory,
N_MESSAGES_TO_KEEP,
} from "../index"
// Create a mock ApiHandler for testing
class MockApiHandler extends BaseProvider {
@ -83,11 +88,13 @@ describe("Condense", () => {
expect(summaryMessage).toBeTruthy()
expect(summaryMessage?.content).toBe("Mock summary of the conversation")
// Verify we have the expected number of messages
// [first message, summary, last N messages]
expect(result.messages.length).toBe(1 + 1 + N_MESSAGES_TO_KEEP)
// With non-destructive condensing, all messages are retained (tagged but not deleted)
// Use getEffectiveApiHistory to verify the effective view matches the old behavior
expect(result.messages.length).toBe(messages.length + 1) // All original messages + summary
const effectiveHistory = getEffectiveApiHistory(result.messages)
expect(effectiveHistory.length).toBe(1 + 1 + N_MESSAGES_TO_KEEP) // first + summary + last N
// Verify the last N messages are preserved
// Verify the last N messages are preserved (same messages by reference)
const lastMessages = result.messages.slice(-N_MESSAGES_TO_KEEP)
expect(lastMessages).toEqual(messages.slice(-N_MESSAGES_TO_KEEP))
})

View file

@ -2,6 +2,7 @@
import type { Mock } from "vitest"
import { Anthropic } from "@anthropic-ai/sdk"
import { TelemetryService } from "@roo-code/telemetry"
import { ApiHandler } from "../../../api"
@ -11,6 +12,8 @@ import {
summarizeConversation,
getMessagesSinceLastSummary,
getKeepMessagesWithToolBlocks,
getEffectiveApiHistory,
cleanupAfterTruncation,
N_MESSAGES_TO_KEEP,
} from "../index"
@ -407,22 +410,28 @@ describe("summarizeConversation", () => {
expect(mockApiHandler.createMessage).toHaveBeenCalled()
expect(maybeRemoveImageBlocks).toHaveBeenCalled()
// Verify the structure of the result
// The result should be: first message + summary + last N messages
expect(result.messages.length).toBe(1 + 1 + N_MESSAGES_TO_KEEP) // First + summary + last N
// With non-destructive condensing, the result contains ALL original messages
// plus the summary message. Condensed messages are tagged but not deleted.
// Use getEffectiveApiHistory to verify the effective API view matches the old behavior.
expect(result.messages.length).toBe(messages.length + 1) // All original messages + summary
// Check that the first message is preserved
expect(result.messages[0]).toEqual(messages[0])
// Check that the summary message was inserted correctly
const summaryMessage = result.messages[1]
expect(summaryMessage.role).toBe("assistant")
expect(summaryMessage.content).toBe("This is a summary")
expect(summaryMessage.isSummary).toBe(true)
// Find the summary message (it has isSummary: true)
const summaryMessage = result.messages.find((m) => m.isSummary)
expect(summaryMessage).toBeDefined()
expect(summaryMessage!.role).toBe("assistant")
expect(summaryMessage!.content).toBe("This is a summary")
expect(summaryMessage!.isSummary).toBe(true)
// Check that the last N_MESSAGES_TO_KEEP messages are preserved
const lastMessages = messages.slice(-N_MESSAGES_TO_KEEP)
expect(result.messages.slice(-N_MESSAGES_TO_KEEP)).toEqual(lastMessages)
// Verify that the effective API history matches expected: first + summary + last N messages
const effectiveHistory = getEffectiveApiHistory(result.messages)
expect(effectiveHistory.length).toBe(1 + 1 + N_MESSAGES_TO_KEEP) // First + summary + last N
// Check that condensed messages are properly tagged
const condensedMessages = result.messages.filter((m) => m.condenseParent !== undefined)
expect(condensedMessages.length).toBeGreaterThan(0)
// Check the cost and token counts
expect(result.cost).toBe(0.05)
@ -643,9 +652,11 @@ describe("summarizeConversation", () => {
prevContextTokens,
)
// Should successfully summarize
// Result should be: first message + summary + last N messages
expect(result.messages.length).toBe(1 + 1 + N_MESSAGES_TO_KEEP) // First + summary + last N
// With non-destructive condensing, result contains all messages plus summary
// Use getEffectiveApiHistory to verify the effective API view
expect(result.messages.length).toBe(messages.length + 1) // All messages + summary
const effectiveHistory = getEffectiveApiHistory(result.messages)
expect(effectiveHistory.length).toBe(1 + 1 + N_MESSAGES_TO_KEEP) // First + summary + last N
expect(result.cost).toBe(0.03)
expect(result.summary).toBe("Concise summary")
expect(result.error).toBeUndefined()
@ -809,23 +820,27 @@ describe("summarizeConversation", () => {
true, // useNativeTools - required for tool_use block preservation
)
// Verify the summary message has content array with text and tool_use blocks
const summaryMessage = result.messages[1]
expect(summaryMessage.role).toBe("assistant")
expect(summaryMessage.isSummary).toBe(true)
expect(Array.isArray(summaryMessage.content)).toBe(true)
// Find the summary message
const summaryMessage = result.messages.find((m) => m.isSummary)
expect(summaryMessage).toBeDefined()
expect(summaryMessage!.role).toBe("assistant")
expect(summaryMessage!.isSummary).toBe(true)
expect(Array.isArray(summaryMessage!.content)).toBe(true)
// Content should be [text block, tool_use block]
const content = summaryMessage.content as any[]
const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[]
expect(content).toHaveLength(2)
expect(content[0].type).toBe("text")
expect(content[0].text).toBe("Summary of conversation")
expect((content[0] as Anthropic.Messages.TextBlockParam).text).toBe("Summary of conversation")
expect(content[1].type).toBe("tool_use")
expect(content[1].id).toBe("toolu_123")
expect(content[1].name).toBe("read_file")
expect((content[1] as Anthropic.Messages.ToolUseBlockParam).id).toBe("toolu_123")
expect((content[1] as Anthropic.Messages.ToolUseBlockParam).name).toBe("read_file")
// Verify the keepMessages are preserved correctly
expect(result.messages).toHaveLength(1 + 1 + N_MESSAGES_TO_KEEP) // first + summary + last 3
// With non-destructive condensing, all messages are retained plus the summary
expect(result.messages.length).toBe(messages.length + 1) // all original + summary
// Verify effective history matches expected
const effectiveHistory = getEffectiveApiHistory(result.messages)
expect(effectiveHistory.length).toBe(1 + 1 + N_MESSAGES_TO_KEEP) // first + summary + last 3
expect(result.error).toBeUndefined()
})
@ -961,12 +976,16 @@ describe("summarizeConversation", () => {
true,
)
const summaryMessage = result.messages[1]
expect(Array.isArray(summaryMessage.content)).toBe(true)
const summaryContent = summaryMessage.content as any[]
// Find the summary message (it has isSummary: true)
const summaryMessage = result.messages.find((m) => m.isSummary)
expect(summaryMessage).toBeDefined()
expect(Array.isArray(summaryMessage!.content)).toBe(true)
const summaryContent = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[]
expect(summaryContent[0]).toEqual({ type: "text", text: "This is a summary" })
const preservedToolUses = summaryContent.filter((block) => block.type === "tool_use")
const preservedToolUses = summaryContent.filter(
(block): block is Anthropic.Messages.ToolUseBlockParam => block.type === "tool_use",
)
expect(preservedToolUses).toHaveLength(2)
expect(preservedToolUses.map((block) => block.id)).toEqual(["toolu_parallel_1", "toolu_parallel_2"])
})

View file

@ -0,0 +1,542 @@
// npx vitest src/core/condense/__tests__/rewind-after-condense.spec.ts
/**
* Regression tests for the issue: "Rewind after Condense is broken"
* https://github.com/RooCodeInc/Roo-Code/issues/8295
*
* These tests verify that when a user rewinds (deletes/truncates) their conversation
* after a condense operation, the orphaned condensed messages are properly reactivated
* so they can be sent to the API again.
*/
import { TelemetryService } from "@roo-code/telemetry"
import { getEffectiveApiHistory, cleanupAfterTruncation } from "../index"
import { ApiMessage } from "../../task-persistence/apiMessages"
describe("Rewind After Condense - Issue #8295", () => {
beforeEach(() => {
if (!TelemetryService.hasInstance()) {
TelemetryService.createInstance([])
}
})
describe("getEffectiveApiHistory", () => {
it("should filter out messages tagged with condenseParent", () => {
const condenseId = "summary-123"
const messages: ApiMessage[] = [
{ role: "user", content: "First message", ts: 1 },
{ role: "assistant", content: "First response", ts: 2, condenseParent: condenseId },
{ role: "user", content: "Second message", ts: 3, condenseParent: condenseId },
{ role: "assistant", content: "Summary", ts: 4, isSummary: true, condenseId },
{ role: "user", content: "Third message", ts: 5 },
{ role: "assistant", content: "Third response", ts: 6 },
]
const effective = getEffectiveApiHistory(messages)
// Effective history should be: first message, summary, third message, third response
expect(effective.length).toBe(4)
expect(effective[0].content).toBe("First message")
expect(effective[1].isSummary).toBe(true)
expect(effective[2].content).toBe("Third message")
expect(effective[3].content).toBe("Third response")
})
it("should include messages without condenseParent", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "Hello", ts: 1 },
{ role: "assistant", content: "Hi", ts: 2 },
]
const effective = getEffectiveApiHistory(messages)
expect(effective.length).toBe(2)
expect(effective).toEqual(messages)
})
it("should handle empty messages array", () => {
const effective = getEffectiveApiHistory([])
expect(effective).toEqual([])
})
})
describe("cleanupAfterTruncation", () => {
it("should clear condenseParent when summary message is deleted", () => {
const condenseId = "summary-123"
const messages: ApiMessage[] = [
{ role: "user", content: "First message", ts: 1 },
{ role: "assistant", content: "First response", ts: 2, condenseParent: condenseId },
{ role: "user", content: "Second message", ts: 3, condenseParent: condenseId },
// Summary is NOT in the array (was truncated/deleted)
]
const cleaned = cleanupAfterTruncation(messages)
// All condenseParent tags should be cleared since summary is gone
expect(cleaned[1].condenseParent).toBeUndefined()
expect(cleaned[2].condenseParent).toBeUndefined()
})
it("should preserve condenseParent when summary message still exists", () => {
const condenseId = "summary-123"
const messages: ApiMessage[] = [
{ role: "user", content: "First message", ts: 1 },
{ role: "assistant", content: "First response", ts: 2, condenseParent: condenseId },
{ role: "assistant", content: "Summary", ts: 3, isSummary: true, condenseId },
]
const cleaned = cleanupAfterTruncation(messages)
// condenseParent should remain since summary exists
expect(cleaned[1].condenseParent).toBe(condenseId)
})
it("should handle multiple condense operations with different IDs", () => {
const condenseId1 = "summary-1"
const condenseId2 = "summary-2"
const messages: ApiMessage[] = [
{ role: "user", content: "Message 1", ts: 1, condenseParent: condenseId1 },
{ role: "assistant", content: "Summary 1", ts: 2, isSummary: true, condenseId: condenseId1 },
{ role: "user", content: "Message 2", ts: 3, condenseParent: condenseId2 },
// Summary 2 is NOT present (was truncated)
]
const cleaned = cleanupAfterTruncation(messages)
// condenseId1 should remain (summary exists)
expect(cleaned[0].condenseParent).toBe(condenseId1)
// condenseId2 should be cleared (summary deleted)
expect(cleaned[2].condenseParent).toBeUndefined()
})
it("should not modify messages without condenseParent", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "Hello", ts: 1 },
{ role: "assistant", content: "Hi", ts: 2 },
]
const cleaned = cleanupAfterTruncation(messages)
expect(cleaned).toEqual(messages)
})
it("should handle empty messages array", () => {
const cleaned = cleanupAfterTruncation([])
expect(cleaned).toEqual([])
})
})
describe("Rewind scenario: truncate after condense", () => {
it("should reactivate condensed messages when their summary is deleted via truncation", () => {
const condenseId = "summary-abc"
// Simulate a conversation after condensing
const fullHistory: ApiMessage[] = [
{ role: "user", content: "Initial task", ts: 1 },
{ role: "assistant", content: "Working on it", ts: 2, condenseParent: condenseId },
{ role: "user", content: "Continue", ts: 3, condenseParent: condenseId },
{ role: "assistant", content: "Summary of work so far", ts: 4, isSummary: true, condenseId },
{ role: "user", content: "Now do this", ts: 5 },
{ role: "assistant", content: "Done", ts: 6 },
{ role: "user", content: "And this", ts: 7 },
{ role: "assistant", content: "Also done", ts: 8 },
]
// Verify effective history before truncation
const effectiveBefore = getEffectiveApiHistory(fullHistory)
// Should be: first message, summary, last 4 messages
expect(effectiveBefore.length).toBe(6)
// Simulate rewind: user truncates back to message ts=4 (keeping 0-3)
const truncatedHistory = fullHistory.slice(0, 4) // Keep first, condensed1, condensed2, summary
// After truncation, the summary is still there, so condensed messages remain condensed
const cleanedAfterKeepingSummary = cleanupAfterTruncation(truncatedHistory)
expect(cleanedAfterKeepingSummary[1].condenseParent).toBe(condenseId)
expect(cleanedAfterKeepingSummary[2].condenseParent).toBe(condenseId)
// Now simulate a more aggressive rewind: delete back to message ts=2
const aggressiveTruncate = fullHistory.slice(0, 2) // Keep only first message and first response
// The condensed messages should now be reactivated since summary is gone
const cleanedAfterDeletingSummary = cleanupAfterTruncation(aggressiveTruncate)
expect(cleanedAfterDeletingSummary[1].condenseParent).toBeUndefined()
// Verify effective history after cleanup
const effectiveAfterCleanup = getEffectiveApiHistory(cleanedAfterDeletingSummary)
// Now both messages should be active (no condensed filtering)
expect(effectiveAfterCleanup.length).toBe(2)
expect(effectiveAfterCleanup[0].content).toBe("Initial task")
expect(effectiveAfterCleanup[1].content).toBe("Working on it")
})
it("should properly restore context after rewind when summary was deleted", () => {
const condenseId = "summary-xyz"
// Scenario: Most of the conversation was condensed, but the summary was deleted.
// getEffectiveApiHistory already correctly handles orphaned messages (includes them
// when their summary doesn't exist). cleanupAfterTruncation cleans up the tags.
const messages: ApiMessage[] = [
{ role: "user", content: "Start", ts: 1 },
{ role: "assistant", content: "Response 1", ts: 2, condenseParent: condenseId },
{ role: "user", content: "More", ts: 3, condenseParent: condenseId },
{ role: "assistant", content: "Response 2", ts: 4, condenseParent: condenseId },
{ role: "user", content: "Even more", ts: 5, condenseParent: condenseId },
// Summary was deleted (not present), so these are "orphaned" messages
]
// getEffectiveApiHistory already includes orphaned messages (summary doesn't exist)
const effectiveBefore = getEffectiveApiHistory(messages)
expect(effectiveBefore.length).toBe(5) // All messages visible since summary was deleted
expect(effectiveBefore[0].content).toBe("Start")
expect(effectiveBefore[1].content).toBe("Response 1")
// cleanupAfterTruncation clears the orphaned condenseParent tags for data hygiene
const cleaned = cleanupAfterTruncation(messages)
// Verify condenseParent was cleared for all orphaned messages
expect(cleaned[1].condenseParent).toBeUndefined()
expect(cleaned[2].condenseParent).toBeUndefined()
expect(cleaned[3].condenseParent).toBeUndefined()
expect(cleaned[4].condenseParent).toBeUndefined()
// After cleanup, effective history is the same (all visible)
const effectiveAfter = getEffectiveApiHistory(cleaned)
expect(effectiveAfter.length).toBe(5) // All messages visible
})
it("should hide condensed messages when their summary still exists", () => {
const condenseId = "summary-exists"
// Scenario: Messages were condensed and summary exists - condensed messages should be hidden
const messages: ApiMessage[] = [
{ role: "user", content: "Start", ts: 1 },
{ role: "assistant", content: "Response 1", ts: 2, condenseParent: condenseId },
{ role: "user", content: "More", ts: 3, condenseParent: condenseId },
{ role: "assistant", content: "Summary", ts: 4, isSummary: true, condenseId },
{ role: "user", content: "After summary", ts: 5 },
]
// Effective history should hide condensed messages since summary exists
const effective = getEffectiveApiHistory(messages)
expect(effective.length).toBe(3) // Start, Summary, After summary
expect(effective[0].content).toBe("Start")
expect(effective[1].content).toBe("Summary")
expect(effective[2].content).toBe("After summary")
// cleanupAfterTruncation should NOT clear condenseParent since summary exists
const cleaned = cleanupAfterTruncation(messages)
expect(cleaned[1].condenseParent).toBe(condenseId)
expect(cleaned[2].condenseParent).toBe(condenseId)
})
describe("Summary timestamp collision prevention", () => {
it("should give summary a unique timestamp that does not collide with first kept message", () => {
// Scenario: After condense, the summary message should have a unique timestamp
// (ts - 1 from the first kept message) to prevent lookup collisions.
//
// This test verifies the expected data structure after condense:
// - Summary should have ts = firstKeptMessage.ts - 1
// - Summary and first kept message should NOT share the same timestamp
//
// With real millisecond timestamps, the summary timestamp (firstKeptTs - 1) will
// never collide with an existing message since timestamps are always increasing.
const condenseId = "summary-unique-ts"
// Use timestamps that represent a realistic scenario where firstKeptTs - 1
// does not collide with any existing message timestamp
const firstKeptTs = 1000
// Simulate post-condense state where summary has unique timestamp (firstKeptTs - 1)
// In real usage, condensed messages have timestamps like 100, 200, 300...
// and firstKeptTs is much larger, so firstKeptTs - 1 = 999 is unique
const messagesAfterCondense: ApiMessage[] = [
{ role: "user", content: "Initial task", ts: 100 },
{ role: "assistant", content: "Response 1", ts: 200, condenseParent: condenseId },
{ role: "user", content: "Continue", ts: 300, condenseParent: condenseId },
{ role: "assistant", content: "Response 2", ts: 400, condenseParent: condenseId },
{ role: "user", content: "More work", ts: 500, condenseParent: condenseId },
{ role: "assistant", content: "Response 3", ts: 600, condenseParent: condenseId },
{ role: "user", content: "Even more", ts: 700, condenseParent: condenseId },
// Summary gets ts = firstKeptTs - 1 = 999, which is unique
{ role: "assistant", content: "Summary", ts: firstKeptTs - 1, isSummary: true, condenseId },
// First kept message
{ role: "user", content: "First kept message", ts: firstKeptTs },
{ role: "assistant", content: "Response to first kept", ts: 1100 },
{ role: "user", content: "Last message", ts: 1200 },
]
// Find all messages with the same timestamp as the summary
const summaryTs = firstKeptTs - 1
const messagesWithSummaryTs = messagesAfterCondense.filter((msg) => msg.ts === summaryTs)
// There should be exactly one message with the summary timestamp
expect(messagesWithSummaryTs.length).toBe(1)
expect(messagesWithSummaryTs[0].isSummary).toBe(true)
// The first kept message should have a different timestamp
const firstKeptMessage = messagesAfterCondense.find(
(msg) => msg.ts === firstKeptTs && !msg.isSummary && !msg.condenseParent,
)
expect(firstKeptMessage).toBeDefined()
expect(firstKeptMessage?.content).toBe("First kept message")
expect(firstKeptMessage?.ts).not.toBe(summaryTs)
})
it("should not target summary message when looking up first kept message by timestamp", () => {
// This test verifies that when timestamps are unique (as they should be after the fix),
// looking up by the first kept message's timestamp returns that message, not the summary.
const condenseId = "summary-lookup-test"
const firstKeptTs = 8
const messages: ApiMessage[] = [
{ role: "user", content: "Initial", ts: 1 },
{ role: "assistant", content: "Summary", ts: firstKeptTs - 1, isSummary: true, condenseId },
{ role: "user", content: "First kept message", ts: firstKeptTs },
{ role: "assistant", content: "Response", ts: 9 },
]
// Look up by first kept message's timestamp
const foundMessage = messages.find((msg) => msg.ts === firstKeptTs)
expect(foundMessage).toBeDefined()
expect(foundMessage?.content).toBe("First kept message")
expect(foundMessage?.isSummary).toBeUndefined()
// Looking up by summary's timestamp should find the summary
const foundSummary = messages.find((msg) => msg.ts === firstKeptTs - 1)
expect(foundSummary).toBeDefined()
expect(foundSummary?.isSummary).toBe(true)
})
})
describe("Message preservation after condense operations", () => {
/**
* These tests verify that the correct user and assistant messages are preserved
* and sent to the LLM after condense operations. With N_MESSAGES_TO_KEEP = 3,
* condense should always preserve:
* - The first message (never condensed)
* - The active summary
* - The last 3 kept messages
*/
it("should preserve correct messages after single condense (10 messages)", () => {
const condenseId = "summary-single"
// Simulate storage state after condensing 10 messages with N_MESSAGES_TO_KEEP = 3
// Original: [msg1, msg2, msg3, msg4, msg5, msg6, msg7, msg8, msg9, msg10]
// After condense:
// - msg1 preserved (first message)
// - msg2-msg7 tagged with condenseParent
// - summary inserted with ts = msg8.ts - 1
// - msg8, msg9, msg10 kept
const storageAfterCondense: ApiMessage[] = [
{ role: "user", content: "Task: Build a feature", ts: 100 },
{ role: "assistant", content: "I'll help with that", ts: 200, condenseParent: condenseId },
{ role: "user", content: "Start with the API", ts: 300, condenseParent: condenseId },
{ role: "assistant", content: "Creating API endpoints", ts: 400, condenseParent: condenseId },
{ role: "user", content: "Add validation", ts: 500, condenseParent: condenseId },
{ role: "assistant", content: "Added validation logic", ts: 600, condenseParent: condenseId },
{ role: "user", content: "Now the tests", ts: 700, condenseParent: condenseId },
// Summary inserted before first kept message
{
role: "assistant",
content: "Summary: Built API with validation, working on tests",
ts: 799, // msg8.ts - 1
isSummary: true,
condenseId,
},
// Last 3 kept messages (N_MESSAGES_TO_KEEP = 3)
{ role: "assistant", content: "Writing unit tests now", ts: 800 },
{ role: "user", content: "Include edge cases", ts: 900 },
{ role: "assistant", content: "Added edge case tests", ts: 1000 },
]
const effective = getEffectiveApiHistory(storageAfterCondense)
// Should send exactly 5 messages to LLM:
// 1. First message (user) - preserved
// 2. Summary (assistant)
// 3-5. Last 3 kept messages
expect(effective.length).toBe(5)
// Verify exact order and content
expect(effective[0].role).toBe("user")
expect(effective[0].content).toBe("Task: Build a feature")
expect(effective[1].role).toBe("assistant")
expect(effective[1].isSummary).toBe(true)
expect(effective[1].content).toBe("Summary: Built API with validation, working on tests")
expect(effective[2].role).toBe("assistant")
expect(effective[2].content).toBe("Writing unit tests now")
expect(effective[3].role).toBe("user")
expect(effective[3].content).toBe("Include edge cases")
expect(effective[4].role).toBe("assistant")
expect(effective[4].content).toBe("Added edge case tests")
// Verify condensed messages are NOT in effective history
const condensedContents = ["I'll help with that", "Start with the API", "Creating API endpoints"]
for (const content of condensedContents) {
expect(effective.find((m) => m.content === content)).toBeUndefined()
}
})
it("should preserve correct messages after double condense (10 msgs → condense → 10 more msgs → condense)", () => {
const condenseId1 = "summary-first"
const condenseId2 = "summary-second"
// Simulate storage state after TWO condense operations
// First condense: 10 messages condensed, summary1 created
// Then: 10 more messages added (making effective history have 15 messages)
// Second condense: summary1 + msg8-msg17 condensed, summary2 created
//
// Storage after double condense:
const storageAfterDoubleCondense: ApiMessage[] = [
// First message - never condensed
{ role: "user", content: "Initial task: Build a full app", ts: 100 },
// Messages from first condense (tagged with condenseId1)
{ role: "assistant", content: "Starting the project", ts: 200, condenseParent: condenseId1 },
{ role: "user", content: "Add authentication", ts: 300, condenseParent: condenseId1 },
{ role: "assistant", content: "Added auth module", ts: 400, condenseParent: condenseId1 },
{ role: "user", content: "Add database", ts: 500, condenseParent: condenseId1 },
{ role: "assistant", content: "Set up database", ts: 600, condenseParent: condenseId1 },
{ role: "user", content: "Connect them", ts: 700, condenseParent: condenseId1 },
// First summary - now ALSO tagged with condenseId2 (from second condense)
{
role: "assistant",
content: "Summary1: Built auth and database",
ts: 799,
isSummary: true,
condenseId: condenseId1,
condenseParent: condenseId2, // Tagged during second condense!
},
// Messages after first condense but before second (tagged with condenseId2)
{ role: "assistant", content: "Continuing development", ts: 800, condenseParent: condenseId2 },
{ role: "user", content: "Add API routes", ts: 900, condenseParent: condenseId2 },
{ role: "assistant", content: "Created REST endpoints", ts: 1000, condenseParent: condenseId2 },
{ role: "user", content: "Add validation", ts: 1100, condenseParent: condenseId2 },
{ role: "assistant", content: "Added input validation", ts: 1200, condenseParent: condenseId2 },
{ role: "user", content: "Add error handling", ts: 1300, condenseParent: condenseId2 },
{ role: "assistant", content: "Implemented error handlers", ts: 1400, condenseParent: condenseId2 },
{ role: "user", content: "Add logging", ts: 1500, condenseParent: condenseId2 },
{ role: "assistant", content: "Set up logging system", ts: 1600, condenseParent: condenseId2 },
{ role: "user", content: "Now write tests", ts: 1700, condenseParent: condenseId2 },
// Second summary - inserted before the last 3 kept messages
{
role: "assistant",
content: "Summary2: App complete with auth, DB, API, validation, errors, logging. Now testing.",
ts: 1799, // msg18.ts - 1
isSummary: true,
condenseId: condenseId2,
},
// Last 3 kept messages (N_MESSAGES_TO_KEEP = 3)
{ role: "assistant", content: "Writing integration tests", ts: 1800 },
{ role: "user", content: "Test the auth flow", ts: 1900 },
{ role: "assistant", content: "Auth tests passing", ts: 2000 },
]
const effective = getEffectiveApiHistory(storageAfterDoubleCondense)
// Should send exactly 5 messages to LLM:
// 1. First message (user) - preserved
// 2. Summary2 (assistant) - the ACTIVE summary
// 3-5. Last 3 kept messages
expect(effective.length).toBe(5)
// Verify exact order and content
expect(effective[0].role).toBe("user")
expect(effective[0].content).toBe("Initial task: Build a full app")
expect(effective[1].role).toBe("assistant")
expect(effective[1].isSummary).toBe(true)
expect(effective[1].condenseId).toBe(condenseId2) // Must be the SECOND summary
expect(effective[1].content).toContain("Summary2")
expect(effective[2].role).toBe("assistant")
expect(effective[2].content).toBe("Writing integration tests")
expect(effective[3].role).toBe("user")
expect(effective[3].content).toBe("Test the auth flow")
expect(effective[4].role).toBe("assistant")
expect(effective[4].content).toBe("Auth tests passing")
// Verify Summary1 is NOT in effective history (it's tagged with condenseParent)
const summary1 = effective.find((m) => m.content?.toString().includes("Summary1"))
expect(summary1).toBeUndefined()
// Verify all condensed messages are NOT in effective history
const condensedContents = [
"Starting the project",
"Added auth module",
"Continuing development",
"Created REST endpoints",
"Implemented error handlers",
]
for (const content of condensedContents) {
expect(effective.find((m) => m.content === content)).toBeUndefined()
}
})
it("should maintain proper user/assistant alternation in effective history", () => {
const condenseId = "summary-alternation"
// Verify that after condense, the effective history maintains proper
// user/assistant message alternation (important for API compatibility)
const storage: ApiMessage[] = [
{ role: "user", content: "Start task", ts: 100 },
{ role: "assistant", content: "Response 1", ts: 200, condenseParent: condenseId },
{ role: "user", content: "Continue", ts: 300, condenseParent: condenseId },
{ role: "assistant", content: "Summary text", ts: 399, isSummary: true, condenseId },
// Kept messages - should alternate properly
{ role: "assistant", content: "Response after summary", ts: 400 },
{ role: "user", content: "User message", ts: 500 },
{ role: "assistant", content: "Final response", ts: 600 },
]
const effective = getEffectiveApiHistory(storage)
// Verify the sequence: user, assistant(summary), assistant, user, assistant
// Note: Having two assistant messages in a row (summary + next response) is valid
// because the summary replaces what would have been multiple messages
expect(effective[0].role).toBe("user")
expect(effective[1].role).toBe("assistant")
expect(effective[1].isSummary).toBe(true)
expect(effective[2].role).toBe("assistant")
expect(effective[3].role).toBe("user")
expect(effective[4].role).toBe("assistant")
})
it("should preserve timestamps in chronological order in effective history", () => {
const condenseId = "summary-timestamps"
const storage: ApiMessage[] = [
{ role: "user", content: "First", ts: 100 },
{ role: "assistant", content: "Condensed", ts: 200, condenseParent: condenseId },
{ role: "assistant", content: "Summary", ts: 299, isSummary: true, condenseId },
{ role: "user", content: "Kept 1", ts: 300 },
{ role: "assistant", content: "Kept 2", ts: 400 },
{ role: "user", content: "Kept 3", ts: 500 },
]
const effective = getEffectiveApiHistory(storage)
// Verify timestamps are in ascending order
for (let i = 1; i < effective.length; i++) {
const prevTs = effective[i - 1].ts ?? 0
const currTs = effective[i].ts ?? 0
expect(currTs).toBeGreaterThan(prevTs)
}
})
})
})
})

View file

@ -1,4 +1,5 @@
import Anthropic from "@anthropic-ai/sdk"
import crypto from "crypto"
import { TelemetryService } from "@roo-code/telemetry"
@ -117,6 +118,7 @@ export type SummarizeResponse = {
cost: number // The cost of the summarization operation
newContextTokens?: number // The number of tokens in the context for the next API request
error?: string // Populated iff the operation fails: error message shown to the user on failure (see Task.ts)
condenseId?: string // The unique ID of the created Summary message, for linking to condense_context clineMessage
}
/**
@ -266,15 +268,51 @@ export async function summarizeConversation(
summaryContent = summary
}
// Generate a unique condenseId for this summary
const condenseId = crypto.randomUUID()
// Use first kept message's timestamp minus 1 to ensure unique timestamp for summary.
// Fallback to Date.now() if keepMessages is empty (shouldn't happen due to earlier checks).
const firstKeptTs = keepMessages[0]?.ts ?? Date.now()
const summaryMessage: ApiMessage = {
role: "assistant",
content: summaryContent,
ts: keepMessages[0].ts,
ts: firstKeptTs - 1, // Unique timestamp before first kept message to avoid collision
isSummary: true,
condenseId, // Unique ID for this summary, used to track which messages it replaces
}
// Reconstruct messages: [first message, summary, last N messages]
const newMessages = [firstMessage, summaryMessage, ...keepMessages]
// NON-DESTRUCTIVE CONDENSE:
// Instead of deleting middle messages, tag them with condenseParent so they can be
// restored if the user rewinds to a point before the summary.
//
// Storage structure after condense:
// [firstMessage, msg2(parent=X), ..., msg8(parent=X), summary(id=X), msg9, msg10, msg11]
//
// Effective for API (filtered by getEffectiveApiHistory):
// [firstMessage, summary, msg9, msg10, msg11]
// Tag middle messages with condenseParent (skip first message, skip last N messages)
const newMessages = messages.map((msg, index) => {
// First message stays as-is
if (index === 0) {
return msg
}
// Messages in the "keep" range stay as-is
if (index >= keepStartIndex) {
return msg
}
// Middle messages get tagged with condenseParent (unless they already have one from a previous condense)
// If they already have a condenseParent, we leave it - nested condense is handled by filtering
if (!msg.condenseParent) {
return { ...msg, condenseParent: condenseId }
}
return msg
})
// Insert the summary message right before the keep messages
newMessages.splice(keepStartIndex, 0, summaryMessage)
// Count the tokens in the context for the next API request
// We only estimate the tokens in summaryMesage if outputTokens is 0, otherwise we use outputTokens
@ -293,7 +331,7 @@ export async function summarizeConversation(
const error = t("common:errors.condense_context_grew")
return { ...response, cost, error }
}
return { messages: newMessages, summary, cost, newContextTokens }
return { messages: newMessages, summary, cost, newContextTokens, condenseId }
}
/* Returns the list of all messages since the last summary message, including the summary. Returns all messages if there is no summary. */
@ -329,3 +367,108 @@ export function getMessagesSinceLastSummary(messages: ApiMessage[]): ApiMessage[
return messagesSinceSummary
}
/**
* Filters the API conversation history to get the "effective" messages to send to the API.
* Messages with a condenseParent that points to an existing summary are filtered out,
* as they have been replaced by that summary.
* Messages with a truncationParent that points to an existing truncation marker are also filtered out,
* as they have been hidden by sliding window truncation.
*
* This allows non-destructive condensing and truncation where messages are tagged but not deleted,
* enabling accurate rewind operations while still sending condensed/truncated history to the API.
*
* @param messages - The full API conversation history including tagged messages
* @returns The filtered history that should be sent to the API
*/
export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
// Collect all condenseIds of summaries that exist in the current history
const existingSummaryIds = new Set<string>()
// Collect all truncationIds of truncation markers that exist in the current history
const existingTruncationIds = new Set<string>()
for (const msg of messages) {
if (msg.isSummary && msg.condenseId) {
existingSummaryIds.add(msg.condenseId)
}
if (msg.isTruncationMarker && msg.truncationId) {
existingTruncationIds.add(msg.truncationId)
}
}
// 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) => {
// Filter out condensed messages if their summary exists
if (msg.condenseParent && existingSummaryIds.has(msg.condenseParent)) {
return false
}
// Filter out truncated messages if their truncation marker exists
if (msg.truncationParent && existingTruncationIds.has(msg.truncationParent)) {
return false
}
return true
})
}
/**
* Cleans up orphaned condenseParent and truncationParent references after a truncation operation (rewind/delete).
* When a summary message or truncation marker is deleted, messages that were tagged with its ID
* should have their parent reference cleared so they become active again.
*
* This function should be called after any operation that truncates the API history
* to ensure messages are properly restored when their summary or truncation marker is deleted.
*
* @param messages - The API conversation history after truncation
* @returns The cleaned history with orphaned condenseParent and truncationParent fields cleared
*/
export function cleanupAfterTruncation(messages: ApiMessage[]): ApiMessage[] {
// Collect all condenseIds of summaries that still exist
const existingSummaryIds = new Set<string>()
// Collect all truncationIds of truncation markers that still exist
const existingTruncationIds = new Set<string>()
for (const msg of messages) {
if (msg.isSummary && msg.condenseId) {
existingSummaryIds.add(msg.condenseId)
}
if (msg.isTruncationMarker && msg.truncationId) {
existingTruncationIds.add(msg.truncationId)
}
}
// Clear orphaned parent references for messages whose summary or truncation marker was deleted
return messages.map((msg) => {
let needsUpdate = false
// Check for orphaned condenseParent
if (msg.condenseParent && !existingSummaryIds.has(msg.condenseParent)) {
needsUpdate = true
}
// Check for orphaned truncationParent
if (msg.truncationParent && !existingTruncationIds.has(msg.truncationParent)) {
needsUpdate = true
}
if (needsUpdate) {
// Create a new object without orphaned parent references
const { condenseParent, truncationParent, ...rest } = msg
const result: ApiMessage = rest as ApiMessage
// Keep condenseParent if its summary still exists
if (condenseParent && existingSummaryIds.has(condenseParent)) {
result.condenseParent = condenseParent
}
// Keep truncationParent if its truncation marker still exists
if (truncationParent && existingTruncationIds.has(truncationParent)) {
result.truncationParent = truncationParent
}
return result
}
return msg
})
}

View file

@ -65,10 +65,14 @@ describe("Context Management", () => {
// With 2 messages after the first, 0.5 fraction means remove 1 message
// But 1 is odd, so it rounds down to 0 (to make it even)
expect(result.length).toBe(3) // First message + 2 remaining messages
expect(result[0]).toEqual(messages[0])
expect(result[1]).toEqual(messages[1])
expect(result[2]).toEqual(messages[2])
// Result should have messages + truncation marker
expect(result.messages.length).toBe(4) // First message + truncation marker + 2 remaining messages
expect(result.messages[0]).toEqual(messages[0])
// messages[1] is the truncation marker
expect(result.messages[1].isTruncationMarker).toBe(true)
// Original messages[1] and messages[2] are at indices 2 and 3 now
expect(result.messages[2].content).toEqual(messages[1].content)
expect(result.messages[3].content).toEqual(messages[2].content)
})
it("should remove the specified fraction of messages (rounded to even number)", () => {
@ -84,10 +88,17 @@ describe("Context Management", () => {
// 2 is already even, so no rounding needed
const result = truncateConversation(messages, 0.5, taskId)
expect(result.length).toBe(3)
expect(result[0]).toEqual(messages[0])
expect(result[1]).toEqual(messages[3])
expect(result[2]).toEqual(messages[4])
// Should have all original messages + truncation marker
expect(result.messages.length).toBe(6) // 5 original + 1 marker
expect(result.messagesRemoved).toBe(2)
expect(result.messages[0]).toEqual(messages[0])
expect(result.messages[1].isTruncationMarker).toBe(true)
// Messages 2 and 3 (indices 1 and 2 from original) should be tagged
expect(result.messages[2].truncationParent).toBe(result.truncationId)
expect(result.messages[3].truncationParent).toBe(result.truncationId)
// Messages 4 and 5 (indices 3 and 4 from original) should NOT be tagged
expect(result.messages[4].truncationParent).toBeUndefined()
expect(result.messages[5].truncationParent).toBeUndefined()
})
it("should round to an even number of messages to remove", () => {
@ -105,8 +116,10 @@ describe("Context Management", () => {
// 1.8 rounds down to 1, then to 0 to make it even
const result = truncateConversation(messages, 0.3, taskId)
expect(result.length).toBe(7) // No messages removed
expect(result).toEqual(messages)
expect(result.messagesRemoved).toBe(0) // No messages removed
// Should still have truncation marker inserted
expect(result.messages.length).toBe(8) // 7 original + 1 marker
expect(result.messages[1].isTruncationMarker).toBe(true)
})
it("should handle edge case with fracToRemove = 0", () => {
@ -118,7 +131,10 @@ describe("Context Management", () => {
const result = truncateConversation(messages, 0, taskId)
expect(result).toEqual(messages)
expect(result.messagesRemoved).toBe(0)
// Should have original messages + truncation marker
expect(result.messages.length).toBe(4)
expect(result.messages[1].isTruncationMarker).toBe(true)
})
it("should handle edge case with fracToRemove = 1", () => {
@ -133,9 +149,16 @@ describe("Context Management", () => {
// But 3 is odd, so it rounds down to 2 to make it even
const result = truncateConversation(messages, 1, taskId)
expect(result.length).toBe(2)
expect(result[0]).toEqual(messages[0])
expect(result[1]).toEqual(messages[3])
expect(result.messagesRemoved).toBe(2)
// Should have all original messages + truncation marker
expect(result.messages.length).toBe(5) // 4 original + 1 marker
expect(result.messages[0]).toEqual(messages[0])
expect(result.messages[1].isTruncationMarker).toBe(true)
// Messages at indices 2 and 3 should be tagged (original indices 1 and 2)
expect(result.messages[2].truncationParent).toBe(result.truncationId)
expect(result.messages[3].truncationParent).toBe(result.truncationId)
// Last message should NOT be tagged
expect(result.messages[4].truncationParent).toBeUndefined()
})
})
@ -289,14 +312,6 @@ describe("Context Management", () => {
{ ...messages[messages.length - 1], content: "" },
]
// When truncating, always uses 0.5 fraction
// With 4 messages after the first, 0.5 fraction means remove 2 messages
const expectedMessages = [
messagesWithSmallContent[0],
messagesWithSmallContent[3],
messagesWithSmallContent[4],
]
const result = await manageContext({
messages: messagesWithSmallContent,
totalTokens,
@ -311,12 +326,14 @@ describe("Context Management", () => {
currentProfileId: "default",
})
expect(result).toEqual({
messages: expectedMessages,
summary: "",
cost: 0,
prevContextTokens: totalTokens,
})
// Should have truncation
expect(result.truncationId).toBeDefined()
expect(result.messagesRemoved).toBe(2) // With 4 messages after first, 0.5 fraction = 2 to remove
expect(result.summary).toBe("")
expect(result.cost).toBe(0)
expect(result.prevContextTokens).toBe(totalTokens)
// Should have all original messages + truncation marker (non-destructive)
expect(result.messages.length).toBe(6) // 5 original + 1 marker
})
it("should work with non-prompt caching models the same as prompt caching models", async () => {
@ -360,10 +377,14 @@ describe("Context Management", () => {
currentProfileId: "default",
})
expect(result1.messages).toEqual(result2.messages)
// For truncation results, we can't compare messages directly because
// truncationId is randomly generated. Compare structure instead.
expect(result1.messages.length).toEqual(result2.messages.length)
expect(result1.summary).toEqual(result2.summary)
expect(result1.cost).toEqual(result2.cost)
expect(result1.prevContextTokens).toEqual(result2.prevContextTokens)
expect(result1.truncationId).toBeDefined()
expect(result2.truncationId).toBeDefined()
// Test above threshold
const aboveThreshold = 70001
@ -395,10 +416,14 @@ describe("Context Management", () => {
currentProfileId: "default",
})
expect(result3.messages).toEqual(result4.messages)
// For truncation results, we can't compare messages directly because
// truncationId is randomly generated. Compare structure instead.
expect(result3.messages.length).toEqual(result4.messages.length)
expect(result3.summary).toEqual(result4.summary)
expect(result3.cost).toEqual(result4.cost)
expect(result3.prevContextTokens).toEqual(result4.prevContextTokens)
expect(result3.truncationId).toBeDefined()
expect(result4.truncationId).toBeDefined()
})
it("should consider incoming content when deciding to truncate", async () => {
@ -510,14 +535,6 @@ describe("Context Management", () => {
{ ...messages[messages.length - 1], content: "" },
]
// When truncating, always uses 0.5 fraction
// With 4 messages after the first, 0.5 fraction means remove 2 messages
const expectedResult = [
messagesWithSmallContent[0],
messagesWithSmallContent[3],
messagesWithSmallContent[4],
]
const result = await manageContext({
messages: messagesWithSmallContent,
totalTokens,
@ -531,12 +548,15 @@ describe("Context Management", () => {
profileThresholds: {},
currentProfileId: "default",
})
expect(result).toEqual({
messages: expectedResult,
summary: "",
cost: 0,
prevContextTokens: totalTokens,
})
// Should have truncation
expect(result.truncationId).toBeDefined()
expect(result.messagesRemoved).toBe(2) // With 4 messages after first, 0.5 fraction = 2 to remove
expect(result.summary).toBe("")
expect(result.cost).toBe(0)
expect(result.prevContextTokens).toBe(totalTokens)
// Should have all original messages + truncation marker (non-destructive)
expect(result.messages.length).toBe(6) // 5 original + 1 marker
})
it("should use summarizeConversation when autoCondenseContext is true and tokens exceed threshold", async () => {
@ -650,10 +670,13 @@ describe("Context Management", () => {
// Verify summarizeConversation was called
expect(summarizeSpy).toHaveBeenCalled()
// Verify it fell back to truncation
expect(result.messages).toEqual(expectedMessages)
// Verify it fell back to truncation (non-destructive)
expect(result.truncationId).toBeDefined()
expect(result.messagesRemoved).toBe(2)
expect(result.summary).toBe("")
expect(result.prevContextTokens).toBe(totalTokens)
// Should have all original messages + truncation marker
expect(result.messages.length).toBe(6) // 5 original + 1 marker
// The cost might be different than expected, so we don't check it
// Clean up
@ -697,13 +720,14 @@ describe("Context Management", () => {
// Verify summarizeConversation was not called
expect(summarizeSpy).not.toHaveBeenCalled()
// Verify it used truncation
expect(result).toEqual({
messages: expectedMessages,
summary: "",
cost: 0,
prevContextTokens: totalTokens,
})
// Verify it used truncation (non-destructive)
expect(result.truncationId).toBeDefined()
expect(result.messagesRemoved).toBe(2)
expect(result.summary).toBe("")
expect(result.cost).toBe(0)
expect(result.prevContextTokens).toBe(totalTokens)
// Should have all original messages + truncation marker
expect(result.messages.length).toBe(6) // 5 original + 1 marker
// Clean up
summarizeSpy.mockRestore()
@ -1093,7 +1117,10 @@ describe("Context Management", () => {
currentProfileId: "default",
})
expect(result2.messages).not.toEqual(messagesWithSmallContent)
expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction
// Should have all original messages + truncation marker (non-destructive)
expect(result2.messages.length).toBe(6) // 5 original + 1 marker
expect(result2.truncationId).toBeDefined()
expect(result2.messagesRemoved).toBe(2)
expect(result2.summary).toBe("")
expect(result2.cost).toBe(0)
expect(result2.prevContextTokens).toBe(50001)
@ -1146,7 +1173,9 @@ describe("Context Management", () => {
currentProfileId: "default",
})
expect(result2.messages).not.toEqual(messagesWithSmallContent)
expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction
// Should have all original messages + truncation marker (non-destructive)
expect(result2.messages.length).toBe(6) // 5 original + 1 marker
expect(result2.truncationId).toBeDefined()
expect(result2.summary).toBe("")
expect(result2.cost).toBe(0)
expect(result2.prevContextTokens).toBe(81809)
@ -1192,8 +1221,10 @@ describe("Context Management", () => {
profileThresholds: {},
currentProfileId: "default",
})
expect(result2).not.toEqual(messagesWithSmallContent)
expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction
expect(result2.messages).not.toEqual(messagesWithSmallContent)
// Should have all original messages + truncation marker (non-destructive)
expect(result2.messages.length).toBe(6) // 5 original + 1 marker
expect(result2.truncationId).toBeDefined()
})
it("should handle large context windows appropriately", async () => {
@ -1237,8 +1268,10 @@ describe("Context Management", () => {
profileThresholds: {},
currentProfileId: "default",
})
expect(result2).not.toEqual(messagesWithSmallContent)
expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction
expect(result2.messages).not.toEqual(messagesWithSmallContent)
// Should have all original messages + truncation marker (non-destructive)
expect(result2.messages.length).toBe(6) // 5 original + 1 marker
expect(result2.truncationId).toBeDefined()
})
})
})

View file

@ -0,0 +1,402 @@
import { describe, it, expect, beforeEach } from "vitest"
import { TelemetryService } from "@roo-code/telemetry"
import { truncateConversation } from "../index"
import { getEffectiveApiHistory, cleanupAfterTruncation } from "../../condense"
import { ApiMessage } from "../../task-persistence/apiMessages"
describe("Non-Destructive Sliding Window Truncation", () => {
let messages: ApiMessage[]
beforeEach(() => {
// Initialize TelemetryService for tests
if (!TelemetryService.hasInstance()) {
TelemetryService.createInstance([])
}
// Create a sample conversation with 11 messages (1 initial + 10 conversation messages)
messages = [
{ role: "user", content: "Initial task", ts: 1000 },
{ role: "assistant", content: "Response 1", ts: 1100 },
{ role: "user", content: "Message 2", ts: 1200 },
{ role: "assistant", content: "Response 2", ts: 1300 },
{ role: "user", content: "Message 3", ts: 1400 },
{ role: "assistant", content: "Response 3", ts: 1500 },
{ role: "user", content: "Message 4", ts: 1600 },
{ role: "assistant", content: "Response 4", ts: 1700 },
{ role: "user", content: "Message 5", ts: 1800 },
{ role: "assistant", content: "Response 5", ts: 1900 },
{ role: "user", content: "Message 6", ts: 2000 },
]
})
describe("truncateConversation()", () => {
it("should tag messages with truncationParent instead of deleting", () => {
const result = truncateConversation(messages, 0.5, "test-task-id")
// All messages should still be present
expect(result.messages.length).toBe(messages.length + 1) // +1 for truncation marker
// Calculate expected messages to remove: floor((11-1) * 0.5) = 5, rounded to even = 4
const expectedMessagesToRemove = 4
// Messages 1-4 should be tagged with truncationParent
for (let i = 1; i <= expectedMessagesToRemove; i++) {
// Account for truncation marker inserted at position 1
const msgIndex = i < 1 ? i : i + 1
expect(result.messages[msgIndex].truncationParent).toBeDefined()
expect(result.messages[msgIndex].truncationParent).toBe(result.truncationId)
}
// First message should not be tagged
expect(result.messages[0].truncationParent).toBeUndefined()
// Remaining messages should not be tagged
for (let i = expectedMessagesToRemove + 2; i < result.messages.length; i++) {
expect(result.messages[i].truncationParent).toBeUndefined()
}
})
it("should insert truncation marker with truncationId", () => {
const result = truncateConversation(messages, 0.5, "test-task-id")
// Truncation marker should be at index 1 (after first message)
const marker = result.messages[1]
expect(marker.isTruncationMarker).toBe(true)
expect(marker.truncationId).toBeDefined()
expect(marker.truncationId).toBe(result.truncationId)
expect(marker.role).toBe("assistant")
expect(marker.content).toContain("Sliding window truncation")
})
it("should return truncationId and messagesRemoved", () => {
const result = truncateConversation(messages, 0.5, "test-task-id")
expect(result.truncationId).toBeDefined()
expect(typeof result.truncationId).toBe("string")
expect(result.messagesRemoved).toBe(4) // floor((11-1) * 0.5) rounded to even
})
it("should round messagesToRemove to an even number", () => {
// Test with 12 messages (1 initial + 11 conversation)
const manyMessages: ApiMessage[] = [
{ role: "user", content: "Initial", ts: 1000 },
...Array.from({ length: 11 }, (_, i) => ({
role: (i % 2 === 0 ? "assistant" : "user") as "assistant" | "user",
content: `Message ${i + 1}`,
ts: 1100 + i * 100,
})),
]
// fracToRemove=0.5 -> rawMessagesToRemove = floor(11 * 0.5) = 5
// messagesToRemove = 5 - (5 % 2) = 4 (rounded down to even)
const result = truncateConversation(manyMessages, 0.5, "test-task-id")
expect(result.messagesRemoved).toBe(4)
})
})
describe("getEffectiveApiHistory()", () => {
it("should filter out truncated messages when truncation marker exists", () => {
const truncationResult = truncateConversation(messages, 0.5, "test-task-id")
const effective = getEffectiveApiHistory(truncationResult.messages)
// Should exclude 4 truncated messages but keep the first message and truncation marker
// Original: 11 messages
// After truncation: 11 + 1 marker = 12
// Effective: 11 - 4 (hidden) + 1 (marker) = 8
expect(effective.length).toBe(8)
// First message should be present
expect(effective[0].content).toBe("Initial task")
// Truncation marker should be present
expect(effective[1].isTruncationMarker).toBe(true)
// Messages with truncationParent should be filtered out
for (const msg of effective) {
if (msg.truncationParent) {
throw new Error("Message with truncationParent should be filtered out")
}
}
})
it("should include truncated messages when truncation marker is removed", () => {
const truncationResult = truncateConversation(messages, 0.5, "test-task-id")
// Remove the truncation marker (simulate rewind past truncation)
const messagesWithoutMarker = truncationResult.messages.filter((msg) => !msg.isTruncationMarker)
const effective = getEffectiveApiHistory(messagesWithoutMarker)
// All messages should be visible now
expect(effective.length).toBe(messages.length)
// Verify first and last messages are present
expect(effective[0].content).toBe("Initial task")
expect(effective[effective.length - 1].content).toBe("Message 6")
})
it("should handle both condenseParent and truncationParent filtering", () => {
// Create a scenario with both condensing and truncation
const messagesWithCondense: ApiMessage[] = [
{ role: "user", content: "Initial", ts: 1000 },
{ role: "assistant", content: "Msg 1", ts: 1100, condenseParent: "condense-1" },
{ role: "user", content: "Msg 2", ts: 1200, condenseParent: "condense-1" },
{
role: "assistant",
content: "Summary 1",
ts: 1250,
isSummary: true,
condenseId: "condense-1",
},
{ role: "user", content: "Msg 3", ts: 1300 },
{ role: "assistant", content: "Msg 4", ts: 1400 },
]
const truncationResult = truncateConversation(messagesWithCondense, 0.5, "test-task-id")
const effective = getEffectiveApiHistory(truncationResult.messages)
// Should filter both condensed messages and truncated messages
// Messages with condenseParent="condense-1" should be filtered (summary exists)
// Messages with truncationParent should be filtered (marker exists)
const hasCondensedMessage = effective.some((msg) => msg.condenseParent === "condense-1")
const hasTruncatedMessage = effective.some((msg) => msg.truncationParent)
expect(hasCondensedMessage).toBe(false)
expect(hasTruncatedMessage).toBe(false)
})
})
describe("cleanupAfterTruncation()", () => {
it("should clear orphaned truncationParent tags when marker is deleted", () => {
const truncationResult = truncateConversation(messages, 0.5, "test-task-id")
// Remove the truncation marker (simulate rewind)
const messagesWithoutMarker = truncationResult.messages.filter((msg) => !msg.isTruncationMarker)
const cleaned = cleanupAfterTruncation(messagesWithoutMarker)
// All truncationParent tags should be cleared
for (const msg of cleaned) {
expect(msg.truncationParent).toBeUndefined()
}
})
it("should preserve truncationParent tags when marker still exists", () => {
const truncationResult = truncateConversation(messages, 0.5, "test-task-id")
const cleaned = cleanupAfterTruncation(truncationResult.messages)
// truncationParent tags should be preserved (marker still exists)
const taggedMessages = cleaned.filter((msg) => msg.truncationParent)
expect(taggedMessages.length).toBeGreaterThan(0)
// All tagged messages should point to the existing marker
for (const msg of taggedMessages) {
expect(msg.truncationParent).toBe(truncationResult.truncationId)
}
})
it("should handle both condenseParent and truncationParent cleanup", () => {
const messagesWithBoth: ApiMessage[] = [
{ role: "user", content: "Initial", ts: 1000 },
{ role: "assistant", content: "Msg 1", ts: 1100, condenseParent: "orphan-condense" },
{ role: "user", content: "Msg 2", ts: 1200, truncationParent: "orphan-truncation" },
{ role: "assistant", content: "Msg 3", ts: 1300 },
]
const cleaned = cleanupAfterTruncation(messagesWithBoth)
// Both orphaned parent references should be cleared
expect(cleaned[1].condenseParent).toBeUndefined()
expect(cleaned[2].truncationParent).toBeUndefined()
})
it("should preserve valid parent references", () => {
const messagesWithValidParents: ApiMessage[] = [
{ role: "user", content: "Initial", ts: 1000 },
{ role: "assistant", content: "Msg 1", ts: 1100, condenseParent: "valid-condense" },
{
role: "assistant",
content: "Summary",
ts: 1150,
isSummary: true,
condenseId: "valid-condense",
},
{ role: "user", content: "Msg 2", ts: 1200, truncationParent: "valid-truncation" },
{
role: "assistant",
content: "Truncation marker",
ts: 1250,
isTruncationMarker: true,
truncationId: "valid-truncation",
},
]
const cleaned = cleanupAfterTruncation(messagesWithValidParents)
// Valid parent references should be preserved
expect(cleaned[1].condenseParent).toBe("valid-condense")
expect(cleaned[3].truncationParent).toBe("valid-truncation")
})
})
describe("Rewind past truncation integration", () => {
it("should restore hidden messages when rewinding past truncation point", () => {
// Step 1: Perform truncation
const truncationResult = truncateConversation(messages, 0.5, "test-task-id")
// Step 2: Verify messages are hidden initially
const effectiveBeforeRewind = getEffectiveApiHistory(truncationResult.messages)
expect(effectiveBeforeRewind.length).toBeLessThan(messages.length)
// Step 3: Simulate rewind by removing truncation marker and subsequent messages
// In practice this would be done via removeMessagesThisAndSubsequent
const markerIndex = truncationResult.messages.findIndex((msg) => msg.isTruncationMarker)
const messagesAfterRewind = truncationResult.messages.slice(0, markerIndex)
// Step 4: Clean up orphaned parent references
const cleanedAfterRewind = cleanupAfterTruncation(messagesAfterRewind)
// Step 5: Get effective history after cleanup
const effectiveAfterRewind = getEffectiveApiHistory(cleanedAfterRewind)
// All original messages before the marker should be restored
expect(effectiveAfterRewind.length).toBe(markerIndex)
// No messages should have truncationParent
for (const msg of effectiveAfterRewind) {
expect(msg.truncationParent).toBeUndefined()
}
})
it("should handle multiple truncations correctly", () => {
// Step 1: First truncation
const firstTruncation = truncateConversation(messages, 0.5, "task-1")
// Step 2: Get effective history and simulate more messages being added
const effectiveAfterFirst = getEffectiveApiHistory(firstTruncation.messages)
const moreMessages: ApiMessage[] = [
...firstTruncation.messages,
{ role: "user", content: "New message 1", ts: 3000 },
{ role: "assistant", content: "New response 1", ts: 3100 },
{ role: "user", content: "New message 2", ts: 3200 },
{ role: "assistant", content: "New response 2", ts: 3300 },
]
// Step 3: Second truncation
const secondTruncation = truncateConversation(moreMessages, 0.5, "task-1")
// Step 4: Get effective history after second truncation
const effectiveAfterSecond = getEffectiveApiHistory(secondTruncation.messages)
// Should have messages hidden by both truncations filtered out
const firstMarker = secondTruncation.messages.find(
(msg) => msg.isTruncationMarker && msg.truncationId === firstTruncation.truncationId,
)
const secondMarker = secondTruncation.messages.find(
(msg) => msg.isTruncationMarker && msg.truncationId === secondTruncation.truncationId,
)
expect(firstMarker).toBeDefined()
expect(secondMarker).toBeDefined()
// Messages tagged with either truncationId should be filtered
for (const msg of effectiveAfterSecond) {
if (msg.truncationParent === firstTruncation.truncationId) {
throw new Error("First truncation messages should be filtered")
}
if (msg.truncationParent === secondTruncation.truncationId) {
throw new Error("Second truncation messages should be filtered")
}
}
})
it("should handle rewinding when second truncation affects first truncation marker", () => {
// Step 1: First truncation
const firstTruncation = truncateConversation(messages, 0.5, "task-1")
// Step 2: Add more messages AFTER getting effective history
// This simulates real usage where we only send effective messages to API
const effectiveAfterFirst = getEffectiveApiHistory(firstTruncation.messages)
const moreMessages: ApiMessage[] = [
...firstTruncation.messages, // Keep full history with tagged messages
{ role: "user", content: "New message 1", ts: 3000 },
{ role: "assistant", content: "New response 1", ts: 3100 },
{ role: "user", content: "New message 2", ts: 3200 },
{ role: "assistant", content: "New response 2", ts: 3300 },
]
// Step 3: Second truncation - this will tag some messages including possibly the first marker
const secondTruncation = truncateConversation(moreMessages, 0.5, "task-1")
// Step 4: Simulate rewind past second truncation marker
const secondMarkerIndex = secondTruncation.messages.findIndex(
(msg) => msg.isTruncationMarker && msg.truncationId === secondTruncation.truncationId,
)
const afterSecondRewind = secondTruncation.messages.slice(0, secondMarkerIndex)
// Step 5: Clean up orphaned references
const cleaned = cleanupAfterTruncation(afterSecondRewind)
// Step 6: Get effective history
const effective = getEffectiveApiHistory(cleaned)
// The second truncation marker should be removed
const hasSecondTruncationMarker = effective.some(
(msg) => msg.isTruncationMarker && msg.truncationId === secondTruncation.truncationId,
)
expect(hasSecondTruncationMarker).toBe(false)
// Messages that were tagged by the second truncation should have those tags cleared
const hasSecondTruncationParent = cleaned.some(
(msg) => msg.truncationParent === secondTruncation.truncationId,
)
expect(hasSecondTruncationParent).toBe(false)
// First truncation marker and its tagged messages may or may not be present
// depending on whether the second truncation affected them
// The important thing is that cleanup works correctly
expect(cleaned.length).toBeGreaterThan(0)
})
})
describe("Edge cases", () => {
it("should handle truncateConversation with fracToRemove=0", () => {
const result = truncateConversation(messages, 0, "test-task-id")
// No messages should be tagged (messagesToRemove = 0)
const taggedMessages = result.messages.filter((msg) => msg.truncationParent)
expect(taggedMessages.length).toBe(0)
// Should still have truncation marker
const marker = result.messages.find((msg) => msg.isTruncationMarker)
expect(marker).toBeDefined()
})
it("should handle truncateConversation with very few messages", () => {
const fewMessages: ApiMessage[] = [
{ role: "user", content: "Initial", ts: 1000 },
{ role: "assistant", content: "Response", ts: 1100 },
]
const result = truncateConversation(fewMessages, 0.5, "test-task-id")
// Should not crash and should still create marker
expect(result.messages.length).toBeGreaterThan(0)
const marker = result.messages.find((msg) => msg.isTruncationMarker)
expect(marker).toBeDefined()
})
it("should handle empty condenseParent and truncationParent gracefully", () => {
const messagesWithoutTags: ApiMessage[] = [
{ role: "user", content: "Message 1", ts: 1000 },
{ role: "assistant", content: "Response 1", ts: 1100 },
]
const cleaned = cleanupAfterTruncation(messagesWithoutTags)
// Should return same messages unchanged
expect(cleaned).toEqual(messagesWithoutTags)
})
})
})

View file

@ -1,4 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"
import crypto from "crypto"
import { TelemetryService } from "@roo-code/telemetry"
@ -39,27 +40,62 @@ export async function estimateTokenCount(
}
/**
* Truncates a conversation by removing a fraction of the messages.
* Result of truncation operation, includes the truncation ID for UI events.
*/
export type TruncationResult = {
messages: ApiMessage[]
truncationId: string
messagesRemoved: number
}
/**
* Truncates a conversation by tagging messages as hidden instead of removing them.
*
* The first message is always retained, and a specified fraction (rounded to an even number)
* of messages from the beginning (excluding the first) is removed.
* of messages from the beginning (excluding the first) is tagged with truncationParent.
* A truncation marker is inserted to track where truncation occurred.
*
* This implements the sliding window truncation behavior.
* This implements non-destructive sliding window truncation, allowing messages to be
* restored if the user rewinds past the truncation point.
*
* @param {ApiMessage[]} messages - The conversation messages.
* @param {number} fracToRemove - The fraction (between 0 and 1) of messages (excluding the first) to remove.
* @param {number} fracToRemove - The fraction (between 0 and 1) of messages (excluding the first) to hide.
* @param {string} taskId - The task ID for the conversation, used for telemetry
* @returns {ApiMessage[]} The truncated conversation messages.
* @returns {TruncationResult} Object containing the tagged messages, truncation ID, and count of messages removed.
*/
export function truncateConversation(messages: ApiMessage[], fracToRemove: number, taskId: string): ApiMessage[] {
export function truncateConversation(messages: ApiMessage[], fracToRemove: number, taskId: string): TruncationResult {
TelemetryService.instance.captureSlidingWindowTruncation(taskId)
const truncatedMessages = [messages[0]]
const truncationId = crypto.randomUUID()
const rawMessagesToRemove = Math.floor((messages.length - 1) * fracToRemove)
const messagesToRemove = rawMessagesToRemove - (rawMessagesToRemove % 2)
const remainingMessages = messages.slice(messagesToRemove + 1)
truncatedMessages.push(...remainingMessages)
return truncatedMessages
// Tag messages that are being "truncated" (hidden from API calls)
const taggedMessages = messages.map((msg, index) => {
if (index > 0 && index <= messagesToRemove) {
return { ...msg, truncationParent: truncationId }
}
return msg
})
// Insert truncation marker after first message (so we know a truncation happened)
const firstKeptTs = messages[messagesToRemove + 1]?.ts ?? Date.now()
const truncationMarker: ApiMessage = {
role: "assistant",
content: `[Sliding window truncation: ${messagesToRemove} messages hidden to reduce context]`,
ts: firstKeptTs - 1,
isTruncationMarker: true,
truncationId,
}
// Insert marker after first message
const result = [taggedMessages[0], truncationMarker, ...taggedMessages.slice(1)]
return {
messages: result,
truncationId,
messagesRemoved: messagesToRemove,
}
}
/**
@ -89,7 +125,11 @@ export type ContextManagementOptions = {
useNativeTools?: boolean
}
export type ContextManagementResult = SummarizeResponse & { prevContextTokens: number }
export type ContextManagementResult = SummarizeResponse & {
prevContextTokens: number
truncationId?: string
messagesRemoved?: number
}
/**
* Conditionally manages conversation context (condense and fallback truncation).
@ -178,8 +218,16 @@ export async function manageContext({
// Fall back to sliding window truncation if needed
if (prevContextTokens > allowedTokens) {
const truncatedMessages = truncateConversation(messages, 0.5, taskId)
return { messages: truncatedMessages, prevContextTokens, summary: "", cost, error }
const truncationResult = truncateConversation(messages, 0.5, taskId)
return {
messages: truncationResult.messages,
prevContextTokens,
summary: "",
cost,
error,
truncationId: truncationResult.truncationId,
messagesRemoved: truncationResult.messagesRemoved,
}
}
// No truncation or condensation needed
return { messages, summary: "", cost, prevContextTokens, error }

View file

@ -20,6 +20,18 @@ export type ApiMessage = Anthropic.MessageParam & {
text?: string
// For OpenRouter reasoning_details array format (used by Gemini 3, etc.)
reasoning_details?: any[]
// For non-destructive condense: unique identifier for summary messages
condenseId?: string
// For non-destructive condense: points to the condenseId of the summary that replaces this message
// Messages with condenseParent are filtered out when sending to API if the summary exists
condenseParent?: string
// For non-destructive truncation: unique identifier for truncation marker messages
truncationId?: string
// For non-destructive truncation: points to the truncationId of the marker that hides this message
// Messages with truncationParent are filtered out when sending to API if the marker exists
truncationParent?: string
// Identifies a message as a truncation boundary marker
isTruncationMarker?: boolean
}
export async function readApiMessages({

View file

@ -21,6 +21,7 @@ import {
type ToolUsage,
type ToolName,
type ContextCondense,
type ContextTruncation,
type ClineMessage,
type ClineSay,
type ClineAsk,
@ -121,7 +122,7 @@ import {
checkpointDiff,
} from "../checkpoints"
import { processUserContentMentions } from "../mentions/processUserContentMentions"
import { getMessagesSinceLastSummary, summarizeConversation } from "../condense"
import { getMessagesSinceLastSummary, summarizeConversation, getEffectiveApiHistory } from "../condense"
import { MessageQueueService } from "../message-queue/MessageQueueService"
import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval"
@ -1327,6 +1328,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
cost,
newContextTokens = 0,
error,
condenseId,
} = await summarizeConversation(
this.apiConversationHistory,
this.api, // Main API handler (fallback)
@ -1352,7 +1354,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
await this.overwriteApiConversationHistory(messages)
const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens }
const contextCondense: ContextCondense = {
summary,
cost,
newContextTokens,
prevContextTokens,
condenseId: condenseId!,
}
await this.say(
"condense_context",
undefined /* text */,
@ -1379,6 +1387,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
isNonInteractive?: boolean
} = {},
contextCondense?: ContextCondense,
contextTruncation?: ContextTruncation,
): Promise<undefined> {
if (this.abort) {
throw new Error(`[RooCode#say] task ${this.taskId}.${this.instanceId} aborted`)
@ -1414,6 +1423,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
images,
partial,
contextCondense,
contextTruncation,
})
}
} else {
@ -1451,6 +1461,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
text,
images,
contextCondense,
contextTruncation,
})
}
}
@ -1474,6 +1485,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
images,
checkpoint,
contextCondense,
contextTruncation,
})
}
@ -3383,6 +3395,24 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
{ isNonInteractive: true } /* options */,
contextCondense,
)
} else if (truncateResult.truncationId) {
// Sliding window truncation occurred (fallback when condensing fails or is disabled)
const contextTruncation: ContextTruncation = {
truncationId: truncateResult.truncationId,
messagesRemoved: truncateResult.messagesRemoved ?? 0,
prevContextTokens: truncateResult.prevContextTokens,
}
await this.say(
"sliding_window_truncation",
undefined /* text */,
undefined /* images */,
false /* partial */,
undefined /* checkpoint */,
undefined /* progressStatus */,
{ isNonInteractive: true } /* options */,
undefined /* contextCondense */,
contextTruncation,
)
}
}
@ -3493,8 +3523,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
if (truncateResult.error) {
await this.say("condense_context_error", truncateResult.error)
} else if (truncateResult.summary) {
const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult
const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens }
const { summary, cost, prevContextTokens, newContextTokens = 0, condenseId } = truncateResult
const contextCondense: ContextCondense = {
summary,
cost,
newContextTokens,
prevContextTokens,
condenseId,
}
await this.say(
"condense_context",
undefined /* text */,
@ -3505,10 +3541,32 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
{ isNonInteractive: true } /* options */,
contextCondense,
)
} else if (truncateResult.truncationId) {
// Sliding window truncation occurred (fallback when condensing fails or is disabled)
const contextTruncation: ContextTruncation = {
truncationId: truncateResult.truncationId,
messagesRemoved: truncateResult.messagesRemoved ?? 0,
prevContextTokens: truncateResult.prevContextTokens,
}
await this.say(
"sliding_window_truncation",
undefined /* text */,
undefined /* images */,
false /* partial */,
undefined /* checkpoint */,
undefined /* progressStatus */,
{ isNonInteractive: true } /* options */,
undefined /* contextCondense */,
contextTruncation,
)
}
}
const messagesSinceLastSummary = getMessagesSinceLastSummary(this.apiConversationHistory)
// Get the effective API history by filtering out condensed messages
// This allows non-destructive condensing where messages are tagged but not deleted,
// enabling accurate rewind operations while still sending condensed history to the API.
const effectiveHistory = getEffectiveApiHistory(this.apiConversationHistory)
const messagesSinceLastSummary = getMessagesSinceLastSummary(effectiveHistory)
const messagesWithoutImages = maybeRemoveImageBlocks(messagesSinceLastSummary, this.api)
const cleanConversationHistory = this.buildCleanConversationHistory(messagesWithoutImages as ApiMessage[])

View file

@ -241,5 +241,322 @@ describe("webviewMessageHandler delete functionality", () => {
{ ts: 1500, role: "assistant", content: { type: "text", text: "First response" } },
])
})
describe("condense preservation behavior", () => {
it("should preserve summary and condensed messages when deleting after the summary", async () => {
// Design: Rewind/delete preserves summaries that were created BEFORE the rewind point.
// Only summaries removed by truncation have their associated condenseParent tags cleared.
const condenseId = "summary-abc"
getCurrentTaskMock.clineMessages = [
{ ts: 100, say: "user", text: "First message" },
{ ts: 200, say: "assistant", text: "Response 1" },
{ ts: 300, say: "user", text: "Second message" },
{ ts: 799, say: "assistant", text: "Summary" },
{ ts: 800, say: "assistant", text: "Kept message 1" },
{ ts: 900, say: "user", text: "Kept message 2" },
{ ts: 1000, say: "assistant", text: "Kept message 3" },
]
// API history after condense: msg1, msg2(tagged), msg3(tagged), summary, kept1, kept2, kept3
getCurrentTaskMock.apiConversationHistory = [
{ ts: 100, role: "user", content: "First message" },
{ ts: 200, role: "assistant", content: "Response 1", condenseParent: condenseId },
{ ts: 300, role: "user", content: "Second message", condenseParent: condenseId },
{ ts: 799, role: "assistant", content: "Summary", isSummary: true, condenseId },
{ ts: 800, role: "assistant", content: "Kept message 1" },
{ ts: 900, role: "user", content: "Kept message 2" },
{ ts: 1000, role: "assistant", content: "Kept message 3" },
]
// Delete kept message 2 (ts=900) - summary is BEFORE truncation point so should be preserved
await webviewMessageHandler(provider, {
type: "deleteMessageConfirm",
messageTs: 900,
})
expect(getCurrentTaskMock.overwriteApiConversationHistory).toHaveBeenCalled()
const result = getCurrentTaskMock.overwriteApiConversationHistory.mock.calls[0][0]
// Summary should be PRESERVED, condensed messages should KEEP their tags
// Expected: [msg1, msg2(tagged), msg3(tagged), summary, kept1]
expect(result.length).toBe(5)
expect(result[0].content).toBe("First message")
expect(result[1].content).toBe("Response 1")
expect(result[1].condenseParent).toBe(condenseId) // Tag preserved
expect(result[2].content).toBe("Second message")
expect(result[2].condenseParent).toBe(condenseId) // Tag preserved
expect(result[3].content).toBe("Summary")
expect(result[3].isSummary).toBe(true) // Summary preserved
expect(result[4].content).toBe("Kept message 1")
})
it("should restore condensed messages when summary is removed by truncation", async () => {
// Scenario: Condensed messages exist, user deletes in a way that removes the summary
// The orphaned condenseParent tags should be cleared
const condenseId = "summary-xyz"
getCurrentTaskMock.clineMessages = [
{ ts: 100, say: "user", text: "Task start" },
{ ts: 200, say: "assistant", text: "Response 1" },
{ ts: 300, say: "user", text: "Message 2" },
{ ts: 999, say: "assistant", text: "Summary displayed" },
{ ts: 1000, say: "user", text: "First kept" },
]
// API history with condensed messages and summary
getCurrentTaskMock.apiConversationHistory = [
{ ts: 100, role: "user", content: "Task start" },
{ ts: 200, role: "assistant", content: "Response 1", condenseParent: condenseId },
{ ts: 300, role: "user", content: "Message 2", condenseParent: condenseId },
{ ts: 999, role: "assistant", content: "Summary", isSummary: true, condenseId },
{ ts: 1000, role: "user", content: "First kept" },
]
// Delete "Message 2" (ts=300) - this removes summary too, so orphaned tags should be cleared
await webviewMessageHandler(provider, {
type: "deleteMessageConfirm",
messageTs: 300,
})
expect(getCurrentTaskMock.overwriteApiConversationHistory).toHaveBeenCalled()
const result = getCurrentTaskMock.overwriteApiConversationHistory.mock.calls[0][0]
// Summary was removed, so orphaned tags should be cleared
expect(result.length).toBe(2)
expect(result[0].content).toBe("Task start")
expect(result[1].content).toBe("Response 1")
expect(result[1].condenseParent).toBeUndefined() // Tag cleared since summary is gone
})
it("should preserve first condense but undo second when rewinding past second condense only", async () => {
// Scenario: Double condense, user deletes a message that removes the second summary
// but keeps the first summary. First condense should remain intact.
const condenseId1 = "summary-first"
const condenseId2 = "summary-second"
getCurrentTaskMock.clineMessages = [
{ ts: 100, say: "user", text: "First message" },
{ ts: 799, say: "assistant", text: "Summary1" },
{ ts: 1799, say: "assistant", text: "Summary2" },
{ ts: 1800, say: "user", text: "Kept1" },
{ ts: 1900, say: "assistant", text: "Kept2" },
{ ts: 2000, say: "user", text: "To delete" },
]
getCurrentTaskMock.apiConversationHistory = [
{ ts: 100, role: "user", content: "First message" },
// Messages from first condense (tagged with condenseId1)
{ ts: 200, role: "assistant", content: "Msg2", condenseParent: condenseId1 },
{ ts: 300, role: "user", content: "Msg3", condenseParent: condenseId1 },
// First summary - ALSO tagged with condenseId2 from second condense
{
ts: 799,
role: "assistant",
content: "Summary1",
isSummary: true,
condenseId: condenseId1,
condenseParent: condenseId2,
},
// Messages from second condense (tagged with condenseId2)
{ ts: 1000, role: "assistant", content: "Msg after summary1", condenseParent: condenseId2 },
{ ts: 1100, role: "user", content: "More msgs", condenseParent: condenseId2 },
// Second summary
{ ts: 1799, role: "assistant", content: "Summary2", isSummary: true, condenseId: condenseId2 },
// Kept messages
{ ts: 1800, role: "user", content: "Kept1" },
{ ts: 1900, role: "assistant", content: "Kept2" },
{ ts: 2000, role: "user", content: "To delete" },
]
// Delete "Kept2" (ts=1900) - summary2 is BEFORE truncation, so it's preserved
await webviewMessageHandler(provider, {
type: "deleteMessageConfirm",
messageTs: 1900,
})
expect(getCurrentTaskMock.overwriteApiConversationHistory).toHaveBeenCalled()
const result = getCurrentTaskMock.overwriteApiConversationHistory.mock.calls[0][0]
// Both summaries should be preserved since they're before the truncation point
const summaries = result.filter((msg: any) => msg.isSummary)
expect(summaries.length).toBe(2)
// Verify tags are preserved
const summary1 = result.find((msg: any) => msg.content === "Summary1")
expect(summary1.condenseParent).toBe(condenseId2) // Still tagged
})
it("should prefer non-summary message when timestamps collide for deletion target", async () => {
// When multiple messages share the same timestamp, prefer non-summary for targeting
const sharedTs = 1000
getCurrentTaskMock.clineMessages = [
{ ts: 900, say: "user", text: "Previous message" },
{ ts: sharedTs, say: "user", text: "First kept message" },
{ ts: 1100, say: "assistant", text: "Response" },
]
// Summary and regular message share timestamp (edge case)
getCurrentTaskMock.apiConversationHistory = [
{ ts: 900, role: "user", content: "Previous message" },
{ ts: sharedTs, role: "assistant", content: "Summary", isSummary: true, condenseId: "abc" },
{ ts: sharedTs, role: "user", content: "First kept message" },
{ ts: 1100, role: "assistant", content: "Response" },
]
// Delete at shared timestamp - should target non-summary message (index 2)
await webviewMessageHandler(provider, {
type: "deleteMessageConfirm",
messageTs: sharedTs,
})
expect(getCurrentTaskMock.overwriteApiConversationHistory).toHaveBeenCalled()
const result = getCurrentTaskMock.overwriteApiConversationHistory.mock.calls[0][0]
// Truncation at index 2 means we keep indices 0-1: previous message and summary
expect(result.length).toBe(2)
expect(result[0].content).toBe("Previous message")
// The summary is kept since it's before truncation point
expect(result[1].content).toBe("Summary")
expect(result[1].isSummary).toBe(true)
})
it("should remove Summary when its condense_context clineMessage is deleted", async () => {
// Scenario: Summary has timestamp BEFORE the deletion point (so it survives truncation),
// BUT the condense_context UI message has timestamp AFTER the deletion point (so it gets removed).
// The fix links them via condenseId so the Summary is explicitly removed.
const condenseId = "summary-sync-test"
getCurrentTaskMock.clineMessages = [
{ ts: 100, say: "user", text: "Task start" },
{ ts: 200, say: "assistant", text: "Response 1" },
{ ts: 300, say: "user", text: "Message to delete this and after" },
{ ts: 400, say: "assistant", text: "Response 2" },
// condense_context is created AFTER the condense operation
{ ts: 500, say: "condense_context", contextCondense: { condenseId, summary: "Summary text" } },
{ ts: 600, say: "user", text: "Post-condense message" },
]
// Summary has ts=299 (before first kept message), so it would survive basic truncation
// But since condense_context (ts=500) is being removed, Summary should be removed too
getCurrentTaskMock.apiConversationHistory = [
{ ts: 100, role: "user", content: "Task start" },
{ ts: 200, role: "assistant", content: "Response 1", condenseParent: condenseId },
// Summary timestamp is BEFORE the kept messages (this is the bug scenario)
{ ts: 299, role: "assistant", content: "Summary text", isSummary: true, condenseId },
{ ts: 300, role: "user", content: "Message to delete this and after" },
{ ts: 400, role: "assistant", content: "Response 2" },
{ ts: 600, role: "user", content: "Post-condense message" },
]
// Delete at ts=300 - this removes condense_context (ts=500), so Summary should be removed too
await webviewMessageHandler(provider, {
type: "deleteMessageConfirm",
messageTs: 300,
})
expect(getCurrentTaskMock.overwriteApiConversationHistory).toHaveBeenCalled()
const result = getCurrentTaskMock.overwriteApiConversationHistory.mock.calls[0][0]
// Summary should be REMOVED even though its timestamp (299) is before truncation point (300)
// because its corresponding condense_context message is being removed
expect(result.length).toBe(2)
expect(result[0].content).toBe("Task start")
expect(result[1].content).toBe("Response 1")
// condenseParent should be cleared since the Summary is gone
expect(result[1].condenseParent).toBeUndefined()
})
it("should preserve first Summary when only second condense_context is deleted (nested condense)", async () => {
// Scenario: Two condense operations occurred. User deletes a message that removes
// the second condense_context but keeps the first. First summary should stay intact.
const condenseId1 = "summary-first"
const condenseId2 = "summary-second"
getCurrentTaskMock.clineMessages = [
{ ts: 100, say: "user", text: "First message" },
{ ts: 200, say: "assistant", text: "Response 1" },
// First condense_context created after first condense
{
ts: 800,
say: "condense_context",
contextCondense: { condenseId: condenseId1, summary: "First summary" },
},
{ ts: 900, say: "user", text: "After first condense" },
{ ts: 1000, say: "assistant", text: "Response after 1st condense" },
// Delete target - deleting this will remove the second condense_context below
{ ts: 1100, say: "user", text: "Message to delete this and after" },
// Second condense_context created after second condense (AFTER delete target)
{
ts: 1800,
say: "condense_context",
contextCondense: { condenseId: condenseId2, summary: "Second summary" },
},
{ ts: 1900, say: "user", text: "Post second condense" },
{ ts: 2000, say: "assistant", text: "Final response" },
]
getCurrentTaskMock.apiConversationHistory = [
{ ts: 100, role: "user", content: "First message" },
// Messages from first condense (tagged with condenseId1)
{ ts: 200, role: "assistant", content: "Response 1", condenseParent: condenseId1 },
// First summary (also tagged with condenseId2 from second condense)
{
ts: 799,
role: "assistant",
content: "First summary",
isSummary: true,
condenseId: condenseId1,
condenseParent: condenseId2,
},
{ ts: 900, role: "user", content: "After first condense", condenseParent: condenseId2 },
{
ts: 1000,
role: "assistant",
content: "Response after 1st condense",
condenseParent: condenseId2,
},
{ ts: 1100, role: "user", content: "Message to delete this and after" },
// Second summary (timestamp is BEFORE the messages it summarized for sort purposes)
{
ts: 1799,
role: "assistant",
content: "Second summary",
isSummary: true,
condenseId: condenseId2,
},
{ ts: 1900, role: "user", content: "Post second condense" },
{ ts: 2000, role: "assistant", content: "Final response" },
]
// Delete at ts=1100 - this removes second condense_context (ts=1800) but keeps first (ts=800)
await webviewMessageHandler(provider, {
type: "deleteMessageConfirm",
messageTs: 1100,
})
expect(getCurrentTaskMock.overwriteApiConversationHistory).toHaveBeenCalled()
const result = getCurrentTaskMock.overwriteApiConversationHistory.mock.calls[0][0]
// First summary should be PRESERVED (its condense_context is not being removed)
const firstSummary = result.find((msg: any) => msg.condenseId === condenseId1)
expect(firstSummary).toBeDefined()
expect(firstSummary.content).toBe("First summary")
expect(firstSummary.isSummary).toBe(true)
// Second summary should be REMOVED (its condense_context is being removed)
const secondSummary = result.find((msg: any) => msg.condenseId === condenseId2)
expect(secondSummary).toBeUndefined()
// Messages that were tagged with condenseId2 should have their tags cleared
const afterFirstCondense = result.find((msg: any) => msg.content === "After first condense")
expect(afterFirstCondense?.condenseParent).toBeUndefined() // Tag cleared
// Messages tagged with condenseId1 should KEEP their tags
const response1 = result.find((msg: any) => msg.content === "Response 1")
expect(response1?.condenseParent).toBe(condenseId1) // Tag preserved
})
})
})
})

View file

@ -21,6 +21,7 @@ import { TelemetryService } from "@roo-code/telemetry"
import { type ApiMessage } from "../task-persistence/apiMessages"
import { saveTaskMessages } from "../task-persistence"
import { cleanupAfterTruncation } from "../condense"
import { ClineProvider } from "./ClineProvider"
import { BrowserSessionPanelManager } from "./BrowserSessionPanelManager"
@ -78,14 +79,24 @@ export const webviewMessageHandler = async (
return provider.getCurrentTask()?.cwd || provider.cwd
}
/**
* Shared utility to find message indices based on timestamp
* Shared utility to find message indices based on timestamp.
* When multiple messages share the same timestamp (e.g., after condense),
* this function prefers non-summary messages to ensure user operations
* target the intended message rather than the summary.
*/
const findMessageIndices = (messageTs: number, currentCline: any) => {
// Find the exact message by timestamp, not the first one after a cutoff
const messageIndex = currentCline.clineMessages.findIndex((msg: ClineMessage) => msg.ts === messageTs)
const apiConversationHistoryIndex = currentCline.apiConversationHistory.findIndex(
(msg: ApiMessage) => msg.ts === messageTs,
)
// Find all matching API messages by timestamp
const allApiMatches = currentCline.apiConversationHistory
.map((msg: ApiMessage, idx: number) => ({ msg, idx }))
.filter(({ msg }: { msg: ApiMessage }) => msg.ts === messageTs)
// Prefer non-summary message if multiple matches exist (handles timestamp collision after condense)
const preferred = allApiMatches.find(({ msg }: { msg: ApiMessage }) => !msg.isSummary) || allApiMatches[0]
const apiConversationHistoryIndex = preferred?.idx ?? -1
return { messageIndex, apiConversationHistoryIndex }
}
@ -101,20 +112,81 @@ export const webviewMessageHandler = async (
}
/**
* Removes the target message and all subsequent messages
* Removes the target message and all subsequent messages.
* After truncation, cleans up orphaned condenseParent and truncationParent references for any
* summaries or truncation markers that were removed by the truncation.
*
* Design: Rewind/delete operations preserve earlier condense and truncation states.
* Only summaries and truncation markers that are removed by the truncation (i.e., were created
* after the rewind point) have their associated tags cleared.
* This allows nested condensing and multiple truncations to work correctly - rewinding past the
* second condense restores visibility of messages condensed by it, while keeping the first condense intact.
* Same applies to truncation markers.
*/
const removeMessagesThisAndSubsequent = async (
currentCline: any,
messageIndex: number,
apiConversationHistoryIndex: number,
) => {
// Delete this message and all that follow
// Step 1: Collect condenseIds from condense_context messages being removed.
// These IDs link clineMessages to their corresponding Summaries in apiConversationHistory.
const removedCondenseIds = new Set<string>()
// Step 1b: Collect truncationIds from sliding_window_truncation messages being removed.
// These IDs link clineMessages to their corresponding truncation markers in apiConversationHistory.
const removedTruncationIds = new Set<string>()
for (let i = messageIndex; i < currentCline.clineMessages.length; i++) {
const msg = currentCline.clineMessages[i]
if (msg.say === "condense_context" && msg.contextCondense?.condenseId) {
removedCondenseIds.add(msg.contextCondense.condenseId)
}
if (msg.say === "sliding_window_truncation" && msg.contextTruncation?.truncationId) {
removedTruncationIds.add(msg.contextTruncation.truncationId)
}
}
// Step 2: Delete this message and all that follow
await currentCline.overwriteClineMessages(currentCline.clineMessages.slice(0, messageIndex))
if (apiConversationHistoryIndex !== -1) {
await currentCline.overwriteApiConversationHistory(
currentCline.apiConversationHistory.slice(0, apiConversationHistoryIndex),
)
// Step 3: Truncate API history by timestamp/index
let truncatedApiHistory = currentCline.apiConversationHistory.slice(0, apiConversationHistoryIndex)
// Step 4: Remove Summaries whose condenseId was in a removed condense_context message.
// This handles the case where Summary.ts < truncation point but condense_context.ts > truncation point.
// Without this, the Summary would survive truncation but its corresponding UI event would be gone.
if (removedCondenseIds.size > 0) {
truncatedApiHistory = truncatedApiHistory.filter((msg: ApiMessage) => {
if (msg.isSummary && msg.condenseId && removedCondenseIds.has(msg.condenseId)) {
console.log(
`[removeMessagesThisAndSubsequent] Removing orphaned Summary with condenseId=${msg.condenseId}`,
)
return false
}
return true
})
}
// Step 4b: Remove truncation markers whose truncationId was in a removed sliding_window_truncation message.
// Same logic as condense - without this, the marker would survive but its UI event would be gone.
if (removedTruncationIds.size > 0) {
truncatedApiHistory = truncatedApiHistory.filter((msg: ApiMessage) => {
if (msg.isTruncationMarker && msg.truncationId && removedTruncationIds.has(msg.truncationId)) {
console.log(
`[removeMessagesThisAndSubsequent] Removing orphaned truncation marker with truncationId=${msg.truncationId}`,
)
return false
}
return true
})
}
// Step 5: Clean up orphaned condenseParent and truncationParent references for messages whose
// summary or truncation marker was removed by the truncation. Summaries, truncation markers, and messages
// from earlier condense/truncation operations are preserved.
const cleanedApiHistory = cleanupAfterTruncation(truncatedApiHistory)
await currentCline.overwriteApiConversationHistory(cleanedApiHistory)
}
}