selfware 0.6.2

Your personal AI workshop — software you own, software that lasts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
use super::*;
use serde_json::json;

#[test]
fn incomplete_action_response_catches_forward_looking_narration() {
    // Regression: the read-only terminal-answer gate must not accept a
    // lead-in like "Now let me check …:" as a final answer.
    assert!(is_incomplete_action_response(
        "Now let me check which module is actually registered/exported:"
    ));
    assert!(is_incomplete_action_response("Let me read the file first."));
    assert!(is_incomplete_action_response(
        "I will inspect the registration next:"
    ));
    // A genuine final answer is NOT flagged.
    assert!(!is_incomplete_action_response(
        "The two files are duplicates. shell_exec is the registered tool; shell.rs is unused."
    ));
    // GATE-INCOMPLETE-FP: recap/summary lead-ins are final answers, not
    // descriptions of pending work.
    assert!(!is_incomplete_action_response(
        "Let me summarize: parse_port now returns Result<u16, String> and main exits on error."
    ));
    assert!(!is_incomplete_action_response(
        "To summarize, the fix changes the return type and updates the caller."
    ));
    // Past-tense tool mentions describe COMPLETED work — not incomplete
    // (found by GLM-5.2 reviewing verification.rs: bare "file_read(" via
    // `contains` false-positived past-tense summaries).
    assert!(!is_incomplete_action_response(
        "I used file_read() to examine the module, found the off-by-one bug, and fixed it."
    ));
    assert!(!is_incomplete_action_response(
        "The shell_exec() call confirmed the tests pass; the change is complete."
    ));
    // But a forward-looking plan to call a tool IS still incomplete.
    assert!(is_incomplete_action_response(
        "Next I'll call file_read( to inspect the registration before editing."
    ));
}

#[test]
fn explicit_visual_expectation_takes_priority() {
    let args = json!({
        "action": "click",
        "expected_visual": "A confirmation dialog should be visible."
    });
    assert_eq!(
        visual_verification_expectation("computer_mouse", &args).as_deref(),
        Some("A confirmation dialog should be visible.")
    );
}

#[test]
fn computer_window_launch_has_default_expectation() {
    let args = json!({
        "action": "launch",
        "app_name": "Firefox"
    });
    let expectation = visual_verification_expectation("computer_window", &args).unwrap();
    assert!(expectation.contains("Firefox"));
    assert!(expectation.contains("visible"));
}

#[test]
fn non_window_actions_without_expectation_skip_visual_gate() {
    let args = json!({
        "action": "type",
        "text": "hello"
    });
    assert!(visual_verification_expectation("computer_keyboard", &args).is_none());
}

#[cfg(test)]
mod completion_gate_tests {
    use super::*;
    use crate::checkpoint::{TaskCheckpoint, ToolCallLog};
    use crate::config::Config;

    fn test_config() -> Config {
        let mut config = Config::default();
        config.agent.min_completion_steps = 0;
        config.agent.require_verification_before_completion = true;
        config
    }

    async fn agent_with_checkpoint(tool_calls: Vec<ToolCallLog>) -> Agent {
        let mut agent = Agent::new(test_config()).await.expect("agent should build");
        let mut checkpoint = TaskCheckpoint::new("task_1".to_string(), "test task".to_string());
        for tc in tool_calls {
            checkpoint.log_tool_call(tc);
        }
        agent.current_checkpoint = Some(checkpoint);
        agent.has_written_any_file = true;
        agent
    }

    fn shell_exec(command: &str, success: bool) -> ToolCallLog {
        ToolCallLog {
            timestamp: chrono::Utc::now(),
            tool_name: "shell_exec".to_string(),
            arguments: serde_json::json!({"command": command}).to_string(),
            result: Some(if success {
                "ok".to_string()
            } else {
                "failed".to_string()
            }),
            success,
            duration_ms: Some(100),
        }
    }

