use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentKind {
Claude,
Opencode,
Antigravity,
Codex,
Omp,
Command,
}
impl AgentKind {
pub fn program(self) -> Option<&'static str> {
match self {
Self::Claude => Some("claude"),
Self::Opencode => Some("opencode"),
Self::Antigravity => Some("agy"),
Self::Codex => Some("codex"),
Self::Omp => Some("omp"),
Self::Command => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::Opencode => "opencode",
Self::Antigravity => "antigravity",
Self::Codex => "codex",
Self::Omp => "omp",
Self::Command => "command",
}
}
pub const ALL: [Self; 6] = [
Self::Claude,
Self::Opencode,
Self::Antigravity,
Self::Codex,
Self::Omp,
Self::Command,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Delivery {
Stdin,
Argv,
File,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgentSpec {
pub id: String,
pub kind: AgentKind,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub command: Vec<String>,
#[serde(default)]
pub extra_args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub prompt_delivery: Option<Delivery>,
}
impl AgentSpec {
pub fn delivery(&self) -> Delivery {
self.prompt_delivery.unwrap_or(match self.kind {
AgentKind::Claude | AgentKind::Command => Delivery::Stdin,
AgentKind::Codex => Delivery::Stdin,
AgentKind::Omp => Delivery::Stdin,
AgentKind::Opencode | AgentKind::Antigravity => Delivery::File,
})
}
pub fn display(&self) -> String {
match &self.model {
Some(m) => format!("{} ({}:{m})", self.id, self.kind.as_str()),
None => format!("{} ({})", self.id, self.kind.as_str()),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Roles {
pub implementers: Vec<String>,
pub judges: Vec<String>,
pub reviewers: Vec<String>,
pub fixer: Option<String>,
pub chatter: Option<String>,
pub conductor: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Graph {
pub candidates: usize,
pub judges: usize,
pub deliberate_rounds: usize,
pub reviewers: usize,
pub review_rounds: usize,
pub max_parallel: usize,
pub language: String,
pub sessions: bool,
pub timeout_implement: u64,
pub timeout_judge: u64,
pub timeout_review: u64,
pub timeout_verify: Option<u64>,
pub timeout_fix: u64,
pub timeout_talk: u64,
pub retries: usize,
pub worktree_root: Option<PathBuf>,
pub land: bool,
pub land_rounds: usize,
pub land_approval: bool,
pub answer_timeout: u64,
pub incomplete_review: IncompleteReviewPolicy,
pub e2e_every_round: bool,
}
impl Default for Graph {
fn default() -> Self {
Self {
candidates: 1,
judges: 3,
deliberate_rounds: 1,
reviewers: 3,
review_rounds: 6,
max_parallel: 4,
language: "en".to_owned(),
sessions: true,
timeout_implement: 3600,
timeout_judge: 1200,
timeout_review: 1200,
timeout_verify: None,
timeout_fix: 1800,
timeout_talk: 3600,
retries: 1,
worktree_root: None,
land: true,
land_rounds: 4,
land_approval: true,
answer_timeout: 86_400,
incomplete_review: IncompleteReviewPolicy::Block,
e2e_every_round: false,
}
}
}
impl Graph {
pub fn verify_timeout(&self) -> u64 {
self.timeout_verify.unwrap_or(self.timeout_review)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum IncompleteReviewPolicy {
Block,
Warn,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum LeakPolicy {
Warn,
Redact,
Fail,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Blind {
pub commit_msg_hook: bool,
pub strip_lines: Vec<String>,
pub vendor_tokens: Vec<String>,
pub on_leak: LeakPolicy,
pub seed: Option<u64>,
}
impl Default for Blind {
fn default() -> Self {
Self {
commit_msg_hook: true,
strip_lines: [
"Co-Authored-By:",
"Signed-off-by:",
"Assisted-by:",
"Generated-by:",
"Generated with",
"\u{1f916}",
]
.iter()
.map(|s| (*s).to_owned())
.collect(),
vendor_tokens: [
"claude",
"anthropic",
"codex",
"openai",
"chatgpt",
"gemini",
"grok",
"xai",
"copilot",
"opencode",
"qoder",
"cursor",
"\u{1f916}",
]
.iter()
.map(|s| (*s).to_owned())
.collect(),
on_leak: LeakPolicy::Warn,
seed: None,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Verify {
pub e2e: Vec<String>,
pub gate: Vec<String>,
pub shell: Option<Vec<String>>,
}
impl Verify {
pub fn cache_dir(&self) -> Option<PathBuf> {
self.e2e
.iter()
.chain(self.gate.iter())
.find_map(|cmd| crate::disk::extract_cargo_target_dir(cmd))
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Disk {
pub min_free_bytes: u64,
pub auto_fold: bool,
pub fold_grace_secs: u64,
pub cache_limit_bytes: u64,
}
impl Default for Disk {
fn default() -> Self {
Self {
min_free_bytes: 8 * 1024 * 1024 * 1024,
auto_fold: true,
fold_grace_secs: 6 * 60 * 60,
cache_limit_bytes: 10 * 1024 * 1024 * 1024,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum MergeMode {
None,
Local,
Pr,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum MergeStyle {
#[default]
Merge,
Squash,
Rebase,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Merge {
pub mode: MergeMode,
pub base: Option<String>,
pub style: MergeStyle,
pub remote: String,
pub release_bump: bool,
}
impl Default for Merge {
fn default() -> Self {
Self {
mode: MergeMode::None,
base: None,
style: MergeStyle::default(),
remote: "origin".to_owned(),
release_bump: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum UpdateMode {
Off,
Notify,
Install,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Update {
pub mode: UpdateMode,
pub interval: Option<String>,
}
impl Default for Update {
fn default() -> Self {
Self {
mode: UpdateMode::Notify,
interval: None,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Config {
pub agents: Vec<AgentSpec>,
pub roles: Roles,
pub graph: Graph,
pub blind: Blind,
pub verify: Verify,
pub disk: Disk,
pub merge: Merge,
pub update: Update,
pub prompts: Prompts,
pub notify: Notify,
pub repos: Repos,
pub talk: Talk,
pub daemon: Daemon,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Daemon {
pub max_concurrent_runs: usize,
}
impl Default for Daemon {
fn default() -> Self {
Self {
max_concurrent_runs: 1,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Repos {
pub roots: Vec<PathBuf>,
pub scan_ttl: u64,
}
impl Default for Repos {
fn default() -> Self {
Self {
roots: Vec::new(),
scan_ttl: 86_400,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Prompts {
pub all: String,
pub implement: String,
pub judge: String,
pub review: String,
pub fix: String,
}
impl Prompts {
pub fn overlay(&self, node: &str) -> Option<String> {
let specific = match node {
"implement" => &self.implement,
"judge" | "vote" | "deliberate" => &self.judge,
"review" => &self.review,
"fix" => &self.fix,
_ => "",
};
let mut parts: Vec<&str> = Vec::new();
for p in [self.all.trim(), specific.trim()] {
if !p.is_empty() {
parts.push(p);
}
}
if parts.is_empty() {
return None;
}
Some(parts.join("\n\n"))
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Notify {
pub command: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Talk {
pub allow_write: bool,
}
#[derive(Debug, Clone)]
pub struct ResolvedRoles {
pub implementers: Vec<AgentSpec>,
pub judges: Vec<AgentSpec>,
pub reviewers: Vec<AgentSpec>,
pub fixer: Option<AgentSpec>,
pub conductor: AgentSpec,
}
fn array_keys(table: &toml::value::Table, prefix: &str) -> Vec<String> {
let mut out = Vec::new();
for (k, v) in table {
if prefix.is_empty() && k == "vars" {
continue;
}
let path = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
match v {
toml::Value::Array(_) => out.push(path),
toml::Value::Table(t) => out.extend(array_keys(t, &path)),
_ => {}
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ArrayMerge {
Append,
Replace,
}
fn array_merge_policy(key: &str) -> ArrayMerge {
match key {
"verify.e2e" | "verify.gate" | "repos.roots" => ArrayMerge::Append,
_ => ArrayMerge::Replace,
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
Self::load_layers(&[path.to_path_buf()])
}
fn render_ctx(paths: &[PathBuf]) -> teravars::Context {
let mut ctx = teravars::system_context();
let env: std::collections::BTreeMap<String, String> = std::env::vars().collect();
ctx.insert("env", &env);
if let Some(last) = paths.last()
&& let Some(dir) = last.parent()
{
ctx.insert("repo", &dir.to_string_lossy());
ctx.insert(
"repo_name",
&dir.file_name().unwrap_or_default().to_string_lossy(),
);
}
ctx
}
pub fn load_layers(paths: &[PathBuf]) -> Result<Self> {
let mut engine = teravars::Engine::default();
let ctx = Self::render_ctx(paths);
if paths.len() > 1 {
Self::refuse_split_arrays(paths, &mut engine, &ctx)?;
}
let merged = teravars::load_merged(paths, &mut engine, &ctx).with_context(|| {
format!(
"rendering config via teravars: {}",
paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)
})?;
let mut table = merged.config;
table.remove("vars");
toml::Value::Table(table)
.try_into()
.context("deserializing magi config")
}
fn refuse_split_arrays(
paths: &[PathBuf],
engine: &mut teravars::Engine,
ctx: &teravars::Context,
) -> Result<()> {
let mut seen: std::collections::BTreeMap<String, PathBuf> = Default::default();
for path in paths {
let one = teravars::load_merged([path], engine, ctx)
.with_context(|| format!("rendering {}", path.display()))?;
for key in array_keys(&one.config, "") {
if array_merge_policy(&key) == ArrayMerge::Append {
continue;
}
if let Some(first) = seen.get(&key) {
bail!(
"`{key}` is an array declared in two config layers:\n \
{}\n {}\nteravars appends arrays when it merges, so \
magi would run the concatenation of both - which is \
not what either file says. Declare `{key}` in exactly \
one of them.",
first.display(),
path.display()
);
}
seen.insert(key, path.clone());
}
}
Ok(())
}
pub fn array_provenance(paths: &[PathBuf], key: &str) -> Vec<(PathBuf, Vec<String>)> {
let mut engine = teravars::Engine::default();
let ctx = Self::render_ctx(paths);
let mut out = Vec::new();
for path in paths {
let Ok(one) = teravars::load_merged([path], &mut engine, &ctx) else {
continue;
};
let mut cur = &one.config;
let mut found = None;
let parts: Vec<&str> = key.split('.').collect();
for (i, part) in parts.iter().enumerate() {
match cur.get(*part) {
Some(toml::Value::Array(a)) if i == parts.len() - 1 => {
found = Some(a);
break;
}
Some(toml::Value::Table(t)) => cur = t,
_ => break,
}
}
let Some(values) = found else { continue };
let strings: Vec<String> = values
.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect();
if !strings.is_empty() {
out.push((path.clone(), strings));
}
}
out
}
pub fn describe_composed(
paths: &[PathBuf],
commands: &[String],
key: &str,
empty: &str,
) -> String {
if commands.is_empty() {
return empty.to_owned();
}
let joined = commands.join(" && ");
let provenance = Self::array_provenance(paths, key);
if provenance.len() <= 1 {
return joined;
}
let mut out = joined;
for (path, cmds) in &provenance {
out.push_str(&format!("\n [{}] {}", path.display(), cmds.join(" && ")));
}
out
}
pub fn discover(repo: &Path, explicit: Option<&Path>) -> Result<(Self, Vec<PathBuf>)> {
if let Some(p) = explicit {
let paths = vec![p.to_path_buf()];
return Ok((Self::load_layers(&paths)?, paths));
}
let paths = Self::layers(repo);
if paths.is_empty() {
return Ok((Self::autodetected(), paths));
}
Ok((Self::load_layers(&paths)?, paths))
}
pub const CONFIG_DIR_ENV: &str = "MAGI_CONFIG_DIR";
pub fn layers(repo: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
paths.extend(Self::machine_layer());
paths.push(repo.join(".magi").join("config.toml"));
paths.push(repo.join("magi.toml"));
paths.retain(|p| p.is_file());
paths
}
#[cfg(test)]
fn machine_layer() -> Option<PathBuf> {
std::env::var(Self::CONFIG_DIR_ENV)
.ok()
.filter(|dir| !dir.trim().is_empty())
.map(|dir| PathBuf::from(dir).join("magi").join("config.toml"))
}
#[cfg(not(test))]
fn machine_layer() -> Option<PathBuf> {
match std::env::var(Self::CONFIG_DIR_ENV) {
Ok(dir) if dir.trim().is_empty() => None,
Ok(dir) => Some(PathBuf::from(dir).join("magi").join("config.toml")),
Err(_) => dirs::config_dir().map(|dir| dir.join("magi").join("config.toml")),
}
}
pub fn autodetected() -> Self {
let mut cfg = Self::default();
for (kind, id, model) in [
(AgentKind::Claude, "opus", Some("opus")),
(AgentKind::Claude, "sonnet", Some("sonnet")),
(AgentKind::Antigravity, "antigravity", None),
(AgentKind::Opencode, "opencode", None),
(AgentKind::Codex, "codex", None),
(AgentKind::Omp, "omp", None),
] {
if kind.program().is_some_and(which) && !cfg.agents.iter().any(|a| a.id == id) {
cfg.agents.push(AgentSpec {
id: id.to_owned(),
kind,
model: model.map(str::to_owned),
command: Vec::new(),
extra_args: Vec::new(),
env: BTreeMap::new(),
prompt_delivery: None,
});
}
}
cfg
}
pub fn cache_dir(&self) -> Option<PathBuf> {
self.verify.cache_dir()
}
pub fn agent(&self, id: &str) -> Result<&AgentSpec> {
self.agents
.iter()
.find(|a| a.id == id)
.with_context(|| format!("no agent with id `{id}` in the roster"))
}
fn rotate(&self, ids: &[String], count: usize, offset: usize) -> Result<Vec<AgentSpec>> {
let mut out = Vec::with_capacity(count);
for i in 0..count {
let spec = if ids.is_empty() {
self.agents[(i + offset) % self.agents.len()].clone()
} else {
self.agent(&ids[i % ids.len()])?.clone()
};
out.push(spec);
}
Ok(out)
}
pub fn resolve_roles(&self) -> Result<ResolvedRoles> {
if self.agents.is_empty() {
bail!(
"agent roster is empty: no agent CLI found on PATH and no \
[[agents]] in the config. Run `magi init` to write a starter \
magi.toml."
);
}
Ok(ResolvedRoles {
implementers: self.rotate(&self.roles.implementers, self.graph.candidates, 0)?,
judges: self.rotate(&self.roles.judges, self.graph.judges, 1)?,
reviewers: self.rotate(&self.roles.reviewers, self.graph.reviewers, 0)?,
fixer: self
.roles
.fixer
.as_deref()
.map(|f| self.agent(f).cloned())
.transpose()?,
conductor: match self.roles.conductor.as_deref() {
Some(id) => self.agent(id)?.clone(),
None => crate::agent::pick(&self.agents, None, &crate::agent::installed)
.unwrap_or_else(|_| self.agents[0].clone()),
},
})
}
pub fn shell(&self) -> Vec<String> {
if let Some(s) = &self.verify.shell {
return s.clone();
}
if which("sh") {
vec!["sh".to_owned(), "-c".to_owned()]
} else {
vec!["cmd".to_owned(), "/C".to_owned()]
}
}
pub fn starter_toml() -> String {
let detected = Self::autodetected();
let mut s = String::from(
"# magi — blind multi-agent implementation competition.\n\
# `magi run \"<task>\"` walks: implement (N parallel worktrees)\n\
# -> blind judging -> deliberation -> private final vote\n\
# -> fold losers -> review + E2E loop -> gate -> merge.\n\
#\n\
# Rendered by teravars: a `[vars]` table, env\n\
# and system lookups, and `include = [...]` all work. Tera\n\
# braces are live everywhere in this file, but comments are\n\
# stripped before rendering (teravars >= 0.2.2), so a comment\n\
# may quote `{{ ... }}` freely.\n\
#\n\
# Layers deep-merge in increasing\n\
# precedence, so the roster can live once per machine in\n\
# <config_dir>/magi/config.toml and each repo only states its own\n\
# gate:\n\
# <config_dir>/magi/config.toml < .magi/config.toml < magi.toml\n\n\
[vars]\n\
# Reference it as vars.cache inside Tera braces, anywhere below.\n\
# Single quotes inside the braces: teravars renders the raw file\n\
# text, so TOML's own \\\" escaping never reaches Tera.\n\
cache = \"{{ env.MAGI_CACHE | default(value='/tmp') }}\"\n\n",
);
if detected.agents.is_empty() {
s.push_str(
"# No agent CLI was found on PATH. Fill this in by hand.\n\
# kind = claude | opencode | antigravity | codex | command\n\
[[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n",
);
} else {
for a in &detected.agents {
s.push_str("[[agents]]\n");
s.push_str(&format!("id = {:?}\n", a.id));
s.push_str(&format!("kind = {:?}\n", a.kind.as_str()));
if let Some(m) = &a.model {
s.push_str(&format!("model = {m:?}\n"));
}
s.push('\n');
}
}
s.push_str(
"# Leave a role list empty to rotate through the roster.\n\
[roles]\n\
implementers = []\n\
judges = []\n\
reviewers = []\n\
# conductor = \"opus\" # arranges the queue; unset picks a seat like chatter does\n\n\
[graph]\n\
candidates = 3\n\
judges = 3\n\
deliberate_rounds = 1\n\
reviewers = 3\n\
review_rounds = 6\n\
max_parallel = 4\n\
language = \"en\"\n\
# One CLI conversation per seat: judges keep their own argument\n\
# across deliberation, the fixer keeps its implementation context.\n\
sessions = true\n\
# Reviewer-seat timeout. When timeout_verify is omitted, E2E and\n\
# the final gate inherit this value for compatibility.\n\
timeout_review = 1200\n\
# Optional independent E2E/final-gate timeout; uncomment to keep\n\
# verification independent if timeout_review changes later.\n\
# timeout_verify = 1200\n\n\
[verify]\n\
# Run once per review round in the winner's worktree; failures are\n\
# fed back to the fixer.\n\
e2e = []\n\
# Final gate. Every command must exit 0 before a merge.\n\
gate = []\n\n\
[merge]\n\
# none | local | pr\n\
mode = \"none\"\n\n\
[update]\n\
# off | notify | install — checked in the background, throttled.\n\
mode = \"notify\"\n\
# interval = \"24h\"\n",
);
s
}
}
pub fn which(program: &str) -> bool {
let Some(paths) = std::env::var_os("PATH") else {
return false;
};
let exts: Vec<String> = std::env::var("PATHEXT")
.map(|v| v.split(';').map(|e| e.to_lowercase()).collect())
.unwrap_or_default();
std::env::split_paths(&paths).any(|dir| {
let direct = dir.join(program);
if direct.is_file() {
return true;
}
exts.iter().any(|ext| {
let mut name = program.to_owned();
name.push_str(ext);
dir.join(name).is_file()
})
})
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(id: &str) -> AgentSpec {
AgentSpec {
id: id.to_owned(),
kind: AgentKind::Command,
model: None,
command: vec!["true".to_owned()],
extra_args: Vec::new(),
env: BTreeMap::new(),
prompt_delivery: None,
}
}
#[test]
fn timeout_verify_omitted_from_old_toml_inherits_timeout_review() {
let g: Graph = toml::from_str("timeout_review = 3600").expect("parse");
assert_eq!(g.timeout_verify, None);
assert_eq!(g.verify_timeout(), 3600);
}
#[test]
fn shrinking_timeout_review_does_not_shrink_timeout_verify() {
let g: Graph = toml::from_str("timeout_review = 45").expect("parse");
assert_eq!(g.timeout_review, 45);
assert_eq!(
g.verify_timeout(),
45,
"an omitted legacy value follows review"
);
let explicit: Graph = toml::from_str("timeout_review = 45\ntimeout_verify = 1200")
.expect("parse explicit override");
assert_eq!(explicit.verify_timeout(), 1200);
}
#[test]
fn a_toml_layer_written_before_these_fields_existed_still_parses() {
let g: Graph =
toml::from_str("candidates = 1\nreviewers = 3\nreview_rounds = 6\nmax_parallel = 4\n")
.expect("an old-shaped [graph] table must still parse");
assert_eq!(g.verify_timeout(), Graph::default().timeout_review);
assert!(
!g.e2e_every_round,
"off by default, same as before this field existed"
);
}
#[test]
fn empty_roles_rotate_judges_off_their_own_candidate() {
let cfg = Config {
agents: vec![spec("a"), spec("b"), spec("c")],
graph: Graph {
candidates: 3,
..Graph::default()
},
..Config::default()
};
let roles = cfg.resolve_roles().unwrap();
let impls: Vec<&str> = roles.implementers.iter().map(|a| a.id.as_str()).collect();
let judges: Vec<&str> = roles.judges.iter().map(|a| a.id.as_str()).collect();
assert_eq!(impls, ["a", "b", "c"]);
assert_eq!(judges, ["b", "c", "a"]);
for (i, j) in judges.iter().enumerate() {
assert_ne!(*j, impls[i], "judge {i} must not sit on its own candidate");
}
}
#[test]
fn single_agent_roster_fills_every_seat() {
let cfg = Config {
agents: vec![spec("solo")],
graph: Graph {
candidates: 3,
..Graph::default()
},
..Config::default()
};
let roles = cfg.resolve_roles().unwrap();
assert_eq!(roles.implementers.len(), 3);
assert!(roles.judges.iter().all(|a| a.id == "solo"));
}
#[test]
fn explicit_roles_win() {
let cfg = Config {
agents: vec![spec("a"), spec("b")],
roles: Roles {
implementers: vec!["b".to_owned()],
judges: vec!["a".to_owned()],
reviewers: Vec::new(),
fixer: Some("a".to_owned()),
..Roles::default()
},
..Config::default()
};
let roles = cfg.resolve_roles().unwrap();
assert!(roles.implementers.iter().all(|a| a.id == "b"));
assert!(roles.judges.iter().all(|a| a.id == "a"));
assert_eq!(roles.fixer.unwrap().id, "a");
assert_eq!(roles.conductor.id, "a");
}
#[test]
fn unknown_agent_id_is_an_error() {
let cfg = Config {
agents: vec![spec("a")],
roles: Roles {
judges: vec!["nope".to_owned()],
..Roles::default()
},
..Config::default()
};
assert!(cfg.resolve_roles().is_err());
}
#[test]
fn conductor_role_is_resolved_validated_and_has_a_fallback() {
let mut cfg = Config {
agents: vec![spec("a"), spec("b")],
..Config::default()
};
assert_eq!(cfg.resolve_roles().unwrap().conductor.id, "a");
cfg.roles.conductor = Some("b".to_owned());
assert_eq!(cfg.resolve_roles().unwrap().conductor.id, "b");
cfg.roles.conductor = Some("missing".to_owned());
assert!(cfg.resolve_roles().is_err());
}
#[test]
fn empty_roster_is_an_error() {
assert!(Config::default().resolve_roles().is_err());
}
#[test]
fn repos_default_to_no_roots_and_a_day_of_trust() {
assert_eq!(Config::default().repos.roots, Vec::<PathBuf>::new());
assert_eq!(Config::default().repos.scan_ttl, 86_400);
}
#[test]
fn a_config_file_with_no_repos_table_still_loads() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("magi.toml");
std::fs::write(&path, "[graph]\ncandidates = 2\n").unwrap();
let cfg = Config::load(&path).expect("must load without [repos]");
assert_eq!(cfg.repos.roots, Vec::<PathBuf>::new());
assert_eq!(cfg.repos.scan_ttl, 86_400);
}
#[test]
fn a_test_build_does_not_read_the_operators_machine_config() {
let repo = tempfile::tempdir().unwrap();
std::fs::write(repo.path().join("magi.toml"), "[graph]\ncandidates = 2\n").unwrap();
let layers = Config::layers(repo.path());
assert_eq!(
layers,
vec![repo.path().join("magi.toml")],
"only the fixture's own file may be a layer"
);
if let Some(real) = dirs::config_dir() {
let machine = real.join("magi").join("config.toml");
assert!(
!layers.contains(&machine),
"the operator's {} must not be a layer in a test build",
machine.display()
);
}
}
#[test]
fn starter_toml_loads_through_teravars() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("magi.toml");
std::fs::write(&path, Config::starter_toml()).unwrap();
let parsed = Config::load(&path).expect("starter config must load");
assert_eq!(parsed.graph.candidates, 3);
assert_eq!(parsed.merge.mode, MergeMode::None);
assert_eq!(parsed.merge.style, MergeStyle::Merge);
assert!(parsed.graph.sessions);
assert_eq!(parsed.graph.timeout_review, 1200);
assert_eq!(parsed.graph.verify_timeout(), 1200);
assert_eq!(parsed.update.mode, UpdateMode::Notify);
}
#[test]
fn starter_toml_explains_inherited_and_explicit_verify_timeouts() {
let starter = Config::starter_toml();
assert!(starter.contains("When timeout_verify is omitted, E2E and"));
assert!(starter.contains("verification independent if timeout_review changes later"));
assert!(starter.contains("# timeout_verify = 1200"));
}
#[test]
fn a_repository_can_declare_a_linear_history_merge_style() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("magi.toml");
std::fs::write(&path, "[merge]\nmode = \"none\"\nstyle = \"squash\"\n").unwrap();
let parsed = Config::load(&path).expect("config must load");
assert_eq!(parsed.merge.style, MergeStyle::Squash);
}
#[test]
fn later_layers_win_and_vars_render() {
let dir = tempfile::tempdir().unwrap();
let machine = dir.path().join("machine.toml");
let project = dir.path().join("magi.toml");
std::fs::write(
&machine,
"[[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n\
[graph]\ncandidates = 3\nmax_parallel = 8\n",
)
.unwrap();
std::fs::write(
&project,
"[vars]\ncache = \"/shared\"\n\n\
[graph]\ncandidates = 2\n\n\
[verify]\ngate = [\"CARGO_TARGET_DIR={{ vars.cache }}/t cargo test\"]\n",
)
.unwrap();
let cfg = Config::load_layers(&[machine, project]).expect("layered load");
assert_eq!(cfg.agents.len(), 1, "roster comes from the machine layer");
assert_eq!(cfg.graph.candidates, 2, "project layer wins");
assert_eq!(cfg.graph.max_parallel, 8, "machine layer survives");
assert_eq!(
cfg.verify.gate,
["CARGO_TARGET_DIR=/shared/t cargo test".to_owned()]
);
assert_eq!(cfg.cache_dir(), Some(PathBuf::from("/shared/t")));
}
#[test]
fn talk_defaults_to_an_hour_and_an_unwritten_config_still_gets_it() {
let g = Graph::default();
assert_eq!(g.timeout_talk, 3600);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("magi.toml");
std::fs::write(&path, "[graph]\ncandidates = 2\n").unwrap();
let cfg = Config::load(&path).expect("must load without timeout_talk set");
assert_eq!(cfg.graph.timeout_talk, 3600);
}
#[test]
fn an_overridden_talk_timeout_reaches_the_loaded_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("magi.toml");
std::fs::write(&path, "[graph]\ntimeout_talk = 120\n").unwrap();
let cfg = Config::load(&path).expect("must load");
assert_eq!(cfg.graph.timeout_talk, 120);
}
#[test]
fn the_disk_defaults_are_the_measurements_made_up_front() {
let cfg = Config::default();
assert_eq!(cfg.disk.min_free_bytes, 8 * 1024 * 1024 * 1024);
assert!(cfg.disk.auto_fold);
assert_eq!(cfg.disk.fold_grace_secs, 6 * 60 * 60);
assert_eq!(cfg.disk.cache_limit_bytes, 10 * 1024 * 1024 * 1024);
}
#[test]
fn an_unset_disk_section_is_the_safe_default() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("magi.toml"), "[graph]\ncandidates = 1\n").unwrap();
let cfg = Config::load(&dir.path().join("magi.toml")).expect("load");
assert_eq!(cfg.disk, Disk::default());
}
#[test]
fn env_is_available_to_templates_with_a_default() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("magi.toml");
std::fs::write(
&path,
"[verify]\n\
gate = [\"cache={{ env.MAGI_TEST_UNSET_XYZ | default(value='fallback') }}\", \
\"populated={{ env | length > 0 }}\"]\n",
)
.unwrap();
let cfg = Config::load(&path).expect("env lookup must render");
assert_eq!(cfg.verify.gate[0], "cache=fallback");
assert_eq!(cfg.verify.gate[1], "populated=true");
}
#[test]
fn a_broken_template_names_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("magi.toml");
std::fs::write(&path, "[graph]\nlanguage = \"{{ nope.\"\n").unwrap();
let err = Config::load(&path).expect_err("must not silently ignore");
assert!(err.to_string().contains("teravars"), "{err}");
}
#[test]
fn tera_syntax_in_comments_is_inert() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("magi.toml");
std::fs::write(
&path,
"# a comment may quote templates: `{{ env.NOPE | default(value='x') }}` and `{% if %}`\n\
[graph]\ncandidates = 2\n",
)
.unwrap();
let cfg = Config::load(&path).expect("comments must be inert, not rendered");
assert_eq!(cfg.graph.candidates, 2);
}
#[test]
fn opencode_defaults_to_file_delivery() {
let mut s = spec("oc");
s.kind = AgentKind::Opencode;
assert_eq!(s.delivery(), Delivery::File);
s.prompt_delivery = Some(Delivery::Argv);
assert_eq!(s.delivery(), Delivery::Argv);
}
#[test]
fn the_land_loop_is_on_but_it_cannot_merge_without_being_asked() {
let g = Graph::default();
assert!(
g.land,
"stopping at an open PR left the watching to a human"
);
assert!(
g.land_approval,
"on-by-default land is only defensible while this is also on"
);
assert!(g.land_rounds > 0, "a loop with no budget never terminates");
}
#[test]
fn an_array_declared_in_two_layers_is_refused_instead_of_concatenated() {
let dir = tempfile::tempdir().unwrap();
let machine = dir.path().join("machine.toml");
let repo = dir.path().join("magi.toml");
std::fs::write(&machine, "[roles]\nimplementers = [\"a\", \"b\"]\n").unwrap();
std::fs::write(&repo, "[roles]\nimplementers = [\"oc\"]\n").unwrap();
let err = Config::load_layers(&[machine.clone(), repo.clone()])
.expect_err("two layers naming one array must not merge silently")
.to_string();
assert!(err.contains("roles.implementers"), "{err}");
assert!(err.contains("machine.toml"), "{err}");
assert!(err.contains("magi.toml"), "{err}");
}
#[test]
fn a_scalar_in_one_layer_and_an_array_in_another_still_merges() {
let dir = tempfile::tempdir().unwrap();
let machine = dir.path().join("machine.toml");
let repo = dir.path().join("magi.toml");
std::fs::write(&machine, "[roles]\nchatter = \"opus\"\n").unwrap();
std::fs::write(
&repo,
"[[agents]]\nid = \"oc\"\nkind = \"opencode\"\n\n\
[roles]\nimplementers = [\"oc\"]\n",
)
.unwrap();
let cfg = Config::load_layers(&[machine, repo]).expect("layers merge");
assert_eq!(cfg.roles.chatter.as_deref(), Some("opus"));
assert_eq!(cfg.roles.implementers, ["oc"]);
assert_eq!(cfg.agents.len(), 1, "the roster is not doubled");
}
#[test]
fn two_layers_declaring_verify_gate_run_both_in_priority_order() {
let dir = tempfile::tempdir().unwrap();
let machine = dir.path().join("machine.toml");
let repo = dir.path().join("magi.toml");
std::fs::write(&machine, "[verify]\ngate = [\"editorconfig-checker\"]\n").unwrap();
std::fs::write(&repo, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
assert_eq!(
cfg.verify.gate,
[
"editorconfig-checker".to_owned(),
"cargo make check".to_owned()
],
"low-priority (machine) command first, high-priority (repo) command after"
);
}
#[test]
fn two_layers_declaring_verify_e2e_run_both_in_priority_order() {
let dir = tempfile::tempdir().unwrap();
let machine = dir.path().join("machine.toml");
let repo = dir.path().join("magi.toml");
std::fs::write(&machine, "[verify]\ne2e = [\"shared-smoke-test\"]\n").unwrap();
std::fs::write(&repo, "[verify]\ne2e = [\"cargo test\"]\n").unwrap();
let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
assert_eq!(
cfg.verify.e2e,
["shared-smoke-test".to_owned(), "cargo test".to_owned()]
);
}
#[test]
fn two_layers_declaring_repos_roots_are_both_scanned() {
let dir = tempfile::tempdir().unwrap();
let machine = dir.path().join("machine.toml");
let repo = dir.path().join("magi.toml");
std::fs::write(&machine, "[repos]\nroots = [\"/machine/root\"]\n").unwrap();
std::fs::write(&repo, "[repos]\nroots = [\"/repo/root\"]\n").unwrap();
let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
assert_eq!(
cfg.repos.roots,
[PathBuf::from("/machine/root"), PathBuf::from("/repo/root")]
);
}
#[test]
fn duplicate_gate_commands_across_layers_both_run() {
let dir = tempfile::tempdir().unwrap();
let machine = dir.path().join("machine.toml");
let repo = dir.path().join("magi.toml");
std::fs::write(&machine, "[verify]\ngate = [\"same-command\"]\n").unwrap();
std::fs::write(&repo, "[verify]\ngate = [\"same-command\"]\n").unwrap();
let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
assert_eq!(
cfg.verify.gate,
["same-command".to_owned(), "same-command".to_owned()]
);
}
#[test]
fn notify_command_is_still_refused_across_two_layers() {
let dir = tempfile::tempdir().unwrap();
let machine = dir.path().join("machine.toml");
let repo = dir.path().join("magi.toml");
std::fs::write(&machine, "[notify]\ncommand = [\"ntfy\", \"publish\"]\n").unwrap();
std::fs::write(&repo, "[notify]\ncommand = [\"curl\", \"-X\"]\n").unwrap();
let err = Config::load_layers(&[machine.clone(), repo.clone()])
.expect_err("an argv split across layers must not concatenate")
.to_string();
assert!(err.contains("notify.command"), "{err}");
assert!(err.contains("machine.toml"), "{err}");
assert!(err.contains("magi.toml"), "{err}");
}
#[test]
fn one_layer_declaring_verify_gate_runs_unchanged() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("magi.toml");
std::fs::write(&path, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
let cfg = Config::load(&path).expect("single layer must still load");
assert_eq!(cfg.verify.gate, ["cargo make check".to_owned()]);
}
#[test]
fn describe_composed_names_the_contributing_layers_only_when_there_are_two() {
let dir = tempfile::tempdir().unwrap();
let machine = dir.path().join("machine.toml");
let repo = dir.path().join("magi.toml");
std::fs::write(&machine, "[verify]\ngate = [\"editorconfig-checker\"]\n").unwrap();
std::fs::write(&repo, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
let paths = vec![machine.clone(), repo.clone()];
let cfg = Config::load_layers(&paths).expect("appendable arrays must merge");
let described =
Config::describe_composed(&paths, &cfg.verify.gate, "verify.gate", "(none)");
assert!(described.contains("editorconfig-checker && cargo make check"));
assert!(
described.contains(&machine.display().to_string()),
"{described}"
);
assert!(
described.contains(&repo.display().to_string()),
"{described}"
);
let single = vec![repo.clone()];
let solo_cfg = Config::load_layers(&single).expect("single layer loads");
let solo_described =
Config::describe_composed(&single, &solo_cfg.verify.gate, "verify.gate", "(none)");
assert_eq!(solo_described, "cargo make check");
}
}