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)
This commit is contained in:
Hannes Rudolph 2025-10-06 12:03:51 -06:00
parent af75083b8a
commit 5b925cf751
3 changed files with 96 additions and 24 deletions

View file

@ -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

View file

@ -1040,6 +1040,29 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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<TaskEvents> implements TaskLike {
false /* partial */,
undefined /* checkpoint */,
undefined /* progressStatus */,
{ isNonInteractive: true } /* options */,
{
isNonInteractive: true,
metadata: newCondenseId ? { condenseId: newCondenseId } : undefined,
} /* options */,
contextCondense,
)
}

View file

@ -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<string>()
// 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