Skip to content
Open
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
44 changes: 44 additions & 0 deletions src/core/message-manager/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
136 changes: 59 additions & 77 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@
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 {
Expand Down Expand Up @@ -4414,6 +4414,56 @@
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<void> {
if (result.error) {

Check warning on line 4423 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:4423: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
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 */,

Check warning on line 4439 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:4439: Survived BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.
undefined /* checkpoint */,
undefined /* progressStatus */,
{ isNonInteractive: true } /* options */,

Check warning on line 4442 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:4442: 2 mutation test gaps; example: Survived ObjectLiteral mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
contextCondense,
)
} else if (result.truncationId) {

Check warning on line 4445 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:4445: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
// Sliding window truncation occurred (fallback when condensing fails or is disabled)
const contextTruncation: ContextTruncation = {

Check warning on line 4447 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:4447: Survived ObjectLiteral mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
truncationId: result.truncationId,
messagesRemoved: result.messagesRemoved ?? 0,

Check warning on line 4449 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:4449: Survived LogicalOperator mutant (replacement: result.messagesRemoved && 0). See the job summary for the complete list and resolution guidance.
prevContextTokens: result.prevContextTokens,
newContextTokens: result.newContextTokensAfterTruncation ?? 0,

Check warning on line 4451 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:4451: Survived LogicalOperator mutant (replacement: result.newContextTokensAfterTruncation && 0). See the job summary for the complete list and resolution guidance.
}
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<void> {
const state = await this.providerRef.deref()?.getState()
const { profileThresholds = {} } = state ?? {}
Expand Down Expand Up @@ -4490,6 +4540,7 @@
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({
Expand All @@ -4502,50 +4553,22 @@
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
Expand Down Expand Up @@ -4774,48 +4797,7 @@
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
Expand Down
68 changes: 68 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading