| 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 |