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 claude_and_codex_share_identical_prompt_text() {
101 let prompt = stage_prompt(Stage::Code, 7);
102 let claude = prompt_arg(AgentKind::Claude, &prompt);
103 let codex = prompt_arg(AgentKind::Codex, &prompt);
104 assert_eq!(
105 claude, codex,
106 "Claude and Codex must receive identical prompt text"
107 );
108 assert_eq!(claude, prompt);
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]
139 fn codex_grants_writable_roots_for_worktree_git_metadata() {
140 let prompt = stage_prompt(Stage::Code, 7);
141 let roots = vec![
142 PathBuf::from("/repo/.git"),
143 PathBuf::from("/repo/.git/worktrees/phase-07"),
144 ];
145 let (_, args) = adapter_for(AgentKind::Codex).exec_command(7, &prompt, &roots);
146 let joined = args.join(" ");
147 assert!(
148 joined.contains(
149 r#"-c sandbox_workspace_write.writable_roots=["/repo/.git","/repo/.git/worktrees/phase-07"]"#
150 ),
151 "codex must whitelist the common .git AND the worktree admin dir: {joined}"
152 );
153
154 let (_, args) = adapter_for(AgentKind::Codex).exec_command(7, &prompt, &[]);
155 assert!(
156 !args.join(" ").contains("writable_roots"),
157 "no override without an extra root"
158 );
159 }
160
161 #[test]
166 fn codex_disables_signing_via_env_others_do_not() {
167 let env = adapter_for(AgentKind::Codex).extra_env();
168 assert!(env.contains(&("GIT_CONFIG_KEY_0".into(), "commit.gpgsign".into())));
169 assert!(env.contains(&("GIT_CONFIG_KEY_1".into(), "tag.gpgsign".into())));
170 assert!(adapter_for(AgentKind::Claude).extra_env().is_empty());
171 assert!(adapter_for(AgentKind::OpenCode).extra_env().is_empty());
172 }
173
174 #[test]
179 fn default_preflight_is_ok_for_built_in_adapters() {
180 let state = crate::state::State::new(
181 1,
182 AgentKind::Claude,
183 crate::mode::Mode::Auto,
184 PathBuf::from("/repo"),
185 );
186 assert!(adapter_for(AgentKind::Claude).preflight(&state).is_ok());
187 assert!(adapter_for(AgentKind::Codex).preflight(&state).is_ok());
188 assert!(adapter_for(AgentKind::OpenCode).preflight(&state).is_ok());
189 }
190
191 struct ReviewerSetTestAdapter {
196 reviewers: Vec<String>,
197 }
198
199 impl AgentAdapter for ReviewerSetTestAdapter {
200 fn name(&self) -> &'static str {
201 "test-reviewer-set"
202 }
203
204 fn exec_command(
205 &self,
206 _phase: u32,
207 _prompt: &str,
208 _extra_writable_roots: &[PathBuf],
209 ) -> (&'static str, Vec<String>) {
210 ("true", Vec::new())
211 }
212
213 fn completion_signal_detected(&self, _output: &str) -> bool {
214 false
215 }
216
217 fn preflight(&self, _state: &crate::state::State) -> Result<(), String> {
218 if self.reviewers.is_empty() {
219 Err("reviewer receiver set is empty".to_string())
220 } else {
221 Ok(())
222 }
223 }
224 }
225
226 #[test]
229 fn reviewer_set_adapter_hook_adjacency_boundary() {
230 let state = crate::state::State::new(
231 1,
232 AgentKind::Claude,
233 crate::mode::Mode::Auto,
234 PathBuf::from("/repo"),
235 );
236
237 let empty = ReviewerSetTestAdapter {
238 reviewers: Vec::new(),
239 };
240 assert!(empty.preflight(&state).is_err());
241
242 let one = ReviewerSetTestAdapter {
243 reviewers: vec!["alice".to_string()],
244 };
245 assert!(one.preflight(&state).is_ok());
246 }
247}