diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 77c055c6e152..8989f10f3b41 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -227,6 +227,8 @@ export const clineMessageSchema = z.object({ .optional(), }) .optional(), + condenseId: z.string().optional(), + condenseParent: z.string().optional(), }) export type ClineMessage = z.infer diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 86cfa7ab1e5d..30812ca484bc 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -57,6 +57,7 @@ export type SummarizeResponse = { cost: number // The cost of the summarization operation newContextTokens?: number // The number of tokens in the context for the next API request error?: string // Populated iff the operation fails: error message shown to the user on failure (see Task.ts) + condenseId?: string // Identifier linking the summary "parent" with its condensed "children" } /** @@ -99,7 +100,8 @@ export async function summarizeConversation( !!condensingApiHandler, ) - const response: SummarizeResponse = { messages, cost: 0, summary: "" } + const condenseId = `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` + const response: SummarizeResponse = { messages, cost: 0, summary: "", condenseId } // Always preserve the first message (which may contain slash command content) const firstMessage = messages[0] @@ -186,6 +188,7 @@ export async function summarizeConversation( content: summary, ts: keepMessages[0].ts, isSummary: true, + condenseId, } // Reconstruct messages: [first message, summary, last N messages] @@ -208,7 +211,7 @@ export async function summarizeConversation( const error = t("common:errors.condense_context_grew") return { ...response, cost, error } } - return { messages: newMessages, summary, cost, newContextTokens } + return { messages: newMessages, summary, cost, newContextTokens, condenseId } } /* Returns the list of all messages since the last summary message, including the summary. Returns all messages if there is no summary. */ diff --git a/src/core/task-persistence/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index f846aaf13f2d..b28ff4289d6f 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/src/core/task-persistence/apiMessages.ts @@ -9,7 +9,12 @@ import { fileExistsAtPath } from "../../utils/fs" import { GlobalFileNames } from "../../shared/globalFileNames" import { getTaskDirectoryPath } from "../../utils/storage" -export type ApiMessage = Anthropic.MessageParam & { ts?: number; isSummary?: boolean } +export type ApiMessage = Anthropic.MessageParam & { + ts?: number + isSummary?: boolean + condenseId?: string + condenseParent?: string +} export async function readApiMessages({ taskId, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 851df91e6c5e..c5706229a615 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -109,7 +109,7 @@ import { checkpointDiff, } from "../checkpoints" import { processUserContentMentions } from "../mentions/processUserContentMentions" -import { getMessagesSinceLastSummary, summarizeConversation } from "../condense" +import { getMessagesSinceLastSummary, summarizeConversation, SummarizeResponse } from "../condense" import { Gpt5Metadata, ClineMessageWithMetadata } from "./types" import { MessageQueueService } from "../message-queue/MessageQueueService" @@ -1007,13 +1007,7 @@ export class Task extends EventEmitter implements TaskLike { const { contextTokens: prevContextTokens } = this.getTokenUsage() - const { - messages, - summary, - cost, - newContextTokens = 0, - error, - } = await summarizeConversation( + const result = await summarizeConversation( this.apiConversationHistory, this.api, // Main API handler (fallback) systemPrompt, // Default summarization prompt (fallback) @@ -1023,10 +1017,11 @@ export class Task extends EventEmitter implements TaskLike { customCondensingPrompt, // User's custom prompt condensingApiHandler, // Specific handler for condensing ) - if (error) { + + if (result.error) { this.say( "condense_context_error", - error, + result.error, undefined /* images */, false /* partial */, undefined /* checkpoint */, @@ -1035,11 +1030,16 @@ export class Task extends EventEmitter implements TaskLike { ) return } - await this.overwriteApiConversationHistory(messages) + + // Merge condense metadata into API and UI histories (mark children and replace summary) + const { condenseId = `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` } = + result as SummarizeResponse + await this.applyCondenseMetadataToHistories(result) // Set flag to skip previous_response_id on the next API call after manual condense this.skipPrevResponseIdOnce = true + const { summary, cost, newContextTokens = 0 } = result as SummarizeResponse const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens } await this.say( "condense_context", @@ -1051,6 +1051,18 @@ export class Task extends EventEmitter implements TaskLike { { isNonInteractive: true } /* options */, contextCondense, ) + + // Tag the condense_context UI message with condenseId for cross-linking + try { + const idx = findLastIndex(this.clineMessages, (m) => m.type === "say" && m.say === "condense_context") + if (idx !== -1) { + ;(this.clineMessages[idx] as any).condenseId = condenseId + await this.saveClineMessages() + await this.updateClineMessage(this.clineMessages[idx]) + } + } catch { + // non-fatal + } } async say( @@ -2497,11 +2509,11 @@ export class Task extends EventEmitter implements TaskLike { }) if (truncateResult.messages !== this.apiConversationHistory) { - await this.overwriteApiConversationHistory(truncateResult.messages) + await this.applyCondenseMetadataToHistories(truncateResult) } if (truncateResult.summary) { - const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult + const { summary, cost, prevContextTokens, newContextTokens = 0, condenseId } = truncateResult const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens } await this.say( "condense_context", @@ -2513,6 +2525,23 @@ export class Task extends EventEmitter implements TaskLike { { isNonInteractive: true } /* options */, contextCondense, ) + + // Tag the condense_context UI message with condenseId for cross-linking + try { + if (condenseId) { + const idx = findLastIndex( + this.clineMessages, + (m) => m.type === "say" && m.say === "condense_context", + ) + if (idx !== -1) { + ;(this.clineMessages[idx] as any).condenseId = condenseId + await this.saveClineMessages() + await this.updateClineMessage(this.clineMessages[idx]) + } + } + } catch { + // non-fatal + } } } @@ -2613,7 +2642,7 @@ export class Task extends EventEmitter implements TaskLike { currentProfileId, }) if (truncateResult.messages !== this.apiConversationHistory) { - await this.overwriteApiConversationHistory(truncateResult.messages) + await this.applyCondenseMetadataToHistories(truncateResult) } if (truncateResult.error) { await this.say("condense_context_error", truncateResult.error) @@ -2622,7 +2651,7 @@ export class Task extends EventEmitter implements TaskLike { // send previous_response_id so the request reflects the fresh condensed context. this.skipPrevResponseIdOnce = true - const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult + const { summary, cost, prevContextTokens, newContextTokens = 0, condenseId } = truncateResult const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens } await this.say( "condense_context", @@ -2634,10 +2663,29 @@ export class Task extends EventEmitter implements TaskLike { { isNonInteractive: true } /* options */, contextCondense, ) + + // Tag the condense_context UI message with condenseId for cross-linking + try { + if (condenseId) { + const idx = findLastIndex( + this.clineMessages, + (m) => m.type === "say" && m.say === "condense_context", + ) + if (idx !== -1) { + ;(this.clineMessages[idx] as any).condenseId = condenseId + await this.saveClineMessages() + await this.updateClineMessage(this.clineMessages[idx]) + } + } + } catch { + // non-fatal + } } } - const messagesSinceLastSummary = getMessagesSinceLastSummary(this.apiConversationHistory) + // Assemble API history excluding condensed children + const filteredForApi = this.apiConversationHistory.filter((m: any) => !m?.condenseParent) + const messagesSinceLastSummary = getMessagesSinceLastSummary(filteredForApi) let cleanConversationHistory = maybeRemoveImageBlocks(messagesSinceLastSummary, this.api).map( ({ role, content }) => ({ role, content }), ) @@ -2888,6 +2936,82 @@ export class Task extends EventEmitter implements TaskLike { } } + // Condense helpers + + /** + * Apply condense metadata to both API and UI histories: + * - Replace the first kept tail message with the summary (parent) and set its condenseId + * - Mark all prior messages (excluding the original first message) with condenseParent = condenseId + * - Persist changes to disk + */ + private async applyCondenseMetadataToHistories( + result: SummarizeResponse, + ): Promise<{ condenseId: string; thresholdTs?: number }> { + try { + const condenseId = + result.condenseId ?? `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` + const summaryMsg = (result.messages || []).find((m) => (m as any).isSummary) as ApiMessage | undefined + const thresholdTs = summaryMsg?.ts + const firstApiTs = this.apiConversationHistory[0]?.ts + + // Build new API history by replacing the threshold message with the summary and marking children + let replacedSummary = false + let newApiHistory: ApiMessage[] = this.apiConversationHistory.map((m) => { + // Replace the first kept message with the new summary + if (typeof thresholdTs === "number" && m.ts === thresholdTs && summaryMsg) { + replacedSummary = true + return { ...summaryMsg, condenseId } + } + // Mark all messages before the threshold as condensed children, except the original first message + if (typeof thresholdTs === "number" && typeof m.ts === "number" && m.ts < thresholdTs) { + if (typeof firstApiTs === "number" && m.ts === firstApiTs) { + return m + } + if (!(m as any).condenseParent) { + return { ...m, condenseParent: condenseId } + } + } + return m + }) + + // If no existing message matched the summary timestamp, insert summary just before the threshold boundary + if (!replacedSummary && summaryMsg && typeof thresholdTs === "number") { + let insertAt = newApiHistory.findIndex( + (m) => typeof m.ts === "number" && (m.ts as number) >= thresholdTs, + ) + if (insertAt === -1) insertAt = newApiHistory.length + newApiHistory = [ + ...newApiHistory.slice(0, insertAt), + { ...summaryMsg, condenseId }, + ...newApiHistory.slice(insertAt), + ] + } + + this.apiConversationHistory = newApiHistory + await this.saveApiConversationHistory() + + // Mark UI messages prior to the threshold as condensed children (exclude very first UI row) + if (typeof thresholdTs === "number") { + let updated = false + for (let i = 0; i < this.clineMessages.length; i++) { + const uiMsg = this.clineMessages[i] as any + if (typeof uiMsg?.ts === "number" && uiMsg.ts < thresholdTs && i > 0 && !uiMsg.condenseParent) { + uiMsg.condenseParent = condenseId + updated = true + } + } + if (updated) { + await this.saveClineMessages() + } + } + + return { condenseId, thresholdTs } + } catch (e) { + console.error(`[Task#${this.taskId}] Failed to apply condense metadata:`, e) + return { condenseId: `c_${Date.now().toString(36)}`, thresholdTs: undefined } + } + } + // Getters public get taskStatus(): TaskStatus {