Summary
After repeated compression of a long session, 146 messages (~1.1 MB) remained that no compression block had ever covered. The largest component is reasoning parts (50.9%), and 83.5% of that reasoning is co-resident with compress tool parts. The proximate cause is that filterProtectedToolMessages excludes messages from compression selection at message granularity while compress is force-protected: the thinking emitted by each compression operation is thereby excluded from all future compression and re-sent with every request. The non-reclaimable context floor consequently grows monotonically with the number of compressions performed.
Environment
- opencode-acp 1.14.27. All code-level statements below were verified against both the shipped
dist/index.js and the TypeScript recovered from its embedded sourcemap (75/75 lib/*.ts files byte-identical).
- Observed session: ~1M-token context limit, 4,846 messages, 227 blocks created across the session's lifetime.
Method
Read-only forensics over opencode.db (SQLite, mode=ro) joined against the plugin registry (storage/plugin/acp/ses_*.json). A message is classified as uncovered if it appears in no block's effectiveMessageIds and has no covering entry in prune.messages.byMessageId. Part size is length(data) in bytes.
Measurements
Residual after the final compression (146 uncovered messages, 1,105,625 B):
| component |
bytes |
share |
reasoning parts |
563,171 |
50.9% |
compress tool parts (summaries in state.input.content, force-protected) |
153,939 |
13.9% |
skill parts |
121,542 |
11.0% |
| remaining (other tools, text, step parts) |
~266,000 |
~24% |
Attribution of the 563,171 B of reasoning by co-resident tool part:
- 95.0% (534,833 B) resides in messages containing at least one protected tool part
- 83.5% (446,479 B) specifically in messages containing a
compress part; session-wide, 120 compress-carrying messages hold 1,130,202 B of reasoning (mean 9,418 B, max 28,067 B per message)
- 0% attributable to the recent-tail window (no uncovered post-last-compression messages without protected parts) — the residual is structural, not ordinary recent context
Mechanism
filterProtectedToolMessages removes whole messages from every compression selection (lib/compress/protected-content.ts:188-234; removedMessageIds.add(messageId) — no part-level handling). A message's reasoning is therefore exempted together with its protected tool part.
compress and skill are the defaults for compress.protectedTools (config.ts:158/245), and compress is force-appended regardless of user configuration (config.ts:167/476).
- Exempted messages never enter
byMessageId, so the pruning survival rule (lib/messages/prune.ts:60-89) never removes them, and they are included in every outgoing request while the message is in the array.
- Retention of the
compress message itself is defensible: the live summary reaches the model only through that message (range.ts:378; wrapCompressedSummary, compress/state.ts:55). The reasoning part in the same message is not covered by that constraint — it never re-enters any selection (excluded prior to selection), and neither tier distillation nor decompress reads it (those operate on the registry and DB).
Scope limitation
The figures above are DB-level totals accumulated over the session's entire lifetime, and bound the per-request residue from above; they do not represent the size of any single outgoing request, which was substantially smaller once consumed blocks were pruned and spliced. When a block is consumed by a higher tier, hideConsumedCompressCalls drops the compress part, and messages left with only structural parts are spliced (compress/parts.ts:1-5 classifies reasoning as structural). Two subclasses have no such reclaim path: (a) the live-block window — every compression round adds an exempt message that persists until T2/T3 consumption; (b) skill-carrying messages — hideConsumedCompressCalls handles only compress parts. Exact per-request decomposition would require simulating the full pipeline and was not performed.
Related findings
- The displayed residual is estimated over text+tool only (
estimateContextComposition, inject/utils.ts:623-666, total at :703; length/4 heuristic) and excludes reasoning; lastPerMessageNudgeTokens is a lagging snapshot. The reported percentage therefore omits the largest measured residual component.
byMessageId entries whose activeBlockIds drain to empty remain permanently visible: prune.ts:62 treats "entry present, no active blocks" as survive. Observed on 8 messages / 28,338 B.
rewriteCompressInput bails out conservatively when all batch siblings are consumed (hide-consumed.ts:42, kept.length === 0 → return null), leaving unmatched batch summaries in the request (~3.7 KB/block in this session).
Possible mitigation
Strip reasoning parts at request time for messages that survive solely via the protected-message exemption, excluding the last unclosed assistant turn. Providers mandate replaying thinking blocks only for the current unresolved tool round (Anthropic thinking-signature validation; Gemini thought_signature on tool calls), so gating on turn-closure state rather than provider identity covers all providers. No persistent writes required.
Summary
After repeated compression of a long session, 146 messages (~1.1 MB) remained that no compression block had ever covered. The largest component is
reasoningparts (50.9%), and 83.5% of that reasoning is co-resident withcompresstool parts. The proximate cause is thatfilterProtectedToolMessagesexcludes messages from compression selection at message granularity whilecompressis force-protected: the thinking emitted by each compression operation is thereby excluded from all future compression and re-sent with every request. The non-reclaimable context floor consequently grows monotonically with the number of compressions performed.Environment
dist/index.jsand the TypeScript recovered from its embedded sourcemap (75/75lib/*.tsfiles byte-identical).Method
Read-only forensics over
opencode.db(SQLite,mode=ro) joined against the plugin registry (storage/plugin/acp/ses_*.json). A message is classified as uncovered if it appears in no block'seffectiveMessageIdsand has no covering entry inprune.messages.byMessageId. Part size islength(data)in bytes.Measurements
Residual after the final compression (146 uncovered messages, 1,105,625 B):
reasoningpartscompresstool parts (summaries instate.input.content, force-protected)skillpartsAttribution of the 563,171 B of reasoning by co-resident tool part:
compresspart; session-wide, 120 compress-carrying messages hold 1,130,202 B of reasoning (mean 9,418 B, max 28,067 B per message)Mechanism
filterProtectedToolMessagesremoves whole messages from every compression selection (lib/compress/protected-content.ts:188-234;removedMessageIds.add(messageId)— no part-level handling). A message'sreasoningis therefore exempted together with its protected tool part.compressandskillare the defaults forcompress.protectedTools(config.ts:158/245), andcompressis force-appended regardless of user configuration (config.ts:167/476).byMessageId, so the pruning survival rule (lib/messages/prune.ts:60-89) never removes them, and they are included in every outgoing request while the message is in the array.compressmessage itself is defensible: the live summary reaches the model only through that message (range.ts:378;wrapCompressedSummary,compress/state.ts:55). Thereasoningpart in the same message is not covered by that constraint — it never re-enters any selection (excluded prior to selection), and neither tier distillation nordecompressreads it (those operate on the registry and DB).Scope limitation
The figures above are DB-level totals accumulated over the session's entire lifetime, and bound the per-request residue from above; they do not represent the size of any single outgoing request, which was substantially smaller once consumed blocks were pruned and spliced. When a block is consumed by a higher tier,
hideConsumedCompressCallsdrops thecompresspart, and messages left with only structural parts are spliced (compress/parts.ts:1-5classifiesreasoningas structural). Two subclasses have no such reclaim path: (a) the live-block window — every compression round adds an exempt message that persists until T2/T3 consumption; (b)skill-carrying messages —hideConsumedCompressCallshandles onlycompressparts. Exact per-request decomposition would require simulating the full pipeline and was not performed.Related findings
estimateContextComposition,inject/utils.ts:623-666, total at:703;length/4heuristic) and excludes reasoning;lastPerMessageNudgeTokensis a lagging snapshot. The reported percentage therefore omits the largest measured residual component.byMessageIdentries whoseactiveBlockIdsdrain to empty remain permanently visible:prune.ts:62treats "entry present, no active blocks" as survive. Observed on 8 messages / 28,338 B.rewriteCompressInputbails out conservatively when all batch siblings are consumed (hide-consumed.ts:42,kept.length === 0 → return null), leaving unmatched batch summaries in the request (~3.7 KB/block in this session).Possible mitigation
Strip
reasoningparts at request time for messages that survive solely via the protected-message exemption, excluding the last unclosed assistant turn. Providers mandate replaying thinking blocks only for the current unresolved tool round (Anthropic thinking-signature validation; Geminithought_signatureon tool calls), so gating on turn-closure state rather than provider identity covers all providers. No persistent writes required.