@zhushanwen/pi-todo 0.1.6 → 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.
@@ -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,101 @@ import {
9
10
  updateTodos,
10
11
  VALID_STATUSES,
11
12
  } from "../model";
13
+ import { renderWidgetLines } from "../render";
12
14
 
13
- // ── Task 1: 数据模型增强 + 向后兼容 ──────────────────
15
+ // ── 数据模型 + 向后兼容 ──────────────────────────────
14
16
 
15
- describe("Todo data model - Task 1", () => {
17
+ describe("Todo data model", () => {
16
18
  it("should load old data without verifyText/verifyAttempts", () => {
17
- // 旧格式: 没有 verifyText 和 verifyAttempts 字段
18
19
  const oldTodo = { id: 1, text: "test", status: "completed" } as unknown as Todo;
19
20
  const migrated = migrateTodo(oldTodo);
20
21
 
21
- expect(migrated.verifyText).toBeUndefined();
22
- expect(migrated.verifyAttempts).toBe(0);
23
22
  expect(migrated.status).toBe("completed");
24
23
  expect(migrated.text).toBe("test");
25
24
  expect(migrated.id).toBe(1);
26
25
  });
27
26
 
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");
27
+ it("should include exactly three valid statuses", () => {
28
+ expect(VALID_STATUSES).toEqual(["pending", "in_progress", "completed"]);
40
29
  });
41
30
 
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;
31
+ it("should migrate verifying in_progress", () => {
32
+ const oldTodo = { id: 1, text: "test", status: "verifying" } as unknown as Todo;
44
33
  const migrated = migrateTodo(oldTodo);
45
-
46
- expect(migrated.verifyAttempts).toBe(0);
34
+ expect(migrated.status).toBe("in_progress");
47
35
  });
48
36
 
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);
37
+ it("should migrate failed pending", () => {
38
+ const oldTodo = { id: 1, text: "test", status: "failed" } as unknown as Todo;
39
+ const migrated = migrateTodo(oldTodo);
40
+ expect(migrated.status).toBe("pending");
61
41
  });
62
42
 
63
- it("should migrate done:true to completed with default verify fields", () => {
64
- // 极旧格式: done: boolean
43
+ it("should migrate done:true to completed", () => {
65
44
  const veryOldTodo = { id: 3, text: "ancient", done: true } as unknown as Todo;
66
45
  const migrated = migrateTodo(veryOldTodo);
67
-
68
46
  expect(migrated.status).toBe("completed");
69
- expect(migrated.verifyText).toBeUndefined();
70
- expect(migrated.verifyAttempts).toBe(0);
71
47
  });
72
48
 
73
- it("should migrate done:false to pending with default verify fields", () => {
49
+ it("should migrate done:false to pending", () => {
74
50
  const veryOldTodo = { id: 4, text: "ancient2", done: false } as unknown as Todo;
75
51
  const migrated = migrateTodo(veryOldTodo);
76
-
77
52
  expect(migrated.status).toBe("pending");
78
- expect(migrated.verifyText).toBeUndefined();
79
- expect(migrated.verifyAttempts).toBe(0);
80
- });
81
-
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;
95
- const migrated = migrateTodo(todo);
96
- expect(migrated.evidence).toBe("grep confirmed no residual");
97
- expect(migrated.status).toBe("verifying");
98
- });
99
-
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();
104
53
  });
105
54
  });
106
55
 
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"]);
56
+ // ── todo add ────────────────────────────────────────
112
57
 
58
+ describe("todo add", () => {
59
+ it("should add todos with sequential IDs", () => {
60
+ const result = addTodos([], 1, ["A", "B"]);
113
61
  expect(result.error).toBeUndefined();
114
62
  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);
63
+ expect(result.newTodos[0].id).toBe(1);
64
+ expect(result.newTodos[1].id).toBe(2);
65
+ expect(result.newTodos[0].status).toBe("pending");
136
66
  });
137
67
 
138
- it("should map all verifyTexts when lengths match", () => {
139
- const result = addTodos([], 1, ["A", "B", "C"], ["V1", "V2", "V3"]);
140
-
68
+ it("should append to existing todos", () => {
69
+ const existing: Todo[] = [{ id: 1, text: "existing", status: "pending" }];
70
+ const result = addTodos(existing, 2, ["new task"]);
141
71
  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");
72
+ expect(result.newTodos).toHaveLength(2);
73
+ expect(result.newTodos[1].id).toBe(2);
74
+ expect(result.newTodos[1].text).toBe("new task");
75
+ expect(result.newNextId).toBe(3);
146
76
  });
147
77
 
148
78
  it("should return error when texts is empty", () => {
149
79
  const result = addTodos([], 1, []);
150
-
151
80
  expect(result.error).toBe("texts required");
152
81
  });
153
82
 
154
83
  it("should return error when all texts are empty after trim", () => {
155
84
  const result = addTodos([], 1, [" ", " "]);
156
-
157
85
  expect(result.error).toBe("all texts empty");
158
86
  });
159
87
 
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 "]);
163
-
88
+ it("should trim texts", () => {
89
+ const result = addTodos([], 1, [" new task "]);
164
90
  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);
91
+ expect(result.newTodos[0].text).toBe("new task");
169
92
  });
170
93
  });
171
94
 
172
- // ── Task 3: todo update batch updates[] ──────────────
95
+ // ── todo update batch ───────────────────────────────
173
96
 
174
- describe("todo update batch - Task 3", () => {
97
+ describe("todo update batch", () => {
175
98
  it("should update multiple todos with updates[]", () => {
176
99
  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 },
100
+ { id: 1, text: "A", status: "pending" },
101
+ { id: 2, text: "B", status: "in_progress" },
102
+ { id: 3, text: "C", status: "pending" },
180
103
  ];
181
104
  const result = updateTodos(todos, [
182
105
  { id: 1, status: "completed" },
183
106
  { id: 2, text: "B updated" },
184
- { id: 3, status: "failed", text: "C failed" },
107
+ { id: 3, status: "completed", text: "C done" },
185
108
  ]);
186
109
 
187
110
  expect(result.error).toBeUndefined();
@@ -189,222 +112,217 @@ describe("todo update batch - Task 3", () => {
189
112
  expect(result.updatedTodos[0].status).toBe("completed");
190
113
  expect(result.updatedTodos[0].text).toBe("A");
191
114
  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)");
115
+ expect(result.updatedTodos[1].status).toBe("in_progress");
116
+ expect(result.updatedTodos[2].status).toBe("completed");
117
+ expect(result.updatedTodos[2].text).toBe("C done");
196
118
  });
197
119
 
198
120
  it("should reject duplicate ids in updates[]", () => {
199
- const todos: Todo[] = [
200
- { id: 1, text: "A", status: "pending", verifyAttempts: 0 },
201
- ];
121
+ const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
202
122
  const result = updateTodos(todos, [
203
123
  { id: 1, status: "completed" },
204
124
  { id: 1, status: "pending" },
205
125
  ]);
206
-
207
126
  expect(result.error).toBe("duplicate ids in updates");
208
- expect(result.updatedTodos).toEqual(todos); // unchanged
127
+ expect(result.updatedTodos).toEqual(todos);
209
128
  });
210
129
 
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
-
130
+ it("should reject non-existent ids", () => {
131
+ const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
132
+ const result = updateTodos(todos, [{ id: 999, status: "pending" }]);
220
133
  expect(result.error).toBe("id 999 not found");
221
- expect(result.updatedTodos[0].status).toBe("pending"); // #1 not modified
222
134
  });
223
135
 
224
136
  it("should reject updates[] item missing both status and text", () => {
225
- const todos: Todo[] = [
226
- { id: 1, text: "A", status: "pending", verifyAttempts: 0 },
227
- ];
137
+ const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
228
138
  const result = updateTodos(todos, [{ id: 1 }]);
229
-
230
139
  expect(result.error).toContain("neither status nor text");
231
- expect(result.updatedTodos[0].status).toBe("pending"); // unchanged
232
140
  });
233
- });
234
141
 
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 测试全通过");
142
+ it("should reject invalid status values", () => {
143
+ const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
144
+ const result = updateTodos(todos, [{ id: 1, status: "banana" }]);
145
+ expect(result.error).toContain("invalid status");
295
146
  });
296
147
  });
