Skip to main content

speccoder_runtime/
external.rs

1//! External executors: another coding agent makes the attempt; speccoder
2//! keeps everything around it.
3//!
4//! An ACP agent (`agent = "copilot"`, …, or `kind = "acp"` with `argv`)
5//! is driven over the Agent Client Protocol ([`crate::acp`]): each
6//! permission request it sends, and every file and command it runs through
7//! speccoder, is decided by speccoder's policy and approvals. What it does
8//! without asking stays outside speccoder. A `kind = "command"` program
9//! gets the prompt on stdin and is judged from its diff alone.
10//!
11//! Either way speccoder keeps the isolated worktree, frozen and
12//! spec-derived acceptance tests written before the attempt, the integrity
13//! audit of the diff (protected files, deleted or weakened tests), the
14//! checks and the verdict, retries with the verifier's digest, the gaming
15//! monitor, ticking and committing.
16
17use std::path::Path;
18use std::time::Duration;
19
20use serde::Deserialize;
21use serde_json::{Value, json};
22
23use crate::acp::agent::{self, Agent};
24
25/// Which agent.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum Kind {
28    /// An ACP agent: a preset, or `argv`.
29    Acp(Agent),
30    /// Any program: the prompt on stdin, the summary on stdout.
31    Command(Vec<String>),
32}
33
34impl Kind {
35    /// Its name in reports: the preset's id, `acp` or `command`.
36    #[must_use]
37    pub fn name(&self) -> &str {
38        match self {
39            Self::Acp(a) => &a.id,
40            Self::Command(_) => "command",
41        }
42    }
43}
44
45/// `[executor]` in `speccoder.toml`.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Executor {
48    /// Which agent.
49    pub kind: Kind,
50    /// The agent's model, if set.
51    pub model: Option<String>,
52    /// How long one attempt may take.
53    pub timeout: Duration,
54}
55
56#[derive(Debug, Deserialize)]
57#[serde(deny_unknown_fields)]
58struct Raw {
59    #[serde(default)]
60    kind: Option<String>,
61    #[serde(default)]
62    agent: Option<String>,
63    #[serde(default)]
64    argv: Vec<String>,
65    model: Option<String>,
66    mode: Option<String>,
67    timeout_secs: Option<u64>,
68}
69
70/// The executor for `id` (a preset), for one run (`run --agent`).
71///
72/// # Errors
73///
74/// No such preset.
75pub fn for_agent(id: &str, configured: Option<&Executor>) -> Result<Executor, String> {
76    let p = agent::preset(id)
77        .ok_or_else(|| format!("no agent `{id}` ({}, or `native`)", agent::ids()))?;
78    let same = configured.filter(|e| e.kind.name() == id);
79    Ok(Executor {
80        kind: Kind::Acp(Agent::of(
81            p,
82            same.and_then(|e| match &e.kind {
83                Kind::Acp(a) => a.mode.clone(),
84                Kind::Command(_) => None,
85            }),
86        )),
87        model: same.and_then(|e| e.model.clone()),
88        timeout: configured.map_or(Duration::from_secs(1800), |e| e.timeout),
89    })
90}
91
92/// Reads `[executor]`; `None` (or `kind = "native"`) is speccoder's own loop.
93pub fn parse(text: &str) -> Result<Option<Executor>, String> {
94    let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
95    let Some(v) = table.get("executor") else {
96        return Ok(None);
97    };
98    let raw: Raw = v
99        .clone()
100        .try_into()
101        .map_err(|e| format!("[executor]: {e}"))?;
102    let kind = match (raw.kind.as_deref(), raw.agent.as_deref()) {
103        (Some("native"), None) => return Ok(None),
104        (Some(old @ ("claude" | "codex")), None) => {
105            return Err(format!(
106                "[executor]: kind = \"{old}\" is now agent = \"{old}\" (it runs over ACP)"
107            ));
108        }
109        (None | Some("acp"), Some(id)) => {
110            if !raw.argv.is_empty() {
111                return Err(
112                    "[executor]: `agent` and `argv` exclusive: an agent of your own is kind = \"acp\" with `argv`".to_owned(),
113                );
114            }
115            let p = agent::preset(id)
116                .ok_or_else(|| format!("[executor]: agent = \"{id}\" ({})", agent::ids()))?;
117            Kind::Acp(Agent::of(p, raw.mode.clone()))
118        }
119        (Some(k @ ("acp" | "command")), None) => {
120            if raw.argv.first().is_none_or(String::is_empty) {
121                return Err(format!("[executor]: kind = \"{k}\" needs `argv`"));
122            }
123            if k == "acp" {
124                Kind::Acp(Agent::custom(raw.argv, raw.mode.clone()))
125            } else {
126                Kind::Command(raw.argv)
127            }
128        }
129        (None, None) => {
130            return Err(format!(
131                "[executor]: say which agent: agent = \"…\" ({}), or kind = \"acp\" / \"command\" with `argv`",
132                agent::ids()
133            ));
134        }
135        (Some(other), _) => {
136            return Err(format!(
137                "[executor]: kind = \"{other}\" (native, acp or command; an agent is agent = \"…\")"
138            ));
139        }
140    };
141    if raw.mode.is_some() && matches!(kind, Kind::Command(_)) {
142        return Err("[executor]: `mode` is an ACP agent's permission mode".to_owned());
143    }
144    Ok(Some(Executor {
145        kind,
146        model: raw.model,
147        timeout: Duration::from_secs(raw.timeout_secs.unwrap_or(1800)),
148    }))
149}
150
151/// What the external agent is told: the same context a native attempt gets,
152/// the rules speccoder will check afterwards, and how to stop honestly.
153#[must_use]
154pub fn prompt(bundle: &Value, digest: Option<&Value>) -> String {
155    prompt_in(crate::format::reference().wording(), bundle, digest)
156}
157
158/// [`prompt`] for a project whose format is worded `w`.
159#[must_use]
160pub fn prompt_in(w: &crate::format::Wording, bundle: &Value, digest: Option<&Value>) -> String {
161    let s = |k: &str| bundle[k].as_str().unwrap_or_default();
162    let task = &bundle["task"];
163    let mut out = format!(
164        "You are implementing exactly one task of a {product} {unit} in this git worktree.\n\n\
165Rules — speccoder checks them after you finish, from the diff:\n\
166- Change only what the task needs. Do not edit the spec ({intent}), the checks' configuration (`speccoder.toml`, CI files, test-runner config) or anything under `.git`.\n\
167- Test files that exist now are frozen: add new tests in new files; never weaken, skip or delete tests.\n\
168- Do not commit, branch or push; speccoder commits a task that passes.\n\
169- Run the project's tests to see whether your change works; speccoder runs them again and decides.\n\
170- If the task cannot be done as written (it contradicts the spec, a test is wrong, information is missing), stop and make your final message start with `BLOCKED:` and the reason.\n\n\
171# Task {}\n\n{}\n\nPhase: {}\n",
172        task["id"].as_str().unwrap_or_default(),
173        task["title"].as_str().unwrap_or_default(),
174        task["phase"].as_str().unwrap_or_default(),
175        product = w.product,
176        unit = w.unit,
177        intent = w.intent_code,
178    );
179    for (title, key) in [
180        (w.story, "story"),
181        (w.plan_short, "plan"),
182        (w.constitution, "constitution"),
183    ] {
184        if !s(key).trim().is_empty() {
185            out.push_str(&format!("\n# {title}\n\n{}\n", s(key)));
186        }
187    }
188    out.push_str(&crate::substrate::propose::oracle_text(bundle));
189    if let Some(d) = digest.filter(|d| !d.is_null()) {
190        out.push_str(&format!(
191            "\n# Previous attempt (failed; its changes were undone)\n\nThe verifier's facts:\n\n```json\n{}\n```\n",
192            serde_json::to_string_pretty(d).unwrap_or_default()
193        ));
194    }
195    out
196}
197
198/// What an external attempt produced, in the shape of a native attempt's output.
199#[derive(Debug, Clone, PartialEq)]
200pub struct Outcome {
201    /// An id for reports (`external_<agent>_<unix time>`); external runs are
202    /// not journaled.
203    pub run_id: String,
204    /// `{status, report?, reason?, tool_calls, usage}` like a native attempt.
205    pub output: Value,
206}
207
208/// Reads a command's final message from its stdout: `BLOCKED:` is an
209/// honest stop, `FAILED:` a failure, anything else the agent's summary.
210#[must_use]
211pub fn interpret(exit: Option<i32>, stdout: &str) -> Value {
212    let message = stdout.trim().to_owned();
213    let summary: String = message.chars().take(2000).collect();
214    let (status, extra) = if exit != Some(0) {
215        (
216            "failed",
217            json!({ "reason": format!("the agent exited {}: {}", exit.map_or("by signal".to_owned(), |c| c.to_string()), summary.chars().take(500).collect::<String>()) }),
218        )
219    } else {
220        return judged(&message);
221    };
222    let mut out = json!({ "status": status, "tool_calls": [], "usage": Value::Null });
223    if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
224        o.extend(e.clone());
225    }
226    out
227}
228
229/// An agent's final message after a turn it finished.
230#[must_use]
231pub fn judged(message: &str) -> Value {
232    let message = message.trim();
233    let summary: String = message.chars().take(2000).collect();
234    let mut out = json!({ "tool_calls": [], "usage": Value::Null });
235    if let Some(reason) = message.strip_prefix("BLOCKED:") {
236        out["status"] = json!("blocked");
237        out["reason"] = json!(reason.trim());
238    } else if message.starts_with("FAILED:") {
239        out["status"] = json!("failed");
240        out["reason"] = json!(summary);
241    } else {
242        out["status"] = json!("completed");
243        out["report"] = json!({ "summary": summary, "status": "done", "files_changed": [], "checks_run": [], "notes": ["external executor"] });
244    }
245    out
246}
247
248/// Runs one `kind = "command"` attempt with `argv` in `worktree`.
249#[must_use]
250pub fn run(
251    argv: &[String],
252    timeout: Duration,
253    worktree: &Path,
254    prompt: &str,
255    tmp: &Path,
256) -> Outcome {
257    use crate::verify::process::{CommandRunner as _, Invocation, LocalRunner, OUTPUT_CAP};
258    let _ = std::fs::create_dir_all(tmp);
259    let stamp = std::time::SystemTime::now()
260        .duration_since(std::time::UNIX_EPOCH)
261        .map_or(0, |d| d.as_secs());
262    let prompt_file = tmp.join(format!("external-{stamp}.prompt"));
263    let _ = std::fs::write(&prompt_file, prompt);
264    // The prompt arrives on stdin (a shell redirect keeps the runner's
265    // closed-stdin rule for everything else); the agent keeps the person's
266    // environment, because it needs its own credentials.
267    let quoted: Vec<String> = argv
268        .iter()
269        .map(|a| format!("'{}'", a.replace('\'', "'\\''")))
270        .collect();
271    let script = format!(
272        "exec {} < '{}'",
273        quoted.join(" "),
274        prompt_file.display().to_string().replace('\'', "'\\''")
275    );
276    let out = LocalRunner.run(&Invocation {
277        argv: vec!["sh".to_owned(), "-c".to_owned(), script],
278        cwd: worktree.to_path_buf(),
279        env: std::env::vars().collect(),
280        timeout,
281        output_cap: OUTPUT_CAP,
282    });
283    let stdout = String::from_utf8_lossy(&out.stdout.kept);
284    let mut output = interpret(out.exit_code, &stdout);
285    // A failing agent often explains itself only on stderr.
286    if out.exit_code != Some(0) && stdout.trim().is_empty() {
287        let stderr = String::from_utf8_lossy(&out.stderr.kept);
288        let lines: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
289        let tail = lines[lines.len().saturating_sub(5)..].join(" | ");
290        output["reason"] = json!(format!(
291            "the agent exited {}: {}",
292            out.exit_code
293                .map_or("by signal".to_owned(), |c| c.to_string()),
294            tail.chars().take(500).collect::<String>()
295        ));
296    }
297    if let Some(e) = &out.spawn_error {
298        output["status"] = json!("failed");
299        output["reason"] = json!(format!("the agent could not be started: {e}"));
300    }
301    if out.timed_out {
302        output["status"] = json!("failed");
303        output["reason"] = json!(format!(
304            "the agent did not finish within {} s",
305            timeout.as_secs()
306        ));
307    }
308    let _ = std::fs::remove_file(&prompt_file);
309    Outcome {
310        run_id: format!("external_command_{stamp}"),
311        output,
312    }
313}
314
315/// What an ACP attempt came to: the native-shaped outcome, and the
316/// agent's own sign-in methods when it needs signing in.
317#[derive(Debug, Clone)]
318pub struct AcpOutcome {
319    /// As a native attempt's.
320    pub outcome: Outcome,
321    /// The agent needs signing in: its ways (the attempt made nothing).
322    pub sign_in: Option<Vec<crate::acp::session::SignIn>>,
323    /// The agent's name, as it gave it.
324    pub agent: String,
325}
326
327/// The permission tally of an attempt's log: what the agent asked,
328/// what was allowed, what waited for a person, what was refused.
329#[must_use]
330pub fn tally(log: &[Value]) -> Value {
331    // One count per request: a permission request's several decisions
332    // (`cat a; ls b`) are one; a file or command call is its own.
333    let mut groups: Vec<(Value, Vec<&Value>)> = Vec::new();
334    for (i, e) in log
335        .iter()
336        .enumerate()
337        .filter(|(_, e)| e["decision"] != "reported")
338    {
339        let key = if e["request"].is_null() {
340            json!(i)
341        } else {
342            e["request"].clone()
343        };
344        match groups.iter_mut().find(|(k, _)| *k == key) {
345            Some((_, g)) => g.push(e),
346            None => groups.push((key, vec![e])),
347        }
348    }
349    let person = |e: &&Value| {
350        (e["by"] == "person" && !e["scope"].as_str().is_some_and(|s| s.ends_with("-grant")))
351            || e["decision"] == "approval"
352    };
353    let refused = |g: &Vec<&Value>| g.iter().any(|e| e["decision"] == "deny");
354    let waiting = |g: &Vec<&Value>| g.iter().any(|e| e["decision"] == "approval");
355    json!({
356        "asked": groups.len(),
357        "allowed": groups.iter().filter(|(_, g)| !refused(g) && !waiting(g)).count(),
358        "waited": groups.iter().filter(|(_, g)| g.iter().any(person)).count(),
359        "refused": groups.iter().filter(|(_, g)| refused(g)).count(),
360        "reported": log.iter().filter(|e| e["decision"] == "reported").count(),
361    })
362}
363
364/// The agent's login, in one line: the command it names, else its words,
365/// else the preset's.
366fn login_line(ways: &[crate::acp::session::SignIn], agent: &Agent) -> String {
367    ways.iter()
368        .find_map(|w| w.command.clone().map(|c| format!("`{c}`")))
369        .or_else(|| ways.iter().find_map(|w| w.description.clone()))
370        .or_else(|| agent.preset().map(|p| format!("`{}`", p.login)))
371        .unwrap_or_else(|| "its own login".to_owned())
372}
373
374/// The report's account of an ACP attempt, from its output.
375#[must_use]
376pub fn agent_attempt(output: &Value) -> Option<speccoder_core::AgentAttempt> {
377    let a = output.get("acp")?;
378    let n = |k: &str| {
379        a["tally"][k]
380            .as_u64()
381            .and_then(|n| u32::try_from(n).ok())
382            .unwrap_or(0)
383    };
384    Some(speccoder_core::AgentAttempt {
385        agent: a["agent"].as_str().unwrap_or_default().to_owned(),
386        preset: a["preset"].as_str().unwrap_or_default().to_owned(),
387        mode: a["mode"].is_object().then(|| {
388            serde_json::from_value::<crate::acp::modes::Statement>(a["mode"].clone())
389                .map(|s| crate::secrets::mask(&s.sentence()))
390                .unwrap_or_default()
391        }),
392        decides_alone: a["decides_alone"] == true,
393        known: a["known"] == true,
394        unasked: a["unasked"]
395            .as_u64()
396            .and_then(|n| u32::try_from(n).ok())
397            .unwrap_or(0),
398        tally: speccoder_core::PermissionTally {
399            asked: n("asked"),
400            allowed: n("allowed"),
401            waited: n("waited"),
402            refused: n("refused"),
403            reported: n("reported"),
404        },
405        plan: a["plan"]
406            .as_array()
407            .into_iter()
408            .flatten()
409            .map(|e| speccoder_core::AgentPlanStep {
410                content: e["content"].as_str().unwrap_or_default().to_owned(),
411                status: e["status"].as_str().unwrap_or("pending").to_owned(),
412            })
413            .collect(),
414    })
415}
416
417/// Runs one ACP attempt: the agent in `worktree`, its calls decided by
418/// `governor`.
419#[must_use]
420pub fn run_acp(
421    exec: &Executor,
422    agent: &Agent,
423    worktree: &Path,
424    prompt: &str,
425    runner: &crate::sandbox::Runner,
426    governor: std::sync::Arc<crate::acp::govern::Governor>,
427    progress: Option<(String, std::sync::Arc<dyn crate::progress::Progress>)>,
428) -> AcpOutcome {
429    let stamp = std::time::SystemTime::now()
430        .duration_since(std::time::UNIX_EPOCH)
431        .map_or(0, |d| d.as_secs());
432    let attempt = crate::acp::session::Attempt {
433        agent: agent.clone(),
434        argv: agent.command(agent::on_path),
435        cwd: governor.root().to_path_buf(),
436        prompt: format!(
437            "{prompt}\n# Commands\n\nRun commands through the client's terminal where you can: speccoder runs them in its sandbox. A command you run with your own tools runs outside it, so the person is asked about each one first.\n"
438        ),
439        model: exec.model.clone(),
440        timeout: exec.timeout,
441        runner: runner.clone(),
442        progress,
443    };
444    let _ = worktree;
445    let f = crate::acp::session::run(&attempt, std::sync::Arc::clone(&governor));
446    let shown = f.agent_name.clone().unwrap_or_else(|| agent.name.clone());
447    let log = governor.log();
448    let pending = governor.pending();
449    let mut output = if let Some(why) = &f.left {
450        // An honest stop for the person: what it would do next would never
451        // reach speccoder.
452        json!({ "status": "blocked", "reason": format!("{why}; its turn was cancelled") })
453    } else if let Some(p) = &pending {
454        json!({ "status": "needs_approval", "pending": p })
455    } else if f.sign_in.is_some() && f.prompted {
456        // Signed out mid-attempt: the attempt happened, and is judged as
457        // every failed one (its log and its diff are kept).
458        json!({ "status": "failed", "reason": format!("{} — sign {shown} in again: {}", f.error.clone().unwrap_or_default(), login_line(f.sign_in.as_deref().unwrap_or_default(), agent)) })
459    } else if f.sign_in.is_some() {
460        json!({ "status": "failed", "reason": f.error.clone().unwrap_or_default() })
461    } else if f.timed_out {
462        json!({ "status": "failed", "reason": format!("{shown} did not finish within {} s; its turn was cancelled", exec.timeout.as_secs()) })
463    } else {
464        match (f.stop_reason.as_deref(), &f.error) {
465            (Some("end_turn"), _) => judged(&f.message),
466            (Some("max_tokens" | "max_turn_requests"), _) => {
467                let mut o = judged(&f.message);
468                if o["status"] == "completed" {
469                    o["status"] = json!("incomplete");
470                    o["report"]["status"] = json!("incomplete");
471                    o["report"]["notes"] = json!([format!(
472                        "{shown} stopped at its own limit ({})",
473                        f.stop_reason.as_deref().unwrap_or_default()
474                    )]);
475                }
476                o
477            }
478            (Some("refusal"), _) => {
479                json!({ "status": "failed", "reason": format!("{shown} refused the task: {}", f.message.chars().take(500).collect::<String>()) })
480            }
481            (Some(other), _) => {
482                json!({ "status": "failed", "reason": format!("{shown} ended its turn: {other}") })
483            }
484            (None, Some(e)) => json!({ "status": "failed", "reason": e }),
485            (None, None) => {
486                json!({ "status": "failed", "reason": format!("{shown} ended without an answer") })
487            }
488        }
489    };
490    // A failure the agent explained only on stderr.
491    if output["status"] == "failed"
492        && let Some(line) = f.stderr.last()
493        && !output["reason"]
494            .as_str()
495            .unwrap_or_default()
496            .contains(line.as_str())
497    {
498        output["reason"] = json!(format!(
499            "{} — {shown} said: {line}",
500            output["reason"].as_str().unwrap_or_default()
501        ));
502    }
503    output["tool_calls"] = json!(log);
504    output["usage"] = f.usage.clone().unwrap_or(Value::Null);
505    output["asked"] = json!(governor.asked());
506    let mut notices = f.notices.clone();
507    if governor.unasked() > 0 {
508        notices.push(format!(
509            "{shown} changed something without asking {} time(s): its own settings let it decide those calls, which never reached speccoder",
510            governor.unasked()
511        ));
512    }
513    output["acp"] = json!({
514        "agent": shown,
515        "preset": agent.id,
516        "mode": f.mode,
517        "decides_alone": f.mode.as_ref().is_some_and(|m| m.asks() == Some(false)) || governor.unasked() > 0,
518        "known": f.mode.as_ref().is_some_and(|m| m.asks().is_some()),
519        "unasked": governor.unasked(),
520        "tally": tally(&log),
521        "plan": f.plan,
522        "notices": notices,
523    });
524    crate::secrets::mask_json(&mut output);
525    AcpOutcome {
526        outcome: Outcome {
527            run_id: format!("external_{}_{stamp}", agent.id),
528            output,
529        },
530        // Before any prompt: nothing was attempted, the task stops for it.
531        sign_in: f.sign_in.filter(|_| !f.prompted),
532        agent: shown,
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    fn kind(text: &str) -> Result<Option<Kind>, String> {
541        parse(text).map(|e| e.map(|e| e.kind))
542    }
543
544    #[test]
545    fn an_executor_is_an_agent_an_acp_argv_or_a_command() {
546        assert_eq!(kind(""), Ok(None));
547        assert_eq!(kind("[executor]\nkind = \"native\"\n"), Ok(None));
548        let Ok(Some(Kind::Acp(a))) = kind("[executor]\nagent = \"copilot\"\nmode = \"x\"\n") else {
549            panic!("copilot")
550        };
551        assert_eq!((a.id.as_str(), a.mode.as_deref()), ("copilot", Some("x")));
552        for id in ["claude", "codex", "gemini", "opencode"] {
553            assert!(
554                matches!(
555                    kind(&format!("[executor]\nagent = \"{id}\"\n")),
556                    Ok(Some(Kind::Acp(_)))
557                ),
558                "{id}"
559            );
560        }
561        let Ok(Some(Kind::Acp(own))) =
562            kind("[executor]\nkind = \"acp\"\nargv = [\"my-agent\", \"--acp\"]\n")
563        else {
564            panic!("argv")
565        };
566        assert_eq!((own.id.as_str(), own.argv.len()), ("acp", 2));
567        assert!(matches!(
568            kind("[executor]\nkind = \"command\"\nargv = [\"x\"]\n"),
569            Ok(Some(Kind::Command(_)))
570        ));
571        assert!(kind("[executor]\nkind = \"command\"\n").is_err());
572        assert!(kind("[executor]\nagent = \"gpt\"\n").is_err());
573        assert!(kind("[executor]\nagent = \"copilot\"\nargv = [\"x\"]\n").is_err());
574        assert!(kind("[executor]\nkind = \"command\"\nargv = [\"x\"]\nmode = \"y\"\n").is_err());
575        assert!(kind("[executor]\n").is_err());
576    }
577
578    #[test]
579    fn the_old_claude_and_codex_kinds_name_their_replacement() {
580        for old in ["claude", "codex"] {
581            let e = kind(&format!("[executor]\nkind = \"{old}\"\n"))
582                .err()
583                .unwrap_or_default();
584            assert!(e.contains(&format!("agent = \"{old}\"")), "{e}");
585        }
586    }
587
588    #[test]
589    fn one_run_can_take_another_agent() {
590        let configured =
591            parse("[executor]\nagent = \"copilot\"\nmodel = \"m\"\ntimeout_secs = 60\n")
592                .ok()
593                .flatten();
594        let e = for_agent("opencode", configured.as_ref()).unwrap_or_else(|e| panic!("{e}"));
595        assert_eq!(
596            (e.kind.name(), e.model, e.timeout.as_secs()),
597            ("opencode", None, 60)
598        );
599        let same = for_agent("copilot", configured.as_ref()).unwrap_or_else(|e| panic!("{e}"));
600        assert_eq!(same.model.as_deref(), Some("m"));
601        assert!(for_agent("nope", None).is_err());
602    }
603
604    #[test]
605    fn final_messages_become_statuses() {
606        assert_eq!(judged("Implemented add.")["status"], "completed");
607        let blocked = judged("BLOCKED: the test asserts 2+2=5");
608        assert_eq!(
609            (blocked["status"].as_str(), blocked["reason"].as_str()),
610            (Some("blocked"), Some("the test asserts 2+2=5"))
611        );
612        assert_eq!(interpret(Some(2), "boom")["status"], "failed");
613    }
614}