1use ao_core::{ActivityState, Agent, AgentConfig, Result, Session};
40use async_trait::async_trait;
41use std::path::Path;
42
43const IDLE_THRESHOLD_SECS: u64 = 300;
46
47const ACTIVE_WINDOW_SECS: u64 = 30;
50
51pub struct CursorAgent {
52 rules: Option<String>,
55 model: Option<String>,
57}
58
59impl CursorAgent {
60 pub fn new() -> Self {
61 Self {
62 rules: None,
63 model: None,
64 }
65 }
66
67 pub fn from_config(config: &AgentConfig) -> Self {
69 let rules = if let Some(ref path) = config.rules_file {
70 match std::fs::read_to_string(path) {
71 Ok(content) => Some(content),
72 Err(e) => {
73 tracing::warn!("could not read rules file {path}: {e}, using inline rules");
74 config.rules.clone()
75 }
76 }
77 } else {
78 config.rules.clone()
79 };
80 Self {
81 rules,
82 model: config.model.clone(),
83 }
84 }
85}
86
87impl Default for CursorAgent {
88 fn default() -> Self {
89 Self::new()
90 }
91}
92
93#[async_trait]
94impl Agent for CursorAgent {
95 fn launch_command(&self, _session: &Session) -> String {
96 let mut cmd = "agent --force --sandbox disabled --approve-mcps".to_string();
101
102 if let Some(ref model) = self.model {
103 let escaped = model.replace('\'', "'\\''");
105 cmd.push_str(&format!(" --model '{escaped}'"));
106 }
107
108 cmd
109 }
110
111 fn environment(&self, session: &Session) -> Vec<(String, String)> {
112 vec![
113 ("AO_SESSION_ID".to_string(), session.id.to_string()),
114 (
116 "AO_ISSUE_ID".to_string(),
117 session.issue_id.clone().unwrap_or_default(),
118 ),
119 ]
120 }
121
122 fn system_prompt(&self) -> Option<String> {
123 self.rules
129 .as_ref()
130 .map(|r| r.trim())
131 .filter(|r| !r.is_empty())
132 .map(|r| r.to_string())
133 }
134
135 fn initial_prompt(&self, session: &Session) -> String {
136 let task_part = if let Some(ref id) = session.issue_id {
143 let url_line = session
144 .issue_url
145 .as_deref()
146 .map(|u| format!("\nIssue URL: {u}"))
147 .unwrap_or_default();
148 format!(
149 "You are working on issue #{id} on branch `{branch}`.{url_line}\n\n\
150 Task:\n{task}\n\n\
151 When complete, push your branch and open a pull request.",
152 branch = session.branch,
153 task = session.task,
154 )
155 } else {
156 session.task.clone()
157 };
158
159 match &self.rules {
160 Some(rules) => format!("{rules}\n\n---\n\n{task_part}"),
161 None => task_part,
162 }
163 }
164
165 async fn detect_activity(&self, session: &Session) -> Result<ActivityState> {
166 let Some(ref ws) = session.workspace_path else {
167 return Ok(ActivityState::Ready);
168 };
169 let ws = ws.clone();
171 tokio::task::spawn_blocking(move || detect_cursor_activity(&ws))
172 .await
173 .map_err(|e| ao_core::AoError::Other(format!("detect_activity panicked: {e}")))?
174 }
175
176 }
178
179fn detect_cursor_activity(workspace_path: &Path) -> Result<ActivityState> {
190 if let Some(state) = state_from_mtime(workspace_path.join(".cursor").join("chat.md"))? {
192 return Ok(state);
193 }
194
195 if let Some(state) = detect_cursor_log_activity(workspace_path)? {
197 return Ok(state);
198 }
199
200 if let Some(state) = detect_git_index_activity(workspace_path)? {
202 return Ok(state);
203 }
204
205 if has_recent_commits(workspace_path) {
207 return Ok(ActivityState::Active);
208 }
209
210 Ok(ActivityState::Ready)
212}
213
214fn age_to_state(age_secs: u64) -> ActivityState {
215 if age_secs <= ACTIVE_WINDOW_SECS {
216 ActivityState::Active
217 } else if age_secs <= IDLE_THRESHOLD_SECS {
218 ActivityState::Ready
219 } else {
220 ActivityState::Idle
221 }
222}
223
224fn state_from_mtime(path: impl AsRef<Path>) -> Result<Option<ActivityState>> {
225 let path = path.as_ref();
226 let Ok(metadata) = std::fs::metadata(path) else {
227 return Ok(None);
228 };
229 let Ok(modified) = metadata.modified() else {
230 return Ok(Some(ActivityState::Ready));
233 };
234 let age = std::time::SystemTime::now()
235 .duration_since(modified)
236 .unwrap_or_default()
237 .as_secs();
238 Ok(Some(age_to_state(age)))
239}
240
241fn detect_cursor_log_activity(workspace_path: &Path) -> Result<Option<ActivityState>> {
242 let cursor_dir = workspace_path.join(".cursor");
243 let logs_dir = cursor_dir.join("logs");
244 let Ok(entries) = std::fs::read_dir(&logs_dir) else {
245 return Ok(None);
246 };
247
248 let mut newest: Option<std::time::SystemTime> = None;
249 for (i, entry) in entries.flatten().enumerate() {
251 if i >= 200 {
252 break;
253 }
254 let Ok(meta) = entry.metadata() else {
255 continue;
256 };
257 if !meta.is_file() {
258 continue;
259 }
260 let Ok(modified) = meta.modified() else {
261 continue;
262 };
263 newest = Some(match newest {
264 Some(prev) if prev > modified => prev,
265 _ => modified,
266 });
267 }
268
269 let Some(newest) = newest else {
270 return Ok(None);
271 };
272 let age = std::time::SystemTime::now()
273 .duration_since(newest)
274 .unwrap_or_default()
275 .as_secs();
276 Ok(Some(age_to_state(age)))
277}
278
279fn detect_git_index_activity(workspace_path: &Path) -> Result<Option<ActivityState>> {
280 let direct = workspace_path.join(".git").join("index");
282 if let Some(state) = state_from_mtime(&direct)? {
283 return Ok(Some(state));
284 }
285
286 let output = std::process::Command::new("git")
288 .args(["rev-parse", "--git-dir"])
289 .current_dir(workspace_path)
290 .stdout(std::process::Stdio::piped())
291 .stderr(std::process::Stdio::null())
292 .output();
293 let Ok(o) = output else {
294 return Ok(None);
295 };
296 if !o.status.success() {
297 return Ok(None);
298 }
299 let git_dir = String::from_utf8_lossy(&o.stdout).trim().to_string();
300 if git_dir.is_empty() {
301 return Ok(None);
302 }
303 let git_dir = if Path::new(&git_dir).is_absolute() {
304 std::path::PathBuf::from(git_dir)
305 } else {
306 workspace_path.join(git_dir)
307 };
308 let idx = git_dir.join("index");
309 state_from_mtime(&idx)
310}
311
312fn has_recent_commits(workspace_path: &Path) -> bool {
314 let output = std::process::Command::new("git")
315 .args(["log", "--since=60 seconds ago", "--format=%H"])
316 .current_dir(workspace_path)
317 .stdout(std::process::Stdio::piped())
318 .stderr(std::process::Stdio::null())
319 .output();
320
321 match output {
322 Ok(o) if o.status.success() => !String::from_utf8_lossy(&o.stdout).trim().is_empty(),
323 _ => false,
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use ao_core::{now_ms, PermissionsMode, SessionId, SessionStatus};
331 use std::path::PathBuf;
332
333 fn fake_session() -> Session {
334 Session {
335 id: SessionId("cursor-test".into()),
336 project_id: "demo".into(),
337 status: SessionStatus::Working,
338 agent: "cursor".into(),
339 agent_config: None,
340 branch: "ao-abc123-feat-test".into(),
341 task: "fix the bug".into(),
342 workspace_path: Some(PathBuf::from("/tmp/cursor-demo")),
343 runtime_handle: None,
344 runtime: "tmux".into(),
345 activity: None,
346 created_at: now_ms(),
347 cost: None,
348 issue_id: None,
349 issue_url: None,
350 claimed_pr_number: None,
351 claimed_pr_url: None,
352 initial_prompt_override: None,
353 spawned_by: None,
354 last_merge_conflict_dispatched: None,
355 last_review_backlog_fingerprint: None,
356 }
357 }
358
359 #[test]
360 fn launch_command_is_permissionless() {
361 let agent = CursorAgent::new();
362 let cmd = agent.launch_command(&fake_session());
363 assert!(cmd.contains("agent"));
364 assert!(cmd.contains("--force"));
365 assert!(cmd.contains("--sandbox disabled"));
366 assert!(cmd.contains("--approve-mcps"));
367 }
368
369 #[test]
370 fn environment_includes_session_id() {
371 let agent = CursorAgent::new();
372 let env = agent.environment(&fake_session());
373 assert!(env
374 .iter()
375 .any(|(k, v)| k == "AO_SESSION_ID" && v == "cursor-test"));
376 }
377
378 #[test]
379 fn environment_includes_empty_issue_id_when_none() {
380 let agent = CursorAgent::new();
381 let env = agent.environment(&fake_session());
382 assert!(env.iter().any(|(k, v)| k == "AO_ISSUE_ID" && v.is_empty()));
383 }
384
385 #[test]
386 fn environment_includes_issue_id_when_set() {
387 let agent = CursorAgent::new();
388 let mut session = fake_session();
389 session.issue_id = Some("42".into());
390 let env = agent.environment(&session);
391 assert!(env.iter().any(|(k, v)| k == "AO_ISSUE_ID" && v == "42"));
392 }
393
394 #[test]
395 fn initial_prompt_task_first() {
396 let agent = CursorAgent::new();
397 assert_eq!(agent.initial_prompt(&fake_session()), "fix the bug");
398 }
399
400 #[test]
401 fn initial_prompt_issue_first() {
402 let agent = CursorAgent::new();
403 let mut session = fake_session();
404 session.issue_id = Some("7".into());
405 session.issue_url = Some("https://github.com/acme/repo/issues/7".into());
406 session.task = "Add dark mode".into();
407
408 let prompt = agent.initial_prompt(&session);
409 assert!(prompt.contains("issue #7"));
410 assert!(prompt.contains("https://github.com/acme/repo/issues/7"));
411 assert!(prompt.contains("Add dark mode"));
412 assert!(prompt.contains("open a pull request"));
413 }
414
415 #[test]
416 fn initial_prompt_with_rules_prepends_rules() {
417 let agent = CursorAgent {
418 rules: Some("Always run tests before committing.".into()),
419 model: None,
420 };
421 let prompt = agent.initial_prompt(&fake_session());
422 assert!(prompt.starts_with("Always run tests"));
423 assert!(prompt.contains("---"));
424 assert!(prompt.contains("fix the bug"));
425 }
426
427 #[test]
430 fn system_prompt_none_when_no_rules() {
431 let agent = CursorAgent::new();
432 assert!(agent.system_prompt().is_none());
433 }
434
435 #[test]
436 fn system_prompt_returns_rules_when_configured() {
437 let config = AgentConfig {
438 permissions: PermissionsMode::Permissionless,
439 rules: Some("Always run tests before committing.".into()),
440 rules_file: None,
441 model: None,
442 orchestrator_model: None,
443 opencode_session_id: None,
444 };
445 let agent = CursorAgent::from_config(&config);
446 assert_eq!(
447 agent.system_prompt().as_deref(),
448 Some("Always run tests before committing.")
449 );
450 }
451
452 #[test]
453 fn system_prompt_none_when_rules_blank() {
454 let config = AgentConfig {
457 permissions: PermissionsMode::Permissionless,
458 rules: Some(" \n \t".into()),
459 rules_file: None,
460 model: None,
461 orchestrator_model: None,
462 opencode_session_id: None,
463 };
464 let agent = CursorAgent::from_config(&config);
465 assert!(agent.system_prompt().is_none());
466 }
467
468 #[test]
471 fn launch_command_no_model_flag_by_default() {
472 let agent = CursorAgent::new();
473 let cmd = agent.launch_command(&fake_session());
474 assert!(!cmd.contains("--model"));
475 }
476
477 #[test]
478 fn launch_command_includes_model_when_set() {
479 let config = AgentConfig {
480 permissions: PermissionsMode::Permissionless,
481 rules: None,
482 rules_file: None,
483 model: Some("gpt-4o".into()),
484 orchestrator_model: None,
485 opencode_session_id: None,
486 };
487 let agent = CursorAgent::from_config(&config);
488 let cmd = agent.launch_command(&fake_session());
489 assert!(cmd.contains("--model 'gpt-4o'"));
490 }
491
492 #[test]
493 fn launch_command_model_is_shell_escaped() {
494 let config = AgentConfig {
495 permissions: PermissionsMode::Permissionless,
496 rules: None,
497 rules_file: None,
498 model: Some("it's-a-model".into()),
499 orchestrator_model: None,
500 opencode_session_id: None,
501 };
502 let agent = CursorAgent::from_config(&config);
503 let cmd = agent.launch_command(&fake_session());
504 assert!(cmd.contains(r"--model 'it'\''s-a-model'"));
506 }
507
508 #[test]
509 fn from_config_reads_inline_rules() {
510 let config = AgentConfig {
511 permissions: PermissionsMode::Permissionless,
512 rules: Some("custom cursor rules".into()),
513 rules_file: None,
514 model: None,
515 orchestrator_model: None,
516 opencode_session_id: None,
517 };
518 let agent = CursorAgent::from_config(&config);
519 let prompt = agent.initial_prompt(&fake_session());
520 assert!(prompt.contains("custom cursor rules"));
521 }
522
523 #[test]
524 fn from_config_no_rules() {
525 let config = AgentConfig {
526 permissions: PermissionsMode::Permissionless,
527 rules: None,
528 rules_file: None,
529 model: None,
530 orchestrator_model: None,
531 opencode_session_id: None,
532 };
533 let agent = CursorAgent::from_config(&config);
534 assert_eq!(agent.initial_prompt(&fake_session()), "fix the bug");
535 }
536
537 #[test]
540 fn detect_activity_no_workspace_returns_ready() {
541 let ws = std::env::temp_dir().join("ao-cursor-no-ws");
542 std::fs::create_dir_all(&ws).unwrap();
543
544 let result = detect_cursor_activity(&ws).unwrap();
546 assert_eq!(result, ActivityState::Ready);
547
548 std::fs::remove_dir_all(&ws).ok();
549 }
550
551 #[test]
552 fn detect_activity_fresh_chat_file_returns_active() {
553 let ws = std::env::temp_dir().join("ao-cursor-active-chat");
554 let cursor_dir = ws.join(".cursor");
555 std::fs::create_dir_all(&cursor_dir).unwrap();
556 std::fs::write(cursor_dir.join("chat.md"), "# Session\nHello").unwrap();
557
558 let result = detect_cursor_activity(&ws).unwrap();
559 assert_eq!(result, ActivityState::Active);
560
561 std::fs::remove_dir_all(&ws).ok();
562 }
563
564 #[test]
565 fn detect_activity_falls_back_to_cursor_logs_when_chat_missing() {
566 let ws = std::env::temp_dir().join("ao-cursor-active-logs");
567 let logs_dir = ws.join(".cursor").join("logs");
568 std::fs::create_dir_all(&logs_dir).unwrap();
569 std::fs::write(logs_dir.join("cursor-agent.log"), "hello").unwrap();
570
571 let result = detect_cursor_activity(&ws).unwrap();
572 assert_eq!(result, ActivityState::Active);
573
574 std::fs::remove_dir_all(&ws).ok();
575 }
576
577 #[test]
578 fn detect_activity_falls_back_to_git_index_mtime_when_no_cursor_artifacts() {
579 let ws = std::env::temp_dir().join("ao-cursor-active-git-index");
580 let git_dir = ws.join(".git");
581 std::fs::create_dir_all(&git_dir).unwrap();
582 std::fs::write(git_dir.join("index"), "fake index").unwrap();
583
584 let result = detect_cursor_activity(&ws).unwrap();
585 assert_eq!(result, ActivityState::Active);
586
587 std::fs::remove_dir_all(&ws).ok();
588 }
589}