1#![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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum BackendHealth {
29 #[default]
31 Healthy,
32 Degraded,
34 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
52fn 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#[derive(Debug, Clone)]
62pub struct SpawnConfig {
63 pub program: String,
65 pub args: Vec<String>,
67 pub work_dir: String,
69 pub env: Vec<(String, String)>,
71}
72
73pub trait AgentAdapter: Send + Sync {
79 fn name(&self) -> &str;
81
82 fn spawn_config(&self, task_description: &str, work_dir: &Path) -> SpawnConfig;
87
88 fn prompt_patterns(&self) -> PromptPatterns;
90
91 fn instruction_candidates(&self) -> &'static [&'static str] {
96 &["CLAUDE.md", "AGENTS.md"]
97 }
98
99 fn wrap_launch_prompt(&self, prompt: &str) -> String {
104 prompt.to_string()
105 }
106
107 fn format_input(&self, response: &str) -> String;
111
112 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 fn new_session_id(&self) -> Option<String> {
132 None
133 }
134
135 fn supports_resume(&self) -> bool {
137 false
138 }
139
140 fn health_check(&self) -> BackendHealth {
142 BackendHealth::Healthy
143 }
144}
145
146pub const KNOWN_AGENT_NAMES: &[&str] = &["claude", "codex", "kiro-cli"];
148
149pub 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
162pub 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 #[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 #[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()); }
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 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}