Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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)
})
})
})
26 changes: 23 additions & 3 deletions src/core/condense/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,35 @@ 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())

// 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 middle messages from the full middle span, including any previous summaries
// that were part of this summarization window. Preserve existing condenseId/isSummary.
const windowTs = new Set(messagesToSummarize.map((m) => m.ts).filter((ts): ts is number => typeof ts === "number"))
const middleMessages = messages.slice(1, -N_MESSAGES_TO_KEEP).map((msg) => {
if (typeof msg.ts === "number" && windowTs.has(msg.ts)) {
// Do not alter isSummary or condenseId on prior summaries; only add condenseParent if missing
return { ...msg, condenseParent: msg.condenseParent ?? condenseId }
}
return msg
})

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