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 delivered_prompt(kind: AgentKind, prompt: &str) -> String {
99 if kind == AgentKind::Claude {
100 let turn: serde_json::Value =
101 serde_json::from_str(&crate::monitor::user_turn_line(prompt))
102 .expect("the stdin user turn must be one valid JSON document");
103 return turn
104 .get("message")
105 .and_then(|message| message.get("content"))
106 .and_then(serde_json::Value::as_str)
107 .expect("the user turn must carry the prompt as message.content")
108 .to_string();
109 }
110 let (_program, args) = adapter_for(kind).exec_command(7, prompt, &[]);
111 args.into_iter()
112 .find(|arg| arg.contains("DEVFLOW_RESULT"))
113 .expect("agent command should carry the prompt with the DEVFLOW_RESULT contract")
114 }
115
116 #[test]
126 fn every_adapter_receives_identical_prompt_text() {
127 let prompt = stage_prompt(Stage::Code, 7);
128 for kind in [AgentKind::Claude, AgentKind::Codex, AgentKind::OpenCode] {
129 assert_eq!(
130 delivered_prompt(kind, &prompt),
131 prompt,
132 "{kind} must receive the canonical stage prompt unchanged"
133 );
134 }
135
136 let (_program, args) = adapter_for(AgentKind::Claude).exec_command(7, &prompt, &[]);
137 assert!(
138 !args.iter().any(|arg| arg.contains("DEVFLOW_RESULT")),
139 "Claude's prompt must travel on stdin ONLY; a copy left in argv \
140 would deliver the initial turn twice: {args:?}"
141 );
142 }
143
144 #[test]
149 fn claude_launches_headless_stream_json_without_positional_prompt() {
150 let prompt = stage_prompt(Stage::Code, 3);
151 let (program, args) = adapter_for(AgentKind::Claude).exec_command(3, &prompt, &[]);
152 assert_eq!(program, "claude");
153 assert!(args.iter().any(|a| a == "-p"));
154 assert!(
155 args.windows(2)
156 .any(|w| w[0] == "--input-format" && w[1] == "stream-json"),
157 "the INPUT format is what moves the initial turn onto stdin; \
158 flipping only the output format leaves the CLI with no first \
159 turn and it stalls headless: {args:?}"
160 );
161 assert!(
162 args.windows(2)
163 .any(|w| w[0] == "--output-format" && w[1] == "stream-json"),
164 "the OUTPUT format is what makes the capture a JSONL event stream \
165 the Layer 1 stream parser can read: {args:?}"
166 );
167 assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
168 assert!(
169 !args.iter().any(|arg| arg.contains("DEVFLOW_RESULT")),
170 "no positional prompt: the initial user turn travels on stdin, \
171 written by the monitor: {args:?}"
172 );
173 }
174
175 #[test]
176 fn codex_wraps_prompt_in_exec_and_json() {
177 let prompt = stage_prompt(Stage::Code, 7);
178 let (program, args) = adapter_for(AgentKind::Codex).exec_command(7, &prompt, &[]);
179 assert_eq!(program, "codex");
180 let joined = args.join(" ");
181 assert!(joined.contains("exec"));
182 assert!(joined.contains("--sandbox workspace-write"));
183 assert!(joined.contains("--json"));
184 }
185
186 #[test]
187 fn opencode_wraps_prompt_in_run() {
188 let prompt = stage_prompt(Stage::Code, 7);
189 let (program, args) = adapter_for(AgentKind::OpenCode).exec_command(7, &prompt, &[]);
190 assert_eq!(program, "opencode");
191 assert_eq!(args, ["run", prompt.as_str()]);
192 }
193
194 #[test]
200 fn codex_grants_writable_roots_for_worktree_git_metadata() {
201 let prompt = stage_prompt(Stage::Code, 7);
202 let roots = vec![
203 PathBuf::from("/repo/.git"),
204 PathBuf::from("/repo/.git/worktrees/phase-07"),
205 ];
206 let (_, args) = adapter_for(AgentKind::Codex).exec_command(7, &prompt, &roots);
207 let joined = args.join(" ");
208 assert!(
209 joined.contains(
210 r#"-c sandbox_workspace_write.writable_roots=["/repo/.git","/repo/.git/worktrees/phase-07"]"#
211 ),
212 "codex must whitelist the common .git AND the worktree admin dir: {joined}"
213 );
214
215 let (_, args) = adapter_for(AgentKind::Codex).exec_command(7, &prompt, &[]);
216 assert!(
217 !args.join(" ").contains("writable_roots"),
218 "no override without an extra root"
219 );
220 }
221
222 #[test]
227 fn codex_disables_signing_via_env_others_do_not() {
228 let env = adapter_for(AgentKind::Codex).extra_env();
229 assert!(env.contains(&("GIT_CONFIG_KEY_0".into(), "commit.gpgsign".into())));
230 assert!(env.contains(&("GIT_CONFIG_KEY_1".into(), "tag.gpgsign".into())));
231 assert!(adapter_for(AgentKind::Claude).extra_env().is_empty());
232 assert!(adapter_for(AgentKind::OpenCode).extra_env().is_empty());
233 }
234
235 #[test]
240 fn default_preflight_is_ok_for_built_in_adapters() {
241 let state = crate::state::State::new(
242 1,
243 AgentKind::Claude,
244 crate::mode::Mode::Auto,
245 PathBuf::from("/repo"),
246 );
247 assert!(adapter_for(AgentKind::Claude).preflight(&state).is_ok());
248 assert!(adapter_for(AgentKind::Codex).preflight(&state).is_ok());
249 assert!(adapter_for(AgentKind::OpenCode).preflight(&state).is_ok());
250 }
251}