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
47pub fn adapter_for(kind: AgentKind) -> Box<dyn AgentAdapter> {
49 match kind {
50 AgentKind::Claude => Box::new(ClaudeAgent),
51 AgentKind::Codex => Box::new(CodexAgent),
52 AgentKind::OpenCode => Box::new(OpenCodeAgent),
53 }
54}
55
56pub mod claude;
57pub mod codex;
58pub mod opencode;
59
60pub use claude::ClaudeAgent;
61pub use codex::CodexAgent;
62pub use opencode::OpenCodeAgent;
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67 use crate::prompt::stage_prompt;
68 use crate::stage::Stage;
69
70 #[test]
71 fn adapter_for_returns_correct_names() {
72 assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
73 assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
74 assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
75 }
76
77 fn prompt_arg(kind: AgentKind, prompt: &str) -> String {
79 let (_program, args) = adapter_for(kind).exec_command(7, prompt, &[]);
80 args.into_iter()
81 .find(|arg| arg.contains("DEVFLOW_RESULT"))
82 .expect("agent command should carry the prompt with the DEVFLOW_RESULT contract")
83 }
84
85 #[test]
86 fn claude_and_codex_share_identical_prompt_text() {
87 let prompt = stage_prompt(Stage::Code, 7);
88 let claude = prompt_arg(AgentKind::Claude, &prompt);
89 let codex = prompt_arg(AgentKind::Codex, &prompt);
90 assert_eq!(
91 claude, codex,
92 "Claude and Codex must receive identical prompt text"
93 );
94 assert_eq!(claude, prompt);
95 }
96
97 #[test]
98 fn claude_wraps_prompt_in_noninteractive_flags() {
99 let prompt = stage_prompt(Stage::Code, 3);
100 let (program, args) = adapter_for(AgentKind::Claude).exec_command(3, &prompt, &[]);
101 assert_eq!(program, "claude");
102 let joined = args.join(" ");
103 assert!(joined.contains("-p"));
104 assert!(joined.contains("--output-format json"));
105 assert!(joined.contains("--dangerously-skip-permissions"));
106 }
107
108 #[test]
109 fn codex_wraps_prompt_in_exec_and_json() {
110 let prompt = stage_prompt(Stage::Code, 7);
111 let (program, args) = adapter_for(AgentKind::Codex).exec_command(7, &prompt, &[]);
112 assert_eq!(program, "codex");
113 let joined = args.join(" ");
114 assert!(joined.contains("exec"));
115 assert!(joined.contains("--sandbox workspace-write"));
116 assert!(joined.contains("--json"));
117 }
118
119 #[test]
125 fn codex_grants_writable_roots_for_worktree_git_metadata() {
126 let prompt = stage_prompt(Stage::Code, 7);
127 let roots = vec![
128 PathBuf::from("/repo/.git"),
129 PathBuf::from("/repo/.git/worktrees/phase-07"),
130 ];
131 let (_, args) = adapter_for(AgentKind::Codex).exec_command(7, &prompt, &roots);
132 let joined = args.join(" ");
133 assert!(
134 joined.contains(
135 r#"-c sandbox_workspace_write.writable_roots=["/repo/.git","/repo/.git/worktrees/phase-07"]"#
136 ),
137 "codex must whitelist the common .git AND the worktree admin dir: {joined}"
138 );
139
140 let (_, args) = adapter_for(AgentKind::Codex).exec_command(7, &prompt, &[]);
141 assert!(
142 !args.join(" ").contains("writable_roots"),
143 "no override without an extra root"
144 );
145 }
146
147 #[test]
152 fn codex_disables_signing_via_env_others_do_not() {
153 let env = adapter_for(AgentKind::Codex).extra_env();
154 assert!(env.contains(&("GIT_CONFIG_KEY_0".into(), "commit.gpgsign".into())));
155 assert!(env.contains(&("GIT_CONFIG_KEY_1".into(), "tag.gpgsign".into())));
156 assert!(adapter_for(AgentKind::Claude).extra_env().is_empty());
157 assert!(adapter_for(AgentKind::OpenCode).extra_env().is_empty());
158 }
159}