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({