Skip to content

fix: preserve DCP state across compaction, protect reasoning signatures, resolve ignored boundary refs#530

Open
tracycam wants to merge 16 commits into
Opencode-DCP:masterfrom
tracycam:fix/compress-summary-limits
Open

fix: preserve DCP state across compaction, protect reasoning signatures, resolve ignored boundary refs#530
tracycam wants to merge 16 commits into
Opencode-DCP:masterfrom
tracycam:fix/compress-summary-limits

Conversation

@tracycam
Copy link
Copy Markdown

Summary

  • Persist lastCompaction and messageIds across process restarts — previously in-memory only, causing resetOnCompaction to wipe all DCP state (compression blocks, prune.tools, messageIds, nudges) on every restart after native /compact
  • Exclude reasoning parts from stripStaleMetadata — was stripping metadata.anthropic.signature on model-switch, breaking Anthropic's signature verification requirement
  • Remove isIgnoredUserMessage filter from buildBoundaryLookup — refs allocated at chat.params time (when parts.ignored not yet set) became unresolvable at compress time when fetchSessionMessages returned ignored:true parts

Also includes from prior branch work:

  • Atomic batch block reservation (previewBlockIds/reserveBlockIds) preventing partial state on validation failure
  • Summary size limits (assertUsefulCompressedSummary: >60K token cap, ratio check for >10K selections)
  • Filediff snapshot pruning for edit/write tools already in prune.tools
  • Protected tool output truncation (20K char cap with head/tail preservation)
  • syncCompressionBlocks authoritative mode — only deactivate missing-origin blocks when called with full DB message list

Bug Details

Bug 1: State lost on compaction restart

lastCompaction was never persisted → every process restart re-triggered resetOnCompaction → wiped ALL DCP state. Fix: persist both lastCompaction and messageIds in PersistedSessionState; make resetOnCompaction inert (opencode /compact preserves all msg_X IDs in DB).

Bug 2: Reasoning signatures wiped on model switch

stripStaleMetadata included reasoning in RELEVANT_TYPES, dropping metadata.anthropic.signature. Anthropic requires thinking block metadata byte-for-byte intact. Fix: RELEVANT_TYPES = Set(["text", "tool"]) — opencode native already handles reasoning→text conversion for different-model requests.

Bug 3: Compress boundary lookup rejects ignored user-message refs

assignMessageRefs allocated refs for slash-command user messages at chat.params time (before parts.ignored was set). At compress time, fetchSessionMessages returned these with ignored:truebuildBoundaryLookup excluded them → 'not available' error. Fix: remove isIgnoredUserMessage checks from lookup (address resolution only; content filtering handled by resolveSelection:129).

Test Results

103 pass / 3 fail (all pre-existing)

Pre-existing failures (unrelated to this PR):

  • Failures in existing test infrastructure (not introduced by these changes)

New test files

  • tests/compaction-resilience.test.ts (8 tests): persistence round-trips, compaction restart preservation, syncCompressionBlocks authoritative mode, chat transform ref persistence
  • tests/reasoning-strip.test.ts (4 tests): reasoning signature preserved on model switch, text/tool metadata still stripped, empty text reasoning untouched, same-model reasoning untouched
  • tests/ignored-boundary-lookup.test.ts (4 tests): ignored ref resolves, both start+end ignored, unknown ref still rejected, compressed block anchored to ignored msg resolves
  • tests/prune-tool-metadata.test.ts (1 test): filediff snapshot pruned only when callID in prune.tools
  • tests/summary-limits.test.ts (1 test): consumed blocks counted as current summaries
  • Updated tests/message-ids.test.ts: asserts preservation instead of reset after compaction

Known Limitations

  1. saveSessionState monotonic-merges lastCompaction but overlapping async saves can still overwrite newer messageIds — would need an atomic write layer to fully solve
  2. resolveSelection:129 still excludes ignored messages from selection content; active blocks anchored to ignored messages may not be auto-included unless referenced via bN boundary
  3. truncateProtectedToolOutput retains 20K chars of original content plus marker text (~80 chars over cap)

