ai-dispatch 8.99.9

Multi-AI CLI team orchestrator
// Tests for `cmd::board` anti-poll behavior and board output helpers.
// Covers limit handling, marker transitions, and rendered output.
// Deps: super module internals, tempfile, chrono, crate::store, crate::paths.

use super::{AntiPollStatus, TruncationNotice, anti_poll_status, apply_limit, board_json_row, long_running_warning, truncation_notice_message, write_board_marker, write_board_output};
use chrono::{Duration, Local};
use crate::paths::AidHomeGuard;
use crate::store::Store;
use crate::types::{AgentKind, Task, TaskId, TaskStatus, VerifyStatus};

#[test]
fn long_running_warning_counts_running_tasks_older_than_one_hour() {
    let now = Local::now();
    let tasks = vec![
        make_task("t-1001", TaskStatus::Running, now - Duration::hours(1)),
        make_task("t-1002", TaskStatus::Running, now - Duration::minutes(59)),
        make_task("t-1003", TaskStatus::Done, now - Duration::hours(3)),
    ];
    let warning = long_running_warning(&tasks, now).unwrap();
    assert!(warning.contains("1 task(s) running >1h"));
}

#[test]
fn board_with_limit_truncates_output() {
    let now = Local::now();
    let mut tasks = vec![
        make_task("t-1001", TaskStatus::Done, now),
        make_task("t-1002", TaskStatus::Done, now),
        make_task("t-1003", TaskStatus::Done, now),
    ];
    let truncation = apply_limit(&mut tasks, Some(2), false, false, false, None);
    assert_eq!(tasks.len(), 2);
    assert_eq!(truncation, Some(TruncationNotice { shown: 2, total: 3 }));
    assert_eq!(
        truncation_notice_message(truncation.unwrap()),
        "[aid] Showing 2 of 3 tasks. Use --limit N or --today/--running for more."
    );
}

#[test]
fn board_json_row_includes_pending_reason() {
    let mut task = make_task("t-1004", TaskStatus::Failed, Local::now());
    task.pending_reason = Some("worker_capacity".to_string());
    let row = board_json_row(&task);
    assert_eq!(row["pending_reason"], "worker_capacity");
}

#[test]
fn board_json_row_includes_delivery_assessment() {
    let mut task = make_task("t-1005", TaskStatus::Done, Local::now());
    task.delivery_assessment = Some(crate::types::DeliveryAssessment::HollowOutput);
    let row = board_json_row(&task);
    assert_eq!(row["delivery_assessment"], "hollow_output");
}

#[test]
fn format_group_header_includes_custom_name() {
    let temp = tempfile::tempdir().unwrap();
    let _guard = AidHomeGuard::set(temp.path());
    let store = Store::open_memory().unwrap();
    let workgroup = store.create_workgroup("My Batch", "", Some("seed"), Some("wg-batch")).unwrap();
    assert_eq!(super::format_group_header(&workgroup), "Workgroup: wg-batch (My Batch)\n\n");
}

#[test]
fn board_output_is_not_written_when_anti_poll_blocks() {
    let temp = tempfile::tempdir().unwrap();
    let _guard = AidHomeGuard::set(temp.path());
    let marker = crate::paths::aid_dir().join("board-last.txt");
    let store = Store::open_memory().unwrap();
    let tasks = vec![make_task("t-1001", TaskStatus::Done, Local::now())];
    let fingerprint = format!("t-1001:{}", TaskStatus::Done.label());
    write_board_marker(&marker, &fingerprint, 100, 0, 0, 0);

    let anti_poll = anti_poll_status(&marker, &fingerprint, 111, false).0;
    let mut output = Vec::new();

    if let AntiPollStatus::Allowed(_) = anti_poll {
        write_board_output(&mut output, &store, &tasks, None, None, false).unwrap();
    }

    assert_eq!(anti_poll, AntiPollStatus::Repeat(1));
    assert!(output.is_empty());
}

#[test]
fn test_anti_poll_cooldown_blocks_rapid_calls() {
    let temp = tempfile::tempdir().unwrap();
    let _guard = AidHomeGuard::set(temp.path());
    let marker = crate::paths::aid_dir().join("board-last.txt");
    std::fs::write(&marker, "100\nfp\n0").unwrap();
    assert_eq!(anti_poll_status(&marker, "changed", 103, false).0, AntiPollStatus::Cooldown(3))
}

