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");
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")));
}
#[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 = [
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"FAILED: the needle is `quota exceeded for this workspace`"}]}}"#,
r#"{"type":"tool_call","subtype":"completed","tool_call":{"grepToolCall":{"args":{"pattern":"you're out of usage|out of usage|ActionRequired"}}}}"#,
"Error: rate limit exceeded, try again later",
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:?}"
);
}
}
#[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() {
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();
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"]);
}