Skip to main content

batty_cli/agent/
mod.rs

1//! Agent adapter layer.
2//!
3//! Each coding agent (Claude Code, Codex CLI, Aider) is wrapped in an adapter
4//! that knows how to:
5//! - Build the command to spawn the agent in a PTY
6//! - Provide prompt detection patterns for its output
7//! - Format input to inject into the agent's stdin
8//!
9//! The supervisor uses this trait to control agents without knowing their
10//! specific CLI conventions.
11#![cfg_attr(not(test), allow(dead_code))]
12
13pub mod claude;
14pub mod codex;
15pub mod kiro;
16pub mod mock;
17
18use std::path::Path;
19use std::process::Command;
20
21use serde::{Deserialize, Serialize};
22
23use crate::prompt::PromptPatterns;
24
25/// Health state of an agent backend.
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum BackendHealth {
29    /// Backend binary found and responsive.
30    #[default]
31    Healthy,
32    /// Backend binary found but returning errors (e.g. API issues).
33    Degraded,
34    /// Backend binary not found or not executable.
35    Unreachable,
36    /// Backend quota/billing limit exhausted — agent cannot work until credits are added.
37    QuotaExhausted,
38}
39
40impl BackendHealth {
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Self::Healthy => "healthy",
44            Self::Degraded => "degraded",
45            Self::QuotaExhausted => "quota_exhausted",
46            Self::Unreachable => "unreachable",
47        }
48    }
49
50    pub fn is_healthy(self) -> bool {
51        self == Self::Healthy
52    }
53}
54
55/// Check if a binary is available on PATH.
56fn check_binary_available(program: &str) -> BackendHealth {
57    match Command::new("which").arg(program).output() {
58        Ok(output) if output.status.success() => BackendHealth::Healthy,
59        _ => BackendHealth::Unreachable,
60    }
61}
62
63/// Configuration for spawning an agent process.
64#[derive(Debug, Clone)]
65pub struct SpawnConfig {
66    /// The program to execute (e.g., "claude", "codex", "aider").
67    pub program: String,
68    /// Arguments to pass to the program.
69    pub args: Vec<String>,
70    /// Working directory for the agent process.
71    pub work_dir: String,
72    /// Environment variables to set (key, value pairs).
73    pub env: Vec<(String, String)>,
74}
75
76/// Trait that all agent adapters must implement.
77///
78/// An adapter translates between Batty's supervisor and a specific agent CLI.
79/// It does not own the PTY or process — the supervisor does that. The adapter
80/// only provides the configuration and patterns needed to drive the agent.
81pub trait AgentAdapter: Send + Sync {
82    /// Human-readable name of the agent (e.g., "claude-code", "codex", "aider").
83    fn name(&self) -> &str;
84
85    /// Build the spawn configuration for this agent.
86    ///
87    /// `task_description` is the task text to pass to the agent.
88    /// `work_dir` is the worktree path where the agent should operate.
89    fn spawn_config(&self, task_description: &str, work_dir: &Path) -> SpawnConfig;
90
91    /// Get the compiled prompt detection patterns for this agent.
92    fn prompt_patterns(&self) -> PromptPatterns;
93
94    /// Preferred project-root instruction file candidates for this agent.
95    ///
96    /// The first existing file is used as launch context. Adapters can
97    /// override this to prefer agent-specific steering docs.
98    fn instruction_candidates(&self) -> &'static [&'static str] {
99        &["CLAUDE.md", "AGENTS.md"]
100    }
101
102    /// Allow adapters to wrap or transform the composed launch context.
103    ///
104    /// Default behavior is passthrough. Adapters can prepend guardrails or
105    /// framing tailored to their CLI behavior.
106    fn wrap_launch_prompt(&self, prompt: &str) -> String {
107        prompt.to_string()
108    }
109
110    /// Format a response to send to the agent's stdin.
111    ///
112    /// Some agents need a trailing newline, some don't. The adapter handles it.
113    fn format_input(&self, response: &str) -> String;
114
115    // --- Launch lifecycle methods (Backend trait) ---
116
117    /// Build the shell command to launch this agent.
118    ///
119    /// Returns the `exec <agent> ...` command string that will be written into
120    /// the launch script. Each backend encodes its own CLI flags, resume
121    /// semantics, and idle/prompt handling.
122    fn launch_command(
123        &self,
124        prompt: &str,
125        idle: bool,
126        resume: bool,
127        session_id: Option<&str>,
128    ) -> anyhow::Result<String>;
129
130    /// Generate a new session ID for this backend, if supported.
131    ///
132    /// Backends that support session resume (e.g., Claude Code) return
133    /// `Some(uuid)`. Backends without session management return `None`.
134    fn new_session_id(&self) -> Option<String> {
135        None
136    }
137
138    /// Whether this backend supports resuming a previous session.
139    fn supports_resume(&self) -> bool {
140        false
141    }
142
143    /// Check if this agent's backend is healthy (binary available, etc.).
144    fn health_check(&self) -> BackendHealth {
145        BackendHealth::Healthy
146    }
147}
148
149/// Known agent backend names (primary aliases only).
150pub const KNOWN_AGENT_NAMES: &[&str] = &["claude", "codex", "kiro-cli"];
151
152/// Look up an agent adapter by name.
153///
154/// Returns `None` if the agent name is not recognized. New adapters are
155/// registered here as they're implemented.
156pub fn adapter_from_name(name: &str) -> Option<Box<dyn AgentAdapter>> {
157    match name {
158        "claude" | "claude-code" => Some(Box::new(claude::ClaudeCodeAdapter::new(None))),
159        "codex" | "codex-cli" => Some(Box::new(codex::CodexCliAdapter::new(None))),
160        "kiro" | "kiro-cli" => Some(Box::new(kiro::KiroCliAdapter::new(None))),
161        _ => None,
162    }
163}
164
165/// Check backend health for a named agent.
166///
167/// Returns `None` if the agent name is not recognized.
168pub fn health_check_by_name(agent_name: &str) -> Option<BackendHealth> {
169    adapter_from_name(agent_name).map(|adapter| adapter.health_check())
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    // Verify the trait is object-safe (can be used as dyn AgentAdapter)
177    #[test]
178    fn trait_is_object_safe() {
179        fn _accepts_dyn(_adapter: &dyn AgentAdapter) {}
180        let adapter = claude::ClaudeCodeAdapter::new(None);
181        _accepts_dyn(&adapter);
182    }
183
184    #[test]
185    fn spawn_config_has_work_dir() {
186        let adapter = claude::ClaudeCodeAdapter::new(None);
187        let config = adapter.spawn_config("Fix the bug", Path::new("/tmp/worktree"));
188        assert_eq!(config.work_dir, "/tmp/worktree");
189    }
190
191    #[test]
192    fn spawn_config_includes_task_in_args() {
193        let adapter = claude::ClaudeCodeAdapter::new(None);
194        let config = adapter.spawn_config("Fix the bug", Path::new("/tmp/worktree"));
195        let args_joined = config.args.join(" ");
196        assert!(
197            args_joined.contains("Fix the bug"),
198            "task description should appear in args: {args_joined}"
199        );
200    }
201
202    #[test]
203    fn format_input_appends_newline() {
204        let adapter = claude::ClaudeCodeAdapter::new(None);
205        let input = adapter.format_input("y");
206        assert_eq!(input, "y\n");
207    }
208
209    #[test]
210    fn lookup_adapter_by_name() {
211        let adapter = adapter_from_name("claude").unwrap();
212        assert_eq!(adapter.name(), "claude-code");
213
214        let adapter = adapter_from_name("claude-code").unwrap();
215        assert_eq!(adapter.name(), "claude-code");
216
217        let adapter = adapter_from_name("codex").unwrap();
218        assert_eq!(adapter.name(), "codex-cli");
219
220        let adapter = adapter_from_name("codex-cli").unwrap();
221        assert_eq!(adapter.name(), "codex-cli");
222
223        let adapter = adapter_from_name("kiro").unwrap();
224        assert_eq!(adapter.name(), "kiro-cli");
225
226        let adapter = adapter_from_name("kiro-cli").unwrap();
227        assert_eq!(adapter.name(), "kiro-cli");
228
229        assert!(adapter_from_name("unknown-agent").is_none());
230    }
231
232    #[test]
233    fn default_instruction_candidates_include_claude_and_agents() {
234        let adapter = claude::ClaudeCodeAdapter::new(None);
235        assert_eq!(
236            adapter.instruction_candidates(),
237            &["CLAUDE.md", "AGENTS.md"]
238        );
239    }
240
241    #[test]
242    fn default_wrap_launch_prompt_is_passthrough() {
243        let adapter = claude::ClaudeCodeAdapter::new(None);
244        let prompt = "test launch prompt";
245        assert_eq!(adapter.wrap_launch_prompt(prompt), prompt);
246    }
247
248    // --- Backend trait dispatch tests ---
249
250    #[test]
251    fn launch_command_dispatches_through_trait_object() {
252        let backends: Vec<Box<dyn AgentAdapter>> = vec![
253            Box::new(claude::ClaudeCodeAdapter::new(None)),
254            Box::new(codex::CodexCliAdapter::new(None)),
255            Box::new(kiro::KiroCliAdapter::new(None)),
256        ];
257        for backend in &backends {
258            let cmd = backend.launch_command("test prompt", true, false, None);
259            assert!(cmd.is_ok(), "launch_command failed for {}", backend.name());
260            assert!(
261                !cmd.unwrap().is_empty(),
262                "launch_command empty for {}",
263                backend.name()
264            );
265        }
266    }
267
268    #[test]
269    fn supports_resume_varies_by_backend() {
270        let claude = adapter_from_name("claude").unwrap();
271        let codex = adapter_from_name("codex").unwrap();
272        let kiro = adapter_from_name("kiro").unwrap();
273        assert!(claude.supports_resume());
274        assert!(codex.supports_resume());
275        assert!(kiro.supports_resume()); // ACP supports session/load
276    }
277
278    #[test]
279    fn new_session_id_varies_by_backend() {
280        let claude = adapter_from_name("claude").unwrap();
281        let codex = adapter_from_name("codex").unwrap();
282        let kiro = adapter_from_name("kiro").unwrap();
283        assert!(claude.new_session_id().is_some());
284        assert!(codex.new_session_id().is_none());
285        assert!(kiro.new_session_id().is_some());
286    }
287
288    #[test]
289    fn backend_health_default_is_healthy() {
290        assert_eq!(BackendHealth::default(), BackendHealth::Healthy);
291    }
292
293    #[test]
294    fn backend_health_as_str() {
295        assert_eq!(BackendHealth::Healthy.as_str(), "healthy");
296        assert_eq!(BackendHealth::Degraded.as_str(), "degraded");
297        assert_eq!(BackendHealth::Unreachable.as_str(), "unreachable");
298    }
299
300    #[test]
301    fn backend_health_is_healthy() {
302        assert!(BackendHealth::Healthy.is_healthy());
303        assert!(!BackendHealth::Degraded.is_healthy());
304        assert!(!BackendHealth::Unreachable.is_healthy());
305    }
306
307    #[test]
308    fn health_check_by_name_returns_none_for_unknown() {
309        assert!(health_check_by_name("unknown-agent").is_none());
310    }
311
312    #[test]
313    fn health_check_for_nonexistent_binary_returns_unreachable() {
314        let adapter =
315            claude::ClaudeCodeAdapter::new(Some("/nonexistent/path/to/claude-9999".to_string()));
316        assert_eq!(adapter.health_check(), BackendHealth::Unreachable);
317    }
318
319    #[test]
320    fn check_binary_available_finds_bash() {
321        // bash should always be present on the system
322        assert_eq!(check_binary_available("bash"), BackendHealth::Healthy);
323    }
324
325    #[test]
326    fn check_binary_available_returns_unreachable_for_missing() {
327        assert_eq!(
328            check_binary_available("nonexistent-binary-12345"),
329            BackendHealth::Unreachable,
330        );
331    }
332}