返回 DeepSeek-Reasonix
todo_test.go
根目录 / internal / tool / builtin / todo_test.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "strings"
7 "testing"
8
9 "reasonix/internal/evidence"
10 "reasonix/internal/tool"
11 )
12
13 func TestTodoWriteAcceptsLevels(t *testing.T) {
14 args := json.RawMessage(`{"todos":[` +
15 `{"content":"Phase","status":"pending","level":0},` +
16 `{"content":"sub","status":"in_progress","level":1}]}`)
17 if _, err := (todoWrite{}).Execute(context.Background(), args); err != nil {
18 t.Fatalf("levels 0/1 should be accepted: %v", err)
19 }
20 }
21
22 func TestTodoWriteRejectsBadLevel(t *testing.T) {
23 args := json.RawMessage(`{"todos":[{"content":"x","status":"pending","level":2}]}`)
24 _, err := (todoWrite{}).Execute(context.Background(), args)
25 if err == nil || !strings.Contains(err.Error(), "level") {
26 t.Fatalf("level 2 should be rejected with a level error, got %v", err)
27 }
28 }
29
30 func TestTodoWriteRejectsNonSerialStates(t *testing.T) {
31 for _, tc := range []struct {
32 name string
33 args string
34 want string
35 }{
36 {
37 name: "out of order completion",
38 args: `{"todos":[{"content":"first","status":"in_progress"},{"content":"second","status":"completed"}]}`,
39 want: "completed after unfinished",
40 },
41 {
42 name: "multiple current items",
43 args: `{"todos":[{"content":"first","status":"in_progress"},{"content":"second","status":"in_progress"}]}`,
44 want: "second in_progress",
45 },
46 {
47 name: "pending without current",
48 args: `{"todos":[{"content":"first","status":"pending"}]}`,
49 want: "no in_progress",
50 },
51 } {
52 t.Run(tc.name, func(t *testing.T) {
53 _, err := (todoWrite{}).Execute(context.Background(), json.RawMessage(tc.args))
54 if err == nil || !strings.Contains(err.Error(), tc.want) {
55 t.Fatalf("todo_write error = %v, want %q", err, tc.want)
56 }
57 })
58 }
59 }
60
61 func TestTodoWriteRejectsNewCompletedWithoutCompleteStepReceipt(t *testing.T) {
62 ledger := evidence.NewLedger()
63 ledger.Record(evidence.Receipt{
64 ToolName: "todo_write",
65 Success: true,
66 Todos: []evidence.TodoItem{{Content: "Add parser", Status: "in_progress"}},
67 })
68 ctx := evidence.WithLedger(context.Background(), ledger)
69 args := json.RawMessage(`{"todos":[{"content":"Add parser","status":"completed"}]}`)
70
71 _, err := (todoWrite{}).Execute(ctx, args)
72 if err == nil || !strings.Contains(err.Error(), "complete_step") {
73 t.Fatalf("new completion without complete_step should be rejected, got %v", err)
74 }
75 }
76
77 func TestTodoWriteAcceptsNewCompletedWithCompleteStepReceipt(t *testing.T) {
78 ledger := evidence.NewLedger()
79 ledger.Record(evidence.Receipt{
80 ToolName: "todo_write",
81 Success: true,
82 Todos: []evidence.TodoItem{{Content: "Add parser", Status: "in_progress"}},
83 })
84 ledger.Record(evidence.Receipt{ToolName: "complete_step", Success: true, Step: "Add parser"})
85 ctx := evidence.WithLedger(context.Background(), ledger)
86 args := json.RawMessage(`{"todos":[{"content":"Add parser","status":"completed"}]}`)
87
88 if _, err := (todoWrite{}).Execute(ctx, args); err != nil {
89 t.Fatalf("matching complete_step should authorize new completion: %v", err)
90 }
91 }
92
93 func TestTodoWriteRejectsInitialCompletedWithoutBaseline(t *testing.T) {
94 ctx := evidence.WithLedger(context.Background(), evidence.NewLedger())
95 args := json.RawMessage(`{"todos":[{"content":"Add parser","status":"completed"}]}`)
96
97 if _, err := (todoWrite{}).Execute(ctx, args); err == nil || !strings.Contains(err.Error(), "cannot start completed") {
98 t.Fatalf("initial completed todo without baseline should be rejected: %v", err)
99 }
100 }
101
102 func TestTodoWriteRejectsDroppingCurrentTodo(t *testing.T) {
103 ledger := evidence.NewLedger()
104 ledger.Record(evidence.Receipt{
105 ToolName: "todo_write",
106 Success: true,
107 Todos: []evidence.TodoItem{
108 {Content: "Inspect environment", Status: "in_progress"},
109 {Content: "Write code", Status: "pending"},
110 },
111 })
112 ctx := evidence.WithLedger(context.Background(), ledger)
113
114 for _, args := range []string{
115 `{"todos":[]}`,
116 `{"todos":[{"content":"Write code","status":"in_progress"}]}`,
117 } {
118 _, err := (todoWrite{}).Execute(ctx, json.RawMessage(args))
119 if err == nil || !strings.Contains(err.Error(), "cannot be removed or replaced") {
120 t.Fatalf("dropping current todo with %s should be rejected: %v", args, err)
121 }
122 }
123 }
124
125 func TestTodoWriteApprovedPlanReplacementPreservesCompletedHistory(t *testing.T) {
126 ledger := evidence.NewLedger()
127 ledger.Record(evidence.Receipt{
128 ToolName: "todo_write",
129 Success: true,
130 Todos: []evidence.TodoItem{
131 {Content: "Inspect environment", Status: "completed"},
132 {Content: "Implement parser", Status: "in_progress"},
133 },
134 })
135 ctx := evidence.WithLedger(context.Background(), ledger)
136 ctx = tool.WithPlanReplacementAuthorization(ctx)
137
138 valid := json.RawMessage(`{"todos":[
139 {"content":"Inspect environment","status":"completed"},
140 {"content":"Replace parser architecture","status":"in_progress"}
141 ]}`)
142 if _, err := (todoWrite{}).Execute(ctx, valid); err != nil {
143 t.Fatalf("approved plan replacement should succeed: %v", err)
144 }
145
146 dropsHistory := json.RawMessage(`{"todos":[{"content":"Replace parser architecture","status":"in_progress"}]}`)
147 if _, err := (todoWrite{}).Execute(ctx, dropsHistory); err == nil || !strings.Contains(err.Error(), "completed task history") {
148 t.Fatalf("approved replacement dropped completed history: %v", err)
149 }
150 }
151
152 func TestTodoWriteDoesNotTreatNumericContentAsStepIndex(t *testing.T) {
153 ledger := evidence.NewLedger()
154 ledger.Record(evidence.Receipt{
155 ToolName: "todo_write",
156 Success: true,
157 Todos: []evidence.TodoItem{
158 {Content: "Finished", Status: "completed"},
159 {Content: "2", Status: "in_progress"},
160 },
161 })
162 ctx := evidence.WithLedger(context.Background(), ledger)
163 args := json.RawMessage(`{"todos":[
164 {"content":"Finished","status":"completed"},
165 {"content":"Replacement","status":"in_progress"}
166 ]}`)
167
168 if _, err := (todoWrite{}).Execute(ctx, args); err == nil || !strings.Contains(err.Error(), "cannot be removed or replaced") {
169 t.Fatalf("numeric todo content should be matched by identity, got %v", err)
170 }
171 }
172
173 func TestTodoWriteAllowsRephrasingCurrentTodo(t *testing.T) {
174 ledger := evidence.NewLedger()
175 ledger.Record(evidence.Receipt{
176 ToolName: "todo_write",
177 Success: true,
178 Todos: []evidence.TodoItem{{Content: "Inspect environment", Status: "in_progress"}},
179 })
180 ctx := evidence.WithLedger(context.Background(), ledger)
181 args := json.RawMessage(`{"todos":[{"content":"Inspect environment and dependencies","status":"in_progress"}]}`)
182 if _, err := (todoWrite{}).Execute(ctx, args); err != nil {
183 t.Fatalf("rephrasing the current todo should remain allowed: %v", err)
184 }
185 }
186
187 func TestTodoWritePreservesCanonicalCompletedPrefixAcrossTurns(t *testing.T) {
188 ctx := evidence.WithLedger(context.Background(), evidence.NewLedger())
189 ctx = evidence.WithTodoState(ctx, []evidence.TodoItem{
190 {Content: "Inspect environment", Status: "completed"},
191 {Content: "Write code", Status: "in_progress"},
192 })
193
194 args := json.RawMessage(`{"todos":[
195 {"content":"Inspect environment","status":"completed"},
196 {"content":"Write code","status":"in_progress"}
197 ]}`)
198 if _, err := (todoWrite{}).Execute(ctx, args); err != nil {
199 t.Fatalf("cross-turn canonical prefix should remain valid: %v", err)
200 }
201 }
202
203 func TestTodoWriteCannotCompleteCanonicalCurrentAcrossTurns(t *testing.T) {
204 ctx := evidence.WithLedger(context.Background(), evidence.NewLedger())
205 ctx = evidence.WithTodoState(ctx, []evidence.TodoItem{
206 {Content: "Inspect environment", Status: "in_progress"},
207 })
208
209 args := json.RawMessage(`{"todos":[{"content":"Inspect environment","status":"completed"}]}`)
210 if _, err := (todoWrite{}).Execute(ctx, args); err == nil || !strings.Contains(err.Error(), "cannot become completed") {
211 t.Fatalf("cross-turn current todo completion should require complete_step: %v", err)
212 }
213 }
214
215 func TestTodoWriteRejectsDuplicatedOrReorderedCompletedPrefix(t *testing.T) {
216 ledger := evidence.NewLedger()
217 ledger.Record(evidence.Receipt{
218 ToolName: "todo_write",
219 Success: true,
220 Todos: []evidence.TodoItem{
221 {Content: "Inspect environment", Status: "completed"},
222 {Content: "Design solution", Status: "completed"},
223 {Content: "Write code", Status: "in_progress"},
224 },
225 })
226 ctx := evidence.WithLedger(context.Background(), ledger)
227
228 for _, args := range []string{
229 `{"todos":[
230 {"content":"Inspect environment","status":"completed"},
231 {"content":"Inspect environment","status":"completed"},
232 {"content":"Write code","status":"in_progress"}
233 ]}`,
234 `{"todos":[
235 {"content":"Design solution","status":"completed"},
236 {"content":"Inspect environment","status":"completed"},
237 {"content":"Write code","status":"in_progress"}
238 ]}`,
239 } {
240 _, err := (todoWrite{}).Execute(ctx, json.RawMessage(args))
241 if err == nil || !strings.Contains(err.Error(), "cannot be inserted, duplicated, or reordered") {
242 t.Fatalf("invalid completed prefix should be rejected: %v", err)
243 }
244 }
245 }
246
247 func TestTodoWriteRejectsFailedCompleteStepReceipt(t *testing.T) {
248 ledger := evidence.NewLedger()
249 ledger.Record(evidence.Receipt{
250 ToolName: "todo_write",
251 Success: true,
252 Todos: []evidence.TodoItem{{Content: "Add parser", Status: "in_progress"}},
253 })
254 ledger.Record(evidence.Receipt{ToolName: "complete_step", Success: false, Step: "Add parser"})
255 ctx := evidence.WithLedger(context.Background(), ledger)
256 args := json.RawMessage(`{"todos":[{"content":"Add parser","status":"completed"}]}`)
257
258 _, err := (todoWrite{}).Execute(ctx, args)
259 if err == nil || !strings.Contains(err.Error(), "complete_step") {
260 t.Fatalf("failed complete_step without proof-bearing recovery should not authorize completion, got %v", err)
261 }
262 }
263
264 func TestTodoWriteRejectsFailedCompleteStepWithoutProof(t *testing.T) {
265 ledger := evidence.NewLedger()
266 ledger.Record(evidence.Receipt{
267 ToolName: "todo_write",
268 Success: true,
269 Todos: []evidence.TodoItem{{Content: "Run project script", Status: "in_progress"}},
270 })
271 ledger.Record(evidence.Receipt{
272 ToolName: "bash",
273 Success: true,
274 Command: `python "script.py"`,
275 })
276 ledger.Record(evidence.ReceiptFromToolCall("complete_step", json.RawMessage(`{
277 "step":"Run project script",
278 "result":"script ran",
279 "evidence":[]
280 }`), false, true))
281 ctx := evidence.WithLedger(context.Background(), ledger)
282 args := json.RawMessage(`{"todos":[{"content":"Run project script","status":"completed"}]}`)
283
284 _, err := (todoWrite{}).Execute(ctx, args)
285 if err == nil || !strings.Contains(err.Error(), "complete_step") {
286 t.Fatalf("failed complete_step without proof should not authorize completion, got %v", err)
287 }
288 }
289
290 func TestTodoWriteRejectsFailedCompleteStepMissingResult(t *testing.T) {
291 ledger := evidence.NewLedger()
292 ledger.Record(evidence.Receipt{
293 ToolName: "todo_write",
294 Success: true,
295 Todos: []evidence.TodoItem{{Content: "Run project script", Status: "in_progress"}},
296 })
297 ledger.Record(evidence.Receipt{
298 ToolName: "bash",
299 Success: true,
300 Command: `python "script.py"`,
301 })
302 ledger.Record(evidence.ReceiptFromToolCall("complete_step", json.RawMessage(`{
303 "step":"Run project script",
304 "evidence":[{"kind":"manual","summary":"checked manually"}]
305 }`), false, true))
306 ctx := evidence.WithLedger(context.Background(), ledger)
307 args := json.RawMessage(`{"todos":[{"content":"Run project script","status":"completed"}]}`)
308
309 _, err := (todoWrite{}).Execute(ctx, args)
310 if err == nil || !strings.Contains(err.Error(), "complete_step") {
311 t.Fatalf("failed complete_step without result should not authorize completion, got %v", err)
312 }
313 }
314
315 func TestTodoWriteRecoversAfterFailedCompleteStepWithProgressReceipt(t *testing.T) {
316 ledger := evidence.NewLedger()
317 ledger.Record(evidence.Receipt{
318 ToolName: "todo_write",
319 Success: true,
320 Todos: []evidence.TodoItem{{Content: "Run project script", Status: "in_progress"}},
321 })
322 ledger.Record(evidence.Receipt{
323 ToolName: "bash",
324 Success: true,
325 Command: `python "script.py"`,
326 })
327 ledger.Record(evidence.ReceiptFromToolCall("complete_step", json.RawMessage(`{
328 "step":"Run project script",
329 "result":"script ran",
330 "evidence":[{"kind":"verification","summary":"script completed","command":"python script.py"}]
331 }`), false, true))
332 ctx := evidence.WithLedger(context.Background(), ledger)
333 args := json.RawMessage(`{"todos":[{"content":"Run project script","status":"completed"}]}`)
334
335 if _, err := (todoWrite{}).Execute(ctx, args); err != nil {
336 t.Fatalf("matching failed complete_step with progress receipt should recover todo completion: %v", err)
337 }
338 }
339
340 func TestTodoWriteRejectsRecoveryWhenProgressIsAfterFailedCompleteStep(t *testing.T) {
341 ledger := evidence.NewLedger()
342 ledger.Record(evidence.Receipt{
343 ToolName: "todo_write",
344 Success: true,
345 Todos: []evidence.TodoItem{{Content: "Run project script", Status: "in_progress"}},
346 })
347 ledger.Record(evidence.ReceiptFromToolCall("complete_step", json.RawMessage(`{
348 "step":"Run project script",
349 "result":"script ran",
350 "evidence":[{"kind":"verification","summary":"script completed","command":"python other.py"}]
351 }`), false, true))
352 ledger.Record(evidence.Receipt{
353 ToolName: "write_file",
354 Success: true,
355 Paths: []string{"docs/notes.md"},
356 Write: true,
357 })
358 ctx := evidence.WithLedger(context.Background(), ledger)
359 args := json.RawMessage(`{"todos":[{"content":"Run project script","status":"completed"}]}`)
360
361 _, err := (todoWrite{}).Execute(ctx, args)
362 if err == nil || !strings.Contains(err.Error(), "complete_step") {
363 t.Fatalf("progress after a failed complete_step should not authorize recovery, got %v", err)
364 }
365 }
366
367 func TestTodoWriteAcceptsPhaseChainProgress(t *testing.T) {
368 ledger := evidence.NewLedger()
369 ledger.Record(evidence.Receipt{
370 ToolName: "todo_write",
371 Success: true,
372 Todos: []evidence.TodoItem{
373 {Content: "Port the parser", Status: "pending"},
374 {Content: "move files", Status: "in_progress", Level: 1},
375 {Content: "fix imports", Status: "pending", Level: 1},
376 },
377 })
378 ctx := evidence.WithLedger(context.Background(), ledger)
379
380 out, err := (todoWrite{}).Execute(ctx, json.RawMessage(`{"todos":[
381 {"content":"Port the parser","status":"pending"},
382 {"content":"move files","status":"in_progress","level":1},
383 {"content":"fix imports","status":"pending","level":1},
384 {"content":"update docs","status":"pending","level":1}]}`))
385 if err != nil {
386 t.Fatalf("narrowing work under the current phase should be accepted: %v", err)
387 }
388 if !strings.Contains(out, "in progress") {
389 t.Fatalf("unexpected todo_write output: %q", out)
390 }
391 }
392
393 func TestTodoWriteRejectsPhaseCompletedBeforeSubSteps(t *testing.T) {
394 _, err := (todoWrite{}).Execute(context.Background(), json.RawMessage(`{"todos":[
395 {"content":"Port the parser","status":"completed"},
396 {"content":"move files","status":"in_progress","level":1}]}`))
397 if err == nil || !strings.Contains(err.Error(), "unfinished") {
398 t.Fatalf("phase completed before its sub-steps should be rejected: %v", err)
399 }
400 }
401
402 func TestTodoWriteRejectsPhaseInProgressBeforeSubSteps(t *testing.T) {
403 _, err := (todoWrite{}).Execute(context.Background(), json.RawMessage(`{"todos":[
404 {"content":"Port the parser","status":"in_progress"},
405 {"content":"move files","status":"pending","level":1}]}`))
406 if err == nil || !strings.Contains(err.Error(), "cannot be in_progress while sub-step") {
407 t.Fatalf("phase in_progress before its sub-steps finish should be rejected: %v", err)
408 }
409 }
410
411 func TestTodoWriteRejectsOrphanSubStep(t *testing.T) {
412 _, err := (todoWrite{}).Execute(context.Background(), json.RawMessage(`{"todos":[
413 {"content":"move files","status":"in_progress","level":1},
414 {"content":"Port the parser","status":"pending"}]}`))
415 if err == nil || !strings.Contains(err.Error(), "no phase above it") {
416 t.Fatalf("a level-1 sub-step with no phase should be rejected: %v", err)
417 }
418 }
419
420 func TestTodoWriteRejectsDroppingActiveSubStep(t *testing.T) {
421 ledger := evidence.NewLedger()
422 ledger.Record(evidence.Receipt{
423 ToolName: "todo_write",
424 Success: true,
425 Todos: []evidence.TodoItem{
426 {Content: "Port the parser", Status: "pending"},
427 {Content: "move files", Status: "in_progress", Level: 1},
428 {Content: "fix imports", Status: "pending", Level: 1},
429 },
430 })
431 ctx := evidence.WithLedger(context.Background(), ledger)
432
433 _, err := (todoWrite{}).Execute(ctx, json.RawMessage(`{"todos":[
434 {"content":"Port the parser","status":"pending"},
435 {"content":"rewrite everything","status":"in_progress","level":1}]}`))
436 if err == nil || !strings.Contains(err.Error(), "cannot be removed or replaced") {
437 t.Fatalf("dropping the active sub-step should be rejected: %v", err)
438 }
439 }
440
440 lines GO