Skip to main content

kranz_engine/
backend_droid.rs

1//! Droid agent backend: drives Factory `droid exec -o json` headless.
2//!
3//! Ground truth is `crates/engine/tests/fixtures/droid_exec_scrutiny.json`, a
4//! recorded `droid exec -o json` result object. Unlike codex/claude, droid
5//! `-o json` emits a SINGLE result object rather than a stream — there is no
6//! stitching required. This module is single-shot only:
7//! [`DroidSession::send_user_message`] and a `resume`d [`SessionSpec`] are
8//! both rejected at the seam rather than translated into droid flags.
9//!
10//! Several `SessionSpec` fields are claude-isms with no droid equivalent and
11//! are deliberately ignored when building argv: `json_schema`,
12//! `max_budget_usd`, `resume`, `permission_mode`, `allowed_tools` /
13//! `disallowed_tools`, `tools`, `settings_json`, `effort`.
14
15use crate::backend::{
16    AgentBackend, AgentEvent, AgentSession, PromptMode, SessionExit, SessionSpec,
17};
18#[cfg(unix)]
19use crate::backend_claude::kill_group;
20#[cfg(windows)]
21use crate::backend_claude::win_job;
22use crate::cost;
23use crate::error::{EngineError, Result};
24use crate::stream_bounds::{drain_to_tail, BoundedLines, STDERR_TAIL_CAP};
25use crate::types::TokenUsage;
26use serde_json::{json, Value};
27use std::collections::VecDeque;
28use std::path::{Path, PathBuf};
29use std::process::Stdio;
30use std::sync::{Arc, Mutex};
31use tokio::process::{Child, ChildStdout};
32use tokio::task::JoinHandle;
33
34/// Max characters of captured stderr included in failure messages.
35const STDERR_TAIL_CHARS: usize = 500;
36
37// ---------------------------------------------------------------------------
38// Binary discovery
39// ---------------------------------------------------------------------------
40
41/// Locate a working `droid` binary.
42///
43/// Order: `configured` → `KRANZ_DROID_BIN` env var → `droid` on PATH →
44/// well-known install locations. Each candidate is validated by running it
45/// with `--version`; the first one that succeeds wins. Errors list every
46/// attempt so the user can see what was tried.
47///
48/// `KRANZ_DROID_BIN`, when set and non-empty, is an *exclusive* override:
49/// only that path is probed, and a failure is returned immediately rather
50/// than falling through to PATH or the well-known fallback locations. Naming
51/// the binary explicitly and having it not work is an error, not a reason to
52/// search elsewhere.
53pub fn discover_droid_binary(configured: Option<&str>) -> Result<PathBuf> {
54    if let Some(env_bin) = std::env::var_os("KRANZ_DROID_BIN") {
55        if !env_bin.is_empty() {
56            let candidate = PathBuf::from(env_bin);
57            return match probe_version(&candidate) {
58                Ok(_version) => Ok(candidate),
59                Err(why) => Err(EngineError::Config(format!(
60                    "KRANZ_DROID_BIN points at {} which did not work: {why}",
61                    candidate.display()
62                ))),
63            };
64        }
65    }
66
67    let mut candidates: Vec<PathBuf> = Vec::new();
68    if let Some(configured) = configured {
69        candidates.push(PathBuf::from(configured));
70    }
71    // Bare names resolve through PATH (std::process handles .cmd/.exe lookup
72    // rules per-platform).
73    candidates.push(PathBuf::from("droid"));
74    #[cfg(windows)]
75    {
76        candidates.push(PathBuf::from("droid.cmd"));
77        candidates.push(PathBuf::from("droid.exe"));
78    }
79    candidates.extend(fallback_candidates());
80
81    // Dedupe, preserving priority order.
82    let mut deduped: Vec<PathBuf> = Vec::new();
83    for candidate in candidates {
84        if !deduped.contains(&candidate) {
85            deduped.push(candidate);
86        }
87    }
88
89    let mut attempts: Vec<String> = Vec::new();
90    for candidate in deduped {
91        match probe_version(&candidate) {
92            Ok(_version) => return Ok(candidate),
93            Err(why) => attempts.push(format!("{} ({why})", candidate.display())),
94        }
95    }
96    Err(EngineError::Config(format!(
97        "no working droid binary found; tried: {}. Install Factory droid \
98         or point kranz at it via the validatorScrutiny.droidBinary config \
99         field or the KRANZ_DROID_BIN environment variable.",
100        attempts.join(", ")
101    )))
102}
103
104/// Well-known install locations checked after PATH.
105#[cfg(not(windows))]
106fn fallback_candidates() -> Vec<PathBuf> {
107    let home = std::env::var_os("HOME").map(PathBuf::from);
108    let mut out = Vec::new();
109    if let Some(home) = &home {
110        out.push(home.join(".factory").join("bin").join("droid"));
111        out.push(home.join(".local").join("bin").join("droid"));
112    }
113    out.push(PathBuf::from("/opt/homebrew/bin/droid"));
114    out.push(PathBuf::from("/usr/local/bin/droid"));
115    if let Some(home) = &home {
116        out.push(home.join(".npm-global").join("bin").join("droid"));
117    }
118    out
119}
120
121/// Well-known install locations checked after PATH (Windows).
122#[cfg(windows)]
123fn fallback_candidates() -> Vec<PathBuf> {
124    let mut out = Vec::new();
125    if let Some(profile) = std::env::var_os("USERPROFILE").map(PathBuf::from) {
126        for dir in [
127            profile.join(".factory").join("bin"),
128            profile.join(".local").join("bin"),
129            profile.join("AppData").join("Roaming").join("npm"),
130            profile.join(".npm-global").join("bin"),
131        ] {
132            for name in ["droid.cmd", "droid.exe", "droid"] {
133                out.push(dir.join(name));
134            }
135        }
136    }
137    out
138}
139
140/// Deadline for a `--version` probe. Generous for a healthy CLI, but bounds
141/// a hung shim on PATH so binary discovery (`kranz ready`, session spawn)
142/// can never block forever on a candidate.
143const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
144
145/// Validate a candidate by running `<candidate> --version`, draining both
146/// output pipes concurrently while enforcing [`VERSION_PROBE_TIMEOUT`].
147fn probe_version(binary: &Path) -> std::result::Result<String, String> {
148    crate::backend_probe::probe_version(binary, VERSION_PROBE_TIMEOUT)
149}
150
151// ---------------------------------------------------------------------------
152// Argument construction
153// ---------------------------------------------------------------------------
154
155/// The prompt text droid actually receives: `append_system_prompt` (if any)
156/// concatenated ahead of the prompt text — droid has no
157/// `--append-system-prompt` flag, so the engine folds it into the single
158/// positional PROMPT argument instead.
159fn effective_prompt(spec: &SessionSpec) -> String {
160    let prompt_text = match &spec.prompt {
161        PromptMode::SingleShot(text) => text.as_str(),
162        PromptMode::Streaming(text) => text.as_str(),
163    };
164    match &spec.append_system_prompt {
165        Some(system) if !system.is_empty() => format!("{system}\n\n{prompt_text}"),
166        _ => prompt_text.to_string(),
167    }
168}
169
170/// Build the argv (excluding the binary itself) for one session.
171///
172/// Public so tests can assert the exact CLI wire format without spawning.
173/// Deliberately ignores every claude-only `SessionSpec` field: `json_schema`,
174/// `max_budget_usd`, `resume`, `permission_mode`, `allowed_tools` /
175/// `disallowed_tools`, `tools`, `settings_json`, `effort`.
176pub fn build_args(spec: &SessionSpec) -> Vec<String> {
177    let auto = if spec.writable { "high" } else { "low" };
178    vec![
179        "exec".into(),
180        "-o".into(),
181        "json".into(),
182        "--auto".into(),
183        auto.into(),
184        "-m".into(),
185        spec.model.clone(),
186        effective_prompt(spec),
187    ]
188}
189
190// ---------------------------------------------------------------------------
191// droid exec -o json result parsing
192// ---------------------------------------------------------------------------
193
194/// Parse one stdout line (a single JSON result object) into
195/// [`AgentEvent`]s: an [`AgentEvent::Init`] followed by a terminal
196/// [`AgentEvent::Result`]. `model` is the configured model, used both as the
197/// `Init` model (droid's result object carries no model field) and as the
198/// pricing key when the result has no CLI-reported dollar cost.
199///
200/// Unparseable lines become [`AgentEvent::Other`] with
201/// `raw = {"unparsed": <line>}` so nothing is ever dropped from transcripts.
202pub fn parse_droid_result(line: &str, model: &str) -> Vec<AgentEvent> {
203    match serde_json::from_str::<Value>(line) {
204        Ok(value) => parse_droid_value(value, model),
205        Err(_) => vec![AgentEvent::Other {
206            raw: json!({ "unparsed": line }),
207        }],
208    }
209}
210
211/// Map one parsed `droid exec -o json` value to events (see module docs /
212/// fixture).
213fn parse_droid_value(value: Value, model: &str) -> Vec<AgentEvent> {
214    let line_type = value.get("type").and_then(Value::as_str).unwrap_or("");
215    if line_type != "result" {
216        return vec![AgentEvent::Other { raw: value }];
217    }
218
219    let session_id = str_field(&value, "session_id");
220    let init = AgentEvent::Init {
221        session_id,
222        model: model.to_string(),
223        raw: value.clone(),
224    };
225
226    let usage_field = |key: &str| {
227        value
228            .pointer(&format!("/usage/{key}"))
229            .and_then(Value::as_u64)
230            .unwrap_or(0)
231    };
232    let usage = TokenUsage {
233        input: usage_field("input_tokens"),
234        output: usage_field("output_tokens"),
235        cache_read: usage_field("cache_read_input_tokens"),
236        cache_write: usage_field("cache_creation_input_tokens"),
237    };
238    let cost_usd = value
239        .get("cost_usd")
240        .and_then(Value::as_f64)
241        .or_else(|| Some(cost::usage_cost_usd(&usage, model)));
242    let result_text = value
243        .get("result")
244        .and_then(Value::as_str)
245        .unwrap_or("")
246        .to_string();
247    let num_turns = value
248        .get("num_turns")
249        .and_then(Value::as_u64)
250        .map(|n| n as u32);
251
252    let terminal = AgentEvent::Result {
253        text: result_text,
254        is_error: value
255            .get("is_error")
256            .and_then(Value::as_bool)
257            .unwrap_or(false),
258        usage,
259        cost_usd,
260        num_turns,
261        raw: value,
262    };
263
264    vec![init, terminal]
265}
266
267fn str_field(value: &Value, key: &str) -> String {
268    value
269        .get(key)
270        .and_then(Value::as_str)
271        .unwrap_or_default()
272        .to_string()
273}
274
275/// Last `max` characters of `text` (for stderr tails in error messages).
276fn last_chars(text: &str, max: usize) -> String {
277    let chars: Vec<char> = text.chars().collect();
278    let start = chars.len().saturating_sub(max);
279    chars[start..].iter().collect()
280}
281
282// ---------------------------------------------------------------------------
283// Backend
284// ---------------------------------------------------------------------------
285
286/// The [`AgentBackend`] for `droid exec -o json`: single-shot with autonomy
287/// selected from the session role.
288#[derive(Debug, Clone)]
289pub struct DroidBackend {
290    binary: PathBuf,
291}
292
293impl DroidBackend {
294    /// Use an explicit binary path (no validation performed).
295    pub fn new(binary: impl Into<PathBuf>) -> Self {
296        DroidBackend {
297            binary: binary.into(),
298        }
299    }
300
301    /// Discover the binary via [`discover_droid_binary`].
302    pub fn discover(configured: Option<&str>) -> Result<Self> {
303        Ok(DroidBackend {
304            binary: discover_droid_binary(configured)?,
305        })
306    }
307
308    /// The binary this backend spawns.
309    pub fn binary(&self) -> &Path {
310        &self.binary
311    }
312}
313
314#[async_trait::async_trait]
315impl AgentBackend for DroidBackend {
316    async fn start(&self, spec: SessionSpec) -> Result<Box<dyn AgentSession>> {
317        if spec.resume.is_some() {
318            return Err(EngineError::Backend(
319                "droid backend is single-shot only; resume is unsupported".to_string(),
320            ));
321        }
322        let model = spec.model.clone();
323        let args = build_args(&spec);
324
325        let mut command = tokio::process::Command::new(&self.binary);
326        command
327            .args(&args)
328            .current_dir(&spec.cwd)
329            // agent-env-clear: CLEARED env from the minimal allowlist; the
330            // one ambient var a droid session may authenticate with is
331            // injected explicitly, never the whole ambient set.
332            .env_clear()
333            .envs(crate::agent_env::agent_session_env(
334                &spec.env,
335                &spec.session_id,
336                Some("FACTORY_API_KEY"),
337            ))
338            .stdin(Stdio::null())
339            .stdout(Stdio::piped())
340            .stderr(Stdio::piped())
341            .kill_on_drop(true);
342        // Unix: make the child the leader of a fresh process group so aborts
343        // can kill the whole tree, mirroring `backend_claude::ClaudeBackend`.
344        #[cfg(unix)]
345        command.process_group(0);
346
347        let mut child = command.spawn().map_err(|e| {
348            EngineError::Backend(format!("failed to spawn {}: {e}", self.binary.display()))
349        })?;
350
351        // Windows: kill-on-close Job Object, mirroring `backend_claude`.
352        #[cfg(windows)]
353        let job = match child.raw_handle() {
354            Some(handle) => match win_job::JobHandle::create_and_assign(handle) {
355                Ok(job) => Some(job),
356                Err(e) => {
357                    tracing::warn!(error = %e, "failed to create Job Object for droid child; \
358                        tree-kill on abort will be unavailable");
359                    None
360                }
361            },
362            None => None,
363        };
364
365        let stdout = child
366            .stdout
367            .take()
368            .ok_or_else(|| EngineError::Backend("droid child has no stdout pipe".to_string()))?;
369        let stderr = child
370            .stderr
371            .take()
372            .ok_or_else(|| EngineError::Backend("droid child has no stderr pipe".to_string()))?;
373
374        // Capture stderr concurrently so a chatty child never blocks on a
375        // full pipe and failure messages can include the tail. The stream is
376        // drained to EOF but only a bounded tail is retained — a noisy or
377        // malicious CLI must not exhaust host memory (stream_bounds).
378        let stderr_buf = Arc::new(Mutex::new(String::new()));
379        let stderr_task = {
380            let buf = Arc::clone(&stderr_buf);
381            tokio::spawn(async move {
382                let tail = drain_to_tail(stderr, STDERR_TAIL_CAP).await;
383                *buf.lock().expect("stderr buffer lock") = tail;
384            })
385        };
386
387        Ok(Box::new(DroidSession {
388            session_id: spec.session_id.clone(),
389            model,
390            child,
391            #[cfg(windows)]
392            job,
393            lines: BoundedLines::new(stdout),
394            stderr_buf,
395            stderr_task: Some(stderr_task),
396            queue: VecDeque::new(),
397            saw_result: false,
398            saw_success_result: false,
399            exit: None,
400        }))
401    }
402}
403
404// ---------------------------------------------------------------------------
405// Session
406// ---------------------------------------------------------------------------
407
408/// A live `droid exec -o json` session (the [`AgentSession`] impl).
409///
410/// Single-shot only: [`send_user_message`](AgentSession::send_user_message)
411/// always errors, and there is no streaming stdin to hold open. Only one
412/// JSON line is ever expected, but the read loop is kept anyway to mirror
413/// `CodexSession` and to tolerate stray blank lines.
414pub struct DroidSession {
415    session_id: String,
416    model: String,
417    child: Child,
418    #[cfg(windows)]
419    job: Option<win_job::JobHandle>,
420    lines: BoundedLines<ChildStdout>,
421    stderr_buf: Arc<Mutex<String>>,
422    stderr_task: Option<JoinHandle<()>>,
423    /// The single result line queues an Init + terminal Result; popped one
424    /// per `next_event`.
425    queue: VecDeque<AgentEvent>,
426    saw_result: bool,
427    saw_success_result: bool,
428    exit: Option<SessionExit>,
429}
430
431#[cfg(unix)]
432impl Drop for DroidSession {
433    fn drop(&mut self) {
434        crate::backend_claude::kill_unreaped_group(&self.child);
435    }
436}
437
438impl DroidSession {
439    fn observe(&mut self, event: &AgentEvent) {
440        match event {
441            AgentEvent::Init { session_id, .. } => {
442                self.session_id = session_id.clone();
443            }
444            AgentEvent::Result { is_error, .. } => {
445                self.saw_result = true;
446                if !is_error {
447                    self.saw_success_result = true;
448                }
449            }
450            _ => {}
451        }
452    }
453
454    /// Kill the child and reap it, best-effort; also joins the stderr capture
455    /// task. Mirrors `backend_claude::ClaudeSession::kill_child` exactly:
456    /// unix process-group SIGKILL (with a post-reap sweep for stragglers that
457    /// raced a mid-fork), windows kill-on-close Job Object.
458    async fn kill_child(&mut self) {
459        #[cfg(unix)]
460        {
461            let pgid = self
462                .child
463                .id()
464                .and_then(|pid| i32::try_from(pid).ok())
465                .filter(|pid| *pid > 0);
466            let group_killed = matches!(pgid, Some(pgid) if kill_group(pgid));
467            if !group_killed {
468                let _ = self.child.start_kill();
469            }
470            let _ = self.child.wait().await;
471            if group_killed {
472                if let Some(pgid) = pgid {
473                    let _ = kill_group(pgid);
474                }
475            }
476        }
477        #[cfg(windows)]
478        {
479            match &self.job {
480                Some(job) => job.kill(),
481                None => {
482                    let _ = self.child.start_kill();
483                }
484            }
485            let _ = self.child.wait().await;
486        }
487        #[cfg(all(not(unix), not(windows)))]
488        {
489            let _ = self.child.start_kill();
490            let _ = self.child.wait().await;
491        }
492        if let Some(task) = self.stderr_task.take() {
493            let _ = task.await;
494        }
495    }
496
497    async fn finish_at_eof(&mut self) {
498        let status = self.child.wait().await;
499        if let Some(task) = self.stderr_task.take() {
500            let _ = task.await;
501        }
502        let exit = match status {
503            Ok(status) if status.success() && self.saw_result => SessionExit::Completed,
504            Ok(status) => SessionExit::Failed(format!(
505                "droid exited with {status}{}; stderr tail: {}",
506                if self.saw_result {
507                    ""
508                } else {
509                    " without emitting a terminal event"
510                },
511                self.stderr_tail(),
512            )),
513            Err(e) => SessionExit::Failed(format!(
514                "failed to reap droid process: {e}; stderr tail: {}",
515                self.stderr_tail(),
516            )),
517        };
518        self.exit = Some(exit);
519    }
520
521    fn stderr_tail(&self) -> String {
522        let captured = self
523            .stderr_buf
524            .lock()
525            .map(|guard| guard.clone())
526            .unwrap_or_default();
527        last_chars(captured.trim_end(), STDERR_TAIL_CHARS)
528    }
529}
530
531#[async_trait::async_trait]
532impl AgentSession for DroidSession {
533    fn session_id(&self) -> String {
534        self.session_id.clone()
535    }
536
537    async fn next_event(&mut self) -> Result<Option<AgentEvent>> {
538        loop {
539            if let Some(event) = self.queue.pop_front() {
540                return Ok(Some(event));
541            }
542            if self.exit.is_some() {
543                return Ok(None);
544            }
545            let line = match self.lines.next_line().await {
546                Ok(Some(line)) => line,
547                Ok(None) => {
548                    self.finish_at_eof().await;
549                    return Ok(None);
550                }
551                Err(e) => {
552                    self.kill_child().await;
553                    self.exit = Some(SessionExit::Failed(format!(
554                        "error reading droid stdout: {e}; stderr tail: {}",
555                        self.stderr_tail(),
556                    )));
557                    return Ok(None);
558                }
559            };
560            if line.trim().is_empty() {
561                continue;
562            }
563            let events = parse_droid_result(&line, &self.model);
564            for event in &events {
565                self.observe(event);
566            }
567            self.queue.extend(events);
568        }
569    }
570
571    async fn send_user_message(&mut self, _text: &str) -> Result<()> {
572        Err(EngineError::Backend(
573            "droid backend is single-shot only; send_user_message is unsupported".to_string(),
574        ))
575    }
576
577    async fn abort(&mut self) -> Result<()> {
578        let already_exited = matches!(self.child.try_wait(), Ok(Some(_)));
579        self.kill_child().await;
580        if self.saw_success_result && already_exited {
581            self.exit = Some(SessionExit::Completed);
582        } else {
583            self.exit = Some(SessionExit::Aborted);
584        }
585        Ok(())
586    }
587
588    fn exit_status(&self) -> Option<SessionExit> {
589        self.exit.clone()
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    const TEST_MODEL: &str = "accounts/fireworks/models/glm-5p2";
598
599    #[test]
600    #[cfg(unix)]
601    fn probe_version_kills_a_hung_binary_within_the_deadline() {
602        use std::os::unix::fs::PermissionsExt;
603        let dir = tempfile::tempdir().unwrap();
604        let stub = dir.path().join("hung-droid");
605        std::fs::write(&stub, "#!/bin/sh\nsleep 30\n").unwrap();
606        std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
607
608        let start = std::time::Instant::now();
609        let result = probe_version(&stub);
610
611        let error = result.expect_err("a hung probe must be reported as broken");
612        assert!(error.contains("did not exit"), "{error}");
613        assert!(
614            start.elapsed() < std::time::Duration::from_secs(10),
615            "probe returned within the deadline, not after the stub's sleep"
616        );
617    }
618
619    fn fixture(name: &str) -> String {
620        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
621            .join("tests")
622            .join("fixtures")
623            .join(name);
624        std::fs::read_to_string(path).expect("read fixture")
625    }
626
627    #[test]
628    fn backend_droid_parse_fixture() {
629        let line = fixture("droid_exec_scrutiny.json");
630        let events = parse_droid_result(line.trim(), TEST_MODEL);
631
632        assert!(
633            events.iter().any(
634                |e| matches!(e, AgentEvent::Init { session_id, .. } if !session_id.is_empty())
635            ),
636            "expected an Init event with a non-empty session id"
637        );
638
639        let terminal = events
640            .iter()
641            .find_map(|e| match e {
642                AgentEvent::Result {
643                    text,
644                    usage,
645                    cost_usd,
646                    num_turns,
647                    ..
648                } => Some((text, usage, cost_usd, num_turns)),
649                _ => None,
650            })
651            .expect("expected a terminal Result event");
652        let (text, usage, cost_usd, num_turns) = terminal;
653        assert!(!text.is_empty(), "expected non-empty terminal text");
654        assert!(
655            usage.input > 0 || usage.output > 0 || usage.cache_read > 0,
656            "expected non-zero usage on the terminal Result"
657        );
658        assert!(cost_usd.is_some(), "expected cost_usd to be Some");
659        assert!(num_turns.is_some(), "expected num_turns to be Some");
660    }
661
662    #[test]
663    fn backend_droid_terminal_text_parses_report() {
664        let line = fixture("droid_exec_scrutiny.json");
665        let events = parse_droid_result(line.trim(), TEST_MODEL);
666
667        let terminal_text = events
668            .iter()
669            .find_map(|e| match e {
670                AgentEvent::Result { text, .. } => Some(text.clone()),
671                _ => None,
672            })
673            .expect("expected a terminal Result event");
674
675        let report = crate::runner::parse_validator_report(&terminal_text)
676            .expect("terminal text should parse as a ValidatorReport");
677        assert!(
678            !report.findings.is_empty(),
679            "expected the fixture's ValidatorReport to have findings"
680        );
681    }
682
683    #[test]
684    fn build_args_droid_read_only() {
685        let spec = SessionSpec {
686            cwd: PathBuf::from("."),
687            prompt: PromptMode::SingleShot("do the thing".to_string()),
688            append_system_prompt: None,
689            model: TEST_MODEL.to_string(),
690            effort: "high".to_string(),
691            session_id: "sess-1".to_string(),
692            resume: None,
693            permission_mode: Some("acceptEdits".to_string()),
694            allowed_tools: vec!["Bash(npm test*)".to_string()],
695            disallowed_tools: vec!["Bash(git push*)".to_string()],
696            tools: vec!["Bash".to_string()],
697            writable: false,
698            settings_json: Some(json!({"hooks": {}})),
699            json_schema: Some(json!({"type": "object"})),
700            max_budget_usd: Some(5.0),
701            max_turns: Some(10),
702            env: Default::default(),
703            sandbox: None,
704            hook_status: None,
705        };
706        let args = build_args(&spec);
707        assert_eq!(
708            args,
709            vec![
710                "exec".to_string(),
711                "-o".to_string(),
712                "json".to_string(),
713                "--auto".to_string(),
714                "low".to_string(),
715                "-m".to_string(),
716                TEST_MODEL.to_string(),
717                "do the thing".to_string(),
718            ]
719        );
720    }
721
722    #[test]
723    fn build_args_droid_writable_uses_high_auto() {
724        let spec = SessionSpec {
725            cwd: PathBuf::from("."),
726            prompt: PromptMode::SingleShot("do the thing".to_string()),
727            append_system_prompt: Some("be terse".to_string()),
728            model: TEST_MODEL.to_string(),
729            effort: "high".to_string(),
730            session_id: "sess-1".to_string(),
731            resume: None,
732            permission_mode: None,
733            allowed_tools: vec![],
734            disallowed_tools: vec![],
735            tools: vec![],
736            writable: true,
737            settings_json: None,
738            json_schema: None,
739            max_budget_usd: None,
740            max_turns: None,
741            env: Default::default(),
742            sandbox: None,
743            hook_status: None,
744        };
745        let args = build_args(&spec);
746        assert_eq!(
747            args,
748            vec![
749                "exec".to_string(),
750                "-o".to_string(),
751                "json".to_string(),
752                "--auto".to_string(),
753                "high".to_string(),
754                "-m".to_string(),
755                TEST_MODEL.to_string(),
756                "be terse\n\ndo the thing".to_string(),
757            ]
758        );
759    }
760
761    #[test]
762    fn build_args_droid_folds_append_system_prompt() {
763        let spec = SessionSpec {
764            cwd: PathBuf::from("."),
765            prompt: PromptMode::SingleShot("do the thing".to_string()),
766            append_system_prompt: Some("be terse".to_string()),
767            model: TEST_MODEL.to_string(),
768            effort: "high".to_string(),
769            session_id: "sess-1".to_string(),
770            resume: None,
771            permission_mode: None,
772            allowed_tools: vec![],
773            disallowed_tools: vec![],
774            tools: vec![],
775            writable: false,
776            settings_json: None,
777            json_schema: None,
778            max_budget_usd: None,
779            max_turns: None,
780            env: Default::default(),
781            sandbox: None,
782            hook_status: None,
783        };
784        let args = build_args(&spec);
785        assert_eq!(args.last().unwrap(), "be terse\n\ndo the thing");
786    }
787
788    #[test]
789    fn droid_backend_rejects_resumed_spec() {
790        use crate::backend::AgentBackend;
791        let backend = DroidBackend::new("droid");
792        let spec = SessionSpec {
793            cwd: PathBuf::from("."),
794            prompt: PromptMode::SingleShot("do the thing".to_string()),
795            append_system_prompt: None,
796            model: TEST_MODEL.to_string(),
797            effort: "high".to_string(),
798            session_id: "sess-1".to_string(),
799            resume: Some("sess-0".to_string()),
800            permission_mode: None,
801            allowed_tools: vec![],
802            disallowed_tools: vec![],
803            tools: vec![],
804            writable: false,
805            settings_json: None,
806            json_schema: None,
807            max_budget_usd: None,
808            max_turns: None,
809            env: Default::default(),
810            sandbox: None,
811            hook_status: None,
812        };
813        let result = tokio::runtime::Builder::new_current_thread()
814            .enable_all()
815            .build()
816            .unwrap()
817            .block_on(backend.start(spec));
818        assert!(result.is_err(), "expected resume to be rejected");
819    }
820}