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
47/// Return an adapter for a configured agent kind.
48pub 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    /// Extract the prompt argument carrying the instruction text.
78    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    /// 13-06 dogfood regression (Codex leg): linked-worktree git metadata
120    /// lives under the main repo's `.git/` — outside the workspace-write
121    /// sandbox — and Codex read-only-mounts the cwd's resolved git dir, so
122    /// BOTH the common `.git` and the worktree admin dir must be granted
123    /// (verified with `codex sandbox` probes). Without roots, no override.
124    #[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    /// 13-06 dogfood regression: signed commits fail inside the Codex
148    /// sandbox (no route to the operator's signing agent) — codex scopes an
149    /// unsigned-commit override to its own process tree via GIT_CONFIG_*
150    /// env; agents without a sandbox get no extra env.
151    #[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}