Skip to content

[Bug][opencode] One-shot state notice is folded away → session keeps stale plan-mode constraints for 20+ turns after switching to build #5458

Open

Description

@MorningStar0709

Summary

In the OpenCode plugin lane, a one-shot host state notice was folded into a compress summary. The previous state notice stayed visible (it sits next to the protected head of the session), so the model kept obeying Plan-mode restrictions for 20+ turns after the session had already switched to build.

This is not "add OpenCode plan-mode support". It is a state-inversion class: any one-shot host notice carrying state (agent / mode / permission / model switch) can be folded away, while the old state remains visible forever. The model then has no way to detect that it is acting on stale instructions.

Environment

  • billion-context 0.1.211 (dist/build-info.json → commit e226544f)
  • host: OpenCode v2, Linux x64
  • lane: opencode plugin (the agent itself calls the compress tool)
  • session: ses_e6911d125ffe3IFBWXRLkZUIre
  • model: deepseek-v4.1-flash-1017 (likely irrelevant, listed for completeness)

Timeline (UTC+8, 2026-11-01)

time seq message
19:45:54 5 synthetic: <system-reminder>You are in Plan mode. ...</system-reminder>
19:49:27 176 synthetic: <system-reminder>You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply. Any Plan mode instructions from earlier in this conversation are no longer active.</system-reminder>
19:49:27 — compress issued, folding the range that contained seq 176
19:49 → 20:13 — 20+ turns executed under plan-mode constraints ("do not modify files", "switch to the build agent first") while session_v2.agent was already build

Evidence for the last row: sqlite3 ~/.local/share/opencode/opencode.db "select id, agent from session_v2 where id='ses_e6911d125ffe3IFBWXRLkZUIre'" → build, while the model was still refusing to run commands on plan-mode grounds.

Why this is worse than losing a history line

The result is not a missing message. It is an inverted state:

  • the old state (You are in Plan mode) is re-surfaced every turn because it sits at the head of the conversation, adjacent to the bootstrap/first user message;
  • the only message that cancels it is a single one-shot notice in the middle of the conversation;
  • nothing re-emits it after the fold, so the loss is unrecoverable for the rest of the session.

The model has no signal that it is out of date. It keeps refusing work, and every correction from the user looks like a misunderstanding.

Root cause (partly inferred)