Files Changed

23 files changed, 1359 insertions(+), 67 deletions(-)

tracycam and others added 16 commits May 1, 2026 15:02
Edit/write filediff.before/after were being stripped from every tool call,
including recent edits not yet flagged for pruning. Gate the strip on
state.prune.tools.has(part.callID) so only tool calls already entering
the prune pipeline lose their filediff snapshots.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…estimate

When a range consumed previously-compressed blocks, estimateSelectedTokens
double-counted: it added every selected message's tokens AND added each
consumed block's summaryTokens. Real current-context cost should skip
messages already covered by the consumed blocks (their tokens are gone
from context) and only add the consumed blocks' summary tokens. Build a
consumedMessageIds Set from each block's effectiveMessageIds and skip
those messages during selection accounting.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…ilure

Batch compression previously called allocateBlockId() per plan inside the
apply loop. If the second plan's summary blew the size limit, the first
plan had already reserved a block id and mutated state.prune.messages,
leaving a half-written batch behind.

Split allocateBlockId into two phases:
  - previewBlockIds(state, n): pure read, returns the next n ids without
    mutating state
  - reserveBlockIds(state, n): advances state.prune.messages.nextBlockId

compress message/range now (1) preview ids for the whole batch, (2) build
storedSummary + summaryTokens + estimateSelectedTokens for every plan and
run assertUsefulCompressedSummary, then (3) reserve ids and allocateRunId
only after every plan validates. allocateBlockId() is kept as a wrapper
around reserveBlockIds(state, 1) for backward compat.

Adds regression tests in compress-{message,range}.test.ts asserting that
when a later plan's summary exceeds limits, blocksById and activeBlockIds
remain empty and nextBlockId/nextRunId stay at 1.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
# Conflicts:
#	lib/compress/message.ts
Persist lastCompaction and messageIds so restarts keep the same mNNNN aliases, make native compaction reset inert because opencode preserves msg_* rows, and only deactivate compression blocks when sync is fed an authoritative full-session message list.

Oracle findings: b1, b2, b3, b4, b5.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Add regression coverage for persisted lastCompaction and messageIds, backward-compatible loads, stale-save protection, native compaction restarts, authority-aware block sync, immediate ref persistence, and preservation of aliases after compaction.

Oracle findings: b1, b2, b3, b4, b5.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Anthropic's thinking blocks must stay byte-for-byte unchanged on subsequent API calls; signature lives in metadata.anthropic. DCP previously stripped that signature on differentModel and Anthropic rejected the next request as "thinking blocks ... cannot be modified".

Reasoning parts now pass through unchanged while opencode native handles reasoning-to-text conversion on differentModel itself (message-v2.ts:958-972). Reproduction session: ses_1e893bcf3ffeZluDvQ4RHEsfbP.
assignMessageRefs allocates refs for user-summary slash-command messages
when parts are not yet marked ignored (chat.params time). At compress time,
fetchSessionMessages returns parts with ignored:true. buildBoundaryLookup
then excluded these refs via isIgnoredUserMessage, causing "not available"
errors for any boundary ID pointing to such a message.

Remove the isIgnoredUserMessage filter from buildBoundaryLookup — the
lookup only maps ref → msgId → rawIndex for address resolution. Downstream
resolveSelection (line 129) already applies the ignored-message filter for
content selection, so ignored messages remain non-compressible content but
are now usable as positional anchors.

Affects every session that uses slash commands (/cost, /share, etc.) and
then attempts to compress ranges containing those message positions.
Reproduced on ses_1e2de93a6ffeXwjU7kSyqHWeFL (ESCTools project) and
ses_1e9066e4effeRkxz5jqH4YqvdZ (this session).
4 test cases:
- ignored user msg ref resolves in resolveBoundaryIds
- ignored user msg as both start and end (single-message compress)
- unknown ref still rejected
- compressed block anchored to ignored user msg resolves
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant