Skip to content

Bug: Context window leaks — compressed messages reappear after /compact, causing unbounded growth #521

Description

@andrewkgabler

Bug: Compression state sync is fragile — reference message presence determines block liveness

Severity: High — causes context window leaks (unbounded token growth)
Root cause: syncCompressionBlocks() rebuilds active state from scratch each transform, using presence checks on reference messages (compressMessageId, anchorMessageId) to determine block liveness. When those messages are removed by opencode compaction, fork, or another compression block, the sync/prune contract breaks.


Symptoms

Symptom A: Block deactivation when compressMessageId is missing

Location: lib/messages/sync.ts:40-50

const hasOriginMessage = messageIds.has(block.compressMessageId)
if (!hasOriginMessage) {
    block.active = false
    block.deactivatedAt = now
    block.deactivatedByBlockId = undefined
    missingOriginBlockIds.push(block.blockId)
    continue
}

compressMessageId is the assistant message that contains the compress tool call. If opencode's native compaction removes this message, the entire compression block is deactivated. This cascades:

  1. sync.ts:99entry.activeBlockIds = allBlockIds.filter(id => activeBlockIds.has(id)) → empty for all compressed messages
  2. prune.ts:222pruneEntry.activeBlockIds.length > 0 → false → messages are NOT pruned
  3. Compressed messages leak back into context fully unpruned

Trigger: Opencode native compaction, /compact command, or any operation that removes the assistant message containing the compress tool call.

Impact: Context bloat — previously compressed messages reappear in full.


Symptom B: No summary injection when anchorMessageId is pruned

Location: lib/messages/sync.ts:88-90

if (messageIds.has(block.anchorMessageId)) {
    messagesState.activeByAnchorMessageId.set(block.anchorMessageId, block.blockId)
}

activeByAnchorMessageId is only populated if the anchor message exists in the current message stream. If the anchor was pruned by a different compression block, the map entry is never created. filterCompressedRanges uses this map to find where to inject the summary:

  1. prune.ts:178activeByAnchorMessageId.get(msgId) → undefined
  2. No summary is injected
  3. Messages are pruned (removed) but no summary replaces them
  4. LLM sees a gap — messages disappeared without explanation

Trigger: Two overlapping compression blocks where one block's anchor is inside another block's compressed range.

Impact: Context holes — messages pruned without summary, LLM loses context continuity.


Symptom C: Total state loss on opencode compaction

Location: lib/state/utils.ts:331-345

export function resetOnCompaction(state: SessionState): void {
    state.toolParameters.clear()
    state.prune.tools = new Map<string, number>()
    state.prune.messages = createPruneMessagesState()  // ← wipes everything
    state.messageIds = { ... }
    state.nudges = { ... }
}

When opencode runs native compaction, resetOnCompaction() clears ALL DCP state: blocks, message mappings, active IDs, anchor mappings, tool prune map, message ID refs, and stats. After compaction, DCP has zero compression state.

Trigger: Opencode native compaction (automatic or /compact command).

Impact: Total context leak — all previously compressed messages sent in full until next compress tool call.


Root Cause Analysis

All three symptoms share the same underlying design flaw:

syncCompressionBlocks() treats reference message presence as a precondition for block liveness, rather than treating the compression as an independent fact that should persist regardless of whether reference messages survive.

The sync function's logic:

  1. Clears all state (activeBlockIds.clear(), activeByAnchorMessageId.clear())
  2. Iterates blocks and checks messageIds.has(block.compressMessageId) — if missing, block is deactivated
  3. Checks messageIds.has(block.anchorMessageId) — if missing, no summary injection point
  4. Computes activeBlockIds as intersection of block.active with allBlockIds

This means the compression contract is entirely dependent on reference messages surviving in the message stream. Any operation that removes those messages (compaction, fork, another compression block) breaks the contract.

Why this is a problem

  • compressMessageId is an implementation detail (which assistant message contained the tool call), not a semantic property of the compression
  • anchorMessageId is a summary injection point, not a requirement for the compression to be valid
  • allBlockIds on PrunedMessageEntry already tracks which blocks compressed a message — this is the authoritative source of "this message was compressed"
  • The active flag on blocks is used as a gate for both pruning AND summary injection, but these should be decoupled

Fix Direction

The fix should decouple compression persistence from reference message presence:

1. Remove compressMessageId as a liveness gate (fixes Symptom A)

Blocks should remain active as long as their compressed messages (directMessageIds, effectiveMessageIds) are still in the stream. The compressMessageId check should be removed or changed to a warning.

2. Add fallback anchor mapping (fixes Symptom B)

When anchorMessageId is not in the current message stream, find the first compressed message that IS present and use it as the summary injection point. This preserves context continuity.

3. Preserve DCP state through compaction (fixes Symptom C)

resetOnCompaction() should NOT wipe all DCP state. Instead, it should:

  • Preserve blocksById and byMessageId (the compression history)
  • Rebuild activeBlockIds based on which compressed messages are still in the stream
  • Reset only transient state (nudges, message IDs for new messages)

4. Use allBlockIds as the authoritative prune source

filterCompressedRanges at prune.ts:222 checks activeBlockIds.length > 0. This should also check allBlockIds.length > 0 as a fallback — if a message was ever compressed, it should stay pruned unless the user explicitly reactivated it.


Test Coverage

Tests exist in tests/sync-prune-bugs.test.ts that assert the correct behavior (TDD style). These tests currently fail with the buggy code:

Test Asserts Currently
syncCompressionBlocks keeps block active when compressMessageId is missing Block stays active FAILS (block deactivates)
filterCompressedRanges prunes messages even when block was deactivated Messages stay pruned FAILS (messages leak)
syncCompressionBlocks populates activeByAnchorMessageId using fallback Fallback anchor mapping FAILS (no mapping)
filterCompressedRanges injects summary when anchor is missing Summary injected FAILS (no summary)
resetOnCompaction preserves compression state State preserved FAILS (state wiped)
syncCompressionBlocks keeps blocks active when compressMessageId is missing Multiple blocks stay active FAILS (block deactivates)

Related


Files Affected

  • lib/messages/sync.tssyncCompressionBlocks() (Symptoms A, B)
  • lib/messages/prune.tsfilterCompressedRanges() (Symptom A — uses activeBlockIds)
  • lib/state/utils.tsresetOnCompaction() (Symptom C)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions