@zhushanwen/pi-todo 0.9.8 → 0.9.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-todo",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
4
4
  "description": "AI-driven todo list for Pi — stateful task management with session persistence and /todos command.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -29,8 +29,9 @@
29
29
  "vitest": "^4.1.8"
30
30
  },
31
31
  "dependencies": {
32
- "@zhushanwen/extension-protocol": "0.15.0",
33
- "@zhushanwen/pi-extension-logger": "0.6.1"
32
+ "@zhushanwen/pi-extension-logger": "0.6.1",
33
+ "@zhushanwen/extension-protocol": "0.17.0",
34
+ "@zhushanwen/pi-session-path": "0.1.0"
34
35
  },
35
36
  "peerDependencies": {
36
37
  "@earendil-works/pi-coding-agent": "^0.84.4",
@@ -0,0 +1,159 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { reconstructState, registerTodoEventHandlers, type RefreshDisplayFn } from "../handlers";
5
+ import { createTodoSessionState } from "../state";
6
+
7
+ /**
8
+ * U6b:reconstructState 回放接活跃路径裁剪——从 leafId 沿 parentId 回溯得活跃路径,
9
+ * 被撤子树的 todo 快照不得进入重建态(不得经 `<todo_context>` 注入模型上下文)。
10
+ */
11
+
12
+ interface BranchFixtureEntry {
13
+ id: string;
14
+ parentId: string | null;
15
+ todos?: { id: number; text: string; status: string }[];
16
+ nextId?: number;
17
+ }
18
+
19
+ /** todo toolResult 快照 entry(合法 TodoDetails,避免触发脏数据降级路径) */
20
+ function todoEntry(id: string, parentId: string | null, todos: { id: number; text: string; status: string }[], nextId?: number) {
21
+ return {
22
+ type: "message",
23
+ id,
24
+ parentId,
25
+ timestamp: "2026-09-24T00:00:00.000Z",
26
+ message: { role: "toolResult", toolName: "todo", details: { todos, nextId } },
27
+ };
28
+ }
29
+
30
+ /** 撤回后真实形态:label entry 落文件尾(parentId = 回退后叶子),leafId 指向它 */
31
+ function labelEntry(id: string, parentId: string | null) {
32
+ return { type: "label", id, parentId, timestamp: "2026-09-24T00:00:01.000Z", targetId: "u-x", label: "taiji:revoked" };
33
+ }
34
+
35
+ function makeCtx(entries: BranchFixtureEntry[], leafId: string | null): ExtensionContext {
36
+ return {
37
+ sessionManager: {
38
+ getEntries: () => entries,
39
+ getLeafId: () => leafId,
40
+ },
41
+ } as unknown as ExtensionContext;
42
+ }
43
+
44
+ describe("reconstructState 活跃路径裁剪(U6b)", () => {
45
+ it("有分支 fixture:被撤子树的 todo 快照不进重建态——取活跃路径内最后一条", () => {
46
+ // 树:e1(base) → e2(active-branch);e1 → e3(revoked-branch,物理后写=被撤子树);
47
+ // 撤回落 label 锚 L(parentId = 回退后叶子 e2),leafId = L → 活跃路径 {L, e2, e1}
48
+ const entries = [
49
+ todoEntry("e1", null, [{ id: 1, text: "base", status: "pending" }], 2),
50
+ todoEntry("e2", "e1", [{ id: 1, text: "active-branch", status: "in_progress" }], 2),
51
+ todoEntry("e3", "e1", [{ id: 1, text: "revoked-branch", status: "completed" }], 2),
52
+ labelEntry("L", "e2"),
53
+ ];
54
+
55
+ const state = createTodoSessionState();
56
+ reconstructState(state, makeCtx(entries, "L"));
57
+
58
+ expect(state.todos).toHaveLength(1);
59
+ expect(state.todos[0]?.text).toBe("active-branch");
60
+ // 反向断言:被撤子树快照不进重建态
61
+ expect(state.todos.map((t) => t.text)).not.toContain("revoked-branch");
62
+ expect(state.nextId).toBe(2);
63
+ });
64
+
65
+ it("活跃路径内无 todo 快照(撤回点早于全部 todo 操作)→ 重建态为空,不回退到旧分支快照", () => {
66
+ // 树:e1(普通 message entry) → e2(todo 快照,被撤子树);撤回后 leafId 指回 e1
67
+ const entries = [
68
+ { type: "message", id: "e1", parentId: null, timestamp: "t", message: { role: "user", content: "hi" } },
69
+ todoEntry("e2", "e1", [{ id: 1, text: "only-on-revoked-branch", status: "pending" }], 2),
70
+ labelEntry("L", "e1"),
71
+ ];
72
+
73
+ const state = createTodoSessionState();
74
+ reconstructState(state, makeCtx(entries, "L"));
75
+
76
+ expect(state.todos).toHaveLength(0);
77
+ expect(state.nextId).toBe(1);
78
+ });
79
+
80
+ it("无分支回归:leafId = 文件尾 → 全量回放最后一条(现行为不变)", () => {
81
+ const entries = [
82
+ todoEntry("e1", null, [{ id: 1, text: "first", status: "pending" }], 2),
83
+ todoEntry("e2", "e1", [{ id: 1, text: "first", status: "completed" }, { id: 2, text: "second", status: "in_progress" }], 3),
84
+ ];
85
+
86
+ const state = createTodoSessionState();
87
+ reconstructState(state, makeCtx(entries, "e2"));
88
+
89
+ expect(state.todos).toHaveLength(2);
90
+ expect(state.todos[1]?.text).toBe("second");
91
+ expect(state.nextId).toBe(3);
92
+ });
93
+
94
+ it("leafId 防御:null / 指向不存在 entry → 回退文件尾全量回放(不静默清空重建态)", () => {
95
+ const entries = [
96
+ todoEntry("e1", null, [{ id: 1, text: "first", status: "pending" }], 2),
97
+ todoEntry("e2", "e1", [{ id: 1, text: "tail-snapshot", status: "pending" }], 2),
98
+ ];
99
+
100
+ for (const leafId of [null, "missing-id"]) {
101
+ const state = createTodoSessionState();
102
+ reconstructState(state, makeCtx(entries, leafId));
103
+ expect(state.todos).toHaveLength(1);
104
+ expect(state.todos[0]?.text).toBe("tail-snapshot");
105
+ }
106
+ });
107
+ });
108
+
109
+ describe("环状 parentId 防御(损坏文件 / 非 pi 产出写入)", () => {
110
+ it("环状 parentId(a↔b,leafId 指向环内)→ 回溯终止不挂死,重建完成且不抛错", () => {
111
+ // 环:a.parentId=b、b.parentId=a;leafId=a → 回溯 a→b→(二次命中 a) 守卫终止
112
+ const entries = [
113
+ todoEntry("a", "b", [{ id: 1, text: "cycle-a", status: "pending" }], 2),
114
+ todoEntry("b", "a", [{ id: 1, text: "cycle-b", status: "in_progress" }], 2),
115
+ ];
116
+
117
+ const state = createTodoSessionState();
118
+ expect(() => reconstructState(state, makeCtx(entries, "a"))).not.toThrow();
119
+
120
+ // 终止形态(实现语义):activeIds = {a, b},回放链上最后一条 todo 快照 b
121
+ expect(state.todos).toHaveLength(1);
122
+ expect(state.todos[0]?.text).toBe("cycle-b");
123
+ expect(state.nextId).toBe(2);
124
+ });
125
+ });
126
+
127
+ describe("session_tree handler 回归(重建态与裁剪后一致)", () => {
128
+ it("触发 session_tree → state 重建为活跃路径态 + refreshDisplay 调用", async () => {
129
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<unknown>>();
130
+ const pi = {
131
+ on: vi.fn((event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<unknown>) => {
132
+ handlers.set(event, handler);
133
+ }),
134
+ } as unknown as ExtensionAPI;
135
+ const refreshDisplay = vi.fn() as unknown as RefreshDisplayFn;
136
+
137
+ const state = createTodoSessionState();
138
+ // 预填 stale:撤回前的旧分支态(模拟运行中撤回后缓存未失效的窗口)
139
+ state.todos = [{ id: 1, text: "revoked-branch", status: "completed" }];
140
+ state.nextId = 2;
141
+
142
+ registerTodoEventHandlers(pi, state, refreshDisplay);
143
+
144
+ const entries = [
145
+ todoEntry("e1", null, [{ id: 1, text: "base", status: "pending" }], 2),
146
+ todoEntry("e2", "e1", [{ id: 1, text: "active-branch", status: "in_progress" }], 2),
147
+ todoEntry("e3", "e1", [{ id: 1, text: "revoked-branch", status: "completed" }], 2),
148
+ labelEntry("L", "e2"),
149
+ ];
150
+ await handlers.get("session_tree")!({ type: "session_tree" }, makeCtx(entries, "L"));
151
+
152
+ // 与直接调 reconstructState(裁剪语义)一致:活跃路径内最后一条
153
+ expect(state.todos).toHaveLength(1);
154
+ expect(state.todos[0]?.text).toBe("active-branch");
155
+ expect(refreshDisplay).toHaveBeenCalledTimes(1);
156
+ // 纯重建体:不注入消息(session_start 块无此副作用,todo handler 本就不 steer——回归锚)
157
+ expect(handlers.has("session_tree")).toBe(true);
158
+ });
159
+ });
@@ -27,7 +27,7 @@ function todoEntry(todos: Todo[], nextId: number) {
27
27
 
28
28
  function makeCtx(entries: unknown[]) {
29
29
  return {
30
- sessionManager: { getEntries: () => entries },
30
+ sessionManager: { getLeafId: () => null, getEntries: () => entries },
31
31
  } as unknown as Parameters<typeof reconstructState>[1];
32
32
  }
33
33
 
@@ -115,6 +115,7 @@ describe("reconstructState dirty entry degradation", () => {
115
115
  function makeCtx(details: unknown): Parameters<typeof reconstructState>[1] {
116
116
  return {
117
117
  sessionManager: {
118
+ getLeafId: () => null,
118
119
  getEntries: () => [
119
120
  { type: "message", message: { role: "toolResult", toolName: "todo", details } },
120
121
  ],
package/src/handlers.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
  import { getLogger } from "@zhushanwen/pi-extension-logger";
8
+ import { filterActivePath } from "@zhushanwen/pi-session-path";
8
9
 
9
10
  import {
10
11
  migrateTodo,
@@ -50,8 +51,13 @@ export function buildBeforeAgentStartMessage(state: TodoSessionState): { message
50
51
 
51
52
  // ── 状态重建 ────────────────────────────────────────
52
53
 
54
+ // 活跃路径裁剪(leafId 沿 parentId 回溯,撤回后被撤子树的 todo 快照不进重建输入)
55
+ // 收敛于 @zhushanwen/pi-session-path 单一实现(四包同构副本收编,防御语义与回退
56
+ // 口径见该包 filterActivePath 注释;runtime 侧 entry-tree-builder 保持独立——
57
+ // extension 不能 import runtime 包)。
58
+
53
59
  /**
54
- * 回放最后一条 todo toolResult 重建 state(纯读,不修改 entries)。
60
+ * 回放活跃路径内最后一条 todo toolResult 重建 state(纯读,不修改 entries)。
55
61
  *
56
62
  * H1(C2 决策):pi 的 SessionManager.getEntries() 返回的是 filter-copy,原先的
57
63
  * splice GC 段对副本操作无效,且修改传入 entries 是反模式。删除整段 splice,只保留
@@ -65,7 +71,7 @@ export function reconstructState(state: TodoSessionState, ctx: ExtensionContext)
65
71
  state.completionSteered = false;
66
72
  state.pendingSteerMessage = null;
67
73
 
68
- const entries = ctx.sessionManager.getEntries();
74
+ const entries = filterActivePath(ctx.sessionManager);
69
75
 
70
76
  for (let i = 0; i < entries.length; i++) {
71
77
  const entry = entries[i];