    fn checkpoint_call(tool_name: &str, arguments: Value, success: bool) -> ToolCallLog {
        ToolCallLog {
            timestamp: chrono::Utc::now(),
            tool_name: tool_name.to_string(),
            arguments: arguments.to_string(),
            result: Some(if success {
                "ok".to_string()
            } else {
                "failed".to_string()
            }),
            success,
            duration_ms: Some(10),
        }
    }

    async fn artifact_agent(task: &str, tool_calls: Vec<ToolCallLog>) -> Agent {
        artifact_agent_with_config(test_config(), task, tool_calls).await
    }

    async fn artifact_agent_with_config(
        config: Config,
        task: &str,
        tool_calls: Vec<ToolCallLog>,
    ) -> Agent {
        let mut agent = Agent::new(config).await.expect("agent should build");
        let mut checkpoint = TaskCheckpoint::new("artifact_task".to_string(), task.to_string());
        for tc in tool_calls {
            checkpoint.log_tool_call(tc);
        }
        agent.current_task_context = task.to_string();
        agent.current_checkpoint = Some(checkpoint);
        agent.has_written_any_file = true;
        agent.last_assistant_response = "Done.".to_string();
        agent
    }

    /// Run a git command in `dir` with a fixed identity; panics on failure.
    fn git(dir: &Path, args: &[&str]) {
        let status = std::process::Command::new("git")
            .args(args)
            .current_dir(dir)
            .env("GIT_AUTHOR_NAME", "gate-test")
            .env("GIT_AUTHOR_EMAIL", "gate-test@example.com")
            .env("GIT_COMMITTER_NAME", "gate-test")
            .env("GIT_COMMITTER_EMAIL", "gate-test@example.com")
            .status()
            .expect("git should be available for gate tests");
        assert!(
            status.success(),
            "git {:?} failed in {}",
            args,
            dir.display()
        );
    }

    /// Init a repo in a fresh temp dir with `files` committed as the base
    /// revision, chdir into it (serialized, auto-restored), and return the
    /// dir guard + cwd guard. The base commit is dated far in the past so
    /// the committed-work fallback never confuses it with run work.
    fn git_repo(files: &[(&str, &str)]) -> (tempfile::TempDir, crate::test_support::CwdGuard) {
        let dir = tempfile::tempdir().unwrap();
        git(dir.path(), &["init", "-q"]);
        for (path, content) in files {
            let full = dir.path().join(path);
            if let Some(parent) = full.parent() {
                std::fs::create_dir_all(parent).unwrap();
            }
            std::fs::write(full, content).unwrap();
        }
        git(dir.path(), &["add", "-A"]);
        let status = std::process::Command::new("git")
            .args(["commit", "-q", "-m", "base"])
            .current_dir(dir.path())
            .env("GIT_AUTHOR_NAME", "gate-test")
            .env("GIT_AUTHOR_EMAIL", "gate-test@example.com")
            .env("GIT_COMMITTER_NAME", "gate-test")
            .env("GIT_COMMITTER_EMAIL", "gate-test@example.com")
            .env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
            .env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
            .status()
            .expect("git should be available for gate tests");
        assert!(
            status.success(),
            "base commit failed in {}",
            dir.path().display()
        );
        let guard = crate::test_support::CwdGuard::enter(dir.path());
        (dir, guard)
    }

    /// An agent whose task requires a mutation, for mutation-gate tests.
    async fn mutation_task_agent(task: &str) -> Agent {
        let mut agent = Agent::new(test_config()).await.expect("agent should build");
        agent.current_task_context = task.to_string();
        agent.current_checkpoint = Some(TaskCheckpoint::new(
            "mutation_task".to_string(),
            task.to_string(),
        ));
        agent
    }

