Skip to main content

devflow_core/agents/
antigravity.rs

1//! Antigravity CLI agent driver (phase 41).
2//!
3//! Launches the operator's `agy` wrapper headless with a bidirectional
4//! `stream-json` transport: the initial user turn travels on the child's
5//! **stdin** as an `event`-key JSON line (`{"event":"user","message":{...}}`),
6//! and its events come back on stdout one JSON object per line under an
7//! `event` key (`init` -> `step_update` -> `result`).
8//!
9//! Every argv/schema fact here is deliberately review-derived and cited, not
10//! assumed (41-CONTEXT round-3, `.planning/phases/41-antigravity-driver/`):
11//!
12//! - **D-01:** `agy` is a shell wrapper (`exec antigravity-cli
13//!   --dangerously-skip-permissions "$@"`); the wrapper injects the
14//!   skip-permissions flag itself, so the driver argv must NOT repeat it.
15//! - **D-02:** no `-p`. `-p` is a Go-flag STRING flag requiring an argument;
16//!   it swallows the next token and exits 0 silently, and it is mutually
17//!   exclusive with `--input-format stream-json`. The prompt travels on stdin,
18//!   never in argv.
19//! - **F3:** `--print-timeout 60m` — the CLI default is 5m, below the
20//!   documented DevFlow stage length (a healthy stage measured at 47m); every
21//!   prior invocation in this repo overrides it. `60m` is the decided floor.
22//! - **D-03:** the completion parser is the Antigravity stream parser
23//!   (`agent_result::parse_antigravity_event_result`), reading the marker from
24//!   the last `event: "result"` object's `result.response` STRING; the ERROR
25//!   envelope is Layer-1 decisive.
26
27use super::AgentDriver;
28use crate::phase_id::PhaseId;
29
30/// The modular driver for Antigravity (`agy`): the stream-json launch with the
31/// reviewed argv, prompt delegation to the Claude-style renderer (D-05), and
32/// the Antigravity completion parser.
33pub struct AntigravityDriver;
34
35impl AgentDriver for AntigravityDriver {
36    fn name(&self) -> &'static str {
37        "Antigravity"
38    }
39
40    fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String {
41        crate::prompt::render_claude_style(intent)
42    }
43
44    /// Build the headless `stream-json` launch (D-02/F3, round 3).
45    ///
46    /// **Exact argv — do not "improve" it without re-deriving against a live
47    /// CLI.** No `-p` (Go-flag string flag, D-02), no
48    /// `--dangerously-skip-permissions` (the `agy` wrapper injects it, D-01),
49    /// no prompt in argv (stdin is the transport, D-02), and
50    /// `--print-timeout 60m` explicitly above the 5m default (F3). The
51    /// `prompt`/`phase` parameters are kept for the shared `AgentDriver`
52    /// shape; they are unused here on purpose.
53    fn build_command(
54        &self,
55        _phase: PhaseId,
56        _prompt: &str,
57        _extra_writable_roots: &[std::path::PathBuf],
58    ) -> (&'static str, Vec<String>) {
59        (
60            "agy",
61            vec![
62                "--input-format".into(),
63                "stream-json".into(),
64                "--output-format".into(),
65                "stream-json".into(),
66                "--print-timeout".into(),
67                "60m".into(),
68            ],
69        )
70    }
71
72    /// Delegate completion parsing to the Antigravity stream parser
73    /// (D-03/round-3): marker from `result.response`, ERROR envelope decisive,
74    /// marker-less -> `None` (Layer 2 owns it).
75    fn parse_completion(&self, output: &str) -> Option<crate::agent_result::AgentResult> {
76        crate::agent_result::parse_antigravity_event_result(output)
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::agent_result::AgentStatus;
84
85    #[test]
86    fn antigravity_driver_build_command_is_exact() {
87        let (program, args) = AntigravityDriver.build_command(PhaseId::new(0), "x", &[]);
88        assert_eq!(program, "agy");
89        assert_eq!(
90            args,
91            vec![
92                "--input-format".to_string(),
93                "stream-json".to_string(),
94                "--output-format".to_string(),
95                "stream-json".to_string(),
96                "--print-timeout".to_string(),
97                "60m".to_string(),
98            ],
99            "exact argv: no -p, no --dangerously-skip-permissions, no prompt, \
100             --print-timeout above the 5m default (D-01/D-02/F3)"
101        );
102    }
103
104    #[test]
105    fn antigravity_driver_render_prompt_delegates_to_claude_style() {
106        let intent =
107            crate::prompt::StageIntent::for_stage(crate::stage::Stage::Code, PhaseId::new(7));
108        let rendered = AntigravityDriver.render_prompt(&intent);
109        assert_eq!(
110            rendered,
111            crate::prompt::render_claude_style(&intent),
112            "D-05"
113        );
114        assert!(
115            rendered.contains("DEVFLOW_RESULT"),
116            "contract: prompt must carry the marker instruction"
117        );
118    }
119
120    #[test]
121    fn antigravity_driver_parse_completion_delegates() {
122        // Live shape: marker inside result.response -> Success.
123        let capture = concat!(
124            "{\"event\":\"init\",\"model\":\"stub\"}\n",
125            "{\"event\":\"result\",\"result\":{\"status\":\"SUCCESS\",\"response\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"success\\\"}\"}}\n",
126        );
127        let got = AntigravityDriver
128            .parse_completion(capture)
129            .expect("marker must resolve");
130        assert_eq!(got.status, AgentStatus::Success);
131
132        // ERROR envelope -> decisive Failed with the CLI's reason (notice (c)).
133        let error = concat!(
134            "{\"event\":\"init\",\"model\":\"stub\"}\n",
135            "{\"event\":\"result\",\"result\":{\"status\":\"ERROR\",\"response\":\"\",\"error\":\"stream input message is missing the \\\"event\\\" field\"}}\n",
136        );
137        let got = AntigravityDriver
138            .parse_completion(error)
139            .expect("ERROR envelope must resolve");
140        assert_eq!(got.status, AgentStatus::Failed);
141        assert!(
142            got.reason
143                .as_deref()
144                .unwrap()
145                .contains("missing the \"event\" field")
146        );
147
148        // Marker-less -> None (Layer 2 owns it).
149        let marker_less = concat!(
150            "{\"event\":\"init\",\"model\":\"stub\"}\n",
151            "{\"event\":\"result\",\"result\":{\"status\":\"SUCCESS\",\"response\":\"all done\"}}\n",
152        );
153        assert!(AntigravityDriver.parse_completion(marker_less).is_none());
154    }
155
156    /// F7 — "argv spawn-tested, not assumed": run the driver's ACTUAL argv
157    /// against a real child process with a stub `agy` on PATH. The stub
158    /// records the argv it received and the stdin turn, then emits an
159    /// antigravity-shaped stream; the test asserts both the argv round-trip
160    /// AND that the emitted stream parses back through the driver.
161    #[test]
162    fn antigravity_driver_spawn_argv_smoke() {
163        let dir = tempfile::tempdir().unwrap();
164        let bin_dir = dir.path().join("bin");
165        let argv_file = dir.path().join("argv.txt");
166        let turn_file = dir.path().join("turn.txt");
167        std::fs::create_dir_all(&bin_dir).unwrap();
168
169        let stub = format!(
170            r#"#!/bin/sh
171printf '%s\n' "$@" > '{}'
172IFS= read -r turn
173printf '%s\n' "$turn" > '{}'
174printf '%s\n' '{{"event":"init","model":"stub","inputFormat":"stream-json","outputFormat":"stream-json"}}'
175printf '%s\n' '{{"event":"result","result":{{"status":"SUCCESS","response":"DEVFLOW_RESULT: {{\"status\":\"success\"}}"}}}}'
176"#,
177            argv_file.display(),
178            turn_file.display(),
179        );
180        std::fs::write(bin_dir.join("agy"), stub).unwrap();
181        #[cfg(unix)]
182        {
183            use std::os::unix::fs::PermissionsExt;
184            std::fs::set_permissions(bin_dir.join("agy"), std::fs::Permissions::from_mode(0o755))
185                .unwrap();
186        }
187
188        let (program, args) = AntigravityDriver.build_command(PhaseId::new(0), "x", &[]);
189        let out = std::process::Command::new(program)
190            .args(&args)
191            .env("PATH", &bin_dir)
192            .stdin(std::process::Stdio::piped())
193            .stdout(std::process::Stdio::piped())
194            .spawn()
195            .and_then(|mut child| {
196                use std::io::Write;
197                child.stdin.take().unwrap().write_all(
198                    b"{\"event\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"x\"}}\n",
199                )?;
200                child.wait_with_output()
201            })
202            .expect("stub agy must spawn");
203
204        assert!(out.status.success(), "stub exited {:?}", out.status.code());
205
206        // The stub received exactly the five reviewed tokens — not -p, not the
207        // skip-permissions flag, not the prompt.
208        let received: Vec<String> = std::fs::read_to_string(&argv_file)
209            .unwrap()
210            .lines()
211            .map(str::to_string)
212            .collect();
213        assert_eq!(
214            received, args,
215            "spawned argv must equal build_command's argv: {received:?}"
216        );
217
218        // The stream round-trips through the driver's parser.
219        let parsed = AntigravityDriver
220            .parse_completion(&String::from_utf8_lossy(&out.stdout))
221            .expect("stub stream must parse");
222        assert_eq!(parsed.status, AgentStatus::Success);
223
224        // The stdin turn arrived as an event-key line (schema probe at the
225        // process boundary — the CLI would reject a type-key turn).
226        let turn = std::fs::read_to_string(&turn_file).unwrap();
227        assert!(
228            turn.contains("\"event\":\"user\""),
229            "first turn must be event-key: {turn}"
230        );
231        assert!(!turn.contains("\"type\":"), "no type key allowed: {turn}");
232    }
233
234    #[test]
235    fn antigravity_driver_name_is_correct() {
236        assert_eq!(AntigravityDriver.name(), "Antigravity");
237    }
238}