ai-dispatch 8.98.0

Multi-AI CLI team orchestrator
// Tests for `aid show` output and diff helpers.
// Exports: none; validates the re-exported show_output module API.
// Deps: show_output hub, Store, Task, tempfile.
use super::*;
use crate::store::Store;
use crate::types::{AgentKind, Task, TaskId, TaskStatus, VerifyStatus};
use chrono::Local;
use serde_json::json;
use std::path::Path;
use std::sync::Arc;
use tempfile::NamedTempFile;

fn test_task(id: &str) -> Task {
    Task {
        id: TaskId(id.to_string()),
        agent: AgentKind::Codex,
        custom_agent_name: None,
        prompt: "prompt".to_string(),
        resolved_prompt: None,
        category: None,
        status: TaskStatus::Done,
        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: Local::now(),
        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,
    }
}

fn numbered_assistant_log(count: usize, body_len: usize) -> String {
    (0..count)
        .map(|index| {
            serde_json::to_string(&json!({
                "type": "message",
                "role": "assistant",
                "content": format!("message-{index:02}-{}", "x".repeat(body_len)),
            }))
        })
        .collect::<Result<Vec<_>, _>>()
        .unwrap()
        .join("\n")
}

fn write_log(path: &Path, content: String) {
    std::fs::write(path, content).unwrap();
}

#[test]
fn reads_task_output_file() {
    let file = NamedTempFile::new().unwrap();
    std::fs::write(file.path(), "hello\n").unwrap();
    let mut task = test_task("t-output");
    task.agent = AgentKind::Gemini;
    task.output_path = Some(file.path().display().to_string());
    assert_eq!(read_task_output(&task).unwrap(), "hello\n");
}

#[test]
fn tail_lines_keeps_only_requested_suffix() {
    assert_eq!(show_output_messages::tail_lines("a\nb\nc\nd", 2), "c\nd");
}

#[test]
fn parse_diff_stat_standard_line() {
    let entries = parse_diff_stat(" src/foo.rs | 8 +++++---\n");
    assert_eq!(entries.len(), 1);
    let entry = &entries[0];
    assert_eq!(entry["file"], json!("src/foo.rs"));
    assert_eq!(entry["insertions"], json!(5));
    assert_eq!(entry["deletions"], json!(3));
}

#[test]
fn parse_diff_stat_skips_binary_entries() {
    assert!(parse_diff_stat(" src/bin.dat | Bin 0 -> 123 bytes\n").is_empty());
}

#[test]
fn parse_diff_stat_empty_text() {
    assert!(parse_diff_stat("").is_empty());
}

#[test]
fn diff_text_falls_back_to_default_log_output() {
    let temp = tempfile::tempdir().unwrap();
    let _aid_home = crate::paths::AidHomeGuard::set(temp.path());
    std::fs::create_dir_all(crate::paths::logs_dir()).unwrap();
    std::fs::write(crate::paths::log_path("t-log-fallback"), "log output\n").unwrap();

    let store = Arc::new(Store::open_memory().unwrap());
    let task = test_task("t-log-fallback");
    store.insert_task(&task).unwrap();

    let text = diff_text(&store, "t-log-fallback").unwrap();

    assert!(text.contains("\n--- Output ---\nlog output\n"));
    assert!(!text.contains("no worktree diff or output file available"));
}

#[test]
fn extract_messages_from_log_collects_supported_formats() {
    let file = NamedTempFile::new().unwrap();
    let content = [
        json!({
            "type": "item.completed",
            "item": { "type": "agent_message", "text": "codex message" }
        }),
        json!({
            "type": "message",
            "role": "assistant",
            "content": "stream ",
            "delta": true
        }),
        json!({
            "type": "message",
            "role": "assistant",
            "content": "delta",
            "delta": true
        }),
        json!({
            "type": "text",
            "part": { "text": "opencode text part" }
        }),
        json!({
            "type": "text",
            "content": "gemini text event"
        }),
    ]
    .iter()
    .map(serde_json::to_string)
    .collect::<Result<Vec<_>, _>>()
    .unwrap()
    .join("\n");
    std::fs::write(file.path(), content).unwrap();

    let output = extract_messages_from_log(file.path(), false);

    assert_eq!(
        output,
        Some(
            "codex message\n---\nstream delta\n---\nopencode text part\n---\ngemini text event"
                .to_string()
        )
    );
}