    #[tokio::test]
    async fn non_code_live_flow_txt_accepts_normalized_fresh_readback() {
        let agent = artifact_agent(
                "Create user-check_1+2=3.txt using file_write. Do not run shell commands or use pty_shell.",
                vec![
                    checkpoint_call(
                        "file_write",
                        json!({"path": "./user-check_1+2=3.txt", "content": "hello\n"}),
                        true,
                    ),
                    checkpoint_call(
                        "file_read",
                        json!({"path": "user-check_1+2=3.txt"}),
                        true,
                    ),
                ],
            )
            .await;

        let readback = agent
            .non_code_artifact_readback()
            .expect("the task-owned text artifact should be recognized from tool logs");
        assert!(readback.artifact_only);
        assert!(readback.missing_paths.is_empty());
        assert!(
            agent.check_completion_gate().await.is_none(),
            "a successful same-path readback should complete without build/test evidence"
        );
    }

    #[tokio::test]
    async fn non_code_edit_verified_via_shell_cat_completes() {
        // The doom-loop fix: a non-code (markdown) edit confirmed with a
        // shell `cat` must be accepted as content-verified — previously only
        // a `file_read` tool read-back was recognized, so shell verification
        // looped the run to the iteration cap.
        let agent = artifact_agent(
            "Edit only notes.md: add a line, then verify with shell.",
            vec![
                checkpoint_call(
                    "file_write",
                    json!({"path": "./notes.md", "content": "hi\n"}),
                    true,
                ),
                checkpoint_call("shell_exec", json!({"command": "cat notes.md"}), true),
            ],
        )
        .await;

        let readback = agent
            .non_code_artifact_readback()
            .expect("the task-owned markdown artifact should be recognized");
        assert!(
            readback.artifact_only,
            "markdown-only edit should be artifact_only"
        );
        assert!(
            readback.missing_paths.is_empty(),
            "a `cat <file>` shell read should count as read-back"
        );
        assert!(
            agent.check_completion_gate().await.is_none(),
            "a shell-verified non-code edit should complete without build/test evidence"
        );
    }

    #[tokio::test]
    async fn non_code_artifact_without_readback_guides_to_file_read_only() {
        let agent = artifact_agent(
            "Create notes.txt containing hello.",
            vec![checkpoint_call(
                "file_write",
                json!({"path": "notes.txt", "content": "hello\n"}),
                true,
            )],
        )
        .await;

        let message = agent
            .check_completion_gate()
            .await
            .expect("missing readback must block completion");
        assert!(message.contains("file_read"));
        assert!(message.contains("notes.txt"));
        assert!(!message.to_ascii_lowercase().contains("shell"));
        assert!(!message.contains("cargo"));
        assert!(!message.contains("pytest"));
    }

    #[tokio::test]
    async fn non_code_readback_before_latest_write_is_stale() {
        let agent = artifact_agent(
            "Update notes.txt.",
            vec![
                checkpoint_call(
                    "file_write",
                    json!({"path": "notes.txt", "content": "first\n"}),
                    true,
                ),
                checkpoint_call("file_read", json!({"path": "./notes.txt"}), true),
                checkpoint_call(
                    "file_edit",
                    json!({"path": "notes.txt", "old_string": "first", "new_string": "final"}),
                    true,
                ),
            ],
        )
        .await;

        let readback = agent
            .non_code_artifact_readback()
            .expect("the text artifact should be tracked");
        assert_eq!(readback.missing_paths, vec!["notes.txt"]);
    }

    #[tokio::test]
    async fn non_code_partial_or_failed_readback_does_not_count() {
        for read_call in [
            checkpoint_call(
                "file_read",
                json!({"path": "notes.txt", "line_range": [1, 1]}),
                true,
            ),
            checkpoint_call("file_read", json!({"path": "notes.txt"}), false),
        ] {
            let agent = artifact_agent(
                "Create notes.txt.",
                vec![
                    checkpoint_call(
                        "file_write",
                        json!({"path": "notes.txt", "content": "hello\n"}),
                        true,
                    ),
                    read_call,
                ],
            )
            .await;

            let readback = agent
                .non_code_artifact_readback()
                .expect("the text artifact should be tracked");
            assert_eq!(readback.missing_paths, vec!["notes.txt"]);
        }
    }

