use crate::state::AgentKind;
use std::path::PathBuf;
pub trait AgentAdapter {
fn name(&self) -> &'static str;
fn exec_command(
&self,
phase: u32,
prompt: &str,
extra_writable_roots: &[PathBuf],
) -> (&'static str, Vec<String>);
fn extra_env(&self) -> Vec<(String, String)> {
Vec::new()
}
fn completion_signal_detected(&self, output: &str) -> bool;
fn preflight(&self, _state: &crate::state::State) -> Result<(), String> {
Ok(())
}
}
pub fn adapter_for(kind: AgentKind) -> Box<dyn AgentAdapter> {
match kind {
AgentKind::Claude => Box::new(ClaudeAgent),
AgentKind::Codex => Box::new(CodexAgent),
AgentKind::OpenCode => Box::new(OpenCodeAgent),
}
}
pub mod claude;
pub mod codex;
pub mod opencode;
pub use claude::ClaudeAgent;
pub use codex::CodexAgent;
pub use opencode::OpenCodeAgent;
#[cfg(test)]
mod tests {
use super::*;
use crate::prompt::stage_prompt;
use crate::stage::Stage;
#[test]
fn adapter_for_returns_correct_names() {
assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
}
fn prompt_arg(kind: AgentKind, prompt: &str) -> String {
let (_program, args) = adapter_for(kind).exec_command(7, prompt, &[]);
args.into_iter()
.find(|arg| arg.contains("DEVFLOW_RESULT"))
.expect("agent command should carry the prompt with the DEVFLOW_RESULT contract")
}
#[test]
fn claude_and_codex_share_identical_prompt_text() {
let prompt = stage_prompt(Stage::Code, 7);
let claude = prompt_arg(AgentKind::Claude, &prompt);
let codex = prompt_arg(AgentKind::Codex, &prompt);
assert_eq!(
claude, codex,
"Claude and Codex must receive identical prompt text"
);
assert_eq!(claude, prompt);
}
#[test]
fn claude_wraps_prompt_in_noninteractive_flags() {
let prompt = stage_prompt(Stage::Code, 3);
let (program, args) = adapter_for(AgentKind::Claude).exec_command(3, &prompt, &[]);
assert_eq!(program, "claude");
let joined = args.join(" ");
assert!(joined.contains("-p"));
assert!(joined.contains("--output-format json"));
assert!(joined.contains("--dangerously-skip-permissions"));
}
#[test]
fn codex_wraps_prompt_in_exec_and_json() {
let prompt = stage_prompt(Stage::Code, 7);
let (program, args) = adapter_for(AgentKind::Codex).exec_command(7, &prompt, &[]);
assert_eq!(program, "codex");
let joined = args.join(" ");
assert!(joined.contains("exec"));
assert!(joined.contains("--sandbox workspace-write"));
assert!(joined.contains("--json"));
}
#[test]
fn codex_grants_writable_roots_for_worktree_git_metadata() {
let prompt = stage_prompt(Stage::Code, 7);
let roots = vec![
PathBuf::from("/repo/.git"),
PathBuf::from("/repo/.git/worktrees/phase-07"),
];
let (_, args) = adapter_for(AgentKind::Codex).exec_command(7, &prompt, &roots);
let joined = args.join(" ");
assert!(
joined.contains(
r#"-c sandbox_workspace_write.writable_roots=["/repo/.git","/repo/.git/worktrees/phase-07"]"#
),
"codex must whitelist the common .git AND the worktree admin dir: {joined}"
);
let (_, args) = adapter_for(AgentKind::Codex).exec_command(7, &prompt, &[]);
assert!(
!args.join(" ").contains("writable_roots"),
"no override without an extra root"
);
}
#[test]
fn codex_disables_signing_via_env_others_do_not() {
let env = adapter_for(AgentKind::Codex).extra_env();
assert!(env.contains(&("GIT_CONFIG_KEY_0".into(), "commit.gpgsign".into())));
assert!(env.contains(&("GIT_CONFIG_KEY_1".into(), "tag.gpgsign".into())));
assert!(adapter_for(AgentKind::Claude).extra_env().is_empty());
assert!(adapter_for(AgentKind::OpenCode).extra_env().is_empty());
}
#[test]
fn default_preflight_is_ok_for_built_in_adapters() {
let state = crate::state::State::new(
1,
AgentKind::Claude,
crate::mode::Mode::Auto,
PathBuf::from("/repo"),
);
assert!(adapter_for(AgentKind::Claude).preflight(&state).is_ok());
assert!(adapter_for(AgentKind::Codex).preflight(&state).is_ok());
assert!(adapter_for(AgentKind::OpenCode).preflight(&state).is_ok());
}
struct ReviewerSetTestAdapter {
reviewers: Vec<String>,
}
impl AgentAdapter for ReviewerSetTestAdapter {
fn name(&self) -> &'static str {
"test-reviewer-set"
}
fn exec_command(
&self,
_phase: u32,
_prompt: &str,
_extra_writable_roots: &[PathBuf],
) -> (&'static str, Vec<String>) {
("true", Vec::new())
}
fn completion_signal_detected(&self, _output: &str) -> bool {
false
}
fn preflight(&self, _state: &crate::state::State) -> Result<(), String> {
if self.reviewers.is_empty() {
Err("reviewer receiver set is empty".to_string())
} else {
Ok(())
}
}
}
#[test]
fn reviewer_set_adapter_hook_adjacency_boundary() {
let state = crate::state::State::new(
1,
AgentKind::Claude,
crate::mode::Mode::Auto,
PathBuf::from("/repo"),
);
let empty = ReviewerSetTestAdapter {
reviewers: Vec::new(),
};
assert!(empty.preflight(&state).is_err());
let one = ReviewerSetTestAdapter {
reviewers: vec!["alice".to_string()],
};
assert!(one.preflight(&state).is_ok());
}
}