ai-dispatch 10.40.0

Multi-AI CLI team orchestrator
// Cursor adapter tests for stream parsing, command construction, and rate-limit state.
// Exercises tool-call metadata variants and public Agent behavior.

use super::CursorAgent;
use crate::agent::{Agent, RunOpts};
use crate::types::{AgentKind, EventKind, TaskId};
use crate::{paths, rate_limit};
use std::fs;
use std::sync::{Mutex, OnceLock};

#[test]
fn parses_result_event_with_usage() {
    let event = parse(r#"{"type":"result","usage":{"inputTokens":3,"outputTokens":5,"cacheReadTokens":12260}}"#);
    assert_eq!(event.event_kind, EventKind::Completion);
    assert_eq!(event.detail, "tokens: 3 in + 5 out = 12268 (12260 cached)");
    assert_eq!(metadata_i64(&event, "tokens"), Some(12268));
    assert_eq!(metadata_i64(&event, "input_tokens"), Some(3));
    assert_eq!(metadata_i64(&event, "output_tokens"), Some(5));
}

#[test]
fn extracts_model_from_system_event() {
    let event = parse(r#"{"type":"system","subtype":"init","model":"composer-1.5"}"#);
    assert_eq!(event.event_kind, EventKind::Reasoning);
    assert_eq!(event.detail, "init: composer-1.5");
    assert_eq!(metadata_str(&event, "model"), Some("composer-1.5"));
}

#[test]
fn parses_assistant_message() {
    let event = parse(r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Hello!"}]}}"#);
    assert_eq!(event.event_kind, EventKind::Reasoning);
    assert_eq!(event.detail, "Hello!");
    assert!(event.metadata.is_none());
}

#[test]
fn tool_call_ignores_sibling_metadata_and_preserves_arguments() {
    let cases = [
        ("readToolCall", "path", "src/read.rs", EventKind::FileRead, "completed: read", "files"),
        ("writeToolCall", "filePath", "src/write.rs", EventKind::FileWrite, "completed: write", "files"),
        ("editToolCall", "path", "src/edit.rs", EventKind::FileWrite, "completed: edit", "files"),
        ("deleteToolCall", "path", "src/delete.rs", EventKind::FileWrite, "completed: delete", "files"),
    ];
    for (tool, path_key, path, kind, detail, metadata_key) in cases {
        let line = format!(
            r#"{{"type":"tool_call","subtype":"completed","tool_call":{{"completedAtMs":"2","hookAdditionalContexts":[],"{tool}":{{"args":{{"{path_key}":"{path}"}},"result":{{"success":true}}}},"startedAtMs":"1","toolCallId":"id"}}}}"#
        );
        let event = parse(&line);
        assert_eq!(event.event_kind, kind);
        assert_eq!(event.detail, format!("{detail} {path}"));
        assert_eq!(metadata_first_str(&event, metadata_key), Some(path));
    }
}

#[test]
fn shell_tool_call_ignores_sibling_metadata_and_preserves_command() {
    let event = parse(r#"{"type":"tool_call","subtype":"started","tool_call":{"hookAdditionalContexts":[],"shellToolCall":{"args":{"command":"cargo check"}},"startedAtMs":"1","toolCallId":"id"}}"#);
    assert_eq!(event.event_kind, EventKind::ToolCall);
    assert_eq!(event.detail, "started: shell cargo check");
    assert_eq!(metadata_str(&event, "command"), Some("cargo check"));
}

#[test]
fn parses_tool_call_glob_as_tool_evidence() {
    let event = parse(r#"{"type":"tool_call","subtype":"started","tool_call":{"globToolCall":{"args":{"globPattern":"**/*.rs"}}}}"#);
    assert_eq!(event.event_kind, EventKind::ToolCall);
    assert_eq!(event.detail, "started: glob **/*.rs");
}

#[test]
fn distinct_unknown_tools_preserve_distinct_command_metadata() {
    let first = parse(r#"{"type":"tool_call","subtype":"started","tool_call":{"alphaToolCall":{"args":{"query":"one"}}}}"#);
    let second = parse(r#"{"type":"tool_call","subtype":"started","tool_call":{"betaToolCall":{"args":{"query":"two"}}}}"#);

    assert_eq!(metadata_str(&first, "command"), Some("alphaToolCall:{\"query\":\"one\"}"));
    assert_eq!(metadata_str(&second, "command"), Some("betaToolCall:{\"query\":\"two\"}"));
    assert_ne!(metadata_str(&first, "command"), metadata_str(&second, "command"));
}

#[test]
fn skips_all_thinking_deltas() {
    let line = r#"{"type":"thinking","subtype":"delta","text":"analyzing the code"}"#;
    assert!(CursorAgent.parse_event(&TaskId("t-think".to_string()), line).is_none());
}

#[test]
fn uses_cursor_agent_binary() {
    let cmd = CursorAgent.build_command("test prompt", &run_opts()).unwrap();
    let program = cmd.get_program().to_string_lossy();
    let is_cursor_cli = program == "agent"
        || program == "cursor-agent"
        || program.ends_with("/agent");
    assert!(is_cursor_cli, "unexpected cursor program: {program}");
    let args: Vec<_> = cmd.get_args().collect();
    assert_eq!(args[0], "-p");
    // composer-2 was delisted by 2026-08-05; `cursor-agent models` marks
    // composer-2.5 as current. This assertion guarded the dead name for as long
    // as it was green.
    assert!(args.windows(2).any(|window| window[0] == "--model" && window[1] == "composer-2.5"));
}

#[test]
fn build_command_embeds_context_files_in_prompt() {
    let dir = tempfile::tempdir().unwrap();
    let context_file = dir.path().join("context.txt");
    fs::write(&context_file, "cursor context").unwrap();
    let mut opts = run_opts();
    opts.context_files = vec![context_file.to_string_lossy().into_owned()];

    let cmd = CursorAgent.build_command("test prompt", &opts).unwrap();
    let prompt = cmd.get_args().nth(1).unwrap().to_string_lossy();
    assert!(prompt.contains("test prompt"));
    assert!(prompt.contains("cursor context"));
}

#[test]
fn read_only_build_command_adds_trust_and_context() {
    let dir = tempfile::tempdir().unwrap();
    let context_file = dir.path().join("readonly.txt");
    fs::write(&context_file, "readonly context").unwrap();
    let mut opts = run_opts();
    opts.read_only = true;
    opts.context_files = vec![context_file.to_string_lossy().into_owned()];

    let cmd = CursorAgent.build_command("plan prompt", &opts).unwrap();
    let args: Vec<_> = cmd.get_args().collect();
    assert_eq!(args[1], "--trust");
    assert!(args.windows(2).any(|window| window[0] == "--mode" && window[1] == "plan"));
}

#[test]
fn read_only_with_result_file_uses_force_not_plan() {
    let mut opts = run_opts();
    opts.read_only = true;
    opts.result_file = Some("result.md".to_string());
    let cmd = CursorAgent.build_command("audit findings", &opts).unwrap();
    let args: Vec<String> = cmd
        .get_args()
        .map(|a| a.to_string_lossy().into_owned())
        .collect();
    assert!(args.iter().any(|a| a == "--force"));
    assert!(!args.windows(2).any(|w| w == ["--mode", "plan"]));
    assert!(args.iter().any(|a| a.contains("EXCEPT the result file")));
}

/// The adapter classifies; it does not decide who spoke. Every line below is one
/// cursor really produced on 2026-08-07, and each of the first three wrote a
/// real hold on a route that was serving the whole time — one of them permanent.
/// Detection now happens where the channel is known (`quota_channel`), so
/// parsing an event must leave the marker directory untouched whatever the line
/// says.
#[test]
fn parse_event_classifies_but_never_marks() {
    let temp = tempfile::tempdir().unwrap();
    let _aid_home = paths::AidHomeGuard::set(temp.path());
    let _guard = rate_limit_lock().lock().unwrap();
    let _ = rate_limit::clear_rate_limit(&AgentKind::Cursor, None);

    let lines = [
        // The audit's own report, quoting this repo's fixture back at us.
        r#"{"type":"assistant","message":{"content":[{"type":"text","text":"FAILED: the needle is `quota exceeded for this workspace`"}]}}"#,
        // The audit's own grep, rendered by aid as `completed: grep <pattern>`.
        r#"{"type":"tool_call","subtype":"completed","tool_call":{"grepToolCall":{"args":{"pattern":"you're out of usage|out of usage|ActionRequired"}}}}"#,
        // A plain-text line carrying a generic token.
        "Error: rate limit exceeded, try again later",
        // And cursor's genuine refusal envelope — still not the adapter's call.
        r#"{"type":"error","message":"quota exceeded for this workspace"}"#,
    ];
    for line in lines {
        let _ = CursorAgent.parse_event(&TaskId("t-cursor".to_string()), line);
        assert!(
            !rate_limit::is_rate_limited(&AgentKind::Cursor, None),
            "adapter wrote a marker from {line:?}"
        );
        assert!(
            !rate_limit::is_group_rate_limited(&AgentKind::Cursor, None, "premium"),
            "adapter wrote a group marker from {line:?}"
        );
    }
}

/// The genuine refusal above is not lost — it is read on the channel it arrived
/// on, and it marks the premium tier rather than the whole agent, so `auto`
/// stays dispatchable.
#[test]
fn cursors_error_envelope_is_read_on_the_stream_channel() {
    let temp = tempfile::tempdir().unwrap();
    let _aid_home = paths::AidHomeGuard::set(temp.path());
    let _guard = rate_limit_lock().lock().unwrap();
    let _ = rate_limit::clear_rate_limit(&AgentKind::Cursor, None);

    let line = r#"{"type":"error","message":"quota exceeded for this workspace"}"#;
    let refusal = rate_limit::refusal_on_channel(
        line,
        AgentKind::Cursor,
        crate::quota_channel::Channel::CliStream,
    );
    assert_eq!(refusal.as_deref(), Some("quota exceeded for this workspace"));
    crate::quota_channel::mark_stream_refusal(AgentKind::Cursor, None, line);
    assert!(rate_limit::is_rate_limited(&AgentKind::Cursor, None));
    rate_limit::clear_all_rate_limits_for_agent(&AgentKind::Cursor, None);
    let premium = r#"{"type":"error","message":"You're out of usage. Switch to Auto."}"#;
    crate::quota_channel::mark_stream_refusal(AgentKind::Cursor, None, premium);
    assert!(rate_limit::is_group_rate_limited(&AgentKind::Cursor, None, "premium"));
    assert!(!rate_limit::is_rate_limited(&AgentKind::Cursor, None));
}

fn parse(line: &str) -> crate::types::TaskEvent {
    CursorAgent.parse_event(&TaskId("t-cursor".to_string()), line).unwrap()
}

fn metadata_i64(event: &crate::types::TaskEvent, key: &str) -> Option<i64> {
    event.metadata.as_ref()?.get(key)?.as_i64()
}

fn metadata_str<'a>(event: &'a crate::types::TaskEvent, key: &str) -> Option<&'a str> {
    event.metadata.as_ref()?.get(key)?.as_str()
}

fn metadata_first_str<'a>(event: &'a crate::types::TaskEvent, key: &str) -> Option<&'a str> {
    event.metadata.as_ref()?.get(key)?.as_array()?.first()?.as_str()
}

fn rate_limit_lock() -> &'static Mutex<()> {
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
}

fn run_opts() -> RunOpts {
    RunOpts {
        dir: None, output: None, result_file: None, model: None, budget: false,
        read_only: false, sandbox: false, context_files: vec![], session_id: None,
        env: None, env_forward: None,
    }
}

#[test]
fn only_accepts_a_bare_agent_binary_that_identifies_as_cursor() {
    // Cursor's own help text names the product; xAI's Grok Build CLI ships a binary with
    // the same `agent` name and must not be mistaken for it.
    assert!(super::help_mentions_cursor(
        "Usage: cursor-agent [OPTIONS]\n  -p, --print  Print response\n"
    ));
    assert!(super::help_mentions_cursor("Cursor Agent CLI\n"));
    assert!(!super::help_mentions_cursor(
        "Grok Build TUI\n\nUsage: agent [OPTIONS] [PROMPT] [COMMAND]\n"
    ));
    assert!(!super::help_mentions_cursor(""));
}

#[test]
fn resolve_cursor_binary_walks_path_with_stubbed_identity() {
    use std::cell::RefCell;
    use std::os::unix::fs::PermissionsExt;
    use std::path::Path;

    fn write_agent(dir: &Path, executable: bool) {
        fs::create_dir_all(dir).unwrap();
        let agent = dir.join("agent");
        fs::write(&agent, "#!/bin/sh\nexit 0\n").unwrap();
        let mode = if executable { 0o755 } else { 0o644 };
        let mut perms = fs::metadata(&agent).unwrap().permissions();
        perms.set_mode(mode);
        fs::set_permissions(&agent, perms).unwrap();
    }

    let root = tempfile::tempdir().unwrap();
    let decoy = root.path().join("decoy");
    let cursor_a = root.path().join("cursor_a");
    let cursor_b = root.path().join("cursor_b");
    let nonexec = root.path().join("nonexec");
    write_agent(&decoy, true);
    write_agent(&cursor_a, true);
    write_agent(&cursor_b, true);
    write_agent(&nonexec, false);

    let decoy_agent = decoy.join("agent").to_str().unwrap().to_owned();
    let cursor_a_agent = cursor_a.join("agent").to_str().unwrap().to_owned();
    let cursor_b_agent = cursor_b.join("agent").to_str().unwrap().to_owned();
    let nonexec_agent = nonexec.join("agent").to_str().unwrap().to_owned();

    // Literal expected values (not re-derived from the function under test).
    let expected_decoy_then_cursor = cursor_b_agent.clone();
    let expected_cursor_first = cursor_a_agent.clone();
    let expected_no_cursor = "cursor-agent".to_owned();
    let expected_skip_nonexec = cursor_a_agent.clone();

    let cases = [
        (
            "decoy_then_cursor",
            vec![decoy.clone(), cursor_b.clone()],
            expected_decoy_then_cursor,
        ),
        (
            "cursor_first",
            vec![cursor_a.clone(), decoy.clone()],
            expected_cursor_first,
        ),
        ("no_cursor", vec![decoy.clone()], expected_no_cursor),
        (
            "skip_nonexec",
            vec![nonexec.clone(), cursor_a.clone()],
            expected_skip_nonexec,
        ),
    ];

    for (name, dirs, expected) in cases {
        let path = std::env::join_paths(dirs.iter()).unwrap();
        let probed = RefCell::new(Vec::<String>::new());
        let cursor_a_hit = cursor_a_agent.clone();
        let cursor_b_hit = cursor_b_agent.clone();
        let got = super::resolve_cursor_binary_from_path(Some(path), |candidate| {
            probed.borrow_mut().push(candidate.to_owned());
            candidate == cursor_a_hit || candidate == cursor_b_hit
        });
        assert_eq!(got, expected.as_str(), "case {name}");
        assert!(
            !probed.borrow().iter().any(|p| p == &nonexec_agent),
            "case {name}: non-executable agent must not be probed"
        );
        if name == "decoy_then_cursor" {
            assert_eq!(
                probed.borrow().as_slice(),
                &[decoy_agent.as_str(), cursor_b_agent.as_str()],
                "case {name}: probe order"
            );
        }
    }
}

#[test]
fn parse_cursor_models_output_strips_ansi_escapes() {
    let raw = "\u{1b}[1mcomposer-2.5\u{1b}[0m (current)\n\u{1b}[32mcomposer-2.5-fast\u{1b}[0m\n\u{1b}[34mgpt-5.6\u{1b}[0m\n";
    let models = super::parse_cursor_models_output(raw);
    assert_eq!(models, vec!["composer-2.5", "composer-2.5-fast", "gpt-5.6"]);
}