Skip to content

Commit 534afc5

Browse files
committed
feat(condense): add condenseId/condenseParent metadata; assemble API history excluding condensed children; tag UI condense_context
1 parent 97f9686 commit 534afc5

File tree

4 files changed

+153
-19
lines changed

4 files changed

+153
-19
lines changed

packages/types/src/message.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,8 @@ export const clineMessageSchema = z.object({
227227
.optional(),
228228
})
229229
.optional(),
230+
condenseId: z.string().optional(),
231+
condenseParent: z.string().optional(),
230232
})
231233

232234
export type ClineMessage = z.infer<typeof clineMessageSchema>

src/core/condense/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export type SummarizeResponse = {
5757
cost: number // The cost of the summarization operation
5858
newContextTokens?: number // The number of tokens in the context for the next API request
5959
error?: string // Populated iff the operation fails: error message shown to the user on failure (see Task.ts)
60+
condenseId?: string // Identifier linking the summary "parent" with its condensed "children"
6061
}
6162

6263
/**
@@ -99,7 +100,8 @@ export async function summarizeConversation(
99100
!!condensingApiHandler,
100101
)
101102

102-
const response: SummarizeResponse = { messages, cost: 0, summary: "" }
103+
const condenseId = `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
104+
const response: SummarizeResponse = { messages, cost: 0, summary: "", condenseId }
103105

104106
// Always preserve the first message (which may contain slash command content)
105107
const firstMessage = messages[0]
@@ -186,6 +188,7 @@ export async function summarizeConversation(
186188
content: summary,
187189
ts: keepMessages[0].ts,
188190
isSummary: true,
191+
condenseId,
189192
}
190193

191194
// Reconstruct messages: [first message, summary, last N messages]
@@ -208,7 +211,7 @@ export async function summarizeConversation(
208211
const error = t("common:errors.condense_context_grew")
209212
return { ...response, cost, error }
210213
}
211-
return { messages: newMessages, summary, cost, newContextTokens }
214+
return { messages: newMessages, summary, cost, newContextTokens, condenseId }
212215
}
213216

214217
/* Returns the list of all messages since the last summary message, including the summary. Returns all messages if there is no summary. */

src/core/task-persistence/apiMessages.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import { fileExistsAtPath } from "../../utils/fs"
99
import { GlobalFileNames } from "../../shared/globalFileNames"
1010
import { getTaskDirectoryPath } from "../../utils/storage"
1111

12-
export type ApiMessage = Anthropic.MessageParam & { ts?: number; isSummary?: boolean }
12+
export type ApiMessage = Anthropic.MessageParam & {
13+
ts?: number
14+
isSummary?: boolean
15+
condenseId?: string
16+
condenseParent?: string
17+
}
1318

