#![cfg_attr(not(test), allow(dead_code))]
pub mod claude;
pub mod codex;
pub mod kiro;
pub mod mock;
use std::path::Path;
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::prompt::PromptPatterns;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BackendHealth {
#[default]
Healthy,
Degraded,
Unreachable,
QuotaExhausted,
AuthRequired,
}
impl BackendHealth {
pub fn as_str(self) -> &'static str {
match self {
Self::Healthy => "healthy",
Self::Degraded => "degraded",
Self::QuotaExhausted => "quota_exhausted",
Self::AuthRequired => "auth_required",
Self::Unreachable => "unreachable",
}
}
pub fn is_healthy(self) -> bool {
self == Self::Healthy
}
}
fn check_binary_available(program: &str) -> BackendHealth {
match Command::new("which").arg(program).output() {
Ok(output) if output.status.success() => BackendHealth::Healthy,
_ => BackendHealth::Unreachable,
}
}
#[derive(Debug, Clone)]
pub struct SpawnConfig {
pub program: String,
pub args: Vec<String>,
pub work_dir: String,
pub env: Vec<(String, String)>,
}
pub trait AgentAdapter: Send + Sync {
fn name(&self) -> &str;
fn spawn_config(&self, task_description: &str, work_dir: &Path) -> SpawnConfig;
fn prompt_patterns(&self) -> PromptPatterns;
fn instruction_candidates(&self) -> &'static [&'static str] {
&["CLAUDE.md", "AGENTS.md"]
}
fn wrap_launch_prompt(&self, prompt: &str) -> String {
prompt.to_string()
}
fn format_input(&self, response: &str) -> String;
fn launch_command(
&self,
prompt: &str,
idle: bool,
resume: bool,
session_id: Option<&str>,
) -> anyhow::Result<String>;
fn new_session_id(&self) -> Option<String> {
None
}
fn supports_resume(&self) -> bool {
false
}
fn health_check(&self) -> BackendHealth {
BackendHealth::Healthy
}
}
pub const KNOWN_AGENT_NAMES: &[&str] = &["claude", "codex", "kiro-cli"];
pub fn adapter_from_name(name: &str) -> Option<Box<dyn AgentAdapter>> {
match name {
"claude" | "claude-code" => Some(Box::new(claude::ClaudeCodeAdapter::new(None))),
"codex" | "codex-cli" => Some(Box::new(codex::CodexCliAdapter::new(None))),
"kiro" | "kiro-cli" => Some(Box::new(kiro::KiroCliAdapter::new(None))),
_ => None,
}
}
pub fn health_check_by_name(agent_name: &str) -> Option<BackendHealth> {
adapter_from_name(agent_name).map(|adapter| adapter.health_check())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trait_is_object_safe() {
fn _accepts_dyn(_adapter: &dyn AgentAdapter) {}
let adapter = claude::ClaudeCodeAdapter::new(None);
_accepts_dyn(&adapter);
}
#[test]
fn spawn_config_has_work_dir() {
let adapter = claude::ClaudeCodeAdapter::new(None);
let config = adapter.spawn_config("Fix the bug", Path::new("/tmp/worktree"));
assert_eq!(config.work_dir, "/tmp/worktree");
}
#[test]
fn spawn_config_includes_task_in_args() {
let adapter = claude::ClaudeCodeAdapter::new(None);
let config = adapter.spawn_config("Fix the bug", Path::new("/tmp/worktree"));
let args_joined = config.args.join(" ");
assert!(
args_joined.contains("Fix the bug"),
"task description should appear in args: {args_joined}"
);
}
#[test]
fn format_input_appends_newline() {
let adapter = claude::ClaudeCodeAdapter::new(None);
let input = adapter.format_input("y");
assert_eq!(input, "y\n");
}
#[test]
fn lookup_adapter_by_name() {
let adapter = adapter_from_name("claude").unwrap();
assert_eq!(adapter.name(), "claude-code");
let adapter = adapter_from_name("claude-code").unwrap();
assert_eq!(adapter.name(), "claude-code");
let adapter = adapter_from_name("codex").unwrap();
assert_eq!(adapter.name(), "codex-cli");
let adapter = adapter_from_name("codex-cli").unwrap();
assert_eq!(adapter.name(), "codex-cli");
let adapter = adapter_from_name("kiro").unwrap();
assert_eq!(adapter.name(), "kiro-cli");
let adapter = adapter_from_name("kiro-cli").unwrap();
assert_eq!(adapter.name(), "kiro-cli");
assert!(adapter_from_name("unknown-agent").is_none());
}
#[test]
fn default_instruction_candidates_include_claude_and_agents() {
let adapter = claude::ClaudeCodeAdapter::new(None);
assert_eq!(
adapter.instruction_candidates(),
&["CLAUDE.md", "AGENTS.md"]
);
}
#[test]
fn default_wrap_launch_prompt_is_passthrough() {
let adapter = claude::ClaudeCodeAdapter::new(None);
let prompt = "test launch prompt";
assert_eq!(adapter.wrap_launch_prompt(prompt), prompt);
}
#[test]
fn launch_command_dispatches_through_trait_object() {
let backends: Vec<Box<dyn AgentAdapter>> = vec![
Box::new(claude::ClaudeCodeAdapter::new(None)),
Box::new(codex::CodexCliAdapter::new(None)),
Box::new(kiro::KiroCliAdapter::new(None)),
];
for backend in &backends {
let cmd = backend.launch_command("test prompt", true, false, None);
assert!(cmd.is_ok(), "launch_command failed for {}", backend.name());
assert!(
!cmd.unwrap().is_empty(),
"launch_command empty for {}",
backend.name()
);
}
}
#[test]
fn supports_resume_varies_by_backend() {
let claude = adapter_from_name("claude").unwrap();
let codex = adapter_from_name("codex").unwrap();
let kiro = adapter_from_name("kiro").unwrap();
assert!(claude.supports_resume());
assert!(codex.supports_resume());
assert!(kiro.supports_resume()); }
#[test]
fn new_session_id_varies_by_backend() {
let claude = adapter_from_name("claude").unwrap();
let codex = adapter_from_name("codex").unwrap();
let kiro = adapter_from_name("kiro").unwrap();
assert!(claude.new_session_id().is_some());
assert!(codex.new_session_id().is_none());
assert!(kiro.new_session_id().is_some());
}
#[test]
fn backend_health_default_is_healthy() {
assert_eq!(BackendHealth::default(), BackendHealth::Healthy);
}
#[test]
fn backend_health_as_str() {
assert_eq!(BackendHealth::Healthy.as_str(), "healthy");
assert_eq!(BackendHealth::Degraded.as_str(), "degraded");
assert_eq!(BackendHealth::Unreachable.as_str(), "unreachable");
}
#[test]
fn backend_health_is_healthy() {
assert!(BackendHealth::Healthy.is_healthy());
assert!(!BackendHealth::Degraded.is_healthy());
assert!(!BackendHealth::Unreachable.is_healthy());
}
#[test]
fn health_check_by_name_returns_none_for_unknown() {
assert!(health_check_by_name("unknown-agent").is_none());
}
#[test]
fn health_check_for_nonexistent_binary_returns_unreachable() {
let adapter =
claude::ClaudeCodeAdapter::new(Some("/nonexistent/path/to/claude-9999".to_string()));
assert_eq!(adapter.health_check(), BackendHealth::Unreachable);
}
#[test]
fn check_binary_available_finds_bash() {
assert_eq!(check_binary_available("bash"), BackendHealth::Healthy);
}
#[test]
fn check_binary_available_returns_unreachable_for_missing() {
assert_eq!(
check_binary_available("nonexistent-binary-12345"),
BackendHealth::Unreachable,
);
}
}