From 5b925cf751b5722b10d52f39263f23d97a10d712 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 6 Oct 2025 12:03:51 -0600 Subject: [PATCH] fix(condense): preserve lineage across successive condenses and add UI-first rewind hygiene - Tag prior summaries within the current window with condenseParent (preserve original condenseId/isSummary) - Propagate condenseParent to the previous UI condense_context for lineage continuity - Add rewind-aware hygiene (uiOnly) so deletes/rewinds restore API history to exact pre-condense state at the chosen point - Keep tests green (condense + task suites) --- src/core/condense/index.ts | 14 ++---- src/core/task/Task.ts | 61 ++++++++++++++++++++++- src/core/webview/webviewMessageHandler.ts | 45 +++++++++++------ 3 files changed, 96 insertions(+), 24 deletions(-) diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 08e7a3f298..47389b60aa 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -196,16 +196,12 @@ export async function summarizeConversation( condenseId: condenseId, } - // Tag middle messages from the full middle span, but only set condenseParent - // for those that were actually part of the current summarization window and lack a tag. - const windowTs = new Set( - messagesToSummarize - .slice(1) // skip the preserved first - .map((m) => m.ts) - .filter((ts): ts is number => typeof ts === "number"), - ) + // Tag middle messages from the full middle span, including any previous summaries + // that were part of this summarization window. Preserve existing condenseId/isSummary. + const windowTs = new Set(messagesToSummarize.map((m) => m.ts).filter((ts): ts is number => typeof ts === "number")) const middleMessages = messages.slice(1, -N_MESSAGES_TO_KEEP).map((msg) => { - if (!msg.isSummary && typeof msg.ts === "number" && windowTs.has(msg.ts)) { + if (typeof msg.ts === "number" && windowTs.has(msg.ts)) { + // Do not alter isSummary or condenseId on prior summaries; only add condenseParent if missing return { ...msg, condenseParent: msg.condenseParent ?? condenseId } } return msg diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ab88e45d75..0aa0474211 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1040,6 +1040,29 @@ export class Task extends EventEmitter implements TaskLike { // Set flag to skip previous_response_id on the next API call after manual condense this.skipPrevResponseIdOnce = true + // If there was a previous condense_context in the UI, tag it with condenseParent = newCondenseId + try { + const newCondenseId = messages.find((m) => m.isSummary && m.condenseId)?.condenseId + if (newCondenseId) { + const lastCondenseIdx = findLastIndex( + this.clineMessages, + (m) => m.type === "say" && m.say === "condense_context", + ) + if (lastCondenseIdx !== -1) { + const lastCondenseMsg = this.clineMessages[lastCondenseIdx] as ClineMessage & + ClineMessageWithMetadata + lastCondenseMsg.metadata = lastCondenseMsg.metadata ?? {} + if (!lastCondenseMsg.metadata.condenseParent) { + lastCondenseMsg.metadata.condenseParent = newCondenseId + await this.saveClineMessages() + await this.updateClineMessage(lastCondenseMsg) + } + } + } + } catch { + // non-fatal; UI metadata tagging is best-effort + } + const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens } await this.say( "condense_context", @@ -2627,6 +2650,39 @@ export class Task extends EventEmitter implements TaskLike { // send previous_response_id so the request reflects the fresh condensed context. this.skipPrevResponseIdOnce = true + // Determine the condenseId of the newly created summary in API history + let newCondenseId: string | undefined + try { + const lastSummary = [...this.apiConversationHistory] + .reverse() + .find((m) => m.isSummary && m.condenseId) + newCondenseId = lastSummary?.condenseId + } catch { + // non-fatal + } + + // Tag the previous UI condense_context (if any) with condenseParent to maintain lineage + try { + if (newCondenseId) { + const lastCondenseIdx = findLastIndex( + this.clineMessages, + (m) => m.type === "say" && m.say === "condense_context", + ) + if (lastCondenseIdx !== -1) { + const lastCondenseMsg = this.clineMessages[lastCondenseIdx] as ClineMessage & + ClineMessageWithMetadata + lastCondenseMsg.metadata = lastCondenseMsg.metadata ?? {} + if (!lastCondenseMsg.metadata.condenseParent) { + lastCondenseMsg.metadata.condenseParent = newCondenseId + await this.saveClineMessages() + await this.updateClineMessage(lastCondenseMsg) + } + } + } + } catch { + // best-effort + } + const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens } await this.say( @@ -2636,7 +2692,10 @@ export class Task extends EventEmitter implements TaskLike { false /* partial */, undefined /* checkpoint */, undefined /* progressStatus */, - { isNonInteractive: true } /* options */, + { + isNonInteractive: true, + metadata: newCondenseId ? { condenseId: newCondenseId } : undefined, + } /* options */, contextCondense, ) } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 6c610f8acb..03ec8a7b35 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -113,33 +113,51 @@ export const webviewMessageHandler = async ( ) } - // Perform hygiene: clean up orphaned condenseParent references - await performCondenseHygiene(currentCline) + // Perform hygiene: UI is source-of-truth during rewinds (purge API summaries whose UI counterparts were removed) + await performCondenseHygiene(currentCline, { uiOnly: true }) } /** * Clean up orphaned condenseParent references after truncation */ - const performCondenseHygiene = async (currentCline: any) => { - // Find all active condenseIds (from remaining summary messages) + const performCondenseHygiene = async (currentCline: any, opts?: { uiOnly?: boolean }) => { + // Build active condenseIds. If uiOnly, treat UI as source-of-truth (used during rewind/delete). + // Otherwise, include API summaries too (general hygiene). const activeCondenseIds = new Set() - // Check API conversation history for active summaries - currentCline.apiConversationHistory.forEach((msg: ApiMessage) => { - if (msg.isSummary && msg.condenseId) { - activeCondenseIds.add(msg.condenseId) - } - }) - - // Check UI messages for active summaries + // Always include UI-derived condenseIds currentCline.clineMessages.forEach((msg: any) => { if (msg.say === "condense_context" && msg.metadata?.condenseId) { activeCondenseIds.add(msg.metadata.condenseId) } }) - // Clean up orphaned condenseParent references in API history + // Optionally include API summaries that remain in history (non-delete hygiene) + if (!opts?.uiOnly) { + currentCline.apiConversationHistory.forEach((msg: ApiMessage) => { + if (msg.isSummary && msg.condenseId) { + activeCondenseIds.add(msg.condenseId) + } + }) + } + let apiHistoryModified = false + let uiMessagesModified = false + + // Purge API summaries that are not represented by UI when uiOnly=true (rewind to pre-condense state). + // In general hygiene (uiOnly=false), we only remove summaries with condenseIds not present anywhere. + const beforeLen = currentCline.apiConversationHistory.length + currentCline.apiConversationHistory = currentCline.apiConversationHistory.filter((msg: ApiMessage) => { + if (msg.isSummary && msg.condenseId && !activeCondenseIds.has(msg.condenseId)) { + return false + } + return true + }) + if (currentCline.apiConversationHistory.length !== beforeLen) { + apiHistoryModified = true + } + + // Clean up orphaned condenseParent references in API history currentCline.apiConversationHistory.forEach((msg: ApiMessage) => { if (msg.condenseParent && !activeCondenseIds.has(msg.condenseParent)) { delete msg.condenseParent @@ -148,7 +166,6 @@ export const webviewMessageHandler = async ( }) // Clean up orphaned condenseParent references in UI messages - let uiMessagesModified = false currentCline.clineMessages.forEach((msg: any) => { if (msg.metadata?.condenseParent && !activeCondenseIds.has(msg.metadata.condenseParent)) { delete msg.metadata.condenseParent