    #[tokio::test]
    async fn non_code_patch_target_uses_the_same_readback_policy() {
        let agent = artifact_agent(
            "Update CHANGELOG.md.",
            vec![
                checkpoint_call(
                    "patch_apply",
                    json!({
                        "diff": "--- a/CHANGELOG.md\n+++ b/CHANGELOG.md\n@@ -1 +1 @@\n-old\n+new\n"
                    }),
                    true,
                ),
                checkpoint_call("file_read", json!({"path": "./CHANGELOG.md"}), true),
            ],
        )
        .await;

        let readback = agent
            .non_code_artifact_readback()
            .expect("patch target should be recognized as a written artifact");
        assert!(readback.artifact_only);
        assert!(readback.missing_paths.is_empty());
    }

    #[tokio::test]
    async fn non_code_readback_does_not_bypass_source_task_gates() {
        let agent = artifact_agent(
            "Fix the bug in src/lib.rs and create notes.txt.",
            vec![
                checkpoint_call(
                    "file_write",
                    json!({"path": "notes.txt", "content": "hello\n"}),
                    true,
                ),
                checkpoint_call("file_read", json!({"path": "notes.txt"}), true),
            ],
        )
        .await;

        let readback = agent
            .non_code_artifact_readback()
            .expect("the named text artifact should still be recognized");
        assert!(!readback.artifact_only);
        assert!(
            agent.check_completion_gate().await.is_some(),
            "artifact evidence must not loosen completion for a source repair task"
        );
    }

    #[tokio::test]
    async fn accepts_completion_after_successful_pytest() {
        let agent = agent_with_checkpoint(vec![shell_exec("pytest tests/", true)]).await;
        assert!(
            agent.check_completion_gate().await.is_none(),
            "completion should be accepted after a successful pytest shell_exec"
        );
    }

    // P0-2 regression: the model verifying a non-Rust fix by running the
    // project's own test script directly (`python3 test_calc.py`) must be
    // credited exactly like a recognized runner — previously only the
    // hardcoded runner-prefix list counted, and the run livelocked.
    #[tokio::test]
    async fn accepts_completion_after_successful_direct_python_test_run() {
        let agent = agent_with_checkpoint(vec![shell_exec("python3 test_calc.py", true)]).await;
        assert!(
            agent.check_completion_gate().await.is_none(),
            "completion should be accepted after a successful direct python test run"
        );
    }

    #[tokio::test]
    async fn rejects_completion_when_no_verification_tool_call_succeeded() {
        let agent = agent_with_checkpoint(vec![]).await;
        let result = agent.check_completion_gate().await;
        assert!(
            result.is_some(),
            "completion should be rejected when no verification tool succeeded"
        );
        let msg = result.unwrap();
        assert!(
            msg.contains("pytest") || msg.contains("cargo_test") || msg.contains("npm test"),
            "rejection message should mention verification examples: {}",
            msg
        );
    }

    #[tokio::test]
    async fn rejects_completion_when_only_failing_pytest_exists() {
        let agent = agent_with_checkpoint(vec![shell_exec("pytest tests/", false)]).await;
        let result = agent.check_completion_gate().await;
        assert!(
            result.is_some(),
            "completion should be rejected when the only verification attempt failed"
        );
    }

    // Regression: a READ-ONLY review whose answer legitimately quotes code
    // must be allowed to complete. Before the read-only guard, the code in
    // the answer tripped `contains_unwritten_code`, the gate demanded a
    // `file_write` the task should never do, and the task livelocked to the
    // step cap (reproduced on a 10k-step read-only code review).
    #[tokio::test]
    async fn read_only_review_completes_even_when_answer_quotes_code() {
        let mut agent = Agent::new(test_config()).await.expect("agent should build");
        agent.current_task_context = "Review this module and report any bugs you find".to_string();
        agent.has_written_any_file = false;
        agent.last_assistant_response = "Review complete. One real bug in `foo`:\n\
                 ```rust\nfn foo() { let x: i32 = parse(); use_it(x); }\n```\n\
                 `parse()` can fail and the error is ignored. That is my full assessment."
            .to_string();
        assert!(
                agent.check_completion_gate().await.is_none(),
                "a read-only review that quotes code must complete, not livelock on a file_write demand"
            );
    }

