1use ao_core::{
38 shell::shell_escape, ActivityState, Agent, AgentConfig, CostEstimate, Result, Session,
39};
40use async_trait::async_trait;
41use std::path::Path;
42
43const ACTIVE_WINDOW_SECS: u64 = 30;
46
47const IDLE_THRESHOLD_SECS: u64 = 300;
50
51pub struct AiderAgent {
52 rules: Option<String>,
55 model: Option<String>,
57 permissions: Option<String>,
59}
60
61impl AiderAgent {
62 pub fn new() -> Self {
63 Self {
64 rules: None,
65 model: None,
66 permissions: None,
67 }
68 }
69
70 pub fn from_config(config: &AgentConfig) -> Self {
72 let rules = if let Some(ref path) = config.rules_file {
73 match std::fs::read_to_string(path) {
74 Ok(content) => Some(content),
75 Err(e) => {
76 tracing::warn!("could not read rules file {path}: {e}, using inline rules");
77 config.rules.clone()
78 }
79 }
80 } else {
81 config.rules.clone()
82 };
83 Self {
84 rules,
85 model: config.model.clone(),
86 permissions: Some(config.permissions.to_string()),
87 }
88 }
89}
90
91impl Default for AiderAgent {
92 fn default() -> Self {
93 Self::new()
94 }
95}
96
97#[async_trait]
98impl Agent for AiderAgent {
99 fn launch_command(&self, _session: &Session) -> String {
100 let mut parts: Vec<String> = vec!["aider".to_string()];
101
102 if let Some(ref raw) = self.permissions {
103 if uses_yes_flag(raw) {
104 parts.push("--yes".to_string());
105 }
106 }
107
108 if let Some(ref model) = self.model {
109 parts.push("--model".to_string());
110 parts.push(shell_escape(model));
111 }
112
113 parts.join(" ")
114 }
115
116 fn environment(&self, session: &Session) -> Vec<(String, String)> {
117 vec![("AO_SESSION_ID".to_string(), session.id.to_string())]
118 }
119
120 fn initial_prompt(&self, session: &Session) -> String {
121 let task_part = if let Some(ref id) = session.issue_id {
122 let url_line = session
123 .issue_url
124 .as_deref()
125 .map(|u| format!("\nIssue URL: {u}"))
126 .unwrap_or_default();
127 format!(
128 "You are working on issue #{id} on branch `{branch}`.{url_line}\n\n\
129 Task:\n{task}\n\n\
130 When complete, push your branch and open a pull request.",
131 branch = session.branch,
132 task = session.task,
133 )
134 } else {
135 session.task.clone()
136 };
137
138 match &self.rules {
139 Some(rules) => format!("{rules}\n\n---\n\n{task_part}"),
140 None => task_part,
141 }
142 }
143
144 async fn detect_activity(&self, session: &Session) -> Result<ActivityState> {
145 let Some(ref ws) = session.workspace_path else {
146 return Ok(ActivityState::Ready);
147 };
148 let ws = ws.clone();
149 tokio::task::spawn_blocking(move || detect_aider_activity(&ws))
150 .await
151 .map_err(|e| ao_core::AoError::Other(format!("detect_activity panicked: {e}")))?
152 }
153
154 async fn cost_estimate(&self, _session: &Session) -> Result<Option<CostEstimate>> {
155 Ok(None)
160 }
161}
162
163fn uses_yes_flag(raw: &str) -> bool {
174 matches!(raw, "permissionless" | "auto-edit" | "skip")
175}
176
177fn detect_aider_activity(workspace_path: &Path) -> Result<ActivityState> {
182 let chat = workspace_path.join(".aider.chat.history.md");
183 if let Ok(s) = classify_mtime(&chat) {
184 return Ok(s);
185 }
186
187 let input = workspace_path.join(".aider.input.history");
188 if let Ok(s) = classify_mtime(&input) {
189 return Ok(s);
190 }
191
192 if has_recent_commits(workspace_path) {
193 return Ok(ActivityState::Active);
194 }
195
196 Ok(ActivityState::Ready)
197}
198
199fn classify_mtime(path: &Path) -> std::io::Result<ActivityState> {
200 let meta = std::fs::metadata(path)?;
201 let modified = meta.modified()?;
202 let age = std::time::SystemTime::now()
203 .duration_since(modified)
204 .unwrap_or_default();
205
206 if age.as_secs() <= ACTIVE_WINDOW_SECS {
207 Ok(ActivityState::Active)
208 } else if age.as_secs() <= IDLE_THRESHOLD_SECS {
209 Ok(ActivityState::Ready)
210 } else {
211 Ok(ActivityState::Idle)
212 }
213}
214
215fn has_recent_commits(workspace_path: &Path) -> bool {
216 let output = std::process::Command::new("git")
217 .args(["log", "--since=60 seconds ago", "--format=%H"])
218 .current_dir(workspace_path)
219 .stdout(std::process::Stdio::piped())
220 .stderr(std::process::Stdio::null())
221 .output();
222
223 match output {
224 Ok(o) if o.status.success() => !String::from_utf8_lossy(&o.stdout).trim().is_empty(),
225 _ => false,
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use ao_core::{now_ms, PermissionsMode, SessionId, SessionStatus};
233 use std::path::PathBuf;
234
235 fn fake_session() -> Session {
236 Session {
237 id: SessionId("aider-test".into()),
238 project_id: "demo".into(),
239 status: SessionStatus::Working,
240 agent: "aider".into(),
241 agent_config: None,
242 branch: "ao-abc123-feat-test".into(),
243 task: "fix the bug".into(),
244 workspace_path: Some(PathBuf::from("/tmp/aider-demo")),
245 runtime_handle: None,
246 runtime: "tmux".into(),
247 activity: None,
248 created_at: now_ms(),
249 cost: None,
250 issue_id: None,
251 issue_url: None,
252 claimed_pr_number: None,
253 claimed_pr_url: None,
254 initial_prompt_override: None,
255 spawned_by: None,
256 last_merge_conflict_dispatched: None,
257 last_review_backlog_fingerprint: None,
258 }
259 }
260
261 fn config(
262 permissions: PermissionsMode,
263 model: Option<&str>,
264 rules: Option<&str>,
265 ) -> AgentConfig {
266 AgentConfig {
267 permissions,
268 rules: rules.map(String::from),
269 rules_file: None,
270 model: model.map(String::from),
271 orchestrator_model: None,
272 opencode_session_id: None,
273 }
274 }
275
276 #[test]
279 fn launch_command_base_is_aider() {
280 let agent = AiderAgent::new();
281 assert_eq!(agent.launch_command(&fake_session()), "aider");
282 }
283
284 #[test]
285 fn launch_command_adds_yes_for_permissionless() {
286 let agent = AiderAgent::from_config(&config(PermissionsMode::Permissionless, None, None));
287 assert_eq!(agent.launch_command(&fake_session()), "aider --yes");
288 }
289
290 #[test]
291 fn launch_command_adds_yes_for_auto_edit() {
292 let agent = AiderAgent::from_config(&config(PermissionsMode::AutoEdit, None, None));
293 assert_eq!(agent.launch_command(&fake_session()), "aider --yes");
294 }
295
296 #[test]
297 fn launch_command_omits_yes_for_default() {
298 let agent = AiderAgent::from_config(&config(PermissionsMode::Default, None, None));
299 assert_eq!(agent.launch_command(&fake_session()), "aider");
300 }
301
302 #[test]
303 fn launch_command_omits_yes_for_suggest() {
304 let agent = AiderAgent::from_config(&config(PermissionsMode::Suggest, None, None));
305 assert_eq!(agent.launch_command(&fake_session()), "aider");
306 }
307
308 #[test]
309 fn launch_command_includes_model_shell_escaped() {
310 let agent =
311 AiderAgent::from_config(&config(PermissionsMode::Default, Some("gpt-4o"), None));
312 assert_eq!(
313 agent.launch_command(&fake_session()),
314 "aider --model 'gpt-4o'"
315 );
316 }
317
318 #[test]
319 fn launch_command_escapes_single_quotes_in_model() {
320 let agent =
321 AiderAgent::from_config(&config(PermissionsMode::Default, Some("weird'name"), None));
322 let cmd = agent.launch_command(&fake_session());
323 assert!(cmd.contains(r#"--model 'weird'\''name'"#));
324 }
325
326 #[test]
327 fn launch_command_combines_yes_and_model() {
328 let agent = AiderAgent::from_config(&config(
329 PermissionsMode::Permissionless,
330 Some("sonnet"),
331 None,
332 ));
333 assert_eq!(
334 agent.launch_command(&fake_session()),
335 "aider --yes --model 'sonnet'"
336 );
337 }
338
339 #[test]
340 fn launch_command_omits_model_flag_when_not_set() {
341 let agent = AiderAgent::from_config(&config(PermissionsMode::Permissionless, None, None));
342 let cmd = agent.launch_command(&fake_session());
343 assert!(!cmd.contains("--model"));
344 }
345
346 #[test]
349 fn environment_includes_session_id() {
350 let agent = AiderAgent::new();
351 let env = agent.environment(&fake_session());
352 assert!(env
353 .iter()
354 .any(|(k, v)| k == "AO_SESSION_ID" && v == "aider-test"));
355 }
356
357 #[test]
360 fn initial_prompt_task_first() {
361 let agent = AiderAgent::new();
362 assert_eq!(agent.initial_prompt(&fake_session()), "fix the bug");
363 }
364
365 #[test]
366 fn initial_prompt_issue_first() {
367 let agent = AiderAgent::new();
368 let mut session = fake_session();
369 session.issue_id = Some("22".into());
370 session.issue_url = Some("https://github.com/org/repo/issues/22".into());
371 session.task = "Port plugin".into();
372 let p = agent.initial_prompt(&session);
373 assert!(p.contains("issue #22"));
374 assert!(p.contains("https://github.com/org/repo/issues/22"));
375 assert!(p.contains("Port plugin"));
376 assert!(p.contains("open a pull request"));
377 }
378
379 #[test]
380 fn initial_prompt_with_rules_prepends_rules() {
381 let agent = AiderAgent {
382 rules: Some("Always run tests.".into()),
383 model: None,
384 permissions: None,
385 };
386 let p = agent.initial_prompt(&fake_session());
387 assert!(p.starts_with("Always run tests."));
388 assert!(p.contains("---"));
389 assert!(p.contains("fix the bug"));
390 }
391
392 #[test]
393 fn from_config_reads_inline_rules() {
394 let cfg = config(PermissionsMode::Permissionless, None, Some("custom rules"));
395 let agent = AiderAgent::from_config(&cfg);
396 let p = agent.initial_prompt(&fake_session());
397 assert!(p.contains("custom rules"));
398 }
399
400 #[tokio::test]
403 async fn cost_estimate_returns_none() {
404 let agent = AiderAgent::new();
405 let result = agent.cost_estimate(&fake_session()).await.unwrap();
406 assert!(
407 result.is_none(),
408 "aider does not expose cost data — should always be None"
409 );
410 }
411
412 #[test]
415 fn shell_escape_wraps_in_single_quotes() {
416 assert_eq!(shell_escape("gpt-4o"), "'gpt-4o'");
417 }
418
419 #[test]
420 fn shell_escape_handles_embedded_single_quote() {
421 assert_eq!(shell_escape("it's"), r#"'it'\''s'"#);
423 }
424
425 #[test]
428 fn uses_yes_flag_matches_ts_normalization() {
429 assert!(uses_yes_flag("permissionless"));
430 assert!(uses_yes_flag("auto-edit"));
431 assert!(uses_yes_flag("skip"));
432 assert!(!uses_yes_flag("default"));
433 assert!(!uses_yes_flag("suggest"));
434 assert!(!uses_yes_flag(""));
435 assert!(!uses_yes_flag("unknown"));
436 }
437
438 #[test]
441 fn detect_activity_no_files_returns_ready() {
442 let ws = std::env::temp_dir().join("ao-aider-no-files");
443 std::fs::create_dir_all(&ws).unwrap();
444 let s = detect_aider_activity(&ws).unwrap();
445 assert_eq!(s, ActivityState::Ready);
446 std::fs::remove_dir_all(&ws).ok();
447 }
448
449 #[test]
450 fn detect_activity_fresh_chat_file_returns_active() {
451 let ws = std::env::temp_dir().join("ao-aider-fresh-chat");
452 std::fs::create_dir_all(&ws).unwrap();
453 std::fs::write(ws.join(".aider.chat.history.md"), "hi").unwrap();
454 let s = detect_aider_activity(&ws).unwrap();
455 assert_eq!(s, ActivityState::Active);
456 std::fs::remove_dir_all(&ws).ok();
457 }
458
459 #[test]
460 fn detect_activity_stale_chat_file_returns_idle() {
461 let ws = std::env::temp_dir().join("ao-aider-stale-chat");
462 std::fs::create_dir_all(&ws).unwrap();
463 let p = ws.join(".aider.chat.history.md");
464 std::fs::write(&p, "hi").unwrap();
465
466 let old_time = filetime::FileTime::from_unix_time(
467 std::time::SystemTime::now()
468 .duration_since(std::time::UNIX_EPOCH)
469 .unwrap()
470 .as_secs() as i64
471 - IDLE_THRESHOLD_SECS as i64
472 - 60,
473 0,
474 );
475 filetime::set_file_mtime(&p, old_time).unwrap();
476
477 let s = detect_aider_activity(&ws).unwrap();
478 assert_eq!(s, ActivityState::Idle);
479 std::fs::remove_dir_all(&ws).ok();
480 }
481}