Skip to content

feat: preserve todo continuity across compression - #637

Open
feriaref wants to merge 2 commits into
ranxianglei:masterfrom
feriaref:2026-09-08_bili-capsule-retarget
Open

feat: preserve todo continuity across compression#637
feriaref wants to merge 2 commits into
ranxianglei:masterfrom
feriaref:2026-09-08_bili-capsule-retarget

Conversation

@feriaref

@feriaref feriaref commented Sep 8, 2026

Copy link
Copy Markdown

Summary

  • Retargets the active todo-bootstrap-PWF Capsule onto clean origin/master.
  • Adds standalone todo-continuity core and one mechanical server-compression adapter binding.
  • Drops all unrelated worktree diffs and does not reactivate the retired legacy Capsules.

Validation

  • capsulectl sot validate --component billion-context: 0 errors, 0 warnings.
  • Core-only replay: APPLIED; fresh-process core smoke: PASS.
  • Adapter replay: APPLIED; second replay: core ALREADY_PRESENT, adapter NOOP.
  • npm run typecheck: PASS.
  • npm run build: PASS.
  • The local WSL fix-78-connect-timeout failure reproduces on clean origin/master; it is outside this Capsule and outside this diff. Full CI remains the release gate.

@ranxianglei

Copy link
Copy Markdown
Owner

ework 接单

@ranxianglei

Copy link
Copy Markdown
Owner

🤖 Powered by ework · qwen3.8-27b

[review] 已接单并完整复核 #637(todo continuity across compression)。结论:核心设计与正确性 LGTM,合并前建议补上单元测试(已写好,见文末),另有两个小项值得处理。

验证(在干净 origin/master = 5836f78 上重跑)

  • npm run typecheck:PASS
  • npm run build:PASS
  • npm test:除 1 个既有环境相关失败外全绿。该失败是 tests/launcher.test.tsresolveClientCommand: codex/claude resolve to themselves——当 PATH 上装有 codex 可执行文件时,它会解析成绝对路径而非裸命令名,属环境依赖;在干净 master 上同样复现,与本 diff 无关(与你提到的 WSL 失败同性质)。本 PR 未引入任何新的测试回归。

设计评估

  • 核心逻辑正确且幂等。 我针对关键场景逐一验证:todo 对被压缩折叠后 → 注入 carrier;todo 对仍可见 → 不注入;无 todo → no-op;把自身输出回灌 → 稳定、不重复注入;按 revision 取最新(同值按 result index 决胜);畸形结果(缺 revision / 负 revision / 非法 status / 重复 id / parent 缺失或自引用 / 孤儿 result / revision 0 空列表)全部正确拒绝。行为与设计一致。
  • 解耦干净。 src/todo-continuity.ts 是独立模块(仅 import CoreMessage 类型 + node:crypto),server 侧是机械绑定(5 个调用点,都是把 stripKernelSummaries(...) 的输出再包一层 biliEnsureTodoContinuity(..., originalMessages))。
  • 两种压缩模式都覆盖。 carrier 每轮从原始历史重新计算,且是临时的(不落 session state、不进 client 重发历史、不碰 kernel 的 ref 空间——其 id 用 bili_todo_continuity_ 前缀,与 kernel 的 content-hash / mNNNNN 不冲突)。plugin 模式与 proxy 模式都安全;"call+result 仍可见"的短路避免了 todo 对尚未被折叠时的重复注入。

合并前建议关闭的缺口

  1. 缺单元测试(主要缺口)。 255 行新逻辑在仓库测试套件(1240 例)里零覆盖。你的 capsulectl replay/smoke 有价值,但本仓库的契约是 npm test。我已写好测试文件,覆盖:快照选择、畸形拒绝、carrier 渲染(含超尺寸截断路径)、注入/不注入、幂等、stale carrier 被新 revision 取代——11 例全过,直接对着你的代码跑通。放在 tests/todo-continuity.test.ts 即可(全文见文末折叠块)。
  2. carrier 文案硬编码 "Hermes"。 工具匹配用的是通用的 todo_list,但注入文案写的是 "replayed Hermes todo_list state data"。若未来有非 Hermes 客户端也暴露 todo_list 工具,会误导模型。建议二选一:(a) 改成通用措辞 "replayed todo_list state data"(我倾向这个,一词之改,保持模块 client-agnostic);(b) 把该特性 gate 到 Hermes 客户端。
  3. 契约未文档化。 该特性仅在 todo_list 结果是含非负整数 revision + todos 数组的 JSON 时才生效(每项 id/content/status∈{pending,in_progress,completed,cancelled},可选 parent)。这是对客户端工具结果形状的硬依赖,建议在 PR 描述或 README 里补一句,方便后续客户端作者知道期望形状。