    // Contrast: the same unwritten-code answer on a MUTATION task must still
    // be rejected — the read-only guard must not loosen the edit-task gate.
    #[tokio::test]
    async fn mutation_task_still_rejects_unwritten_code() {
        let mut agent = Agent::new(test_config()).await.expect("agent should build");
        agent.current_task_context =
            "Fix the bug in foo() and implement the missing error handling".to_string();
        agent.has_written_any_file = false;
        agent.last_assistant_response = "Here is the fix:\n\
                 ```rust\nfn foo() { let x = parse().unwrap_or(0); use_it(x); }\n```"
            .to_string();
        assert!(
            agent.check_completion_gate().await.is_some(),
            "a mutation task that only pastes code as text must still be rejected"
        );
    }

    // #11: the injected "requires these tools" appendix lists tool names like
    // `file_edit`; its "edit" substring must NOT flip a read-only task to
    // mutation. Classification strips the appendix first.
    #[tokio::test]
    async fn tool_requirement_appendix_does_not_flip_readonly_to_mutation() {
        let mut agent = Agent::new(test_config()).await.expect("agent should build");
        agent.current_task_context = "Summarize the auth module\n\n\
                 This task explicitly requires these tools before answering:\n\
                 - `file_edit`\n\
                 Do not answer until each required tool has been called successfully."
            .to_string();
        assert_eq!(
            agent.task_context_for_classification(),
            "Summarize the auth module",
            "the tool-requirement appendix must be stripped before classification"
        );
        assert!(
            !crate::agent::tool_dispatch::task_requires_mutation(
                agent.task_context_for_classification()
            ),
            "a read-only task must stay read-only despite a file_edit tool appendix"
        );
    }

    // P0-2a regression: the NoSourceEdit supported-language list exists for
    // SWE-bench repair tasks. When the task itself names the changed
    // artifact ("update deploy.sh"), that file IS the deliverable and the
    // gate must not livelock the run demanding a supported-language edit.
    #[tokio::test]
    async fn no_source_edit_gate_skips_when_task_names_the_deliverable() {
        let (_dir, _cwd) = git_repo(&[("deploy.sh", "#!/bin/sh\nexit 0\n")]);
        // The agent edits the tracked, task-named non-source artifact.
        std::fs::write("deploy.sh", "#!/bin/sh\necho deployed\nexit 0\n").unwrap();

        let agent = mutation_task_agent("Update deploy.sh to print deployed").await;
        assert!(
            agent.mutation_completion_gate().await.is_none(),
            "a task-named artifact deliverable must not be rejected as NoSourceEdit"
        );
    }

    #[tokio::test]
    async fn no_source_edit_gate_still_rejects_unnamed_non_source_diffs() {
        let (_dir, _cwd) = git_repo(&[("deploy.sh", "#!/bin/sh\nexit 0\n")]);
        std::fs::write("deploy.sh", "#!/bin/sh\necho deployed\nexit 0\n").unwrap();

        // Same diff, but the task does NOT name the artifact — the
        // SWE-style source-edit requirement still applies.
        let agent = mutation_task_agent("Fix the deployment automation").await;
        let message = agent
            .mutation_completion_gate()
            .await
            .expect("an unnamed non-source diff must still be rejected");
        assert!(
            message.contains("NoSourceEdit"),
            "expected NoSourceEdit, got: {}",
            message
        );
    }

    // P0-2b regression: a test-only patch is the requested deliverable when
    // the task is "write tests for X" — the exemption the workflow
    // validator already had must also apply to the TestOnlyPatch gate.
    #[tokio::test]
    async fn test_only_patch_accepted_when_task_is_writing_tests() {
        let (_dir, _cwd) = git_repo(&[("tests/test_calc.py", "def test_div():\n    pass\n")]);
        std::fs::write(
            "tests/test_calc.py",
            "def test_div():\n    assert 6 / 2 == 3\n",
        )
        .unwrap();

        let agent = mutation_task_agent("Write tests for the calc module").await;
        assert!(
            agent.mutation_completion_gate().await.is_none(),
            "a test-only patch must be accepted for a test-writing task"
        );
    }