297
148
 
298
- // ── Task 5: agent_end loop logic (pure data model tests) ──────────
149
+ // ── completed 无拦截 ────────────────────────────────
299
150
 
300
- describe("agent_end loop logic - Task 5", () => {
301
- const MAX_VERIFY_ATTEMPTS = 2;
151
+ describe("completed without interception", () => {
152
+ it("should allow in_progress → completed directly", () => {
153
+ const todos: Todo[] = [{ id: 1, text: "simple", status: "in_progress" }];
154
+ const result = updateTodos(todos, [{ id: 1, status: "completed" }]);
155
+ expect(result.error).toBeUndefined();
156
+ expect(result.updatedTodos[0].status).toBe("completed");
157
+ });
158
+
159
+ it("should allow pending → completed directly", () => {
160
+ const todos: Todo[] = [{ id: 1, text: "skip", status: "pending" }];
161
+ const result = updateTodos(todos, [{ id: 1, status: "completed" }]);
162
+ expect(result.error).toBeUndefined();
163
+ expect(result.updatedTodos[0].status).toBe("completed");
164
+ });
302
165
 
303
- it("should detect completed tasks needing verification", () => {
166
+ it("should allow batch all completed without evidence", () => {
304
167
  const todos: Todo[] = [
305
- {
306
- id: 1,
307
- text: "task A",
308
- status: "completed",
309
- verifyText: "check output",
310
- verifyAttempts: 0,
311
- },
168
+ { id: 1, text: "A", status: "in_progress" },
169
+ { id: 2, text: "B", status: "in_progress" },
312
170
  ];
171
+ const result = updateTodos(todos, [
172
+ { id: 1, status: "completed" },
173
+ { id: 2, status: "completed" },
174
+ ]);
175
+ expect(result.error).toBeUndefined();
176
+ expect(result.updatedTodos[0].status).toBe("completed");
177
+ expect(result.updatedTodos[1].status).toBe("completed");
178
+ });
179
+ });
313
180
 
314
- const needsVerify = todos.find(
315
- (t) =>
316
- t.status === "completed" &&
317
- t.verifyText &&
318
- t.verifyAttempts < MAX_VERIFY_ATTEMPTS,
319
- );
181
+ // ── formatTodoLine ──────────────────────────────────
320
182
 
321
- expect(needsVerify).toBeDefined();
322
- expect(needsVerify!.id).toBe(1);
323
- expect(needsVerify!.verifyText).toBe("check output");
183
+ describe("formatTodoLine", () => {
184
+ it("should format pending todo", () => {
185
+ const todo: Todo = { id: 1, text: "task A", status: "pending" };
186
+ expect(formatTodoLine(todo)).toBe("[ ] #1: task A");
324
187
  });
325
188
 
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
- );
189
+ it("should format in_progress todo", () => {
190
+ const todo: Todo = { id: 2, text: "task B", status: "in_progress" };
191
+ expect(formatTodoLine(todo)).toBe("[~] #2: task B");
192
+ });
342
193
 
343
- expect(needsVerify).toBeUndefined();
194
+ it("should format completed todo", () => {
195
+ const todo: Todo = { id: 3, text: "task C", status: "completed" };
196
+ expect(formatTodoLine(todo)).toBe("[x] #3: task C");
344
197
  });
198
+ });
345
199
 
346
- it("should detect verify failure when attempts exceed max (completed status)", () => {
200
+ // ── buildRender ─────────────────────────────────────
201
+
202
+ describe("buildRender", () => {
203
+ it("should calculate summary correctly", () => {
347
204
  const todos: Todo[] = [
348
- {
349
- id: 2,
350
- text: "task B",
351
- status: "completed",
352
- verifyText: "check B",
353
- verifyAttempts: MAX_VERIFY_ATTEMPTS,
354
- },
205
+ { id: 1, text: "a", status: "completed" },
206
+ { id: 2, text: "b", status: "pending" },
207
+ { id: 3, text: "c", status: "in_progress" },
355
208
  ];
209
+ const render = buildRender(todos);
210
+ expect(render).toBeDefined();
211
+ expect(render!.summary).toBe("1/3 completed");
212
+ expect(render!.data.items).toHaveLength(3);
213
+ });
356
214
 
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");
215
+ it("should handle empty list", () => {
216
+ const render = buildRender([]);
217
+ expect(render!.summary).toBe("0/0 completed");
367
218
  });
219
+ });
368
220
 
369
- it("should increment verifyAttempts when AI re-opens completed task with verifyText", () => {
221
+ // ── widget 渲染布局 ────────────────────────────────
222
+
223
+ const mockTheme = {
224
+ fg: (_color: string, text: string) => text,
225
+ bg: (_color: string, text: string) => text,
226
+ bold: (text: string) => text,
227
+ italic: (text: string) => text,
228
+ underline: (text: string) => text,
229
+ inverse: (text: string) => text,
230
+ strikethrough: (text: string) => text,
231
+ getFgAnsi: (_color: string) => "",
232
+ getBgAnsi: (_color: string) => "",
233
+ getColorMode: () => "truecolor" as const,
234
+ getThinkingBorderColor: () => (text: string) => text,
235
+ getBashModeBorderColor: () => (text: string) => text,
236
+ } as unknown as Theme;
237
+
238
+ describe("widget rendering", () => {
239
+ it("should render empty list as empty widget", () => {
240
+ expect(renderWidgetLines([], mockTheme, 80)).toEqual([]);
241
+ });
242
+
243
+ it("should use single column for 3 tasks", () => {
370
244
  const todos: Todo[] = [
371
- { id: 1, text: "task A", status: "completed", verifyText: "check A", verifyAttempts: 0 },
245
+ { id: 1, text: "A", status: "pending" },
246
+ { id: 2, text: "B", status: "in_progress" },
247
+ { id: 3, text: "C", status: "completed" },
372
248
  ];
249
+ const lines = renderWidgetLines(todos, mockTheme, 80);
250
+ expect(lines.length).toBe(4); // 1 header + 3 items
251
+ expect(lines[0]).toContain("1/3");
252
+ expect(lines[1]).toContain("#1");
253
+ expect(lines[2]).toContain("#2");
254
+ expect(lines[3]).toContain("#3");
255
+ });
256
+
257
+ it("should use single column up to 8 tasks", () => {
258
+ const todos: Todo[] = Array.from({ length: 8 }, (_, i) => ({
259
+ id: i + 1,
260
+ text: `Task ${i + 1}`,
261
+ status: "pending" as const,
262
+ }));
263
+ const lines = renderWidgetLines(todos, mockTheme, 80);
264
+ expect(lines.length).toBe(9); // 1 header + 8 items
265
+ for (let i = 1; i < lines.length; i++) {
266
+ expect(lines[i]).toContain(`#${i}`);
267
+ }
268
+ });
373
269
 
374
- todos[0].status = "in_progress";
375
- todos[0].verifyAttempts++;
376
- expect(todos[0].verifyAttempts).toBe(1);
270
+ it("should switch to dual column for 9 tasks", () => {
271
+ const todos: Todo[] = Array.from({ length: 9 }, (_, i) => ({
272
+ id: i + 1,
273
+ text: `Task ${i + 1}`,
274
+ status: "pending" as const,
275
+ }));
276
+ const lines = renderWidgetLines(todos, mockTheme, 80);
277
+ expect(lines.length).toBe(6); // 1 header + ceil(9/2)=5 rows
278
+ expect(lines[0]).toContain("0/9");
279
+ });
280
+
281
+ it("should keep dual column within Pi widget max lines for 18 tasks", () => {
282
+ const todos: Todo[] = Array.from({ length: 18 }, (_, i) => ({
283
+ id: i + 1,
284
+ text: `Task ${i + 1}`,
285
+ status: "pending" as const,
286
+ }));
287
+ const lines = renderWidgetLines(todos, mockTheme, 80);
288
+ expect(lines.length).toBe(10); // 1 header + 9 rows
289
+ });
290
+
291
+ it("should keep widget lines within Pi max for 19 tasks", () => {
292
+ const todos: Todo[] = Array.from({ length: 19 }, (_, i) => ({
293
+ id: i + 1,
294
+ text: `Task ${i + 1}`,
295
+ status: "pending" as const,
296
+ }));
297
+ const lines = renderWidgetLines(todos, mockTheme, 80);
298
+ expect(lines.length).toBe(11); // 1 header + ceil(19/2)=10 rows; Pi truncates at 10
377
299
  });
300
+ });
301
+
302
+ // ── agent_end logic (pure data) ─────────────────────
378
303
 
304
+ describe("agent_end logic", () => {
379
305
  it("should detect stall when no todo activity for threshold rounds", () => {
380
306
  const STALL_THRESHOLD = 5;
381
307
  const userMessageCount = 10;
382
308
  const lastTodoCallCount = 3;
383
- const todos: Todo[] = [
384
- { id: 1, text: "pending task", status: "pending", verifyAttempts: 0 },
385
- ];
386
- const allCompletedAtCount = null;
309
+ const todos: Todo[] = [{ id: 1, text: "pending task", status: "pending" }];
387
310
 
388
311
  const isStalled =
389
312
  todos.length > 0 &&
390
- allCompletedAtCount === null &&
391
313
  userMessageCount - lastTodoCallCount >= STALL_THRESHOLD;
392
314
 
393
315
  expect(isStalled).toBe(true);
394
316
  });
395
317
 
396
318
  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;
319
+ const REMINDER_INTERVAL = 2;
320
+ const userMessageCount = 5;
321
+ const lastTodoCallCount = 3;
322
+ const todos: Todo[] = [{ id: 1, text: "task", status: "pending" }];
404
323
 
405
324
  const needsReminder =
406
325
  todos.length > 0 &&
407
- allCompletedAtCount === null &&
408
326
  userMessageCount - lastTodoCallCount >= REMINDER_INTERVAL;
409
327
 
410
328
  expect(needsReminder).toBe(true);
@@ -434,327 +352,14 @@ describe("agent_end loop logic - Task 5", () => {
434
352
  expect(shouldClear).toBe(false);
435
353
  });
436
354
 
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
- });
477
- });
478
-
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 }];
484
- const result = updateTodos(todos, [{ id: 1, status: "banana" }]);
485
- 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
- });
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
-
509
- expect(todo.verifyAttempts).toBe(1);
510
- expect(todo.status).toBe("in_progress");
511
- });
512
-
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
523
- });
524
- });
525
-
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
- });
551
-
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");
560
- });
561
-
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");
572
- });
573
-
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
- });
595
-
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
- ];
600
- const result = updateTodos(todos, [{ id: 1, status: "completed" }]);
601
-
602
- expect(result.error).toBeUndefined();
603
- expect(result.blocked).toBeUndefined();
604
- expect(result.updatedTodos[0].status).toBe("completed");
605
- });
606
-
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
- ];
611
- 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
- expect(result.error).toBeUndefined();
624
- 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
- });
637
-
638
- it("should block batch completed when mixed verifyText tasks lack evidence", () => {
639
- 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 },
642
- ];
643
- const result = updateTodos(todos, [
644
- { id: 1, status: "completed" },
645
- { id: 2, status: "completed" },
646
- ]);
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
- expect(result.error).toBeUndefined();
667
- expect(result.updatedTodos[0].status).toBe("completed");
668
- expect(result.updatedTodos[1].status).toBe("completed");
669
- });
670
-
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" }]);
676
-
677
- expect(result.error).toBeUndefined();
678
- expect(result.blocked).toBeUndefined();
679
- expect(result.updatedTodos[0].status).toBe("in_progress");
680
- });
681
-
682
- it("should allow verifying → in_progress (verification failed, rework)", () => {
355
+ it("should pick first pending todo as next recommended", () => {
683
356
  const todos: Todo[] = [
684
- { id: 1, text: "fix auth", status: "verifying", verifyText: "check codes", verifyAttempts: 0, evidence: "initial check" },
357
+ { id: 1, text: "A", status: "completed" },
358
+ { id: 2, text: "B", status: "pending" },
359
+ { id: 3, text: "C", status: "pending" },
685
360
  ];
686
- const result = updateTodos(todos, [{ id: 1, status: "in_progress" }]);
687
-
688
- expect(result.error).toBeUndefined();
689
- expect(result.updatedTodos[0].status).toBe("in_progress");
690
- });
691
- });
692
-
693
- // ── buildRender ───────────────────────────────────────
694
-
695
- describe("buildRender", () => {
696
- it("should calculate summary correctly", () => {
697
- 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 },
701
- ];
702
- const render = buildRender(todos);
703
-
704
- expect(render).toBeDefined();
705
- expect(render!.summary).toBe("1/3 completed");
706
- expect(render!.data.items).toHaveLength(3);
707
- });
708
-
709
- it("should handle empty list", () => {
710
- const render = buildRender([]);
711
-
712
- expect(render).toBeDefined();
713
- expect(render!.summary).toBe("0/0 completed");
714
- expect(render!.data.items).toHaveLength(0);
715
- });
716
- });
717
-
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", () => {
722
- const todos: Todo[] = [
723
- { id: 1, text: "fix auth", status: "completed", verifyText: "check codes", verifyAttempts: 0 },
724
- ];
725
- const result = updateTodos(todos, [{ id: 1, status: "in_progress" }]);
726
-
727
- expect(result.error).toBeUndefined();
728
- expect(result.updatedTodos[0].verifyAttempts).toBe(1);
729
- });
730
-
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" }]);
736
-
737
- expect(result.error).toBeUndefined();
738
- expect(result.updatedTodos[0].verifyAttempts).toBe(2);
739
- });
740
-
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" }]);
746
-
747
- expect(result.error).toBeUndefined();
748
- expect(result.updatedTodos[0].verifyAttempts).toBe(0);
749
- });
750
-
751
- it("should NOT increment verifyAttempts when already at MAX (2)", () => {
752
- const todos: Todo[] = [
753
- { id: 1, text: "fix auth", status: "completed", verifyText: "check codes", verifyAttempts: 2 },
754
- ];
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
361
+ const next = todos.find((t) => t.status !== "completed");
362
+ expect(next!.id).toBe(2);
363
+ expect(next!.text).toBe("B");
759
364
  });
760
365
  });