建议

核心设计与正确性 LGTM。请合并前补上 #1 的测试(已提供);#2#3 较小但值得处理(#2 建议一词通用化改写,#3 建议补一句文档)。

tests/todo-continuity.test.ts(11 例,全过)
import test from "node:test";
import assert from "node:assert/strict";
import type { CoreMessage } from "acp-kernel";
import {
    BILI_TODO_CONTINUITY_HEADER,
    BILI_TODO_CONTINUITY_END,
    BILI_TODO_CONTINUITY_PREFIX,
    biliEnsureTodoContinuity,
    biliTodoLatestSnapshot,
    biliTodoRenderCarrier,
} from "../src/todo-continuity.ts";

function todoCall(id: string, toolCallId: string): CoreMessage {
    return { id, role: "assistant", contentType: "tool-call", toolName: "todo_list", toolCallId, text: "" };
}
function todoResult(id: string, toolCallId: string, body: unknown): CoreMessage {
    return { id, role: "user", contentType: "tool-result", toolCallId, text: JSON.stringify(body) };
}
function userText(id: string, text: string): CoreMessage {
    return { id, role: "user", contentType: "text", text };
}

const REV3 = { revision: 3, todos: [
    { id: "a", content: "do A", status: "in_progress" },
    { id: "b", content: "do B", status: "pending", parent: "a" },
] };

test("latest snapshot: picks highest revision, tie-breaks by result index", () => {
    const src = [
        todoCall("c1", "t1"), todoResult("r1", "t1", { revision: 3, todos: [{ id: "a", content: "A", status: "pending" }] }),
        todoCall("c2", "t2"), todoResult("r2", "t2", { revision: 5, todos: [{ id: "c", content: "C", status: "completed" }] }),
        todoCall("c3", "t3"), todoResult("r3", "t3", { revision: 5, todos: [{ id: "d", content: "D", status: "pending" }] }),
    ];
    const snap = biliTodoLatestSnapshot(src);
    assert.equal(snap?.callId, "t3");
    assert.equal(snap?.revision, 5);
});

test("latest snapshot: undefined with no todo_list tool", () => {
    assert.equal(biliTodoLatestSnapshot([userText("u", "hi")]), undefined);
});

test("latest snapshot: rejects malformed results", () => {
    const cases: { name: string; messages: CoreMessage[] }[] = [
        { name: "missing revision", messages: [todoCall("c", "t"), todoResult("r", "t", { todos: [] })] },
        { name: "negative revision", messages: [todoCall("c", "t"), todoResult("r", "t", { revision: -1, todos: [] })] },
        { name: "invalid status", messages: [todoCall("c", "t"), todoResult("r", "t", { revision: 1, todos: [{ id: "a", content: "A", status: "bogus" }] })] },
        { name: "duplicate ids", messages: [todoCall("c", "t"), todoResult("r", "t", { revision: 1, todos: [{ id: "a", content: "A", status: "pending" }, { id: "a", content: "A2", status: "pending" }] })] },
        { name: "parent not present", messages: [todoCall("c", "t"), todoResult("r", "t", { revision: 1, todos: [{ id: "a", content: "A", status: "pending", parent: "zz" }] })] },
        { name: "parent self-reference", messages: [todoCall("c", "t"), todoResult("r", "t", { revision: 1, todos: [{ id: "a", content: "A", status: "pending", parent: "a" }] })] },
        { name: "orphan result without call", messages: [todoResult("r", "t", REV3)] },
        { name: "empty todos at revision 0", messages: [todoCall("c", "t"), todoResult("r", "t", { revision: 0, todos: [] })] },
    ];
    for (const c of cases) {
        assert.equal(biliTodoLatestSnapshot(c.messages), undefined, c.name);
    }
});

test("render carrier: wraps small payload in header/end markers", () => {
    const snap = biliTodoLatestSnapshot([todoCall("c", "t"), todoResult("r", "t", REV3)]);
    assert.ok(snap);
    const text = biliTodoRenderCarrier(snap);
    assert.ok(text.startsWith(BILI_TODO_CONTINUITY_HEADER + "\n"));
    assert.ok(text.endsWith("\n" + BILI_TODO_CONTINUITY_END));
    assert.ok(text.includes('"revision":3'));
    assert.ok(text.includes('"do A"'));
});