    #[tokio::test]
    async fn test_only_patch_still_rejected_for_source_repair_task() {
        let (_dir, _cwd) = git_repo(&[("tests/test_calc.py", "def test_div():\n    pass\n")]);
        std::fs::write(
            "tests/test_calc.py",
            "def test_div():\n    assert 6 / 2 == 3\n",
        )
        .unwrap();

        let agent = mutation_task_agent("Fix the divide-by-zero bug in the calc module").await;
        let message = agent
            .mutation_completion_gate()
            .await
            .expect("a test-only patch must still be rejected for a repair task");
        assert!(
            message.contains("TestOnlyPatch"),
            "expected TestOnlyPatch, got: {}",
            message
        );
    }

    // The workflow validator shares the same exemption via
    // `task_is_test_writing_task`.
    #[tokio::test]
    async fn workflow_validator_keeps_test_writing_exemption() {
        let mut agent = mutation_task_agent("Write tests for the calc module").await;
        agent.messages.push(crate::api::types::Message {
            role: "assistant".to_string(),
            content: crate::api::types::MessageContent::Text(String::new()),
            reasoning_content: None,
            tool_calls: Some(vec![crate::api::types::ToolCall {
                id: "tc_test".to_string(),
                call_type: "function".to_string(),
                function: crate::api::types::ToolFunction {
                    name: "file_write".to_string(),
                    arguments: r#"{"path":"tests/test_calc.py","content":"x"}"#.to_string(),
                },
            }]),
            tool_call_id: None,
            name: None,
        });
        assert!(
            agent.validate_workflow_edits().is_none(),
            "a test-writing task must be allowed to edit only test files"
        );
    }

    // P0-2c regression: a task that ends in `git commit` leaves a clean
    // working tree; committed-HEAD evidence must satisfy the EmptyDiff gate
    // instead of refusing the run forever.
    #[tokio::test]
    async fn empty_diff_gate_counts_work_committed_during_the_run() {
        let (_dir, _cwd) = git_repo(&[("README.md", "base\n")]);
        let agent = mutation_task_agent("Fix the divide-by-zero bug in calc.py").await;

        // The agent fixes the source and ends the task with `git commit`.
        std::fs::write("calc.py", "def div(a, b):\n    return a / b\n").unwrap();
        git(Path::new("."), &["add", "calc.py"]);
        git(
            Path::new("."),
            &["commit", "-q", "-m", "fix divide-by-zero"],
        );

        assert!(
            agent.mutation_completion_gate().await.is_none(),
            "work committed during the run must satisfy the EmptyDiff gate"
        );
    }

    #[tokio::test]
    async fn empty_diff_gate_still_rejects_when_nothing_changed() {
        let (_dir, _cwd) = git_repo(&[("README.md", "base\n")]);
        let agent = mutation_task_agent("Fix the divide-by-zero bug in calc.py").await;

        // Clean tree, no commits during the run: the gate must still fire.
        let message = agent
            .mutation_completion_gate()
            .await
            .expect("a clean tree with no run commits must still be EmptyDiff");
        assert!(
            message.contains("EmptyDiff"),
            "expected EmptyDiff, got: {}",
            message
        );
    }

    // P1 regression: `git diff --name-only HEAD` never lists untracked
    // files, so "create hello.py" succeeded on disk but the gate refused
    // completion as EmptyDiff and churned to MAX_ITERATIONS. A newly
    // created, still-untracked deliverable must satisfy the gate.
    #[tokio::test]
    async fn untracked_file_creation_satisfies_empty_diff_gate() {
        let (_dir, _cwd) = git_repo(&[("README.md", "base\n")]);
        let agent = mutation_task_agent("Create hello.py that prints hello").await;

        // The agent creates the deliverable; it is never `git add`ed or
        // committed, so only the untracked-files union can see it.
        std::fs::write("hello.py", "print('hello')\n").unwrap();

        assert!(
            agent.mutation_completion_gate().await.is_none(),
            "a newly created untracked source file must satisfy the EmptyDiff gate"
        );
    }

