Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ export const clineMessageSchema = z.object({
reasoning_summary: z.string().optional(),
})
.optional(),
condenseId: z.string().optional(),
condenseParent: z.string().optional(),
})
.optional(),
})
Expand Down
5 changes: 3 additions & 2 deletions src/core/condense/__tests__/condense.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ describe("Condense", () => {
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 condense, we keep ALL messages plus the summary
// [first message, middle messages (tagged), summary, last N messages]
expect(result.messages.length).toBe(messages.length + 1) // All original messages + 1 summary

// Verify the last N messages are preserved
const lastMessages = result.messages.slice(-N_MESSAGES_TO_KEEP)
Expand Down
36 changes: 24 additions & 12 deletions src/core/condense/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,21 +188,33 @@ describe("summarizeConversation", () => {
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 condense, all original messages are preserved plus the summary
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 should be inserted after the first message and before tail)
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)
expect(summaryMessage?.condenseId).toBeDefined()

// Check that middle messages are tagged with condenseParent
const middleMessages = result.messages.slice(1, messages.length - N_MESSAGES_TO_KEEP + 1)
middleMessages.forEach((msg) => {
if (!msg.isSummary) {
expect(msg.condenseParent).toBe(summaryMessage?.condenseId)
}
})

// 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)
// Check that the last N_MESSAGES_TO_KEEP messages are preserved without condenseParent
const tailMessages = result.messages.slice(-N_MESSAGES_TO_KEEP)
tailMessages.forEach((msg) => {
expect(msg.condenseParent).toBeUndefined()
})

// Check the cost and token counts
expect(result.cost).toBe(0.05)
Expand Down Expand Up @@ -424,8 +436,8 @@ describe("summarizeConversation", () => {
)

// 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 condense, all original messages are preserved plus the summary
expect(result.messages.length).toBe(messages.length + 1) // All original messages + summary
expect(result.cost).toBe(0.03)
expect(result.summary).toBe("Concise summary")
expect(result.error).toBeUndefined()
Expand Down
281 changes: 281 additions & 0 deletions src/core/condense/__tests__/non-destructive-condense.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
// npx vitest src/core/condense/__tests__/non-destructive-condense.spec.ts

import { describe, it, expect, beforeEach, vi } from "vitest"
import { summarizeConversation } from "../index"
import { ApiMessage } from "../../task-persistence/apiMessages"
import { ApiHandler } from "../../../api"

// Mock the translation function
vi.mock("../../../i18n", () => ({
t: (key: string) => key,
}))

// Mock TelemetryService
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureContextCondensed: vi.fn(),
},
},
}))

