@zhushanwen/pi-todo 0.1.5 → 0.2.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 +2 -2
- package/src/__tests__/todo.test.ts +199 -594
- package/src/commands.ts +5 -26
- package/src/component.ts +11 -34
- package/src/handlers.ts +72 -108
- package/src/index.ts +2 -3
- package/src/model.ts +25 -137
- package/src/render.ts +101 -66
- package/src/state.ts +7 -1
- package/src/tool.ts +28 -130
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,13 +28,10 @@ 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; verified?: boolean; evidence?: string }>;
|
|
40
|
-
verified?: boolean;
|
|
41
|
-
evidence?: string;
|
|
31
|
+
updates?: Array<{ id: number; status?: string; text?: string }>;
|
|
42
32
|
}
|
|
43
33
|
|
|
44
|
-
// ── TodoParams schema
|
|
34
|
+
// ── TodoParams schema ────────────────────────────────
|
|
45
35
|
|
|
46
36
|
export const TodoParams = Type.Object({
|
|
47
37
|
action: StringEnum(["list", "add", "update", "delete", "clear"] as const),
|
|
@@ -52,27 +42,18 @@ export const TodoParams = Type.Object({
|
|
|
52
42
|
status: Type.Optional(
|
|
53
43
|
StringEnum(VALID_STATUSES, { description: "Target status (for update action)" }),
|
|
54
44
|
),
|
|
55
|
-
verifyTexts: Type.Optional(
|
|
56
|
-
Type.Array(Type.String(), {
|
|
57
|
-
description: "Verification text list (one per texts entry, for add action)",
|
|
58
|
-
}),
|
|
59
|
-
),
|
|
60
45
|
updates: Type.Optional(
|
|
61
46
|
Type.Array(
|
|
62
47
|
Type.Object({
|
|
63
|
-
id: Type.Number({ description: "Todo ID to update
|
|
48
|
+
id: Type.Number({ description: "Todo ID to update" }),
|
|
64
49
|
status: Type.Optional(
|
|
65
|
-
Type.String({ description: "Target status
|
|
50
|
+
Type.String({ description: "Target status; one of pending/in_progress/completed" }),
|
|
66
51
|
),
|
|
67
|
-
text: Type.Optional(Type.String({ description: "New todo text
|
|
68
|
-
verified: Type.Optional(Type.Boolean({ description: "Required true when skipping verifying to mark completed on tasks with verifyText" })),
|
|
69
|
-
evidence: Type.Optional(Type.String({ description: "Verification evidence (≥10 chars, required for verifying→completed or in_progress→verifying)" })),
|
|
52
|
+
text: Type.Optional(Type.String({ description: "New todo text" })),
|
|
70
53
|
}),
|
|
71
54
|
{ description: "Batch updates array (takes priority over single id/status/text)" },
|
|
72
55
|
),
|
|
73
56
|
),
|
|
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
57
|
});
|
|
77
58
|
|
|
78
59
|
// ── 错误结果构造 helper ──────────────────────────────
|
|
@@ -98,16 +79,16 @@ function errorResult(
|
|
|
98
79
|
};
|
|
99
80
|
}
|
|
100
81
|
|
|
101
|
-
// ── 5 个 action handler
|
|
82
|
+
// ── 5 个 action handler ──────────────────────────────
|
|
102
83
|
|
|
103
|
-
/** list action
|
|
84
|
+
/** list action */
|
|
104
85
|
function handleList(state: TodoSessionState): string {
|
|
105
86
|
return state.todos.length
|
|
106
87
|
? state.todos.map((t) => formatTodoLine(t)).join("\n")
|
|
107
88
|
: "No todos";
|
|
108
89
|
}
|
|
109
90
|
|
|
110
|
-
/** add action
|
|
91
|
+
/** add action */
|
|
111
92
|
function handleAdd(
|
|
112
93
|
state: TodoSessionState,
|
|
113
94
|
params: TodoActionParams,
|
|
@@ -116,7 +97,7 @@ function handleAdd(
|
|
|
116
97
|
return { resultText: "", error: "texts required" };
|
|
117
98
|
}
|
|
118
99
|
|
|
119
|
-
const addResult = addTodos(state.todos, state.nextId, params.texts
|
|
100
|
+
const addResult = addTodos(state.todos, state.nextId, params.texts);
|
|
120
101
|
if (addResult.error) {
|
|
121
102
|
return { resultText: addResult.resultText || "", error: addResult.error };
|
|
122
103
|
}
|
|
@@ -126,7 +107,7 @@ function handleAdd(
|
|
|
126
107
|
return { resultText: addResult.resultText || "" };
|
|
127
108
|
}
|
|
128
109
|
|
|
129
|
-
/** update action:
|
|
110
|
+
/** update action: batch */
|
|
130
111
|
function handleBatchUpdate(
|
|
131
112
|
state: TodoSessionState,
|
|
132
113
|
params: TodoActionParams,
|
|
@@ -149,30 +130,10 @@ function handleBatchUpdate(
|
|
|
149
130
|
};
|
|
150
131
|
}
|
|
151
132
|
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 };
|
|
133
|
+
return { resultText: result.resultText || "" };
|
|
173
134
|
}
|
|
174
135
|
|
|
175
|
-
/** update action:
|
|
136
|
+
/** update action: single */
|
|
176
137
|
function handleSingleUpdate(
|
|
177
138
|
state: TodoSessionState,
|
|
178
139
|
params: TodoActionParams,
|
|
@@ -190,49 +151,8 @@ function handleSingleUpdate(
|
|
|
190
151
|
const todo = state.todos.find((t) => t.id === params.id);
|
|
191
152
|
if (!todo) return { resultText: "", error: `#${params.id} not found` };
|
|
192
153
|
|
|
193
|
-
// 状态转换拦截
|
|
194
|
-
const MIN_EVIDENCE_LEN = 10;
|
|
195
|
-
if (params.status === "verifying") {
|
|
196
|
-
if (!todo.verifyText) return { resultText: "", error: "no verifyText" };
|
|
197
|
-
if (!params.evidence || params.evidence.trim().length < MIN_EVIDENCE_LEN) {
|
|
198
|
-
return { resultText: "", error: "evidence required" };
|
|
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
|
-
}
|
|
211
|
-
}
|
|
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
|
-
|
|
220
154
|
if (params.status !== undefined) {
|
|
221
|
-
const oldStatus = todo.status;
|
|
222
155
|
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
156
|
}
|
|
237
157
|
if (params.text !== undefined) {
|
|
238
158
|
todo.text = params.text;
|
|
@@ -241,29 +161,29 @@ function handleSingleUpdate(
|
|
|
241
161
|
const parts: string[] = [`Updated todo #${todo.id}`];
|
|
242
162
|
if (params.status !== undefined) parts.push(`status → ${params.status}`);
|
|
243
163
|
if (params.text !== undefined) parts.push(`text → "${todo.text}"`);
|
|
244
|
-
const resultText = parts.join(", ");
|
|
245
164
|
|
|
246
|
-
|
|
247
|
-
|
|
165
|
+
// 最后一个完成提示
|
|
166
|
+
const incompleteAfter = state.todos.filter((t) => t.status !== "completed");
|
|
167
|
+
if (params.status === "completed" && incompleteAfter.length === 0) {
|
|
168
|
+
return { resultText: parts.join(", ") + "\n\nAll todos completed. Please summarize your work." };
|
|
248
169
|
}
|
|
249
|
-
return { resultText };
|
|
170
|
+
return { resultText: parts.join(", ") };
|
|
250
171
|
}
|
|
251
172
|
|
|
252
|
-
/** update action:
|
|
173
|
+
/** update action: dispatcher */
|
|
253
174
|
function handleUpdate(
|
|
254
175
|
state: TodoSessionState,
|
|
255
176
|
params: TodoActionParams,
|
|
256
177
|
):
|
|
257
178
|
| { resultText: string; error?: string; earlyReturn?: { content: Array<{ type: "text"; text: string }>; details: TodoDetails } }
|
|
258
179
|
| undefined {
|
|
259
|
-
// Batch updates[] takes priority over single id/status/text
|
|
260
180
|
if (params.updates && params.updates.length > 0) {
|
|
261
181
|
return handleBatchUpdate(state, params);
|
|
262
182
|
}
|
|
263
183
|
return handleSingleUpdate(state, params);
|
|
264
184
|
}
|
|
265
185
|
|
|
266
|
-
/** delete action
|
|
186
|
+
/** delete action */
|
|
267
187
|
function handleDelete(
|
|
268
188
|
state: TodoSessionState,
|
|
269
189
|
params: TodoActionParams,
|
|
@@ -287,19 +207,18 @@ function handleDelete(
|
|
|
287
207
|
return { resultText: `Deleted ${removedIds.length} items (#${removedIds.join(", #")}), ${state.todos.length} remaining` };
|
|
288
208
|
}
|
|
289
209
|
|
|
290
|
-
/** clear action
|
|
210
|
+
/** clear action */
|
|
291
211
|
function handleClear(state: TodoSessionState): string {
|
|
292
212
|
const count = state.todos.length;
|
|
293
213
|
state.todos = [];
|
|
294
214
|
state.nextId = 1;
|
|
295
215
|
state.allCompletedAtCount = null;
|
|
296
|
-
|
|
216
|
+
state.completionSteered = false;
|
|
297
217
|
return count > 0 ? `Cleared ${count} todos` : "No todos to clear";
|
|
298
218
|
}
|
|
299
219
|
|
|
300
|
-
// ── Dispatcher
|
|
220
|
+
// ── Dispatcher ───────────────────────────────────────
|
|
301
221
|
|
|
302
|
-
/** Tool execute dispatcher — 接受 TodoActionParams + state + ctx,调用对应 handler */
|
|
303
222
|
export function executeTodoAction(
|
|
304
223
|
params: TodoActionParams,
|
|
305
224
|
state: TodoSessionState,
|
|
@@ -309,7 +228,6 @@ export function executeTodoAction(
|
|
|
309
228
|
content: Array<{ type: "text"; text: string }>;
|
|
310
229
|
details: TodoDetails;
|
|
311
230
|
} {
|
|
312
|
-
// v3: 记录本次 todo 工具调用轮次(userMessageCount 在 agent_start 中递增)
|
|
313
231
|
state.lastTodoCallCount = state.userMessageCount;
|
|
314
232
|
state.stallNotified = false;
|
|
315
233
|
|
|
@@ -330,7 +248,6 @@ export function executeTodoAction(
|
|
|
330
248
|
return errorResult("add", state, r.resultText, r.error);
|
|
331
249
|
}
|
|
332
250
|
resultText = r.resultText;
|
|
333
|
-
// v3: 新增 todo 表示未全部完成
|
|
334
251
|
break;
|
|
335
252
|
}
|
|
336
253
|
|
|
@@ -342,7 +259,6 @@ export function executeTodoAction(
|
|
|
342
259
|
}
|
|
343
260
|
if (r.earlyReturn) return r.earlyReturn;
|
|
344
261
|
if (r.error) {
|
|
345
|
-
// 把原始人类可读错误文本与 error code 关联
|
|
346
262
|
const errorText = mapUpdateErrorText(state, params, r.error);
|
|
347
263
|
return errorResult("update", state, errorText, r.error);
|
|
348
264
|
}
|
|
@@ -384,8 +300,7 @@ export function executeTodoAction(
|
|
|
384
300
|
};
|
|
385
301
|
}
|
|
386
302
|
|
|
387
|
-
|
|
388
|
-
function mapUpdateErrorText(state: TodoSessionState, params: TodoActionParams, code: string): string {
|
|
303
|
+
function mapUpdateErrorText(state: TodoSessionState, _params: TodoActionParams, code: string): string {
|
|
389
304
|
switch (code) {
|
|
390
305
|
case "id required":
|
|
391
306
|
return "Error: update requires id parameter";
|
|
@@ -397,19 +312,8 @@ function mapUpdateErrorText(state: TodoSessionState, params: TodoActionParams, c
|
|
|
397
312
|
if (code.startsWith("invalid status:")) {
|
|
398
313
|
return `Error: status only accepts ${VALID_STATUSES.join(" / ")}`;
|
|
399
314
|
}
|
|
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)";
|
|
315
|
+
if (code.startsWith("#") && code.includes("not found")) {
|
|
316
|
+
return `Error: Todo ${code} not found`;
|
|
413
317
|
}
|
|
414
318
|
return `Error: ${code}`;
|
|
415
319
|
}
|
|
@@ -417,7 +321,6 @@ function mapUpdateErrorText(state: TodoSessionState, params: TodoActionParams, c
|
|
|
417
321
|
|
|
418
322
|
// ── Tool 注册入口 ─────────────────────────────────────
|
|
419
323
|
|
|
420
|
-
/** 注册 todo tool 到 pi */
|
|
421
324
|
export function registerTodoTool(
|
|
422
325
|
pi: ExtensionAPI,
|
|
423
326
|
state: TodoSessionState,
|
|
@@ -430,7 +333,7 @@ export function registerTodoTool(
|
|
|
430
333
|
"Manage a todo list." +
|
|
431
334
|
"\n\nAvailable actions:" +
|
|
432
335
|
"\n- list: View all todos" +
|
|
433
|
-
"\n- add: Batch add todos (requires texts array
|
|
336
|
+
"\n- add: Batch add todos (requires texts array)" +
|
|
434
337
|
"\n- update: Update a todo (requires id, optional status/text)" +
|
|
435
338
|
"\n- delete: Batch delete todos (requires ids array)" +
|
|
436
339
|
"\n- clear: Clear all todos and reset IDs"
|
|
@@ -440,16 +343,12 @@ export function registerTodoTool(
|
|
|
440
343
|
"[Usage] 多步骤工作(3+步)时使用。AI 自发创建,无需用户触发",
|
|
441
344
|
"[Goal 冲突] /goal 激活后禁止使用 todo — 改用 add_subtasks",
|
|
442
345
|
"[批量优先] 完成多项任务时使用 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
346
|
"[自动闭合] 全部完成后工具自动清理,无需手动 clear",
|
|
447
347
|
"[Not for] 单步操作、简单对话、/goal 已激活时",
|
|
448
348
|
],
|
|
449
349
|
parameters: TodoParams,
|
|
450
350
|
|
|
451
351
|
async execute(_toolCallId: string, params: Static<typeof TodoParams>, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
|
|
452
|
-
// P1-5: 尊重 signal —— 异步被取消时提前返回
|
|
453
352
|
if (signal?.aborted) {
|
|
454
353
|
return {
|
|
455
354
|
content: [{ type: "text" as const, text: "Todo call aborted by signal." }],
|
|
@@ -463,7 +362,6 @@ export function registerTodoTool(
|
|
|
463
362
|
};
|
|
464
363
|
}
|
|
465
364
|
const result = executeTodoAction(params as TodoActionParams, state, ctx, refreshDisplay);
|
|
466
|
-
// Append input params to error results for debugging
|
|
467
365
|
const details = result.details as { error?: string } | undefined;
|
|
468
366
|
if (details?.error) {
|
|
469
367
|
const textPart = result.content[0];
|