test("render carrier: oversized payload truncates to active todos + ancestors", () => {
    const big = (id: string, status: string, parent?: string) => ({ id, content: "x".repeat(4000), status, ...(parent ? { parent } : {}) });
    const todos = [
        big("root", "completed"),
        big("active", "in_progress", "root"),
        big("done1", "completed", "root"),
        big("done2", "completed", "root"),
        big("done3", "completed", "root"),
        big("done4", "completed", "root"),
        big("done5", "completed", "root"),
        big("done6", "completed", "root"),
        big("done7", "completed", "root"),
        big("done8", "completed", "root"),
    ];
    const snap = biliTodoLatestSnapshot([todoCall("c", "t"), todoResult("r", "t", { revision: 1, todos })]);
    assert.ok(snap);
    const text = biliTodoRenderCarrier(snap);
    assert.ok(text.length <= 32768, `carrier ${text.length} exceeds cap`);
    const firstNl = text.indexOf("\n", BILI_TODO_CONTINUITY_HEADER.length);
    const secondNl = text.indexOf("\n", firstNl + 1);
    const body = text.slice(secondNl + 1, text.lastIndexOf("\n" + BILI_TODO_CONTINUITY_END));
    const parsed = JSON.parse(body) as { todos: { id: string }[]; truncated?: boolean };
    assert.ok(parsed.todos.some((t) => t.id === "active"), "active todo kept");
    assert.ok(parsed.todos.some((t) => t.id === "root"), "ancestor of active todo kept");
    assert.ok(parsed.truncated === true || parsed.todos.length < todos.length);
});

test("ensure: injects carrier when todo pair was compressed away", () => {
    const source = [todoCall("c", "t"), todoResult("r", "t", REV3), userText("u", "continue")];
    const view = [userText("u", "continue")];
    const out = biliEnsureTodoContinuity(view, source);
    assert.equal(out.length, 1);
    assert.ok(out[0].text!.startsWith(BILI_TODO_CONTINUITY_HEADER), "carrier merged in front of user text");
    assert.ok(out[0].text!.endsWith("continue"), "original user text preserved after carrier");
});

test("ensure: no carrier when todo pair still visible", () => {
    const source = [todoCall("c", "t"), todoResult("r", "t", REV3), userText("u", "continue")];
    const out = biliEnsureTodoContinuity(source, source);
    assert.deepEqual(out, source, "unchanged when call+result present");
});

test("ensure: no-op when there is no todo state", () => {
    const view = [userText("u", "hi")];
    assert.deepEqual(biliEnsureTodoContinuity(view, view), view);
});

test("ensure: idempotent — re-feeding its own output does not double-inject", () => {
    const source = [todoCall("c", "t"), todoResult("r", "t", REV3), userText("u", "continue")];
    const once = biliEnsureTodoContinuity([userText("u", "continue")], source);
    const twice = biliEnsureTodoContinuity(once, source);
    assert.deepEqual(twice, once, "stable across re-feed");
    assert.equal(twice[0].text!.split(BILI_TODO_CONTINUITY_HEADER).length - 1, 1, "exactly one carrier header");
});

test("ensure: appends carrier when no user text message exists", () => {
    const source = [todoCall("c", "t"), todoResult("r", "t", REV3)];
    const view: CoreMessage[] = [{ id: "a1", role: "assistant", contentType: "text", text: "working" }];
    const out = biliEnsureTodoContinuity(view, source);
    assert.equal(out.length, 2);
    assert.ok(out[1].text!.startsWith(BILI_TODO_CONTINUITY_HEADER));
    assert.ok(out[1].id.startsWith(BILI_TODO_CONTINUITY_PREFIX));
});

test("ensure: supersedes a stale carrier when the source revision advances", () => {
    const staleSource = [todoCall("c0", "t0"), todoResult("r0", "t0", { revision: 1, todos: [{ id: "z", content: "old", status: "pending" }] }), userText("u", "continue")];
    const withStale = biliEnsureTodoContinuity([userText("u", "continue")], staleSource);
    const advanced = [todoCall("c", "t"), todoResult("r", "t", REV3), userText("u", "continue")];
    const out = biliEnsureTodoContinuity(withStale, advanced);
    assert.equal(out[0].text!.split('"revision":1').length - 1, 0, "stale revision carrier removed");
    assert.ok(out[0].text!.includes('"revision":3'), "current revision carrier present");
});

@ranxianglei

Copy link
Copy Markdown
Owner

简述这个是什么功能 并补充对应 issue
测试你来改