#[test]
fn extract_messages_strips_ansi_before_parsing() {
    let file = NamedTempFile::new().unwrap();
    let esc = String::from(char::from(0x1b));
    let json1 = r#"{"type":"text","part":{"text":"hello from opencode"}}"#;
    let json2 = r#"{"type":"text","content":"clean line"}"#;
    let content = format!("{esc}[0m{esc}[32m{json1}{esc}[0m\n{json2}\n");
    std::fs::write(file.path(), content).unwrap();

    let output = extract_messages_from_log(file.path(), false);

    assert!(output.is_some());
    let output = output.unwrap();
    assert!(output.contains("hello from opencode"), "got: {output}");
    assert!(output.contains("clean line"), "got: {output}");
}

#[test]
fn extract_messages_collects_opencode_tool_use_errors() {
    let file = NamedTempFile::new().unwrap();
    let content = [
        json!({"type":"text","part":{"text":"I'll explore the workspace"}}),
        json!({"type":"tool_use","part":{"tool":"glob","state":{"status":"error","input":{"path":"/tmp/test"},"error":"Permission denied"}}}),
        json!({"type":"text","part":{"text":"Let me try another approach"}}),
    ]
    .iter()
    .map(serde_json::to_string)
    .collect::<Result<Vec<_>, _>>()
    .unwrap()
    .join("\n");
    std::fs::write(file.path(), content).unwrap();

    let output = extract_messages_from_log(file.path(), true).unwrap();

    assert!(output.contains("I'll explore the workspace"), "got: {output}");
    assert!(output.contains("[glob] Error: Permission denied"), "got: {output}");
    assert!(output.contains("Let me try another approach"), "got: {output}");
}

#[test]
fn extract_messages_from_log_returns_none_without_supported_messages() {
    let file = NamedTempFile::new().unwrap();
    std::fs::write(file.path(), "{\"type\":\"event\"}\nnot-json\n").unwrap();

    assert_eq!(extract_messages_from_log(file.path(), false), None);
}

#[test]
fn extract_messages_accumulates_cursor_assistant_deltas() {
    let log = "{\"type\":\"system\",\"subtype\":\"init\",\"model\":\"composer-2\"}\n{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Hello \"}]}}\n{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"world!\"}]}}\n{\"type\":\"tool_call\",\"subtype\":\"started\",\"tool_call\":{\"readToolCall\":{\"args\":{\"filePath\":\"src/main.rs\"}}}}\n{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Done.\"}]}}";
    let file = NamedTempFile::new().unwrap();
    std::fs::write(file.path(), log).unwrap();
    let output = extract_messages_from_log(file.path(), true).unwrap();
    assert!(output.contains("Hello world!"), "Expected merged deltas, got: {output}");
    assert!(output.contains("Done."), "Expected separate message after tool_call");
}

#[test]
fn extract_messages_from_log_caps_message_count_and_size() {
    let file = NamedTempFile::new().unwrap();
    write_log(file.path(), numbered_assistant_log(22, 500));

    let output = extract_messages_from_log(file.path(), false).unwrap();
    let parts = output.split("\n---\n").collect::<Vec<_>>();

    assert_eq!(output.matches("\n---\n").count(), 10);
    assert_eq!(parts.len(), 11);
    assert!(parts[3].starts_with("[... 12 messages omitted ...]"));
    assert!(parts[0].starts_with("message-00-"));
    assert!(parts[10].starts_with("message-21-"));
    assert!(parts.iter().all(|part| part.len() <= 1_000));
    assert!(output.len() <= 8_000);
}

