Skip to content

Commit 5b925cf

Browse files
committed
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)
1 parent af75083 commit 5b925cf

File tree

3 files changed

+96
-24
lines changed

3 files changed

+96
-24
lines changed

src/core/condense/index.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -196,16 +196,12 @@ export async function summarizeConversation(
196196
condenseId: condenseId,
197197
}
198198

199-
// Tag middle messages from the full middle span, but only set condenseParent
200-
// for those that were actually part of the current summarization window and lack a tag.
201-
const windowTs = new Set(
202-
messagesToSummarize
203-
.slice(1) // skip the preserved first
204-
.map((m) => m.ts)
205-
.filter((ts): ts is number => typeof ts === "number"),
206-
)
199+
// Tag middle messages from the full middle span, including any previous summaries
200+
// that were part of this summarization window. Preserve existing condenseId/isSummary.
201+
const windowTs = new Set(messagesToSummarize.map((m) => m.ts).filter((ts): ts is number => typeof ts === "number"))
207202
const middleMessages = messages.slice(1, -N_MESSAGES_TO_KEEP).map((msg) => {
208-
if (!msg.isSummary && typeof msg.ts === "number" && windowTs.has(msg.ts)) {
203+
if (typeof msg.ts === "number" && windowTs.has(msg.ts)) {
204+
// Do not alter isSummary or condenseId on prior summaries; only add condenseParent if missing
209205
return { ...msg, condenseParent: msg.condenseParent ?? condenseId }
210206
}
211207
return msg

src/core/task/Task.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1040,6 +1040,29 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
10401040
// Set flag to skip previous_response_id on the next API call after manual condense
10411041
this.skipPrevResponseIdOnce = true
10421042

1043+
// If there was a previous condense_context in the UI, tag it with condenseParent = newCondenseId
1044+
try {
1045+
const newCondenseId = messages.find((m) => m.isSummary && m.condenseId)?.condenseId
1046+
if (newCondenseId) {
1047+
const lastCondenseIdx = findLastIndex(
1048+
this.clineMessages,
1049+
(m) => m.type === "say" && m.say === "condense_context",
1050+
)
1051+
if (lastCondenseIdx !== -1) {
1052+
const lastCondenseMsg = this.clineMessages[lastCondenseIdx] as ClineMessage &
1053+
ClineMessageWithMetadata
1054+
lastCondenseMsg.metadata = lastCondenseMsg.metadata ?? {}
1055+
if (!lastCondenseMsg.metadata.condenseParent) {
1056+
lastCondenseMsg.metadata.condenseParent = newCondenseId
1057+
await this.saveClineMessages()
1058+
await this.updateClineMessage(lastCondenseMsg)
1059+
}
1060+
}
1061+
}
1062+
} catch {
1063+
// non-fatal; UI metadata tagging is best-effort
1064+
}
1065+
10431066
const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens }
10441067
await this.say(
10451068
"condense_context",
@@ -2627,6 +2650,39 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
26272650
// send previous_response_id so the request reflects the fresh condensed context.
26282651
this.skipPrevResponseIdOnce = true
26292652

2653+
// Determine the condenseId of the newly created summary in API history
2654+
let newCondenseId: string | undefined
2655+
try {
2656+
const lastSummary = [...this.apiConversationHistory]
2657+
.reverse()
2658+
.find((m) => m.isSummary && m.condenseId)
2659+
newCondenseId = lastSummary?.condenseId
2660+
} catch {
2661+
// non-fatal
2662+
}
2663+
2664+
// Tag the previous UI condense_context (if any) with condenseParent to maintain lineage
2665+
try {
2666+
if (newCondenseId) {
2667+
const lastCondenseIdx = findLastIndex(
2668+
this.clineMessages,
2669+
(m) => m.type === "say" && m.say === "condense_context",
2670+
)
2671+
if (lastCondenseIdx !== -1) {
2672+
const lastCondenseMsg = this.clineMessages[lastCondenseIdx] as ClineMessage &
2673+
ClineMessageWithMetadata
2674+
lastCondenseMsg.metadata = lastCondenseMsg.metadata ?? {}
2675+
if (!lastCondenseMsg.metadata.condenseParent) {
2676+
lastCondenseMsg.metadata.condenseParent = newCondenseId
2677+
await this.saveClineMessages()
2678+
await this.updateClineMessage(lastCondenseMsg)
2679+
}
2680+
}
2681+
}
2682+
} catch {
2683+
// best-effort
2684+
}
2685+
26302686
const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult
26312687
const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens }
26322688
await this.say(
@@ -2636,7 +2692,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
26362692
false /* partial */,
26372693
undefined /* checkpoint */,
26382694
undefined /* progressStatus */,
2639-
{ isNonInteractive: true } /* options */,
2695+
{
2696+
isNonInteractive: true,
2697+
metadata: newCondenseId ? { condenseId: newCondenseId } : undefined,
2698+
} /* options */,
26402699
contextCondense,
26412700
)
26422701
}

src/core/webview/webviewMessageHandler.ts

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -113,33 +113,51 @@ export const webviewMessageHandler = async (
113113
)
114114
}
115115