    // The untracked-files union must respect .gitignore: an ignored
    // scratch file is not the agent's deliverable.
    #[tokio::test]
    async fn gitignored_untracked_file_does_not_satisfy_empty_diff_gate() {
        let (_dir, _cwd) = git_repo(&[("README.md", "base\n"), (".gitignore", "scratch.py\n")]);
        let agent = mutation_task_agent("Fix the divide-by-zero bug in calc.py").await;

        // Only an ignored untracked file exists — no real change.
        std::fs::write("scratch.py", "x = 1\n").unwrap();

        let message = agent
            .mutation_completion_gate()
            .await
            .expect("an ignored untracked file must not count as a change");
        assert!(
            message.contains("EmptyDiff"),
            "expected EmptyDiff, got: {}",
            message
        );
    }

    // P0-2 regression: a correct fix on a non-Rust project, verified by
    // directly running the project's own test script, must satisfy the
    // StaleVerification gate. Previously the run was never credited
    // (hardcoded runner-prefix list) and each passing run was instead
    // counted as a mutation that re-staled the gate.
    #[tokio::test]
    async fn non_rust_direct_test_run_satisfies_stale_verification_gate() {
        let (_dir, _cwd) = git_repo(&[("calc.py", "def div(a, b):\n    return a // b\n")]);
        let mut agent = mutation_task_agent("Fix the divide-by-zero bug in calc.py").await;

        // The model fixes the source file on disk…
        std::fs::write("calc.py", "def div(a, b):\n    return a / b\n").unwrap();
        agent.note_mutating_tool_call();
        // …then runs the project's own test command directly and it
        // passes. Apply the same accounting the dispatch loop applies,
        // in the same order.
        let args = serde_json::json!({"command": "python3 test_calc.py"});
        if crate::agent::tool_dispatch::tool_call_is_mutating("shell_exec", &args) {
            agent.note_mutating_tool_call();
        }
        agent.note_verification_outcome("shell_exec", &args.to_string(), true, "1 passed");

        assert!(
            agent.mutation_completion_gate().await.is_none(),
            "a direct run of the project's own passing test must satisfy the gate"
        );
    }

    // P1-6 regression: a trivial artifact task that is complete AND
    // verified (write + read-back) must stop before min_completion_steps
    // instead of being refused for "not enough steps".
    #[tokio::test]
    async fn verified_trivial_artifact_task_completes_before_min_steps() {
        let mut config = test_config();
        config.agent.min_completion_steps = 3;
        let agent = artifact_agent_with_config(
            config,
            "Create notes.txt containing hello.",
            vec![
                checkpoint_call(
                    "file_write",
                    json!({"path": "notes.txt", "content": "hello\n"}),
                    true,
                ),
                checkpoint_call("file_read", json!({"path": "notes.txt"}), true),
            ],
        )
        .await;
        assert_eq!(
            agent.loop_control.current_step(),
            0,
            "the test agent must be below min_completion_steps"
        );
        assert!(
            agent.check_completion_gate().await.is_none(),
            "a verified-complete trivial task must not be taxed up to min_completion_steps"
        );
    }

    #[tokio::test]
    async fn unverified_artifact_task_is_still_blocked_with_min_steps() {
        let mut config = test_config();
        config.agent.min_completion_steps = 3;
        let agent = artifact_agent_with_config(
            config,
            "Create notes.txt containing hello.",
            vec![checkpoint_call(
                "file_write",
                json!({"path": "notes.txt", "content": "hello\n"}),
                true,
            )],
        )
        .await;
        // The read-back guidance now fires before the min-steps nudge.
        let message = agent
            .check_completion_gate()
            .await
            .expect("an unverified artifact must not complete");
        assert!(
            message.contains("file_read"),
            "expected read-back guidance, got: {}",
            message
        );
    }
}