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:
sync.ts:99 → entry.activeBlockIds = allBlockIds.filter(id => activeBlockIds.has(id)) → empty for all compressed messages
prune.ts:222 → pruneEntry.activeBlockIds.length > 0 → false → messages are NOT pruned
- 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:
prune.ts:178 → activeByAnchorMessageId.get(msgId) → undefined
- No summary is injected
- Messages are pruned (removed) but no summary replaces them
- 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:
- Clears all state (
activeBlockIds.clear(), activeByAnchorMessageId.clear())
- Iterates blocks and checks
messageIds.has(block.compressMessageId) — if missing, block is deactivated
- Checks
messageIds.has(block.anchorMessageId) — if missing, no summary injection point
- 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.ts — syncCompressionBlocks() (Symptoms A, B)
lib/messages/prune.ts — filterCompressedRanges() (Symptom A — uses activeBlockIds)
lib/state/utils.ts — resetOnCompaction() (Symptom C)
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
compressMessageIdis missingLocation:
lib/messages/sync.ts:40-50compressMessageIdis 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:sync.ts:99→entry.activeBlockIds = allBlockIds.filter(id => activeBlockIds.has(id))→ empty for all compressed messagesprune.ts:222→pruneEntry.activeBlockIds.length > 0→ false → messages are NOT prunedTrigger: Opencode native compaction,
/compactcommand, 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
anchorMessageIdis prunedLocation:
lib/messages/sync.ts:88-90activeByAnchorMessageIdis 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.filterCompressedRangesuses this map to find where to inject the summary:prune.ts:178→activeByAnchorMessageId.get(msgId)→ undefinedTrigger: 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-345When 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
/compactcommand).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:
activeBlockIds.clear(),activeByAnchorMessageId.clear())messageIds.has(block.compressMessageId)— if missing, block is deactivatedmessageIds.has(block.anchorMessageId)— if missing, no summary injection pointactiveBlockIdsas intersection of block.active withallBlockIdsThis 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
compressMessageIdis an implementation detail (which assistant message contained the tool call), not a semantic property of the compressionanchorMessageIdis a summary injection point, not a requirement for the compression to be validallBlockIdsonPrunedMessageEntryalready tracks which blocks compressed a message — this is the authoritative source of "this message was compressed"activeflag on blocks is used as a gate for both pruning AND summary injection, but these should be decoupledFix Direction
The fix should decouple compression persistence from reference message presence:
1. Remove
compressMessageIdas a liveness gate (fixes Symptom A)Blocks should remain active as long as their compressed messages (
directMessageIds,effectiveMessageIds) are still in the stream. ThecompressMessageIdcheck should be removed or changed to a warning.2. Add fallback anchor mapping (fixes Symptom B)
When
anchorMessageIdis 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:blocksByIdandbyMessageId(the compression history)activeBlockIdsbased on which compressed messages are still in the stream4. Use
allBlockIdsas the authoritative prune sourcefilterCompressedRangesatprune.ts:222checksactiveBlockIds.length > 0. This should also checkallBlockIds.length > 0as 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.tsthat assert the correct behavior (TDD style). These tests currently fail with the buggy code:syncCompressionBlocks keeps block active when compressMessageId is missingfilterCompressedRanges prunes messages even when block was deactivatedsyncCompressionBlocks populates activeByAnchorMessageId using fallbackfilterCompressedRanges injects summary when anchor is missingresetOnCompaction preserves compression statesyncCompressionBlocks keeps blocks active when compressMessageId is missingRelated
/fork message(model issue uncertain) #457: "DCP context window may leak pre-fork messages after /fork message" — same class of bug where message IDs get out of sync425339f(reset message ids after /compact),90d4089(fix stale tokens after /compact) — same underlying fragilityFiles Affected
lib/messages/sync.ts—syncCompressionBlocks()(Symptoms A, B)lib/messages/prune.ts—filterCompressedRanges()(Symptom A — usesactiveBlockIds)lib/state/utils.ts—resetOnCompaction()(Symptom C)