Skip to main content

devflow_core/agents/
mod.rs

1//! Agent adapter trait and implementations.
2//!
3//! Each adapter knows how to wrap a stage prompt into its CLI's non-interactive
4//! launch command. The prompt text itself comes from [`crate::prompt`] — the
5//! adapter only formats it into the right flags for its agent.
6
7use crate::state::AgentKind;
8use std::path::PathBuf;
9
10/// Common behavior implemented by every supported coding-agent backend.
11pub trait AgentAdapter {
12    /// Human-readable adapter name.
13    fn name(&self) -> &'static str;
14
15    /// Build the command and arguments to launch this agent headless with the
16    /// given `prompt` for `phase`. Returns `(program, args)`.
17    ///
18    /// `extra_writable_roots` are directories OUTSIDE the agent's working
19    /// directory that its sandbox must still be allowed to write. Linked git
20    /// worktrees keep their git metadata under the main repo's `.git/` — and
21    /// Codex additionally read-only-mounts the cwd's resolved git dir, so
22    /// BOTH the common `.git` and the worktree admin dir
23    /// (`.git/worktrees/<name>`) must be granted explicitly (13-06 dogfood
24    /// finding, verified with `codex sandbox` probes). Adapters without a
25    /// sandbox ignore it.
26    fn exec_command(
27        &self,
28        phase: u32,
29        prompt: &str,
30        extra_writable_roots: &[PathBuf],
31    ) -> (&'static str, Vec<String>);
32
33    /// Extra environment variables for the agent process tree. Codex uses
34    /// this to disable commit/tag signing inside its sandbox: the operator's
35    /// signing agent (ssh-agent/gpg-agent) is unreachable there, so signed
36    /// commits fail headless with a passphrase error (13-06 dogfood finding
37    /// — same rationale as the unsigned VersionBump tags). `GIT_CONFIG_*`
38    /// env scoping keeps the override out of every repo/global config.
39    fn extra_env(&self) -> Vec<(String, String)> {
40        Vec::new()
41    }
42
43    /// Detect an agent-specific completion signal in captured output.
44    fn completion_signal_detected(&self, output: &str) -> bool;
45
46    /// Adapter-specific pre-launch readiness check (D-13/D-14 adapter hook,
47    /// Phase 17c). The default is a no-op — most adapters have nothing extra
48    /// to check, mirroring [`Self::extra_env`]'s empty-default shape. The
49    /// `Err` variant is a human-readable failure reason that flows into the
50    /// preflight gate's context (`run_preflight` in `devflow-cli/src/main.rs`).
51    /// This is the trait surface Phase 18's Hermes adapter implements to
52    /// enforce a non-empty reviewer/receiver set — no built-in adapter
53    /// (Claude/Codex/OpenCode) overrides it in Phase 17 because no
54    /// reviewer-set storage exists yet in `state.rs`/`config.rs` (review
55    /// consensus #6).
56    fn preflight(&self, _state: &crate::state::State) -> Result<(), String> {
57        Ok(())
58    }
59}
60
61/// Return an adapter for a configured agent kind.
62pub 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    /// Extract the prompt argument carrying the instruction text.
92    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    /// 13-06 dogfood regression (Codex leg): linked-worktree git metadata
134    /// lives under the main repo's `.git/` — outside the workspace-write
135    /// sandbox — and Codex read-only-mounts the cwd's resolved git dir, so
136    /// BOTH the common `.git` and the worktree admin dir must be granted
137    /// (verified with `codex sandbox` probes). Without roots, no override.
138    #[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    /// 13-06 dogfood regression: signed commits fail inside the Codex
162    /// sandbox (no route to the operator's signing agent) — codex scopes an
163    /// unsigned-commit override to its own process tree via GIT_CONFIG_*
164    /// env; agents without a sandbox get no extra env.
165    #[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    /// D-13: `preflight`'s default body is `Ok(())` for every built-in
175    /// adapter — none of Claude/Codex/OpenCode override it in Phase 17 (no
176    /// reviewer-set storage exists yet in `state.rs`/`config.rs`, review
177    /// consensus #6).
178    #[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    /// TEST-ONLY adapter exercising the D-14 adapter-hook contract (reviewer
192    /// receiver set non-empty) without inventing production storage — this
193    /// is the adjacency boundary Phase 18's Hermes adapter will implement
194    /// for real once a reviewer field exists.
195    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    /// D-14 adjacency edge (17c): an empty reviewer receiver set fails
227    /// preflight; a set with one receiver passes.
228    #[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}