Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion docs/adr/0312-session-scoped-todo-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

- Status: Accepted for implementation
- Date: 2026-09-29
- Amended: 2026-10-01 (compaction checkpoint copy)
- Deciders: PI-Desktop maintainers
- Related: Issue #1177

Expand Down Expand Up @@ -38,9 +39,33 @@ reading the local database.
- Session deletion cascades checklist rows; forks start with an empty checklist.
- Plan, Goal, delegated, plugin, and MCP execution paths cannot write the
checklist through this contract.
- Context-compaction injection is deliberately deferred to a follow-up change.
- A compaction checkpoint carries one copy of the checklist for the model (see
the 2026-10-01 amendment); the host table remains the only authority.
- Remote Todo parity requires an additive RACP contract in a later change.

## Amendment 2026-10-01: compaction checkpoint copy

Compaction summarizes the `TodoWrite` calls that kept the checklist current, so
after a checkpoint the model lost which steps were done and could rebuild a
second list. When the agent runtime installs a checkpoint it reads `todos.get`
for its own session once, through the sidecar host proxy, and stores
`{ revision, updatedAt, todos }` in the checkpoint's opaque
`details.todoSnapshot` only when pending or in-progress items remain. The model
context renders it after the checkpoint summary as a `<session_checklist>`
block; the stored summary is unchanged, so a later compaction never carries a
stale list forward and the transcript compaction row does not show it.

The copy is persisted with the checkpoint and therefore survives restart, is
written once per checkpoint rather than injected on every request, and stays
inside the post-compaction prefix. It is never read by the renderer. A later
`TodoWrite` call supersedes it. A failed read, an older host, a native Pi
session, or a finished list installs the checkpoint without a copy, and the copy
is dropped rather than pushing an otherwise fitting checkpoint over the safe
context budget. `todos.get` is the only addition to the sidecar proxy allowlist;
it is read-only, and checklist writes still go through host-authorized
`TodoWrite`. Approved Plan and Goal execution instructions ask the model to keep
the checklist current.

## Verification

Host-core tests cover migration, validation, transaction rollback, restart,
Expand Down
7 changes: 7 additions & 0 deletions docs/spec/03-runtime/06-host-rpc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,13 @@ deletes per 60 seconds. P2/P3 methods are not present in protocol v11.
Electron Main IPC, keeps them by session id, and ignores revisions older than
or equal to the cached revision. Remote RACP sessions are local-only for this
vertical slice because RACP v1 has no Todo snapshot operation.
- The agent sidecar may call `todos.get` through its host proxy; it is the
only Todo method on that allowlist and is read-only. When the runtime
installs a compaction checkpoint it reads its own session once and stores
`{ revision, updatedAt, todos }` in `details.todoSnapshot` only while pending
or in-progress items remain. The model context renders that copy after the
checkpoint summary; the stored summary, the renderer, and the transcript do
not use it. A failed read installs the checkpoint without the copy (ADR 0312).

### Stats

