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::phase_id::PhaseId;
8use crate::state::AgentKind;
9use std::path::PathBuf;
10
11/// Common behavior implemented by every supported coding-agent backend.
12pub trait AgentAdapter {
13    /// Human-readable adapter name.
14    fn name(&self) -> &'static str;
15
16    /// Build the command and arguments to launch this agent headless with the
17    /// given `prompt` for `phase`. Returns `(program, args)`.
18    ///
19    /// `extra_writable_roots` are directories OUTSIDE the agent's working
20    /// directory that its sandbox must still be allowed to write. Linked git
21    /// worktrees keep their git metadata under the main repo's `.git/` — and
22    /// Codex additionally read-only-mounts the cwd's resolved git dir, so
23    /// BOTH the common `.git` and the worktree admin dir
24    /// (`.git/worktrees/<name>`) must be granted explicitly (13-06 dogfood
25    /// finding, verified with `codex sandbox` probes). Adapters without a
26    /// sandbox ignore it.
27    fn exec_command(
28        &self,
29        phase: PhaseId,
30        prompt: &str,
31        extra_writable_roots: &[PathBuf],
32    ) -> (&'static str, Vec<String>);
33
34    /// Extra environment variables for the agent process tree. Codex uses
35    /// this to disable commit/tag signing inside its sandbox: the operator's
36    /// signing agent (ssh-agent/gpg-agent) is unreachable there, so signed
37    /// commits fail headless with a passphrase error (13-06 dogfood finding
38    /// — same rationale as the unsigned VersionBump tags). `GIT_CONFIG_*`
39    /// env scoping keeps the override out of every repo/global config.
40    fn extra_env(&self) -> Vec<(String, String)> {
41        Vec::new()
42    }
43
44    /// Detect an agent-specific completion signal in captured output.
45    fn completion_signal_detected(&self, output: &str) -> bool;
46
47    /// Adapter-specific pre-launch readiness check (D-13/D-14 adapter hook,
48    /// Phase 17c). The default is a no-op — most adapters have nothing extra
49    /// to check, mirroring [`Self::extra_env`]'s empty-default shape. The
50    /// `Err` variant is a human-readable failure reason that flows into the
51    /// preflight gate's context (`run_preflight` in `devflow-cli/src/main.rs`).
52    /// This is the trait surface Phase 18's Hermes adapter implements to
53    /// enforce a non-empty reviewer/receiver set — no built-in adapter
54    /// (Claude/Codex/OpenCode) overrides it in Phase 17 because no
55    /// reviewer-set storage exists yet in `state.rs`/`config.rs` (review
56    /// consensus #6).
57    fn preflight(&self, _state: &crate::state::State) -> Result<(), String> {
58        Ok(())
59    }
60}
61
62/// Return an adapter for a configured agent kind.
63pub 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    /// Extract the prompt text as this adapter actually DELIVERS it.
93    ///
94    /// Codex and OpenCode pass it positionally, so it is read back out of
95    /// argv. Claude does not: under `--input-format stream-json` the initial
96    /// user turn travels on the child's stdin, so it is read back out of the
97    /// wire document [`crate::monitor::user_turn_line`] builds. Two lookups,
98    /// one question — "what text did the agent receive?".
99    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    /// The invariant survived a transport change; it was not deleted with the
118    /// mechanism that used to carry it. Every adapter still receives the
119    /// canonical stage prompt byte-for-byte — Codex and OpenCode in argv,
120    /// Claude in the stdin user turn.
121    ///
122    /// The Claude leg additionally asserts the prompt is ABSENT from argv,
123    /// because "identical text" would otherwise be satisfiable by an adapter
124    /// that sent the prompt through both routes — which would double the
125    /// initial turn.
126    #[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    /// The Phase 31 launch contract, asserted as one thing because getting
147    /// only the flags right is the documented way to half-implement it: the
148    /// transport is `stream-json` in BOTH directions, and the prompt is not a
149    /// positional argument at all.
150    #[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    /// 13-06 dogfood regression (Codex leg): linked-worktree git metadata
200    /// lives under the main repo's `.git/` — outside the workspace-write
201    /// sandbox — and Codex read-only-mounts the cwd's resolved git dir, so
202    /// BOTH the common `.git` and the worktree admin dir must be granted
203    /// (verified with `codex sandbox` probes). Without roots, no override.
204    #[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    /// 13-06 dogfood regression: signed commits fail inside the Codex
229    /// sandbox (no route to the operator's signing agent) — codex scopes an
230    /// unsigned-commit override to its own process tree via GIT_CONFIG_*
231    /// env; agents without a sandbox get no extra env.
232    #[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    /// D-13: `preflight`'s default body is `Ok(())` for every built-in
242    /// adapter — none of Claude/Codex/OpenCode override it in Phase 17 (no
243    /// reviewer-set storage exists yet in `state.rs`/`config.rs`, review
244    /// consensus #6).
245    #[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}