diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 0438bf6bcb..0a4eda80ec 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -544,19 +544,33 @@ export function getMessagesSinceLastSummary(messages: ApiMessage[]): ApiMessage[ * @returns The filtered history that should be sent to the API */ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] { + const effectiveHistoryWithIndices = getEffectiveApiHistoryWithIndices(messages) + return effectiveHistoryWithIndices.map(({ message }) => message) +} + +export function getEffectiveApiHistoryIndices(messages: ApiMessage[]): number[] { + const effectiveHistoryWithIndices = getEffectiveApiHistoryWithIndices(messages) + return effectiveHistoryWithIndices.map(({ index }) => index) +} + +function getEffectiveApiHistoryWithIndices(messages: ApiMessage[]): Array<{ message: ApiMessage; index: number }> { // Find the most recent summary message const lastSummary = findLast(messages, (msg) => msg.isSummary === true) if (lastSummary) { // Fresh start model: return only messages from the summary onwards const summaryIndex = messages.indexOf(lastSummary) - let messagesFromSummary = messages.slice(summaryIndex) + let messagesFromSummary = messages.slice(summaryIndex).map((message, offset) => ({ + message, + index: summaryIndex + offset, + })) // Collect all tool_use IDs from assistant messages in the result // This is needed to filter out orphan tool_result blocks that reference // tool_use IDs from messages that were condensed away const toolUseIds = new Set() - for (const msg of messagesFromSummary) { + for (const { message } of messagesFromSummary) { + const msg = message if (msg.role === "assistant" && Array.isArray(msg.content)) { for (const block of msg.content) { if (block.type === "tool_use" && (block as Anthropic.Messages.ToolUseBlockParam).id) { @@ -568,7 +582,8 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] { // Filter out orphan tool_result blocks from user messages messagesFromSummary = messagesFromSummary - .map((msg) => { + .map(({ message, index }) => { + const msg = message if (msg.role === "user" && Array.isArray(msg.content)) { const filteredContent = msg.content.filter((block) => { if (block.type === "tool_result") { @@ -582,22 +597,24 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] { } // If some content was filtered, return updated message if (filteredContent.length !== msg.content.length) { - return { ...msg, content: filteredContent } + return { message: { ...msg, content: filteredContent }, index } } } - return msg + return { message: msg, index } }) - .filter((msg): msg is ApiMessage => msg !== null) + .filter((entry): entry is { message: ApiMessage; index: number } => entry !== null) // Still need to filter out any truncated messages within this range const existingTruncationIds = new Set() - for (const msg of messagesFromSummary) { + for (const { message } of messagesFromSummary) { + const msg = message if (msg.isTruncationMarker && msg.truncationId) { existingTruncationIds.add(msg.truncationId) } } - return messagesFromSummary.filter((msg) => { + return messagesFromSummary.filter(({ message }) => { + const msg = message // Filter out truncated messages if their truncation marker exists if (msg.truncationParent && existingTruncationIds.has(msg.truncationParent)) { return false @@ -626,17 +643,20 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] { // 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 - }) + return messages + .map((message, index) => ({ message, index })) + .filter(({ message }) => { + const msg = message + // 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 + }) } /** diff --git a/src/core/context-management/__tests__/context-management.spec.ts b/src/core/context-management/__tests__/context-management.spec.ts index 9950ec536b..a3f9624041 100644 --- a/src/core/context-management/__tests__/context-management.spec.ts +++ b/src/core/context-management/__tests__/context-management.spec.ts @@ -56,6 +56,22 @@ describe("Context Management", () => { TelemetryService.createInstance([]) } }) + + const countEffectiveHistoryTokens = async (messages: ApiMessage[], systemPrompt: string) => { + const effectiveMessages = condenseModule.getEffectiveApiHistory(messages) + let total = await estimateTokenCount([{ type: "text", text: systemPrompt }], mockApiHandler) + + for (const message of effectiveMessages) { + if (Array.isArray(message.content)) { + total += await estimateTokenCount(message.content, mockApiHandler) + } else if (typeof message.content === "string") { + total += await estimateTokenCount([{ type: "text", text: message.content }], mockApiHandler) + } + } + + return total + } + /** * Tests for the truncateConversation function */ @@ -172,6 +188,35 @@ describe("Context Management", () => { // Last message should NOT be tagged (now at index 4) expect(result.messages[4].truncationParent).toBeUndefined() }) + + it("should truncate from the effective history when earlier messages were condensed", () => { + const condenseId = "prior-condense" + const messages: ApiMessage[] = [ + { role: "user", content: "Hidden user", condenseParent: condenseId }, + { role: "assistant", content: "Hidden assistant", condenseParent: condenseId }, + { role: "user", content: "Earlier summary", isSummary: true, condenseId }, + { role: "assistant", content: "Visible assistant 1" }, + { role: "user", content: "Visible user 1" }, + { role: "assistant", content: "Visible assistant 2" }, + { role: "user", content: "Visible user 2" }, + ] + + const result = truncateConversation(messages, 0.5, taskId) + + expect(result.messagesRemoved).toBe(2) + + // Messages hidden behind the summary should stay untouched. + expect(result.messages[0].truncationParent).toBeUndefined() + expect(result.messages[1].truncationParent).toBeUndefined() + + // The summary remains the anchor, and truncation starts after it. + expect(result.messages[2].isSummary).toBe(true) + expect(result.messages[3].truncationParent).toBe(result.truncationId) + expect(result.messages[4].truncationParent).toBe(result.truncationId) + expect(result.messages[5].isTruncationMarker).toBe(true) + expect(result.messages[6].truncationParent).toBeUndefined() + expect(result.messages[7].truncationParent).toBeUndefined() + }) }) /** @@ -1700,5 +1745,49 @@ describe("Context Management", () => { // With system prompt included, we expect roughly 50% of the messages remaining expect(result.newContextTokensAfterTruncation).toBeGreaterThan(0) }) + + it("should count only the effective API history after truncation when prior condenses exist", async () => { + const modelInfo = createModelInfo(100000, 30000) + const totalTokens = 70001 + const condenseId = "prior-condense" + const hiddenContent = "hidden historical content ".repeat(4000) + const visibleContent = "visible content that should actually be truncated ".repeat(200) + + const messages: ApiMessage[] = [ + { role: "user", content: hiddenContent, condenseParent: condenseId }, + { role: "assistant", content: hiddenContent, condenseParent: condenseId }, + { role: "user", content: hiddenContent, condenseParent: condenseId }, + { role: "user", content: "Earlier summary", isSummary: true, condenseId }, + { role: "assistant", content: visibleContent }, + { role: "user", content: visibleContent }, + { role: "assistant", content: visibleContent }, + { role: "user", content: "" }, + ] + + const systemPrompt = "System prompt for truncation recount" + const effectiveTokensBefore = await countEffectiveHistoryTokens(messages, systemPrompt) + + const result = await manageContext({ + messages, + totalTokens, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + apiHandler: mockApiHandler, + autoCondenseContext: false, + autoCondenseContextPercent: 100, + systemPrompt, + taskId, + profileThresholds: {}, + currentProfileId: "default", + }) + + expect(result.truncationId).toBeDefined() + expect(result.newContextTokensAfterTruncation).toBeDefined() + + const expectedEffectiveTokens = await countEffectiveHistoryTokens(result.messages, systemPrompt) + expect(result.newContextTokensAfterTruncation).toBe(expectedEffectiveTokens) + expect(result.newContextTokensAfterTruncation).toBeLessThan(effectiveTokensBefore) + expect(result.newContextTokensAfterTruncation).toBeLessThan(result.prevContextTokens) + }) }) }) diff --git a/src/core/context-management/__tests__/truncation.spec.ts b/src/core/context-management/__tests__/truncation.spec.ts index 2e6cbed5b6..59c5efceec 100644 --- a/src/core/context-management/__tests__/truncation.spec.ts +++ b/src/core/context-management/__tests__/truncation.spec.ts @@ -94,6 +94,50 @@ describe("Non-Destructive Sliding Window Truncation", () => { const result = truncateConversation(manyMessages, 0.5, "test-task-id") expect(result.messagesRemoved).toBe(4) }) + + it("should ignore orphan tool_result-only messages when truncating fresh-start history", () => { + const condenseId = "condense-1" + const freshStartMessages: ApiMessage[] = [ + { role: "user", content: "Original task", ts: 1000, condenseParent: condenseId }, + { + role: "assistant", + content: [{ type: "tool_use", id: "tool-orphan", name: "read_file", input: { path: "a.ts" } }], + ts: 1100, + condenseParent: condenseId, + }, + { + role: "user", + content: [{ type: "text", text: "Summary" }], + ts: 1200, + isSummary: true, + condenseId, + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "tool-orphan", content: "orphan result" }], + ts: 1300, + }, + { role: "assistant", content: "Visible assistant 1", ts: 1400 }, + { role: "user", content: "Visible user 1", ts: 1500 }, + { role: "assistant", content: "Visible assistant 2", ts: 1600 }, + { role: "user", content: "Visible user 2", ts: 1700 }, + ] + + const effectiveBefore = getEffectiveApiHistory(freshStartMessages) + expect(effectiveBefore).toHaveLength(5) + + const result = truncateConversation(freshStartMessages, 0.5, "test-task-id") + + expect(result.messagesRemoved).toBe(2) + expect(result.messages[3].truncationParent).toBeUndefined() + expect(result.messages[4].truncationParent).toBe(result.truncationId) + expect(result.messages[5].truncationParent).toBe(result.truncationId) + + const effectiveAfter = getEffectiveApiHistory(result.messages) + expect(effectiveAfter).toHaveLength(4) + expect(effectiveAfter[0].isSummary).toBe(true) + expect(effectiveAfter[1].isTruncationMarker).toBe(true) + }) }) describe("getEffectiveApiHistory()", () => { diff --git a/src/core/context-management/index.ts b/src/core/context-management/index.ts index 243d7bd797..70acda8d49 100644 --- a/src/core/context-management/index.ts +++ b/src/core/context-management/index.ts @@ -4,7 +4,14 @@ import crypto from "crypto" import { TelemetryService } from "@roo-code/telemetry" import { ApiHandler, ApiHandlerCreateMessageMetadata } from "../../api" -import { MAX_CONDENSE_THRESHOLD, MIN_CONDENSE_THRESHOLD, summarizeConversation, SummarizeResponse } from "../condense" +import { + MAX_CONDENSE_THRESHOLD, + MIN_CONDENSE_THRESHOLD, + getEffectiveApiHistory, + getEffectiveApiHistoryIndices, + summarizeConversation, + SummarizeResponse, +} from "../condense" import { ApiMessage } from "../task-persistence/apiMessages" import { ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types" import { RooIgnoreController } from "../ignore/RooIgnoreController" @@ -52,9 +59,10 @@ export type TruncationResult = { /** * 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 tagged with truncationParent. - * A truncation marker is inserted to track where truncation occurred. + * The first message in the effective API history is always retained, and a specified fraction + * (rounded to an even number) of messages from the beginning of that effective history + * (excluding the first effective message) is tagged with truncationParent. A truncation marker + * is inserted to track where truncation occurred. * * This implements non-destructive sliding window truncation, allowing messages to be * restored if the user rewinds past the truncation point. @@ -69,14 +77,12 @@ export function truncateConversation(messages: ApiMessage[], fracToRemove: numbe const truncationId = crypto.randomUUID() - // Filter to only visible messages (those not already truncated) - // We need to track original indices to correctly tag messages in the full array - const visibleIndices: number[] = [] - messages.forEach((msg, index) => { - if (!msg.truncationParent && !msg.isTruncationMarker) { - visibleIndices.push(index) - } - }) + // Only truncate messages that are still part of the effective API history. + // Prior condensed history remains stored for rewind, but it should not consume + // the fallback truncation slice. + const visibleIndices = getEffectiveApiHistoryIndices(messages).filter( + (index) => !messages[index].isTruncationMarker, + ) // Calculate how many visible messages to truncate (excluding first visible message) const visibleCount = visibleIndices.length @@ -334,11 +340,10 @@ export async function manageContext({ if (prevContextTokens > allowedTokens) { const truncationResult = truncateConversation(messages, 0.5, taskId) - // Calculate new context tokens after truncation by counting non-truncated messages - // Messages with truncationParent are hidden, so we count only those without it - const effectiveMessages = truncationResult.messages.filter( - (msg) => !msg.truncationParent && !msg.isTruncationMarker, - ) + // Calculate new context tokens after truncation from the effective API history. + // This keeps the post-truncation recount aligned with the same filtered history + // we actually send to the provider, including prior condense/truncation layers. + const effectiveMessages = getEffectiveApiHistory(truncationResult.messages) // Include system prompt tokens so this value matches what we send to the API. // Note: `prevContextTokens` is computed locally here (totalTokens + lastMessageTokens).