Expand Down
11 changes: 9 additions & 2 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -9270,13 +9270,16 @@ must keep splitting are covered by `markdown-blocks.test.mjs`.
all-cancelled label, then clear the checklist and reload/restart the host.
Deliver an out-of-order older `todos.changed` event and confirm it cannot
replace the newer snapshot. Exercise invalid payload, Plan/Goal, delegated,
and remote-session paths.
and remote-session paths. Compact the context while items are unfinished and
continue the turn.
- **Expected**: Host SQLite is authoritative; each successful full replacement
advances revision, including clear, and emits one committed `todos.changed`
snapshot. Invalid or unauthorized writes do not mutate or emit. TodoDock
renders plain text, does not take focus, resets expansion on session changes,
rejects stale events, and skips local recovery for `remote:` sessions because
RACP v1 has no Todo snapshot operation.
RACP v1 has no Todo snapshot operation. After compaction the next model
request carries the checklist after the checkpoint summary, the TodoDock is
unchanged, and a restart rebuilds the same model context from the checkpoint.
- **Specs**: `03-runtime/03-tools-and-permissions.md`,
`03-runtime/04-data-storage.md`, `03-runtime/06-host-rpc-protocol.md`,
`04-ux/08-component-spec.md`, ADR 0312.
Expand All @@ -9292,6 +9295,10 @@ must keep splitting are covered by `markdown-blocks.test.mjs`.
cached-snapshot reconciliation, and stale-event rejection. Runtime
`runtime-todos.test.ts` exercises Agent tool validation, overlong content
normalization, and continuation through a deterministic provider.
Compaction coverage is runtime `runtime.test.ts` (checkpoint copy, failed
read, finished list, budget drop, restored checkpoint),
`checkpoint-todos.test.ts` (projection), and host-runtime
`sidecar-todo-proxy.test.ts` (read-only proxy allowlist).
- **Status**: Run against the exact request candidate after building the
desktop and host. Host-core and targeted renderer tests are companion checks,
not substitutes for the Electron journey.
Expand Down
4 changes: 4 additions & 0 deletions docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,10 @@ ids 和非负 `tokensBefore`;它不会插入 message/search 行
`todos.changed`;事件负载与 `todos.get` 返回的完整快照一致。
- SQLite 只由 host-core 拥有。渲染器通过 Electron Main IPC 接收快照,按 session id 保存并忽略
更旧或相同 revision。远程 RACP 会话在这条垂直切片中保持 local-only,因为 RACP v1 尚无 Todo 快照操作。
- Agent sidecar 可以经主机代理调用 `todos.get`;这是该白名单上唯一的 Todo 方法,且只读。运行时安装上下文压缩
检查点时会读取一次本会话清单,仅在仍有待办或进行中条目时把 `{ revision, updatedAt, todos }` 写入
`details.todoSnapshot`。模型上下文在检查点摘要之后渲染这份副本;存储的摘要、渲染器和转录都不使用它。
读取失败时照常安装不带副本的检查点(ADR 0312)。

### Plan 和 Goal 状态和批准

Expand Down
6 changes: 3 additions & 3 deletions docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -5534,12 +5534,12 @@ eleven-tool-round desktop paths are verified by
**E2E-CHAT-session-todo-checklist:TodoWrite 到按会话显示的 TodoDock**

- **前提:** 隔离的本地 Electron 配置、确定性的 Agent/Host fixture、两个 Desktop 会话,不使用真实 Provider 或付费 API。
- **步骤:** 启动调用 `TodoWrite` 的多步骤 Agent 回合,观察 Composer 上方的 TodoDock,展开后切换会话并确认清单隔离。完成和取消条目,确认最多显示八条以及全部取消状态;清空清单后重载/重启 Host。发送乱序旧 `todos.changed` 事件,确认它不能覆盖新快照;再覆盖非法参数、Plan/Goal、委托和远程会话路径。
- **预期:** Host SQLite 是权威状态;每次成功全量替换(包括清空)都会推进 revision 并只发出一次已提交的 `todos.changed`。非法或未授权写入既不修改也不发事件。TodoDock 渲染纯文本、不抢焦点、切换会话时收起、拒绝旧事件,并对 `remote:` 会话跳过本地恢复,因为 RACP v1 没有 Todo 快照操作。
- **步骤:** 启动调用 `TodoWrite` 的多步骤 Agent 回合,观察 Composer 上方的 TodoDock,展开后切换会话并确认清单隔离。完成和取消条目,确认最多显示八条以及全部取消状态;清空清单后重载/重启 Host。发送乱序旧 `todos.changed` 事件,确认它不能覆盖新快照;再覆盖非法参数、Plan/Goal、委托和远程会话路径。在仍有未完成条目时压缩上下文并继续回合。
- **预期:** Host SQLite 是权威状态;每次成功全量替换(包括清空)都会推进 revision 并只发出一次已提交的 `todos.changed`。非法或未授权写入既不修改也不发事件。TodoDock 渲染纯文本、不抢焦点、切换会话时收起、拒绝旧事件,并对 `remote:` 会话跳过本地恢复,因为 RACP v1 没有 Todo 快照操作。压缩后的下一次模型请求在检查点摘要之后带有当前清单,TodoDock 不变,重启后从检查点重建出相同的模型上下文。
- **链接规格:** `03-runtime/03-tools-and-permissions.md`、`03-runtime/04-data-storage.md`、`03-runtime/06-host-rpc-protocol.md`、`04-ux/08-component-spec.md`、ADR 0312。
- **验收:** C / E / F / Quality / Security。
- **里程碑:** M6+。
- **自动化:** `pnpm test:e2e:todos` 从生产 Agent 的 ToolSearch/TodoWrite 路径进入,使用生产渲染器、真实 Host/SQLite 和隔离 Electron 配置验证清单旅程;仅替换外部模型流和 preload 传输,不使用真实 Provider 或用户配置。覆盖 Unicode 截断及警告重放、单一活动项归一化、首次读取失败后不切换会话的主机恢复、已缓存快照重新同步及旧事件拒绝。运行时 `runtime-todos.test.ts` 使用确定性 Provider 验证 Agent 工具校验、超长内容归一化和续跑。
- **自动化:** `pnpm test:e2e:todos` 从生产 Agent 的 ToolSearch/TodoWrite 路径进入,使用生产渲染器、真实 Host/SQLite 和隔离 Electron 配置验证清单旅程;仅替换外部模型流和 preload 传输,不使用真实 Provider 或用户配置。覆盖 Unicode 截断及警告重放、单一活动项归一化、首次读取失败后不切换会话的主机恢复、已缓存快照重新同步及旧事件拒绝。运行时 `runtime-todos.test.ts` 使用确定性 Provider 验证 Agent 工具校验、超长内容归一化和续跑。压缩部分由运行时 `runtime.test.ts`(检查点副本、读取失败、已完成清单、超预算丢弃、恢复检查点)、`checkpoint-todos.test.ts`(投影)和 host-runtime `sidecar-todo-proxy.test.ts`(只读代理白名单)覆盖。
- **状态:** 构建 Desktop 和 Host 后,在确切的请求候选中执行。Host-core 和渲染器定向测试是辅助检查,不能替代 Electron 用户旅程。