1419
export async function readApiMessages({
1520
taskId,

src/core/task/Task.ts

Lines changed: 140 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ import {
109109
checkpointDiff,
110110
} from "../checkpoints"
111111
import { processUserContentMentions } from "../mentions/processUserContentMentions"
112-
import { getMessagesSinceLastSummary, summarizeConversation } from "../condense"
112+
import { getMessagesSinceLastSummary, summarizeConversation, SummarizeResponse } from "../condense"
113113
import { Gpt5Metadata, ClineMessageWithMetadata } from "./types"
114114
import { MessageQueueService } from "../message-queue/MessageQueueService"
115115

@@ -1007,13 +1007,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
10071007

10081008
const { contextTokens: prevContextTokens } = this.getTokenUsage()
10091009

1010-
const {
1011-
messages,
1012-
summary,
1013-
cost,
1014-
newContextTokens = 0,
1015-
error,
1016-
} = await summarizeConversation(
1010+
const result = await summarizeConversation(
10171011
this.apiConversationHistory,
10181012
this.api, // Main API handler (fallback)
10191013
systemPrompt, // Default summarization prompt (fallback)
@@ -1023,10 +1017,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
10231017
customCondensingPrompt, // User's custom prompt
10241018
condensingApiHandler, // Specific handler for condensing
10251019
)
1026-
if (error) {
1020+
1021+
if (result.error) {
10271022
this.say(
10281023
"condense_context_error",
1029-
error,
1024+
result.error,
10301025
undefined /* images */,
10311026
false /* partial */,
10321027
undefined /* checkpoint */,
@@ -1035,11 +1030,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
10351030
)
10361031
return
10371032
}
1038-
await this.overwriteApiConversationHistory(messages)
1033+
1034+
// Merge condense metadata into API and UI histories (mark children and replace summary)
1035+
const { condenseId = `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` } =
1036+
result as SummarizeResponse
1037+
await this.applyCondenseMetadataToHistories(result)
10391038

10401039
// Set flag to skip previous_response_id on the next API call after manual condense
10411040
this.skipPrevResponseIdOnce = true
10421041

1042+
const { summary, cost, newContextTokens = 0 } = result as SummarizeResponse
10431043
const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens }
10441044
await this.say(
10451045
"condense_context",
@@ -1051,6 +1051,18 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
10511051
{ isNonInteractive: true } /* options */,
10521052
contextCondense,
10531053
)
1054+
1055+
// Tag the condense_context UI message with condenseId for cross-linking
1056+
try {
1057+
const idx = findLastIndex(this.clineMessages, (m) => m.type === "say" && m.say === "condense_context")
1058+
if (idx !== -1) {
1059+
;(this.clineMessages[idx] as any).condenseId = condenseId
1060+
await this.saveClineMessages()
1061+
await this.updateClineMessage(this.clineMessages[idx])
1062+
}
1063+
} catch {
1064+
// non-fatal
1065+
}
10541066
}
10551067

10561068
async say(
@@ -2497,11 +2509,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
24972509
})
24982510

24992511
if (truncateResult.messages !== this.apiConversationHistory) {
2500-
await this.overwriteApiConversationHistory(truncateResult.messages)
2512+
await this.applyCondenseMetadataToHistories(truncateResult)
25012513
}
25022514

25032515
if (truncateResult.summary) {
2504-
const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult
2516+
const { summary, cost, prevContextTokens, newContextTokens = 0, condenseId } = truncateResult
25052517
const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens }
25062518
await this.say(
25072519
"condense_context",
@@ -2513,6 +2525,23 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
25132525
{ isNonInteractive: true } /* options */,
25142526
contextCondense,
25152527
)
2528+
2529+
// Tag the condense_context UI message with condenseId for cross-linking
2530+
try {
2531+
if (condenseId) {
2532+
const idx = findLastIndex(
2533+
this.clineMessages,
2534+
(m) => m.type === "say" && m.say === "condense_context",
2535+
)
2536+
if (idx !== -1) {
2537+
;(this.clineMessages[idx] as any).condenseId = condenseId
2538+
await this.saveClineMessages()
2539+
await this.updateClineMessage(this.clineMessages[idx])
2540+
}
2541+
}
2542+
} catch {
2543+
// non-fatal
2544+
}
25162545
}
25172546
}
25182547

@@ -2613,7 +2642,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
26132642
currentProfileId,
26142643
})
26152644
if (truncateResult.messages !== this.apiConversationHistory) {
2616-
await this.overwriteApiConversationHistory(truncateResult.messages)
2645+
await this.applyCondenseMetadataToHistories(truncateResult)
26172646
}
26182647
if (truncateResult.error) {
26192648
await this.say("condense_context_error", truncateResult.error)
@@ -2622,7 +2651,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
26222651
// send previous_response_id so the request reflects the fresh condensed context.
26232652
this.skipPrevResponseIdOnce = true
26242653

2625-
const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult
2654+
const { summary, cost, prevContextTokens, newContextTokens = 0, condenseId } = truncateResult
26262655
const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens }
26272656
await this.say(
26282657
"condense_context",
@@ -2634,10 +2663,29 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
26342663
{ isNonInteractive: true } /* options */,
26352664
contextCondense,
26362665
)
2666+
2667+
// Tag the condense_context UI message with condenseId for cross-linking
2668+
try {
2669+
if (condenseId) {
2670+
const idx = findLastIndex(
2671+
this.clineMessages,
2672+
(m) => m.type === "say" && m.say === "condense_context",
2673+
)
2674+
if (idx !== -1) {
2675+
;(this.clineMessages[idx] as any).condenseId = condenseId
2676+
await this.saveClineMessages()
2677+
await this.updateClineMessage(this.clineMessages[idx])
2678+
}
2679+
}
2680+
} catch {
2681+
// non-fatal
2682+
}
26372683
}
26382684
}
26392685

