@zhushanwen/pi-todo 0.1.6 → 0.3.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/package.json +1 -1
- package/src/__tests__/todo.test.ts +263 -586
- package/src/commands.ts +5 -26
- package/src/component.ts +11 -34
- package/src/handlers.ts +73 -109
- package/src/index.ts +5 -3
- package/src/model.ts +48 -144
- package/src/render.ts +108 -70
- package/src/state.ts +7 -1
- package/src/tool.ts +56 -144
package/src/tool.ts
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Todo tool 注册 + execute dispatcher + 5 个 action handler。
|
|
3
|
-
*
|
|
4
|
-
* 拆分理由:原 src/index.ts 的 executeTodoAction 函数 318 行(远超 80 行
|
|
5
|
-
* 限制),且工厂函数体也 612 行。本文件将 dispatcher 与子 handler 分离,
|
|
6
|
-
* 满足 §11 "单文件 ≤ 500 行" 与 "函数 ≤ 80 行" 规范。
|
|
7
|
-
*
|
|
8
|
-
* 行为契约:所有 handler 行为与原 index.ts 内的 switch case 完全一致;
|
|
9
|
-
* 任何与原代码的偏差都属于 bug。
|
|
10
3
|
*/
|
|
11
4
|
|
|
12
5
|
import { StringEnum } from "@mariozechner/pi-ai";
|
|
13
6
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
14
7
|
import { Text } from "@mariozechner/pi-tui";
|
|
15
|
-
import { type Static,Type } from "typebox";
|
|
8
|
+
import { type Static, Type } from "typebox";
|
|
16
9
|
|
|
17
10
|
import {
|
|
18
11
|
addTodos,
|
|
@@ -26,7 +19,7 @@ import {
|
|
|
26
19
|
import { renderTodoResult } from "./render";
|
|
27
20
|
import type { TodoSessionState } from "./state";
|
|
28
21
|
|
|
29
|
-
// ── Action
|
|
22
|
+
// ── Action 参数类型 ──────────────────────────────────
|
|
30
23
|
|
|
31
24
|
export interface TodoActionParams {
|
|
32
25
|
action: string;
|
|
@@ -35,45 +28,39 @@ export interface TodoActionParams {
|
|
|
35
28
|
texts?: string[];
|
|
36
29
|
ids?: number[];
|
|
37
30
|
status?: string;
|
|
38
|
-
|
|
39
|
-
updates?: Array<{ id: number; status?: string; text?: string
|
|
40
|
-
verified?: boolean;
|
|
41
|
-
evidence?: string;
|
|
31
|
+
isVerification?: boolean;
|
|
32
|
+
updates?: Array<{ id: number; status?: string; text?: string }>;
|
|
42
33
|
}
|
|
43
34
|
|
|
44
|
-
// ── TodoParams schema
|
|
35
|
+
// ── TodoParams schema ────────────────────────────────
|
|
45
36
|
|
|
46
|
-
|
|
37
|
+
const TodoParams = Type.Object({
|
|
47
38
|
action: StringEnum(["list", "add", "update", "delete", "clear"] as const),
|
|
48
39
|
text: Type.Optional(Type.String({ description: "Todo text (for update action)" })),
|
|
49
40
|
id: Type.Optional(Type.Number({ description: "Todo ID (for update action)" })),
|
|
50
41
|
texts: Type.Optional(Type.Array(Type.String(), { description: "Todo text list (for add action)" })),
|
|
51
42
|
ids: Type.Optional(Type.Array(Type.Number(), { description: "Todo ID list (for delete action)" })),
|
|
52
43
|
status: Type.Optional(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
Type.
|
|
57
|
-
description: "
|
|
44
|
+
StringEnum(VALID_STATUSES, { description: "Target status (for update action)" }),
|
|
45
|
+
),
|
|
46
|
+
isVerification: Type.Optional(
|
|
47
|
+
Type.Boolean({
|
|
48
|
+
description: "Mark added todos as verification tasks (for add action). Verification todos must be completed (not cancelled) before goal completion.",
|
|
58
49
|
}),
|
|
59
50
|
),
|
|
60
51
|
updates: Type.Optional(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
{ description: "Batch updates array (takes priority over single id/status/text)" },
|
|
52
|
+
Type.Array(
|
|
53
|
+
Type.Object({
|
|
54
|
+
id: Type.Number({ description: "Todo ID to update" }),
|
|
55
|
+
status: Type.Optional(
|
|
56
|
+
Type.String({ description: "Target status; one of pending/in_progress/completed/cancelled" }),
|
|
57
|
+
),
|
|
58
|
+
text: Type.Optional(Type.String({ description: "New todo text" })),
|
|
59
|
+
}),
|
|
60
|
+
{ description: "Batch updates array (takes priority over single id/status/text)" },
|
|
61
|
+
),
|
|
72
62
|
),
|
|
73
|
-
)
|
|
74
|
-
verified: Type.Optional(Type.Boolean({ description: "Required true when skipping verifying to mark completed on a task with verifyText" })),
|
|
75
|
-
evidence: Type.Optional(Type.String({ description: "Verification evidence (≥10 chars, required for verifying/completed on tasks with verifyText)" })),
|
|
76
|
-
});
|
|
63
|
+
});
|
|
77
64
|
|
|
78
65
|
// ── 错误结果构造 helper ──────────────────────────────
|
|
79
66
|
|
|
@@ -98,16 +85,16 @@ function errorResult(
|
|
|
98
85
|
};
|
|
99
86
|
}
|
|
100
87
|
|
|
101
|
-
// ── 5 个 action handler
|
|
88
|
+
// ── 5 个 action handler ──────────────────────────────
|
|
102
89
|
|
|
103
|
-
/** list action
|
|
90
|
+
/** list action */
|
|
104
91
|
function handleList(state: TodoSessionState): string {
|
|
105
92
|
return state.todos.length
|
|
106
93
|
? state.todos.map((t) => formatTodoLine(t)).join("\n")
|
|
107
94
|
: "No todos";
|
|
108
95
|
}
|
|
109
96
|
|
|
110
|
-
/** add action
|
|
97
|
+
/** add action */
|
|
111
98
|
function handleAdd(
|
|
112
99
|
state: TodoSessionState,
|
|
113
100
|
params: TodoActionParams,
|
|
@@ -116,7 +103,7 @@ function handleAdd(
|
|
|
116
103
|
return { resultText: "", error: "texts required" };
|
|
117
104
|
}
|
|
118
105
|
|
|
119
|
-
const addResult = addTodos(state.todos, state.nextId, params.texts, params.
|
|
106
|
+
const addResult = addTodos(state.todos, state.nextId, params.texts, params.isVerification);
|
|
120
107
|
if (addResult.error) {
|
|
121
108
|
return { resultText: addResult.resultText || "", error: addResult.error };
|
|
122
109
|
}
|
|
@@ -126,7 +113,7 @@ function handleAdd(
|
|
|
126
113
|
return { resultText: addResult.resultText || "" };
|
|
127
114
|
}
|
|
128
115
|
|
|
129
|
-
/** update action:
|
|
116
|
+
/** update action: batch */
|
|
130
117
|
function handleBatchUpdate(
|
|
131
118
|
state: TodoSessionState,
|
|
132
119
|
params: TodoActionParams,
|
|
@@ -149,31 +136,11 @@ function handleBatchUpdate(
|
|
|
149
136
|
};
|
|
150
137
|
}
|
|
151
138
|
state.todos = result.updatedTodos;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// 检查是否有状态转换拦截
|
|
155
|
-
if (result.blocked && result.blocked.length > 0) {
|
|
156
|
-
return {
|
|
157
|
-
resultText,
|
|
158
|
-
error: "blocked",
|
|
159
|
-
earlyReturn: {
|
|
160
|
-
content: [{ type: "text" as const, text: resultText }],
|
|
161
|
-
details: {
|
|
162
|
-
action: "update" as const,
|
|
163
|
-
todos: [...state.todos],
|
|
164
|
-
nextId: state.nextId,
|
|
165
|
-
error: "blocked",
|
|
166
|
-
_render: buildRender(state.todos),
|
|
167
|
-
} as TodoDetails,
|
|
168
|
-
},
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return { resultText };
|
|
139
|
+
return { resultText: result.resultText || "" };
|
|
173
140
|
}
|
|
174
141
|
|
|
175
|
-
/** update action:
|
|
176
|
-
function handleSingleUpdate(
|
|
142
|
+
/** update action: single */
|
|
143
|
+
export function handleSingleUpdate(
|
|
177
144
|
state: TodoSessionState,
|
|
178
145
|
params: TodoActionParams,
|
|
179
146
|
): { resultText: string; error?: string } {
|
|
@@ -190,49 +157,16 @@ function handleSingleUpdate(
|
|
|
190
157
|
const todo = state.todos.find((t) => t.id === params.id);
|
|
191
158
|
if (!todo) return { resultText: "", error: `#${params.id} not found` };
|
|
192
159
|
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
} else if (params.status === "completed") {
|
|
201
|
-
if (todo.verifyText && todo.status !== "verifying") {
|
|
202
|
-
if (params.verified !== true) return { resultText: "", error: "verify required" };
|
|
203
|
-
if (!params.evidence || params.evidence.trim().length < MIN_EVIDENCE_LEN) {
|
|
204
|
-
return { resultText: "", error: "evidence required" };
|
|
205
|
-
}
|
|
206
|
-
} else if (todo.status === "verifying") {
|
|
207
|
-
if (!params.evidence || params.evidence.trim().length < MIN_EVIDENCE_LEN) {
|
|
208
|
-
return { resultText: "", error: "evidence required" };
|
|
209
|
-
}
|
|
210
|
-
}
|
|
160
|
+
// FR-6 不变量守卫:(a) cancelled 不可恢复;(b) 验证任务不可 cancelled
|
|
161
|
+
if (todo.status === "cancelled" && params.status !== undefined) {
|
|
162
|
+
return { resultText: "", error: `#${params.id} is cancelled (cannot restore)` };
|
|
163
|
+
}
|
|
164
|
+
if (todo.isVerification && params.status === "cancelled") {
|
|
165
|
+
return { resultText: "", error: `#${params.id} is verification todo (cannot cancel)` };
|
|
211
166
|
}
|
|
212
|
-
|
|
213
|
-
// T5 完成引导:判断是否是最后一个 pending 即将完成
|
|
214
|
-
const incompleteBefore = state.todos.filter((t) => t.status !== "completed");
|
|
215
|
-
const isLastCompletion =
|
|
216
|
-
params.status === "completed" &&
|
|
217
|
-
incompleteBefore.length === 1 &&
|
|
218
|
-
incompleteBefore[0].id === todo.id;
|
|
219
167
|
|
|
220
168
|
if (params.status !== undefined) {
|
|
221
|
-
const oldStatus = todo.status;
|
|
222
169
|
todo.status = params.status as Todo["status"];
|
|
223
|
-
if (params.evidence && (params.status === "verifying" || params.status === "completed")) {
|
|
224
|
-
todo.evidence = params.evidence.trim();
|
|
225
|
-
}
|
|
226
|
-
// 检测验证失败: completed/verifying → in_progress
|
|
227
|
-
if (
|
|
228
|
-
params.status === "in_progress" &&
|
|
229
|
-
todo.verifyText &&
|
|
230
|
-
todo.verifyAttempts < 2 // 来自 MAX_VERIFY_ATTEMPTS(与 handlers.ts / model.ts 保持一致)
|
|
231
|
-
) {
|
|
232
|
-
if (oldStatus === "completed" || oldStatus === "verifying") {
|
|
233
|
-
todo.verifyAttempts++;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
170
|
}
|
|
237
171
|
if (params.text !== undefined) {
|
|
238
172
|
todo.text = params.text;
|
|
@@ -241,29 +175,29 @@ function handleSingleUpdate(
|
|
|
241
175
|
const parts: string[] = [`Updated todo #${todo.id}`];
|
|
242
176
|
if (params.status !== undefined) parts.push(`status → ${params.status}`);
|
|
243
177
|
if (params.text !== undefined) parts.push(`text → "${todo.text}"`);
|
|
244
|
-
const resultText = parts.join(", ");
|
|
245
178
|
|
|
246
|
-
|
|
247
|
-
|
|
179
|
+
// 最后一个完成提示
|
|
180
|
+
const incompleteAfter = state.todos.filter((t) => t.status !== "completed");
|
|
181
|
+
if (params.status === "completed" && incompleteAfter.length === 0) {
|
|
182
|
+
return { resultText: parts.join(", ") + "\n\nAll todos completed. Please summarize your work." };
|
|
248
183
|
}
|
|
249
|
-
return { resultText };
|
|
184
|
+
return { resultText: parts.join(", ") };
|
|
250
185
|
}
|
|
251
186
|
|
|
252
|
-
/** update action:
|
|
187
|
+
/** update action: dispatcher */
|
|
253
188
|
function handleUpdate(
|
|
254
189
|
state: TodoSessionState,
|
|
255
190
|
params: TodoActionParams,
|
|
256
191
|
):
|
|
257
192
|
| { resultText: string; error?: string; earlyReturn?: { content: Array<{ type: "text"; text: string }>; details: TodoDetails } }
|
|
258
193
|
| undefined {
|
|
259
|
-
// Batch updates[] takes priority over single id/status/text
|
|
260
194
|
if (params.updates && params.updates.length > 0) {
|
|
261
195
|
return handleBatchUpdate(state, params);
|
|
262
196
|
}
|
|
263
197
|
return handleSingleUpdate(state, params);
|
|
264
198
|
}
|
|
265
199
|
|
|
266
|
-
/** delete action
|
|
200
|
+
/** delete action */
|
|
267
201
|
function handleDelete(
|
|
268
202
|
state: TodoSessionState,
|
|
269
203
|
params: TodoActionParams,
|
|
@@ -287,20 +221,19 @@ function handleDelete(
|
|
|
287
221
|
return { resultText: `Deleted ${removedIds.length} items (#${removedIds.join(", #")}), ${state.todos.length} remaining` };
|
|
288
222
|
}
|
|
289
223
|
|
|
290
|
-
/** clear action
|
|
224
|
+
/** clear action */
|
|
291
225
|
function handleClear(state: TodoSessionState): string {
|
|
292
226
|
const count = state.todos.length;
|
|
293
227
|
state.todos = [];
|
|
294
228
|
state.nextId = 1;
|
|
295
229
|
state.allCompletedAtCount = null;
|
|
296
|
-
|
|
230
|
+
state.completionSteered = false;
|
|
297
231
|
return count > 0 ? `Cleared ${count} todos` : "No todos to clear";
|
|
298
232
|
}
|
|
299
233
|
|
|
300
|
-
// ── Dispatcher
|
|
234
|
+
// ── Dispatcher ───────────────────────────────────────
|
|
301
235
|
|
|
302
|
-
|
|
303
|
-
export function executeTodoAction(
|
|
236
|
+
function executeTodoAction(
|
|
304
237
|
params: TodoActionParams,
|
|
305
238
|
state: TodoSessionState,
|
|
306
239
|
ctx: ExtensionContext,
|
|
@@ -309,7 +242,6 @@ export function executeTodoAction(
|
|
|
309
242
|
content: Array<{ type: "text"; text: string }>;
|
|
310
243
|
details: TodoDetails;
|
|
311
244
|
} {
|
|
312
|
-
// v3: 记录本次 todo 工具调用轮次(userMessageCount 在 agent_start 中递增)
|
|
313
245
|
state.lastTodoCallCount = state.userMessageCount;
|
|
314
246
|
state.stallNotified = false;
|
|
315
247
|
|
|
@@ -330,7 +262,6 @@ export function executeTodoAction(
|
|
|
330
262
|
return errorResult("add", state, r.resultText, r.error);
|
|
331
263
|
}
|
|
332
264
|
resultText = r.resultText;
|
|
333
|
-
// v3: 新增 todo 表示未全部完成
|
|
334
265
|
break;
|
|
335
266
|
}
|
|
336
267
|
|
|
@@ -342,7 +273,6 @@ export function executeTodoAction(
|
|
|
342
273
|
}
|
|
343
274
|
if (r.earlyReturn) return r.earlyReturn;
|
|
344
275
|
if (r.error) {
|
|
345
|
-
// 把原始人类可读错误文本与 error code 关联
|
|
346
276
|
const errorText = mapUpdateErrorText(state, params, r.error);
|
|
347
277
|
return errorResult("update", state, errorText, r.error);
|
|
348
278
|
}
|
|
@@ -384,8 +314,7 @@ export function executeTodoAction(
|
|
|
384
314
|
};
|
|
385
315
|
}
|
|
386
316
|
|
|
387
|
-
|
|
388
|
-
function mapUpdateErrorText(state: TodoSessionState, params: TodoActionParams, code: string): string {
|
|
317
|
+
function mapUpdateErrorText(state: TodoSessionState, _params: TodoActionParams, code: string): string {
|
|
389
318
|
switch (code) {
|
|
390
319
|
case "id required":
|
|
391
320
|
return "Error: update requires id parameter";
|
|
@@ -397,19 +326,8 @@ function mapUpdateErrorText(state: TodoSessionState, params: TodoActionParams, c
|
|
|
397
326
|
if (code.startsWith("invalid status:")) {
|
|
398
327
|
return `Error: status only accepts ${VALID_STATUSES.join(" / ")}`;
|
|
399
328
|
}
|
|
400
|
-
if (code
|
|
401
|
-
return `Error: Todo ${code
|
|
402
|
-
}
|
|
403
|
-
if (code === "no verifyText") {
|
|
404
|
-
const todo = state.todos.find((t) => t.id === params.id);
|
|
405
|
-
return `Error: #${todo?.id ?? "?"} 无 verifyText,不能进入 verifying 状态`;
|
|
406
|
-
}
|
|
407
|
-
if (code === "verify required") {
|
|
408
|
-
const todo = state.todos.find((t) => t.id === params.id);
|
|
409
|
-
return `⚠️ Task #${todo?.id ?? "?"} "${todo?.text ?? ""}" 有验证要求。\n请先: todo update(id=${todo?.id}, status=verifying, evidence="验证进度")\n或跳过: todo update(id=${todo?.id}, status=completed, verified=true, evidence="验证结论")\n验证标准: ${todo?.verifyText}`;
|
|
410
|
-
}
|
|
411
|
-
if (code === "evidence required") {
|
|
412
|
-
return "⚠️ evidence required (≥10 chars)";
|
|
329
|
+
if (code.startsWith("#") && code.includes("not found")) {
|
|
330
|
+
return `Error: Todo ${code} not found`;
|
|
413
331
|
}
|
|
414
332
|
return `Error: ${code}`;
|
|
415
333
|
}
|
|
@@ -417,7 +335,6 @@ function mapUpdateErrorText(state: TodoSessionState, params: TodoActionParams, c
|
|
|
417
335
|
|
|
418
336
|
// ── Tool 注册入口 ─────────────────────────────────────
|
|
419
337
|
|
|
420
|
-
/** 注册 todo tool 到 pi */
|
|
421
338
|
export function registerTodoTool(
|
|
422
339
|
pi: ExtensionAPI,
|
|
423
340
|
state: TodoSessionState,
|
|
@@ -430,26 +347,22 @@ export function registerTodoTool(
|
|
|
430
347
|
"Manage a todo list." +
|
|
431
348
|
"\n\nAvailable actions:" +
|
|
432
349
|
"\n- list: View all todos" +
|
|
433
|
-
"\n- add: Batch add todos (requires texts array
|
|
350
|
+
"\n- add: Batch add todos (requires texts array; optional isVerification marks verification tasks)" +
|
|
434
351
|
"\n- update: Update a todo (requires id, optional status/text)" +
|
|
435
352
|
"\n- delete: Batch delete todos (requires ids array)" +
|
|
436
|
-
"\n- clear: Clear all todos and reset IDs"
|
|
437
|
-
|
|
438
|
-
promptSnippet: "Use todo when breaking multi-step work into trackable items during normal (non-goal) conversation. Not for single-step operations.",
|
|
353
|
+
"\n- clear: Clear all todos and reset IDs",
|
|
354
|
+
promptSnippet: "Use todo when breaking multi-step work into trackable items. Add verification todos (isVerification=true) for checks like running tests.",
|
|
439
355
|
promptGuidelines: [
|
|
440
356
|
"[Usage] 多步骤工作(3+步)时使用。AI 自发创建,无需用户触发",
|
|
441
|
-
"[
|
|
357
|
+
"[验证任务] 执行任务 + 验证任务(isVerification=true,如 run tests / typecheck)一起建",
|
|
442
358
|
"[批量优先] 完成多项任务时使用 updates[] 批量更新,减少工具调用次数",
|
|
443
|
-
"[验证流程] 有 verifyText 的任务: in_progress → verifying(evidence=\"验证进度\") → completed(evidence=\"验证结论\")。evidence ≥ 10 字符",
|
|
444
|
-
"[跳过验证] 有 verifyText 但想直接 completed: 必须传 verified=true + evidence",
|
|
445
|
-
"[验证失败] verifying/completed 被改回 in_progress 时 verifyAttempts++,2 次后进入 failed",
|
|
446
359
|
"[自动闭合] 全部完成后工具自动清理,无需手动 clear",
|
|
447
|
-
"[Not for]
|
|
360
|
+
"[Not for] 单步操作、简单对话",
|
|
448
361
|
],
|
|
362
|
+
executionMode: "sequential",
|
|
449
363
|
parameters: TodoParams,
|
|
450
364
|
|
|
451
365
|
async execute(_toolCallId: string, params: Static<typeof TodoParams>, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
|
|
452
|
-
// P1-5: 尊重 signal —— 异步被取消时提前返回
|
|
453
366
|
if (signal?.aborted) {
|
|
454
367
|
return {
|
|
455
368
|
content: [{ type: "text" as const, text: "Todo call aborted by signal." }],
|
|
@@ -463,7 +376,6 @@ export function registerTodoTool(
|
|
|
463
376
|
};
|
|
464
377
|
}
|
|
465
378
|
const result = executeTodoAction(params as TodoActionParams, state, ctx, refreshDisplay);
|
|
466
|
-
// Append input params to error results for debugging
|
|
467
379
|
const details = result.details as { error?: string } | undefined;
|
|
468
380
|
if (details?.error) {
|
|
469
381
|
const textPart = result.content[0];
|