Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
7 changes: 5 additions & 2 deletions src/core/condense/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

/**
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand All @@ -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. */
Expand Down
7 changes: 6 additions & 1 deletion src/core/task-persistence/apiMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
156 changes: 140 additions & 16 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand All @@ -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 */,
Expand All @@ -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)}` } =
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the condenseContext() method, the code extracts a local condenseId from the result and then calls applyCondenseMetadataToHistories(result), which may generate its own condenseId. To ensure consistency, consider using the returned condenseId from applyCondenseMetadataToHistories instead of destructuring it from result separately.

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",
Expand All @@ -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
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] condenseId source of truth for UI tagging.

applyCondenseMetadataToHistories may synthesize a condenseId when result.condenseId is missing. To avoid a mismatch between persisted histories and the UI tag on condense_context, capture the returned condenseId and use it here.

Suggested change
;(this.clineMessages[idx] as any).condenseId = condenseId
// Merge condense metadata into API and UI histories (mark children and replace summary)
const { condenseId } = await this.applyCondenseMetadataToHistories(result)

await this.saveClineMessages()
await this.updateClineMessage(this.clineMessages[idx])
}
} catch {
// non-fatal
}
}

async say(
Expand Down Expand Up @@ -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",
Expand All @@ -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
}
}
}

Expand Down Expand Up @@ -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)
Expand All @@ -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",
Expand All @@ -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 }),
)
Expand Down Expand Up @@ -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(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the applyCondenseMetadataToHistories() method, several casts are used (e.g. '(m as any).condenseParent') to check/update properties that are defined in the ClineMessage schema. Consider using proper type assertions to avoid 'as any'. Also, similar UI tagging logic (e.g. for condense_context messages at multiple locations) is duplicated; refactoring this into a shared helper would improve maintainability.

This comment was generated because it violated a code review rule: irule_tTqpIuNs8DV0QFGj.

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 {
Expand Down