#[test]
fn extract_messages_full_skips_truncation() {
    let file = NamedTempFile::new().unwrap();
    write_log(file.path(), numbered_assistant_log(22, 500));

    let output = extract_messages_from_log(file.path(), true).unwrap();
    let parts: Vec<&str> = output.split("\n---\n").collect();

    assert_eq!(parts.len(), 22);
    assert!(parts[0].starts_with("message-00-"));
    assert!(parts[21].starts_with("message-21-"));
    assert!(!output.contains("[... "));
    assert!(parts.iter().all(|part| part.len() > 500));
}

#[test]
fn output_text_for_task_prefers_extracted_messages_to_raw_log() {
    let temp = tempfile::tempdir().unwrap();
    let _aid_home = crate::paths::AidHomeGuard::set(temp.path());
    std::fs::create_dir_all(crate::paths::logs_dir()).unwrap();
    let log_path = crate::paths::log_path("t-output-messages");
    let log_content = [
        json!({
            "type": "message",
            "role": "assistant",
            "content": "human-readable output"
        }),
        json!({
            "type": "text",
            "part": { "text": "second chunk" }
        }),
    ]
    .iter()
    .map(serde_json::to_string)
    .collect::<Result<Vec<_>, _>>()
    .unwrap()
    .join("\n");
    std::fs::write(&log_path, log_content).unwrap();

    let store = Store::open_memory().unwrap();
    let task = test_task("t-output-messages");
    store.insert_task(&task).unwrap();

    let output = output_text_for_task(&store, "t-output-messages", false).unwrap();

    assert_eq!(output, "human-readable output\n---\nsecond chunk");
}

#[test]
fn truncate_conclusion_at_2000_chars() {
    let short = "a".repeat(200);
    let short_file = NamedTempFile::new().unwrap();
    std::fs::write(short_file.path(), &short).unwrap();
    let mut short_task = test_task("t-summary-short");
    short_task.output_path = Some(short_file.path().display().to_string());
    assert_eq!(crate::cmd::summary::generate_summary(&short_task).conclusion, short);

    let long_file = NamedTempFile::new().unwrap();
    std::fs::write(long_file.path(), "b".repeat(2_500)).unwrap();
    let mut long_task = test_task("t-summary-long");
    long_task.output_path = Some(long_file.path().display().to_string());
    let conclusion = crate::cmd::summary::generate_summary(&long_task).conclusion;

    assert_eq!(conclusion.len(), 2_000);
    assert_eq!(conclusion, format!("{}...", "b".repeat(1_997)));
}

#[test]
fn extract_messages_research_relaxed_limits() {
    let file = NamedTempFile::new().unwrap();
    write_log(file.path(), numbered_assistant_log(15, 1_200));

    let output = show_output_messages::extract_messages_research(file.path()).unwrap();
    let parts = output.split("\n---\n").collect::<Vec<_>>();

    assert_eq!(parts.len(), 15);
    assert!(parts.iter().all(|part| part.len() > 1_000));
    assert!(output.contains("message-00-"));
    assert!(output.contains("message-14-"));
    assert!(!output.contains("[... "));
}

#[test]
fn output_text_uses_research_mode_for_no_worktree() {
    let temp = tempfile::tempdir().unwrap();
    let _aid_home = crate::paths::AidHomeGuard::set(temp.path());
    std::fs::create_dir_all(crate::paths::logs_dir()).unwrap();
    let log_path = crate::paths::log_path("t-research-output");
    write_log(&log_path, numbered_assistant_log(12, 1_200));

    let store = Arc::new(Store::open_memory().unwrap());
    let task = test_task("t-research-output");
    store.insert_task(&task).unwrap();

    let output = output_text(&store, "t-research-output").unwrap();
    let parts = output.split("\n---\n").collect::<Vec<_>>();

    assert_eq!(parts.len(), 12);
    assert!(parts.iter().all(|part| part.len() > 1_000));
    assert!(!output.contains("[... "));
}