devflow_core/agents/
mod.rs1use crate::phase_id::PhaseId;
8use crate::state::AgentKind;
9use std::path::PathBuf;
10
11pub trait AgentAdapter {
13 fn name(&self) -> &'static str;
15
16 fn exec_command(
28 &self,
29 phase: PhaseId,
30 prompt: &str,
31 extra_writable_roots: &[PathBuf],
32 ) -> (&'static str, Vec<String>);
33
34 fn extra_env(&self) -> Vec<(String, String)> {
41 Vec::new()
42 }
43
44 fn completion_signal_detected(&self, output: &str) -> bool;
46
47 fn preflight(&self, _state: &crate::state::State) -> Result<(), String> {
58 Ok(())
59 }
60}
61
62pub fn adapter_for(kind: AgentKind) -> Box<dyn AgentAdapter> {
64 match kind {
65 AgentKind::Claude => Box::new(ClaudeAgent),
66 AgentKind::Codex => Box::new(CodexAgent),
67 AgentKind::OpenCode => Box::new(OpenCodeAgent),
68 }
69}
70
71pub mod claude;
72pub mod codex;
73pub mod opencode;
74
75pub use claude::ClaudeAgent;
76pub use codex::CodexAgent;
77pub use opencode::OpenCodeAgent;
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use crate::prompt::stage_prompt;
83 use crate::stage::Stage;
84
85 #[test]
86 fn adapter_for_returns_correct_names() {
87 assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
88 assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
89 assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
90 }
91
92 fn delivered_prompt(kind: AgentKind, prompt: &str) -> String {
100 if kind == AgentKind::Claude {
101 let turn: serde_json::Value =
102 serde_json::from_str(&crate::monitor::user_turn_line(prompt))
103 .expect("the stdin user turn must be one valid JSON document");
104 return turn
105 .get("message")
106 .and_then(|message| message.get("content"))
107 .and_then(serde_json::Value::as_str)
108 .expect("the user turn must carry the prompt as message.content")
109 .to_string();
110 }
111 let (_program, args) = adapter_for(kind).exec_command(PhaseId::new(7), prompt, &[]);
112 args.into_iter()
113 .find(|arg| arg.contains("DEVFLOW_RESULT"))
114 .expect("agent command should carry the prompt with the DEVFLOW_RESULT contract")
115 }
116
117 #[test]
127 fn every_adapter_receives_identical_prompt_text() {
128 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
129 for kind in [AgentKind::Claude, AgentKind::Codex, AgentKind::OpenCode] {
130 assert_eq!(
131 delivered_prompt(kind, &prompt),
132 prompt,
133 "{kind} must receive the canonical stage prompt unchanged"
134 );
135 }
136
137 let (_program, args) =
138 adapter_for(AgentKind::Claude).exec_command(PhaseId::new(7), &prompt, &[]);
139 assert!(
140 !args.iter().any(|arg| arg.contains("DEVFLOW_RESULT")),
141 "Claude's prompt must travel on stdin ONLY; a copy left in argv \
142 would deliver the initial turn twice: {args:?}"
143 );
144 }
145
146 #[test]
151 fn claude_launches_headless_stream_json_without_positional_prompt() {
152 let prompt = stage_prompt(Stage::Code, PhaseId::new(3));
153 let (program, args) =
154 adapter_for(AgentKind::Claude).exec_command(PhaseId::new(3), &prompt, &[]);
155 assert_eq!(program, "claude");
156 assert!(args.iter().any(|a| a == "-p"));
157 assert!(
158 args.windows(2)
159 .any(|w| w[0] == "--input-format" && w[1] == "stream-json"),
160 "the INPUT format is what moves the initial turn onto stdin; \
161 flipping only the output format leaves the CLI with no first \
162 turn and it stalls headless: {args:?}"
163 );
164 assert!(
165 args.windows(2)
166 .any(|w| w[0] == "--output-format" && w[1] == "stream-json"),
167 "the OUTPUT format is what makes the capture a JSONL event stream \
168 the Layer 1 stream parser can read: {args:?}"
169 );
170 assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
171 assert!(
172 !args.iter().any(|arg| arg.contains("DEVFLOW_RESULT")),
173 "no positional prompt: the initial user turn travels on stdin, \
174 written by the monitor: {args:?}"
175 );
176 }
177
178 #[test]
179 fn codex_wraps_prompt_in_exec_and_json() {
180 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
181 let (program, args) =
182 adapter_for(AgentKind::Codex).exec_command(PhaseId::new(7), &prompt, &[]);
183 assert_eq!(program, "codex");
184 let joined = args.join(" ");
185 assert!(joined.contains("exec"));
186 assert!(joined.contains("--sandbox workspace-write"));
187 assert!(joined.contains("--json"));
188 }
189
190 #[test]
191 fn opencode_wraps_prompt_in_run() {
192 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
193 let (program, args) =
194 adapter_for(AgentKind::OpenCode).exec_command(PhaseId::new(7), &prompt, &[]);
195 assert_eq!(program, "opencode");
196 assert_eq!(args, ["run", prompt.as_str()]);
197 }
198
199 #[test]
205 fn codex_grants_writable_roots_for_worktree_git_metadata() {
206 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
207 let roots = vec![
208 PathBuf::from("/repo/.git"),
209 PathBuf::from("/repo/.git/worktrees/phase-07"),
210 ];
211 let (_, args) =
212 adapter_for(AgentKind::Codex).exec_command(PhaseId::new(7), &prompt, &roots);
213 let joined = args.join(" ");
214 assert!(
215 joined.contains(
216 r#"-c sandbox_workspace_write.writable_roots=["/repo/.git","/repo/.git/worktrees/phase-07"]"#
217 ),
218 "codex must whitelist the common .git AND the worktree admin dir: {joined}"
219 );
220
221 let (_, args) = adapter_for(AgentKind::Codex).exec_command(PhaseId::new(7), &prompt, &[]);
222 assert!(
223 !args.join(" ").contains("writable_roots"),
224 "no override without an extra root"
225 );
226 }
227
228 #[test]
233 fn codex_disables_signing_via_env_others_do_not() {
234 let env = adapter_for(AgentKind::Codex).extra_env();
235 assert!(env.contains(&("GIT_CONFIG_KEY_0".into(), "commit.gpgsign".into())));
236 assert!(env.contains(&("GIT_CONFIG_KEY_1".into(), "tag.gpgsign".into())));
237 assert!(adapter_for(AgentKind::Claude).extra_env().is_empty());
238 assert!(adapter_for(AgentKind::OpenCode).extra_env().is_empty());
239 }
240
241 #[test]
246 fn default_preflight_is_ok_for_built_in_adapters() {
247 let state = crate::state::State::new(
248 PhaseId::new(1),
249 AgentKind::Claude,
250 crate::mode::Mode::Auto,
251 PathBuf::from("/repo"),
252 );
253 assert!(adapter_for(AgentKind::Claude).preflight(&state).is_ok());
254 assert!(adapter_for(AgentKind::Codex).preflight(&state).is_ok());
255 assert!(adapter_for(AgentKind::OpenCode).preflight(&state).is_ok());
256 }
257}