devflow_core/agents/
mod.rs1use crate::state::AgentKind;
8use std::path::PathBuf;
9
10pub trait AgentAdapter {
12 fn name(&self) -> &'static str;
14
15 fn exec_command(
27 &self,
28 phase: u32,
29 prompt: &str,
30 extra_writable_roots: &[PathBuf],
31 ) -> (&'static str, Vec<String>);
32
33 fn extra_env(&self) -> Vec<(String, String)> {
40 Vec::new()
41 }
42
43 fn completion_signal_detected(&self, output: &str) -> bool;
45
46 fn preflight(&self, _state: &crate::state::State) -> Result<(), String> {
57 Ok(())
58 }
59}
60
61pub fn adapter_for(kind: AgentKind) -> Box<dyn AgentAdapter> {
63 match kind {
64 AgentKind::Claude => Box::new(ClaudeAgent),
65 AgentKind::Codex => Box::new(CodexAgent),
66 AgentKind::OpenCode => Box::new(OpenCodeAgent),
67 }
68}
69
70pub mod claude;
71pub mod codex;
72pub mod opencode;
73
74pub use claude::ClaudeAgent;
75pub use codex::CodexAgent;
76pub use opencode::OpenCodeAgent;
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81 use crate::prompt::stage_prompt;
82 use crate::stage::Stage;
83
84 #[test]
85 fn adapter_for_returns_correct_names() {
86 assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
87 assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
88 assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
89 }
90
91 fn prompt_arg(kind: AgentKind, prompt: &str) -> String {
93 let (_program, args) = adapter_for(kind).exec_command(7, prompt, &[]);
94 args.into_iter()
95 .find(|arg| arg.contains("DEVFLOW_RESULT"))
96 .expect("agent command should carry the prompt with the DEVFLOW_RESULT contract")
97 }
98
99 #[test]
100 fn every_adapter_receives_identical_prompt_text() {
101 let prompt = stage_prompt(Stage::Code, 7);
102 for kind in [AgentKind::Claude, AgentKind::Codex, AgentKind::OpenCode] {
103 assert_eq!(
104 prompt_arg(kind, &prompt),
105 prompt,
106 "{kind} must receive the canonical stage prompt unchanged"
107 );
108 }
109 }
110
111 #[test]
112 fn claude_wraps_prompt_in_noninteractive_flags() {
113 let prompt = stage_prompt(Stage::Code, 3);
114 let (program, args) = adapter_for(AgentKind::Claude).exec_command(3, &prompt, &[]);
115 assert_eq!(program, "claude");
116 let joined = args.join(" ");
117 assert!(joined.contains("-p"));
118 assert!(joined.contains("--output-format json"));
119 assert!(joined.contains("--dangerously-skip-permissions"));
120 }
121
122 #[test]
123 fn codex_wraps_prompt_in_exec_and_json() {
124 let prompt = stage_prompt(Stage::Code, 7);
125 let (program, args) = adapter_for(AgentKind::Codex).exec_command(7, &prompt, &[]);
126 assert_eq!(program, "codex");
127 let joined = args.join(" ");
128 assert!(joined.contains("exec"));
129 assert!(joined.contains("--sandbox workspace-write"));
130 assert!(joined.contains("--json"));
131 }
132
133 #[test]
134 fn opencode_wraps_prompt_in_run() {
135 let prompt = stage_prompt(Stage::Code, 7);
136 let (program, args) = adapter_for(AgentKind::OpenCode).exec_command(7, &prompt, &[]);
137 assert_eq!(program, "opencode");
138 assert_eq!(args, ["run", prompt.as_str()]);
139 }
140
141 #[test]
147 fn codex_grants_writable_roots_for_worktree_git_metadata() {
148 let prompt = stage_prompt(Stage::Code, 7);
149 let roots = vec![
150 PathBuf::from("/repo/.git"),
151 PathBuf::from("/repo/.git/worktrees/phase-07"),
152 ];
153 let (_, args) = adapter_for(AgentKind::Codex).exec_command(7, &prompt, &roots);
154 let joined = args.join(" ");
155 assert!(
156 joined.contains(
157 r#"-c sandbox_workspace_write.writable_roots=["/repo/.git","/repo/.git/worktrees/phase-07"]"#
158 ),
159 "codex must whitelist the common .git AND the worktree admin dir: {joined}"
160 );
161
162 let (_, args) = adapter_for(AgentKind::Codex).exec_command(7, &prompt, &[]);
163 assert!(
164 !args.join(" ").contains("writable_roots"),
165 "no override without an extra root"
166 );
167 }
168
169 #[test]
174 fn codex_disables_signing_via_env_others_do_not() {
175 let env = adapter_for(AgentKind::Codex).extra_env();
176 assert!(env.contains(&("GIT_CONFIG_KEY_0".into(), "commit.gpgsign".into())));
177 assert!(env.contains(&("GIT_CONFIG_KEY_1".into(), "tag.gpgsign".into())));
178 assert!(adapter_for(AgentKind::Claude).extra_env().is_empty());
179 assert!(adapter_for(AgentKind::OpenCode).extra_env().is_empty());
180 }
181
182 #[test]
187 fn default_preflight_is_ok_for_built_in_adapters() {
188 let state = crate::state::State::new(
189 1,
190 AgentKind::Claude,
191 crate::mode::Mode::Auto,
192 PathBuf::from("/repo"),
193 );
194 assert!(adapter_for(AgentKind::Claude).preflight(&state).is_ok());
195 assert!(adapter_for(AgentKind::Codex).preflight(&state).is_ok());
196 assert!(adapter_for(AgentKind::OpenCode).preflight(&state).is_ok());
197 }
198}