Skip to content

Conversation

@roomote
Copy link

@roomote roomote bot commented Oct 4, 2025

Fixes #8295

Non-destructive condense: lineage + rewind hygiene

This PR refines non-destructive condensing so conversation state can be restored precisely when users delete/rewind, and successive summaries retain lineage across condenses.

Summary of changes

  • Preserve condense lineage across successive condenses

    • Tag all middle messages in the active summarization window (including prior summaries) with condenseParent while preserving isSummary and original condenseId.
    • When a new condense is created, tag the previous UI condense_context message with metadata.condenseParent = <newCondenseId> to form a chain.
  • Rewind/deletion hygiene aligned with UI placement semantics

    • Introduce UI-first rewind hygiene (uiOnly mode) that treats the UI as the source of truth when deleting/rewinding:
      • Immediately purge API-only summaries whose UI counterpart has been removed.
      • Clear orphaned condenseParent in both API and UI histories.
    • General hygiene continues to consider both UI and API summaries for active condenseIds.
  • Keep summary ordering correct

    • The summary continues to be timestamped just before the first preserved message in API history. With UI-first hygiene during rewinds, we now accurately restore the API history to the exact pre-condense state at the selected rewind point.

Why this is needed

  • Previously, rewinding to a message just before a condense window could leave a newer summary present in api_conversation_history.json because the UI displays condense after preserved messages, while the API inserts the summary just before them. This created a mismatch.
  • The new rewind hygiene removes that mismatch, ensuring the API history rolls back exactly to the point-in-time state.

Implementation details (high level)

  • Tag middle window messages (including prior summaries) with condenseParent while preserving isSummary/condenseId.
  • On new condense:
    • Propagate lineage by tagging the prior condense_context UI message with metadata.condenseParent = <newCondenseId>.
    • Set skipPrevResponseIdOnce so the next API call reflects the condensed context.
  • Rewind hygiene:
    • performCondenseHygiene(currentCline, { uiOnly: true }) during delete/rewind treats the UI as the source-of-truth and purges API-only summaries immediately, plus clears orphaned condenseParent.
    • General hygiene (no rewind) continues to consider both UI and API summaries to avoid premature purging.

Testing

Notes

  • No i18n changes.
  • No user-facing strings altered; UI presents condense_context rows in their existing order while the API is restored precisely during rewinds.

Important

Implement non-destructive condense with metadata tracking to improve conversation state management and lineage across condenses.

  • Behavior:
    • Implement non-destructive condense with metadata tracking in summarizeConversation() in index.ts.
    • Tag middle messages with condenseParent and preserve isSummary/condenseId.
    • On new condense, update previous condense_context with metadata.condenseParent.
    • Introduce performCondenseHygiene() in webviewMessageHandler.ts to clean orphaned condenseParent references.
  • Testing:
    • Add non-destructive-condense.spec.ts for unit tests covering message preservation, unique condenseId generation, and rollback behavior.
  • Misc:
    • Update ApiMessage type in apiMessages.ts to include condenseId and condenseParent fields.

This description was created by Ellipsis for 54c603a. You can customize this summary. It will automatically update as commits are pushed.

- Add condenseId and condenseParent fields to ApiMessage type
- Update condense logic to preserve all messages with metadata tags
- Filter condensed messages during API assembly based on active summaries
- Implement hygiene operations to clean orphaned references after truncation
- Add comprehensive tests for non-destructive condense behavior
- Update existing tests to match new behavior

This fixes the issue where manual condense was breaking delete/edit operations
by replacing persisted history, causing timestamp-based lookups to fail.

Fixes #8295
@roomote roomote bot requested review from cte, jr and mrubens as code owners October 4, 2025 21:41
@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. bug Something isn't working labels Oct 4, 2025
@hannesrudolph hannesrudolph added the Issue/PR - Triage New issue. Needs quick review to confirm validity and assign labels. label Oct 4, 2025
Copy link
Author

@roomote roomote bot left a comment

Choose a reason for hiding this comment

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

Self-review: auditing my own bits—turns out recursion isn’t just for code, it’s for regrets.

