1use crate::phase_id::PhaseId;
9use crate::state::AgentKind;
10use std::path::PathBuf;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16#[non_exhaustive]
17pub struct DriverCapabilities {
18 pub subagent_dispatch: bool,
23}
24
25#[derive(Debug, Clone, Default)]
28#[non_exhaustive]
29pub struct SandboxRequirements {}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ContractResult {
34 pub name: &'static str,
35 pub passed: bool,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum InteractivityMode {
42 HeadlessSafe,
44 RequiresExistingArtifact,
48 RequiresTypedSubagents,
50 InteractiveOnly,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum DriverHealth {
58 BinaryAbsent,
60 NotHeadlessCapable(String),
62 HeadlessCapable,
64}
65
66pub trait AgentDriver {
71 fn name(&self) -> &'static str;
73
74 fn capabilities(&self) -> DriverCapabilities {
76 DriverCapabilities::default()
77 }
78
79 fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String;
81
82 fn build_command(
84 &self,
85 phase: PhaseId,
86 prompt: &str,
87 extra_writable_roots: &[PathBuf],
88 ) -> (&'static str, Vec<String>);
89
90 fn parse_completion(&self, _output: &str) -> Option<crate::agent_result::AgentResult> {
93 None
94 }
95
96 fn health(&self, _state: &crate::state::State) -> Result<(), String> {
98 Ok(())
99 }
100
101 fn environment(&self) -> Vec<(String, String)> {
103 Vec::new()
104 }
105
106 fn sandbox_requirements(&self) -> SandboxRequirements {
108 SandboxRequirements::default()
109 }
110
111 fn discover(&self) -> Result<(), String> {
113 Ok(())
114 }
115
116 fn test_contract(&self) -> Vec<ContractResult> {
118 contract_checks(self)
119 }
120
121 fn interactivity_mode(&self, _stage: crate::stage::Stage) -> InteractivityMode {
123 InteractivityMode::HeadlessSafe
124 }
125
126 fn workflow_root(&self) -> String {
130 "$HOME/.codex/gsd-core/workflows".to_string()
131 }
132
133 fn health_classification(&self, state: &crate::state::State) -> DriverHealth {
136 match self.health(state) {
137 Ok(()) => DriverHealth::HeadlessCapable,
138 Err(reason) => DriverHealth::NotHeadlessCapable(reason),
139 }
140 }
141}
142
143fn contract_checks<D: AgentDriver + ?Sized>(driver: &D) -> Vec<ContractResult> {
147 let mut checks = vec![ContractResult {
148 name: "name is non-empty",
149 passed: !driver.name().is_empty(),
150 }];
151 for stage in [
152 crate::stage::Stage::Define,
153 crate::stage::Stage::Plan,
154 crate::stage::Stage::Code,
155 crate::stage::Stage::Validate,
156 crate::stage::Stage::Ship,
157 ] {
158 let intent = crate::prompt::StageIntent::for_stage(stage, PhaseId::new(1));
159 let prompt = driver.render_prompt(&intent);
160 checks.push(ContractResult {
161 name: "render_prompt states the completion contract",
162 passed: prompt.contains("DEVFLOW_RESULT"),
163 });
164 }
165 let (program, _args) = driver.build_command(PhaseId::new(1), "contract", &[]);
166 checks.push(ContractResult {
167 name: "build_command names a program",
168 passed: !program.is_empty(),
169 });
170 checks
171}
172
173pub fn driver_for(kind: AgentKind) -> Box<dyn AgentDriver> {
175 match kind {
176 AgentKind::Claude => Box::new(ClaudeDriver),
177 AgentKind::Codex => Box::new(CodexDriver),
178 AgentKind::OpenCode => Box::new(OpenCodeDriver),
179 AgentKind::Pi => Box::new(PiDriver),
180 }
181}
182
183pub mod claude;
184pub mod codex;
185pub mod opencode;
186pub mod pi;
187
188pub use claude::ClaudeDriver;
189pub use codex::CodexDriver;
190pub use opencode::OpenCodeDriver;
191pub use pi::PiDriver;
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use crate::prompt::stage_prompt;
197 use crate::stage::Stage;
198
199 #[test]
200 fn driver_for_returns_correct_names() {
201 assert_eq!(driver_for(AgentKind::Claude).name(), "Claude Code");
202 assert_eq!(driver_for(AgentKind::Codex).name(), "OpenAI Codex");
203 assert_eq!(driver_for(AgentKind::OpenCode).name(), "OpenCode");
204 assert_eq!(driver_for(AgentKind::Pi).name(), "Pi");
205 }
206
207 #[test]
211 fn drivers_reproduce_legacy_adapter_behavior() {
212 let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
213
214 let (program, args) = ClaudeDriver.build_command(PhaseId::new(7), "x", &[]);
216 assert_eq!(program, "claude");
217 assert!(
218 args.windows(2)
219 .any(|w| w[0] == "--input-format" && w[1] == "stream-json")
220 );
221 assert_eq!(
222 ClaudeDriver.render_prompt(&intent),
223 crate::prompt::render_claude_style(&intent)
224 );
225
226 let (program, args) = OpenCodeDriver.build_command(PhaseId::new(7), "x", &[]);
228 assert_eq!(program, "opencode");
229 assert_eq!(args, ["run", "x"]);
230 assert_eq!(
231 OpenCodeDriver.render_prompt(&intent),
232 crate::prompt::render_claude_style(&intent)
233 );
234 }
235
236 #[test]
240 fn codex_and_pi_drivers_reproduce_legacy_behavior() {
241 let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
242
243 let (program, args) = CodexDriver.build_command(PhaseId::new(7), "x", &[]);
244 assert_eq!(program, "codex");
245 assert_eq!(
246 &args[0..2],
247 ["-a", "never"],
248 "the global approval flag must precede `exec` (verified form): {args:?}"
249 );
250 assert!(args.contains(&"exec".to_string()));
251 assert!(
252 CodexDriver
253 .render_prompt(&intent)
254 .contains("execute-phase.md")
255 );
256 assert!(
257 !CodexDriver
258 .render_prompt(&intent)
259 .contains("/gsd-execute-phase")
260 );
261
262 let (program, args) = PiDriver.build_command(PhaseId::new(7), "x", &[]);
263 assert_eq!(program, "pi");
264 assert_eq!(args, ["-p", "--no-approve", "x"]);
265 assert!(PiDriver.render_prompt(&intent).contains("execute-phase.md"));
266 assert!(
267 !PiDriver
268 .render_prompt(&intent)
269 .contains("/gsd-execute-phase")
270 );
271 }
272
273 #[test]
277 fn every_driver_passes_the_conformance_suite() {
278 let drivers: [Box<dyn AgentDriver>; 4] = [
279 Box::new(ClaudeDriver),
280 Box::new(CodexDriver),
281 Box::new(OpenCodeDriver),
282 Box::new(PiDriver),
283 ];
284 for driver in &drivers {
285 let results = driver.test_contract();
286 assert!(
287 !results.is_empty(),
288 "{} has no conformance cases",
289 driver.name()
290 );
291 for result in &results {
292 assert!(
293 result.passed,
294 "{} failed conformance case {:?}",
295 driver.name(),
296 result.name
297 );
298 }
299 }
300 }
301
302 struct BrokenDriver;
306
307 impl AgentDriver for BrokenDriver {
308 fn name(&self) -> &'static str {
309 "broken"
310 }
311 fn render_prompt(&self, _intent: &crate::prompt::StageIntent) -> String {
312 String::new()
313 }
314 fn build_command(
315 &self,
316 _phase: PhaseId,
317 _prompt: &str,
318 _roots: &[PathBuf],
319 ) -> (&'static str, Vec<String>) {
320 ("", Vec::new())
321 }
322 }
323
324 #[test]
325 fn conformance_suite_fails_a_broken_driver() {
326 let results = BrokenDriver.test_contract();
327 assert!(
328 results.iter().any(|r| !r.passed),
329 "the conformance suite must fail a broken driver (empty render, empty program)"
330 );
331 }
332
333 #[test]
337 fn workflow_render_preserves_stage_contracts() {
338 use crate::prompt::StageIntent;
339 use crate::stage::Stage;
340
341 let codex = CodexDriver;
342
343 let validate =
345 codex.render_prompt(&StageIntent::for_stage(Stage::Validate, PhaseId::new(7)));
346 assert!(validate.contains("\"verdict\": \"pass\""));
347 assert!(validate.contains("\"verdict\": \"gaps\""));
348
349 let ship = codex.render_prompt(&StageIntent::for_stage(Stage::Ship, PhaseId::new(7)));
351 assert!(ship.contains("Critical"));
352 assert!(ship.contains("review:"));
353
354 let define = codex.render_prompt(&StageIntent::for_stage(Stage::Define, PhaseId::new(7)));
356 assert!(define.contains("must NOT run") || define.contains("do NOT run"));
357 assert!(!define.contains("discuss-phase.md"));
358
359 let plan = codex.render_prompt(&StageIntent::for_stage(Stage::Plan, PhaseId::new(7)));
361 assert!(plan.contains("already exists"));
362
363 let pi_code = PiDriver.render_prompt(&StageIntent::for_stage(Stage::Code, PhaseId::new(7)));
365 assert!(pi_code.contains("$HOME/.pi/agent/gsd-core/workflows"));
366 assert!(!pi_code.contains("$HOME/.codex/gsd-core"));
367 }
368
369 #[test]
370 fn codex_define_and_plan_require_an_existing_artifact() {
371 assert_eq!(
372 CodexDriver.interactivity_mode(crate::stage::Stage::Define),
373 InteractivityMode::RequiresExistingArtifact
374 );
375 assert_eq!(
376 CodexDriver.interactivity_mode(crate::stage::Stage::Plan),
377 InteractivityMode::RequiresExistingArtifact
378 );
379 assert_eq!(
380 CodexDriver.interactivity_mode(crate::stage::Stage::Code),
381 InteractivityMode::HeadlessSafe
382 );
383 assert_eq!(
384 ClaudeDriver.interactivity_mode(crate::stage::Stage::Define),
385 InteractivityMode::HeadlessSafe
386 );
387 }
388
389 #[test]
393 fn claude_and_opencode_stay_identical_but_codex_renders_native() {
394 let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
395 let claude = driver_for(AgentKind::Claude).render_prompt(&intent);
396 let opencode = driver_for(AgentKind::OpenCode).render_prompt(&intent);
397 let codex = driver_for(AgentKind::Codex).render_prompt(&intent);
398
399 assert_eq!(
401 claude, opencode,
402 "Claude and OpenCode must stay byte-identical after the migration"
403 );
404 assert_eq!(
405 claude,
406 stage_prompt(Stage::Code, PhaseId::new(7)),
407 "Claude must render the legacy stage_prompt text byte-for-byte (CONTEXT D-01)"
408 );
409
410 assert_ne!(
412 codex, claude,
413 "Codex must no longer render the shared slash-command text"
414 );
415 for command in [
419 "/gsd-discuss-phase",
420 "/gsd-plan-phase",
421 "/gsd-execute-phase",
422 "/gsd-validate-phase",
423 "/gsd-ship",
424 "/gsd-code-review",
425 "/gsd-audit-fix",
426 ] {
427 assert!(
428 !codex.contains(command),
429 "Codex render must not carry {command}: {codex}"
430 );
431 }
432 assert!(codex.contains("execute-phase.md"));
436 assert!(codex.contains("--auto"));
437 assert!(codex.contains("DEVFLOW_RESULT"));
438 }
439
440 #[test]
445 fn claude_launches_headless_stream_json_without_positional_prompt() {
446 let prompt = stage_prompt(Stage::Code, PhaseId::new(3));
447 let (program, args) =
448 driver_for(AgentKind::Claude).build_command(PhaseId::new(3), &prompt, &[]);
449 assert_eq!(program, "claude");
450 assert!(args.iter().any(|a| a == "-p"));
451 assert!(
452 args.windows(2)
453 .any(|w| w[0] == "--input-format" && w[1] == "stream-json"),
454 "the INPUT format is what moves the initial turn onto stdin; \
455 flipping only the output format leaves the CLI with no first \
456 turn and it stalls headless: {args:?}"
457 );
458 assert!(
459 args.windows(2)
460 .any(|w| w[0] == "--output-format" && w[1] == "stream-json"),
461 "the OUTPUT format is what makes the capture a JSONL event stream \
462 the Layer 1 stream parser can read: {args:?}"
463 );
464 assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
465 assert!(
466 !args.iter().any(|arg| arg.contains("DEVFLOW_RESULT")),
467 "no positional prompt: the initial user turn travels on stdin, \
468 written by the monitor: {args:?}"
469 );
470 }
471
472 #[test]
473 fn codex_wraps_prompt_in_exec_and_json() {
474 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
475 let (program, args) =
476 driver_for(AgentKind::Codex).build_command(PhaseId::new(7), &prompt, &[]);
477 assert_eq!(program, "codex");
478 let joined = args.join(" ");
479 assert!(joined.contains("exec"));
480 assert!(joined.contains("--sandbox workspace-write"));
481 assert!(joined.contains("--json"));
482 }
483
484 #[test]
485 fn opencode_wraps_prompt_in_run() {
486 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
487 let (program, args) =
488 driver_for(AgentKind::OpenCode).build_command(PhaseId::new(7), &prompt, &[]);
489 assert_eq!(program, "opencode");
490 assert_eq!(args, ["run", prompt.as_str()]);
491 }
492
493 #[test]
499 fn codex_grants_writable_roots_for_worktree_git_metadata() {
500 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
501 let roots = vec![
502 PathBuf::from("/repo/.git"),
503 PathBuf::from("/repo/.git/worktrees/phase-07"),
504 ];
505 let (_, args) =
506 driver_for(AgentKind::Codex).build_command(PhaseId::new(7), &prompt, &roots);
507 let joined = args.join(" ");
508 assert!(
509 joined.contains(
510 r#"-c sandbox_workspace_write.writable_roots=["/repo/.git","/repo/.git/worktrees/phase-07"]"#
511 ),
512 "codex must whitelist the common .git AND the worktree admin dir: {joined}"
513 );
514
515 let (_, args) = driver_for(AgentKind::Codex).build_command(PhaseId::new(7), &prompt, &[]);
516 assert!(
517 !args.join(" ").contains("writable_roots"),
518 "no override without an extra root"
519 );
520 }
521
522 #[test]
527 fn codex_disables_signing_via_env_others_do_not() {
528 let env = driver_for(AgentKind::Codex).environment();
529 assert!(env.contains(&("GIT_CONFIG_KEY_0".into(), "commit.gpgsign".into())));
530 assert!(env.contains(&("GIT_CONFIG_KEY_1".into(), "tag.gpgsign".into())));
531 assert!(driver_for(AgentKind::Claude).environment().is_empty());
532 assert!(driver_for(AgentKind::OpenCode).environment().is_empty());
533 }
534
535 #[test]
540 fn default_preflight_is_ok_for_built_in_adapters() {
541 let state = crate::state::State::new(
542 PhaseId::new(1),
543 AgentKind::Claude,
544 crate::mode::Mode::Auto,
545 PathBuf::from("/repo"),
546 );
547 assert!(driver_for(AgentKind::Claude).health(&state).is_ok());
548 assert!(driver_for(AgentKind::Codex).health(&state).is_ok());
549 assert!(driver_for(AgentKind::OpenCode).health(&state).is_ok());
550 }
551}