@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/package.json +2 -2
- package/src/__tests__/gui.test.ts +2 -5
- package/src/__tests__/schema.test.ts +76 -0
- package/src/__tests__/steer.test.ts +27 -107
- package/src/__tests__/todo.test.ts +58 -94
- package/src/__tests__/tool-detectors.test.ts +12 -0
- package/src/__tests__/tool-prompt.test.ts +32 -52
- package/src/__tests__/tool-rpc.test.ts +3 -3
- package/src/handlers.ts +13 -64
- package/src/index.ts +2 -2
- package/src/model.ts +35 -42
- package/src/render.ts +5 -13
- package/src/state.ts +1 -5
- package/src/tool.ts +106 -85
package/src/tool.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Todo tool 注册 + execute dispatcher +
|
|
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
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
),
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
// ──
|
|
98
|
+
// ── 4 个 action handler ──────────────────────────────
|
|
60
99
|
// 错误处理约定(见 CLAUDE.md「Tool 设计」):handler 失败直接 throw,
|
|
61
|
-
// 不返回「错误成功模式」。model
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 模式走原生文本渲染(
|
|
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:
|
|
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
|
-
"
|
|
252
|
-
"\n\
|
|
253
|
-
"\n- list:
|
|
254
|
-
"\n- add:
|
|
255
|
-
"\n- update:
|
|
256
|
-
"\n- delete:
|
|
257
|
-
"\n
|
|
258
|
-
"\n
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
290
|
+
"[Usage] 多步骤工作(3+步)时使用,AI 自发创建,无需用户触发",
|
|
270
291
|
"[验证任务] 为测试 / 类型检查等验证步骤单独建 todo,完成前确保验证通过",
|
|
271
292
|
"[批量优先] 完成多项任务时使用 updates[] 批量更新,减少工具调用次数",
|
|
272
|
-
"[自动闭合]
|
|
293
|
+
"[自动闭合] 全部完成后自动清理,无需手动 delete",
|
|
273
294
|
"[Not for] 单步操作、简单对话",
|
|
274
295
|
],
|
|
275
296
|
executionMode: "sequential",
|