@zhushanwen/pi-todo 0.6.0 → 0.7.0

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/src/tool.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  /**
2
- * Todo tool 注册 + execute dispatcher + 5 个 action handler。
2
+ * Todo tool 注册 + execute dispatcher + 4 个 action handler。
3
+ *
4
+ * Schema 设计(T4):TodoParams 为 discriminated union(按 action 区分),每个分支
5
+ * 只声明自己的参数且 additionalProperties:false。这样缺失必填(如 {action:'add'} 缺
6
+ * texts)在 schema 层就被拒绝,不依赖运行时 handler throw。实测 typebox Value.Check
7
+ * 与 ajv(plain,不开 discriminator 选项)均正确拒绝;故不使用 discriminator keyword
8
+ * (typebox 输出 anyOf,ajv discriminator 选项要求 oneOf 会编译失败)。
3
9
  */
4
10
 
5
11
  import { StringEnum } from "@earendil-works/pi-ai";
@@ -10,7 +16,7 @@ import { type Static, Type } from "typebox";
10
16
  import {
11
17
  addTodos,
12
18
  buildGui,
13
- formatTodoLine,
19
+ formatTodoList,
14
20
  type Todo,
15
21
  type TodoDetails,
16
22
  updateTodos,
@@ -19,7 +25,11 @@ import {
19
25
  import { renderTodoResult } from "./render";
20
26
  import type { TodoSessionState } from "./state";
21
27
 
22
- // ── Action 参数类型 ──────────────────────────────────
28
+ // ── Action 参数类型(运行时)──────────────────────────
29
+ // 刻意保持为宽松 interface(全部字段可选)而非 strict discriminated union:
30
+ // handler 需要检测「双形陷阱」(add 同时传 text+texts 等错误输入),schema 层虽已用
31
+ // additionalProperties:false 拒绝,但 handler 作为 defense-in-depth 仍需能访问/判断
32
+ // 这些字段。类型严格性由 TodoParams schema(discriminated union)承担。
23
33
 
24
34
  export interface TodoActionParams {
25
35
  action: string;
@@ -31,46 +41,77 @@ export interface TodoActionParams {
31
41
  updates?: Array<{ id: number; status?: string; text?: string }>;
32
42
  }
33
43
 
34
- // ── TodoParams schema ────────────────────────────────
44
+ // ── TodoParams schema(discriminated union by action)──────────
35
45
 
36
- const TodoParams = Type.Object({
37
- action: StringEnum(["list", "add", "update", "delete", "clear"] as const),
38
- text: Type.Optional(Type.String({ description: "Todo text (for update action)" })),
39
- id: Type.Optional(Type.Number({ description: "Todo ID (for update action)" })),
40
- texts: Type.Optional(Type.Array(Type.String(), { description: "Todo text list (for add action)" })),
41
- ids: Type.Optional(Type.Array(Type.Number(), { description: "Todo ID list (for delete action)" })),
42
- status: Type.Optional(
43
- StringEnum(VALID_STATUSES, { description: "Target status (for update action)" }),
44
- ),
45
- updates: Type.Optional(
46
- Type.Array(
47
- Type.Object({
48
- id: Type.Number({ description: "Todo ID to update" }),
49
- status: Type.Optional(
50
- Type.String({ description: "Target status; one of pending/in_progress/completed/cancelled" }),
51
- ),
52
- text: Type.Optional(Type.String({ description: "New todo text" })),
53
- }),
54
- { description: "Batch updates array (takes priority over single id/status/text)" },
55
- ),
46
+ const StatusSchema = StringEnum(VALID_STATUSES);
47
+
48
+ const ListParams = Type.Object(
49
+ { action: Type.Literal("list") },
50
+ { additionalProperties: false },
51
+ );
52
+ const AddParams = Type.Object(
53
+ {
54
+ action: Type.Literal("add"),
55
+ texts: Type.Array(Type.String(), { description: "待添加的 todo 文本数组" }),
56
+ },
57
+ { additionalProperties: false },
58
+ );
59
+ const UpdateSingleParams = Type.Object(
60
+ {
61
+ action: Type.Literal("update"),
62
+ id: Type.Number({ description: "要更新的 todo id" }),
63
+ status: Type.Optional(StatusSchema),
64
+ text: Type.Optional(Type.String({ description: "新文本(trim 后不可为空)" })),
65
+ },
66
+ { additionalProperties: false },
67
+ );
68
+ const UpdateBatchParams = Type.Object(
69
+ {
70
+ action: Type.Literal("update"),
71
+ updates: Type.Array(
72
+ Type.Object({
73
+ id: Type.Number({ description: "要更新的 todo id" }),
74
+ status: Type.Optional(StatusSchema),
75
+ text: Type.Optional(Type.String({ description: "新文本(trim 后不可为空)" })),
76
+ }),
77
+ { description: "批量更新数组(优先于单条 id/status/text)" },
56
78
  ),
57
- });
79
+ },
80
+ { additionalProperties: false },
81
+ );
82
+ const DeleteParams = Type.Object(
83
+ {
84
+ action: Type.Literal("delete"),
85
+ ids: Type.Array(Type.Number(), { description: "要删除的 todo id 数组" }),
86
+ },
87
+ { additionalProperties: false },
88
+ );
89
+
90
+ export const TodoParams = Type.Union([
91
+ ListParams,
92
+ AddParams,
93
+ UpdateSingleParams,
94
+ UpdateBatchParams,
95
+ DeleteParams,
96
+ ]);
58
97
 
59
- // ── 5 个 action handler ──────────────────────────────
98
+ // ── 4 个 action handler ──────────────────────────────
60
99
  // 错误处理约定(见 CLAUDE.md「Tool 设计」):handler 失败直接 throw,
61
- // 不返回「错误成功模式」。model 层纯函数返回 Result 对象(合法),
100
+ // 不返回「错误成功模式」。model 层纯函数(updateTodos)返回 Result 对象(合法),
62
101
  // 由 dispatcher 在拿到 error 时 throw,把友好文案交给 Pi 框架展示。
102
+ // addTodos 的校验失败直接 throw(C1:不再静默 filter)。
63
103
 
64
- /** list action */
104
+ /** list action — 返回完整格式化列表 */
65
105
  function handleList(state: TodoSessionState): string {
66
- return state.todos.length
67
- ? state.todos.map((t) => formatTodoLine(t)).join("\n")
68
- : "No todos";
106
+ return state.todos.length ? formatTodoList(state.todos) : "No todos";
69
107
  }
70
108
 
71
- /** add action — 失败抛错 */
72
109
  /** add action — 失败抛错。export 供 behavioral 测试(text/texts 双形陷阱检测)。 */
73
110
  export function handleAdd(state: TodoSessionState, params: TodoActionParams): string {
111
+ // 双形陷阱:同时传 text 和 texts → throw(TC7)
112
+ if (params.text !== undefined && params.texts !== undefined) {
113
+ throw new Error('add only accepts texts array; do not also pass singular "text"');
114
+ }
74
115
  if (!params.texts || params.texts.length === 0) {
75
116
  // 双形陷阱:弱模型 add 时误用单数 text(那是 update 的字段)
76
117
  if (params.text !== undefined) {
@@ -82,11 +123,11 @@ export function handleAdd(state: TodoSessionState, params: TodoActionParams): st
82
123
  'add requires texts parameter (non-empty array). Correct: {"action":"add","texts":["..."]}',
83
124
  );
84
125
  }
126
+ // addTodos 内部对空项 trim+throw(C1)
85
127
  const r = addTodos(state.todos, state.nextId, params.texts);
86
- if (r.error) throw new Error(r.resultText);
87
128
  state.todos = r.newTodos;
88
129
  state.nextId = r.newNextId;
89
- return r.resultText!;
130
+ return r.resultText;
90
131
  }
91
132
 
92
133
  /** update action: batch — 失败抛错 */
@@ -107,7 +148,9 @@ export function handleSingleUpdate(state: TodoSessionState, params: TodoActionPa
107
148
  throw new Error(
108
149
  'update requires at least status or text parameter. Correct: {"action":"update","id":<n>,"status":"in_progress"}',
109
150
  );
110
- if (params.text !== undefined && params.text === "") throw new Error("text cannot be empty string");
151
+ // text 校验统一(CT5):trim 后空串 throw(不只判 ===)
152
+ if (params.text !== undefined && params.text.trim().length === 0)
153
+ throw new Error("text cannot be empty or whitespace-only");
111
154
  if (
112
155
  params.status !== undefined &&
113
156
  !VALID_STATUSES.includes(params.status as (typeof VALID_STATUSES)[number])
@@ -118,23 +161,12 @@ export function handleSingleUpdate(state: TodoSessionState, params: TodoActionPa
118
161
  const todo = state.todos.find((t) => t.id === params.id);
119
162
  if (!todo) throw new Error(`Todo #${params.id} not found`);
120
163
 
121
- // cancelled 不可恢复(失败抛错)
122
- if (todo.status === "cancelled" && params.status !== undefined) {
123
- throw new Error(`#${params.id} is cancelled (cannot restore)`);
124
- }
125
-
126
164
  if (params.status !== undefined) todo.status = params.status as Todo["status"];
127
- if (params.text !== undefined) todo.text = params.text;
165
+ if (params.text !== undefined) todo.text = params.text.trim();
128
166
 
129
167
  const parts: string[] = [`Updated todo #${todo.id}`];
130
168
  if (params.status !== undefined) parts.push(`status → ${params.status}`);
131
169
  if (params.text !== undefined) parts.push(`text → "${todo.text}"`);
132
-
133
- // 最后一个完成提示
134
- const incompleteAfter = state.todos.filter((t) => t.status !== "completed");
135
- if (params.status === "completed" && incompleteAfter.length === 0) {
136
- return parts.join(", ") + "\n\nAll todos completed. Please summarize your work.";
137
- }
138
170
  return parts.join(", ");
139
171
  }
140
172
 
@@ -144,8 +176,8 @@ function handleUpdate(state: TodoSessionState, params: TodoActionParams): string
144
176
  return handleSingleUpdate(state, params);
145
177
  }
146
178
 
147
- /** delete action — 失败抛错;部分 id 缺失则整体拒绝(原子性) */
148
- /** delete action — 失败抛错。export 供 behavioral 测试(id/ids 双形陷阱检测)。 */
179
+ /** delete action — 失败抛错;部分 id 缺失则整体拒绝(原子性)。
180
+ * export 供 behavioral 测试(id/ids 双形陷阱检测)。 */
149
181
  export function handleDelete(state: TodoSessionState, params: TodoActionParams): string {
150
182
  if (!params.ids || params.ids.length === 0) {
151
183
  // 双形陷阱:弱模型 delete 时误用单数 id(那是 update 的字段)
@@ -174,16 +206,6 @@ export function handleDelete(state: TodoSessionState, params: TodoActionParams):
174
206
  return `Deleted ${removedIds.length} items (#${removedIds.join(", #")}), ${state.todos.length} remaining`;
175
207
  }
176
208
 
177
- /** clear action */
178
- function handleClear(state: TodoSessionState): string {
179
- const count = state.todos.length;
180
- state.todos = [];
181
- state.nextId = 1;
182
- state.allCompletedAtCount = null;
183
- state.completionSteered = false;
184
- return count > 0 ? `Cleared ${count} todos` : "No todos to clear";
185
- }
186
-
187
209
  // ── Dispatcher ───────────────────────────────────────
188
210
 
189
211
  function executeTodoAction(
@@ -195,8 +217,7 @@ function executeTodoAction(
195
217
  content: Array<{ type: "text"; text: string }>;
196
218
  details: TodoDetails;
197
219
  } {
198
- state.lastTodoCallCount = state.userMessageCount;
199
- state.stallNotified = false;
220
+ const isMutation = params.action !== "list";
200
221
 
201
222
  let resultText: string;
202
223
  switch (params.action) {
@@ -212,27 +233,33 @@ function executeTodoAction(
212
233
  case "delete":
213
234
  resultText = handleDelete(state, params);
214
235
  break;
215
- case "clear":
216
- resultText = handleClear(state);
217
- break;
218
236
  default:
219
237
  throw new Error(`Unknown action: ${params.action}`);
220
238
  }
221
239
 
222
240
  refreshDisplay(ctx);
223
241
 
242
+ // content 组装(T3):突变附带完整列表;list 已含列表
243
+ let contentText: string;
244
+ if (isMutation) {
245
+ const listText = state.todos.length > 0 ? formatTodoList(state.todos) : "No todos";
246
+ contentText = `${resultText}\n${listText}`;
247
+ } else {
248
+ contentText = resultText;
249
+ }
250
+
224
251
  const details: TodoDetails = {
225
252
  action: params.action as TodoDetails["action"],
226
253
  todos: [...state.todos],
227
254
  nextId: state.nextId,
228
255
  };
229
256
  // RPC 模式(xyz-agent GUI)附加 __gui__,前端按 list-tree 渲染。
230
- // TUI/print/json 模式走原生文本渲染(resultText 已在 content 中)。
257
+ // TUI/print/json 模式走原生文本渲染(contentText 已在 content 中)。
231
258
  if (ctx.mode === "rpc") {
232
259
  details.__gui__ = buildGui(state.todos);
233
260
  }
234
261
  return {
235
- content: [{ type: "text" as const, text: resultText }],
262
+ content: [{ type: "text" as const, text: contentText }],
236
263
  details,
237
264
  };
238
265
  }
@@ -248,28 +275,22 @@ export function registerTodoTool(
248
275
  name: "todo",
249
276
  label: "Todo",
250
277
  description:
251
- "Manage a todo list." +
252
- "\n\nAvailable actions:" +
253
- "\n- list: View all todos" +
254
- "\n- add: Batch add todos (requires texts array)" +
255
- "\n- update: Update todo(s) — single (id + optional status/text) or batch (updates[], takes priority)" +
256
- "\n- delete: Batch delete todos (requires ids array)" +
257
- "\n- clear: Clear all todos and reset IDs" +
258
- "\n\nExamples:" +
259
- '\n{"action":"add","texts":["write spec","implement"]}' +
260
- '\n{"action":"update","id":1,"status":"in_progress"}' +
261
- '\n{"action":"update","updates":[{"id":1,"status":"completed"},{"id":2,"status":"in_progress"}]}' +
262
- '\n{"action":"delete","ids":[3]}' +
263
- "\n\nDon't:" +
264
- '\n{"action":"add","text":"x"} ← text is for update; add uses texts:[...]' +
265
- '\n{"action":"delete","id":3} ← id is for update; delete uses ids:[...]' +
266
- '\n{"action":"update","status":"x"} ← missing id',
267
- promptSnippet: "Use todo when breaking multi-step work into trackable items. Consider adding a separate todo for verification checks like running tests or typecheck.",
278
+ "管理当前会话的 todo 列表。" +
279
+ "\n\n动作:" +
280
+ "\n- list: 查看全部 todo" +
281
+ "\n- add: 批量添加 todo(texts 数组)" +
282
+ "\n- update: 按 id 更新 todo——status 和/或 text;批量用 updates[]" +
283
+ "\n- delete: 按 id 删除 todo(ids 数组)" +
284
+ "\n\n规则:" +
285
+ "\n- 同一时间只有一个 todo 处于 in_progress" +
286
+ "\n- 完成一个 todo 立即标记 completed,不要攒到最后批量标记" +
287
+ "\n- 未真正完成不得标记 completed:被阻塞或测试失败时保持 in_progress",
288
+ promptSnippet: "用 todo 跟踪多步骤工作;记得为验证步骤(测试、类型检查)单独建 todo。",
268
289
  promptGuidelines: [
269
- "[Usage] 多步骤工作(3+步)时使用。AI 自发创建,无需用户触发",
290
+ "[Usage] 多步骤工作(3+步)时使用,AI 自发创建,无需用户触发",
270
291
  "[验证任务] 为测试 / 类型检查等验证步骤单独建 todo,完成前确保验证通过",
271
292
  "[批量优先] 完成多项任务时使用 updates[] 批量更新,减少工具调用次数",
272
- "[自动闭合] 全部完成后工具自动清理,无需手动 clear",
293
+ "[自动闭合] 全部完成后自动清理,无需手动 delete",
273
294
  "[Not for] 单步操作、简单对话",
274
295
  ],
275
296
  executionMode: "sequential",