Same family as #4921 and #5173:

  1. No class rule exists. grep -c "system-reminder" dist/index.js returns 0 in 0.1.211. There is no detector for host-injected state notices at all.
  2. Protection only covers a few shapes. Per The "most recent user message" slot in the protected zone was preempted by a system-injected prompt, causing the just-is | 保护区「最近一条 user 消息」被系统注入提示抢占,导致刚下达的用户指令被压缩 #5173, computeProtectedRefs takes the most recent role === "user" message and isSyntheticOrPruned excludes only summary headers and already-covered messages. A synthetic reminder in the middle of a range qualifies for folding.
  3. One-shot semantics. OpenCode emits the "NO LONGER" notice exactly once. Unlike Instructions from: baselines ([Bug][dsh] 0.1.208 · dshWorkspaceInstructionsPath only recognizes uppercase "Instructions from:", leaving rule update/ad | [Bug][dsh] 0.1.208 · dshWorkspaceInstructionsPath 只认大写 "Instructions from:",规则更新/新增/删除消息因此不设防——折掉后模型留着过期基线,更正消失 #4921), there is no re-emission to recover from, so a single fold is permanent.

I did not trace the exact fold path inside the kernel; points 1 and 3 are measured on 0.1.211, point 2 is quoted from #5173.

Repro

  1. Start an OpenCode session in the plan agent.
  2. Run it long enough that at least one compress happens after the switch.
  3. Switch the session to the build agent (host injects You are NO LONGER in Plan mode).
  4. Issue a compress covering a range that contains that notice (it is mid-conversation, not the most recent user message).
  5. After the fold, ask the model whether it is in plan mode, or simply ask it to edit a file. It still refuses on plan-mode grounds, even though session_v2.agent is build.

Suggested fix

A. Minimal, reuses the existing durable mechanism. Treat one-shot state notices as durable, ref=BLOCKED, the same way the Instructions from: baseline is protected. They are cheap to keep: one short message per state change.

B. Fold-safe carry. When a range containing such a notice is folded, force a verbatim state line into the summary (e.g. STATE: agent=build @ m00231), so the state change survives regardless of protection.

C. Host-agnostic detector. A message whose body matches a state-change shape (mode / permission / agent / plan / model, positive or negative) is never folded. This is what #4921 asks for at the Instructions from: level, generalized.

A + C is the smallest combination that removes the class rather than the instance.

Related

Activity

  1. ranxianglei commented on Nov 1, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    (ai: vllm-qwen/qwen3.8-27b)

    标签:bug / opencode / scope/kernel / p2(缺陷;opencode 车道;src 侧折叠决策面;与同族 #4921/#5173 一致定级——有绕行但体验破坏明显)

    查重:不重复(同族不同机制,已互链)。与 #4921、#5173 是同一症状族(宿主注入的状态载体被折掉 → 模型按过期状态行事),但机制各不相同:#4921 是既有 dsh 检测器的大小写盲区、#5173 是保护区「最新用户消息」槽位被合成消息抢占,本条是一类载体完全没有保护规则(grep -c "system-reminder" dist/index.js = 0 属实)。另查了 open/closed 全量:无其他条目针对 opencode plan-mode 通知;在途的 PR #4922/#4927 只修 #4921(dsh 车道),不覆盖本条。

    审核结论(三步已过,证据如下):

    1. 复现/查证:从 OpenCode v2 发行二进制里直接提取了两条通知的精确模板,与你贴的 seq 176 逐字一致:
      • 进入:<system-reminder>\nYou are in Plan mode. Discuss the plan … write them only in:\n<path>\n… Tell them they need to switch agents.\n</system-reminder>
      • 退出:<system-reminder>\nYou are NO LONGER in Plan mode. The previous Plan restrictions no longer apply. Any Plan mode instructions from earlier in this conversation are no longer active.\n</system-reminder>
        并把整个二进制的 <system-reminder> 模板族枚举了一遍:本构建里一次性状态通知就这一对(其余如 "Note: The user opened the file…" 是逐轮 UI 事件,可折)。所以检测面有界、不需要猜。
    2. 层次判断:根因不是某个既有检测器的局部 bug,而是保护类缺失。仓库里其实已有为这类问题设计的架构——durable 机制(src/durable-message-guards.ts 把宿主注入的状态载体投影成虚拟工具路径 durable/<name>,走 kernel 的 protectedLatestTools: ["durable/*"] 策略),但目前注册表里只有 dsh 车道(KDD release v0.1.2 #9 证据白名单纪律)。opencode 车道从未登记过这个载体 → 零保护。
    3. 方案评估:你的 A+C 方向正确,且恰好落在既有架构上,修法收敛为:给 opencode 车道加一个证据化分类器,把 enter/exit 两条投影到同一个身份 durable/plan-mode。关键语义:latest-per-identity 下只有最近一条声明被钉住、更早的可折——切到 build 后 "NO LONGER" 通知成为受保护活副本,而头部那条 "in Plan mode" 通知反而变得可折(事故里它正是靠位置侥幸存活)。这同时消掉「旧的可见、新的没了」和「两条矛盾声明都可见」两个方向。先例同形:dsh runtime-context 的 populated/cleared 双形态共用一个身份(dsh 车道第三个载体:runtime-context 快照(沙箱/审批策略)同属一次性 baseline user 消息,#2428 的文本标记守卫覆盖不到 #2481)。B(摘要携带)不需要:当前状态声明恒可见时,摘要再抄一份状态是冗余。C 不做成「全车道通用 detector」:那会违反 KDD release v0.1.2 #9(对没有此契约的宿主硬钉任意用户散文),类级保护通过 durable 机制按车道落地就是正确的「通用」粒度。

    动手:host 侧修改(src/durable-message-guards.ts + 测试),不动 kernel/。回归测试按事故场景端到端复现(enter → 增长 → compress 覆盖 exit 通知 → 断言重建 wire 上最新状态可见、旧状态被折;未注册车道对照组仍照旧折叠)。完成后回 PR 链接。

  2. ranxianglei commented on Nov 1, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 (ai: vllm-qwen/qwen3.8-27b)

    修好了,PR 已开:#5464(Closes #5458)。

    八问

    1. 是什么问题:OpenCode 切 plan/build 时各发一条一次性状态通知("You are in Plan mode…" / "You are NO LONGER in Plan mode…"),发一次就再也不发;折叠引擎没有任何一类规则认识它们,最新那条一被折掉,会话就永久退回旧状态——事故里模型在切到 build 后 20+ 轮仍在守 plan 模式约束。
    2. 什么原因导致的:保护机制只认少数已知形状(durable 载体、最近一条 user 消息、受保护头部);这两条通知不在任何白名单里,又因为"发过一次就不再重发",丢失即永久。根因是缺少对"一次性状态声明"这一类的识别,不是某次折叠参数不对。
    3. 影响范围与严重程度:所有 opencode 插件车道上发生 plan/build 切换、之后又触发压缩且折叠范围盖住退出通知的会话都会中招;后果是模型长期执行错误约束(体验严重受损,但可人工纠正或新开会话绕过)→ p2。
    4. 怎么修复的:按你建议的 A+C 方向走既有 durable 机制,不发明新东西——新增 opencode 车道分类器,把两条通知投影到同一持久身份 durable/plan-mode。默认 latest-per-identity 策略下只有最新一条声明被钉住(BLOCKED ref,永不折叠),被取代的旧声明正常折叠,可见状态永远收敛到当前状态(形状先例同 dsh runtime-context 的 populated/cleared 对,dsh 车道第三个载体:runtime-context 快照(沙箱/审批策略)同属一次性 baseline user 消息,#2428 的文本标记守卫覆盖不到 #2481)。另加一笔:把该分类器登记进 [Performance][dsh] Mega-sessions pay an ever-growing O(full-history) fold-reconcile tax every turn: expected host churn (one-shot carrier retirement, image/offload retroactive rewrites) misclassified as drift | 大会话每轮付不断增长的 O(全史) fold-reconcile 税:合法宿主变更(o… #5142 的 fold-drift 记账面,否则收敛性折叠本身会把"被取代通知永久退役"误记成每轮漂移。
    5. 是否有回归问题:逐项核过——dsh 及其他车道行为不变(注册表是逐车道证据白名单,控制组测试专门钉死"未注册车道仍照旧折叠");用户自配 durable pattern 的整体接管语义未动;kernel 零改动;无阈值/时序/输出格式漂移。唯一行为变更即第 4 点的 opencode 车道新增保护(已在 PR 描述按旧值→新值披露)。
    6. 是否破坏缓存:不涉及。变的是折叠判定面(最新通知不再可折),线上传输字节本身没变(通知内容照常发送),前缀缓存行为不变;BLOCKED ref 只影响 ref 身份渲染,不改线上字节。
    7. 是否改了设计方向:没有。沿用 KDD release v0.1.2 #9"逐宿主证据白名单"纪律:你方案 C 的"通用检测"我收敛到同一机制的白名单入口(opencode 一条证据、一个分类器),不做无证据的全宿主启用——枚举了发行版二进制的全部 <system-reminder> 模板族,确认这一类在该车道就这两条,注释里留了依据。
    8. 是否引入了新的配置:没有新 env/config/默认值;分类器挂在 withDurableProtection 已有的 durable/* 默认上。

    标签:bug / opencode / scope/kernel / p2(opencode 车道一次性状态通知被折叠致会话持旧状态;有绕行故 p2)。

    查重交叉链接:与 #4921(dsh 指令更新的大小写漏配)、#5173(合成 user 消息抢占保护槽位)同属"宿主注入的状态载体被折叠 → 模型持旧状态"家族,但机制不同(本条是整类缺失,那两条是既有机制的缝隙)且车道不同,保持各自独立处理,均不关闭。

    验证状态:已 rebase 到当前 master(v0.1.211,与你事故版本一致);本地 typecheck/build 干净,定向套件 53/53 绿(新回归测试 5 个场景 + 六个相关兄弟套件 + issue5142 记账套件);全量回归以 CI 为准。等你验收合并。

  3. ranxianglei commented on Nov 1, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 (ai: vllm-qwen/qwen3.8-27b)

    补充:PR #5464 的 CI 已全部绿——主 CI(guards + core 测试矩阵 + build)success,全部 e2e 车道 success,含与本改动直接相关的 e2e-native-opencode(真实 opencode 插件车道 vs 确定性 fake upstream)。mergeable=true,状态 blocked 仅因等待人工合并。等你验收。

  4. added 2 commits that reference this issue on Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions