@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/src/tool.ts CHANGED
@@ -2,14 +2,14 @@
2
2
  * Todo tool 注册 + execute dispatcher + 5 个 action handler。
3
3
  */
4
4
 
5
- import { StringEnum } from "@mariozechner/pi-ai";
5
+ import { StringEnum } from "@earendil-works/pi-ai";
6
+ import { Text } from "@earendil-works/pi-tui";
6
7
  import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
7
- import { Text } from "@mariozechner/pi-tui";
8
8
  import { type Static, Type } from "typebox";
9
9
 
10
10
  import {
11
11
  addTodos,
12
- buildRender,
12
+ buildGui,
13
13
  formatTodoLine,
14
14
  type Todo,
15
15
  type TodoDetails,
@@ -62,30 +62,10 @@ const TodoParams = Type.Object({
62
62
  ),
63
63
  });
64
64
 
65
- // ── 错误结果构造 helper ──────────────────────────────
66
-
67
- function errorResult(
68
- action: TodoDetails["action"],
69
- state: TodoSessionState,
70
- errorText: string,
71
- errorCode: string,
72
- ): {
73
- content: Array<{ type: "text"; text: string }>;
74
- details: TodoDetails;
75
- } {
76
- return {
77
- content: [{ type: "text" as const, text: errorText }],
78
- details: {
79
- action,
80
- todos: [...state.todos],
81
- nextId: state.nextId,
82
- error: errorCode,
83
- _render: buildRender(state.todos),
84
- } as TodoDetails,
85
- };
86
- }
87
-
88
65
  // ── 5 个 action handler ──────────────────────────────
66
+ // 错误处理约定(见 CLAUDE.md「Tool 设计」):handler 失败直接 throw,
67
+ // 不返回「错误成功模式」。model 层纯函数返回 Result 对象(合法),
68
+ // 由 dispatcher 在拿到 error 时 throw,把友好文案交给 Pi 框架展示。
89
69
 
90
70
  /** list action */
