@zhushanwen/pi-todo 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -1,45 +1,108 @@
1
1
  # todo
2
2
 
3
- 轻量级 AI 任务清单 三态(pending / in_progress / completed),支持 session 持久化、状态栏、批量操作。
3
+ 轻量三态任务清单`pending` / `in_progress` / `completed`。支持 session 持久化、状态栏、双列 widget、`/todos` TUI 视图,以及延迟 steer 驱动任务推进。
4
4
 
5
- ## 功能
5
+ ## 设计定位
6
6
 
7
- - **三态任务**:`pending` `in_progress` `completed`
8
- - **批量操作**:add / update / delete / clear
9
- - **Session 持久化**:任务状态保存在 session entries 中,重启后恢复
10
- - **状态栏**:底部显示任务进度(如 `2/5 done`)
11
- - **自动清理**:所有任务完成后自动清空
7
+ | 维度 | todo | goal |
8
+ |------|------|------|
9
+ | 状态机 | **刻意无约束**,任意状态自由流转(含反向) | 7 态状态机 + 强制任务分解 |
10
+ | 持久化 | 复用 Pi 的 toolResult entry(不调用 appendEntry) | appendEntry 主动写入 |
11
+ | 定位 | 多步骤工作的临时进度追踪 | 持久化目标驱动循环 |
12
+
13
+ `in_progress` 非强制,`pending → completed` 直接跳转合法。
12
14
 
13
15
  ## 安装
14
16
 
15
17
  ```bash
16
- # symlink 方式(开发推荐)
17
- ln -s /path/to/xyz-pi-extensions-workspace/main/packages/todo \
18
- ~/.pi/agent/extensions/todo
19
-
20
- # npm 方式(正式)
21
18
  pi install npm:@zhushanwen/pi-todo
22
19
  ```
23
20
 
24
- ## 使用
21
+ ## todo tool
22
+
23
+ ### Action 与参数
24
+
25
+ | action | 参数 | 必填 | 行为 |
26
+ |--------|------|------|------|
27
+ | `list` | — | — | 返回全部 todo |
28
+ | `add` | `texts: string[]` | 是 | 批量追加,自动分配连续 ID,初始 `status=pending` |
29
+ | `update` | `id` + `status` 或 `text`;**或** `updates: Array<{id, status?, text?}>` | `id` 必填 | `updates[]` **优先于** single 的 `id/status/text` |
30
+ | `delete` | `ids: number[]` | 是 | 批量删除;**部分 id 缺失则整体拒绝**(原子性) |
31
+ | `clear` | — | — | 清空全部,重置 `nextId=1` 和完成态标记 |
32
+
33
+ - `status` 枚举:`pending` / `in_progress` / `completed`
34
+ - `add` 不接受 `status`(恒为 pending),不存在 `verifyTexts`(那是 goal 的概念)
35
+
36
+ ### 错误处理约定
37
+
38
+ handler 失败**直接 `throw new Error()`**,不返回错误成功模式(见 CLAUDE.md「Tool 设计」)。常见错误:
39
+
40
+ | 触发 | 错误信息 |
41
+ |------|---------|
42
+ | `add` 缺 `texts` | `add requires texts parameter (non-empty array)` |
43
+ | `update` 缺 `id` | `update requires id parameter` |
44
+ | `update` 缺 `status` 和 `text` | `update requires at least status or text parameter` |
45
+ | `update` `text` 空串 | `text cannot be empty string` |
46
+ | `update` `status` 非法 | `status only accepts pending / in_progress / completed` |
47
+ | `update`/`delete` id 不存在 | `Todo #N not found` |
48
+ | `delete` 缺 `ids` | `delete requires ids parameter (non-empty array)` |
49
+
50
+ ## Steer 机制(延迟注入)
51
+
52
+ todo 的核心驱动力是「延迟一拍」的 steer:
53
+
54
+ ```
55
+ agent_end 设置 pendingSteerMessage
56
+ → 下一 turn 的 before_agent_start 消费(用户不可见,display:false)
57
+ ```
58
+
59
+ 四个子机制(handlers.ts,阈值常量硬编码):
60
+
61
+ | 机制 | 触发 | 行为 |
62
+ |------|------|------|
63
+ | **auto-clear** | 全部 completed 后再过 2 轮 | 自动清空 todos + 重置标记 |
64
+ | **completion-steer** | 首次全部 completed | 注入「检查交付质量」steer(一次性,`completionSteered` 防重) |
65
+ | **stall 检测** | 无 todo 活动达 5 轮 | 注入极简 reminder(仅下一个任务),整个 session 只触发一次 |
66
+ | **reminder** | 无 todo 活动达 2 轮 | 温和 reminder |
67
+
68
+ `agent_end` 内短路顺序:completion-steer **不短路**(继续往下),auto-clear / stall / reminder 各自短路 return。详见 `ARCHITECTURE.md`。
69
+
70
+ ## 持久化机制
71
+
72
+ todo 扩展**自己不调用 `appendEntry`**。状态快照随 Pi 框架自动记录的 toolResult entry 落盘:
73
+
74
+ 1. 每次 todo tool 调用,`execute` 返回的 `details.todos` / `details.nextId` 被 Pi 自动序列化为一条 `toolResult` entry
75
+ 2. `session_start` / `session_tree` 时,`reconstructState` 回放**最后一条** todo toolResult 重建状态
76
+ 3. 回放后 splice 掉更早的 todo toolResult(entry GC,从后往前删避免索引漂移)
77
+ 4. 向后兼容:`migrateTodo` 把旧五态(`verifying→in_progress`、`failed→pending`)和极旧的 `done:boolean` 降级映射到三态
78
+
79
+ ## 三层渲染
25
80
 