{
isNonInteractive: true,
metadata: {
condenseId: messages.find((m) => m.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.

[P1] condenseId may point to an older summary. messages.find((m) => m.condenseId) returns the first condenseId in the array, but summarizeConversation preserves older summary rows in the middle region (they still carry condenseId). This can attach the wrong condenseId to the condense_context UI message, which the hygiene step uses to compute active condense sets. Prefer selecting the newest summary's id.

Suggested change
condenseId: messages.find((m) => m.condenseId)?.condenseId,
condenseId: [...messages].reverse().find((m) => m.isSummary && m.condenseId)?.condenseId,

Copy link
Collaborator

@hannesrudolph hannesrudolph left a comment

Choose a reason for hiding this comment

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

Self-review: found a timestamp doppelgänger and a deprecated string relic—past me owes future me a coffee.

Copy link
Author

@roomote roomote bot left a comment

Choose a reason for hiding this comment

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

Self-review: compiling my own existential dread into metadata tags and calling it "hygiene."

{
isNonInteractive: true,
metadata: {
condenseId: messages.find((m) => m.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.

[P2] The condenseId assigned to the UI condense_context message uses messages.find((m) => m.condenseId) which returns the first occurrence in the array. Since older summaries may appear before the newly inserted summary in newMessages (they are part of the preserved middle region), this can attach an outdated condenseId to the UI message. Use the newest summary's condenseId instead by searching from the end or filtering by isSummary.

Suggested change:

Suggested change
condenseId: messages.find((m) => m.condenseId)?.condenseId,
condenseId: [...messages].reverse().find((m) => m.isSummary && m.condenseId)?.condenseId,


const messagesSinceLastSummary = getMessagesSinceLastSummary(this.apiConversationHistory)
// Filter messages based on active summaries (non-destructive condense)
const activeCondenseIds = new Set(
Copy link
Author

Choose a reason for hiding this comment

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

[P2] Filtering condensed middle messages (effectiveHistory) happens after the call to truncateConversationIfNeeded earlier in this method. That truncation currently operates on the full apiConversationHistory, which can over-count tokens and trigger unnecessary truncation/condense even though filtered messages would be excluded from the actual API request. Consider computing effectiveHistory earlier and passing it to truncateConversationIfNeeded, or moving the filtering block above the truncation logic, so both token estimates and truncation decisions reflect the effective history actually sent to the model.

Copy link
Author

@roomote roomote bot left a comment

Choose a reason for hiding this comment

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

Self-review: tagging history like Schrödinger—it's both there and not there until the API looks.

{
isNonInteractive: true,
metadata: {
condenseId: messages.find((m) => m.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.

P1: condenseId may reference an older summary. messages.find((m) => m.condenseId) returns the first summary in the array, which might be a previous condense. This can mis-tag the UI row and break hygiene that relies on the active summary id. Prefer picking the most recent summary (last in timeline) to ensure the metadata points to the new condense.

Suggested change
condenseId: messages.find((m) => m.condenseId)?.condenseId,
condenseId: [...messages].reverse().find((m) => m.isSummary && m.condenseId)?.condenseId,

const condenseId = `condense-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`

// Choose a unique timestamp that sorts just before the first kept tail message
const summaryTs = Math.min((keepMessages[0]?.ts ?? Date.now()) - 1, Date.now())
Copy link
Author

Choose a reason for hiding this comment

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

P2: Possible timestamp collision/out-of-order risk. Using keepMessages[0].ts - 1 can collide with the last middle message when timestamps are close, and doesn’t ensure the summary sorts strictly after the middle and before the tail. This can break operations that locate messages by exact ts. Suggest bounding the summary ts between the max middle ts and the first tail ts, and make it strictly increasing.

Suggested change
const summaryTs = Math.min((keepMessages[0]?.ts ?? Date.now()) - 1, Date.now())
const prevMaxTs = Math.max(...messages.slice(1, -N_MESSAGES_TO_KEEP).map((m) => m.ts ?? 0), firstMessage?.ts ?? 0)
const nextMinTs = (keepMessages[0]?.ts ?? Date.now()) - 1
const summaryTs = Math.max(prevMaxTs + 1, Math.min(nextMinTs, Date.now()))

…I-first rewind hygiene

- Tag prior summaries within the current window with condenseParent (preserve original condenseId/isSummary)
- Propagate condenseParent to the previous UI condense_context for lineage continuity
- Add rewind-aware hygiene (uiOnly) so deletes/rewinds restore API history to exact pre-condense state at the chosen point
- Keep tests green (condense + task suites)
@dosubot dosubot bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Oct 6, 2025
@hannesrudolph hannesrudolph force-pushed the fix/non-destructive-condense-8295 branch from 54c603a to 5b925cf Compare October 6, 2025 18:40
Copy link
Author

@roomote roomote bot left a comment

Choose a reason for hiding this comment

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

Self-review: I indexed the past so hard I accidentally time-traveled to the wrong summary.


// If there was a previous condense_context in the UI, tag it with condenseParent = newCondenseId
try {
const newCondenseId = messages.find((m) => m.isSummary && m.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.

[P1] Lineage tagging may use an older condenseId when multiple summaries exist. messages.find(...) returns the first summary in the array, but newMessages places prior summaries before the newly created one. This can chain UI condense_context to the wrong parent. Use the most recent summary (scan from the end).

Suggested change
const newCondenseId = messages.find((m) => m.isSummary && m.condenseId)?.condenseId
const newCondenseId = [...messages].reverse().find((m) => m.isSummary && m.condenseId)?.condenseId

{
isNonInteractive: true,
metadata: {
condenseId: messages.find((m) => m.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.

[P1] The condense_context metadata may capture a stale condenseId. messages.find((m) => m.condenseId) can match a previous summary before the newly inserted one. Reuse the newCondenseId computed above to ensure metadata points to the latest condensation.

Suggested change
condenseId: messages.find((m) => m.condenseId)?.condenseId,
condenseId: newCondenseId,

Copy link
Author

@roomote roomote bot left a comment

Choose a reason for hiding this comment

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

Self-review: I taught my context compressor non-violence, then accidentally made the auto-path hide the bodies.

{
isNonInteractive: true,
metadata: {
condenseId: messages.find((m) => m.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.

P1: When tagging the UI condense_context, this picks the first summary in messages which can be an older one if prior summaries are present in the preserved middle. Prefer the newest summary to ensure lineage points to the active condense.

Suggested change
condenseId: messages.find((m) => m.condenseId)?.condenseId,
metadata: {
condenseId: [...messages].reverse().find((m) => m.isSummary && m.condenseId)?.condenseId,
},

@github-project-automation github-project-automation bot moved this from New to Done in Roo Code Roadmap Oct 17, 2025
@github-project-automation github-project-automation bot moved this from Triage to Done in Roo Code Roadmap Oct 17, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working Issue/PR - Triage New issue. Needs quick review to confirm validity and assign labels. size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

[BUG] Rewind after condense keeps only initial + new message

2 participants