返回 DeepSeek-Reasonix
completestep_test.go
根目录 / internal / tool / builtin / completestep_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/instruction"
11 "reasonix/internal/provider"
12 )
13
14 func TestTodoInventoryListsTurnTodos(t *testing.T) {
15 ledger := evidence.NewLedger()
16 ledger.Record(evidence.Receipt{
17 ToolName: "todo_write",
18 Success: true,
19 Todos: []evidence.TodoItem{{Content: "Phase 5:脚本编辑与执行代码"}, {Content: "Review notes"}},
20 })
21 got := todoInventory(ledger)
22 if !strings.Contains(got, `1) "Phase 5:脚本编辑与执行代码"`) || !strings.Contains(got, `2) "Review notes"`) {
23 t.Fatalf("inventory should list both todos, got %s", got)
24 }
25 if got := todoInventory(evidence.NewLedger()); !strings.Contains(got, "no todos") {
26 t.Fatalf("empty ledger inventory = %s", got)
27 }
28 }
29
30 func TestCompleteStepRejectsMissingEvidence(t *testing.T) {
31 _, err := completeStep{}.Execute(context.Background(),
32 json.RawMessage(`{"step":"Add the parser","result":"parser added","evidence":[]}`))
33 if err == nil {
34 t.Fatal("completion with empty evidence should be rejected")
35 }
36 if !strings.Contains(err.Error(), "evidence") {
37 t.Fatalf("error should mention evidence, got %v", err)
38 }
39 }
40
41 func TestCompleteStepRequiresStepAndResult(t *testing.T) {
42 cases := []string{
43 `{"step":"","result":"x","evidence":[{"kind":"manual","summary":"checked"}]}`,
44 `{"step":"x","result":"","evidence":[{"kind":"manual","summary":"checked"}]}`,
45 }
46 for _, c := range cases {
47 if _, err := (completeStep{}).Execute(context.Background(), json.RawMessage(c)); err == nil {
48 t.Fatalf("expected rejection for %s", c)
49 }
50 }
51 }
52
53 func TestCompleteStepRejectsBadEvidenceKind(t *testing.T) {
54 _, err := completeStep{}.Execute(context.Background(),
55 json.RawMessage(`{"step":"x","result":"y","evidence":[{"kind":"vibes","summary":"trust me"}]}`))
56 if err == nil || !strings.Contains(err.Error(), "kind") {
57 t.Fatalf("bad evidence kind should be rejected, got %v", err)
58 }
59 }
60
61 func TestCompleteStepRejectsEmptyEvidenceSummary(t *testing.T) {
62 _, err := completeStep{}.Execute(context.Background(),
63 json.RawMessage(`{"step":"x","result":"y","evidence":[{"kind":"verification","summary":""}]}`))
64 if err == nil || !strings.Contains(err.Error(), "summary") {
65 t.Fatalf("empty evidence summary should be rejected, got %v", err)
66 }
67 }
68
69 func TestCompleteStepAccepts(t *testing.T) {
70 out, err := completeStep{}.Execute(context.Background(), json.RawMessage(`{
71 "step":"Add the parser",
72 "result":"parser added and wired into the loop",
73 "evidence":[
74 {"kind":"verification","summary":"all tests pass","command":"go test ./..."},
75 {"kind":"diff","summary":"new parser.go + call site","paths":["parser.go","loop.go"]}
76 ]}`))
77 if err != nil {
78 t.Fatalf("valid completion rejected: %v", err)
79 }
80 for _, want := range []string{"Add the parser", "2 evidence", "verification", "diff"} {
81 if !strings.Contains(out, want) {
82 t.Fatalf("ack %q missing %q", out, want)
83 }
84 }
85 }
86
87 func TestCompleteStepVerifiesHostReceipts(t *testing.T) {
88 ledger := evidence.NewLedger()
89 ledger.Record(evidence.Receipt{
90 ToolName: "bash",
91 Success: true,
92 Command: "go test ./internal/...",
93 })
94 ledger.Record(evidence.Receipt{
95 ToolName: "write_file",
96 Success: true,
97 Paths: []string{"internal/evidence/evidence.go"},
98 Write: true,
99 })
100 ledger.Record(evidence.Receipt{
101 ToolName: "read_file",
102 Success: true,
103 Paths: []string{"internal/tool/builtin/completestep.go"},
104 Read: true,
105 })
106 ctx := evidence.WithLedger(context.Background(), ledger)
107
108 out, err := completeStep{}.Execute(ctx, json.RawMessage(`{
109 "step":"Verify receipts",
110 "result":"complete_step checks host receipts",
111 "evidence":[
112 {"kind":"verification","summary":"tests passed","command":"go test ./internal/..."},
113 {"kind":"diff","summary":"ledger package added","paths":["internal/evidence/evidence.go"]},
114 {"kind":"files","summary":"complete_step implementation inspected","paths":["internal/tool/builtin/completestep.go"]}
115 ]}`))
116 if err != nil {
117 t.Fatalf("host-verified evidence rejected: %v", err)
118 }
119 if !strings.Contains(out, "host-verified 3") {
120 t.Fatalf("ack should report host verification, got %q", out)
121 }
122 }
123
124 func TestCompleteStepRejectsUnverifiedHostEvidence(t *testing.T) {
125 ledger := evidence.NewLedger()
126 ledger.Record(evidence.Receipt{ToolName: "bash", Success: false, Command: "go test ./..."})
127 ledger.Record(evidence.Receipt{ToolName: "write_file", Success: true, Paths: []string{"changed.go"}, Write: true})
128 ctx := evidence.WithLedger(context.Background(), ledger)
129
130 cases := []struct {
131 name string
132 body string
133 want string
134 }{
135 {
136 name: "failed verification command",
137 body: `{"step":"x","result":"y","evidence":[{"kind":"verification","summary":"claimed tests","command":"go test ./..."}]}`,
138 want: "exited non-zero",
139 },
140 {
141 name: "missing diff writer",
142 body: `{"step":"x","result":"y","evidence":[{"kind":"diff","summary":"claimed diff","paths":["other.go"]}]}`,
143 want: "successful writer receipt",
144 },
145 {
146 name: "missing file receipt",
147 body: `{"step":"x","result":"y","evidence":[{"kind":"files","summary":"claimed file","paths":["other.go"]}]}`,
148 want: "successful read/write receipt",
149 },
150 {
151 name: "diff without path",
152 body: `{"step":"x","result":"y","evidence":[{"kind":"diff","summary":"claimed diff"}]}`,
153 want: "paths",
154 },
155 }
156
157 for _, tc := range cases {
158 t.Run(tc.name, func(t *testing.T) {
159 _, err := completeStep{}.Execute(ctx, json.RawMessage(tc.body))
160 if err == nil {
161 t.Fatal("unverified host evidence should be rejected")
162 }
163 if !strings.Contains(err.Error(), tc.want) {
164 t.Fatalf("error %q missing %q", err, tc.want)
165 }
166 })
167 }
168 }
169
170 func TestCompleteStepAllowsManualAsUnverified(t *testing.T) {
171 ctx := evidence.WithLedger(context.Background(), evidence.NewLedger())
172 out, err := completeStep{}.Execute(ctx, json.RawMessage(`{
173 "step":"Manual check",
174 "result":"operator confirmed behavior",
175 "evidence":[{"kind":"manual","summary":"checked the visible output"}]}`))
176 if err != nil {
177 t.Fatalf("manual evidence should remain allowed: %v", err)
178 }
179 if !strings.Contains(out, "manual/unverified 1") {
180 t.Fatalf("manual evidence should be marked unverified, got %q", out)
181 }
182 }
183
184 func TestCompleteStepExplainsRenewalAgainstCompletedTodoList(t *testing.T) {
185 ledger := evidence.NewLedger()
186 ledger.Record(evidence.ReceiptFromToolCall("todo_write", json.RawMessage(`{"todos":[{"content":"Implement","status":"completed"},{"content":"Final review","status":"completed"}]}`), true, true))
187 ctx := evidence.WithLedger(context.Background(), ledger)
188
189 _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
190 "step":"Review and verify again",
191 "result":"ready",
192 "evidence":[{"kind":"manual","summary":"reviewed"}]
193 }`))
194 if err == nil {
195 t.Fatal("invented renewal step should not bypass the canonical todo list")
196 }
197 for _, want := range []string{"renewal sign-off", "step_index 2", "Final review", "do not invent a new step"} {
198 if !strings.Contains(err.Error(), want) {
199 t.Fatalf("renewal error %q missing %q", err, want)
200 }
201 }
202 }
203
204 func TestCompleteStepDeliveryRejectsOpaqueEvalVerification(t *testing.T) {
205 ledger := evidence.NewLedger()
206 ledger.Record(evidence.ReceiptFromToolCall("bash", json.RawMessage(`{"command":"node -e 'console.log(1)'"}`), true, false))
207 ctx := evidence.WithDeliveryProfile(evidence.WithLedger(context.Background(), ledger))
208
209 _, err := completeStep{}.Execute(ctx, json.RawMessage(`{
210 "step":"Check JavaScript",
211 "result":"syntax valid",
212 "evidence":[{"kind":"verification","summary":"syntax valid","command":"node -e 'console.log(1)'"}]
213 }`))
214 if err == nil {
215 t.Fatal("delivery complete_step should reject a command the final gate cannot recognize")
216 }
217 for _, want := range []string{"not a recognized delivery verification", "node --check"} {
218 if !strings.Contains(err.Error(), want) {
219 t.Fatalf("error %q missing recovery hint %q", err, want)
220 }
221 }
222 }
223
224 func TestCompleteStepDeliveryAcceptsNodeSyntaxCheck(t *testing.T) {
225 ledger := evidence.NewLedger()
226 ledger.Record(evidence.ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"app.js"}`), true, false))
227 ledger.Record(evidence.ReceiptFromToolCall("bash", json.RawMessage(`{"command":"node --check app.js"}`), true, false))
228 ctx := evidence.WithDeliveryProfile(evidence.WithLedger(context.Background(), ledger))
229
230 if _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
231 "step":"Check JavaScript",
232 "result":"syntax valid",
233 "evidence":[{"kind":"verification","summary":"syntax valid","command":"node --check app.js"}]
234 }`)); err != nil {
235 t.Fatalf("delivery complete_step rejected node --check: %v", err)
236 }
237 }
238
239 func TestCompleteStepDeliveryKeepsReadOnlyEvidenceCompatibility(t *testing.T) {
240 ledger := evidence.NewLedger()
241 ledger.Record(evidence.ReceiptFromToolCall("bash", json.RawMessage(`{"command":"grep -n TODO app.js"}`), true, false))
242 ctx := evidence.WithDeliveryProfile(evidence.WithLedger(context.Background(), ledger))
243
244 if _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
245 "step":"Inspect JavaScript",
246 "result":"TODOs inspected",
247 "evidence":[{"kind":"verification","summary":"inspection completed","command":"grep -n TODO app.js"}]
248 }`)); err != nil {
249 t.Fatalf("read-only delivery evidence regressed: %v", err)
250 }
251 }
252
253 func TestCompleteStepRejectsMissingProjectCheckAfterWrite(t *testing.T) {
254 ledger := evidence.NewLedger()
255 ledger.Record(evidence.Receipt{ToolName: "write_file", Success: true, Paths: []string{"changed.go"}, Write: true})
256 ctx := instruction.WithChecks(evidence.WithLedger(context.Background(), ledger), []instruction.VerifyCheck{
257 {Command: "go test ./...", SourcePath: "AGENTS.md", Line: 3},
258 })
259
260 _, err := completeStep{}.Execute(ctx, json.RawMessage(`{
261 "step":"Edit code",
262 "result":"code changed",
263 "evidence":[{"kind":"diff","summary":"changed code","paths":["changed.go"]}]
264 }`))
265 if err == nil {
266 t.Fatal("write-backed completion should require project verify checks")
267 }
268 for _, want := range []string{"project check", "go test ./...", "AGENTS.md:3"} {
269 if !strings.Contains(err.Error(), want) {
270 t.Fatalf("error %q missing %q", err, want)
271 }
272 }
273 }
274
275 func TestCompleteStepRejectsProjectCheckBeforeWrite(t *testing.T) {
276 ledger := evidence.NewLedger()
277 ledger.Record(evidence.Receipt{ToolName: "bash", Success: true, Command: "go test ./..."})
278 ledger.Record(evidence.Receipt{ToolName: "write_file", Success: true, Paths: []string{"changed.go"}, Write: true})
279 ctx := instruction.WithChecks(evidence.WithLedger(context.Background(), ledger), []instruction.VerifyCheck{
280 {Command: "go test ./...", SourcePath: "AGENTS.md", Line: 3},
281 })
282
283 _, err := completeStep{}.Execute(ctx, json.RawMessage(`{
284 "step":"Edit code",
285 "result":"code changed",
286 "evidence":[{"kind":"diff","summary":"changed code","paths":["changed.go"]}]
287 }`))
288 if err == nil || !strings.Contains(err.Error(), "after the latest matching write") {
289 t.Fatalf("check before write should be rejected, got %v", err)
290 }
291 }
292
293 func TestCompleteStepAcceptsProjectChecksAfterWrite(t *testing.T) {
294 ledger := evidence.NewLedger()
295 ledger.Record(evidence.Receipt{ToolName: "write_file", Success: true, Paths: []string{"changed.go"}, Write: true})
296 ledger.Record(evidence.Receipt{ToolName: "bash", Success: true, Command: "go test ./..."})
297 ledger.Record(evidence.Receipt{ToolName: "bash", Success: true, Command: "git diff --check"})
298 ctx := instruction.WithChecks(evidence.WithLedger(context.Background(), ledger), []instruction.VerifyCheck{
299 {Command: "go test ./...", SourcePath: "AGENTS.md", Line: 3},
300 {Command: "git diff --check", SourcePath: "AGENTS.md", Line: 4},
301 })
302
303 out, err := completeStep{}.Execute(ctx, json.RawMessage(`{
304 "step":"Edit code",
305 "result":"code changed",
306 "evidence":[{"kind":"diff","summary":"changed code","paths":["changed.go"]}]
307 }`))
308 if err != nil {
309 t.Fatalf("project checks after write should pass: %v", err)
310 }
311 if !strings.Contains(out, "project checks 2") {
312 t.Fatalf("ack should mention project checks, got %q", out)
313 }
314 }
315
316 func TestCompleteStepProjectChecksOnlyGateWriteBackedCompletions(t *testing.T) {
317 ledger := evidence.NewLedger()
318 ledger.Record(evidence.Receipt{ToolName: "read_file", Success: true, Paths: []string{"notes.md"}, Read: true})
319 ctx := instruction.WithChecks(evidence.WithLedger(context.Background(), ledger), []instruction.VerifyCheck{
320 {Command: "go test ./...", SourcePath: "AGENTS.md", Line: 3},
321 })
322
323 cases := []string{
324 `{"step":"Manual","result":"checked","evidence":[{"kind":"manual","summary":"operator checked"}]}`,
325 `{"step":"Inspect","result":"read file","evidence":[{"kind":"files","summary":"inspected file","paths":["notes.md"]}]}`,
326 }
327 for _, body := range cases {
328 if _, err := (completeStep{}).Execute(ctx, json.RawMessage(body)); err != nil {
329 t.Fatalf("non-write-backed completion should not require project checks: %v", err)
330 }
331 }
332 }
333
334 func TestCompleteStepMatchesTodoReceipt(t *testing.T) {
335 ledger := evidence.NewLedger()
336 ledger.Record(evidence.Receipt{
337 ToolName: "todo_write",
338 Success: true,
339 Todos: []evidence.TodoItem{
340 {Content: "Add parser", Status: "in_progress", ActiveForm: "Adding parser"},
341 {Content: "Wire parser", Status: "completed"},
342 },
343 })
344 ctx := evidence.WithLedger(context.Background(), ledger)
345
346 for _, step := range []string{"Add parser", "Adding parser", "2"} {
347 t.Run(step, func(t *testing.T) {
348 out, err := completeStep{}.Execute(ctx, json.RawMessage(`{
349 "step":"`+step+`",
350 "result":"step is complete",
351 "evidence":[{"kind":"manual","summary":"checked manually"}]}`))
352 if err != nil {
353 t.Fatalf("todo-backed step rejected: %v", err)
354 }
355 if !strings.Contains(out, "todo-matched") {
356 t.Fatalf("ack should mention todo match, got %q", out)
357 }
358 })
359 }
360 }
361
362 func TestCompleteStepMatchesTodoByExplicitStepIndex(t *testing.T) {
363 ledger := evidence.NewLedger()
364 ledger.Record(evidence.Receipt{
365 ToolName: "todo_write",
366 Success: true,
367 Todos: []evidence.TodoItem{
368 {Content: "Add parser", Status: "completed"},
369 {Content: "Wire parser", Status: "in_progress"},
370 },
371 })
372 ctx := evidence.WithLedger(context.Background(), ledger)
373
374 out, err := completeStep{}.Execute(ctx, json.RawMessage(`{
375 "step_index":2,
376 "result":"parser wiring is complete",
377 "evidence":[{"kind":"manual","summary":"checked manually"}]}`))
378 if err != nil {
379 t.Fatalf("todo-backed step_index rejected: %v", err)
380 }
381 if !strings.Contains(out, "todo-matched 2") {
382 t.Fatalf("ack should mention todo index match, got %q", out)
383 }
384 if !strings.Contains(out, "Wire parser") {
385 t.Fatalf("ack should name the indexed todo, got %q", out)
386 }
387 }
388
389 func TestCompleteStepRejectsTodoMismatch(t *testing.T) {
390 ledger := evidence.NewLedger()
391 ledger.Record(evidence.Receipt{
392 ToolName: "todo_write",
393 Success: true,
394 Todos: []evidence.TodoItem{
395 {Content: "Add parser", Status: "in_progress"},
396 {Content: "Document parser", Status: "pending"},
397 },
398 })
399 ctx := evidence.WithLedger(context.Background(), ledger)
400
401 cases := []struct {
402 name string
403 step string
404 want string
405 }{
406 {name: "missing", step: "Ship parser", want: "matching todo_write item"},
407 }
408 for _, tc := range cases {
409 t.Run(tc.name, func(t *testing.T) {
410 _, err := completeStep{}.Execute(ctx, json.RawMessage(`{
411 "step":"`+tc.step+`",
412 "result":"step is complete",
413 "evidence":[{"kind":"manual","summary":"checked manually"}]}`))
414 if err == nil {
415 t.Fatal("todo-backed mismatch should be rejected")
416 }
417 if !strings.Contains(err.Error(), tc.want) {
418 t.Fatalf("error %q missing %q", err, tc.want)
419 }
420 })
421 }
422 }
423
424 func TestCompleteStepRejectsPendingTodo(t *testing.T) {
425 ledger := evidence.NewLedger()
426 ledger.Record(evidence.Receipt{
427 ToolName: "todo_write",
428 Success: true,
429 Todos: []evidence.TodoItem{
430 {Content: "Inspect environment", Status: "in_progress"},
431 {Content: "Add parser", Status: "pending"},
432 },
433 })
434 ctx := evidence.WithLedger(context.Background(), ledger)
435
436 out, err := completeStep{}.Execute(ctx, json.RawMessage(`{
437 "step":"Add parser",
438 "result":"parser added",
439 "evidence":[{"kind":"manual","summary":"checked manually"}]}`))
440 if err == nil || !strings.Contains(err.Error(), "only signs the current in_progress item") {
441 t.Fatalf("pending todo should be rejected, out=%q err=%v", out, err)
442 }
443 if !strings.Contains(err.Error(), "Inspect environment") {
444 t.Fatalf("pending rejection should name the current todo, got %v", err)
445 }
446 }
447
448 func TestCompleteStepRejectsPendingCanonicalTodoAcrossTurns(t *testing.T) {
449 ledger := evidence.NewLedger()
450 ctx := evidence.WithLedger(context.Background(), ledger)
451 ctx = evidence.WithTodoState(ctx, []evidence.TodoItem{
452 {Content: "Inspect environment", Status: "in_progress"},
453 {Content: "Add parser", Status: "pending"},
454 })
455
456 _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
457 "step":"Add parser",
458 "result":"parser added",
459 "evidence":[{"kind":"manual","summary":"checked manually"}]}`))
460 if err == nil || !strings.Contains(err.Error(), "only signs the current in_progress item") {
461 t.Fatalf("cross-turn pending todo should be rejected, got %v", err)
462 }
463 if !strings.Contains(err.Error(), "Inspect environment") {
464 t.Fatalf("cross-turn rejection should name the current todo, got %v", err)
465 }
466 }
467
468 func TestCompleteStepIgnoresFailedTodoReceipt(t *testing.T) {
469 ledger := evidence.NewLedger()
470 ledger.Record(evidence.Receipt{
471 ToolName: "todo_write",
472 Success: false,
473 Todos: []evidence.TodoItem{{Content: "Add parser", Status: "in_progress"}},
474 })
475 ctx := evidence.WithLedger(context.Background(), ledger)
476
477 if _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
478 "step":"Anything",
479 "result":"step is complete",
480 "evidence":[{"kind":"manual","summary":"checked manually"}]}`)); err != nil {
481 t.Fatalf("failed todo_write receipt should not constrain step: %v", err)
482 }
483 }
484
485 func TestCompleteStepReadOnlyForPermissionLayer(t *testing.T) {
486 if !(completeStep{}).ReadOnly() {
487 t.Fatal("complete_step stays ReadOnly so permission policy need not prompt; plan mode blocks it as an execution-only workflow")
488 }
489 }
490
491 // Replays of real complete_step rejections captured from local sessions (2026-06-02) and issue #2917.
492 func TestCompleteStepMatchesParaphrasedCommands(t *testing.T) {
493 cases := []struct {
494 name string
495 ran string
496 cited string
497 }{
498 {
499 name: "cd prefix dropped",
500 ran: "cd /repo && git merge upstream/main-v2 --ff-only",
501 cited: "git merge upstream/main-v2 --ff-only",
502 },
503 {
504 name: "flag drift inside compound",
505 ran: "rm -v scripts/test_lines.txt && ls -la scripts/test_lines.txt 2>&1 || true",
506 cited: "rm -v scripts/test_lines.txt && ls scripts/test_lines.txt 2>&1",
507 },
508 {
509 name: "quote style drift",
510 ran: `test -f test-tools.md && echo "still exists" || echo "deleted"`,
511 cited: `test -f test-tools.md && echo 'still exists' || echo 'deleted'`,
512 },
513 }
514 for _, tc := range cases {
515 t.Run(tc.name, func(t *testing.T) {
516 ledger := evidence.NewLedger()
517 ledger.Record(evidence.Receipt{ToolName: "bash", Success: true, Command: tc.ran})
518 ctx := evidence.WithLedger(context.Background(), ledger)
519
520 body, _ := json.Marshal(map[string]any{
521 "step": "x", "result": "y",
522 "evidence": []map[string]any{{"kind": "verification", "summary": "verified", "command": tc.cited}},
523 })
524 if _, err := (completeStep{}).Execute(ctx, body); err != nil {
525 t.Fatalf("paraphrased citation of a ran command rejected: %v", err)
526 }
527 })
528 }
529 }
530
531 func TestCompleteStepAcceptsSuccessfulReviewEvidence(t *testing.T) {
532 ledger := evidence.NewLedger()
533 ledger.Record(evidence.ReceiptFromToolCall("review", json.RawMessage(`{"task":"review changes"}`), true, true))
534 ctx := evidence.WithLedger(context.Background(), ledger)
535
536 if _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
537 "step":"Review code","result":"review completed",
538 "evidence":[{"kind":"review","summary":"the built-in review completed"}]}`)); err != nil {
539 t.Fatalf("successful review evidence rejected: %v", err)
540 }
541 }
542
543 func TestCompleteStepRejectsFailedReviewEvidence(t *testing.T) {
544 ledger := evidence.NewLedger()
545 ledger.Record(evidence.ReceiptFromToolCall("review", json.RawMessage(`{"task":"review changes"}`), false, true))
546 ctx := evidence.WithLedger(context.Background(), ledger)
547
548 if _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
549 "step":"Review code","result":"review completed",
550 "evidence":[{"kind":"review","summary":"the built-in review completed"}]}`)); err == nil {
551 t.Fatal("failed review task must not satisfy review evidence")
552 }
553 }
554
555 func TestCompleteStepRejectsReviewEvidenceBeforeLatestMutation(t *testing.T) {
556 ledger := evidence.NewLedger()
557 ledger.Record(evidence.ReceiptFromToolCall("review", json.RawMessage(`{"task":"review changes"}`), true, true))
558 ledger.Record(evidence.ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"changed.go"}`), true, false))
559 ctx := evidence.WithLedger(context.Background(), ledger)
560
561 _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
562 "step":"Review code","result":"review completed",
563 "evidence":[{"kind":"review","summary":"the built-in review completed"}]}`))
564 if err == nil || !strings.Contains(err.Error(), "review must be newer and cover the changed result") {
565 t.Fatalf("stale review evidence should be rejected with recovery guidance, got %v", err)
566 }
567 }
568
569 func TestCompleteStepAcceptsStructuredReviewWithBlockingFindings(t *testing.T) {
570 ledger := evidence.NewLedger()
571 ledger.Record(evidence.ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"changed.go"}`), true, false))
572 ledger.Record(evidence.Receipt{ToolName: "review_report", Success: true, Args: json.RawMessage(`{
573 "kind":"review",
574 "verdict":"block",
575 "reviewed_paths":["changed.go"],
576 "findings":[{"severity":"critical","summary":"must fix","path":"changed.go"}]
577 }`)})
578 ctx := evidence.WithLedger(context.Background(), ledger)
579
580 if _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
581 "step":"Review code","result":"review found blocking follow-up",
582 "evidence":[{"kind":"review","summary":"structured review completed with a blocking finding"}]}`)); err != nil {
583 t.Fatalf("a blocking verdict should complete the review activity while remaining enforceable by delivery gates: %v", err)
584 }
585 }
586
587 func TestCompleteStepExplainsFailedCommandReceipt(t *testing.T) {
588 ledger := evidence.NewLedger()
589 ledger.Record(evidence.Receipt{ToolName: "bash", Success: false, Command: "ls scripts/test_lines.txt 2>&1"})
590 ctx := evidence.WithLedger(context.Background(), ledger)
591
592 _, err := completeStep{}.Execute(ctx, json.RawMessage(`{
593 "step":"x","result":"y",
594 "evidence":[{"kind":"verification","summary":"ls confirms the file is gone","command":"ls scripts/test_lines.txt 2>&1"}]}`))
595 if err == nil {
596 t.Fatal("failed command citation should be rejected")
597 }
598 for _, want := range []string{"exited non-zero", "|| true"} {
599 if !strings.Contains(err.Error(), want) {
600 t.Fatalf("error should carry the recovery hint %q, got %v", want, err)
601 }
602 }
603 }
604
605 func TestCompleteStepRejectionListsRanCommands(t *testing.T) {
606 ledger := evidence.NewLedger()
607 ledger.Record(evidence.Receipt{ToolName: "bash", Success: true, Command: "wc -l scripts/test_lines.txt"})
608 ctx := evidence.WithLedger(context.Background(), ledger)
609
610 _, err := completeStep{}.Execute(ctx, json.RawMessage(`{
611 "step":"x","result":"y",
612 "evidence":[{"kind":"verification","summary":"claimed","command":"go test ./internal/nilutil/... ./internal/fileutil/..."}]}`))
613 if err == nil || !strings.Contains(err.Error(), "wc -l scripts/test_lines.txt") {
614 t.Fatalf("rejection should list the commands that actually ran, got %v", err)
615 }
616 }
617
618 func TestCompleteStepRejectionListsTouchedPaths(t *testing.T) {
619 ledger := evidence.NewLedger()
620 ledger.Record(evidence.Receipt{ToolName: "write_file", Success: true, Paths: []string{"changed.go"}, Write: true})
621 ctx := evidence.WithLedger(context.Background(), ledger)
622
623 _, err := completeStep{}.Execute(ctx, json.RawMessage(`{
624 "step":"x","result":"y",
625 "evidence":[{"kind":"diff","summary":"claimed","paths":["other.go"]}]}`))
626 if err == nil || !strings.Contains(err.Error(), "changed.go") {
627 t.Fatalf("rejection should list the files actually written, got %v", err)
628 }
629 }
630
631 func TestCompleteStepSessionFallbackUsesNormalizedMatching(t *testing.T) {
632 msgs := []provider.Message{
633 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
634 ID: "c1", Name: "bash",
635 Arguments: `{"command":"go test ./internal/tool/... -count=1 -timeout 60s 2>&1 | tail -10"}`,
636 }}},
637 {Role: provider.RoleTool, ToolCallID: "c1", Name: "bash", Content: "ok\nPASS"},
638 }
639 ctx := evidence.WithLedger(context.Background(), evidence.NewLedger())
640 ctx = evidence.WithSessionMessages(ctx, msgs)
641
642 if _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
643 "step":"x","result":"y",
644 "evidence":[{"kind":"verification","summary":"tool tests pass","command":"go test ./internal/tool/... -count=1 -timeout 60s"}]}`)); err != nil {
645 t.Fatalf("cross-turn citation of a ran command rejected: %v", err)
646 }
647 }
648
649 func TestCompleteStepSessionFallbackSkipsFailedCalls(t *testing.T) {
650 msgs := []provider.Message{
651 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
652 ID: "c1", Name: "bash", Arguments: `{"command":"go test ./broken/..."}`,
653 }}},
654 {Role: provider.RoleTool, ToolCallID: "c1", Name: "bash", Content: "error: command exited: exit status 1\nFAIL"},
655 }
656 ctx := evidence.WithLedger(context.Background(), evidence.NewLedger())
657 ctx = evidence.WithSessionMessages(ctx, msgs)
658
659 if _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
660 "step":"x","result":"y",
661 "evidence":[{"kind":"verification","summary":"tests pass","command":"go test ./broken/..."}]}`)); err == nil {
662 t.Fatal("a call whose recorded result is an error must not count as verification")
663 }
664 }
665
666 // Replay from the 2026-06-11 e2e run: a file created via bash redirection has
667 // no reader/writer receipt, but the command text names the path.
668 func TestCompleteStepFilesEvidenceAcceptsBashCreatedFile(t *testing.T) {
669 ledger := evidence.NewLedger()
670 ledger.Record(evidence.Receipt{
671 ToolName: "bash",
672 Success: true,
673 Command: `mkdir -p scripts && seq -w 1 20 | while read i; do echo "line $i"; done > scripts/test_lines.txt && cat scripts/test_lines.txt`,
674 })
675 ctx := evidence.WithLedger(context.Background(), ledger)
676
677 if _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
678 "step":"x","result":"y",
679 "evidence":[{"kind":"files","paths":["scripts/test_lines.txt"],"summary":"file created with 20 lines"}]}`)); err != nil {
680 t.Fatalf("bash-created file should count as a files receipt: %v", err)
681 }
682
683 if _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
684 "step":"x","result":"y",
685 "evidence":[{"kind":"files","paths":["scripts/never_touched.txt"],"summary":"claimed"}]}`)); err == nil {
686 t.Fatal("a path no command mentions must still be rejected")
687 }
688 }
689
690 func TestCompleteStepSessionFallbackResolvesDiffPaths(t *testing.T) {
691 msgs := []provider.Message{
692 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
693 ID: "w1", Name: "write_file", Arguments: `{"path":"internal/foo/bar.go"}`,
694 }}},
695 {Role: provider.RoleTool, ToolCallID: "w1", Name: "write_file", Content: "wrote 10 lines"},
696 }
697 ctx := evidence.WithLedger(context.Background(), evidence.NewLedger())
698 ctx = evidence.WithSessionMessages(ctx, msgs)
699
700 if _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
701 "step":"x","result":"y",
702 "evidence":[{"kind":"diff","summary":"added bar","paths":["internal/foo/bar.go"]}]}`)); err != nil {
703 t.Fatalf("cross-turn diff citation of a written file rejected: %v", err)
704 }
705 }
706
707 func TestCompleteStepSessionFallbackSkipsFailedWrite(t *testing.T) {
708 msgs := []provider.Message{
709 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
710 ID: "w1", Name: "write_file", Arguments: `{"path":"internal/foo/bar.go"}`,
711 }}},
712 {Role: provider.RoleTool, ToolCallID: "w1", Name: "write_file", Content: "error: permission denied"},
713 }
714 ctx := evidence.WithLedger(context.Background(), evidence.NewLedger())
715 ctx = evidence.WithSessionMessages(ctx, msgs)
716
717 if _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
718 "step":"x","result":"y",
719 "evidence":[{"kind":"diff","summary":"added bar","paths":["internal/foo/bar.go"]}]}`)); err == nil {
720 t.Fatal("a failed write must not satisfy cross-turn diff evidence")
721 }
722 }
723
724 func TestCompleteStepRejectsPhaseWithUnfinishedSubSteps(t *testing.T) {
725 ledger := evidence.NewLedger()
726 ledger.Record(evidence.Receipt{
727 ToolName: "todo_write",
728 Success: true,
729 Todos: []evidence.TodoItem{
730 {Content: "Port the parser", Status: "in_progress"},
731 {Content: "move files", Status: "completed", Level: 1},
732 {Content: "fix imports", Status: "in_progress", Level: 1},
733 },
734 })
735 ctx := evidence.WithLedger(context.Background(), ledger)
736
737 _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
738 "step":"Port the parser",
739 "result":"parser ported",
740 "evidence":[{"kind":"manual","summary":"checked manually"}]}`))
741 if err == nil || !strings.Contains(err.Error(), "sub-steps are unfinished") {
742 t.Fatalf("phase with unfinished sub-steps should be rejected, got %v", err)
743 }
744 if !strings.Contains(err.Error(), `sub-step 3 "fix imports"`) {
745 t.Fatalf("phase rejection should name the first unfinished sub-step, got %v", err)
746 }
747 }
748
749 func TestCompleteStepSignsPhaseAfterSubStepsComplete(t *testing.T) {
750 ledger := evidence.NewLedger()
751 ledger.Record(evidence.Receipt{
752 ToolName: "todo_write",
753 Success: true,
754 Todos: []evidence.TodoItem{
755 {Content: "Port the parser", Status: "in_progress"},
756 {Content: "move files", Status: "completed", Level: 1},
757 {Content: "fix imports", Status: "completed", Level: 1},
758 },
759 })
760 ctx := evidence.WithLedger(context.Background(), ledger)
761
762 out, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
763 "step":"Port the parser",
764 "result":"parser ported",
765 "evidence":[{"kind":"manual","summary":"checked manually"}]}`))
766 if err != nil {
767 t.Fatalf("phase with completed sub-steps should sign off: %v", err)
768 }
769 if !strings.Contains(out, "signed off") {
770 t.Fatalf("phase sign-off output = %q, want signed off", out)
771 }
772 }
773
774 func TestCompleteStepPendingHintNamesActiveSubStep(t *testing.T) {
775 ledger := evidence.NewLedger()
776 ledger.Record(evidence.Receipt{
777 ToolName: "todo_write",
778 Success: true,
779 Todos: []evidence.TodoItem{
780 {Content: "Port the parser", Status: "pending"},
781 {Content: "move files", Status: "in_progress", Level: 1},
782 {Content: "fix imports", Status: "pending", Level: 1},
783 },
784 })
785 ctx := evidence.WithLedger(context.Background(), ledger)
786
787 _, err := (completeStep{}).Execute(ctx, json.RawMessage(`{
788 "step":"fix imports",
789 "result":"imports fixed",
790 "evidence":[{"kind":"manual","summary":"checked manually"}]}`))
791 if err == nil || !strings.Contains(err.Error(), `finish todo 2 "move files" first`) {
792 t.Fatalf("pending hint should point at the active sub-step, got %v", err)
793 }
794 }
795
795 lines GO