26
- AI 可调用 `todo` 工具:
81
+ | | 触发 | 规则 |
82
+ |----|------|------|
83
+ | **status line** | 每次 tool execute / session 恢复 | 空列表不显示;全完成 `✓ c/t`(绿);否则 `☑ c/t` |
84
+ | **widget**(侧边) | 有 todo 时 | ≤8 项单列;≥9 项双列(规避 Pi 的 10 行 widget 截断) |
85
+ | **tool result** | tool 返回时 | collapsed 显示前 5 项 + `... N more`;expanded 全显示 |
27
86
 
28
- | Action | 说明 |
29
- |--------|------|
30
- | `list` | 查看所有 todo |
31
- | `add` | 批量添加 todo |
32
- | `update` | 更新 todo(状态/文本) |
33
- | `delete` | 批量删除 |
34
- | `clear` | 清空所有 |
87
+ ## 命令
35
88
 
36
- 用户命令:`/todos` 交互式面板。
89
+ `/todos` — 进入只读 TUI 视图(`TodoListComponent`,固定双列布局)。Escape / Ctrl+C 关闭。需 interactive mode。
37
90
 
38
91
  ## 文件结构
39
92
 
40
93
  ```
41
94
  todo/
42
- ├── index.ts
95
+ ├── index.ts # 工厂入口(re-export src/index.ts)
96
+ ├── PLAN.md # [SUPERSEDED] v2 历史计划,保留作决策记录
97
+ ├── ARCHITECTURE.md # 架构详图(文件依赖 + steer 时序 + 事件流)
43
98
  └── src/
44
- └── index.ts # 入口 工具、命令、事件、状态栏
99
+ ├── index.ts # 工厂入口(创建 state + 注册 tool/command/event)
100
+ ├── state.ts # TodoSessionState 会话状态接口 + 工厂
101
+ ├── model.ts # 纯函数数据层(类型/迁移/addTodos/updateTodos/format/buildGui)
102
+ ├── tool.ts # todo tool 注册 — 5 action + execute dispatcher
103
+ ├── handlers.ts # 5 事件处理器 + reconstructState + steer 四机制
104
+ ├── render.ts # status line / widget / tool result 三层渲染
105
+ ├── component.ts # /todos 的 TodoListComponent TUI 组件
106
+ ├── commands.ts # /todos 命令注册
107
+ └── __tests__/ # 单测(model 纯函数 + widget 布局 + agent_end 数据条件)
45
108
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-todo",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",
@@ -24,6 +24,9 @@
24
24
  "devDependencies": {
25
25
  "vitest": "^4.1.8"
26
26
  },
27
+ "dependencies": {
28
+ "@xyz-agent/extension-protocol": "^0.2.0"
29
+ },
27
30
  "peerDependencies": {
28
31
  "@mariozechner/pi-coding-agent": "*",
29
32
  "@earendil-works/pi-tui": "*",
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { buildGui, type Todo } from "../model";
4
+
5
+ describe("buildGui", () => {
6
+ it("maps 4 statuses to list-tree with correct icons", () => {
7
+ const todos: Todo[] = [
8
+ { id: 1, text: "pending task", status: "pending" },
9
+ { id: 2, text: "active task", status: "in_progress" },
10
+ { id: 3, text: "done task", status: "completed" },
11
+ { id: 4, text: "cancelled task", status: "cancelled" },
12
+ ];
13
+ const gui = buildGui(todos);
14
+ expect(gui.v).toBe(1);
15
+ expect(gui.component.type).toBe("list-tree");
16
+ const items = gui.component.props.items;
17
+ expect(items).toHaveLength(4);
18
+ // pending → dot, no status(guiResult 的 stripUndefined 删除 undefined 键)
19
+ expect(items[0]).toMatchObject({ icon: "dot", label: "#1: pending task", depth: 0 });
20
+ expect(items[0]).not.toHaveProperty("status");
21
+ // in_progress → circle, running
22
+ expect(items[1]).toMatchObject({ icon: "circle", label: "#2: active task", status: "running", depth: 0 });
23
+ // completed → check, done
24
+ expect(items[2]).toMatchObject({ icon: "check", label: "#3: done task", status: "done", depth: 0 });
25
+ // cancelled → cross, failed
26
+ expect(items[3]).toMatchObject({ icon: "cross", label: "#4: cancelled task", status: "failed", depth: 0 });
27
+ });
28
+
29
+ it("empty todos → empty list-tree", () => {
30
+ const gui = buildGui([]);
31
+ expect(gui.component.props.items).toEqual([]);
32
+ });
33
+
34
+ it("isVerification todo still maps correctly", () => {
35
+ const todos: Todo[] = [{ id: 1, text: "verify", status: "pending", isVerification: true }];
36
+ const gui = buildGui(todos);
37
+ expect(gui.component.props.items[0]).toMatchObject({ icon: "dot", label: "#1: verify" });
38
+ });
39
+ });
@@ -0,0 +1,254 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ buildBeforeAgentStartMessage,
5
+ buildMinimalReminder,
6
+ handleAutoClear,
7
+ handleCompletionSteer,
8
+ handleReminder,
9
+ handleStallDetection,
10
+ reconstructState,
11
+ } from "../handlers";
12
+ import type { Todo } from "../model";
13
+ import { createTodoSessionState, type TodoSessionState } from "../state";
14
+
15
+ // ── helpers ─────────────────────────────────────────
16
+
17
+ function makeState(todos: Todo[], overrides: Partial<TodoSessionState> = {}): TodoSessionState {
18
+ const s = createTodoSessionState();
19
+ s.todos = todos;
20
+ Object.assign(s, overrides);
21
+ return s;
22
+ }
23
+
24
+ function todoEntry(todos: Todo[], nextId: number) {
25
+ return {
26
+ type: "message",
27
+ message: { role: "toolResult", toolName: "todo", details: { todos, nextId } },
28
+ };
29
+ }
30
+
31
+ function makeCtx(entries: unknown[]) {
32
+ return {
33
+ sessionManager: { getEntries: () => entries },
34
+ } as unknown as Parameters<typeof reconstructState>[1];
35
+ }
36
+
37
+ // ── completion steer ────────────────────────────────
38
+
39
+ describe("handleCompletionSteer", () => {
40
+ it("sets one-shot steer when all completed", () => {
41
+ const s = makeState([{ id: 1, text: "a", status: "completed" }]);
42
+ expect(handleCompletionSteer(s)).toBe(true);
43
+ expect(s.completionSteered).toBe(true);
44
+ expect(s.pendingSteerMessage).toContain("交付质量");
45
+ });
46
+
47
+ it("does not steer twice (single-shot lock)", () => {
48
+ const s = makeState([{ id: 1, text: "a", status: "completed" }], { completionSteered: true });
49
+ expect(handleCompletionSteer(s)).toBe(false);
50
+ expect(s.pendingSteerMessage).toBeNull();
51
+ });
52
+
53
+ it("does not steer when not all completed", () => {
54
+ const s = makeState([
55
+ { id: 1, text: "a", status: "completed" },
56
+ { id: 2, text: "b", status: "pending" },
57
+ ]);
58
+ expect(handleCompletionSteer(s)).toBe(false);
59
+ expect(s.completionSteered).toBe(false);
60
+ });
61
+
62
+ it("does not steer on empty list", () => {
63
+ expect(handleCompletionSteer(makeState([]))).toBe(false);
64
+ });
65
+ });
66
+
67
+ // ── auto-clear ──────────────────────────────────────
68
+
69
+ describe("handleAutoClear", () => {
70
+ it("does not handle when not all completed, and resets anchor", () => {
71
+ const s = makeState([{ id: 1, text: "a", status: "pending" }], { allCompletedAtCount: 3 });
72
+ expect(handleAutoClear(s)).toEqual({ handled: false, cleared: false });
73
+ expect(s.allCompletedAtCount).toBeNull();
74
+ });
75
+
76
+ it("anchors on first all-completed round without clearing", () => {
77
+ const s = makeState([{ id: 1, text: "a", status: "completed" }], { userMessageCount: 5 });
78
+ expect(handleAutoClear(s)).toEqual({ handled: true, cleared: false });
79
+ expect(s.allCompletedAtCount).toBe(5);
80
+ expect(s.todos).toHaveLength(1);
81
+ });
82
+
83
+ it("does not clear before AUTO_CLEAR_DELAY_ROUNDS (2) elapse", () => {
84
+ const s = makeState([{ id: 1, text: "a", status: "completed" }], {
85
+ userMessageCount: 5, allCompletedAtCount: 4,
86
+ });
87
+ expect(handleAutoClear(s)).toEqual({ handled: true, cleared: false });
88
+ });
89
+
90
+ it("clears and resets flags after delay elapses", () => {
91
+ const s = makeState([{ id: 1, text: "a", status: "completed" }], {
92
+ userMessageCount: 6, allCompletedAtCount: 4, completionSteered: true,
93
+ });
94
+ expect(handleAutoClear(s)).toEqual({ handled: true, cleared: true });
95
+ expect(s.todos).toEqual([]);
96
+ expect(s.nextId).toBe(1);
97
+ expect(s.allCompletedAtCount).toBeNull();
98
+ expect(s.completionSteered).toBe(false);
99
+ });
100
+ });
101
+
102
+ // ── stall detection ─────────────────────────────────
103
+
104
+ describe("handleStallDetection", () => {
105
+ it("fires once when idle exceeds STALL_THRESHOLD (5)", () => {
106
+ const s = makeState([{ id: 1, text: "task", status: "pending" }], {
107
+ userMessageCount: 10, lastTodoCallCount: 5,
108
+ });
109
+ expect(handleStallDetection(s)).toBe(true);
110
+ expect(s.stallNotified).toBe(true);
111
+ expect(s.pendingSteerMessage).toContain("#1");
112
+ });
113
+
114
+ it("does not fire twice (single-shot lock)", () => {
115
+ const s = makeState([{ id: 1, text: "task", status: "pending" }], {
116
+ userMessageCount: 10, lastTodoCallCount: 5, stallNotified: true,
117
+ });
118
+ expect(handleStallDetection(s)).toBe(false);
119
+ expect(s.pendingSteerMessage).toBeNull();
120
+ });
121
+
122
+ it("does not fire below threshold", () => {
123
+ const s = makeState([{ id: 1, text: "task", status: "pending" }], {
124
+ userMessageCount: 8, lastTodoCallCount: 5,
125
+ });
126
+ expect(handleStallDetection(s)).toBe(false);
127
+ });
128
+ });
129
+
130
+ // ── reminder ────────────────────────────────────────
131
+
132
+ describe("handleReminder", () => {
133
+ it("fires when idle exceeds REMINDER_INTERVAL (2)", () => {
134
+ const s = makeState([{ id: 1, text: "task", status: "pending" }], {
135
+ userMessageCount: 5, lastTodoCallCount: 3,
136
+ });
137
+ expect(handleReminder(s)).toBe(true);
138
+ expect(s.pendingSteerMessage).toContain("#1");
139
+ });
140
+
141
+ it("does not fire within interval", () => {
142
+ const s = makeState([{ id: 1, text: "task", status: "pending" }], {
143
+ userMessageCount: 4, lastTodoCallCount: 3,
144
+ });
145
+ expect(handleReminder(s)).toBe(false);
146
+ });
147
+ });
148
+
149
+ // ── reminder / context builders ─────────────────────
150
+
151
+ describe("buildMinimalReminder", () => {
152
+ it("mentions the next pending task", () => {
153
+ const s = makeState([
154
+ { id: 1, text: "done", status: "completed" },
155
+ { id: 2, text: "next", status: "pending" },
156
+ ]);
157
+ expect(buildMinimalReminder(s)).toContain("#2 next");
158
+ });
159
+
160
+ it("returns empty string when no pending", () => {
161
+ expect(buildMinimalReminder(makeState([{ id: 1, text: "done", status: "completed" }]))).toBe("");
162
+ });
163
+ });
164
+
165
+ describe("buildBeforeAgentStartMessage", () => {
166
+ it("injects hidden context for pending tasks only", () => {
167
+ const s = makeState([
168
+ { id: 1, text: "a", status: "pending" },
169
+ { id: 2, text: "b", status: "completed" },
170
+ ]);
171
+ const m = buildBeforeAgentStartMessage(s);
172
+ expect(m).toBeDefined();
173
+ expect(m!.message.display).toBe(false);
174
+ expect(m!.message.customType).toBe("todo-context");
175
+ expect(m!.message.content).toContain("#1: a");
176
+ expect(m!.message.content).not.toContain("#2");
177
+ });
178
+
179
+ it("returns undefined when list empty", () => {
180
+ expect(buildBeforeAgentStartMessage(makeState([]))).toBeUndefined();
181
+ });
182
+
183
+ it("returns undefined when all completed", () => {
184
+ const s = makeState([{ id: 1, text: "a", status: "completed" }]);
185
+ expect(buildBeforeAgentStartMessage(s)).toBeUndefined();
186
+ });
187
+ });
188
+
189
+ // ── reconstructState ────────────────────────────────
190
+
191
+ describe("reconstructState", () => {
192
+ it("leaves state empty when no todo entry", () => {
193
+ const entries = [{ type: "message", message: { role: "user", content: "hi" } }];
194
+ const s = createTodoSessionState();
195
+ reconstructState(s, makeCtx(entries));
196
+ expect(s.todos).toEqual([]);
197
+ expect(s.nextId).toBe(1);
198
+ });
199
+
200
+ it("replays the latest todo entry snapshot", () => {
201
+ const entries = [todoEntry([{ id: 1, text: "a", status: "pending" }], 2)];
202
+ const s = createTodoSessionState();
203
+ reconstructState(s, makeCtx(entries));
204
+ expect(s.todos).toHaveLength(1);
205
+ expect(s.todos[0].text).toBe("a");
206
+ expect(s.nextId).toBe(2);
207
+ });
208
+
209
+ it("uses the last todo entry and GCs older ones (splice from tail)", () => {
210
+ const entries = [
211
+ todoEntry([{ id: 1, text: "old", status: "pending" }], 2),
212
+ todoEntry([{ id: 5, text: "new", status: "completed" }], 6),
213
+ ];
214
+ const s = createTodoSessionState();
215
+ reconstructState(s, makeCtx(entries));
216
+ expect(s.todos[0].id).toBe(5);
217
+ expect(s.nextId).toBe(6);
218
+ expect(entries).toHaveLength(1);
219
+ });
220
+
221
+ it("migrates legacy status on replay", () => {
222
+ const legacy = [{ id: 1, text: "a", status: "failed" }] as unknown as Todo[];
223
+ const entries = [todoEntry(legacy, 2)];
224
+ const s = createTodoSessionState();
225
+ reconstructState(s, makeCtx(entries));
226
+ expect(s.todos[0].status).toBe("pending");
227
+ });
228
+ });
229
+
230
+ // ── agent_end integration (短路顺序) ────────────────
231
+
232
+ describe("agent_end short-circuit order", () => {
233
+ it("completion steer fires before auto-clear (completion does not short-circuit)", () => {
234
+ const s = makeState([{ id: 1, text: "a", status: "completed" }], { userMessageCount: 5 });
235
+ // 模拟 agent_end: handleCompletionSteer(不短路) → handleAutoClear(短路)
236
+ expect(handleCompletionSteer(s)).toBe(true);
237
+ expect(s.pendingSteerMessage).toContain("交付质量");
238
+ expect(handleAutoClear(s)).toEqual({ handled: true, cleared: false });
239
+ expect(s.allCompletedAtCount).toBe(5);
240
+ });
241
+
242
+ it("after delay, auto-clear clears but the one-shot steer stays queued", () => {
243
+ // 竞态点:completion steer 早已置位,auto-clear 现在清空 todos
244
+ const s = makeState([{ id: 1, text: "a", status: "completed" }], {
245
+ userMessageCount: 7, allCompletedAtCount: 5,
246
+ completionSteered: true, pendingSteerMessage: "<queued>",
247
+ });
248
+ expect(handleCompletionSteer(s)).toBe(false); // 已 steered,不重复
249
+ expect(handleAutoClear(s).cleared).toBe(true);
250
+ expect(s.todos).toEqual([]);
251
+ // pendingSteerMessage 仍保留,由下一 turn before_agent_start 消费(此时 todos 已空)
252
+ expect(s.pendingSteerMessage).toBe("<queued>");
253
+ });
254
+ });
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
3
3
 
4
4
  import {
5
5
  addTodos,
6
- buildRender,
7
6
  formatTodoLine,
8
7
  migrateTodo,
9
8
  type Todo,
@@ -201,15 +200,15 @@ describe("handleSingleUpdate FR-6 guards (tool single path)", () => {
201
200
  it("FR-6: cancelled todo + status → cannot restore", () => {
202
201
  const state = createTodoSessionState();
203
202
  state.todos = [{ id: 1, text: "dropped", status: "cancelled" }];
204
- const result = handleSingleUpdate(state, { action: "update", id: 1, status: "pending" });
205
- expect(result.error).toBe("#1 is cancelled (cannot restore)");
203
+ expect(() => handleSingleUpdate(state, { action: "update", id: 1, status: "pending" }))
204
+ .toThrow("#1 is cancelled (cannot restore)");
206
205
  });
207
206
 
208
207
  it("FR-6: verification todo + status=cancelled → cannot cancel", () => {
209
208
  const state = createTodoSessionState();
210
209
  state.todos = [{ id: 2, text: "run tests", status: "in_progress", isVerification: true }];
211
- const result = handleSingleUpdate(state, { action: "update", id: 2, status: "cancelled" });
212
- expect(result.error).toBe("#2 is verification todo (cannot cancel)");
210
+ expect(() => handleSingleUpdate(state, { action: "update", id: 2, status: "cancelled" }))
211
+ .toThrow("#2 is verification todo (cannot cancel)");
213
212
  });
214
213
  });
215
214
 
@@ -269,27 +268,6 @@ describe("formatTodoLine", () => {
269
268
  });
270
269
  });
271
270
 
272
- // ── buildRender ─────────────────────────────────────
273
-
274
- describe("buildRender", () => {
275
- it("should calculate summary correctly", () => {
276
- const todos: Todo[] = [
277
- { id: 1, text: "a", status: "completed" },
278
- { id: 2, text: "b", status: "pending" },
279
- { id: 3, text: "c", status: "in_progress" },
280
- ];
281
- const render = buildRender(todos);
282
- expect(render).toBeDefined();
283
- expect(render!.summary).toBe("1/3 completed");
284
- expect(render!.data.items).toHaveLength(3);
285
- });
286
-
287
- it("should handle empty list", () => {
288
- const render = buildRender([]);
289
- expect(render!.summary).toBe("0/0 completed");
290
- });
291
- });
292
-
293
271
  // ── widget 渲染布局 ────────────────────────────────
294
272
 
295
273
  const mockTheme = {
@@ -0,0 +1,56 @@
1
+ // Behavioral tests for todo dual-form traps (text/texts, id/ids).
2
+ //
3
+ // Complements the source-text prompt-quality locks in tool-prompt.test.ts: those
4
+ // verify the Correct/error STRINGS exist; these exercise the actual throw logic of
5
+ // handleAdd/handleDelete, so a refactor cannot silently drop the dual-form detection.
6
+ //
7
+ // handleAdd/handleDelete were exported specifically to enable these tests.
8
+
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { createTodoSessionState } from "../state";
12
+ import { handleAdd, handleDelete } from "../tool";
13
+
14
+ describe("handleAdd — text/texts dual-form detection", () => {
15
+ it("triggers dual-form error when singular 'text' used instead of 'texts'", () => {
16
+ const state = createTodoSessionState();
17
+ expect(() => handleAdd(state, { action: "add", text: "write spec" })).toThrow(
18
+ /singular "text"|add needs texts/,
19
+ );
20
+ });
21
+
22
+ it("throws 'requires texts' when neither text nor texts given", () => {
23
+ const state = createTodoSessionState();
24
+ expect(() => handleAdd(state, { action: "add" })).toThrow(/requires texts/);
25
+ });
26
+
27
+ it("throws 'requires texts' on empty array (missing, not dual-form)", () => {
28
+ const state = createTodoSessionState();
29
+ expect(() => handleAdd(state, { action: "add", texts: [] })).toThrow(/requires texts/);
30
+ });
31
+
32
+ it("does NOT throw when correct 'texts' array provided", () => {
33
+ const state = createTodoSessionState();
34
+ expect(() => handleAdd(state, { action: "add", texts: ["write spec"] })).not.toThrow();
35
+ });
36
+ });
37
+
38
+ describe("handleDelete — id/ids dual-form detection", () => {
39
+ it("triggers dual-form error when singular 'id' used instead of 'ids'", () => {
40
+ const state = createTodoSessionState();
41
+ expect(() => handleDelete(state, { action: "delete", id: 5 })).toThrow(
42
+ /singular "id"|delete needs ids/,
43
+ );
44
+ });
45
+
46
+ it("throws 'requires ids' when neither id nor ids given", () => {
47
+ const state = createTodoSessionState();
48
+ expect(() => handleDelete(state, { action: "delete" })).toThrow(/requires ids/);
49
+ });
50
+
51
+ it("does NOT throw when correct 'ids' array provided (after seeding a todo)", () => {
52
+ const state = createTodoSessionState();
53
+ handleAdd(state, { action: "add", texts: ["temp"] }); // seed todo #1
54
+ expect(() => handleDelete(state, { action: "delete", ids: [1] })).not.toThrow();
55
+ });
56
+ });
@@ -0,0 +1,105 @@
1
+ // 提示词质量回归:todo tool 的 description 与 runtime 纠错文案必须是
2
+ // "弱模型友好"的——条件必填字段要给完整 JSON 正例,双形陷阱(text/texts、
3
+ // id/ids)要给消歧反例,失败后 throw 要带 Correct 正例让模型自我纠正。
4
+ //
5
+ // 本测试用源码文本断言锁定这些约束,防止后续重构把正例/反例/纠错文案删掉或
6
+ // 弱化。读源码而非 import,避免 mock 链(tool.ts 依赖 typebox/ExtensionAPI/
7
+ // Theme 等值导入)。参考 subagent-workflow 的 prompt 回归范式。
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ import { describe, expect, it } from "vitest";
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const TOOL_SRC = readFileSync(join(__dirname, "../tool.ts"), "utf-8");
17
+
18
+ /**
19
+ * 提取 description 拼接区(从 `description:` 到下一个 `promptSnippet:`)。
20
+ * tool.ts 的 description 用字符串拼接(非模板字面量),故整段截取后做子串断言。
21
+ */
22
+ function extractDescriptionRegion(src: string): string {
23
+ // 锚定到 registerTodoTool 内的 tool description,而非 schema 字段的 description:
24
+ // (Type.String({ description: "..." }) 等 schema 字段会先被 indexOf 命中,导致
25
+ // 捕获区域含 schema 描述 + tool 描述——虽当前子串唯一能过但 fragile to restructuring)。
26
+ const regIdx = src.indexOf("registerTodoTool");
27
+ if (regIdx === -1) throw new Error("registerTodoTool not found in tool.ts");
28
+ const start = src.indexOf("description:", regIdx);
29
+ if (start === -1) throw new Error("tool description: not found in registerTodoTool");
30
+ const end = src.indexOf("promptSnippet:", start);
31
+ if (end === -1) throw new Error("promptSnippet: not found in tool.ts");
32
+ return src.slice(start, end);
33
+ }
34
+
35
+ const DESCRIPTION_REGION = extractDescriptionRegion(TOOL_SRC);
36
+
37
+ // ── description 必须给完整 JSON 正例 ─────────────────
38
+
39
+ describe("todo description — 给完整 JSON 正例", () => {
40
+ it("add 正例:含 {\"action\":\"add\",\"texts\"", () => {
41
+ // 弱模型 add 时易误用单数 text;正例必须显式 texts 数组。
42
+ expect(DESCRIPTION_REGION).toContain('{"action":"add","texts"');
43
+ });
44
+
45
+ it("add+verification 正例:含 isVerification", () => {
46
+ expect(DESCRIPTION_REGION).toContain('"isVerification":true');
47
+ });
48
+
49
+ it("update single 正例:含 {\"action\":\"update\",\"id\"", () => {
50
+ expect(DESCRIPTION_REGION).toContain('{"action":"update","id"');
51
+ });
52
+
53
+ it("update batch 正例:含 {\"action\":\"update\",\"updates\"", () => {
54
+ // updates[] 批量路径是 [批量优先] 规范的核心,正例不可缺。
55
+ expect(DESCRIPTION_REGION).toContain('{"action":"update","updates"');
56
+ });
57
+
58
+ it("delete 正例:含 {\"action\":\"delete\",\"ids\"", () => {
59
+ // 弱模型 delete 时易误用单数 id;正例必须显式 ids 数组。
60
+ expect(DESCRIPTION_REGION).toContain('{"action":"delete","ids"');
61
+ });
62
+ });
63
+
64
+ // ── description 必须给双形陷阱反例(消歧) ──────────
65
+
66
+ describe("todo description — 双形陷阱反例(消歧单复数)", () => {
67
+ it("Don't 段存在", () => {
68
+ expect(DESCRIPTION_REGION).toMatch(/Don't/);
69
+ });
70
+
71
+ it("反例标注:text 属于 update(add 用 texts)", () => {
72
+ // 反例必须含消歧词,告诉模型 text 属于 update 而非 add。
73
+ expect(DESCRIPTION_REGION).toContain("text is for update");
74
+ expect(DESCRIPTION_REGION).toContain("add uses texts");
75
+ });
76
+
77
+ it("反例标注:id 属于 update(delete 用 ids)", () => {
78
+ expect(DESCRIPTION_REGION).toContain("id is for update");
79
+ expect(DESCRIPTION_REGION).toContain("delete uses ids");
80
+ });
81
+
82
+ it("反例标注:update 缺 id", () => {
83
+ expect(DESCRIPTION_REGION).toContain("missing id");
84
+ });
85
+ });
86
+
87
+ // ── runtime throw 必须带 Correct 纠错正例 ───────────
88
+
89
+ describe("todo runtime — throw 含 Correct 纠错正例", () => {
90
+ it("源码含 ≥4 处 Correct: 纠错文案(覆盖 add/delete/update 路径)", () => {
91
+ // 每个 required throw 必须追加完整 JSON 正例,弱模型失败后能自我纠正。
92
+ const matches = TOOL_SRC.match(/Correct:/g) || [];
93
+ expect(matches.length).toBeGreaterThanOrEqual(4);
94
+ });
95
+
96
+ it('add 双形检测:含 singular "text" 纠错文案', () => {
97
+ expect(TOOL_SRC).toContain('singular "text"');
98
+ expect(TOOL_SRC).toContain('"text" — that field is for update');
99
+ });
100
+
101
+ it('delete 双形检测:含 singular "id" 纠错文案', () => {
102
+ expect(TOOL_SRC).toContain('singular "id"');
103
+ expect(TOOL_SRC).toContain('"id" — that field is for update');
104
+ });
105
+ });