@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.
- package/package.json +1 -1
- package/src/__tests__/todo.test.ts +263 -586
- package/src/commands.ts +5 -26
- package/src/component.ts +11 -34
- package/src/handlers.ts +73 -109
- package/src/index.ts +5 -3
- package/src/model.ts +48 -144
- package/src/render.ts +108 -70
- package/src/state.ts +7 -1
- package/src/tool.ts +56 -144
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
-
// ──
|
|
17
|
+
// ── 数据模型 + 向后兼容 ──────────────────────────────
|
|
14
18
|
|
|
15
|
-
describe("Todo data model
|
|
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
|
|
29
|
-
|
|
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
|
|
43
|
-
const oldTodo = { id:
|
|
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
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
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
|
|
83
|
-
|
|
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.
|
|
97
|
-
expect(migrated.status).toBe("verifying");
|
|
60
|
+
expect(migrated.isVerification).toBe(true);
|
|
98
61
|
});
|
|
99
62
|
|
|
100
|
-
it("should
|
|
101
|
-
const
|
|
102
|
-
const migrated = migrateTodo(
|
|
103
|
-
expect(migrated.
|
|
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
|
-
// ──
|
|
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].
|
|
116
|
-
expect(result.newTodos[1].
|
|
117
|
-
expect(result.newTodos[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
|
|
139
|
-
const
|
|
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(
|
|
143
|
-
expect(result.newTodos[
|
|
144
|
-
expect(result.newTodos[1].
|
|
145
|
-
expect(result.
|
|
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
|
|
161
|
-
const
|
|
162
|
-
|
|
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).
|
|
166
|
-
expect(result.newTodos[1].
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
// ──
|
|
128
|
+
// ── todo update batch ───────────────────────────────
|
|
173
129
|
|
|
174
|
-
describe("todo update batch
|
|
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"
|
|
178
|
-
{ id: 2, text: "B", status: "in_progress"
|
|
179
|
-
{ id: 3, text: "C", status: "pending"
|
|
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: "
|
|
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");
|
|
193
|
-
expect(result.updatedTodos[2].status).toBe("
|
|
194
|
-
expect(result.updatedTodos[2].text).toBe("C
|
|
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);
|
|
160
|
+
expect(result.updatedTodos).toEqual(todos);
|
|
209
161
|
});
|
|
210
162
|
|
|
211
|
-
it("should reject non-existent ids
|
|
212
|
-
const todos: Todo[] = [
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
510
|
-
|
|
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("
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
];
|
|
556
|
-
const result =
|
|
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("
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
|
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
|
|
233
|
+
it("should allow batch all completed without evidence", () => {
|
|
639
234
|
const todos: Todo[] = [
|
|
640
|
-
{ id: 1, text: "A", status: "in_progress"
|
|
641
|
-
{ id: 2, text: "B", status: "in_progress"
|
|
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
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
|
683
|
-
const
|
|
684
|
-
|
|
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
|
-
|
|
689
|
-
|
|
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"
|
|
699
|
-
{ id: 2, text: "b", status: "pending"
|
|
700
|
-
{ id: 3, text: "c", status: "
|
|
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
|
-
// ──
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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: "
|
|
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
|
|
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
|
-
|
|
728
|
-
|
|
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
|
-
|
|
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
|
-
|
|
738
|
-
|
|
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
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
const
|
|
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
|
-
|
|
748
|
-
|
|
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
|
|
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: "
|
|
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
|
|
756
|
-
|
|
757
|
-
expect(
|
|
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
|
});
|