@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 +87 -24
- package/package.json +4 -1
- package/src/__tests__/gui.test.ts +39 -0
- package/src/__tests__/steer.test.ts +254 -0
- package/src/__tests__/todo.test.ts +4 -26
- package/src/__tests__/tool-detectors.test.ts +56 -0
- package/src/__tests__/tool-prompt.test.ts +105 -0
- package/src/__tests__/tool-rpc.test.ts +285 -0
- package/src/component.ts +1 -1
- package/src/handlers.ts +7 -12
- package/src/index.ts +17 -18
- package/src/model.ts +39 -21
- package/src/render.ts +2 -6
- package/src/tool.ts +101 -198
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 "@
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
state.
|
|
113
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (params.status === undefined && params.text === undefined)
|
|
149
|
-
|
|
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
|
-
|
|
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)
|
|
125
|
+
if (!todo) throw new Error(`Todo #${params.id} not found`);
|
|
159
126
|
|
|
160
|
-
// FR-6
|
|
127
|
+
// FR-6 不变量守卫(失败抛错):(a) cancelled 不可恢复;(b) 验证任务不可 cancelled
|
|
161
128
|
if (todo.status === "cancelled" && params.status !== undefined) {
|
|
162
|
-
|
|
129
|
+
throw new Error(`#${params.id} is cancelled (cannot restore)`);
|
|
163
130
|
}
|
|
164
131
|
if (todo.isVerification && params.status === "cancelled") {
|
|
165
|
-
|
|
132
|
+
throw new Error(`#${params.id} is verification todo (cannot cancel)`);
|
|
166
133
|
}
|
|
167
134
|
|
|
168
|
-
if (params.status !== undefined)
|
|
169
|
-
|
|
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
|
|
145
|
+
return parts.join(", ") + "\n\nAll todos completed. Please summarize your work.";
|
|
183
146
|
}
|
|
184
|
-
return
|
|
147
|
+
return parts.join(", ");
|
|
185
148
|
}
|
|
186
149
|
|
|
187
|
-
/** update action: dispatcher */
|
|
188
|
-
function handleUpdate(
|
|
189
|
-
state
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|