116-
// Perform hygiene: clean up orphaned condenseParent references
117-
await performCondenseHygiene(currentCline)
116+
// Perform hygiene: UI is source-of-truth during rewinds (purge API summaries whose UI counterparts were removed)
117+
await performCondenseHygiene(currentCline, { uiOnly: true })
118118
}
119119

120120
/**
121121
* Clean up orphaned condenseParent references after truncation
122122
*/
123-
const performCondenseHygiene = async (currentCline: any) => {
124-
// Find all active condenseIds (from remaining summary messages)
123+
const performCondenseHygiene = async (currentCline: any, opts?: { uiOnly?: boolean }) => {
124+
// Build active condenseIds. If uiOnly, treat UI as source-of-truth (used during rewind/delete).
125+
// Otherwise, include API summaries too (general hygiene).
125126
const activeCondenseIds = new Set<string>()
126127

127-
// Check API conversation history for active summaries
128-
currentCline.apiConversationHistory.forEach((msg: ApiMessage) => {
129-
if (msg.isSummary && msg.condenseId) {
130-
activeCondenseIds.add(msg.condenseId)
131-
}
132-
})
133-
134-
// Check UI messages for active summaries
128+
// Always include UI-derived condenseIds
135129
currentCline.clineMessages.forEach((msg: any) => {
136130
if (msg.say === "condense_context" && msg.metadata?.condenseId) {
137131
activeCondenseIds.add(msg.metadata.condenseId)
138132
}
139133
})
140134

141-
// Clean up orphaned condenseParent references in API history
135+
// Optionally include API summaries that remain in history (non-delete hygiene)
136+
if (!opts?.uiOnly) {
137+
currentCline.apiConversationHistory.forEach((msg: ApiMessage) => {
138+
if (msg.isSummary && msg.condenseId) {
139+
activeCondenseIds.add(msg.condenseId)
140+
}
141+
})
142+
}
143+
142144
let apiHistoryModified = false
145+
let uiMessagesModified = false
146+
147+
// Purge API summaries that are not represented by UI when uiOnly=true (rewind to pre-condense state).
148+
// In general hygiene (uiOnly=false), we only remove summaries with condenseIds not present anywhere.
149+
const beforeLen = currentCline.apiConversationHistory.length
150+
currentCline.apiConversationHistory = currentCline.apiConversationHistory.filter((msg: ApiMessage) => {
151+
if (msg.isSummary && msg.condenseId && !activeCondenseIds.has(msg.condenseId)) {
152+
return false
153+
}
154+
return true
155+
})
156+
if (currentCline.apiConversationHistory.length !== beforeLen) {
157+
apiHistoryModified = true
158+
}
159+
160+
// Clean up orphaned condenseParent references in API history
143161
currentCline.apiConversationHistory.forEach((msg: ApiMessage) => {
144162
if (msg.condenseParent && !activeCondenseIds.has(msg.condenseParent)) {
145163
delete msg.condenseParent
@@ -148,7 +166,6 @@ export const webviewMessageHandler = async (
148166
})
149167

150168
// Clean up orphaned condenseParent references in UI messages
151-
let uiMessagesModified = false
152169
currentCline.clineMessages.forEach((msg: any) => {
153170
if (msg.metadata?.condenseParent && !activeCondenseIds.has(msg.metadata.condenseParent)) {
154171
delete msg.metadata.condenseParent

0 commit comments

Comments
 (0)