From a4ef01101aa65126b17717517405cc5fe30f30c3 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Mon, 28 Sep 2026 19:53:23 +0200 Subject: [PATCH] fix(task): report emergency condense like the automatic path The emergency condense in handleContextWindowExceededError duplicated the automatic path's result handling and had diverged: - Its condense_context event carried no condenseId, so rewind cleanup could not remove the emergency summary; deleting the condense row cut after the summary and left it in place. - A condense error returned by manageContext was dropped instead of being shown as condense_context_error. - It ignored the custom CONDENSE prompt and did not pass filesReadByRoo, cwd, or rooIgnoreController. Both paths now report through one reportContextManagementResult helper, and the emergency call passes the same condense settings. The profile threshold overriding the forced 75% is left for a follow-up. Fixes #1769 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/core/message-manager/index.spec.ts | 44 ++++++++ src/core/task/Task.ts | 136 +++++++++++-------------- src/core/task/__tests__/Task.spec.ts | 68 +++++++++++++ 3 files changed, 171 insertions(+), 77 deletions(-) diff --git a/src/core/message-manager/index.spec.ts b/src/core/message-manager/index.spec.ts index c9fa4b131f..770b8347cf 100644 --- a/src/core/message-manager/index.spec.ts +++ b/src/core/message-manager/index.spec.ts @@ -204,6 +204,50 @@ describe("MessageManager", () => { expect(hasSummary).toBe(false) }) + it.each([ + ["carries the condenseId", "emergency-summary", false], + ["has no condenseId (pre-#1769 emergency event)", undefined, true], + ] as const)( + "deleting an emergency condense row that %s removes its summary only via the id", + async (_label, eventCondenseId, summarySurvives) => { + const condenseId = "emergency-summary" + // The summary sits just after the last pre-condense message (ts 301), while the + // condense row is emitted later (ts 400); deleting that row cuts after the summary, + // so only the id-based cleanup can remove it. + mockTask.clineMessages = [ + { ts: 100, say: "user", text: "First" }, + { ts: 300, say: "assistant", text: "Response" }, + { + ts: 400, + say: "condense_context", + contextCondense: { condenseId: eventCondenseId, summary: "Summary" }, + }, + ] + mockTask.apiConversationHistory = [ + { ts: 100, role: "user", content: [{ type: "text", text: "First" }], condenseParent: condenseId }, + { + ts: 300, + role: "assistant", + content: [{ type: "text", text: "Response" }], + condenseParent: condenseId, + }, + { + ts: 301, + role: "user", + content: [{ type: "text", text: "Summary" }], + isSummary: true, + condenseId, + }, + ] + + await manager.rewindToTimestamp(400) + + const apiCall: Array<{ isSummary?: boolean; condenseId?: string }> = + mockTask.overwriteApiConversationHistory.mock.calls[0]?.[0] ?? mockTask.apiConversationHistory + expect(apiCall.some((m) => m.isSummary && m.condenseId === condenseId)).toBe(summarySurvives) + }, + ) + it("should clear orphaned condenseParent tags via cleanup", async () => { const condenseId = "summary-123" diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d5313f68cf..c7ca344ea9 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -108,7 +108,7 @@ import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser" -import { manageContext, willManageContext } from "../context-management" +import { type ContextManagementResult, manageContext, willManageContext } from "../context-management" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { @@ -4414,6 +4414,56 @@ export class Task extends EventEmitter implements TaskLike { return this.api.getModel().info } + /** + * Report a manageContext result to the webview. Shared by the automatic and emergency + * condense paths so both emit the same events: a surfaced error, and a condense event + * carrying the condenseId that rewind cleanup uses to remove the matching summary. + */ + private async reportContextManagementResult(result: ContextManagementResult): Promise { + if (result.error) { + await this.say("condense_context_error", result.error) + } + if (result.summary) { + const { summary, cost, prevContextTokens, newContextTokens = 0, condenseId } = result + const contextCondense: ContextCondense = { + summary, + cost, + newContextTokens, + prevContextTokens, + condenseId, + } + await this.say( + "condense_context", + undefined /* text */, + undefined /* images */, + false /* partial */, + undefined /* checkpoint */, + undefined /* progressStatus */, + { isNonInteractive: true } /* options */, + contextCondense, + ) + } else if (result.truncationId) { + // Sliding window truncation occurred (fallback when condensing fails or is disabled) + const contextTruncation: ContextTruncation = { + truncationId: result.truncationId, + messagesRemoved: result.messagesRemoved ?? 0, + prevContextTokens: result.prevContextTokens, + newContextTokens: result.newContextTokensAfterTruncation ?? 0, + } + await this.say( + "sliding_window_truncation", + undefined /* text */, + undefined /* images */, + false /* partial */, + undefined /* checkpoint */, + undefined /* progressStatus */, + { isNonInteractive: true } /* options */, + undefined /* contextCondense */, + contextTruncation, + ) + } + } + private async handleContextWindowExceededError(requestModelInfo: ModelInfo): Promise { const state = await this.providerRef.deref()?.getState() const { profileThresholds = {} } = state ?? {} @@ -4490,6 +4540,7 @@ export class Task extends EventEmitter implements TaskLike { try { // Generate environment details to include in the condensed summary const environmentDetails = await getEnvironmentDetails(this, true) + const filesReadByRoo = await this.getFilesReadByRooSafely("handleContextWindowExceededError") // Force aggressive truncation by keeping only 75% of the conversation history const truncateResult = await manageContext({ @@ -4502,50 +4553,22 @@ export class Task extends EventEmitter implements TaskLike { autoCondenseContextPercent: FORCED_CONTEXT_REDUCTION_PERCENT, systemPrompt: await this.getSystemPrompt(state, modelInfo), taskId: this.taskId, + // Recovery must summarize like the automatic path: same prompt and folded file context. + customCondensingPrompt: state?.customSupportPrompts?.CONDENSE, profileThresholds, currentProfileId, metadata, environmentDetails, + filesReadByRoo, + cwd: this.cwd, + rooIgnoreController: this.rooIgnoreController, useAvailableInputForContextPercent, }) if (truncateResult.messages !== this.apiConversationHistory) { await this.overwriteApiConversationHistory(truncateResult.messages) } - - if (truncateResult.summary) { - const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult - const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens } - await this.say( - "condense_context", - undefined /* text */, - undefined /* images */, - false /* partial */, - undefined /* checkpoint */, - undefined /* progressStatus */, - { isNonInteractive: true } /* options */, - contextCondense, - ) - } else if (truncateResult.truncationId) { - // Sliding window truncation occurred (fallback when condensing fails or is disabled) - const contextTruncation: ContextTruncation = { - truncationId: truncateResult.truncationId, - messagesRemoved: truncateResult.messagesRemoved ?? 0, - prevContextTokens: truncateResult.prevContextTokens, - newContextTokens: truncateResult.newContextTokensAfterTruncation ?? 0, - } - await this.say( - "sliding_window_truncation", - undefined /* text */, - undefined /* images */, - false /* partial */, - undefined /* checkpoint */, - undefined /* progressStatus */, - { isNonInteractive: true } /* options */, - undefined /* contextCondense */, - contextTruncation, - ) - } + await this.reportContextManagementResult(truncateResult) } finally { // Notify webview that context management is complete (removes in-progress spinner) // IMPORTANT: Must always be sent to dismiss the spinner, even on error @@ -4774,48 +4797,7 @@ export class Task extends EventEmitter implements TaskLike { if (truncateResult.messages !== this.apiConversationHistory) { await this.overwriteApiConversationHistory(truncateResult.messages) } - if (truncateResult.error) { - await this.say("condense_context_error", truncateResult.error) - } - if (truncateResult.summary) { - const { summary, cost, prevContextTokens, newContextTokens = 0, condenseId } = truncateResult - const contextCondense: ContextCondense = { - summary, - cost, - newContextTokens, - prevContextTokens, - condenseId, - } - await this.say( - "condense_context", - undefined /* text */, - undefined /* images */, - false /* partial */, - undefined /* checkpoint */, - undefined /* progressStatus */, - { isNonInteractive: true } /* options */, - contextCondense, - ) - } else if (truncateResult.truncationId) { - // Sliding window truncation occurred (fallback when condensing fails or is disabled) - const contextTruncation: ContextTruncation = { - truncationId: truncateResult.truncationId, - messagesRemoved: truncateResult.messagesRemoved ?? 0, - prevContextTokens: truncateResult.prevContextTokens, - newContextTokens: truncateResult.newContextTokensAfterTruncation ?? 0, - } - await this.say( - "sliding_window_truncation", - undefined /* text */, - undefined /* images */, - false /* partial */, - undefined /* checkpoint */, - undefined /* progressStatus */, - { isNonInteractive: true } /* options */, - undefined /* contextCondense */, - contextTruncation, - ) - } + await this.reportContextManagementResult(truncateResult) } finally { // Notify webview that context management is complete (sets isCondensing = false) // This removes the in-progress spinner and allows the completed result to show diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index eae85cf9f2..964dec4a1e 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1449,6 +1449,74 @@ describe("Cline", () => { expect(getSystemPromptSpy).toHaveBeenCalledWith(snapshot, ctxModelInfo) }) + describe("emergency condense reporting (#1769)", () => { + const ctxModelInfo: ModelInfo = { contextWindow: 50_000, maxTokens: 1024, supportsPromptCache: false } + + // Overflow the 50k window so the real handler reaches manageContext's condense branch. + const createOverflowingTask = async (state: ProviderState) => { + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 100_000, + }) + vi.spyOn(task.api, "getModel").mockReturnValue({ id: "ctx-model", info: ctxModelInfo }) + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) + task.apiConversationHistory = [{ role: "user", content: [{ type: "text", text: "x" }], ts: Date.now() }] + const say = vi.spyOn(task, "say").mockResolvedValue(undefined) + return { task, say } + } + + it("emits the summary's condenseId and summarizes with the task's condense settings", async () => { + const { task, say } = await createOverflowingTask( + providerStateWith({ customSupportPrompts: { CONDENSE: "custom condense prompt" } }), + ) + vi.mocked(summarizeConversation).mockResolvedValueOnce({ + messages: [{ role: "user", content: [{ type: "text", text: "condensed" }], ts: Date.now() }], + summary: "summary", + cost: 0, + newContextTokens: 1, + condenseId: "emergency-condense-id", + }) + + await getTaskTestAccess(task).handleContextWindowExceededError(ctxModelInfo) + + // Rewind cleanup can only remove the summary if the event carries its condenseId. + const condenseCall = say.mock.calls.find(([type]) => type === "condense_context") + expect(condenseCall?.[7]).toMatchObject({ condenseId: "emergency-condense-id" }) + const [options] = requireDefined(vi.mocked(summarizeConversation).mock.calls.at(-1)) + expect(options).toMatchObject({ customCondensingPrompt: "custom condense prompt", cwd: task.cwd }) + }) + + it("surfaces a failed emergency condense as condense_context_error", async () => { + const { task, say } = await createOverflowingTask(providerStateWith()) + vi.mocked(summarizeConversation).mockResolvedValueOnce({ + messages: task.apiConversationHistory, + summary: "", + cost: 0, + error: "Condensing failed: rate limited", + }) + + await getTaskTestAccess(task).handleContextWindowExceededError(ctxModelInfo) + + expect(say).toHaveBeenCalledWith("condense_context_error", "Condensing failed: rate limited") + // The sliding-window fallback still reports its truncation after the error row. + expect(say.mock.calls.map(([type]) => type)).toEqual([ + "condense_context_error", + "sliding_window_truncation", + ]) + }) + }) + it("uses the task mode when manually condensing after focused state changes", async () => { vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "architect" })) const task = new Task({