91
71
  function handleList(state: TodoSessionState): string {
@@ -94,83 +74,66 @@ function handleList(state: TodoSessionState): string {
94
74
  : "No todos";
95
75
  }
96
76
 
97
- /** add action */
98
- function handleAdd(
99
- state: TodoSessionState,
100
- params: TodoActionParams,
101
- ): { resultText: string; error?: string } {
77
+ /** add action — 失败抛错 */
78
+ /** add action — 失败抛错。export 供 behavioral 测试(text/texts 双形陷阱检测)。 */
79
+ export function handleAdd(state: TodoSessionState, params: TodoActionParams): string {
102
80
  if (!params.texts || params.texts.length === 0) {
103
- return { resultText: "", error: "texts required" };
104
- }
105
-
106
- const addResult = addTodos(state.todos, state.nextId, params.texts, params.isVerification);
107
- if (addResult.error) {
108
- return { resultText: addResult.resultText || "", error: addResult.error };
81
+ // 双形陷阱:弱模型 add 时误用单数 text(那是 update 的字段)
82
+ if (params.text !== undefined) {
83
+ throw new Error(
84
+ 'add needs texts (array). You passed singular "text" — that field is for update. Correct: {"action":"add","texts":["<your text>"]}',
85
+ );
86
+ }
87
+ throw new Error(
88
+ 'add requires texts parameter (non-empty array). Correct: {"action":"add","texts":["..."]}',
89
+ );
109
90
  }
110
-
111
- state.todos = addResult.newTodos;
112
- state.nextId = addResult.newNextId;
113
- return { resultText: addResult.resultText || "" };
91
+ const r = addTodos(state.todos, state.nextId, params.texts, params.isVerification);
92
+ if (r.error) throw new Error(r.resultText);
93
+ state.todos = r.newTodos;
94
+ state.nextId = r.newNextId;
95
+ return r.resultText!;
114
96
  }
115
97
 
116
- /** update action: batch */
117
- function handleBatchUpdate(
118
- state: TodoSessionState,
119
- params: TodoActionParams,
120
- ): { resultText: string; error?: string; earlyReturn?: { content: Array<{ type: "text"; text: string }>; details: TodoDetails } } {
121
- const result = updateTodos(state.todos, params.updates ?? []);
122
- if (result.error) {
123
- return {
124
- resultText: result.resultText || "",
125
- error: result.error,
126
- earlyReturn: {
127
- content: [{ type: "text" as const, text: result.resultText || "" }],
128
- details: {
129
- action: "update" as const,
130
- todos: [...state.todos],
131
- nextId: state.nextId,
132
- error: result.error,
133
- _render: buildRender(state.todos),
134
- } as TodoDetails,
135
- },
136
- };
137
- }
138
- state.todos = result.updatedTodos;
139
- return { resultText: result.resultText || "" };
98
+ /** update action: batch — 失败抛错 */
99
+ function handleBatchUpdate(state: TodoSessionState, params: TodoActionParams): string {
100
+ const r = updateTodos(state.todos, params.updates ?? []);
101
+ if (r.error) throw new Error(r.resultText);
102
+ state.todos = r.updatedTodos;
103
+ return r.resultText!;
140
104
  }
141
105
 
142
- /** update action: single */
143
- export function handleSingleUpdate(
144
- state: TodoSessionState,
145
- params: TodoActionParams,
146
- ): { resultText: string; error?: string } {
147
- if (params.id === undefined) return { resultText: "", error: "id required" };
148
- if (params.status === undefined && params.text === undefined) return { resultText: "", error: "need status or text" };
149
- if (params.text !== undefined && params.text === "") return { resultText: "", error: "text empty" };
106
+ /** update action: single — 失败抛错 */
107
+ export function handleSingleUpdate(state: TodoSessionState, params: TodoActionParams): string {
108
+ if (params.id === undefined)
109
+ throw new Error(
110
+ 'update requires id parameter. Correct: {"action":"update","id":<n>,"status":"in_progress"}',
111
+ );
112
+ if (params.status === undefined && params.text === undefined)
113
+ throw new Error(
114
+ 'update requires at least status or text parameter. Correct: {"action":"update","id":<n>,"status":"in_progress"}',
115
+ );
116
+ if (params.text !== undefined && params.text === "") throw new Error("text cannot be empty string");
150
117
  if (
151
118
  params.status !== undefined &&
152
119
  !VALID_STATUSES.includes(params.status as (typeof VALID_STATUSES)[number])
153
120
  ) {
154
- return { resultText: "", error: `invalid status: ${params.status}` };
121
+ throw new Error(`status only accepts ${VALID_STATUSES.join(" / ")}`);
155
122
  }
156
123
 
157
124
  const todo = state.todos.find((t) => t.id === params.id);
158
- if (!todo) return { resultText: "", error: `#${params.id} not found` };
125
+ if (!todo) throw new Error(`Todo #${params.id} not found`);
159
126
 
160
- // FR-6 不变量守卫:(a) cancelled 不可恢复;(b) 验证任务不可 cancelled
127
+ // FR-6 不变量守卫(失败抛错):(a) cancelled 不可恢复;(b) 验证任务不可 cancelled
161
128
  if (todo.status === "cancelled" && params.status !== undefined) {
162
- return { resultText: "", error: `#${params.id} is cancelled (cannot restore)` };
129
+ throw new Error(`#${params.id} is cancelled (cannot restore)`);
163
130
  }
164
131
  if (todo.isVerification && params.status === "cancelled") {
165
- return { resultText: "", error: `#${params.id} is verification todo (cannot cancel)` };
132
+ throw new Error(`#${params.id} is verification todo (cannot cancel)`);
166
133
  }
167
134
 
168
- if (params.status !== undefined) {
169
- todo.status = params.status as Todo["status"];
170
- }
171
- if (params.text !== undefined) {
172
- todo.text = params.text;
173
- }
135
+ if (params.status !== undefined) todo.status = params.status as Todo["status"];
136
+ if (params.text !== undefined) todo.text = params.text;
174
137
 
175
138
  const parts: string[] = [`Updated todo #${todo.id}`];
176
139
  if (params.status !== undefined) parts.push(`status → ${params.status}`);
@@ -179,36 +142,35 @@ export function handleSingleUpdate(
179
142
  // 最后一个完成提示
180
143
  const incompleteAfter = state.todos.filter((t) => t.status !== "completed");
181
144
  if (params.status === "completed" && incompleteAfter.length === 0) {
182
- return { resultText: parts.join(", ") + "\n\nAll todos completed. Please summarize your work." };
145
+ return parts.join(", ") + "\n\nAll todos completed. Please summarize your work.";
183
146
  }
184
- return { resultText: parts.join(", ") };
147
+ return parts.join(", ");
185
148
  }
186
149
 
187
- /** update action: dispatcher */
188
- function handleUpdate(
189
- state: TodoSessionState,
190
- params: TodoActionParams,
191
- ):
192
- | { resultText: string; error?: string; earlyReturn?: { content: Array<{ type: "text"; text: string }>; details: TodoDetails } }
193
- | undefined {
194
- if (params.updates && params.updates.length > 0) {
195
- return handleBatchUpdate(state, params);
196
- }
150
+ /** update action: dispatcher — batch 优先于 single */
151
+ function handleUpdate(state: TodoSessionState, params: TodoActionParams): string {
152
+ if (params.updates && params.updates.length > 0) return handleBatchUpdate(state, params);
197
153
  return handleSingleUpdate(state, params);
198
154
  }
199
155
 
200
- /** delete action */
201
- function handleDelete(
202
- state: TodoSessionState,
203
- params: TodoActionParams,
204
- ): { resultText: string; error?: string } {
156
+ /** delete action — 失败抛错;部分 id 缺失则整体拒绝(原子性) */
157
+ /** delete action — 失败抛错。export 供 behavioral 测试(id/ids 双形陷阱检测)。 */
158
+ export function handleDelete(state: TodoSessionState, params: TodoActionParams): string {
205
159
  if (!params.ids || params.ids.length === 0) {
206
- return { resultText: "", error: "ids required" };
160
+ // 双形陷阱:弱模型 delete 时误用单数 id(那是 update 的字段)
161
+ if (params.id !== undefined) {
162
+ throw new Error(
163
+ 'delete needs ids (array). You passed singular "id" — that field is for update. Correct: {"action":"delete","ids":[<your id>]}',
164
+ );
165
+ }
166
+ throw new Error(
167
+ 'delete requires ids parameter (non-empty array). Correct: {"action":"delete","ids":[<n>]}',
168
+ );
207
169
  }
208
170
  const uniqueIds = [...new Set(params.ids)];
209
171
  const missing = uniqueIds.filter((id) => !state.todos.some((t) => t.id === id));
210
172
  if (missing.length > 0) {
211
- return { resultText: "", error: `#${missing.map((id) => id).join(", #")} not found` };
173
+ throw new Error(`Todo #${missing.join(", #")} not found`);
212
174
  }
213
175
  const removedIds: number[] = [];
214
176
  for (const id of uniqueIds) {
@@ -218,7 +180,7 @@ function handleDelete(
218
180
  removedIds.push(id);
219
181
  }
220
182
  }
221
- return { resultText: `Deleted ${removedIds.length} items (#${removedIds.join(", #")}), ${state.todos.length} remaining` };
183
+ return `Deleted ${removedIds.length} items (#${removedIds.join(", #")}), ${state.todos.length} remaining`;
222
184
  }
223
185
 
224
186
  /** clear action */
@@ -245,94 +207,45 @@ function executeTodoAction(
245
207
  state.lastTodoCallCount = state.userMessageCount;
246
208
  state.stallNotified = false;
247
209
 
248
- let resultText = "";
249
-
210
+ let resultText: string;
250
211
  switch (params.action) {
251
- case "list": {
212
+ case "list":
252
213
  resultText = handleList(state);
253
214
  break;
254
- }
255
-
256
- case "add": {
257
- const r = handleAdd(state, params);
258
- if (r.error === "texts required") {
259
- return errorResult("add", state, "Error: add requires texts parameter (non-empty array)", r.error);
260
- }
261
- if (r.error) {
262
- return errorResult("add", state, r.resultText, r.error);
263
- }
264
- resultText = r.resultText;
215
+ case "add":
216
+ resultText = handleAdd(state, params);
265
217
  break;
266
- }
267
-
268
- case "update": {
269
- const r = handleUpdate(state, params);
270
- if (!r) {
271
- resultText = "Unknown error";
272
- break;
273
- }
274
- if (r.earlyReturn) return r.earlyReturn;
275
- if (r.error) {
276
- const errorText = mapUpdateErrorText(state, params, r.error);
277
- return errorResult("update", state, errorText, r.error);
278
- }
279
- resultText = r.resultText;
218
+ case "update":
219
+ resultText = handleUpdate(state, params);
280
220
  break;
281
- }
282
-
283
- case "delete": {
284
- const r = handleDelete(state, params);
285
- if (r.error === "ids required") {
286
- return errorResult("delete", state, "Error: delete requires ids parameter (non-empty array)", r.error);
287
- }
288
- if (r.error) {
289
- return errorResult("delete", state, `Error: Todo ${r.error.replace(/^#/, "#")}`, r.error);
290
- }
291
- resultText = r.resultText;
221
+ case "delete":
222
+ resultText = handleDelete(state, params);
292
223
  break;
293
- }
294
-
295
- case "clear": {
224
+ case "clear":
296
225
  resultText = handleClear(state);
297
226
  break;
298
- }
299
-
300
227
  default:
301
- return errorResult("list", state, `Unknown action: ${params.action}`, `unknown action: ${params.action}`);
228
+ throw new Error(`Unknown action: ${params.action}`);
302
229
  }
303
230
 
304
231
  refreshDisplay(ctx);
305
232
 
233
+ const details: TodoDetails = {
234
+ action: params.action as TodoDetails["action"],
235
+ todos: [...state.todos],
236
+ nextId: state.nextId,
237
+ };
238
+ // RPC 模式(xyz-agent GUI)附加 __gui__,前端按 list-tree 渲染。
239
+ // TUI/print/json 模式走原生文本渲染(resultText 已在 content 中)。
240
+ if (ctx.mode === "rpc") {
241
+ details.__gui__ = buildGui(state.todos);
242
+ }
306
243
  return {
307
244
  content: [{ type: "text" as const, text: resultText }],
308
- details: {
309
- action: params.action as TodoDetails["action"],
310
- todos: [...state.todos],
311
- nextId: state.nextId,
312
- _render: buildRender(state.todos),
313
- } as TodoDetails,
245
+ details,
314
246
  };
315
247
  }
316
248
 
317
- function mapUpdateErrorText(state: TodoSessionState, _params: TodoActionParams, code: string): string {
318
- switch (code) {
319
- case "id required":
320
- return "Error: update requires id parameter";
321
- case "need status or text":
322
- return "Error: update requires at least status or text parameter";
323
- case "text empty":
324
- return "Error: text cannot be empty string";
325
- default:
326
- if (code.startsWith("invalid status:")) {
327
- return `Error: status only accepts ${VALID_STATUSES.join(" / ")}`;
328
- }
329
- if (code.startsWith("#") && code.includes("not found")) {
330
- return `Error: Todo ${code} not found`;
331
- }
332
- return `Error: ${code}`;
333
- }
334
- }
335
-
336
249
  // ── Tool 注册入口 ─────────────────────────────────────
337
250
 
338
251
  export function registerTodoTool(
@@ -348,9 +261,19 @@ export function registerTodoTool(
348
261
  "\n\nAvailable actions:" +
349
262
  "\n- list: View all todos" +
350
263
  "\n- add: Batch add todos (requires texts array; optional isVerification marks verification tasks)" +
351
- "\n- update: Update a todo (requires id, optional status/text)" +
264
+ "\n- update: Update todo(s) — single (id + optional status/text) or batch (updates[], takes priority)" +
352
265
  "\n- delete: Batch delete todos (requires ids array)" +
353
- "\n- clear: Clear all todos and reset IDs",
266
+ "\n- clear: Clear all todos and reset IDs" +
267
+ "\n\nExamples:" +
268
+ '\n{"action":"add","texts":["write spec","implement"]}' +
269
+ '\n{"action":"add","texts":["run tests"],"isVerification":true}' +
270
+ '\n{"action":"update","id":1,"status":"in_progress"}' +
271
+ '\n{"action":"update","updates":[{"id":1,"status":"completed"},{"id":2,"status":"in_progress"}]}' +
272
+ '\n{"action":"delete","ids":[3]}' +
273
+ "\n\nDon't:" +
274
+ '\n{"action":"add","text":"x"} ← text is for update; add uses texts:[...]' +
275
+ '\n{"action":"delete","id":3} ← id is for update; delete uses ids:[...]' +
276
+ '\n{"action":"update","status":"x"} ← missing id',
354
277
  promptSnippet: "Use todo when breaking multi-step work into trackable items. Add verification todos (isVerification=true) for checks like running tests.",
355
278
  promptGuidelines: [
356
279
  "[Usage] 多步骤工作(3+步)时使用。AI 自发创建,无需用户触发",
@@ -363,28 +286,8 @@ export function registerTodoTool(
363
286
  parameters: TodoParams,
364
287
 
365
288
  async execute(_toolCallId: string, params: Static<typeof TodoParams>, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
366
- if (signal?.aborted) {
367
- return {
368
- content: [{ type: "text" as const, text: "Todo call aborted by signal." }],
369
- details: {
370
- action: "list" as const,
371
- todos: [],
372
- nextId: 1,
373
- error: "aborted",
374
- _render: undefined,
375
- } as TodoDetails,
376
- };
377
- }
378
- const result = executeTodoAction(params as TodoActionParams, state, ctx, refreshDisplay);
379
- const details = result.details as { error?: string } | undefined;
380
- if (details?.error) {
381
- const textPart = result.content[0];
382
- if (textPart?.type === "text") {
383
- const inputSummary = JSON.stringify(params);
384
- textPart.text += `\nInput: ${inputSummary}`;
385
- }
386
- }
387
- return result;
289
+ if (signal?.aborted) throw new Error("Todo call aborted by signal.");
290
+ return executeTodoAction(params as TodoActionParams, state, ctx, refreshDisplay);
388
291
  },
389
292
 
390
293
  renderCall(args: Record<string, unknown>, theme: Theme, _context?: unknown) {