-
Notifications
You must be signed in to change notification settings - Fork 2.3k
feat(condense): unify UI/API histories; condense metadata; API history excludes condensed children #8512
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(condense): unify UI/API histories; condense metadata; API history excludes condensed children #8512
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -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 | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||||||
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( | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||||||||
|
There was a problem hiding this comment.
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.