diff --git a/src/core/sliding-window/__tests__/context-compression-fix.test.ts b/src/core/sliding-window/__tests__/context-compression-fix.test.ts new file mode 100644 index 0000000000..39a7e174ef --- /dev/null +++ b/src/core/sliding-window/__tests__/context-compression-fix.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { truncateConversationIfNeeded } from "../index" +import { ApiHandler } from "../../../api" +import { ApiMessage } from "../../task-persistence/apiMessages" + +// Mock dependencies +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureSlidingWindowTruncation: vi.fn(), + }, + }, +})) + +vi.mock("../../condense", () => ({ + MAX_CONDENSE_THRESHOLD: 100, + MIN_CONDENSE_THRESHOLD: 50, + summarizeConversation: vi.fn().mockResolvedValue({ + messages: [], + summary: "Test summary", + cost: 0.01, + newContextTokens: 100, + }), +})) + +describe("Context Compression Fix for Issue #4430", () => { + let mockApiHandler: ApiHandler + let testMessages: ApiMessage[] + let mockSummarizeConversation: any + + beforeEach(async () => { + // Reset all mocks before each test + vi.clearAllMocks() + + // Get the mocked function + const { summarizeConversation } = await import("../../condense") + mockSummarizeConversation = summarizeConversation + + mockApiHandler = { + countTokens: vi.fn().mockResolvedValue(1000), + } as any + + testMessages = [ + { + role: "user", + content: [{ type: "text", text: "Test message 1" }], + ts: Date.now(), + }, + { + role: "assistant", + content: [{ type: "text", text: "Test response 1" }], + ts: Date.now(), + }, + ] as ApiMessage[] + }) + + it("should skip context compression when skipContextCompression flag is true", async () => { + const result = await truncateConversationIfNeeded({ + messages: testMessages, + totalTokens: 8000, // High token count to trigger compression + contextWindow: 10000, + maxTokens: 1000, + apiHandler: mockApiHandler, + autoCondenseContext: true, + autoCondenseContextPercent: 50, // Low threshold to trigger compression + systemPrompt: "Test system prompt", + taskId: "test-task-id", + profileThresholds: {}, + currentProfileId: "default", + skipContextCompression: true, // This should prevent compression + }) + + // Should return original messages without compression + expect(result.messages).toBe(testMessages) + expect(result.summary).toBe("") + expect(result.cost).toBe(0) + }) + + it("should perform normal compression when skipContextCompression flag is false", async () => { + const result = await truncateConversationIfNeeded({ + messages: testMessages, + totalTokens: 8000, // High token count to trigger compression + contextWindow: 10000, + maxTokens: 1000, + apiHandler: mockApiHandler, + autoCondenseContext: true, + autoCondenseContextPercent: 50, // Low threshold to trigger compression + systemPrompt: "Test system prompt", + taskId: "test-task-id", + profileThresholds: {}, + currentProfileId: "default", + skipContextCompression: false, // Normal compression should occur + }) + + // Should call summarizeConversation for compression + expect(mockSummarizeConversation).toHaveBeenCalled() + expect(result.summary).toBe("Test summary") + expect(result.cost).toBe(0.01) + }) + + it("should not trigger compression when context is below threshold", async () => { + const result = await truncateConversationIfNeeded({ + messages: testMessages, + totalTokens: 1000, // Low token count, below threshold + contextWindow: 10000, + maxTokens: 1000, + apiHandler: mockApiHandler, + autoCondenseContext: true, + autoCondenseContextPercent: 80, // High threshold + systemPrompt: "Test system prompt", + taskId: "test-task-id", + profileThresholds: {}, + currentProfileId: "default", + skipContextCompression: false, + }) + + // Should not call summarizeConversation + expect(mockSummarizeConversation).not.toHaveBeenCalled() + expect(result.messages).toBe(testMessages) + expect(result.summary).toBe("") + }) + + it("should handle multi-file read scenario correctly", async () => { + // Simulate the scenario from issue #4430: + // - Multi-file read is enabled (maxConcurrentFileReads > 1) + // - Context usage is at 100% threshold + // - Settings save operation triggers compression check + + const result = await truncateConversationIfNeeded({ + messages: testMessages, + totalTokens: 10000, // Exactly at 100% of context window + contextWindow: 10000, + maxTokens: 1000, + apiHandler: mockApiHandler, + autoCondenseContext: true, + autoCondenseContextPercent: 100, // 100% threshold as in the issue + systemPrompt: "Test system prompt", + taskId: "test-task-id", + profileThresholds: {}, + currentProfileId: "default", + skipContextCompression: true, // Skip flag set by settings save + }) + + // Should skip compression despite being at threshold + expect(result.messages).toBe(testMessages) + expect(result.summary).toBe("") + expect(result.cost).toBe(0) + }) + + it("should clear the skip flag after use to prevent side effects", async () => { + // First call with skip flag set to true + const firstResult = await truncateConversationIfNeeded({ + messages: testMessages, + totalTokens: 8000, // High token count to trigger compression + contextWindow: 10000, + maxTokens: 1000, + apiHandler: mockApiHandler, + autoCondenseContext: true, + autoCondenseContextPercent: 50, // Low threshold to trigger compression + systemPrompt: "Test system prompt", + taskId: "test-task-id", + profileThresholds: {}, + currentProfileId: "default", + skipContextCompression: true, // Skip compression on first call + }) + + // Should skip compression + expect(firstResult.messages).toBe(testMessages) + expect(firstResult.summary).toBe("") + expect(mockSummarizeConversation).not.toHaveBeenCalled() + + // Reset mock call count + vi.clearAllMocks() + + // Second call without skip flag (simulating next API request) + const secondResult = await truncateConversationIfNeeded({ + messages: testMessages, + totalTokens: 8000, // Same high token count + contextWindow: 10000, + maxTokens: 1000, + apiHandler: mockApiHandler, + autoCondenseContext: true, + autoCondenseContextPercent: 50, // Same low threshold + systemPrompt: "Test system prompt", + taskId: "test-task-id", + profileThresholds: {}, + currentProfileId: "default", + skipContextCompression: false, // Normal compression should occur + }) + + // Should perform compression this time + expect(mockSummarizeConversation).toHaveBeenCalled() + expect(secondResult.summary).toBe("Test summary") + expect(secondResult.cost).toBe(0.01) + }) +}) diff --git a/src/core/sliding-window/index.ts b/src/core/sliding-window/index.ts index 1e518c9a56..0798842184 100644 --- a/src/core/sliding-window/index.ts +++ b/src/core/sliding-window/index.ts @@ -77,6 +77,7 @@ type TruncateOptions = { condensingApiHandler?: ApiHandler profileThresholds: Record currentProfileId: string + skipContextCompression?: boolean } type TruncateResponse = SummarizeResponse & { prevContextTokens: number } @@ -102,6 +103,7 @@ export async function truncateConversationIfNeeded({ condensingApiHandler, profileThresholds, currentProfileId, + skipContextCompression, }: TruncateOptions): Promise { let error: string | undefined let cost = 0 @@ -145,6 +147,10 @@ export async function truncateConversationIfNeeded({ if (autoCondenseContext) { const contextPercent = (100 * prevContextTokens) / contextWindow if (contextPercent >= effectiveThreshold || prevContextTokens > allowedTokens) { + if (skipContextCompression) { + return { messages, summary: "", cost, prevContextTokens, error } + } + // Attempt to intelligently condense the context const result = await summarizeConversation( messages, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1dd615f0eb..950df1410d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -130,6 +130,31 @@ export class Task extends EventEmitter implements TaskLike { readonly taskNumber: number readonly workspacePath: string + /** + * Flag to skip context compression on the next API request. + * Used to prevent unwanted compression during batch operations like multi-file reads or settings saves. + * Automatically cleared after use to prevent side effects. + */ + private skipNextContextCompressionCheck: boolean = false + + /** + * Sets the flag to skip context compression on the next API request. + * This is used to prevent unwanted compression during batch operations. + */ + public setSkipNextContextCompression(): void { + this.skipNextContextCompressionCheck = true + } + + /** + * Gets and clears the skip context compression flag. + * This ensures the flag is only used once and doesn't persist. + */ + private getAndClearSkipContextCompression(): boolean { + const shouldSkip = this.skipNextContextCompressionCheck + this.skipNextContextCompressionCheck = false + return shouldSkip + } + /** * The mode associated with this task. Persisted across sessions * to maintain user context when reopening tasks from history. @@ -2003,7 +2028,10 @@ export class Task extends EventEmitter implements TaskLike { condensingApiHandler, profileThresholds, currentProfileId, + skipContextCompression: this.getAndClearSkipContextCompression(), }) + + // Flag is automatically cleared by getAndClearSkipContextCompression() if (truncateResult.messages !== this.apiConversationHistory) { await this.overwriteApiConversationHistory(truncateResult.messages) } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index f5dc6a467f..2f09b2cf38 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1275,6 +1275,11 @@ export const webviewMessageHandler = async ( break case "maxReadFileLine": await updateGlobalState("maxReadFileLine", message.value) + // Skip context compression for file reading settings changes + const activeTaskForFileRead = provider.getCurrentCline() + if (activeTaskForFileRead) { + activeTaskForFileRead.setSkipNextContextCompression() + } await provider.postStateToWebview() break case "maxImageFileSize": @@ -1288,16 +1293,30 @@ export const webviewMessageHandler = async ( case "maxConcurrentFileReads": const valueToSave = message.value // Capture the value intended for saving await updateGlobalState("maxConcurrentFileReads", valueToSave) + const activeTask = provider.getCurrentCline() + if (activeTask && typeof valueToSave === "number" && valueToSave > 1) { + activeTask.setSkipNextContextCompression() + } await provider.postStateToWebview() break case "includeDiagnosticMessages": // Only apply default if the value is truly undefined (not false) const includeValue = message.bool !== undefined ? message.bool : true await updateGlobalState("includeDiagnosticMessages", includeValue) + // Skip context compression for diagnostic settings changes + const activeTaskForDiagnostics = provider.getCurrentCline() + if (activeTaskForDiagnostics) { + activeTaskForDiagnostics.setSkipNextContextCompression() + } await provider.postStateToWebview() break case "maxDiagnosticMessages": await updateGlobalState("maxDiagnosticMessages", message.value ?? 50) + // Skip context compression for diagnostic settings changes + const activeTaskForMaxDiagnostics = provider.getCurrentCline() + if (activeTaskForMaxDiagnostics) { + activeTaskForMaxDiagnostics.setSkipNextContextCompression() + } await provider.postStateToWebview() break case "setHistoryPreviewCollapsed": // Add the new case handler