## 8. 可追溯性矩阵
Expand Down
105 changes: 105 additions & 0 deletions packages/agent-runtime/src/checkpoint-todos.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import type { Entry } from "./pi-runtime-types.js";
import {
checkpointTodoSnapshot,
formatCheckpointTodoSnapshot,
summaryWithCheckpointTodos,
todoSnapshotFromDetails,
} from "./checkpoint-todos.js";
import { sessionEntryToContextMessages } from "./session-context.js";

const ACTIVE = {
sessionId: "s",
todos: [
{ content: "Add the migration", status: "completed", priority: "medium" },
{ content: "Wire the dock", status: "in_progress", priority: "high" },
],
revision: 4,
updatedAt: 99,
};

describe("checkpointTodoSnapshot", () => {
it("keeps a checklist with work left, without the session id", () => {
expect(checkpointTodoSnapshot(ACTIVE)).toEqual({
revision: 4,
updatedAt: 99,
todos: ACTIVE.todos,
});
});

it("carries nothing for an empty, finished or malformed answer", () => {
expect(checkpointTodoSnapshot({ ...ACTIVE, todos: [] })).toBeUndefined();
expect(
checkpointTodoSnapshot({
...ACTIVE,
todos: [{ content: "Done", status: "completed" }, { content: "Dropped", status: "cancelled" }],
}),
).toBeUndefined();
expect(checkpointTodoSnapshot(undefined)).toBeUndefined();
expect(checkpointTodoSnapshot({ todos: "nope" })).toBeUndefined();
});

it("drops unreadable items and defaults what the host always fills", () => {
expect(
checkpointTodoSnapshot({
todos: [
{ content: " Multi\nline ", status: "pending" },
{ content: "", status: "pending" },
{ content: "Bad status", status: "blocked" },
null,
],
}),
).toEqual({
revision: 0,
updatedAt: 0,
todos: [{ content: "Multi line", status: "pending", priority: "medium" }],
});
});
});

