Skip to main content

ao_plugin_agent_aider/
lib.rs

1//! Aider agent plugin.
2//!
3//! Launches the `aider` CLI in interactive mode and delivers the initial task
4//! via post-launch `send_message` (same flow as Claude Code).
5//!
6//! ## Launch command (TS parity)
7//!
8//! Mirrors the `packages/plugins/agent-aider` TypeScript plugin:
9//! - `--yes`: added when `permissions` is `permissionless` or `auto-edit`
10//!   (or legacy `skip`, which normalizes to `permissionless`).
11//! - `--model <value>`: added when `agent_config.model` is set. The value is
12//!   shell-escaped with single quotes to match the TS `shellEscape` helper.
13//!
14//! Rules are delivered as part of the first `send_message` payload rather than
15//! via a CLI flag — aider's `--system-prompt` behavior varies across providers,
16//! so post-launch delivery is the safer default (consistent with Claude Code's
17//! approach when no stable system-prompt flag is available).
18//!
19//! ## Cost estimation
20//!
21//! Aider does not expose structured token/cost data in a machine-readable form
22//! (the TS reference's `getSessionInfo` explicitly leaves `cost` undefined).
23//! `cost_estimate` therefore always returns `None`.
24//!
25//! ## Activity detection
26//!
27//! Aider writes local history files in the workspace by default:
28//! - `.aider.chat.history.md`
29//! - `.aider.input.history`
30//!
31//! Detection mirrors the TS plugin strategy:
32//! 1. If `.aider.chat.history.md` mtime is fresh → Active/Ready/Idle.
33//! 2. Else if `.aider.input.history` mtime is fresh → Active/Ready/Idle.
34//! 3. Else if git has recent commits → Active.
35//! 4. Fallback: Ready.
36
37use ao_core::{
38    shell::shell_escape, ActivityState, Agent, AgentConfig, CostEstimate, Result, Session,
39};
40use async_trait::async_trait;
41use std::path::Path;
42
43/// If the history file was modified within this many seconds, consider the
44/// agent actively working.
45const ACTIVE_WINDOW_SECS: u64 = 30;
46
47/// If the history file was modified within this many seconds, consider the
48/// agent alive and ready (but not actively writing right now).
49const IDLE_THRESHOLD_SECS: u64 = 300;
50
51pub struct AiderAgent {
52    /// Rules prepended to the task. Aider doesn't expose a stable system-prompt
53    /// flag across providers, so we deliver rules as part of the first message.
54    rules: Option<String>,
55    /// Model override passed via `--model`.
56    model: Option<String>,
57    /// Permission mode (TS: `AgentPermissionMode`). Drives `--yes` inclusion.
58    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    /// Create from project agent config.
71    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        // Aider does not emit structured token/cost data (the TS reference's
156        // `getSessionInfo` explicitly leaves `cost` undefined). Explicitly
157        // return `None` so callers know cost tracking is unsupported, rather
158        // than relying on the trait default.
159        Ok(None)
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Launch-command helpers
165// ---------------------------------------------------------------------------
166
167/// Normalize the permission string and decide whether `--yes` should be passed.
168///
169/// Mirrors `normalizeAgentPermissionMode` in the TS core: `permissionless`,
170/// `auto-edit`, and the legacy alias `skip` (which TS remaps to
171/// `permissionless`) all imply non-interactive behavior and map to `--yes`.
172/// `default` and `suggest` leave aider in its normal interactive mode.
173fn uses_yes_flag(raw: &str) -> bool {
174    matches!(raw, "permissionless" | "auto-edit" | "skip")
175}
176
177// ---------------------------------------------------------------------------
178// Activity detection
179// ---------------------------------------------------------------------------
180
181fn 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    // ---- launch_command ----
277
278    #[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    // ---- environment ----
347
348    #[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    // ---- initial_prompt ----
358
359    #[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    // ---- cost_estimate ----
401
402    #[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    // ---- shell_escape ----
413
414    #[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        // POSIX idiom: close-quote, escaped quote, reopen quote.
422        assert_eq!(shell_escape("it's"), r#"'it'\''s'"#);
423    }
424
425    // ---- uses_yes_flag ----
426
427    #[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    // ---- activity detection ----
439
440    #[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}