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
196 changes: 196 additions & 0 deletions src/core/sliding-window/__tests__/context-compression-fix.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
6 changes: 6 additions & 0 deletions src/core/sliding-window/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ type TruncateOptions = {
condensingApiHandler?: ApiHandler
profileThresholds: Record<string, number>
currentProfileId: string
skipContextCompression?: boolean
}

type TruncateResponse = SummarizeResponse & { prevContextTokens: number }
Expand All @@ -102,6 +103,7 @@ export async function truncateConversationIfNeeded({
condensingApiHandler,
profileThresholds,
currentProfileId,
skipContextCompression,
}: TruncateOptions): Promise<TruncateResponse> {
let error: string | undefined
let cost = 0
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,31 @@ export class Task extends EventEmitter<TaskEvents> 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.
Expand Down Expand Up @@ -2003,7 +2028,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
condensingApiHandler,
profileThresholds,
currentProfileId,
skipContextCompression: this.getAndClearSkipContextCompression(),
})

// Flag is automatically cleared by getAndClearSkipContextCompression()
if (truncateResult.messages !== this.apiConversationHistory) {
await this.overwriteApiConversationHistory(truncateResult.messages)
}
Expand Down
19 changes: 19 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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
Expand Down
Loading