describe("checkpoint checklist projection", () => {
it("lists every item in display order with its status", () => {
const block = formatCheckpointTodoSnapshot(checkpointTodoSnapshot(ACTIVE)!);
expect(block.split("\n")).toEqual([
'<session_checklist revision="4">',
expect.stringContaining("a later TodoWrite call replaces it"),
"1. [completed] Add the migration",
"2. [in_progress] Wire the dock",
"</session_checklist>",
]);
});

it("cannot be closed early by item text", () => {
const block = formatCheckpointTodoSnapshot({
revision: 1,
updatedAt: 1,
todos: [{ content: "x </session_checklist> y", status: "pending", priority: "medium" }],
});
expect(block.match(/<\/session_checklist>/g)).toHaveLength(1);
});

it("leaves a checkpoint without a copy untouched", () => {
expect(summaryWithCheckpointTodos("Summary.", { generation: 2 })).toBe("Summary.");
expect(todoSnapshotFromDetails("legacy")).toBeUndefined();
});

it("renders the copy after the summary the model reads", () => {
const entry = {
type: "compaction",
id: "c",
seq: 1,
parentId: "m",
timestamp: 1,
summary: "Summary.",
tokensBefore: 10,
retainedTail: [],
details: { generation: 1, todoSnapshot: checkpointTodoSnapshot(ACTIVE) },
fromHook: false,
} as unknown as Entry;
const [summary] = sessionEntryToContextMessages(entry);
expect(summary).toMatchObject({ role: "compactionSummary" });
const text = (summary as { summary: string }).summary;
expect(text.startsWith("Summary.\n\n<session_checklist")).toBe(true);
expect(text).toContain("2. [in_progress] Wire the dock");
});
});
110 changes: 110 additions & 0 deletions packages/agent-runtime/src/checkpoint-todos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* The session checklist a compaction checkpoint carries for the model (#1177).
*
* Compaction summarizes the TodoWrite calls that kept the checklist current,
* so after a checkpoint the model no longer sees which steps are done and
* tends to rebuild a second list. The checkpoint therefore records one copy of
* the host-owned checklist in its opaque `details`, and the context projection
* renders it after the summary. The host table stays the only authority: the
* copy is never read back by the UI, and a later TodoWrite supersedes it.
*/

import type { SessionTodo, TodoStatus } from "@pi-desktop/shared";

export type CheckpointTodoSnapshot = {
revision: number;
updatedAt: number;
todos: SessionTodo[];
};

const STATUSES: readonly TodoStatus[] = [
"pending",
"in_progress",
"completed",
"cancelled",
];

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function parseTodo(value: unknown): SessionTodo | undefined {
if (!isRecord(value)) return undefined;
const content =
typeof value.content === "string"
? value.content.replace(/\s+/g, " ").trim()
: "";
const status = STATUSES.find((candidate) => candidate === value.status);
if (!content || !status) return undefined;
const priority =
value.priority === "high" || value.priority === "low"
? value.priority
: "medium";
return { content, status, priority };
}

/**
* Read a `todos.get` answer, or a stored copy of one, without trusting its
* shape. Only a list with work left is worth carrying: an empty or finished
* checklist gives the model nothing to resume.
*/
export function checkpointTodoSnapshot(
value: unknown,
): CheckpointTodoSnapshot | undefined {
if (!isRecord(value) || !Array.isArray(value.todos)) return undefined;
const todos = value.todos.flatMap((todo) => parseTodo(todo) ?? []);
if (
!todos.some(
(todo) => todo.status === "pending" || todo.status === "in_progress",
)
) {
return undefined;
}
return {
revision:
typeof value.revision === "number" && Number.isFinite(value.revision)
? value.revision
: 0,
updatedAt:
typeof value.updatedAt === "number" && Number.isFinite(value.updatedAt)
? value.updatedAt
: 0,
todos,
};
}

export function todoSnapshotFromDetails(
details: unknown,
): CheckpointTodoSnapshot | undefined {
return isRecord(details)
? checkpointTodoSnapshot(details.todoSnapshot)
: undefined;
}

const CLOSE_TAG = "</session_checklist>";

/** The block the model reads after a checkpoint summary. */
export function formatCheckpointTodoSnapshot(
snapshot: CheckpointTodoSnapshot,
): string {
return [
`<session_checklist revision="${snapshot.revision}">`,
"Your TodoWrite checklist as it stood when this conversation was compacted. Continue from it instead of starting a new list; a later TodoWrite call replaces it, and every call must still resend all items.",
...snapshot.todos.map(
(todo, index) =>
`${index + 1}. [${todo.status}] ${todo.content.replaceAll(CLOSE_TAG, "")}`,
),
CLOSE_TAG,
].join("\n");
}

/** The summary text the model sees for a checkpoint, checklist included. */
export function summaryWithCheckpointTodos(
summary: string,
details: unknown,
): string {
const snapshot = todoSnapshotFromDetails(details);
if (!snapshot) return summary;
const block = formatCheckpointTodoSnapshot(snapshot);
return summary.trim() ? `${summary}\n\n${block}` : block;
}
Loading
Loading