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    if let Some(d) = digest.filter(|d| !d.is_null()) {
189        out.push_str(&format!(
190            "\n# Previous attempt (failed; its changes were undone)\n\nThe verifier's facts:\n\n```json\n{}\n```\n",
191            serde_json::to_string_pretty(d).unwrap_or_default()
192        ));
193    }
194    out
195}
196
197/// What an external attempt produced, in the shape of a native attempt's output.
198#[derive(Debug, Clone, PartialEq)]
199pub struct Outcome {
200    /// An id for reports (`external_<agent>_<unix time>`); external runs are
201    /// not journaled.
202    pub run_id: String,
203    /// `{status, report?, reason?, tool_calls, usage}` like a native attempt.
204    pub output: Value,
205}
206
207/// Reads a command's final message from its stdout: `BLOCKED:` is an
208/// honest stop, `FAILED:` a failure, anything else the agent's summary.
209#[must_use]
210pub fn interpret(exit: Option<i32>, stdout: &str) -> Value {
211    let message = stdout.trim().to_owned();
212    let summary: String = message.chars().take(2000).collect();
213    let (status, extra) = if exit != Some(0) {
214        (
215            "failed",
216            json!({ "reason": format!("the agent exited {}: {}", exit.map_or("by signal".to_owned(), |c| c.to_string()), summary.chars().take(500).collect::<String>()) }),
217        )
218    } else {
219        return judged(&message);
220    };
221    let mut out = json!({ "status": status, "tool_calls": [], "usage": Value::Null });
222    if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
223        o.extend(e.clone());
224    }
225    out
226}
227
228/// An agent's final message after a turn it finished.
229#[must_use]
230pub fn judged(message: &str) -> Value {
231    let message = message.trim();
232    let summary: String = message.chars().take(2000).collect();
233    let mut out = json!({ "tool_calls": [], "usage": Value::Null });
234    if let Some(reason) = message.strip_prefix("BLOCKED:") {
235        out["status"] = json!("blocked");
236        out["reason"] = json!(reason.trim());
237    } else if message.starts_with("FAILED:") {
238        out["status"] = json!("failed");
239        out["reason"] = json!(summary);
240    } else {
241        out["status"] = json!("completed");
242        out["report"] = json!({ "summary": summary, "status": "done", "files_changed": [], "checks_run": [], "notes": ["external executor"] });
243    }
244    out
245}
246
247/// Runs one `kind = "command"` attempt with `argv` in `worktree`.
248#[must_use]
249pub fn run(
250    argv: &[String],
251    timeout: Duration,
252    worktree: &Path,
253    prompt: &str,
254    tmp: &Path,
255) -> Outcome {
256    use crate::verify::process::{CommandRunner as _, Invocation, LocalRunner, OUTPUT_CAP};
257    let _ = std::fs::create_dir_all(tmp);
258    let stamp = std::time::SystemTime::now()
259        .duration_since(std::time::UNIX_EPOCH)
260        .map_or(0, |d| d.as_secs());
261    let prompt_file = tmp.join(format!("external-{stamp}.prompt"));
262    let _ = std::fs::write(&prompt_file, prompt);
263    // The prompt arrives on stdin (a shell redirect keeps the runner's
264    // closed-stdin rule for everything else); the agent keeps the person's
265    // environment, because it needs its own credentials.
266    let quoted: Vec<String> = argv
267        .iter()
268        .map(|a| format!("'{}'", a.replace('\'', "'\\''")))
269        .collect();
270    let script = format!(
271        "exec {} < '{}'",
272        quoted.join(" "),
273        prompt_file.display().to_string().replace('\'', "'\\''")
274    );
275    let out = LocalRunner.run(&Invocation {
276        argv: vec!["sh".to_owned(), "-c".to_owned(), script],
277        cwd: worktree.to_path_buf(),
278        env: std::env::vars().collect(),
279        timeout,
280        output_cap: OUTPUT_CAP,
281    });
282    let stdout = String::from_utf8_lossy(&out.stdout.kept);
283    let mut output = interpret(out.exit_code, &stdout);
284    // A failing agent often explains itself only on stderr.
285    if out.exit_code != Some(0) && stdout.trim().is_empty() {
286        let stderr = String::from_utf8_lossy(&out.stderr.kept);
287        let lines: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
288        let tail = lines[lines.len().saturating_sub(5)..].join(" | ");
289        output["reason"] = json!(format!(
290            "the agent exited {}: {}",
291            out.exit_code
292                .map_or("by signal".to_owned(), |c| c.to_string()),
293            tail.chars().take(500).collect::<String>()
294        ));
295    }
296    if let Some(e) = &out.spawn_error {
297        output["status"] = json!("failed");
298        output["reason"] = json!(format!("the agent could not be started: {e}"));
299    }
300    if out.timed_out {
301        output["status"] = json!("failed");
302        output["reason"] = json!(format!(
303            "the agent did not finish within {} s",
304            timeout.as_secs()
305        ));
306    }
307    let _ = std::fs::remove_file(&prompt_file);
308    Outcome {
309        run_id: format!("external_command_{stamp}"),
310        output,
311    }
312}
313
314/// What an ACP attempt came to: the native-shaped outcome, and the
315/// agent's own sign-in methods when it needs signing in.
316#[derive(Debug, Clone)]
317pub struct AcpOutcome {
318    /// As a native attempt's.
319    pub outcome: Outcome,
320    /// The agent needs signing in: its ways (the attempt made nothing).
321    pub sign_in: Option<Vec<crate::acp::session::SignIn>>,
322    /// The agent's name, as it gave it.
323    pub agent: String,
324}
325
326/// The permission tally of an attempt's log: what the agent asked,
327/// what was allowed, what waited for a person, what was refused.
328#[must_use]
329pub fn tally(log: &[Value]) -> Value {
330    // One count per request: a permission request's several decisions
331    // (`cat a; ls b`) are one; a file or command call is its own.
332    let mut groups: Vec<(Value, Vec<&Value>)> = Vec::new();
333    for (i, e) in log
334        .iter()
335        .enumerate()
336        .filter(|(_, e)| e["decision"] != "reported")
337    {
338        let key = if e["request"].is_null() {
339            json!(i)
340        } else {
341            e["request"].clone()
342        };
343        match groups.iter_mut().find(|(k, _)| *k == key) {
344            Some((_, g)) => g.push(e),
345            None => groups.push((key, vec![e])),
346        }
347    }
348    let person = |e: &&Value| {
349        (e["by"] == "person" && !e["scope"].as_str().is_some_and(|s| s.ends_with("-grant")))
350            || e["decision"] == "approval"
351    };
352    let refused = |g: &Vec<&Value>| g.iter().any(|e| e["decision"] == "deny");
353    let waiting = |g: &Vec<&Value>| g.iter().any(|e| e["decision"] == "approval");
354    json!({
355        "asked": groups.len(),
356        "allowed": groups.iter().filter(|(_, g)| !refused(g) && !waiting(g)).count(),
357        "waited": groups.iter().filter(|(_, g)| g.iter().any(person)).count(),
358        "refused": groups.iter().filter(|(_, g)| refused(g)).count(),
359        "reported": log.iter().filter(|e| e["decision"] == "reported").count(),
360    })
361}
362
363/// The agent's login, in one line: the command it names, else its words,
364/// else the preset's.
365fn login_line(ways: &[crate::acp::session::SignIn], agent: &Agent) -> String {
366    ways.iter()
367        .find_map(|w| w.command.clone().map(|c| format!("`{c}`")))
368        .or_else(|| ways.iter().find_map(|w| w.description.clone()))
369        .or_else(|| agent.preset().map(|p| format!("`{}`", p.login)))
370        .unwrap_or_else(|| "its own login".to_owned())
371}
372
373/// The report's account of an ACP attempt, from its output.
374#[must_use]
375pub fn agent_attempt(output: &Value) -> Option<speccoder_core::AgentAttempt> {
376    let a = output.get("acp")?;
377    let n = |k: &str| {
378        a["tally"][k]
379            .as_u64()
380            .and_then(|n| u32::try_from(n).ok())
381            .unwrap_or(0)
382    };
383    Some(speccoder_core::AgentAttempt {
384        agent: a["agent"].as_str().unwrap_or_default().to_owned(),
385        preset: a["preset"].as_str().unwrap_or_default().to_owned(),
386        mode: a["mode"].is_object().then(|| {
387            serde_json::from_value::<crate::acp::modes::Statement>(a["mode"].clone())
388                .map(|s| crate::secrets::mask(&s.sentence()))
389                .unwrap_or_default()
390        }),
391        decides_alone: a["decides_alone"] == true,
392        known: a["known"] == true,
393        unasked: a["unasked"]
394            .as_u64()
395            .and_then(|n| u32::try_from(n).ok())
396            .unwrap_or(0),
397        tally: speccoder_core::PermissionTally {
398            asked: n("asked"),
399            allowed: n("allowed"),
400            waited: n("waited"),
401            refused: n("refused"),
402            reported: n("reported"),
403        },
404        plan: a["plan"]
405            .as_array()
406            .into_iter()
407            .flatten()
408            .map(|e| speccoder_core::AgentPlanStep {
409                content: e["content"].as_str().unwrap_or_default().to_owned(),
410                status: e["status"].as_str().unwrap_or("pending").to_owned(),
411            })
412            .collect(),
413    })
414}
415
416/// Runs one ACP attempt: the agent in `worktree`, its calls decided by
417/// `governor`.
418#[must_use]
419pub fn run_acp(
420    exec: &Executor,
421    agent: &Agent,
422    worktree: &Path,
423    prompt: &str,
424    runner: &crate::sandbox::Runner,
425    governor: std::sync::Arc<crate::acp::govern::Governor>,
426    progress: Option<(String, std::sync::Arc<dyn crate::progress::Progress>)>,
427) -> AcpOutcome {
428    let stamp = std::time::SystemTime::now()
429        .duration_since(std::time::UNIX_EPOCH)
430        .map_or(0, |d| d.as_secs());
431    let attempt = crate::acp::session::Attempt {
432        agent: agent.clone(),
433        argv: agent.command(agent::on_path),
434        cwd: governor.root().to_path_buf(),
435        prompt: format!(
436            "{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"
437        ),
438        model: exec.model.clone(),
439        timeout: exec.timeout,
440        runner: runner.clone(),
441        progress,
442    };
443    let _ = worktree;
444    let f = crate::acp::session::run(&attempt, std::sync::Arc::clone(&governor));
445    let shown = f.agent_name.clone().unwrap_or_else(|| agent.name.clone());
446    let log = governor.log();
447    let pending = governor.pending();
448    let mut output = if let Some(why) = &f.left {
449        // An honest stop for the person: what it would do next would never
450        // reach speccoder.
451        json!({ "status": "blocked", "reason": format!("{why}; its turn was cancelled") })
452    } else if let Some(p) = &pending {
453        json!({ "status": "needs_approval", "pending": p })
454    } else if f.sign_in.is_some() && f.prompted {
455        // Signed out mid-attempt: the attempt happened, and is judged as
456        // every failed one (its log and its diff are kept).
457        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)) })
458    } else if f.sign_in.is_some() {
459        json!({ "status": "failed", "reason": f.error.clone().unwrap_or_default() })
460    } else if f.timed_out {
461        json!({ "status": "failed", "reason": format!("{shown} did not finish within {} s; its turn was cancelled", exec.timeout.as_secs()) })
462    } else {
463        match (f.stop_reason.as_deref(), &f.error) {
464            (Some("end_turn"), _) => judged(&f.message),
465            (Some("max_tokens" | "max_turn_requests"), _) => {
466                let mut o = judged(&f.message);
467                if o["status"] == "completed" {
468                    o["status"] = json!("incomplete");
469                    o["report"]["status"] = json!("incomplete");
470                    o["report"]["notes"] = json!([format!(
471                        "{shown} stopped at its own limit ({})",
472                        f.stop_reason.as_deref().unwrap_or_default()
473                    )]);
474                }
475                o
476            }
477            (Some("refusal"), _) => {
478                json!({ "status": "failed", "reason": format!("{shown} refused the task: {}", f.message.chars().take(500).collect::<String>()) })
479            }
480            (Some(other), _) => {
481                json!({ "status": "failed", "reason": format!("{shown} ended its turn: {other}") })
482            }
483            (None, Some(e)) => json!({ "status": "failed", "reason": e }),
484            (None, None) => {
485                json!({ "status": "failed", "reason": format!("{shown} ended without an answer") })
486            }
487        }
488    };
489    // A failure the agent explained only on stderr.
490    if output["status"] == "failed"
491        && let Some(line) = f.stderr.last()
492        && !output["reason"]
493            .as_str()
494            .unwrap_or_default()
495            .contains(line.as_str())
496    {
497        output["reason"] = json!(format!(
498            "{} — {shown} said: {line}",
499            output["reason"].as_str().unwrap_or_default()
500        ));
501    }
502    output["tool_calls"] = json!(log);
503    output["usage"] = f.usage.clone().unwrap_or(Value::Null);
504    output["asked"] = json!(governor.asked());
505    let mut notices = f.notices.clone();
506    if governor.unasked() > 0 {
507        notices.push(format!(
508            "{shown} changed something without asking {} time(s): its own settings let it decide those calls, which never reached speccoder",
509            governor.unasked()
510        ));
511    }
512    output["acp"] = json!({
513        "agent": shown,
514        "preset": agent.id,
515        "mode": f.mode,
516        "decides_alone": f.mode.as_ref().is_some_and(|m| m.asks() == Some(false)) || governor.unasked() > 0,
517        "known": f.mode.as_ref().is_some_and(|m| m.asks().is_some()),
518        "unasked": governor.unasked(),
519        "tally": tally(&log),
520        "plan": f.plan,
521        "notices": notices,
522    });
523    crate::secrets::mask_json(&mut output);
524    AcpOutcome {
525        outcome: Outcome {
526            run_id: format!("external_{}_{stamp}", agent.id),
527            output,
528        },
529        // Before any prompt: nothing was attempted, the task stops for it.
530        sign_in: f.sign_in.filter(|_| !f.prompted),
531        agent: shown,
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    fn kind(text: &str) -> Result<Option<Kind>, String> {
540        parse(text).map(|e| e.map(|e| e.kind))
541    }
542
543    #[test]
544    fn an_executor_is_an_agent_an_acp_argv_or_a_command() {
545        assert_eq!(kind(""), Ok(None));
546        assert_eq!(kind("[executor]\nkind = \"native\"\n"), Ok(None));
547        let Ok(Some(Kind::Acp(a))) = kind("[executor]\nagent = \"copilot\"\nmode = \"x\"\n") else {
548            panic!("copilot")
549        };
550        assert_eq!((a.id.as_str(), a.mode.as_deref()), ("copilot", Some("x")));
551        for id in ["claude", "codex", "gemini", "opencode"] {
552            assert!(
553                matches!(
554                    kind(&format!("[executor]\nagent = \"{id}\"\n")),
555                    Ok(Some(Kind::Acp(_)))
556                ),
557                "{id}"
558            );
559        }
560        let Ok(Some(Kind::Acp(own))) =
561            kind("[executor]\nkind = \"acp\"\nargv = [\"my-agent\", \"--acp\"]\n")
562        else {
563            panic!("argv")
564        };
565        assert_eq!((own.id.as_str(), own.argv.len()), ("acp", 2));
566        assert!(matches!(
567            kind("[executor]\nkind = \"command\"\nargv = [\"x\"]\n"),
568            Ok(Some(Kind::Command(_)))
569        ));
570        assert!(kind("[executor]\nkind = \"command\"\n").is_err());
571        assert!(kind("[executor]\nagent = \"gpt\"\n").is_err());
572        assert!(kind("[executor]\nagent = \"copilot\"\nargv = [\"x\"]\n").is_err());
573        assert!(kind("[executor]\nkind = \"command\"\nargv = [\"x\"]\nmode = \"y\"\n").is_err());
574        assert!(kind("[executor]\n").is_err());
575    }
576
577    #[test]
578    fn the_old_claude_and_codex_kinds_name_their_replacement() {
579        for old in ["claude", "codex"] {
580            let e = kind(&format!("[executor]\nkind = \"{old}\"\n"))
581                .err()
582                .unwrap_or_default();
583            assert!(e.contains(&format!("agent = \"{old}\"")), "{e}");
584        }
585    }
586
587    #[test]
588    fn one_run_can_take_another_agent() {
589        let configured =
590            parse("[executor]\nagent = \"copilot\"\nmodel = \"m\"\ntimeout_secs = 60\n")
591                .ok()
592                .flatten();
593        let e = for_agent("opencode", configured.as_ref()).unwrap_or_else(|e| panic!("{e}"));
594        assert_eq!(
595            (e.kind.name(), e.model, e.timeout.as_secs()),
596            ("opencode", None, 60)
597        );
598        let same = for_agent("copilot", configured.as_ref()).unwrap_or_else(|e| panic!("{e}"));
599        assert_eq!(same.model.as_deref(), Some("m"));
600        assert!(for_agent("nope", None).is_err());
601    }
602
603    #[test]
604    fn final_messages_become_statuses() {
605        assert_eq!(judged("Implemented add.")["status"], "completed");
606        let blocked = judged("BLOCKED: the test asserts 2+2=5");
607        assert_eq!(
608            (blocked["status"].as_str(), blocked["reason"].as_str()),
609            (Some("blocked"), Some("the test asserts 2+2=5"))
610        );
611        assert_eq!(interpret(Some(2), "boom")["status"], "failed");
612    }
613}