diff --git a/README.md b/README.md index bca815d..b30bfd2 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ The nudge system tells the model *when* to compress. It implements: ✅ **Engine complete** — 23 source modules, 167 tests, typecheck + build clean. 3-tier compression, growth-gated nudges, emergency truncation,, fork-recovery, batch merge, composable node pipeline. Ready for adapter authoring. -> **Known limitation:** protected tool messages are excluded from *ref assignment* (marked `BLOCKED`) but, unlike opencode-acp's Bug 39, are **not** hard-excluded from an explicitly-referenced compress range. A model that names a range covering a `BLOCKED` message will compress it. This is an intentional simplification for the pure core; adapters that need hard-exclusion should pre-split ranges before calling `applyCompression`. +> **Protected tool messages:** protected tool calls (per `config.protectedTools`) and their paired tool-results are hard-excluded from compression — they are dropped from the compressible set and from the new block's `effectiveMessageIds`, so they stay fully visible and are never folded into a summary. This matches opencode-acp's Bug 39 fix. The soft-protected recent zone (`preserveRecentMessages` / last user message) is handled separately: messages there are excluded from the range but do not fail it (an entirely-protected range fails with a clear error). ## License diff --git a/src/compress.ts b/src/compress.ts index 946d89b..cc98ac1 100644 --- a/src/compress.ts +++ b/src/compress.ts @@ -537,6 +537,17 @@ function applySingleRange(input: SingleRangeInput): SingleRangeOutcome { input.config, ); + // filterProtectedToolMessages drops protected tool calls (and their paired + // results) from the compressible set. They must also leave effectiveMessageIds, + // otherwise the block would record them as covered and hide them from view. + // (Bug 39: protected tool messages folded into a block.) + if (filteredIds.length < directMessageIds.length) { + const kept = new Set(filteredIds); + for (const id of directMessageIds) { + if (!kept.has(id)) effectiveMessageIds.delete(id); + } + } + // SOFT PROTECTION: the recent-N / last-user-message zone is advisory-only at // compress time. Instead of failing the whole range when it brushes protected // messages, exclude those messages and proceed with the rest (so the model diff --git a/tests/protected-content.test.ts b/tests/protected-content.test.ts index f7ecd06..0c50888 100644 --- a/tests/protected-content.test.ts +++ b/tests/protected-content.test.ts @@ -64,6 +64,12 @@ test("Feature 2: protected tool-call is excluded from compression range", () => assert.ok(!block.directMessageIds.includes("c"), "tool-result 'c' should be excluded"); assert.ok(block.directMessageIds.includes("a"), "regular msg 'a' should remain"); assert.ok(block.directMessageIds.includes("d"), "regular msg 'd' should remain"); + // Bug 39 regression: effectiveMessageIds must also exclude protected tool + // messages, otherwise the block would mark them as covered and hide them. + assert.ok(!block.effectiveMessageIds.includes("b"), "tool-call 'b' excluded from effective coverage"); + assert.ok(!block.effectiveMessageIds.includes("c"), "tool-result 'c' excluded from effective coverage"); + assert.ok(block.effectiveMessageIds.includes("a"), "regular msg 'a' in effective coverage"); + assert.ok(block.effectiveMessageIds.includes("d"), "regular msg 'd' in effective coverage"); }); test("Feature 2: protected tool messages are filtered out, not appended", () => { @@ -87,6 +93,10 @@ test("Feature 2: protected tool messages are filtered out, not appended", () => const block = result.state.blocks[0]!; assert.ok(!block.directMessageIds.includes("b"), "protected skill tool-call excluded from compressed set"); assert.ok(!block.directMessageIds.includes("c"), "protected skill tool-result excluded"); + // Bug 39 regression: effectiveMessageIds must match directMessageIds here + // (no consumed blocks), so protected messages are excluded from both. + assert.ok(!block.effectiveMessageIds.includes("b"), "protected skill tool-call excluded from effective coverage"); + assert.ok(!block.effectiveMessageIds.includes("c"), "protected skill tool-result excluded from effective coverage"); assert.ok(!block.summary.includes("Protected:"), "no protected content folded into summary"); assert.ok(!block.summary.includes('{"name":"git-master"}'), "protected tool content not leaked into summary"); assert.equal(block.summary, validSummary, "summary is exactly what the author wrote");