describe("Non-destructive condense", () => {
let mockApiHandler: ApiHandler
let messages: ApiMessage[]

beforeEach(() => {
// Create a mock API handler
mockApiHandler = {
createMessage: vi.fn().mockImplementation(() => {
// Return an async generator that yields a summary
return (async function* () {
yield { type: "text", text: "This is a summary of the conversation" }
yield { type: "usage", totalCost: 0.01, outputTokens: 50 }
})()
}),
countTokens: vi.fn().mockResolvedValue(100),
getModel: vi.fn().mockReturnValue({ info: {} }),
} as any

// Create test messages
messages = [
{ role: "user", content: "First message", ts: 1000 },
{ role: "assistant", content: "Response 1", ts: 2000 },
{ role: "user", content: "Second message", ts: 3000 },
{ role: "assistant", content: "Response 2", ts: 4000 },
{ role: "user", content: "Third message", ts: 5000 },
{ role: "assistant", content: "Response 3", ts: 6000 },
{ role: "user", content: "Fourth message", ts: 7000 },
{ role: "assistant", content: "Response 4", ts: 8000 },
]
})

describe("summarizeConversation", () => {
it("should preserve all messages with condenseParent tags", async () => {
const result = await summarizeConversation(
messages,
mockApiHandler,
"system prompt",
"task-123",
1000, // prevContextTokens
false,
)

// Should not have an error
expect(result.error).toBeUndefined()

// Should have more messages than before (all original + summary)
expect(result.messages.length).toBeGreaterThan(messages.length)

// First message should be preserved
expect(result.messages[0]).toEqual(messages[0])

// Should have a summary message with condenseId
const summaryMessage = result.messages.find((m) => m.isSummary)
expect(summaryMessage).toBeDefined()
expect(summaryMessage?.condenseId).toBeDefined()
expect(summaryMessage?.condenseId).toMatch(/^condense-\d+-[a-z0-9]+$/)

// Middle messages should have condenseParent
const middleMessages = result.messages.filter((m) => m.condenseParent)
expect(middleMessages.length).toBeGreaterThan(0)
expect(middleMessages.every((m) => m.condenseParent === summaryMessage?.condenseId)).toBe(true)

// Last N messages should not have condenseParent
const tailMessages = result.messages.slice(-3) // N_MESSAGES_TO_KEEP = 3
expect(tailMessages.every((m) => !m.condenseParent)).toBe(true)
})

it("should generate unique condenseId for each condensation", async () => {
const result1 = await summarizeConversation(
messages,
mockApiHandler,
"system prompt",
"task-123",
1000,
false,
)

const result2 = await summarizeConversation(
messages,
mockApiHandler,
"system prompt",
"task-123",
1000,
false,
)

const summaryMessage1 = result1.messages.find((m) => m.isSummary)
const summaryMessage2 = result2.messages.find((m) => m.isSummary)

expect(summaryMessage1?.condenseId).toBeDefined()
expect(summaryMessage2?.condenseId).toBeDefined()
expect(summaryMessage1?.condenseId).not.toEqual(summaryMessage2?.condenseId)
})

it("should not condense if not enough messages", async () => {
const shortMessages = messages.slice(0, 3)
const result = await summarizeConversation(
shortMessages,
mockApiHandler,
"system prompt",
"task-123",
1000,
false,
)

expect(result.error).toBe("common:errors.condense_not_enough_messages")
expect(result.messages).toEqual(shortMessages)
})

it("should not condense if recent summary exists", async () => {
const messagesWithSummary: ApiMessage[] = [
...messages.slice(0, -2),
{ role: "assistant" as const, content: "Previous summary", ts: 6500, isSummary: true },
...messages.slice(-2),
]

const result = await summarizeConversation(
messagesWithSummary,
mockApiHandler,
"system prompt",
"task-123",
1000,
false,
)

expect(result.error).toBe("common:errors.condensed_recently")
expect(result.messages).toEqual(messagesWithSummary)
})
})

describe("Message filtering with active summaries", () => {
it("should filter out messages with condenseParent matching active summary", () => {
const messagesWithCondense: ApiMessage[] = [
{ role: "user", content: "First", ts: 1000 },
{ role: "user", content: "Second", ts: 2000, condenseParent: "condense-123-abc" },
{ role: "assistant", content: "Response", ts: 3000, condenseParent: "condense-123-abc" },
{
role: "assistant",
content: "Summary",
ts: 4000,
isSummary: true,
condenseId: "condense-123-abc",
},
{ role: "user", content: "Latest", ts: 5000 },
]

// Simulate filtering logic from Task.attemptApiRequest
const activeCondenseIds = new Set(
messagesWithCondense.filter((m) => m.isSummary && m.condenseId).map((m) => m.condenseId!),
)

const effectiveHistory = messagesWithCondense.filter(
(m) => !m.condenseParent || !activeCondenseIds.has(m.condenseParent),
)

// Should filter out the middle messages with condenseParent
expect(effectiveHistory.length).toBe(3)
expect(effectiveHistory[0].content).toBe("First")
expect(effectiveHistory[1].content).toBe("Summary")
expect(effectiveHistory[2].content).toBe("Latest")
})

it("should include messages with orphaned condenseParent", () => {
const messagesWithOrphan: ApiMessage[] = [
{ role: "user", content: "First", ts: 1000 },
{ role: "user", content: "Second", ts: 2000, condenseParent: "condense-old-xyz" }, // Orphaned
{ role: "assistant", content: "Response", ts: 3000 },
]

// No active summaries
const activeCondenseIds = new Set(
messagesWithOrphan.filter((m) => m.isSummary && m.condenseId).map((m) => m.condenseId!),
)

const effectiveHistory = messagesWithOrphan.filter(
(m) => !m.condenseParent || !activeCondenseIds.has(m.condenseParent),
)

// Should include the orphaned message since its condenseParent doesn't match any active summary
expect(effectiveHistory.length).toBe(3)
})
})

describe("Nested condense support", () => {
it("should handle multiple condensations with different condenseIds", async () => {
// First condensation
const result1 = await summarizeConversation(
messages,
mockApiHandler,
"system prompt",
"task-123",
1000,
false,
)

// Add more messages
const extendedMessages: ApiMessage[] = [
...result1.messages,
{ role: "user" as const, content: "Fifth message", ts: 9000 },
{ role: "assistant" as const, content: "Response 5", ts: 10000 },
{ role: "user" as const, content: "Sixth message", ts: 11000 },
{ role: "assistant" as const, content: "Response 6", ts: 12000 },
]

// Second condensation
const result2 = await summarizeConversation(
extendedMessages,
mockApiHandler,
"system prompt",
"task-123",
1000,
false,
)

// Should have two different summaries with different condenseIds
const summaries = result2.messages.filter((m) => m.isSummary)
expect(summaries.length).toBeGreaterThanOrEqual(1)

// Messages should have different condenseParent values
const condenseParents = new Set(
result2.messages.filter((m) => m.condenseParent).map((m) => m.condenseParent),
)
expect(condenseParents.size).toBeGreaterThanOrEqual(1)
})
})

describe("Rollback behavior", () => {
it("should support rollback by removing summary and cleaning condenseParent", () => {
const messagesWithCondense: ApiMessage[] = [
{ role: "user", content: "First", ts: 1000 },
{ role: "user", content: "Second", ts: 2000, condenseParent: "condense-123-abc" },
{ role: "assistant", content: "Response", ts: 3000, condenseParent: "condense-123-abc" },
{
role: "assistant",
content: "Summary",
ts: 4000,
isSummary: true,
condenseId: "condense-123-abc",
},
{ role: "user", content: "Latest", ts: 5000 },
]

// Simulate rollback: remove summary
const afterRollback = messagesWithCondense.filter((m) => !m.isSummary)

// Simulate hygiene: clean orphaned condenseParent
const activeCondenseIds = new Set(afterRollback.filter((m) => m.condenseId).map((m) => m.condenseId!))

afterRollback.forEach((m) => {
if (m.condenseParent && !activeCondenseIds.has(m.condenseParent)) {
delete m.condenseParent
}
})

// All messages should have condenseParent removed
expect(afterRollback.every((m) => !m.condenseParent)).toBe(true)
expect(afterRollback.length).toBe(4)
})
})
})
22 changes: 19 additions & 3 deletions src/core/condense/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,31 @@ export async function summarizeConversation(
return { ...response, cost, error }
}

// Generate a unique condenseId for this condensation
const condenseId = `condense-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`

// Choose a unique timestamp that sorts just before the first kept tail message
const summaryTs = Math.min((keepMessages[0]?.ts ?? Date.now()) - 1, Date.now())
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Possible timestamp collision/out-of-order risk. Using keepMessages[0].ts - 1 can collide with the last middle message when timestamps are close, and doesn’t ensure the summary sorts strictly after the middle and before the tail. This can break operations that locate messages by exact ts. Suggest bounding the summary ts between the max middle ts and the first tail ts, and make it strictly increasing.

Suggested change
const summaryTs = Math.min((keepMessages[0]?.ts ?? Date.now()) - 1, Date.now())
const prevMaxTs = Math.max(...messages.slice(1, -N_MESSAGES_TO_KEEP).map((m) => m.ts ?? 0), firstMessage?.ts ?? 0)
const nextMinTs = (keepMessages[0]?.ts ?? Date.now()) - 1
const summaryTs = Math.max(prevMaxTs + 1, Math.min(nextMinTs, Date.now()))


// Create the summary message with condenseId
const summaryMessage: ApiMessage = {
role: "assistant",
content: summary,
ts: keepMessages[0].ts,
ts: summaryTs,
isSummary: true,
condenseId: condenseId,
}

// Reconstruct messages: [first message, summary, last N messages]
const newMessages = [firstMessage, summaryMessage, ...keepMessages]
// Tag all middle messages (between first and tail) with condenseParent
// Middle messages are those that were summarized but not kept
const middleMessages = messages.slice(1, -N_MESSAGES_TO_KEEP).map((msg) => ({
...msg,
condenseParent: msg.condenseParent ?? condenseId,
}))

// Reconstruct messages: [first message, tagged middle messages, summary, last N messages]
// This preserves ALL messages, with middle ones tagged for filtering
const newMessages = [firstMessage, ...middleMessages, summaryMessage, ...keepMessages]

// 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
Expand Down
Loading