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