@ranxianglei

Copy link
Copy Markdown
Owner

🤖 Powered by ework · qwen3.8-27b

三件事都办好了:

1. 这个功能是什么

解决「压缩后 todo 状态丢失」的问题。

当客户端的 todo_list 工具调用+结果被上下文压缩折叠掉之后,模型就看不到当前哪些 todo 还是 pending / in_progress 了,于是可能在压缩边界后重新规划、丢掉进行中的工作、或重复已完成步骤。

本 PR 的做法:当 todo_list 的 call+result 对被压缩折叠、从可见历史中消失时,把最新的 todo 状态作为一段临时的 carrier 文本重新注入,让模型保留当前 todo 上下文。

  • 快照选择:从原始历史里取最新的 todo_list 结果(最高 revision,同值按 result index 决胜);畸形结果(缺/负 revision、非法 status、重复 id、parent 悬空或自引用、孤儿 result、revision 0 空列表)一律拒绝。
  • carrier 渲染:渲染成有界文本块(≤ 32768 字符);超尺寸时截断为「活跃 todo + 其祖先」。
  • 注入:合并到最后一条 user 文本消息前面(没有则追加);若 todo 对仍可见则短路不注入,避免重复。
  • 幂等 + 临时:carrier 每轮从原始历史重算,不落 session state、不进 client 重发历史,plugin / proxy 两种压缩模式都安全,也不碰 kernel 的 message-id / ref 空间。

2. 对应 issue

已补:#639(feature issue:问题 / 方案 / 契约 / 实现)。

3. 测试(我来改,已落地到本 PR)

已在 PR 分支提交并推送:commit c5aa321test: add todo-continuity unit tests),新增 tests/todo-continuity.test.ts11 例全过——覆盖快照选择、畸形拒绝、carrier 渲染(含超尺寸截断)、注入/不注入、幂等、stale carrier 被新 revision 取代。PR 现为 2 个 commit、mergeable。

Fixes #639

@ranxianglei

Copy link
Copy Markdown
Owner

参考我名下的 opencode-acp 项目 应该对类似消息实施保护 而不是压缩后召回。
方案需要重新设计 对齐 opencode-acp 原语义

另外我理解类似保护已经存在。

todo 消息保护特殊性在于只保护最近一条即可

可能内核需要协同改动甚至改动仅仅发生在内核
额外提供可配置选项

@ranxianglei

Copy link
Copy Markdown
Owner

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 收到,方向明确:改成「保护」而非「压缩后召回」,对齐 opencode-acp 原语义。先确认现状,再给方案,最后是需要你拍板的点。这一轮我只做设计分析,未动代码。

1. 现状确认:保护机制已经存在(你的判断对)

acp-kernel(billion-context 内联的压缩内核)已经有 opencode-acp 那套「保护而非召回」的机制:

  • 硬保护 protectedTools:被列名的工具调用 + 其配对 tool-result 从可压缩集合里硬排除——不进新 block 的 effectiveMessageIds,永远可见、永不被折叠进 summary。这正是 opencode-acp 的 Bug 39 修复。
  • 软保护 recent zone preserveRecentMessages:最近 N 条(含最近一条 user 消息)排除在压缩范围外,但不会让整段范围失败。

