@zhushanwen/pi-todo 0.5.2 → 0.6.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__/gui.test.ts +0 -5
- package/src/__tests__/steer.test.ts +80 -3
- package/src/__tests__/todo.test.ts +10 -44
- package/src/__tests__/tool-prompt.test.ts +24 -4
- package/src/__tests__/tool-rpc.test.ts +1 -1
- package/src/handlers.ts +28 -10
- package/src/model.ts +12 -19
- package/src/tool.ts +5 -15
package/package.json
CHANGED
|
@@ -31,9 +31,4 @@ describe("buildGui", () => {
|
|
|
31
31
|
expect(gui.component.props.items).toEqual([]);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
it("isVerification todo still maps correctly", () => {
|
|
35
|
-
const todos: Todo[] = [{ id: 1, text: "verify", status: "pending", isVerification: true }];
|
|
36
|
-
const gui = buildGui(todos);
|
|
37
|
-
expect(gui.component.props.items[0]).toMatchObject({ icon: "dot", label: "#1: verify" });
|
|
38
|
-
});
|
|
39
34
|
});
|
|
@@ -42,6 +42,8 @@ describe("handleCompletionSteer", () => {
|
|
|
42
42
|
expect(handleCompletionSteer(s)).toBe(true);
|
|
43
43
|
expect(s.completionSteered).toBe(true);
|
|
44
44
|
expect(s.pendingSteerMessage).toContain("交付质量");
|
|
45
|
+
expect(s.pendingSteerMessage).toContain("检查实际产出");
|
|
46
|
+
expect(s.pendingSteerMessage).toContain("不要凭印象");
|
|
45
47
|
});
|
|
46
48
|
|
|
47
49
|
it("does not steer twice (single-shot lock)", () => {
|
|
@@ -148,13 +150,40 @@ describe("handleReminder", () => {
|
|
|
148
150
|
|
|
149
151
|
// ── reminder / context builders ─────────────────────
|
|
150
152
|
|
|
151
|
-
describe("buildMinimalReminder", () => {
|
|
152
|
-
it("mentions the next pending task", () => {
|
|
153
|
+
describe("buildMinimalReminder", () => {
|
|
154
|
+
it("mentions the next pending task with action directive", () => {
|
|
153
155
|
const s = makeState([
|
|
154
156
|
{ id: 1, text: "done", status: "completed" },
|
|
155
157
|
{ id: 2, text: "next", status: "pending" },
|
|
156
158
|
]);
|
|
157
|
-
|
|
159
|
+
const out = buildMinimalReminder(s);
|
|
160
|
+
expect(out).toContain("#2 next");
|
|
161
|
+
expect(out).toContain("必须处理");
|
|
162
|
+
expect(out).toContain("todo update");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("includes in_progress todos as pending", () => {
|
|
166
|
+
const s = makeState([
|
|
167
|
+
{ id: 1, text: "working", status: "in_progress" },
|
|
168
|
+
{ id: 2, text: "next", status: "pending" },
|
|
169
|
+
]);
|
|
170
|
+
expect(buildMinimalReminder(s)).toContain("#1 working");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("excludes cancelled todos from reminder", () => {
|
|
174
|
+
const s = makeState([
|
|
175
|
+
{ id: 1, text: "cancelled task", status: "cancelled" },
|
|
176
|
+
{ id: 2, text: "active", status: "in_progress" },
|
|
177
|
+
]);
|
|
178
|
+
const out = buildMinimalReminder(s);
|
|
179
|
+
expect(out).toContain("#2 active");
|
|
180
|
+
expect(out).not.toContain("#1");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns empty string when only cancelled remain", () => {
|
|
184
|
+
expect(
|
|
185
|
+
buildMinimalReminder(makeState([{ id: 1, text: "x", status: "cancelled" }])),
|
|
186
|
+
).toBe("");
|
|
158
187
|
});
|
|
159
188
|
|
|
160
189
|
it("returns empty string when no pending", () => {
|
|
@@ -176,6 +205,31 @@ describe("buildBeforeAgentStartMessage", () => {
|
|
|
176
205
|
expect(m!.message.content).not.toContain("#2");
|
|
177
206
|
});
|
|
178
207
|
|
|
208
|
+
it("injects action directives (process first / mark completed / stalled != done)", () => {
|
|
209
|
+
const s = makeState([{ id: 1, text: "a", status: "in_progress" }]);
|
|
210
|
+
const m = buildBeforeAgentStartMessage(s);
|
|
211
|
+
expect(m!.message.content).toContain("开始工作前先推进 pending 任务");
|
|
212
|
+
expect(m!.message.content).toContain("todo update 标记 completed");
|
|
213
|
+
expect(m!.message.content).toContain("搁置不等于完成");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("excludes cancelled todos from context injection", () => {
|
|
217
|
+
const s = makeState([
|
|
218
|
+
{ id: 1, text: "cancelled", status: "cancelled" },
|
|
219
|
+
{ id: 2, text: "active", status: "pending" },
|
|
220
|
+
]);
|
|
221
|
+
const m = buildBeforeAgentStartMessage(s);
|
|
222
|
+
expect(m).toBeDefined();
|
|
223
|
+
expect(m!.message.content).toContain("#2: active");
|
|
224
|
+
expect(m!.message.content).not.toContain("#1");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("returns undefined when only cancelled remain", () => {
|
|
228
|
+
expect(
|
|
229
|
+
buildBeforeAgentStartMessage(makeState([{ id: 1, text: "x", status: "cancelled" }])),
|
|
230
|
+
).toBeUndefined();
|
|
231
|
+
});
|
|
232
|
+
|
|
179
233
|
it("returns undefined when list empty", () => {
|
|
180
234
|
expect(buildBeforeAgentStartMessage(makeState([]))).toBeUndefined();
|
|
181
235
|
});
|
|
@@ -225,6 +279,29 @@ describe("reconstructState", () => {
|
|
|
225
279
|
reconstructState(s, makeCtx(entries));
|
|
226
280
|
expect(s.todos[0].status).toBe("pending");
|
|
227
281
|
});
|
|
282
|
+
|
|
283
|
+
it("skips dirty (null/primitive) elements without throwing", () => {
|
|
284
|
+
const entries = [
|
|
285
|
+
todoEntry([null, { id: 1, text: "ok", status: "pending" }] as unknown as Todo[], 3),
|
|
286
|
+
];
|
|
287
|
+
const s = createTodoSessionState();
|
|
288
|
+
expect(() => reconstructState(s, makeCtx(entries))).not.toThrow();
|
|
289
|
+
expect(s.todos).toHaveLength(1);
|
|
290
|
+
expect(s.todos[0].id).toBe(1);
|
|
291
|
+
expect(s.nextId).toBe(3);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("ignores snapshot when all elements are dirty (replay continues)", () => {
|
|
295
|
+
const entries = [
|
|
296
|
+
todoEntry([null, "garbage"] as unknown as Todo[], 5),
|
|
297
|
+
todoEntry([{ id: 9, text: "valid", status: "pending" }], 10),
|
|
298
|
+
];
|
|
299
|
+
const s = createTodoSessionState();
|
|
300
|
+
expect(() => reconstructState(s, makeCtx(entries))).not.toThrow();
|
|
301
|
+
expect(s.todos).toHaveLength(1);
|
|
302
|
+
expect(s.todos[0].id).toBe(9);
|
|
303
|
+
expect(s.nextId).toBe(10);
|
|
304
|
+
});
|
|
228
305
|
});
|
|
229
306
|
|
|
230
307
|
// ── agent_end integration (短路顺序) ────────────────
|
|
@@ -53,17 +53,17 @@ describe("Todo data model", () => {
|
|
|
53
53
|
expect(migrated.status).toBe("pending");
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
it("should preserve isVerification flag (FR-6)", () => {
|
|
57
|
-
const todo = { id: 1, text: "run tests", status: "pending", isVerification: true } as unknown as Todo;
|
|
58
|
-
const migrated = migrateTodo(todo);
|
|
59
|
-
expect(migrated.isVerification).toBe(true);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
56
|
it("should preserve cancelled status (FR-1 four-state)", () => {
|
|
63
57
|
const todo = { id: 1, text: "dropped", status: "cancelled" } as unknown as Todo;
|
|
64
58
|
const migrated = migrateTodo(todo);
|
|
65
59
|
expect(migrated.status).toBe("cancelled");
|
|
66
60
|
});
|
|
61
|
+
|
|
62
|
+
it("should throw on null/primitive input (dirty data guard)", () => {
|
|
63
|
+
expect(() => migrateTodo(null)).toThrow(TypeError);
|
|
64
|
+
expect(() => migrateTodo(undefined)).toThrow(TypeError);
|
|
65
|
+
expect(() => migrateTodo("garbage")).toThrow(TypeError);
|
|
66
|
+
});
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
// ── todo add ────────────────────────────────────────
|
|
@@ -103,25 +103,6 @@ describe("todo add", () => {
|
|
|
103
103
|
expect(result.error).toBeUndefined();
|
|
104
104
|
expect(result.newTodos[0].text).toBe("new task");
|
|
105
105
|
});
|
|
106
|
-
|
|
107
|
-
it("should mark todos as verification when isVerification=true (FR-6)", () => {
|
|
108
|
-
const result = addTodos([], 1, ["run tests", "typecheck"], true);
|
|
109
|
-
expect(result.error).toBeUndefined();
|
|
110
|
-
expect(result.newTodos[0].isVerification).toBe(true);
|
|
111
|
-
expect(result.newTodos[1].isVerification).toBe(true);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("should not set isVerification when omitted", () => {
|
|
115
|
-
const result = addTodos([], 1, ["regular task"]);
|
|
116
|
-
expect(result.error).toBeUndefined();
|
|
117
|
-
expect(result.newTodos[0].isVerification).toBeUndefined();
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("should not set isVerification when isVerification=false", () => {
|
|
121
|
-
const result = addTodos([], 1, ["regular task"], false);
|
|
122
|
-
expect(result.error).toBeUndefined();
|
|
123
|
-
expect(result.newTodos[0].isVerification).toBeUndefined();
|
|
124
|
-
});
|
|
125
106
|
});
|
|
126
107
|
|
|
127
108
|
// ── todo update batch ───────────────────────────────
|
|
@@ -177,39 +158,24 @@ describe("todo update batch", () => {
|
|
|
177
158
|
expect(result.error).toContain("invalid status");
|
|
178
159
|
});
|
|
179
160
|
|
|
180
|
-
it("
|
|
161
|
+
it("cancelled todo 不可恢复(status 更新拒绝)", () => {
|
|
181
162
|
const todos: Todo[] = [{ id: 1, text: "dropped", status: "cancelled" }];
|
|
182
163
|
const result = updateTodos(todos, [{ id: 1, status: "pending" }]);
|
|
183
164
|
expect(result.error).toBe("id 1 is cancelled");
|
|
184
165
|
expect(result.resultText).toContain("cannot be restored");
|
|
185
166
|
expect(result.updatedTodos).toEqual(todos);
|
|
186
167
|
});
|
|
187
|
-
|
|
188
|
-
it("FR-6: 验证任务不可 cancelled", () => {
|
|
189
|
-
const todos: Todo[] = [{ id: 2, text: "run tests", status: "in_progress", isVerification: true }];
|
|
190
|
-
const result = updateTodos(todos, [{ id: 2, status: "cancelled" }]);
|
|
191
|
-
expect(result.error).toBe("id 2 is verification todo");
|
|
192
|
-
expect(result.resultText).toContain("cannot be cancelled");
|
|
193
|
-
expect(result.updatedTodos).toEqual(todos);
|
|
194
|
-
});
|
|
195
168
|
});
|
|
196
169
|
|
|
197
|
-
// ── handleSingleUpdate
|
|
170
|
+
// ── handleSingleUpdate 守卫(tool 单条路径)────
|
|
198
171
|
|
|
199
|
-
describe("handleSingleUpdate
|
|
200
|
-
it("
|
|
172
|
+
describe("handleSingleUpdate guards (tool single path)", () => {
|
|
173
|
+
it("cancelled todo + status → cannot restore", () => {
|
|
201
174
|
const state = createTodoSessionState();
|
|
202
175
|
state.todos = [{ id: 1, text: "dropped", status: "cancelled" }];
|
|
203
176
|
expect(() => handleSingleUpdate(state, { action: "update", id: 1, status: "pending" }))
|
|
204
177
|
.toThrow("#1 is cancelled (cannot restore)");
|
|
205
178
|
});
|
|
206
|
-
|
|
207
|
-
it("FR-6: verification todo + status=cancelled → cannot cancel", () => {
|
|
208
|
-
const state = createTodoSessionState();
|
|
209
|
-
state.todos = [{ id: 2, text: "run tests", status: "in_progress", isVerification: true }];
|
|
210
|
-
expect(() => handleSingleUpdate(state, { action: "update", id: 2, status: "cancelled" }))
|
|
211
|
-
.toThrow("#2 is verification todo (cannot cancel)");
|
|
212
|
-
});
|
|
213
179
|
});
|
|
214
180
|
|
|
215
181
|
// ── completed 无拦截 ────────────────────────────────
|
|
@@ -42,10 +42,6 @@ describe("todo description — 给完整 JSON 正例", () => {
|
|
|
42
42
|
expect(DESCRIPTION_REGION).toContain('{"action":"add","texts"');
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
it("add+verification 正例:含 isVerification", () => {
|
|
46
|
-
expect(DESCRIPTION_REGION).toContain('"isVerification":true');
|
|
47
|
-
});
|
|
48
|
-
|
|
49
45
|
it("update single 正例:含 {\"action\":\"update\",\"id\"", () => {
|
|
50
46
|
expect(DESCRIPTION_REGION).toContain('{"action":"update","id"');
|
|
51
47
|
});
|
|
@@ -103,3 +99,27 @@ describe("todo runtime — throw 含 Correct 纠错正例", () => {
|
|
|
103
99
|
expect(TOOL_SRC).toContain('"id" — that field is for update');
|
|
104
100
|
});
|
|
105
101
|
});
|
|
102
|
+
|
|
103
|
+
// ── promptSnippet / promptGuidelines 验证引导(verification guidance)──
|
|
104
|
+
// DESCRIPTION_REGION 截取止于 promptSnippet:,不含这两个字段,故对完整 TOOL_SRC 断言。
|
|
105
|
+
|
|
106
|
+
describe("todo tool prompt — verification guidance", () => {
|
|
107
|
+
it("promptSnippet 引导验证步骤建 todo", () => {
|
|
108
|
+
expect(TOOL_SRC).toContain(
|
|
109
|
+
"Consider adding a separate todo for verification checks like running tests or typecheck.",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("promptSnippet 声明多步骤工作场景", () => {
|
|
114
|
+
expect(TOOL_SRC).toContain("Use todo when breaking multi-step work into trackable items.");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("promptGuidelines 含 [验证任务] 条目(完成前确保验证通过)", () => {
|
|
118
|
+
expect(TOOL_SRC).toContain("[验证任务] 为测试 / 类型检查等验证步骤单独建 todo,完成前确保验证通过");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("promptGuidelines 含 [自动闭合] / [批量优先] 核心条目", () => {
|
|
122
|
+
expect(TOOL_SRC).toContain("[自动闭合] 全部完成后工具自动清理,无需手动 clear");
|
|
123
|
+
expect(TOOL_SRC).toContain("[批量优先] 完成多项任务时使用 updates[] 批量更新");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -20,7 +20,7 @@ interface ExecuteResult {
|
|
|
20
20
|
content: Array<{ type: "text"; text: string }>;
|
|
21
21
|
details: {
|
|
22
22
|
action: string;
|
|
23
|
-
todos: Array<{ id: number; text: string; status: string
|
|
23
|
+
todos: Array<{ id: number; text: string; status: string }>;
|
|
24
24
|
nextId: number;
|
|
25
25
|
__gui__?: {
|
|
26
26
|
v: number;
|
package/src/handlers.ts
CHANGED
|
@@ -24,24 +24,29 @@ const REMINDER_INTERVAL = 2;
|
|
|
24
24
|
|
|
25
25
|
export type RefreshDisplayFn = (ctx: ExtensionContext) => void;
|
|
26
26
|
|
|
27
|
-
/**
|
|
27
|
+
/** 未完成任务判定:pending / in_progress(cancelled 不可恢复,从提醒排除) */
|
|
28
|
+
function isPending(t: TodoDetails["todos"][number]): boolean {
|
|
29
|
+
return t.status === "pending" || t.status === "in_progress";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 构建极简提醒:只含下一个推荐任务 + 行动指令 */
|
|
28
33
|
export function buildMinimalReminder(state: TodoSessionState): string {
|
|
29
|
-
const pendingTodos = state.todos.filter(
|
|
34
|
+
const pendingTodos = state.todos.filter(isPending);
|
|
30
35
|
if (pendingTodos.length === 0) return "";
|
|
31
36
|
|
|
32
37
|
const next = pendingTodos[0];
|
|
33
|
-
return `<todo_context>\n[TODO] 你有 ${pendingTodos.length}
|
|
38
|
+
return `<todo_context>\n[TODO] 你有 ${pendingTodos.length} 个未完成任务已搁置。下一个必须处理:#${next.id} ${next.text}。完成后用 todo update 标记 completed,不要继续搁置。\n</todo_context>`;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
export function buildBeforeAgentStartMessage(state: TodoSessionState): { message: { customType: string; content: string; display: boolean } } | undefined {
|
|
37
42
|
if (state.todos.length === 0) return undefined;
|
|
38
43
|
|
|
39
|
-
const pendingTodos = state.todos.filter(
|
|
44
|
+
const pendingTodos = state.todos.filter(isPending);
|
|
40
45
|
if (pendingTodos.length === 0) return undefined;
|
|
41
46
|
|
|
42
47
|
const lines = pendingTodos.map((t) => `#${t.id}: ${t.text}`);
|
|
43
48
|
const contextStr =
|
|
44
|
-
`<todo_context>\n[TODO] ${pendingTodos.length}
|
|
49
|
+
`<todo_context>\n[TODO] ${pendingTodos.length} 个未完成任务待处理:\n${lines.join("\n")}\n处理规则:开始工作前先推进 pending 任务;任务做完后立即用 todo update 标记 completed,不要搁置 pending 状态(搁置不等于完成)。\n</todo_context>`;
|
|
45
50
|
|
|
46
51
|
return {
|
|
47
52
|
message: {
|
|
@@ -75,9 +80,21 @@ export function reconstructState(state: TodoSessionState, ctx: ExtensionContext)
|
|
|
75
80
|
|
|
76
81
|
const details = msg.details as TodoDetails | undefined;
|
|
77
82
|
if (details?.todos && Array.isArray(details.todos)) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
// 脏数据降级:单条迁移失败(null/primitive)跳过该条,全部失败则忽略整个快照,不中断回放
|
|
84
|
+
const migrated: TodoDetails["todos"] = [];
|
|
85
|
+
for (const t of details.todos) {
|
|
86
|
+
try {
|
|
87
|
+
migrated.push(migrateTodo(t));
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// best-effort 降级:脏数据(null/primitive)跳过该条,不中断会话回放
|
|
90
|
+
console.debug("[todo] reconstructState: skipping dirty todo entry:", e);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (migrated.length > 0) {
|
|
94
|
+
state.todos = migrated;
|
|
95
|
+
state.nextId = details.nextId ?? Math.max(...migrated.map((t) => t.id)) + 1;
|
|
96
|
+
latestIdx = i;
|
|
97
|
+
}
|
|
81
98
|
}
|
|
82
99
|
}
|
|
83
100
|
|
|
@@ -124,7 +141,7 @@ export function handleCompletionSteer(state: TodoSessionState): boolean {
|
|
|
124
141
|
if (!allCompleted) return false;
|
|
125
142
|
|
|
126
143
|
state.completionSteered = true;
|
|
127
|
-
state.pendingSteerMessage = `<todo_context>\n[TODO]
|
|
144
|
+
state.pendingSteerMessage = `<todo_context>\n[TODO] 所有任务已标记完成。请逐项核对交付质量(不要凭印象,检查实际产出),确认无误后向用户汇报结果。\n</todo_context>`;
|
|
128
145
|
return true;
|
|
129
146
|
}
|
|
130
147
|
|
|
@@ -176,7 +193,7 @@ export function registerTodoEventHandlers(
|
|
|
176
193
|
|
|
177
194
|
pi.on("before_agent_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
178
195
|
try {
|
|
179
|
-
const pendingTodos = state.todos.filter(
|
|
196
|
+
const pendingTodos = state.todos.filter(isPending);
|
|
180
197
|
if (pendingTodos.length > 0) {
|
|
181
198
|
ctx.ui.setStatus("todo", `📋 ${pendingTodos.length} pending`);
|
|
182
199
|
}
|
|
@@ -208,6 +225,7 @@ export function registerTodoEventHandlers(
|
|
|
208
225
|
if (handleStallDetection(state)) return;
|
|
209
226
|
handleReminder(state);
|
|
210
227
|
} catch (e) {
|
|
228
|
+
// best-effort:agent_end 事件处理器出错不阻断会话主流程,仅记录调试日志
|
|
211
229
|
console.debug("[todo] agent_end error:", e);
|
|
212
230
|
}
|
|
213
231
|
});
|
package/src/model.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Todo 数据模型 — 纯函数,不依赖 Pi 运行时。
|
|
3
3
|
* 四态: pending → in_progress → completed;任一状态 → cancelled
|
|
4
|
-
* (cancelled
|
|
4
|
+
* (cancelled 不可恢复)
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { guiComponent, type GuiRenderResult, guiResult, type TreeItem } from "@xyz-agent/extension-protocol";
|
|
@@ -12,8 +12,6 @@ export interface Todo {
|
|
|
12
12
|
id: number;
|
|
13
13
|
text: string;
|
|
14
14
|
status: "pending" | "in_progress" | "completed" | "cancelled";
|
|
15
|
-
/** 验证任务标记(FR-6 completion audit)。验证任务必须 completed,不可 cancelled。 */
|
|
16
|
-
isVerification?: boolean;
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
export interface TodoDetails {
|
|
@@ -31,8 +29,15 @@ export type ValidStatus = (typeof VALID_STATUSES)[number];
|
|
|
31
29
|
// ── 迁移/兼容 ───────────────────────────────────────
|
|
32
30
|
|
|
33
31
|
/** 旧格式迁移:verifying → in_progress,failed → pending,done:boolean → status */
|
|
34
|
-
export function migrateTodo(raw:
|
|
35
|
-
|
|
32
|
+
export function migrateTodo(raw: unknown): Todo {
|
|
33
|
+
// raw 是任意旧格式数据(兼容 done:boolean 等历史结构),以 Record 方式安全访问字段
|
|
34
|
+
// 守卫:null/原始类型(typeof null === 'object',必须显式排除 null)→ 明确报错而非混淆的 TypeError
|
|
35
|
+
if (raw === null || typeof raw !== "object") {
|
|
36
|
+
throw new TypeError(
|
|
37
|
+
`migrateTodo: expected object, got ${raw === null ? "null" : typeof raw}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const record = raw as Record<string, unknown>;
|
|
36
41
|
const hasValidStatus =
|
|
37
42
|
typeof record.status === "string" &&
|
|
38
43
|
VALID_STATUSES.includes(record.status as ValidStatus);
|
|
@@ -42,7 +47,7 @@ export function migrateTodo(raw: Todo): Todo {
|
|
|
42
47
|
status = record.status as ValidStatus;
|
|
43
48
|
} else {
|
|
44
49
|
// 极旧格式 done: boolean
|
|
45
|
-
const
|
|
50
|
+
const done = typeof record.done === "boolean" ? record.done : undefined;
|
|
46
51
|
status = done === true ? "completed" : "pending";
|
|
47
52
|
}
|
|
48
53
|
|
|
@@ -55,8 +60,6 @@ export function migrateTodo(raw: Todo): Todo {
|
|
|
55
60
|
id: record.id as number,
|
|
56
61
|
text: record.text as string,
|
|
57
62
|
status,
|
|
58
|
-
// FR-6: 保留 isVerification 标记(可选字段,旧数据可能缺失)
|
|
59
|
-
isVerification: record.isVerification === true ? true : undefined,
|
|
60
63
|
};
|
|
61
64
|
}
|
|
62
65
|
|
|
@@ -115,7 +118,6 @@ export function addTodos(
|
|
|
115
118
|
currentTodos: Todo[],
|
|
116
119
|
currentNextId: number,
|
|
117
120
|
texts: string[],
|
|
118
|
-
isVerification?: boolean,
|
|
119
121
|
): AddResult {
|
|
120
122
|
if (!texts || texts.length === 0) {
|
|
121
123
|
return {
|
|
@@ -144,8 +146,6 @@ export function addTodos(
|
|
|
144
146
|
id: nextId++,
|
|
145
147
|
text: trimmed[i],
|
|
146
148
|
status: "pending" as const,
|
|
147
|
-
// FR-6: isVerification 标记验证任务(可选,仅 add 时可设)
|
|
148
|
-
isVerification: isVerification === true ? true : undefined,
|
|
149
149
|
});
|
|
150
150
|
}
|
|
151
151
|
const endId = nextId - 1;
|
|
@@ -200,7 +200,7 @@ export function updateTodos(
|
|
|
200
200
|
resultText: `Error: invalid status '${u.status}' for update item id ${u.id}`,
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
|
-
//
|
|
203
|
+
// cancelled 不可恢复
|
|
204
204
|
if (todo.status === "cancelled" && u.status !== undefined) {
|
|
205
205
|
return {
|
|
206
206
|
updatedTodos: currentTodos,
|
|
@@ -208,13 +208,6 @@ export function updateTodos(
|
|
|
208
208
|
resultText: `Error: Todo #${u.id} is cancelled and cannot be restored`,
|
|
209
209
|
};
|
|
210
210
|
}
|
|
211
|
-
if (todo.isVerification && u.status === "cancelled") {
|
|
212
|
-
return {
|
|
213
|
-
updatedTodos: currentTodos,
|
|
214
|
-
error: `id ${u.id} is verification todo`,
|
|
215
|
-
resultText: `Error: Todo #${u.id} is a verification todo and cannot be cancelled`,
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
211
|
}
|
|
219
212
|
|
|
220
213
|
const updated = currentTodos.map((t) => {
|
package/src/tool.ts
CHANGED
|
@@ -28,7 +28,6 @@ export interface TodoActionParams {
|
|
|
28
28
|
texts?: string[];
|
|
29
29
|
ids?: number[];
|
|
30
30
|
status?: string;
|
|
31
|
-
isVerification?: boolean;
|
|
32
31
|
updates?: Array<{ id: number; status?: string; text?: string }>;
|
|
33
32
|
}
|
|
34
33
|
|
|
@@ -43,11 +42,6 @@ const TodoParams = Type.Object({
|
|
|
43
42
|
status: Type.Optional(
|
|
44
43
|
StringEnum(VALID_STATUSES, { description: "Target status (for update action)" }),
|
|
45
44
|
),
|
|
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.",
|
|
49
|
-
}),
|
|
50
|
-
),
|
|
51
45
|
updates: Type.Optional(
|
|
52
46
|
Type.Array(
|
|
53
47
|
Type.Object({
|
|
@@ -88,7 +82,7 @@ export function handleAdd(state: TodoSessionState, params: TodoActionParams): st
|
|
|
88
82
|
'add requires texts parameter (non-empty array). Correct: {"action":"add","texts":["..."]}',
|
|
89
83
|
);
|
|
90
84
|
}
|
|
91
|
-
const r = addTodos(state.todos, state.nextId, params.texts
|
|
85
|
+
const r = addTodos(state.todos, state.nextId, params.texts);
|
|
92
86
|
if (r.error) throw new Error(r.resultText);
|
|
93
87
|
state.todos = r.newTodos;
|
|
94
88
|
state.nextId = r.newNextId;
|
|
@@ -124,13 +118,10 @@ export function handleSingleUpdate(state: TodoSessionState, params: TodoActionPa
|
|
|
124
118
|
const todo = state.todos.find((t) => t.id === params.id);
|
|
125
119
|
if (!todo) throw new Error(`Todo #${params.id} not found`);
|
|
126
120
|
|
|
127
|
-
//
|
|
121
|
+
// cancelled 不可恢复(失败抛错)
|
|
128
122
|
if (todo.status === "cancelled" && params.status !== undefined) {
|
|
129
123
|
throw new Error(`#${params.id} is cancelled (cannot restore)`);
|
|
130
124
|
}
|
|
131
|
-
if (todo.isVerification && params.status === "cancelled") {
|
|
132
|
-
throw new Error(`#${params.id} is verification todo (cannot cancel)`);
|
|
133
|
-
}
|
|
134
125
|
|
|
135
126
|
if (params.status !== undefined) todo.status = params.status as Todo["status"];
|
|
136
127
|
if (params.text !== undefined) todo.text = params.text;
|
|
@@ -260,13 +251,12 @@ export function registerTodoTool(
|
|
|
260
251
|
"Manage a todo list." +
|
|
261
252
|
"\n\nAvailable actions:" +
|
|
262
253
|
"\n- list: View all todos" +
|
|
263
|
-
"\n- add: Batch add todos (requires texts array
|
|
254
|
+
"\n- add: Batch add todos (requires texts array)" +
|
|
264
255
|
"\n- update: Update todo(s) — single (id + optional status/text) or batch (updates[], takes priority)" +
|
|
265
256
|
"\n- delete: Batch delete todos (requires ids array)" +
|
|
266
257
|
"\n- clear: Clear all todos and reset IDs" +
|
|
267
258
|
"\n\nExamples:" +
|
|
268
259
|
'\n{"action":"add","texts":["write spec","implement"]}' +
|
|
269
|
-
'\n{"action":"add","texts":["run tests"],"isVerification":true}' +
|
|
270
260
|
'\n{"action":"update","id":1,"status":"in_progress"}' +
|
|
271
261
|
'\n{"action":"update","updates":[{"id":1,"status":"completed"},{"id":2,"status":"in_progress"}]}' +
|
|
272
262
|
'\n{"action":"delete","ids":[3]}' +
|
|
@@ -274,10 +264,10 @@ export function registerTodoTool(
|
|
|
274
264
|
'\n{"action":"add","text":"x"} ← text is for update; add uses texts:[...]' +
|
|
275
265
|
'\n{"action":"delete","id":3} ← id is for update; delete uses ids:[...]' +
|
|
276
266
|
'\n{"action":"update","status":"x"} ← missing id',
|
|
277
|
-
promptSnippet: "Use todo when breaking multi-step work into trackable items.
|
|
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
268
|
promptGuidelines: [
|
|
279
269
|
"[Usage] 多步骤工作(3+步)时使用。AI 自发创建,无需用户触发",
|
|
280
|
-
"[验证任务]
|
|
270
|
+
"[验证任务] 为测试 / 类型检查等验证步骤单独建 todo,完成前确保验证通过",
|
|
281
271
|
"[批量优先] 完成多项任务时使用 updates[] 批量更新,减少工具调用次数",
|
|
282
272
|
"[自动闭合] 全部完成后工具自动清理,无需手动 clear",
|
|
283
273
|
"[Not for] 单步操作、简单对话",
|