feat(condense): add condenseId/condenseParent metadata; assemble API history excluding condensed children; tag UI condense_context

This commit is contained in:
Roo Code 2025-10-04 20:14:48 +00:00
parent 97f968673f
commit 534afc5ac7
4 changed files with 153 additions and 19 deletions

View file

@ -227,6 +227,8 @@ export const clineMessageSchema = z.object({
.optional(),
})
.optional(),
condenseId: z.string().optional(),
condenseParent: z.string().optional(),
})
export type ClineMessage = z.infer<typeof clineMessageSchema>

View file

@ -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. */

View file

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

View file

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