ai-dispatch 11.3.0

Multi-AI CLI team orchestrator
// E2E coverage for Codex successful exits that omit the final deliverable.
// Verifies delivery failure persistence and one-shot same-session recovery.
// Deps: compiled aid binary, a PATH-injected fake Codex CLI, and SQLite.

use rusqlite::Connection;
use std::path::Path;
use tempfile::TempDir;

mod common;
use common::aid_cmd_in;

#[test]
fn fresh_codex_0147_accepts_native_permissions() {
    assert_fresh_codex_succeeds("t-current-codex-approval", false);
}

#[test]
fn fresh_codex_0146_accepts_native_permissions() {
    assert_fresh_codex_succeeds("t-legacy-codex-approval", true);
}

#[test]
fn exact_short_answer_is_a_successful_delivery() {
    let aid_home = TempDir::new().unwrap();
    let bin_dir = TempDir::new().unwrap();
    write_fake_codex(bin_dir.path());

    let output = aid_cmd_in(aid_home.path())
        .env("PATH", test_path(bin_dir.path()))
        .env("FAKE_CODEX_SHORT_FINAL", "1")
        .args([
            "run",
            "codex",
            "Reply with the single word: ok",
            "--id",
            "t-short-final",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    let conn = Connection::open(aid_home.path().join("aid.db")).unwrap();
    assert_eq!(task_facts(&conn, "t-short-final").0, "done");
}

#[test]
fn missing_explicit_result_file_fails_on_the_artifact_contract() {
    assert_missing_explicit_result_file_fails(false);
}

#[test]
fn missing_explicit_result_file_fails_in_background_on_the_artifact_contract() {
    assert_missing_explicit_result_file_fails(true);
}

fn assert_missing_explicit_result_file_fails(background: bool) {
    let aid_home = TempDir::new().unwrap();
    let bin_dir = TempDir::new().unwrap();
    write_fake_codex(bin_dir.path());
    let mut command = aid_cmd_in(aid_home.path());
    command
        .env("PATH", test_path(bin_dir.path()))
        .env("FAKE_CODEX_SHORT_FINAL", "1")
        .args([
            "run",
            "codex",
            "Reply with the single word: ok",
            "--result-file",
            "required-result.md",
            "--id",
            "t-missing-required-result",
        ]);
    if background {
        command.arg("--bg");
    }
    let output = command.output().unwrap();

    assert_eq!(
        output.status.success(),
        background,
        "stdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    if background {
        let wait = aid_cmd_in(aid_home.path())
            .args(["wait", "--timeout", "5", "t-missing-required-result"])
            .output()
            .unwrap();
        assert_eq!(
            wait.status.code(),
            Some(1),
            "background task unexpectedly succeeded"
        );
    }
    let conn = Connection::open(aid_home.path().join("aid.db")).unwrap();
    let facts = task_facts(&conn, "t-missing-required-result");
    assert_eq!(facts.0, "failed");
    assert_eq!(facts.1.as_deref(), Some("missing_final_delivery"));
}

fn assert_fresh_codex_succeeds(task_id: &str, legacy: bool) {
    let aid_home = TempDir::new().unwrap();
    let bin_dir = TempDir::new().unwrap();
    write_fake_codex(bin_dir.path());

    let mut command = aid_cmd_in(aid_home.path());
    command
        .env("PATH", test_path(bin_dir.path()))
        .env("FAKE_CODEX_FINAL_ON_FRESH", "1");
    if legacy {
        command.env("FAKE_CODEX_LEGACY", "1");
    }
    let output = command
        .args([
            "run",
            "codex",
            "Implement and report the requested change",
            "--id",
            task_id,
        ])
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "stdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let conn = Connection::open(aid_home.path().join("aid.db")).unwrap();
    assert_eq!(task_facts(&conn, task_id).0, "done");
}

#[test]
fn missing_final_delivery_fails_parent_and_resumes_once() {
    let aid_home = TempDir::new().unwrap();
    let bin_dir = TempDir::new().unwrap();
    write_fake_codex(bin_dir.path());
    let path = format!(
        "{}:{}",
        bin_dir.path().display(),
        std::env::var("PATH").unwrap_or_default()
    );

    let home = home_with_seeded_rollout();
    let workspace = TempDir::new().unwrap();
    let output = aid_cmd_in(aid_home.path())
        .current_dir(workspace.path())
        .env("PATH", path)
        .env("HOME", home.path())
        .args([
            "run",
            "codex",
            "Investigate and report the result",
            "--read-only",
            "--id",
            "t-delivery-parent",
        ])
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "stdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let conn = Connection::open(aid_home.path().join("aid.db")).unwrap();
    let parent = task_facts(&conn, "t-delivery-parent");
    assert_eq!(parent.0, "failed");
    assert_eq!(parent.1.as_deref(), Some("missing_final_delivery"));
    let child = child_facts(&conn, "t-delivery-parent");
    assert_eq!(child.0, "done");
    assert_eq!(child.1, 1);
}

#[test]
fn repeated_missing_delivery_does_not_start_third_attempt() {
    let aid_home = TempDir::new().unwrap();
    let bin_dir = TempDir::new().unwrap();
    write_fake_codex(bin_dir.path());

    let home = home_with_seeded_rollout();
    let workspace = TempDir::new().unwrap();
    let output = aid_cmd_in(aid_home.path())
        .current_dir(workspace.path())
        .env("PATH", test_path(bin_dir.path()))
        .env("HOME", home.path())
        .env("FAKE_CODEX_HOLLOW_RESUME", "1")
        .args([
            "run",
            "codex",
            "Investigate and report the result",
            "--read-only",
            "--id",
            "t-delivery-repeat",
        ])
        .output()
        .unwrap();
    assert!(
        !output.status.success(),
        "repeated hollow delivery must fail"
    );

    let conn = Connection::open(aid_home.path().join("aid.db")).unwrap();
    let facts: (i64, i64) = conn
        .query_row(
            "SELECT COUNT(*), SUM(status = 'failed') FROM tasks",
            [],
            |row| Ok((row.get(0)?, row.get(1)?)),
        )
        .unwrap();
    assert_eq!(facts, (2, 2));
}

/// The resume preflight looks for the session's rollout under the operator's
/// real `$HOME`, so a test that leaves HOME alone reads whatever that machine
/// happens to have and falls back to a fresh run instead of resuming. Give the
/// test its own HOME and seed the rollout the fake CLI's thread_id implies.
const FIXTURE_SESSION_ID: &str = "019e3e49-6b83-7563-a3d8-b51a3a716dd1";

fn home_with_seeded_rollout() -> TempDir {
    let home = TempDir::new().unwrap();
    let sessions = home.path().join(".codex/sessions/2026/08/09");
    std::fs::create_dir_all(&sessions).unwrap();
    std::fs::write(
        sessions.join(format!(
            "rollout-2026-08-09T00-00-00-{FIXTURE_SESSION_ID}.jsonl"
        )),
        "{\"type\":\"turn_context\",\"model\":\"gpt-5.6-sol\"}\n",
    )
    .unwrap();
    home
}

fn task_facts(conn: &Connection, task_id: &str) -> (String, Option<String>) {
    conn.query_row(
        "SELECT status, delivery_assessment FROM tasks WHERE id = ?1",
        [task_id],
        |row| Ok((row.get(0)?, row.get(1)?)),
    )
    .unwrap()
}

fn child_facts(conn: &Connection, parent_id: &str) -> (String, i64) {
    conn.query_row(
        "SELECT status, COUNT(*) OVER () FROM tasks WHERE parent_task_id = ?1",
        [parent_id],
        |row| Ok((row.get(0)?, row.get(1)?)),
    )
    .unwrap()
}

fn write_fake_codex(dir: &Path) {
    let path = dir.join("codex");
    common::write_executable(&path, include_str!("fixtures/codex-delivery-cli.sh"));
}

fn test_path(bin_dir: &Path) -> String {
    format!(
        "{}:{}",
        bin_dir.display(),
        std::env::var("PATH").unwrap_or_default()
    )
}