@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.
@@ -1,4 +1,5 @@
1
- import { describe, expect,it } from "vitest";
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
2
+ import { describe, expect, it } from "vitest";
2
3
 
3
4
  import {
4
5
  addTodos,
@@ -9,179 +10,134 @@ import {
9
10
  updateTodos,
10
11
  VALID_STATUSES,
11
12
  } from "../model";
13
+ import { renderWidgetLines } from "../render";
14
+ import { createTodoSessionState } from "../state";
15
+ import { handleSingleUpdate } from "../tool";
12
16
 
13
- // ── Task 1: 数据模型增强 + 向后兼容 ──────────────────
17
+ // ── 数据模型 + 向后兼容 ──────────────────────────────
14
18
 
15
- describe("Todo data model - Task 1", () => {
19
+ describe("Todo data model", () => {
16
20
  it("should load old data without verifyText/verifyAttempts", () => {
17
- // 旧格式: 没有 verifyText 和 verifyAttempts 字段
18
21
  const oldTodo = { id: 1, text: "test", status: "completed" } as unknown as Todo;
19
22
  const migrated = migrateTodo(oldTodo);
20
23
 
21
- expect(migrated.verifyText).toBeUndefined();
22
- expect(migrated.verifyAttempts).toBe(0);
23
24
  expect(migrated.status).toBe("completed");
24
25
  expect(migrated.text).toBe("test");
25
26
  expect(migrated.id).toBe(1);
26
27
  });
27
28
 
28
- it("should accept failed status", () => {
29
- const todo: Todo = {
30
- id: 1,
31
- text: "test",
32
- status: "failed",
33
- verifyAttempts: 2,
34
- };
35
- const migrated = migrateTodo(todo);
36
-
37
- expect(migrated.status).toBe("failed");
38
- expect(migrated.verifyAttempts).toBe(2);
39
- expect(VALID_STATUSES).toContain("failed");
29
+ it("should include exactly four valid statuses", () => {
30
+ expect(VALID_STATUSES).toEqual(["pending", "in_progress", "completed", "cancelled"]);
40
31
  });
41
32
 
42
- it("should set verifyAttempts default to 0 when migrating old data", () => {
43
- const oldTodo = { id: 5, text: "old task", status: "pending" } as unknown as Todo;
33
+ it("should migrate verifying in_progress", () => {
34
+ const oldTodo = { id: 1, text: "test", status: "verifying" } as unknown as Todo;
44
35
  const migrated = migrateTodo(oldTodo);
45
-
46
- expect(migrated.verifyAttempts).toBe(0);
36
+ expect(migrated.status).toBe("in_progress");
47
37
  });
48
38
 
49
- it("should preserve verifyText when present in old data", () => {
50
- const todo = {
51
- id: 2,
52
- text: "task",
53
- status: "completed",
54
- verifyText: "check output",
55
- verifyAttempts: 1,
56
- } as unknown as Todo;
57
- const migrated = migrateTodo(todo);
58
-
59
- expect(migrated.verifyText).toBe("check output");
60
- expect(migrated.verifyAttempts).toBe(1);
39
+ it("should migrate failed pending", () => {
40
+ const oldTodo = { id: 1, text: "test", status: "failed" } as unknown as Todo;
41
+ const migrated = migrateTodo(oldTodo);
42
+ expect(migrated.status).toBe("pending");
61
43
  });
62
44
 
63
- it("should migrate done:true to completed with default verify fields", () => {
64
- // 极旧格式: done: boolean
45
+ it("should migrate done:true to completed", () => {
65
46
  const veryOldTodo = { id: 3, text: "ancient", done: true } as unknown as Todo;
66
47
  const migrated = migrateTodo(veryOldTodo);
67
-
68
48
  expect(migrated.status).toBe("completed");
69
- expect(migrated.verifyText).toBeUndefined();
70
- expect(migrated.verifyAttempts).toBe(0);
71
49
  });
72
50
 
73
- it("should migrate done:false to pending with default verify fields", () => {
51
+ it("should migrate done:false to pending", () => {
74
52
  const veryOldTodo = { id: 4, text: "ancient2", done: false } as unknown as Todo;
75
53
  const migrated = migrateTodo(veryOldTodo);
76
-
77
54
  expect(migrated.status).toBe("pending");
78
- expect(migrated.verifyText).toBeUndefined();
79
- expect(migrated.verifyAttempts).toBe(0);
80
55
  });
81
56
 
82
- it("should include all five valid statuses", () => {
83
- expect(VALID_STATUSES).toEqual(["pending", "in_progress", "verifying", "completed", "failed"]);
84
- });
85
-
86
- it("should preserve evidence when migrating old data", () => {
87
- const todo = {
88
- id: 1,
89
- text: "task",
90
- status: "verifying",
91
- verifyText: "check X",
92
- evidence: "grep confirmed no residual",
93
- verifyAttempts: 0,
94
- } as unknown as Todo;
57
+ it("should preserve isVerification flag (FR-6)", () => {
58
+ const todo = { id: 1, text: "run tests", status: "pending", isVerification: true } as unknown as Todo;
95
59
  const migrated = migrateTodo(todo);
96
- expect(migrated.evidence).toBe("grep confirmed no residual");
97
- expect(migrated.status).toBe("verifying");
60
+ expect(migrated.isVerification).toBe(true);
98
61
  });
99
62
 
100
- it("should default evidence to undefined when absent", () => {
101
- const oldTodo = { id: 1, text: "test", status: "pending" } as unknown as Todo;
102
- const migrated = migrateTodo(oldTodo);
103
- expect(migrated.evidence).toBeUndefined();
63
+ it("should preserve cancelled status (FR-1 four-state)", () => {
64
+ const todo = { id: 1, text: "dropped", status: "cancelled" } as unknown as Todo;
65
+ const migrated = migrateTodo(todo);
66
+ expect(migrated.status).toBe("cancelled");
104
67
  });
105
68
  });
106
69
 
107
- // ── Task 2: todo add 支持 verifyTexts 参数 ────────────
108
-
109
- describe("todo add verifyTexts - Task 2", () => {
110
- it("should map verifyTexts to todos at corresponding indices", () => {
111
- const result = addTodos([], 1, ["A", "B"], ["V1"]);
70
+ // ── todo add ────────────────────────────────────────
112
71
 
72
+ describe("todo add", () => {
73
+ it("should add todos with sequential IDs", () => {
74
+ const result = addTodos([], 1, ["A", "B"]);
113
75
  expect(result.error).toBeUndefined();
114
76
  expect(result.newTodos).toHaveLength(2);
115
- expect(result.newTodos[0].verifyText).toBe("V1");
116
- expect(result.newTodos[1].verifyText).toBeUndefined();
117
- expect(result.newTodos[0].verifyAttempts).toBe(0);
118
- expect(result.newTodos[1].verifyAttempts).toBe(0);
119
- });
120
-
121
- it("should reject verifyTexts longer than texts", () => {
122
- const result = addTodos([], 1, ["A"], ["V1", "V2"]);
123
-
124
- expect(result.error).toBe("verifyTexts too long");
125
- expect(result.resultText).toContain("Error");
126
- expect(result.newTodos).toHaveLength(0);
127
- });
128
-
129
- it("should work without verifyTexts (backward compat)", () => {
130
- const result = addTodos([], 1, ["A"]);
131
-
132
- expect(result.error).toBeUndefined();
133
- expect(result.newTodos).toHaveLength(1);
134
- expect(result.newTodos[0].verifyText).toBeUndefined();
135
- expect(result.newTodos[0].verifyAttempts).toBe(0);
77
+ expect(result.newTodos[0].id).toBe(1);
78
+ expect(result.newTodos[1].id).toBe(2);
79
+ expect(result.newTodos[0].status).toBe("pending");
136
80
  });
137
81
 
138
- it("should map all verifyTexts when lengths match", () => {
139
- const result = addTodos([], 1, ["A", "B", "C"], ["V1", "V2", "V3"]);
140
-
82
+ it("should append to existing todos", () => {
83
+ const existing: Todo[] = [{ id: 1, text: "existing", status: "pending" }];
84
+ const result = addTodos(existing, 2, ["new task"]);
141
85
  expect(result.error).toBeUndefined();
142
- expect(result.newTodos).toHaveLength(3);
143
- expect(result.newTodos[0].verifyText).toBe("V1");
144
- expect(result.newTodos[1].verifyText).toBe("V2");
145
- expect(result.newTodos[2].verifyText).toBe("V3");
86
+ expect(result.newTodos).toHaveLength(2);
87
+ expect(result.newTodos[1].id).toBe(2);
88
+ expect(result.newTodos[1].text).toBe("new task");
89
+ expect(result.newNextId).toBe(3);
146
90
  });
147
91
 
148
92
  it("should return error when texts is empty", () => {
149
93
  const result = addTodos([], 1, []);
150
-
151
94
  expect(result.error).toBe("texts required");
152
95
  });
153
96
 
154
97
  it("should return error when all texts are empty after trim", () => {
155
98
  const result = addTodos([], 1, [" ", " "]);
156
-
157
99
  expect(result.error).toBe("all texts empty");
158
100
  });
159
101
 
160
- it("should trim texts and assign correct IDs", () => {
161
- const existing: Todo[] = [{ id: 1, text: "existing", status: "pending", verifyAttempts: 0 }];
162
- const result = addTodos(existing, 2, [" new task "]);
102
+ it("should trim texts", () => {
103
+ const result = addTodos([], 1, [" new task "]);
104
+ expect(result.error).toBeUndefined();
105
+ expect(result.newTodos[0].text).toBe("new task");
106
+ });
163
107
 
108
+ it("should mark todos as verification when isVerification=true (FR-6)", () => {
109
+ const result = addTodos([], 1, ["run tests", "typecheck"], true);
164
110
  expect(result.error).toBeUndefined();
165
- expect(result.newTodos).toHaveLength(2);
166
- expect(result.newTodos[1].id).toBe(2);
167
- expect(result.newTodos[1].text).toBe("new task");
168
- expect(result.newNextId).toBe(3);
111
+ expect(result.newTodos[0].isVerification).toBe(true);
112
+ expect(result.newTodos[1].isVerification).toBe(true);
113
+ });
114
+
115
+ it("should not set isVerification when omitted", () => {
116
+ const result = addTodos([], 1, ["regular task"]);
117
+ expect(result.error).toBeUndefined();
118
+ expect(result.newTodos[0].isVerification).toBeUndefined();
119
+ });
120
+
121
+ it("should not set isVerification when isVerification=false", () => {
122
+ const result = addTodos([], 1, ["regular task"], false);
123
+ expect(result.error).toBeUndefined();
124
+ expect(result.newTodos[0].isVerification).toBeUndefined();
169
125
  });
170
126
  });
171
127
 
172
- // ── Task 3: todo update batch updates[] ──────────────
128
+ // ── todo update batch ───────────────────────────────
173
129
 
174
- describe("todo update batch - Task 3", () => {
130
+ describe("todo update batch", () => {
175
131
  it("should update multiple todos with updates[]", () => {
176
132
  const todos: Todo[] = [
177
- { id: 1, text: "A", status: "pending", verifyAttempts: 0 },
178
- { id: 2, text: "B", status: "in_progress", verifyAttempts: 0 },
179
- { id: 3, text: "C", status: "pending", verifyAttempts: 0 },
133
+ { id: 1, text: "A", status: "pending" },
134
+ { id: 2, text: "B", status: "in_progress" },
135
+ { id: 3, text: "C", status: "pending" },
180
136
  ];
181
137
  const result = updateTodos(todos, [
182
138
  { id: 1, status: "completed" },
183
139
  { id: 2, text: "B updated" },
184
- { id: 3, status: "failed", text: "C failed" },
140
+ { id: 3, status: "completed", text: "C done" },
185
141
  ]);
186
142
 
187
143
  expect(result.error).toBeUndefined();
@@ -189,518 +145,140 @@ describe("todo update batch - Task 3", () => {
189
145
  expect(result.updatedTodos[0].status).toBe("completed");
190
146
  expect(result.updatedTodos[0].text).toBe("A");
191
147
  expect(result.updatedTodos[1].text).toBe("B updated");
192
- expect(result.updatedTodos[1].status).toBe("in_progress"); // unchanged
193
- expect(result.updatedTodos[2].status).toBe("failed");
194
- expect(result.updatedTodos[2].text).toBe("C failed");
195
- expect(result.resultText).toBe("Updated 3 todo(s)");
148
+ expect(result.updatedTodos[1].status).toBe("in_progress");
149
+ expect(result.updatedTodos[2].status).toBe("completed");
150
+ expect(result.updatedTodos[2].text).toBe("C done");
196
151
  });
197
152
 
198
153
  it("should reject duplicate ids in updates[]", () => {
199
- const todos: Todo[] = [
200
- { id: 1, text: "A", status: "pending", verifyAttempts: 0 },
201
- ];
154
+ const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
202
155
  const result = updateTodos(todos, [
203
156
  { id: 1, status: "completed" },
204
157
  { id: 1, status: "pending" },
205
158
  ]);
206
-
207
159
  expect(result.error).toBe("duplicate ids in updates");
208
- expect(result.updatedTodos).toEqual(todos); // unchanged
160
+ expect(result.updatedTodos).toEqual(todos);
209
161
  });
210
162
 
211
- it("should reject non-existent ids in updates[] (all-or-nothing)", () => {
212
- const todos: Todo[] = [
213
- { id: 1, text: "A", status: "pending", verifyAttempts: 0 },
214
- ];
215
- const result = updateTodos(todos, [
216
- { id: 1, status: "completed" },
217
- { id: 999, status: "pending" },
218
- ]);
219
-
163
+ it("should reject non-existent ids", () => {
164
+ const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
165
+ const result = updateTodos(todos, [{ id: 999, status: "pending" }]);
220
166
  expect(result.error).toBe("id 999 not found");
221
- expect(result.updatedTodos[0].status).toBe("pending"); // #1 not modified
222
167
  });
223
168
 
224
169
  it("should reject updates[] item missing both status and text", () => {
225
- const todos: Todo[] = [
226
- { id: 1, text: "A", status: "pending", verifyAttempts: 0 },
227
- ];
170
+ const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
228
171
  const result = updateTodos(todos, [{ id: 1 }]);
229
-
230
172
  expect(result.error).toContain("neither status nor text");
231
- expect(result.updatedTodos[0].status).toBe("pending"); // unchanged
232
- });
233
- });
234
-
235
- // ── Task 4: todo list verifyText ────────────────────
236
-
237
- describe("todo list verifyText - Task 4", () => {
238
- it("should include verifyText in list output when present", () => {
239
- const todo: Todo = {
240
- id: 1,
241
- text: "check output",
242
- status: "pending",
243
- verifyText: "check X",
244
- verifyAttempts: 0,
245
- };
246
- const line = formatTodoLine(todo);
247
-
248
- expect(line).toContain(" | 验证: check X");
249
- expect(line).toContain("#1");
250
- expect(line).toContain("check output");
251
- });
252
-
253
- it("should not include verify suffix when verifyText is absent", () => {
254
- const todo: Todo = {
255
- id: 2,
256
- text: "plain task",
257
- status: "completed",
258
- verifyAttempts: 0,
259
- };
260
- const line = formatTodoLine(todo);
261
-
262
- expect(line).not.toContain("验证");
263
- expect(line).toContain("#2");
264
- expect(line).toContain("plain task");
265
- });
266
-
267
- it("should show verifying status with evidence", () => {
268
- const todo: Todo = {
269
- id: 3,
270
- text: "fix auth",
271
- status: "verifying",
272
- verifyText: "check status codes",
273
- evidence: "grep confirmed no display:true residual",
274
- verifyAttempts: 0,
275
- };
276
- const line = formatTodoLine(todo);
277
-
278
- expect(line).toContain("[v]");
279
- expect(line).toContain("验证中: grep confirmed no display:true residual");
280
- });
281
-
282
- it("should show completed with evidence", () => {
283
- const todo: Todo = {
284
- id: 4,
285
- text: "fix login",
286
- status: "completed",
287
- verifyText: "密码错误时返回正确错误码",
288
- evidence: "42 测试全通过,typecheck 无错误",
289
- verifyAttempts: 0,
290
- };
291
- const line = formatTodoLine(todo);
292
-
293
- expect(line).toContain("[x]");
294
- expect(line).toContain("已验证: 42 测试全通过");
295
- });
296
- });
297
-
298
- // ── Task 5: agent_end loop logic (pure data model tests) ──────────
299
-
300
- describe("agent_end loop logic - Task 5", () => {
301
- const MAX_VERIFY_ATTEMPTS = 2;
302
-
303
- it("should detect completed tasks needing verification", () => {
304
- const todos: Todo[] = [
305
- {
306
- id: 1,
307
- text: "task A",
308
- status: "completed",
309
- verifyText: "check output",
310
- verifyAttempts: 0,
311
- },
312
- ];
313
-
314
- const needsVerify = todos.find(
315
- (t) =>
316
- t.status === "completed" &&
317
- t.verifyText &&
318
- t.verifyAttempts < MAX_VERIFY_ATTEMPTS,
319
- );
320
-
321
- expect(needsVerify).toBeDefined();
322
- expect(needsVerify!.id).toBe(1);
323
- expect(needsVerify!.verifyText).toBe("check output");
324
- });
325
-
326
- it("should not trigger verify for tasks without verifyText", () => {
327
- const todos: Todo[] = [
328
- {
329
- id: 1,
330
- text: "task A",
331
- status: "completed",
332
- verifyAttempts: 0,
333
- },
334
- ];
335
-
336
- const needsVerify = todos.find(
337
- (t) =>
338
- t.status === "completed" &&
339
- t.verifyText &&
340
- t.verifyAttempts < MAX_VERIFY_ATTEMPTS,
341
- );
342
-
343
- expect(needsVerify).toBeUndefined();
344
- });
345
-
346
- it("should detect verify failure when attempts exceed max (completed status)", () => {
347
- const todos: Todo[] = [
348
- {
349
- id: 2,
350
- text: "task B",
351
- status: "completed",
352
- verifyText: "check B",
353
- verifyAttempts: MAX_VERIFY_ATTEMPTS,
354
- },
355
- ];
356
-
357
- const failed = todos.find(
358
- (t) =>
359
- t.status === "completed" &&
360
- t.verifyText &&
361
- t.verifyAttempts >= MAX_VERIFY_ATTEMPTS,
362
- );
363
-
364
- expect(failed).toBeDefined();
365
- failed!.status = "failed";
366
- expect(failed!.status).toBe("failed");
367
- });
368
-
369
- it("should increment verifyAttempts when AI re-opens completed task with verifyText", () => {
370
- const todos: Todo[] = [
371
- { id: 1, text: "task A", status: "completed", verifyText: "check A", verifyAttempts: 0 },
372
- ];
373
-
374
- todos[0].status = "in_progress";
375
- todos[0].verifyAttempts++;
376
- expect(todos[0].verifyAttempts).toBe(1);
377
- });
378
-
379
- it("should detect stall when no todo activity for threshold rounds", () => {
380
- const STALL_THRESHOLD = 5;
381
- const userMessageCount = 10;
382
- const lastTodoCallCount = 3;
383
- const todos: Todo[] = [
384
- { id: 1, text: "pending task", status: "pending", verifyAttempts: 0 },
385
- ];
386
- const allCompletedAtCount = null;
387
-
388
- const isStalled =
389
- todos.length > 0 &&
390
- allCompletedAtCount === null &&
391
- userMessageCount - lastTodoCallCount >= STALL_THRESHOLD;
392
-
393
- expect(isStalled).toBe(true);
394
- });
395
-
396
- it("should detect reminder when interval elapsed", () => {
397
- const REMINDER_INTERVAL = 3;
398
- const userMessageCount = 8;
399
- const lastTodoCallCount = 4;
400
- const todos: Todo[] = [
401
- { id: 1, text: "task", status: "pending", verifyAttempts: 0 },
402
- ];
403
- const allCompletedAtCount = null;
404
-
405
- const needsReminder =
406
- todos.length > 0 &&
407
- allCompletedAtCount === null &&
408
- userMessageCount - lastTodoCallCount >= REMINDER_INTERVAL;
409
-
410
- expect(needsReminder).toBe(true);
411
- });
412
-
413
- it("should auto-clear when all completed and delay rounds elapsed", () => {
414
- const AUTO_CLEAR_DELAY_ROUNDS = 2;
415
- const userMessageCount = 7;
416
- const allCompletedAtCount = 4;
417
-
418
- const shouldClear =
419
- allCompletedAtCount !== null &&
420
- userMessageCount - allCompletedAtCount >= AUTO_CLEAR_DELAY_ROUNDS;
421
-
422
- expect(shouldClear).toBe(true);
423
- });
424
-
425
- it("should not auto-clear when delay rounds not yet elapsed", () => {
426
- const AUTO_CLEAR_DELAY_ROUNDS = 2;
427
- const userMessageCount = 5;
428
- const allCompletedAtCount = 4;
429
-
430
- const shouldClear =
431
- allCompletedAtCount !== null &&
432
- userMessageCount - allCompletedAtCount >= AUTO_CLEAR_DELAY_ROUNDS;
433
-
434
- expect(shouldClear).toBe(false);
435
- });
436
-
437
- it("should format pending tasks with verifyText for context injection", () => {
438
- const todos: Todo[] = [
439
- {
440
- id: 1,
441
- text: "task A",
442
- status: "pending",
443
- verifyText: "check A",
444
- verifyAttempts: 0,
445
- },
446
- {
447
- id: 2,
448
- text: "task B",
449
- status: "in_progress",
450
- verifyAttempts: 0,
451
- },
452
- ];
453
-
454
- const pendingTodos = todos.filter((t) => t.status !== "completed");
455
- const lines = pendingTodos.map((t) => {
456
- const verifyTag = t.verifyText
457
- ? ` [待验证: ${t.verifyText}]`
458
- : "";
459
- return `#${t.id}: ${t.text}${verifyTag}`;
460
- });
461
-
462
- expect(lines).toHaveLength(2);
463
- expect(lines[0]).toBe("#1: task A [待验证: check A]");
464
- expect(lines[1]).toBe("#2: task B");
465
- });
466
-
467
- it("should include verifying tasks as pending (not completed)", () => {
468
- const todos: Todo[] = [
469
- { id: 1, text: "A", status: "verifying", verifyText: "check A", verifyAttempts: 0, evidence: "testing..." },
470
- { id: 2, text: "B", status: "completed", verifyAttempts: 0 },
471
- ];
472
-
473
- const pendingTodos = todos.filter((t) => t.status !== "completed");
474
- expect(pendingTodos).toHaveLength(1);
475
- expect(pendingTodos[0].status).toBe("verifying");
476
173
  });
477
- });
478
174
 
479
- // ── batch update validation - Task 3b ─────────────────
480
-
481
- describe("batch update validation - Task 3b", () => {
482
- it("should reject invalid status values in updates[]", () => {
483
- const todos: Todo[] = [{ id: 1, text: "A", status: "pending", verifyAttempts: 0 }];
175
+ it("should reject invalid status values", () => {
176
+ const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
484
177
  const result = updateTodos(todos, [{ id: 1, status: "banana" }]);
485
178
  expect(result.error).toContain("invalid status");
486
- expect(result.updatedTodos[0].status).toBe("pending"); // unchanged
487
- });
488
-
489
- it("should accept valid status values in updates[]", () => {
490
- const todos: Todo[] = [{ id: 1, text: "A", status: "pending", verifyAttempts: 0 }];
491
- const result = updateTodos(todos, [{ id: 1, status: "completed" }]);
492
- expect(result.error).toBeUndefined();
493
- expect(result.updatedTodos[0].status).toBe("completed");
494
179
  });
495
- });
496
-
497
- // ── verifyAttempts increment on re-open ─────────────
498
-
499
- describe("verifyAttempts increment on re-open", () => {
500
- it("should increment verifyAttempts when status changes from completed to in_progress with verifyText", () => {
501
- const todo: Todo = { id: 1, text: "fix auth", status: "completed", verifyText: "check status codes", verifyAttempts: 0 };
502
-
503
- const oldStatus = todo.status;
504
- todo.status = "in_progress";
505
- if (oldStatus === "completed" && todo.verifyText && todo.verifyAttempts < 2) {
506
- todo.verifyAttempts++;
507
- }
508
180
 
509
- expect(todo.verifyAttempts).toBe(1);
510
- expect(todo.status).toBe("in_progress");
181
+ it("FR-6: cancelled todo 不可恢复(status 更新拒绝)", () => {
182
+ const todos: Todo[] = [{ id: 1, text: "dropped", status: "cancelled" }];
183
+ const result = updateTodos(todos, [{ id: 1, status: "pending" }]);
184
+ expect(result.error).toBe("id 1 is cancelled");
185
+ expect(result.resultText).toContain("cannot be restored");
186
+ expect(result.updatedTodos).toEqual(todos);
511
187
  });
512
188
 
513
- it("should NOT increment verifyAttempts when re-opening task without verifyText", () => {
514
- const todo: Todo = { id: 1, text: "simple task", status: "completed", verifyAttempts: 0 };
515
-
516
- const oldStatus = todo.status;
517
- todo.status = "in_progress";
518
- if (oldStatus === "completed" && todo.verifyText && todo.verifyAttempts < 2) {
519
- todo.verifyAttempts++;
520
- }
521
-
522
- expect(todo.verifyAttempts).toBe(0); // no verifyText → no increment
189
+ it("FR-6: 验证任务不可 cancelled", () => {
190
+ const todos: Todo[] = [{ id: 2, text: "run tests", status: "in_progress", isVerification: true }];
191
+ const result = updateTodos(todos, [{ id: 2, status: "cancelled" }]);
192
+ expect(result.error).toBe("id 2 is verification todo");
193
+ expect(result.resultText).toContain("cannot be cancelled");
194
+ expect(result.updatedTodos).toEqual(todos);
523
195
  });
524
196
  });
525
197
 
526
- // ── verifying state transitions ────────────────────
527
-
528
- describe("verifying state transitions", () => {
529
- it("should block verifying on task without verifyText", () => {
530
- const todos: Todo[] = [
531
- { id: 1, text: "simple", status: "in_progress", verifyAttempts: 0 },
532
- ];
533
- const result = updateTodos(todos, [{ id: 1, status: "verifying", evidence: "this should be blocked" }]);
534
-
535
- expect(result.blocked).toBeDefined();
536
- expect(result.blocked!.length).toBe(1);
537
- expect(result.blocked![0].reason).toContain("verifyText");
538
- expect(result.updatedTodos[0].status).toBe("in_progress"); // unchanged
539
- });
540
-
541
- it("should block verifying without evidence", () => {
542
- const todos: Todo[] = [
543
- { id: 1, text: "fix auth", status: "in_progress", verifyText: "check codes", verifyAttempts: 0 },
544
- ];
545
- const result = updateTodos(todos, [{ id: 1, status: "verifying" }]);
546
-
547
- expect(result.blocked).toBeDefined();
548
- expect(result.blocked![0].reason).toContain("evidence");
549
- expect(result.updatedTodos[0].status).toBe("in_progress");
550
- });
198
+ // ── handleSingleUpdate FR-6 守卫(tool 单条路径)────
551
199
 
552
- it("should block verifying with evidence shorter than 10 chars", () => {
553
- const todos: Todo[] = [
554
- { id: 1, text: "fix auth", status: "in_progress", verifyText: "check codes", verifyAttempts: 0 },
555
- ];
556
- const result = updateTodos(todos, [{ id: 1, status: "verifying", evidence: "short" }]);
557
-
558
- expect(result.blocked).toBeDefined();
559
- expect(result.blocked![0].reason).toContain("10");
200
+ describe("handleSingleUpdate FR-6 guards (tool single path)", () => {
201
+ it("FR-6: cancelled todo + status → cannot restore", () => {
202
+ const state = createTodoSessionState();
203
+ state.todos = [{ id: 1, text: "dropped", status: "cancelled" }];
204
+ const result = handleSingleUpdate(state, { action: "update", id: 1, status: "pending" });
205
+ expect(result.error).toBe("#1 is cancelled (cannot restore)");
560
206
  });
561
207
 
562
- it("should allow verifying with valid evidence", () => {
563
- const todos: Todo[] = [
564
- { id: 1, text: "fix auth", status: "in_progress", verifyText: "check codes", verifyAttempts: 0 },
565
- ];
566
- const result = updateTodos(todos, [{ id: 1, status: "verifying", evidence: "grep confirmed no residual code" }]);
567
-
568
- expect(result.error).toBeUndefined();
569
- expect(result.blocked).toBeUndefined();
570
- expect(result.updatedTodos[0].status).toBe("verifying");
571
- expect(result.updatedTodos[0].evidence).toBe("grep confirmed no residual code");
208
+ it("FR-6: verification todo + status=cancelled → cannot cancel", () => {
209
+ const state = createTodoSessionState();
210
+ state.todos = [{ id: 2, text: "run tests", status: "in_progress", isVerification: true }];
211
+ const result = handleSingleUpdate(state, { action: "update", id: 2, status: "cancelled" });
212
+ expect(result.error).toBe("#2 is verification todo (cannot cancel)");
572
213
  });
214
+ });
573
215
 
574
- it("should block verifying → completed without evidence", () => {
575
- const todos: Todo[] = [
576
- { id: 1, text: "fix auth", status: "verifying", verifyText: "check codes", verifyAttempts: 0, evidence: "testing" },
577
- ];
578
- const result = updateTodos(todos, [{ id: 1, status: "completed" }]);
579
-
580
- expect(result.blocked).toBeDefined();
581
- expect(result.blocked![0].reason).toContain("evidence");
582
- expect(result.updatedTodos[0].status).toBe("verifying"); // unchanged
583
- });
584
-
585
- it("should allow verifying → completed with evidence", () => {
586
- const todos: Todo[] = [
587
- { id: 1, text: "fix auth", status: "verifying", verifyText: "check codes", verifyAttempts: 0, evidence: "testing in progress" },
588
- ];
589
- const result = updateTodos(todos, [{ id: 1, status: "completed", evidence: "all 42 tests passed, typecheck clean" }]);
590
-
591
- expect(result.error).toBeUndefined();
592
- expect(result.updatedTodos[0].status).toBe("completed");
593
- expect(result.updatedTodos[0].evidence).toBe("all 42 tests passed, typecheck clean");
594
- });
216
+ // ── completed 无拦截 ────────────────────────────────
595
217
 
596
- it("should allow in_progress → completed directly for tasks without verifyText", () => {
597
- const todos: Todo[] = [
598
- { id: 1, text: "simple", status: "in_progress", verifyAttempts: 0 },
599
- ];
218
+ describe("completed without interception", () => {
219
+ it("should allow in_progress completed directly", () => {
220
+ const todos: Todo[] = [{ id: 1, text: "simple", status: "in_progress" }];
600
221
  const result = updateTodos(todos, [{ id: 1, status: "completed" }]);
601
-
602
222
  expect(result.error).toBeUndefined();
603
- expect(result.blocked).toBeUndefined();
604
223
  expect(result.updatedTodos[0].status).toBe("completed");
605
224
  });
606
225
 
607
- it("should block in_progress → completed on verifyText task without verified+evidence", () => {
608
- const todos: Todo[] = [
609
- { id: 1, text: "fix auth", status: "in_progress", verifyText: "check codes", verifyAttempts: 0 },
610
- ];
226
+ it("should allow pending → completed directly", () => {
227
+ const todos: Todo[] = [{ id: 1, text: "skip", status: "pending" }];
611
228
  const result = updateTodos(todos, [{ id: 1, status: "completed" }]);
612
-
613
- expect(result.blocked).toBeDefined();
614
- expect(result.blocked![0].reason).toContain("verifying");
615
- });
616
-
617
- it("should allow in_progress → completed with verified=true + evidence (skip verifying)", () => {
618
- const todos: Todo[] = [
619
- { id: 1, text: "fix auth", status: "in_progress", verifyText: "check codes", verifyAttempts: 0 },
620
- ];
621
- const result = updateTodos(todos, [{ id: 1, status: "completed", verified: true, evidence: "all tests passed confirmed" }]);
622
-
623
229
  expect(result.error).toBeUndefined();
624
230
  expect(result.updatedTodos[0].status).toBe("completed");
625
- expect(result.updatedTodos[0].evidence).toBe("all tests passed confirmed");
626
- });
627
-
628
- it("should block in_progress → completed with verified=true but no evidence", () => {
629
- const todos: Todo[] = [
630
- { id: 1, text: "fix auth", status: "in_progress", verifyText: "check codes", verifyAttempts: 0 },
631
- ];
632
- const result = updateTodos(todos, [{ id: 1, status: "completed", verified: true }]);
633
-
634
- expect(result.blocked).toBeDefined();
635
- expect(result.blocked![0].reason).toContain("evidence");
636
231
  });
637
232
 
638
- it("should block batch completed when mixed verifyText tasks lack evidence", () => {
233
+ it("should allow batch all completed without evidence", () => {
639
234
  const todos: Todo[] = [
640
- { id: 1, text: "A", status: "in_progress", verifyText: "check A", verifyAttempts: 0 },
641
- { id: 2, text: "B", status: "in_progress", verifyAttempts: 0 },
235
+ { id: 1, text: "A", status: "in_progress" },
236
+ { id: 2, text: "B", status: "in_progress" },
642
237
  ];
643
238
  const result = updateTodos(todos, [
644
239
  { id: 1, status: "completed" },
645
240
  { id: 2, status: "completed" },
646
241
  ]);
647
-
648
- expect(result.blocked).toBeDefined();
649
- expect(result.blocked!.length).toBe(1); // only #1 blocked
650
- expect(result.blocked![0].id).toBe(1);
651
- // both unchanged (all-or-nothing)
652
- expect(result.updatedTodos[0].status).toBe("in_progress");
653
- expect(result.updatedTodos[1].status).toBe("in_progress");
654
- });
655
-
656
- it("should allow batch when verifyText task has verified+evidence and non-verifyText task is plain", () => {
657
- const todos: Todo[] = [
658
- { id: 1, text: "A", status: "in_progress", verifyText: "check A", verifyAttempts: 0 },
659
- { id: 2, text: "B", status: "in_progress", verifyAttempts: 0 },
660
- ];
661
- const result = updateTodos(todos, [
662
- { id: 1, status: "completed", verified: true, evidence: "confirmed via grep" },
663
- { id: 2, status: "completed" },
664
- ]);
665
-
666
242
  expect(result.error).toBeUndefined();
667
243
  expect(result.updatedTodos[0].status).toBe("completed");
668
244
  expect(result.updatedTodos[1].status).toBe("completed");
669
245
  });
246
+ });
670
247
 
671
- it("should allow non-completed status changes without evidence", () => {
672
- const todos: Todo[] = [
673
- { id: 1, text: "A", status: "pending", verifyText: "check A", verifyAttempts: 0 },
674
- ];
675
- const result = updateTodos(todos, [{ id: 1, status: "in_progress" }]);
248
+ // ── formatTodoLine ──────────────────────────────────
676
249
 
677
- expect(result.error).toBeUndefined();
678
- expect(result.blocked).toBeUndefined();
679
- expect(result.updatedTodos[0].status).toBe("in_progress");
250
+ describe("formatTodoLine", () => {
251
+ it("should format pending todo", () => {
252
+ const todo: Todo = { id: 1, text: "task A", status: "pending" };
253
+ expect(formatTodoLine(todo)).toBe("[ ] #1: task A");
680
254
  });
681
255
 
682
- it("should allow verifying → in_progress (verification failed, rework)", () => {
683
- const todos: Todo[] = [
684
- { id: 1, text: "fix auth", status: "verifying", verifyText: "check codes", verifyAttempts: 0, evidence: "initial check" },
685
- ];
686
- const result = updateTodos(todos, [{ id: 1, status: "in_progress" }]);
256
+ it("should format in_progress todo", () => {
257
+ const todo: Todo = { id: 2, text: "task B", status: "in_progress" };
258
+ expect(formatTodoLine(todo)).toBe("[~] #2: task B");
259
+ });
687
260
 
688
- expect(result.error).toBeUndefined();
689
- expect(result.updatedTodos[0].status).toBe("in_progress");
261
+ it("should format completed todo", () => {
262
+ const todo: Todo = { id: 3, text: "task C", status: "completed" };
263
+ expect(formatTodoLine(todo)).toBe("[x] #3: task C");
264
+ });
265
+
266
+ it("should format cancelled todo", () => {
267
+ const todo: Todo = { id: 4, text: "task D", status: "cancelled" };
268
+ expect(formatTodoLine(todo)).toBe("[-] #4: task D");
690
269
  });
691
270
  });
692
271
 
693
- // ── buildRender ───────────────────────────────────────
272
+ // ── buildRender ─────────────────────────────────────
694
273
 
695
274
  describe("buildRender", () => {
696
275
  it("should calculate summary correctly", () => {
697
276
  const todos: Todo[] = [
698
- { id: 1, text: "a", status: "completed", verifyAttempts: 0 },
699
- { id: 2, text: "b", status: "pending", verifyAttempts: 0 },
700
- { id: 3, text: "c", status: "failed", verifyAttempts: 2 },
277
+ { id: 1, text: "a", status: "completed" },
278
+ { id: 2, text: "b", status: "pending" },
279
+ { id: 3, text: "c", status: "in_progress" },
701
280
  ];
702
281
  const render = buildRender(todos);
703
-
704
282
  expect(render).toBeDefined();
705
283
  expect(render!.summary).toBe("1/3 completed");
706
284
  expect(render!.data.items).toHaveLength(3);
@@ -708,53 +286,152 @@ describe("buildRender", () => {
708
286
 
709
287
  it("should handle empty list", () => {
710
288
  const render = buildRender([]);
711
-
712
- expect(render).toBeDefined();
713
289
  expect(render!.summary).toBe("0/0 completed");
714
- expect(render!.data.items).toHaveLength(0);
715
290
  });
716
291
  });
717
292
 
718
- // ── batch update verifyAttempts increment ───────────
719
-
720
- describe("batch update verifyAttempts increment", () => {
721
- it("should increment verifyAttempts when batch reverts completed → in_progress with verifyText", () => {
293
+ // ── widget 渲染布局 ────────────────────────────────
294
+
295
+ const mockTheme = {
296
+ fg: (_color: string, text: string) => text,
297
+ bg: (_color: string, text: string) => text,
298
+ bold: (text: string) => text,
299
+ italic: (text: string) => text,
300
+ underline: (text: string) => text,
301
+ inverse: (text: string) => text,
302
+ strikethrough: (text: string) => text,
303
+ getFgAnsi: (_color: string) => "",
304
+ getBgAnsi: (_color: string) => "",
305
+ getColorMode: () => "truecolor" as const,
306
+ getThinkingBorderColor: () => (text: string) => text,
307
+ getBashModeBorderColor: () => (text: string) => text,
308
+ } as unknown as Theme;
309
+
310
+ describe("widget rendering", () => {
311
+ it("should render empty list as empty widget", () => {
312
+ expect(renderWidgetLines([], mockTheme, 80)).toEqual([]);
313
+ });
314
+
315
+ it("should use single column for 3 tasks", () => {
722
316
  const todos: Todo[] = [
723
- { id: 1, text: "fix auth", status: "completed", verifyText: "check codes", verifyAttempts: 0 },
317
+ { id: 1, text: "A", status: "pending" },
318
+ { id: 2, text: "B", status: "in_progress" },
319
+ { id: 3, text: "C", status: "completed" },
724
320
  ];
725
- const result = updateTodos(todos, [{ id: 1, status: "in_progress" }]);
321
+ const lines = renderWidgetLines(todos, mockTheme, 80);
322
+ expect(lines.length).toBe(4); // 1 header + 3 items
323
+ expect(lines[0]).toContain("1/3");
324
+ expect(lines[1]).toContain("#1");
325
+ expect(lines[2]).toContain("#2");
326
+ expect(lines[3]).toContain("#3");
327
+ });
328
+
329
+ it("should use single column up to 8 tasks", () => {
330
+ const todos: Todo[] = Array.from({ length: 8 }, (_, i) => ({
331
+ id: i + 1,
332
+ text: `Task ${i + 1}`,
333
+ status: "pending" as const,
334
+ }));
335
+ const lines = renderWidgetLines(todos, mockTheme, 80);
336
+ expect(lines.length).toBe(9); // 1 header + 8 items
337
+ for (let i = 1; i < lines.length; i++) {
338
+ expect(lines[i]).toContain(`#${i}`);
339
+ }
340
+ });
726
341
 
727
- expect(result.error).toBeUndefined();
728
- expect(result.updatedTodos[0].verifyAttempts).toBe(1);
342
+ it("should switch to dual column for 9 tasks", () => {
343
+ const todos: Todo[] = Array.from({ length: 9 }, (_, i) => ({
344
+ id: i + 1,
345
+ text: `Task ${i + 1}`,
346
+ status: "pending" as const,
347
+ }));
348
+ const lines = renderWidgetLines(todos, mockTheme, 80);
349
+ expect(lines.length).toBe(6); // 1 header + ceil(9/2)=5 rows
350
+ expect(lines[0]).toContain("0/9");
351
+ });
352
+
353
+ it("should keep dual column within Pi widget max lines for 18 tasks", () => {
354
+ const todos: Todo[] = Array.from({ length: 18 }, (_, i) => ({
355
+ id: i + 1,
356
+ text: `Task ${i + 1}`,
357
+ status: "pending" as const,
358
+ }));
359
+ const lines = renderWidgetLines(todos, mockTheme, 80);
360
+ expect(lines.length).toBe(10); // 1 header + 9 rows
361
+ });
362
+
363
+ it("should keep widget lines within Pi max for 19 tasks", () => {
364
+ const todos: Todo[] = Array.from({ length: 19 }, (_, i) => ({
365
+ id: i + 1,
366
+ text: `Task ${i + 1}`,
367
+ status: "pending" as const,
368
+ }));
369
+ const lines = renderWidgetLines(todos, mockTheme, 80);
370
+ expect(lines.length).toBe(11); // 1 header + ceil(19/2)=10 rows; Pi truncates at 10
729
371
  });
372
+ });
730
373
 
731
- it("should increment verifyAttempts when batch reverts verifying → in_progress with verifyText", () => {
732
- const todos: Todo[] = [
733
- { id: 1, text: "fix auth", status: "verifying", verifyText: "check codes", verifyAttempts: 1, evidence: "testing" },
734
- ];
735
- const result = updateTodos(todos, [{ id: 1, status: "in_progress" }]);
374
+ // ── agent_end logic (pure data) ─────────────────────
736
375
 
737
- expect(result.error).toBeUndefined();
738
- expect(result.updatedTodos[0].verifyAttempts).toBe(2);
376
+ describe("agent_end logic", () => {
377
+ it("should detect stall when no todo activity for threshold rounds", () => {
378
+ const STALL_THRESHOLD = 5;
379
+ const userMessageCount = 10;
380
+ const lastTodoCallCount = 3;
381
+ const todos: Todo[] = [{ id: 1, text: "pending task", status: "pending" }];
382
+
383
+ const isStalled =
384
+ todos.length > 0 &&
385
+ userMessageCount - lastTodoCallCount >= STALL_THRESHOLD;
386
+
387
+ expect(isStalled).toBe(true);
739
388
  });
740
389
 
741
- it("should NOT increment verifyAttempts for tasks without verifyText", () => {
742
- const todos: Todo[] = [
743
- { id: 1, text: "simple", status: "completed", verifyAttempts: 0 },
744
- ];
745
- const result = updateTodos(todos, [{ id: 1, status: "in_progress" }]);
390
+ it("should detect reminder when interval elapsed", () => {
391
+ const REMINDER_INTERVAL = 2;
392
+ const userMessageCount = 5;
393
+ const lastTodoCallCount = 3;
394
+ const todos: Todo[] = [{ id: 1, text: "task", status: "pending" }];
746
395
 
747
- expect(result.error).toBeUndefined();
748
- expect(result.updatedTodos[0].verifyAttempts).toBe(0);
396
+ const needsReminder =
397
+ todos.length > 0 &&
398
+ userMessageCount - lastTodoCallCount >= REMINDER_INTERVAL;
399
+
400
+ expect(needsReminder).toBe(true);
749
401
  });
750
402
 
751
- it("should NOT increment verifyAttempts when already at MAX (2)", () => {
403
+ it("should auto-clear when all completed and delay rounds elapsed", () => {
404
+ const AUTO_CLEAR_DELAY_ROUNDS = 2;
405
+ const userMessageCount = 7;
406
+ const allCompletedAtCount = 4;
407
+
408
+ const shouldClear =
409
+ allCompletedAtCount !== null &&
410
+ userMessageCount - allCompletedAtCount >= AUTO_CLEAR_DELAY_ROUNDS;
411
+
412
+ expect(shouldClear).toBe(true);
413
+ });
414
+
415
+ it("should not auto-clear when delay rounds not yet elapsed", () => {
416
+ const AUTO_CLEAR_DELAY_ROUNDS = 2;
417
+ const userMessageCount = 5;
418
+ const allCompletedAtCount = 4;
419
+
420
+ const shouldClear =
421
+ allCompletedAtCount !== null &&
422
+ userMessageCount - allCompletedAtCount >= AUTO_CLEAR_DELAY_ROUNDS;
423
+
424
+ expect(shouldClear).toBe(false);
425
+ });
426
+
427
+ it("should pick first pending todo as next recommended", () => {
752
428
  const todos: Todo[] = [
753
- { id: 1, text: "fix auth", status: "completed", verifyText: "check codes", verifyAttempts: 2 },
429
+ { id: 1, text: "A", status: "completed" },
430
+ { id: 2, text: "B", status: "pending" },
431
+ { id: 3, text: "C", status: "pending" },
754
432
  ];
755
- const result = updateTodos(todos, [{ id: 1, status: "in_progress" }]);
756
-
757
- expect(result.error).toBeUndefined();
758
- expect(result.updatedTodos[0].verifyAttempts).toBe(2); // capped
433
+ const next = todos.find((t) => t.status !== "completed");
434
+ expect(next!.id).toBe(2);
435
+ expect(next!.text).toBe("B");
759
436
  });
760
437
  });