opencode-acp 1.12.7 → 1.12.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -422,6 +422,14 @@ For the complete list with root cause analysis, see the [bug tracker](https://gi
422
422
 
423
423
  ## Changelog
424
424
 
425
+ ### v1.12.8 — Phantom Block Rejection (PR #148)
426
+
427
+ **Problem**: When the model called `compress` on a range that was already covered by an active compression block, `applyCompressionState` still created a new block with `directMessageIds: []`, `compressedTokens: 0`, and `effectiveMessageIds` inherited from the consumed block. The model saw "0 tokens removed" in the notification, retried the same range, and entered a death loop: each phantom block added ~1K of summary overhead while compressing nothing, causing context to *grow* with every compression call (issues #93, #135). User sessions showed 9 consecutive phantom compressions (b12–b20) on the same range before the user manually intervened.
428
+
429
+ **Fix**: Added `checkPhantomBlock()` — a stateless pre-check in `lib/compress/pipeline.ts` that mirrors `applyCompressionState`'s `newlyCompressedMessageIds` computation. For each plan, it builds the effective message set (plan messages + consumed blocks' effective messages) and checks whether ANY message is "new" (i.e., has no active block covering it BEFORE mutation). If no message is new, the plan is a phantom and the entire compress call is rejected with a clear error before any state mutation occurs. Wired into both range-mode (`compress/range.ts`) and message-mode (`compress/message.ts`) after plan preparation, before snapshot. 12 tests cover: empty plans, all-new messages, consumed-block inheritance, GC'd messages (deactivated blocks count as new), and the exact `applyCompressionState` mirroring.
430
+
431
+ Files: `lib/compress/pipeline.ts`, `lib/compress/range.ts`, `lib/compress/message.ts`. Tests: `tests/phantom-block.test.ts` (NEW, 12 tests). 725 tests pass.
432
+
425
433
  ### v1.12.7 — Smart Recommendation Filter + Dangerous Parameter + Ref-Leak Fix + Phantom Turn Fix (PRs #142, #147, #150)
426
434
 
427
435
  **Problem**: Four issues. (1) The recommendation filter used a hardcoded 5× growth threshold for single-message ranges (25% of context), leaked context, showed tiny ranges, and contradicted itself by recommending the last segment while blocking it. (2) Nudge text was injected even when the filter suppressed all ranges — wasting context with an empty recommendation list. (3) Compression block metadata leaked message refs (`m01309–m02150`) via `acp_context_recap` tool input, `acp_status` output, and `recap` tool output — the model copied these into compress calls on already-compressed ranges, creating phantom blocks (#93, #135). (4) `sendIgnoredMessage` during the transform hook persisted an ignored user message that resolved async after the model's response — the loop's `lastUser` detection picked it up → phantom LLM call with no new input → confusion → hallucination → feedback loop ("待命" spam).
package/README.zh-CN.md CHANGED
@@ -395,6 +395,14 @@ ACP 在首次启动时自动将配置从 `dcp.jsonc` 迁移到 `acp.jsonc`,将
395
395
 
396
396
  ## 更新日志
397
397
 
398
+ ### v1.12.8 — 幽灵块拒绝(PR #148)
399
+
400
+ **问题**:当模型对已被活跃压缩块覆盖的范围调用 `compress` 时,`applyCompressionState` 仍然创建新块,`directMessageIds: []`、`compressedTokens: 0`、`effectiveMessageIds` 从被消费的块继承。模型在通知中看到"移除 0 tokens",重试同一范围,进入死亡循环:每个幽灵块增加约 1K 摘要开销却什么都不压缩,导致上下文随每次压缩调用*增长*(issues #93, #135)。用户会话显示同一范围连续 9 次幽灵压缩(b12–b20),直到用户手动干预。
401
+
402
+ **修复**:新增 `checkPhantomBlock()` —— `lib/compress/pipeline.ts` 中的无状态前置检查,镜像 `applyCompressionState` 的 `newlyCompressedMessageIds` 计算。对每个计划,构建有效消息集(计划消息 + 被消费块的有效消息),检查是否有任何消息是"新的"(即变异前没有活跃块覆盖它)。如果没有新消息,该计划是幽灵的,整个 compress 调用在任何状态变异前以清晰错误被拒绝。接入 range 模式(`compress/range.ts`)和 message 模式(`compress/message.ts`)的计划准备之后、快照之前。12 个测试覆盖:空计划、全新消息、被消费块继承、GC'd 消息(已停用块算新)、以及与 `applyCompressionState` 的精确镜像。
403
+
404
+ 文件:`lib/compress/pipeline.ts`、`lib/compress/range.ts`、`lib/compress/message.ts`。测试:`tests/phantom-block.test.ts`(新增,12 个测试)。725 测试通过。
405
+
398
406
  ### v1.12.7 — 智能推荐过滤 + Dangerous 参数 + Ref 泄漏修复 + Phantom Turn 修复(PR #142, #147, #150)
399
407
 
400
408
  **问题**:四个问题。(1)推荐过滤器使用硬编码的 5× 增长阈值(上下文的 25%),太大且容易泄露上下文;推荐最后一段的同时又阻止它,自相矛盾。(2)过滤器抑制所有 range 后仍然注入 nudge 文本——用空推荐列表浪费上下文。(3)压缩块元数据通过 `acp_context_recap` 工具输入、`acp_status` 输出和 `recap` 工具输出泄漏消息 ref(`m01309–m02150`)——模型复制这些 ref 到已压缩范围的 compress 调用中,产生幽灵块(#93, #135)。(4)`sendIgnoredMessage` 在 transform hook 中持久化 ignored 用户消息,异步在模型回复后完成——loop 的 `lastUser` 检测拾取它 → 无新输入的 phantom LLM 调用 → 困惑 → 幻觉 → 反馈循环("待命" 刷屏)。
package/dist/index.js CHANGED
@@ -5253,6 +5253,30 @@ function getLastVisibleMessageId(rawMessages, state) {
5253
5253
  }
5254
5254
  return null;
5255
5255
  }
5256
+ function checkPhantomBlock(state, plans) {
5257
+ for (let i = 0; i < plans.length; i++) {
5258
+ const plan = plans[i];
5259
+ const effective = new Set(plan.messageIds);
5260
+ for (const consumedId of plan.consumedBlockIds) {
5261
+ const block = state.prune.messages.blocksById.get(consumedId);
5262
+ if (block) {
5263
+ for (const mid of block.effectiveMessageIds) {
5264
+ effective.add(mid);
5265
+ }
5266
+ }
5267
+ }
5268
+ const hasNew = [...effective].some((mid) => {
5269
+ const entry = state.prune.messages.byMessageId.get(mid);
5270
+ return !entry || entry.activeBlockIds.length === 0;
5271
+ });
5272
+ if (!hasNew) {
5273
+ return new Error(
5274
+ `Compression range ${i + 1} contains only already-compressed messages (0 new direct messages, 0 tokens saved). Nothing to compress \u2014 pick a range with visible, uncompressed content. Use \`acp_status({scope:"uncompressed"})\` to see which ranges are still compressible.`
5275
+ );
5276
+ }
5277
+ }
5278
+ return null;
5279
+ }
5256
5280
  function checkLastSegmentDangerous(ctx, allPlanMessageIds, rawMessages, dangerous) {
5257
5281
  if (ctx.config.compress.lastSegmentSoftBlock === false) return null;
5258
5282
  const lastVisibleId = getLastVisibleMessageId(rawMessages, ctx.state);
@@ -5491,6 +5515,14 @@ function createCompressMessageTool(ctx) {
5491
5515
  summaryWithTools
5492
5516
  });
5493
5517
  }
5518
+ const phantomError = checkPhantomBlock(
5519
+ ctx.state,
5520
+ preparedPlans.map(({ plan }) => ({
5521
+ messageIds: plan.selection.messageIds,
5522
+ consumedBlockIds: []
5523
+ }))
5524
+ );
5525
+ if (phantomError) throw phantomError;
5494
5526
  const snapshot = snapshotCompressionState(ctx.state);
5495
5527
  const runId = allocateRunId(ctx.state);
5496
5528
  try {
@@ -5709,6 +5741,14 @@ function createCompressRangeTool(ctx) {
5709
5741
  consumedBlockIds: mergeConsumedBlockIds
5710
5742
  });
5711
5743
  }
5744
+ const phantomError = checkPhantomBlock(
5745
+ ctx.state,
5746
+ preparedPlans.map((p) => ({
5747
+ messageIds: p.selection.messageIds,
5748
+ consumedBlockIds: p.consumedBlockIds
5749
+ }))
5750
+ );
5751
+ if (phantomError) throw phantomError;
5712
5752
  const snapshot = snapshotCompressionState(ctx.state);
5713
5753
  const runId = allocateRunId(ctx.state);
5714
5754
  try {