use std::io::{IsTerminal as _, Write as _};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use anyhow::{Context as _, Result, bail};
use crate::chat;
use crate::config::{AgentKind, AgentSpec, Config, which};
use crate::queue::{self, Queue, Source, Task};
use crate::repos;
use crate::run;
pub const TASK_FILE_SPEC: &str = "\
The task file is markdown. magi hands it to every candidate verbatim and to
every judge as the statement of what was asked, so it is the only thing any of
them knows about the change. Use this shape:
# <one line, imperative: what the change is>
## Context
Why this change, and what a competent stranger to this repository needs to know
that the code does not say. Name the files, the modules and the symbols
involved, with paths.
## Change
What to do, in enough mechanical detail that two candidates could not
reasonably disagree about the target: the interfaces, the names, the shape of
the data. Leave the *design* open - how it is built, in what order, with what
internal structure. That gap is where blind judging does its work; closing it
turns the competition into three transcriptions of the same answer.
## Constraints
Anything that must hold: files that must not be touched, dependencies that must
not be added, conventions to follow, commands that must not be run.
## Completion criteria
- [ ] One observable, checkable statement per line.
- [ ] Written so that a judge holding only the diff and this list can decide
whether each line holds. \"Works well\" cannot be judged; \"`magi plan`
exits non-zero and names the draft path when the draft has no completion
criteria\" can.
## Out of scope
What this competition must not touch, so that no candidate can win on breadth
instead of on the change that was asked for.
Rules for the task itself:
- One change per competition. Bundling unrelated fixes makes the diff
unjudgeable and the statistics meaningless.
- Nothing destructive or irreversible. Several candidates run unattended and in
parallel, and no node stops to ask.
- Visual and UX judgement stays with the operator: no judge sees a rendered
screen, so do not ask for one to be evaluated.
";
const MIN_DRAFT_BYTES: usize = 200;
pub const SHORT_DRAFT: &str = "the draft is under 200 bytes, which is about a \
title and one criterion: check the interview actually finished";
const EMPTY_DRAFT: &str = "the draft is empty";
const NO_TITLE: &str = "no line in the draft can be used as a title: the first \
non-blank line must say what the change is";
const NO_CRITERIA: &str = "no completion criteria: add a `## Completion \
criteria` heading (or `## 完了条件`) with one checkable statement per line, \
or the candidates cannot be compared and the judges have nothing to \
measure against";
const CRITERIA_HEADINGS: [&str; 4] = [
"completion criteria",
"acceptance",
"完了条件",
"受け入れ基準",
];
#[derive(Debug, Clone)]
pub struct Opts {
pub idea: Option<String>,
pub repo: PathBuf,
pub config: Option<PathBuf>,
pub agent: Option<String>,
pub priority: i32,
pub yes: bool,
pub from: Option<String>,
}
impl Default for Opts {
fn default() -> Self {
Self {
idea: None,
repo: PathBuf::from("."),
config: None,
agent: None,
priority: 0,
yes: false,
from: None,
}
}
}
pub async fn plan(opts: Opts) -> Result<Task> {
if !std::io::stdin().is_terminal() {
bail!(
"`magi plan` is an interview and needs a terminal. To file a task \
without one, pipe it to `magi task add`."
);
}
let repo = resolve_repo(&opts.repo, opts.config.as_deref())?;
let repo = repo.canonicalize().unwrap_or(repo);
let (config, _sources) = Config::discover(&repo, opts.config.as_deref())?;
let want = opts.agent.as_deref().or(config.roles.planner.as_deref());
let leader = pick(&config.agents, want, &installed)?;
let background = from_background(&chat::Chats::open(), opts.from.as_deref())?;
let dir = drafts_dir();
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let id = new_id();
let draft = dir.join(format!("{id}.md"));
let brief_path = dir.join(format!("{id}.briefing.md"));
let mut brief = briefing(opts.idea.as_deref(), &repo, &draft, &config.graph.language);
if let Some(background) = &background {
brief = format!("{background}\n\n{brief}");
}
std::fs::write(&brief_path, &brief)
.with_context(|| format!("write {}", brief_path.display()))?;
let argv = interactive_argv(&leader, &brief_path, &dir, &repo)?;
println!("leader: {}", leader.display());
println!("briefing: {}", brief_path.display());
println!("task file goes to: {}", draft.display());
println!("talk it through, then let the leader write the task file and exit.\n");
let mut cmd = tokio::process::Command::new(&argv[0]);
cmd.args(&argv[1..])
.current_dir(&repo)
.envs(&leader.env)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let status = cmd
.status()
.await
.with_context(|| format!("spawn {} (is it installed?)", argv[0]))?;
if !status.success() {
eprintln!("note: {} exited with {status}", argv[0]);
}
let (body, warnings) = vet(&draft)?;
for w in &warnings {
eprintln!("warning: {w}");
}
let title = queue::title_from(&body, 72);
if !opts.yes {
println!("\n{title}");
println!("draft: {} ({} bytes)", draft.display(), body.len());
print!("file this task? [y/N] ");
std::io::stdout().flush().ok();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("read the confirmation")?;
if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
bail!(
"not filed. The draft is kept at {0} - file it later with \
`magi task add --file {0}`.",
draft.display()
);
}
}
let q = Queue::open();
let mut task = Task::new(title, body, repo, Source::Human);
task.priority = opts.priority;
q.put(&mut task)?;
println!("filed {} {}", task.short(), task.title);
Ok(task)
}
pub fn review_draft(body: &str) -> Result<(), Vec<String>> {
if body.trim().is_empty() {
return Err(vec![EMPTY_DRAFT.to_owned()]);
}
let mut problems = Vec::new();
if queue::title_from(body, 72) == "(empty task)" {
problems.push(NO_TITLE.to_owned());
}
if !has_completion_criteria(body) {
problems.push(NO_CRITERIA.to_owned());
}
if body.len() < MIN_DRAFT_BYTES {
problems.push(SHORT_DRAFT.to_owned());
}
if problems.is_empty() {
Ok(())
} else {
Err(problems)
}
}
fn vet(draft: &Path) -> Result<(String, Vec<String>)> {
let body = std::fs::read_to_string(draft).with_context(|| {
format!(
"no task file at {} - the leader was asked to write one there",
draft.display()
)
})?;
match review_draft(&body) {
Ok(()) => Ok((body, Vec::new())),
Err(problems) => {
let (soft, hard): (Vec<String>, Vec<String>) =
problems.into_iter().partition(|p| p == SHORT_DRAFT);
if hard.is_empty() {
return Ok((body, soft));
}
let list = hard
.iter()
.map(|p| format!(" - {p}"))
.collect::<Vec<_>>()
.join("\n");
bail!(
"the draft is not usable as a magi task:\n{list}\n\n\
It is kept at {0} - nothing was thrown away. Edit it and file \
it with `magi task add --file {0}`.",
draft.display()
);
}
}
}
fn has_completion_criteria(body: &str) -> bool {
body.lines().any(|line| {
let line = line.trim();
is_checkbox(line) || is_criteria_heading(line)
})
}
fn is_criteria_heading(line: &str) -> bool {
let decorated = line.starts_with(['#', '*', '_']);
let bare = line
.trim_start_matches(['#', '*', '_', '>', ' '])
.trim_end_matches(['#', '*', '_', ':', ':', ' '])
.trim()
.to_lowercase();
CRITERIA_HEADINGS.iter().any(|h| {
if decorated {
bare.starts_with(h)
} else {
bare == *h
}
})
}
fn is_checkbox(line: &str) -> bool {
let Some(rest) = line.strip_prefix(['-', '*', '+']) else {
return false;
};
let rest = rest.trim_start();
rest.starts_with("[ ]") || rest.starts_with("[x]") || rest.starts_with("[X]")
}
fn drafts_dir() -> PathBuf {
run::home().join("drafts")
}
fn new_id() -> String {
let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
let seed = crate::rng::entropy();
format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
}
fn resolve_repo(raw: &Path, explicit_config: Option<&Path>) -> Result<PathBuf> {
if raw.is_dir() {
return Ok(raw.to_owned());
}
let (cfg, _) = Config::discover(raw, explicit_config)?;
repos::resolve(&cfg.repos.roots, &raw.to_string_lossy())
}
fn from_background(chats: &chat::Chats, from: Option<&str>) -> Result<Option<String>> {
match from {
None => Ok(None),
Some(id) => Ok(Some(chat::derived_background(&chats.get(id)?))),
}
}
pub fn installed(spec: &AgentSpec) -> bool {
spec.kind.program().is_none_or(which)
}
pub fn pick(
agents: &[AgentSpec],
want: Option<&str>,
available: &dyn Fn(&AgentSpec) -> bool,
) -> Result<AgentSpec> {
if let Some(id) = want {
let spec = agents
.iter()
.find(|a| a.id == id)
.with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
if !available(spec) {
bail!(
"agent `{}` needs `{}` on PATH; install it or pass a different \
--agent",
spec.id,
spec.kind.program().unwrap_or("its command")
);
}
return Ok(spec.clone());
}
if agents.is_empty() {
bail!(
"the agent roster is empty, so there is nobody to plan with: \
install one of claude, opencode or agy - magi derives a roster \
from what is on PATH - or add an [[agents]] entry to magi.toml."
);
}
if let Some(spec) = agents
.iter()
.find(|a| a.kind == AgentKind::Claude && available(a))
{
return Ok(spec.clone());
}
agents
.iter()
.find(|a| available(a))
.cloned()
.with_context(|| {
let missing = agents
.iter()
.filter_map(|a| a.kind.program())
.collect::<Vec<_>>()
.join(", ");
format!(
"no agent in the roster can be run here: install one of \
{missing}, or add an [[agents]] entry to magi.toml for a CLI \
you do have"
)
})
}
fn ids(agents: &[AgentSpec]) -> String {
if agents.is_empty() {
return "no agents at all".to_owned();
}
agents
.iter()
.map(|a| a.id.clone())
.collect::<Vec<_>>()
.join(", ")
}
fn interactive_argv(
spec: &AgentSpec,
brief_path: &Path,
widen: &Path,
repo: &Path,
) -> Result<Vec<String>> {
let mut argv: Vec<String> = Vec::new();
match spec.kind {
AgentKind::Claude => {
argv.push("claude".to_owned());
if let Some(m) = &spec.model {
argv.push("--model".to_owned());
argv.push(m.clone());
}
argv.push("--add-dir".to_owned());
argv.push(widen.to_string_lossy().into_owned());
argv.push(format!(
"Read the file at {} and follow it. Interview me about the \
change first; write the task file only once I say the plan is \
right.",
brief_path.display()
));
}
AgentKind::Opencode => argv.push("opencode".to_owned()),
AgentKind::Antigravity => {
argv.push("agy".to_owned());
argv.push("--add-dir".to_owned());
argv.push(widen.to_string_lossy().into_owned());
}
AgentKind::Command => {
if spec.command.is_empty() {
bail!("agent `{}` has kind = \"command\" but no command", spec.id);
}
for raw in &spec.command {
argv.push(
raw.replace("{prompt_file}", &brief_path.to_string_lossy())
.replace("{cwd}", &repo.to_string_lossy()),
);
}
argv.extend(spec.extra_args.iter().cloned());
}
}
Ok(argv)
}
fn briefing(idea: Option<&str>, repo: &Path, out: &Path, language: &str) -> String {
let idea = match idea.map(str::trim).filter(|s| !s.is_empty()) {
Some(i) => i.to_owned(),
None => "The operator has not written the idea down yet. Ask them what \
they want to change, starting from the repository itself."
.to_owned(),
};
let lang = if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
String::new()
} else {
format!("\n\nConduct the interview in {language}, and write the task file in {language}.")
};
format!(
"You are the planning leader for magi, which runs a blind \
multi-agent implementation competition: several agents will implement \
the task file you write, in isolated worktrees, unaware of each other, \
and judges will rank the results without knowing who wrote what.\n\n\
Your job is not to implement anything. It is to interview the operator \
until the change is pinned down, and then write one task file.\n\n\
# Repository\n\n{repo}\n\n\
Read it before you start asking. Questions that the code already \
answers spend the operator's patience for nothing.\n\n\
# The idea\n\n{idea}\n\n\
# How to run the interview\n\n\
- Ask about what you cannot determine yourself: intent, scope, which \
of several defensible designs the operator wants, what must not \
change.\n\
- Ask a few questions at a time and wait for the answers. Do not \
produce the task file after one exchange.\n\
- Disagree when you have grounds. A leader that agrees with everything \
adds nothing to what the operator already typed.\n\
- Confirm the plan in your own words and get an explicit yes before \
writing.\n\n\
# What to write, and where\n\n\
When the operator agrees the plan is right, write the task file to \
exactly this path:\n\n{out}\n\n\
Write that file and nothing else. Do not modify the repository: the \
competing agents do the implementation, and a repository you have \
already edited makes their diffs unjudgeable.\n\n\
magi will refuse a task file with no completion criteria, so those are \
not optional.\n\n\
# Task file specification\n\n{spec}\n\n\
When the file is written, tell the operator it is done and exit.{lang}",
repo = repo.display(),
out = out.display(),
spec = TASK_FILE_SPEC,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn good_draft() -> String {
"# Report per-node durations in `magi show`\n\
\n\
## Context\n\
\n\
`report::run` prints a run's nodes but not how long each took, so the \
numbers behind a slow competition have to be recovered from \
`run.json`'s `events` with `jq`.\n\
\n\
## Change\n\
\n\
Add a duration column to the node table in `src/report.rs`, computed \
from the existing `events` timestamps in `RunState`.\n\
\n\
## Constraints\n\
\n\
No new dependencies. Do not change `run.json`'s schema.\n\
\n\
## Completion criteria\n\
\n\
- [ ] `magi show <id>` prints a duration for every finished node.\n\
- [ ] A node still running prints its elapsed time, not a blank.\n\
- [ ] `cargo test` passes.\n\
\n\
## Out of scope\n\
\n\
The TUI's detail pane.\n"
.to_owned()
}
fn spec(id: &str, kind: AgentKind) -> AgentSpec {
AgentSpec {
id: id.to_owned(),
kind,
model: None,
command: Vec::new(),
extra_args: Vec::new(),
env: Default::default(),
prompt_delivery: None,
}
}
fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
move |a: &AgentSpec| !missing.contains(&a.id.as_str())
}
#[test]
fn a_realistic_task_file_is_accepted() {
let draft = good_draft();
assert!(
draft.len() >= MIN_DRAFT_BYTES,
"the fixture must be a real task file, not a stub"
);
assert_eq!(review_draft(&draft), Ok(()));
}
#[test]
fn a_bad_draft_reports_every_problem_at_once_rather_than_one_per_run() {
let problems = review_draft("###\n\n- - -\n").expect_err("must be rejected");
assert_eq!(problems.len(), 3, "{problems:?}");
assert_eq!(problems[0], NO_TITLE);
assert_eq!(problems[1], NO_CRITERIA);
assert_eq!(problems[2], SHORT_DRAFT);
}
#[test]
fn an_empty_draft_is_reported_as_empty_and_not_as_three_other_things() {
for body in ["", " \n\t\n "] {
let problems = review_draft(body).expect_err("must be rejected");
assert_eq!(problems, vec![EMPTY_DRAFT.to_owned()], "body {body:?}");
}
}
#[test]
fn a_draft_without_a_usable_title_is_rejected() {
let body = format!(
"#\n\n## Completion criteria\n\n- it works\n\n{}",
"x".repeat(300)
);
assert_eq!(
review_draft(&body).expect_err("must be rejected"),
vec![NO_TITLE.to_owned()]
);
}
#[test]
fn a_draft_without_completion_criteria_is_rejected_on_that_alone() {
let body = format!(
"# Rework the config loader\n\n## Change\n\nMake it layered.\n\n{}",
"prose. ".repeat(60)
);
assert!(body.len() >= MIN_DRAFT_BYTES);
assert_eq!(
review_draft(&body).expect_err("must be rejected"),
vec![NO_CRITERIA.to_owned()]
);
}
#[test]
fn completion_criteria_are_recognised_in_english_and_japanese_and_as_checkboxes() {
let filler = "x".repeat(300);
for section in [
"## Completion criteria\n\n- everything holds",
"## Acceptance\n\n- everything holds",
"### Acceptance criteria (all of them)\n\n- everything holds",
"**Completion criteria**\n\n- everything holds",
"## 完了条件\n\n- 全部そろっている",
"## 受け入れ基準\n\n- 全部そろっている",
"完了条件:\n\n- 全部そろっている",
"- [ ] no heading at all, just a checkbox",
] {
let body = format!("# A real change\n\n{section}\n\n{filler}");
assert_eq!(
review_draft(&body),
Ok(()),
"must accept criteria written as {section:?}"
);
}
}
#[test]
fn prose_that_merely_mentions_acceptance_is_not_a_criteria_section() {
let body = format!(
"# A real change\n\nAcceptance of the design is up to you.\n\n{}",
"x".repeat(300)
);
assert_eq!(
review_draft(&body).expect_err("prose is not a section"),
vec![NO_CRITERIA.to_owned()]
);
}
#[test]
fn a_complete_but_tiny_draft_is_warned_about_and_not_refused() {
let body = "# Bump the poll interval to 5s\n\n## Completion criteria\n\n- [ ] it is 5s\n";
assert!(body.len() < MIN_DRAFT_BYTES);
let problems = review_draft(body).expect_err("must warn");
assert_eq!(problems, vec![SHORT_DRAFT.to_owned()]);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tiny.md");
std::fs::write(&path, body).unwrap();
let (read_back, warnings) = vet(&path).expect("length alone must not refuse");
assert_eq!(read_back, body);
assert_eq!(warnings, vec![SHORT_DRAFT.to_owned()]);
}
#[test]
fn a_refused_draft_is_still_on_disk_at_the_path_the_error_names() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("20260902-231501-ab12.md");
let body = "# Something the operator spent twenty minutes on\n\nBut with no criteria.\n";
std::fs::write(&path, body).unwrap();
let err = vet(&path).expect_err("no criteria must be refused");
let msg = err.to_string();
assert!(
msg.contains(&path.display().to_string()),
"the error must name the draft path: {msg}"
);
assert!(msg.contains("magi task add --file"), "{msg}");
assert_eq!(
std::fs::read_to_string(&path).expect("the draft must survive its refusal"),
body
);
}
#[test]
fn a_draft_lives_under_the_run_home_so_it_outlives_the_command_that_wrote_it() {
assert_eq!(drafts_dir(), run::home().join("drafts"));
}
#[test]
fn a_missing_draft_is_reported_against_the_path_the_leader_was_given() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("never-written.md");
let msg = vet(&path).expect_err("nothing to file").to_string();
assert!(msg.contains(&path.display().to_string()), "{msg}");
}
#[test]
fn the_leader_is_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
let agents = [
spec("oc", AgentKind::Opencode),
spec("opus", AgentKind::Claude),
spec("agy", AgentKind::Antigravity),
];
let got = pick(&agents, None, &without(&[])).expect("a leader");
assert_eq!(got.id, "opus");
}
#[test]
fn the_leader_falls_back_to_the_first_installed_agent_in_roster_order() {
let agents = [
spec("opus", AgentKind::Claude),
spec("oc", AgentKind::Opencode),
spec("agy", AgentKind::Antigravity),
];
let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a leader");
assert_eq!(got.id, "agy");
}
#[test]
fn an_empty_roster_says_what_to_install() {
let msg = pick(&[], None, &without(&[]))
.expect_err("nobody to plan with")
.to_string();
assert!(msg.contains("roster is empty"), "{msg}");
assert!(msg.contains("claude"), "{msg}");
assert!(msg.contains("magi.toml"), "{msg}");
}
#[test]
fn a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
let agents = [
spec("opus", AgentKind::Claude),
spec("oc", AgentKind::Opencode),
];
let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
let msg = format!("{err:#}");
assert!(msg.contains("claude"), "{msg}");
assert!(msg.contains("opencode"), "{msg}");
}
#[test]
fn an_explicitly_named_agent_wins_over_the_claude_preference() {
let agents = [
spec("opus", AgentKind::Claude),
spec("oc", AgentKind::Opencode),
];
let got = pick(&agents, Some("oc"), &without(&[])).expect("a leader");
assert_eq!(got.id, "oc");
}
#[test]
fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
let agents = [
spec("opus", AgentKind::Claude),
spec("oc", AgentKind::Opencode),
];
let msg = pick(&agents, Some("gemini"), &without(&[]))
.expect_err("no such agent")
.to_string();
assert!(msg.contains("gemini"), "{msg}");
assert!(msg.contains("opus, oc"), "{msg}");
}
#[test]
fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
let agents = [
spec("opus", AgentKind::Claude),
spec("oc", AgentKind::Opencode),
];
let msg = pick(&agents, Some("oc"), &without(&["oc"]))
.expect_err("must not silently interview with another model")
.to_string();
assert!(msg.contains("opencode"), "{msg}");
assert!(msg.contains("--agent"), "{msg}");
}
#[test]
fn the_task_file_spec_asks_for_the_completion_criteria_the_validator_requires() {
assert!(TASK_FILE_SPEC.contains("## Completion criteria"));
assert!(has_completion_criteria(TASK_FILE_SPEC));
assert_eq!(
review_draft(TASK_FILE_SPEC),
Ok(()),
"the spec must pass the validator it is paired with"
);
}
#[test]
fn the_briefing_carries_the_idea_the_repository_the_output_path_and_the_spec() {
let b = briefing(
Some("make the queue drain faster"),
Path::new("/src/magi"),
Path::new("/home/magi/drafts/x.md"),
"en",
);
assert!(b.contains("make the queue drain faster"));
assert!(b.contains("/src/magi"));
assert!(b.contains("/home/magi/drafts/x.md"));
assert!(b.contains("## Completion criteria"));
assert!(
!b.contains("Conduct the interview in"),
"en adds no language line"
);
}
#[test]
fn a_briefing_without_an_idea_tells_the_leader_to_start_the_conversation() {
let b = briefing(
Some(" "),
Path::new("/src/magi"),
Path::new("/o.md"),
"ja",
);
assert!(b.contains("has not written the idea down yet"));
assert!(b.contains("Conduct the interview in ja"));
}
#[test]
fn the_interactive_invocation_is_never_the_headless_one() {
let brief = Path::new("/home/magi/drafts/x.briefing.md");
let widen = Path::new("/home/magi/drafts");
let repo = Path::new("/src/magi");
let mut claude = spec("opus", AgentKind::Claude);
claude.model = Some("opus".to_owned());
let argv = interactive_argv(&claude, brief, widen, repo).unwrap();
assert_eq!(argv[0], "claude");
assert!(!argv.iter().any(|a| a == "-p" || a == "--output-format"));
assert!(!argv.iter().any(|a| a == "--permission-mode"));
assert!(argv.windows(2).any(|w| w == ["--model", "opus"]));
assert!(
argv.windows(2)
.any(|w| w == ["--add-dir", "/home/magi/drafts"])
);
assert!(
argv.last().unwrap().contains(&brief.display().to_string()),
"claude gets the briefing as its opening prompt: {argv:?}"
);
assert_eq!(
interactive_argv(&spec("oc", AgentKind::Opencode), brief, widen, repo).unwrap(),
vec!["opencode".to_owned()],
"opencode is entered plain, in the repository"
);
assert_eq!(
interactive_argv(&spec("agy", AgentKind::Antigravity), brief, widen, repo).unwrap(),
vec![
"agy".to_owned(),
"--add-dir".to_owned(),
"/home/magi/drafts".to_owned()
]
);
}
#[test]
fn a_command_agent_gets_its_own_argv_with_the_briefing_substituted_in() {
let mut cmd = spec("local", AgentKind::Command);
cmd.command = vec![
"my-agent".to_owned(),
"--brief".to_owned(),
"{prompt_file}".to_owned(),
"--in".to_owned(),
"{cwd}".to_owned(),
];
cmd.extra_args = vec!["--interactive".to_owned()];
let argv = interactive_argv(
&cmd,
Path::new("/b.md"),
Path::new("/drafts"),
Path::new("/src/magi"),
)
.unwrap();
assert_eq!(
argv,
vec![
"my-agent",
"--brief",
"/b.md",
"--in",
"/src/magi",
"--interactive"
]
);
let empty = spec("broken", AgentKind::Command);
let msg = interactive_argv(&empty, Path::new("/b.md"), Path::new("/d"), Path::new("/r"))
.expect_err("a command agent with no command cannot be spawned")
.to_string();
assert!(msg.contains("broken"), "{msg}");
}
#[test]
fn the_configured_planner_is_used_and_an_explicit_agent_still_beats_it() {
let agents = [
spec("opus", AgentKind::Claude),
spec("oc", AgentKind::Opencode),
spec("agy", AgentKind::Antigravity),
];
let by_config = pick(&agents, Some("oc"), &without(&[])).expect("configured");
assert_eq!(by_config.id, "oc");
let by_default = pick(&agents, None, &without(&[])).expect("default");
assert_eq!(by_default.kind, AgentKind::Claude);
let err = pick(&agents, Some("oc"), &without(&["oc"])).expect_err("not runnable");
assert!(err.to_string().contains("oc"), "{err}");
}
#[test]
fn resolve_repo_uses_an_existing_directory_as_is() {
let dir = tempfile::tempdir().unwrap();
let resolved = resolve_repo(dir.path(), None).expect("an existing directory resolves");
assert_eq!(resolved, dir.path());
}
#[test]
fn resolve_repo_resolves_a_short_name_against_configured_roots() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
let checkout = root.join("github.com").join("yukimemi").join("magi");
std::fs::create_dir_all(checkout.join(".git")).unwrap();
let config_path = tmp.path().join("machine.toml");
std::fs::write(
&config_path,
format!(
"[repos]\nroots = [{:?}]\n",
root.to_string_lossy().into_owned()
),
)
.unwrap();
let resolved =
resolve_repo(Path::new("yukimemi/magi"), Some(&config_path)).expect("must resolve");
assert_eq!(resolved, checkout.canonicalize().unwrap());
}
#[test]
fn resolve_repo_reports_an_unresolvable_short_name() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("machine.toml");
std::fs::write(&config_path, "[repos]\nroots = []\n").unwrap();
let err = resolve_repo(Path::new("nope/nope"), Some(&config_path))
.expect_err("nothing configured to match")
.to_string();
assert!(err.contains("nope/nope"), "{err}");
}
#[test]
fn from_background_is_none_when_no_chat_is_named() {
let tmp = tempfile::tempdir().unwrap();
let chats = chat::Chats::at(tmp.path().join("chats"));
assert_eq!(from_background(&chats, None).unwrap(), None);
}
#[test]
fn from_background_names_the_missing_chat_id() {
let tmp = tempfile::tempdir().unwrap();
let chats = chat::Chats::at(tmp.path().join("chats"));
let err = from_background(&chats, Some("nope"))
.expect_err("no such chat")
.to_string();
assert!(err.contains("nope"), "{err}");
}
}