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 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
55fn 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#[derive(Debug, Clone)]
65pub struct SpawnConfig {
66 pub program: String,
68 pub args: Vec<String>,
70 pub work_dir: String,
72 pub env: Vec<(String, String)>,
74}
75
76pub trait AgentAdapter: Send + Sync {
82 fn name(&self) -> &str;
84
85 fn spawn_config(&self, task_description: &str, work_dir: &Path) -> SpawnConfig;
90
91 fn prompt_patterns(&self) -> PromptPatterns;
93
94 fn instruction_candidates(&self) -> &'static [&'static str] {
99 &["CLAUDE.md", "AGENTS.md"]
100 }
101
102 fn wrap_launch_prompt(&self, prompt: &str) -> String {
107 prompt.to_string()
108 }
109
110 fn format_input(&self, response: &str) -> String;
114
115 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 fn new_session_id(&self) -> Option<String> {
135 None
136 }
137
138 fn supports_resume(&self) -> bool {
140 false
141 }
142
143 fn health_check(&self) -> BackendHealth {
145 BackendHealth::Healthy
146 }
147}
148
149pub const KNOWN_AGENT_NAMES: &[&str] = &["claude", "codex", "kiro-cli"];
151
152pub 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
165pub 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 #[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 #[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()); }
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 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}