You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
prune: unconditional first-user pin defeats fold coverage when a covered message becomes the view's first user #489
When a folded message occupies (or becomes) the session's first user message in the resent view, the fold reports success ([Compressed mX–mY → 1 block(s)]) and the block stays active, yet the rebuilt wire keeps carrying the covered payload verbatim on every subsequent turn. No compression benefit ever materializes while the client believes compression happened.
Two trigger shapes, same mechanism:
Divergent resend: the client trims earlier history before resending without previous_response_id; an earlier-folded message shifts into the first-user slot.
Direct fold of the opening message: protected-zone rules are tail-based, so folding the current first user is allowed, and its coverage is void from creation.
Root cause
In rebuildMessages (src/prune.ts) the first-user pin check runs before the covered check unconditionally:
The pin's stated intent (DESIGN.md §8.1) is a property — "the rebuilt wire retains at least one user message whenever the input had one" (strict providers reject 0-user conversations). The implementation over-guarantees by pinning the specific first message. When that message is covered, syncBlocks keeps its block active (the base id is present in the pre-prune view), so the payload re-sends itself forever.
Evidence (deterministic, A/B)
Reproduced through the billion-context Responses WS test harness (fake upstream + real proxy, OpenCode V2 plugin lane, defect-free full terminal mode):
Shape: seed user → bulky sentinel user (~12 KB) → 5 plain users; fold the sentinel (success, block active); next turn the client resends full history without the seed (sentinel now first user).
Seed dropped: rebuilt frame retains the covered sentinel verbatim, no summary benefit — exact #1869 signature.
Seed kept (control): sentinel correctly pruned from the same fold state. Single variable isolated.
Tool pairs in the conversation are a red herring: they only correlated via a separate checkpoint defect (#1866) that also shifts the first user.
Proposed fix
Make the pin conditional: a covered first user drops when another user message survives behind it (earliest non-covered, non-rendered-summary follower must itself be a user — firstUserDropSafe). Degenerate shapes where no user would lead keep today's behavior (documented residual limitation in DESIGN.md §8.1). No ref/id churn, no wire-artifact format change, no node-ordering change.
Consumer note
billion-context consumes this via its bundled pin; the adapter-side regression test (Responses WS lane) lands after this ships. Wire-shape change is intentionally narrow: frames lose only a previously-pinned covered message that another surviving user makes redundant.
CI 跟进(透明记录):首推 commit 328de51 的 CI 全量回归(Node 22,1217 测试)抓到 1 个失败——tests/issue1476-reid.test.ts 的 #462 e2e("first-user pinned echo keeps its id and ref across post-fold passes")。
定性:不是修复引入的行为回归,而是该既有测试把旧契约(无条件 pin)钉死在了一个 drop-safe 场景里(被覆盖的首条 user 后面还有存活的 user)。按新契约这条消息本就该被剪掉——修复行为是对的,测试期望过期了。这正是本地定向集没覆盖到该文件、全量 CI 兜住的价值所在。
Activity
ranxianglei commentedon Oct 1, 2026
已完成修复并开 PR:**https://github.com/ranxianglei/acp-kernel/pull/491**(分支
2026-10-01_first-user-pin-drop,commit328de51,基于当前 mastere7e1614,未合并——合并留给人)。查重:全仓 open/closed 无同类条目,非重复。结构化七问
是什么问题:当某条被 fold 覆盖的消息成为(重发视图中的)第一条 user 消息时,fold 报成功、block 保持 active,但重建后的 wire 每轮仍逐字携带被覆盖的 payload——压缩收益永远不兑现。两种触发形态(divergent resend 把早先折叠的消息挤进 first-user 槽位;直接折叠开场消息)同一机制。
什么原因导致的:根因是「属性」与「特定消息」的混淆。pin 的设计意图(DESIGN.md §8.1)是个属性——"只要输入有 user,重建 wire 就至少保留一条 user"(严格 provider 拒绝 0-user 会话);但
rebuildMessages的实现把它焊死在特定的第一条消息上:pin 检查无条件先于 covered 检查执行。当该消息恰好被覆盖时,syncBlocks因 base id 存在于 pre-prune 视图(定义上必然存在——它就是视图的第一条 user)而保持 block active,payload 遂永久重发。因果分层:表面现象 = fold 静默失效;机制层 = pin 过度保证;契约层 = DESIGN.md 写的是属性,代码实现的是实例。怎么修复的:把 pin 变成条件式(
src/prune.ts):仅当「covered 的第一条 user 若丢弃将导致没有 user 能领头」时才钉住。判据firstUserDropSafe:扫描其后最早会存活的非 covered、非渲染摘要消息,必须是 user 才可丢;渲染摘要跳过是因为四个 wire codec 全部把 system 消息提升到系统通道(openai-chat/anthropic/google/responses 已逐一核实),leading summary 不会破坏 user-led 约束。退化形态(最早存活者是 assistant/tool,或无后续存活者)保持旧行为,作为 DESIGN.md §8.1 记录的残余限制。无 ref/id churn、无 wire-artifact 格式变更、无节点顺序变更。是否回归问题:审过既有行为并逐项确认——① uncovered 首条 user:照旧存活(covered 检查本来就放行);② 退化形态(assistant-led 后继 / 首条 user 后无任何存活 user):行为与修复前逐字节一致,
orphan-fixes.test.ts既有 pin 测试原样通过;③ 孤儿 tool-result 不会领头(其 call id 缺失时结果本身被 strip);④ sub-id 投影(prune: isCovered misses base-id coverage when block effective ids use sub-id forms (base#suffix) #231)按 base 归一化,覆盖判定不变;⑤blockVisibleInRange逻辑未动,对 pinned 退化形态仍正确。本地定向验证:typecheck ✓、build ✓、format:check ✓、触及路径全部相关测试 174/174 ✓(prune/pipeline/boundaries/sync/protected-zone/refold/range-coverage 等);全量回归交 CI。新增tests/regression-first-user-pin.test.ts:7 个单元形态 + 2 个 pipeline A/B(processTurn+applyCompression,覆盖 issue 描述的两种触发形态,含 seed 保留对照组)。是否破坏缓存:不涉及。ref/id 不重发不回收,summary 锚点位置(pair-safe anchor)机制未动,block 生命周期(sync/syncBlocks)未动;唯一变化是帧里少了一条本来就该被剪掉的 covered 消息。
是否改了设计方向:不涉及架构转向。设计方向本来就是属性式 pin——本次是把实现拉回文档声明的属性,并在文档中显式记录退化形态的残余限制。
是否引入新配置:不涉及。无新 env/config/默认值。
收工判定:代码/测试/文档齐备,本地定向验证全绿,PR 已开且引用本 issue(Fixes #489),等待 CI 与人工 review/merge。
test: #462 pinned-echo e2e now uses the degenerate pin shape (#489)
ranxianglei commentedon Oct 1, 2026
CI 跟进(透明记录):首推 commit
328de51的 CI 全量回归(Node 22,1217 测试)抓到 1 个失败——tests/issue1476-reid.test.ts的 #462 e2e("first-user pinned echo keeps its id and ref across post-fold passes")。定性:不是修复引入的行为回归,而是该既有测试把旧契约(无条件 pin)钉死在了一个 drop-safe 场景里(被覆盖的首条 user 后面还有存活的 user)。按新契约这条消息本就该被剪掉——修复行为是对的,测试期望过期了。这正是本地定向集没覆盖到该文件、全量 CI 兜住的价值所在。
处置(commit
b36d622,已推):把该 e2e 移到退化形态(pin 仍生效的形状:首条 user 被覆盖、后继是 assistant),它原本要守护的 #462 关切——pinned echo 跨 pass 的 id/ref 稳定性——继续被追踪;DESIGN.md §8.1 的测试清单同步补上引用。当前状态:PR #491 head
b36d622,CI 四项全绿(test 22 / test 24 / pr-validation / build-artifact)。等待人工 review + merge。ranxianglei commentedon Oct 2, 2026
三层核查结论:首条消息在选区层没有任何保护(记录备查,暂不实施)
讨论中存在一个关键分歧:「第一次压缩时首条消息是被保护的,所以第一条压不掉」。逐层查证后确认该保护在任何一层都不存在,证据如下。
1. billion-context-pi(master 1cb6340,v0.1.83)
firstUser仅一处:src/export.ts:48 firstUserText()—— 会话列表标题派生,与压缩无关;package.json:70钉 0.0.98),pi 侧自身没有首条消息逻辑。2. acp-kernel 选区层(src/recommend.ts)
ALWAYS_PROTECTED_TOOLS(compress / acp_rule 载体)+ 尾部窗口(preserveRecentMessages/preserveRecentTokens+ 最后一条 user);buildCompressibleRanges无头部排除:开场消息只要够大且不在尾部窗口,会照常进入推荐 range;3. acp-kernel wire 层(src/prune.ts)
建议(影响面大,先评论记录,不动代码,待拍板)
ranxianglei commentedon Oct 2, 2026
三层结论我逐条对内核代码复核过(kernel 侧我有责任自己验证,不只采信转述):
复核结果
computeProtectedRefs(src/recommend.ts:66)三条规则 = 尾部 N 消息(preserveRecentMessages)/ 尾部 N tokens 回扩 / 最后一条可见 user(与preserveRecentMessages > 0耦合,置 0 全关)。无任何头部排除;buildCompressibleRanges同理。开场消息够大且不在尾窗就进推荐 range。另有一个对你选项 2 有利的结构事实:protected zone 是选区推荐与手动 fold 的共享校验点——
applyCompression(src/compress.ts:~1280)对同一 zone 做软剔除(从 range/effectiveMessageIds 剔除 + warn,全被保护则 throw)。所以「首条硬保护」不需要新子系统:把首条塞进这个 zone,自动推荐和模型手动 compress 同时命中,且语义天然自洽——剔除出 effectiveMessageIds 后 block 不覆盖它,prune 按普通未覆盖内容保留,#489 式的「报成功不生效」影子在机制上不存在。我的建议(明确立场,供拍板)
一句话:#491 先合(修正确性);「首条要不要永远压不掉」是独立产品决策,要就做选区层硬保护(session-origin + 软剔除 + 两个边界定案),不要就维持现状(#491 后的条件 pin)。