2640-
const messagesSinceLastSummary = getMessagesSinceLastSummary(this.apiConversationHistory)
2686+
// Assemble API history excluding condensed children
2687+
const filteredForApi = this.apiConversationHistory.filter((m: any) => !m?.condenseParent)
2688+
const messagesSinceLastSummary = getMessagesSinceLastSummary(filteredForApi)
26412689
let cleanConversationHistory = maybeRemoveImageBlocks(messagesSinceLastSummary, this.api).map(
26422690
({ role, content }) => ({ role, content }),
26432691
)
@@ -2888,6 +2936,82 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
28882936
}
28892937
}
28902938

2939+
// Condense helpers
2940+
2941+
/**
2942+
* Apply condense metadata to both API and UI histories:
2943+
* - Replace the first kept tail message with the summary (parent) and set its condenseId
2944+
* - Mark all prior messages (excluding the original first message) with condenseParent = condenseId
2945+
* - Persist changes to disk
2946+
*/
2947+
private async applyCondenseMetadataToHistories(
2948+
result: SummarizeResponse,
2949+
): Promise<{ condenseId: string; thresholdTs?: number }> {
2950+
try {
2951+
const condenseId =
2952+
result.condenseId ?? `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
2953+
const summaryMsg = (result.messages || []).find((m) => (m as any).isSummary) as ApiMessage | undefined
2954+
const thresholdTs = summaryMsg?.ts
2955+
const firstApiTs = this.apiConversationHistory[0]?.ts
2956+
2957+
// Build new API history by replacing the threshold message with the summary and marking children
2958+
let replacedSummary = false
2959+
let newApiHistory: ApiMessage[] = this.apiConversationHistory.map((m) => {
2960+
// Replace the first kept message with the new summary
2961+
if (typeof thresholdTs === "number" && m.ts === thresholdTs && summaryMsg) {
2962+
replacedSummary = true
2963+
return { ...summaryMsg, condenseId }
2964+
}
2965+
// Mark all messages before the threshold as condensed children, except the original first message
2966+
if (typeof thresholdTs === "number" && typeof m.ts === "number" && m.ts < thresholdTs) {
2967+
if (typeof firstApiTs === "number" && m.ts === firstApiTs) {
2968+
return m
2969+
}
2970+
if (!(m as any).condenseParent) {
2971+
return { ...m, condenseParent: condenseId }
2972+
}
2973+
}
2974+
return m
2975+
})
2976+
2977+
// If no existing message matched the summary timestamp, insert summary just before the threshold boundary
2978+
if (!replacedSummary && summaryMsg && typeof thresholdTs === "number") {
2979+
let insertAt = newApiHistory.findIndex(
2980+
(m) => typeof m.ts === "number" && (m.ts as number) >= thresholdTs,
2981+
)
2982+
if (insertAt === -1) insertAt = newApiHistory.length
2983+
newApiHistory = [
2984+
...newApiHistory.slice(0, insertAt),
2985+
{ ...summaryMsg, condenseId },
2986+
...newApiHistory.slice(insertAt),
2987+
]
2988+
}
2989+
2990+
this.apiConversationHistory = newApiHistory
2991+
await this.saveApiConversationHistory()
2992+
2993+
// Mark UI messages prior to the threshold as condensed children (exclude very first UI row)
2994+
if (typeof thresholdTs === "number") {
2995+
let updated = false
2996+
for (let i = 0; i < this.clineMessages.length; i++) {
2997+
const uiMsg = this.clineMessages[i] as any
2998+
if (typeof uiMsg?.ts === "number" && uiMsg.ts < thresholdTs && i > 0 && !uiMsg.condenseParent) {
2999+
uiMsg.condenseParent = condenseId
3000+
updated = true
3001+
}
3002+
}
3003+
if (updated) {
3004+
await this.saveClineMessages()
3005+
}
3006+
}
3007+
3008+
return { condenseId, thresholdTs }
3009+
} catch (e) {
3010+
console.error(`[Task#${this.taskId}] Failed to apply condense metadata:`, e)
3011+
return { condenseId: `c_${Date.now().toString(36)}`, thresholdTs: undefined }
3012+
}
3013+
}
3014+
28913015
// Getters
28923016

28933017
public get taskStatus(): TaskStatus {

0 commit comments

Comments
 (0)