#[test]
fn test_anti_poll_force_bypasses_cooldown() {
    let temp = tempfile::tempdir().unwrap();
    let _guard = AidHomeGuard::set(temp.path());
    let marker = crate::paths::aid_dir().join("board-last.txt");
    std::fs::write(&marker, "100\nfp\n0").unwrap();
    assert_eq!(anti_poll_status(&marker, "changed", 103, true).0, AntiPollStatus::ForceCooldown(3))
}

#[test]
fn test_anti_poll_allows_first_empty_board_call() {
    let temp = tempfile::tempdir().unwrap();
    let _guard = AidHomeGuard::set(temp.path());
    let marker = crate::paths::aid_dir().join("board-last.txt");

    assert_eq!(anti_poll_status(&marker, "", 111, false).0, AntiPollStatus::Allowed(0));
}

#[test]
fn test_anti_poll_repeat_blocks_on_second_same_fingerprint_call() {
    let temp = tempfile::tempdir().unwrap();
    let _guard = AidHomeGuard::set(temp.path());
    let marker = crate::paths::aid_dir().join("board-last.txt");
    write_board_marker(&marker, "fp", 100, 0, 0, 0);

    assert_eq!(anti_poll_status(&marker, "fp", 111, false).0, AntiPollStatus::Repeat(1));
}

#[test]
fn test_force_cooldown_blocks_within_30s() {
    let temp = tempfile::tempdir().unwrap();
    let _guard = AidHomeGuard::set(temp.path());
    let marker = crate::paths::aid_dir().join("board-last.txt");
    write_board_marker(&marker, "fp", 100, 0, 0, 0);
    assert_eq!(anti_poll_status(&marker, "changed", 120, true).0, AntiPollStatus::ForceCooldown(20));
}

#[test]
fn test_force_cooldown_allows_after_30s() {
    let temp = tempfile::tempdir().unwrap();
    let _guard = AidHomeGuard::set(temp.path());
    let marker = crate::paths::aid_dir().join("board-last.txt");
    write_board_marker(&marker, "fp", 100, 0, 0, 0);
    assert_eq!(anti_poll_status(&marker, "changed", 130, true).0, AntiPollStatus::Allowed(0));
}

#[test]
fn test_force_escalation_blocks_after_3_calls() {
    let temp = tempfile::tempdir().unwrap();
    let _guard = AidHomeGuard::set(temp.path());
    let marker = crate::paths::aid_dir().join("board-last.txt");

    write_board_marker(&marker, "fp", 100, 0, 1, 100);
    let (_, force_state) = anti_poll_status(&marker, "changed", 131, true);
    write_board_marker(&marker, "changed", 131, 0, force_state.count, force_state.window_start);

    let (_, force_state) = anti_poll_status(&marker, "changed", 162, true);
    write_board_marker(&marker, "changed", 162, 0, force_state.count, force_state.window_start);

    assert_eq!(anti_poll_status(&marker, "changed", 193, true).0, AntiPollStatus::ForceBlocked);
}

#[test]
fn test_force_escalation_resets_after_window() {
    let temp = tempfile::tempdir().unwrap();
    let _guard = AidHomeGuard::set(temp.path());
    let marker = crate::paths::aid_dir().join("board-last.txt");
    write_board_marker(&marker, "fp", 100, 0, 3, 10);

    let (status, force_state) = anti_poll_status(&marker, "changed", 231, true);

    assert_eq!(status, AntiPollStatus::Allowed(0));
    assert_eq!(force_state.count, 1);
    assert_eq!(force_state.window_start, 231);
}

fn make_task(task_id: &str, status: TaskStatus, created_at: chrono::DateTime<Local>) -> Task {
    Task {
        id: TaskId(task_id.to_string()),
        agent: AgentKind::Codex,
        custom_agent_name: None,
        prompt: "prompt".to_string(),
        resolved_prompt: None,
        category: None,
        status,
        parent_task_id: None,
        workgroup_id: None,
        caller_kind: None,
        caller_session_id: None,
        agent_session_id: None,
        repo_path: None,
        worktree_path: None,
        worktree_branch: None,
        start_sha: None,
        log_path: None,
        output_path: None,
        tokens: None,
        prompt_tokens: None,
        duration_ms: None,
        model: None,
        cost_usd: None,
        exit_code: None,
        created_at,
        completed_at: None,
        verify: None,
        verify_status: VerifyStatus::Skipped,
        pending_reason: None,
        read_only: false,
        budget: false,
        audit_verdict: None,
        audit_report_path: None,
        delivery_assessment: None,
    }
}