billion-context 侧:preserveRecentMessages 已作为配置暴露(src/config.ts:99src/compress-settings.ts:166 三级合并、src/preflight.ts #330 的 relax 逻辑);内核的硬 protectedTools 排除始终生效(preflight 注释 "hard protectedTools remain excluded"),但目前没暴露成用户可配置项

2. 差距:「只保护最近一条」现有机制给不了

  • protectedTools工具名保护 → 会保护所有 todo_list 结果(旧快照也永不压缩,冗余、吃上下文)。
  • preserveRecentMessages最近 N 条(任意类型) → 保护面太宽,N 得设够大才能总罩住最近一条 todo。

而 todo 状态是累积快照(revision N 含当前完整状态),旧 todo 结果严格冗余,只有最新一条是 source of truth。所以「只保护最近一条」既正确又高效——但这不是上面两个机制能直接表达的。

3. 推荐方案(对齐 opencode-acp 语义)

内核加一个「最近一条」保护模式 + 宿主暴露配置:

  1. acp-kernel 新增保护模式:对给定工具只保护最近一条(call + 配对 result),复用现有硬排除机制(从可压缩集合 + effectiveMessageIds 剔除)。建议配置 compress.protectedLatestTools: string[],与现有 protectedTools(=全部)并列。逻辑落在内核,宿主只传配置——正对应你说的「改动仅仅发生在内核」。
  2. billion-context 暴露可配置选项(走现有三级 compress 配置合并 + per-provider/model 覆盖):如 compress.protectTodo: boolean(默认开)映射到内核 protectedLatestTools: ["todo_list"],工具名也可配。
  3. 移除 PR feat: preserve todo continuity across compression #637 的召回/carrier 方案(src/todo-continuity.ts + 5 个 server 调用点 + 我上轮加的 tests/todo-continuity.test.ts)——保护取代召回。

跨仓库代价(重要):按 AGENTS.md 跨仓依赖,acp-kernel 必须先发版,billion-context 才能 bump。顺序:acp-kernel 加 protectedLatestTools 并发版 → billion-context bump 内核、接配置、删 carrier、更新 #637

4. 需要你拍板

  1. 确认「只保护最近一条」+ 内核改动路线(而非用现有 protectedTools 保护所有 todo_list 的免内核改动折中——那个能立刻上,但会 over-protect 冗余旧快照)。
  2. 配置命名/形状:我倾向内核 protectedLatestTools + 宿主 compress.protectTodo: boolean(默认开)。你定。
  3. 现在动内核(跨仓、多一次发版)还是先上「保护所有 todo_list」临时版顶住、「最近一条」随后?

我的建议:走内核路线、一次到位(避免临时版返工),并同步把 #639 的描述从「召回」改成「保护」。你确认后我按 acp-kernel → billion-context 顺序开做。

@ranxianglei

Copy link
Copy Markdown
Owner

建议增加按照工具名字的保护,而不是 compress.protectTodo,
每个保护都提供几个选择
1.硬保护
2.软保护

所有配置通用化

@ranxianglei

Copy link
Copy Markdown
Owner

继续

@ranxianglei

Copy link
Copy Markdown
Owner

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 继续完成——内核侧已做完,PR 已开:acp-kernel#223

本轮做了什么

按 floor 14 的定调(按工具名保护、硬/软两档、配置通用化)在内核实现了「最近一条」保护:

acp-kernel PR #223(open,branch 2026-09-09_protected-latest-tools,基于 master v0.0.59):

  • 新增 Config.protectedLatestTools(可选,string[],支持 glob 后缀):对每个模式,只保护最近一条匹配的 tool-call + 其配对 tool-result;旧实例保持可压缩。这就是「软保护」的内核半边;「硬保护」= 既有 protectedTools(保护全部实例)。宿主侧的通用配置将映射到这两个内核机制。
  • 被取代的旧对保留 BLOCKED ref(内核不变量:ref 永不重发),但 apply 时按当前配置重判 → 一旦出现更新的 todo_list 结果,旧对正常折叠进 block。测试覆盖了这个取代路径。
  • 接入所有保护点,保证 recommend / apply / absorb 三方一致:ref 分配(BLOCKED)、range apply(从 block 的 direct+effective 排除,Bug 39 语义——保护消息不能被 block 覆盖藏掉)、recommend(nudge 里显示为 protected range)、absorb(最新受保护结果不可 absorb)。
  • isAbsorbCandidate 新增可选第三参,既有 2 参调用不受影响;protectedLatestToolsConfig 上为可选字段(默认 []),宿主手写 Config 字面量不会挂。
  • Pre-flight:typecheck PASS;637/637 测试(624 既有 + 13 新增 tests/protected-latest.test.ts:最新对选择含 glob/无 toolCallId、BLOCKED 分配、range 排除 vs 旧对折叠、取代折叠、硬/软对比、recommend 切分、absorb 拒绝/接受);build PASS。

下一步(需要你操作)

跨仓规则:内核必须先发版(billion-context 精确 pin + CI npm ci,pin 到未发版版本会直接挂 CI):

  1. 你 merge acp-kernel#223(merge 只能人工)
  2. 我准备内核 release v0.0.60(release PR,流程同 §5)
  3. 0.0.60 上 npm 后,我开 billion-context PR:bump 内核 + 新增通用配置 compress.toolProtection: { <toolName>: "hard" | "soft" | "off" }(三级合并,默认 todo_list: "soft")+ 移除本 PR 的 carrier 方案src/todo-continuity.ts + server 5 个调用点 + 测试)+ 文档
  4. feat: preserve todo continuity across context compression #639 已同步更新为保护语义(见该 issue 最新回复)

本 PR 的 carrier 实现先保持原样不动,等保护方案落地后由 billion-context 侧 PR 移除。

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.

2 participants