#![allow(dead_code)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use magi::config::{
AgentKind, AgentSpec, Blind, Config, Graph, Merge, MergeMode, Roles, Update, UpdateMode, Verify,
};
static HOME_LOCK: LazyLock<tokio::sync::Mutex<()>> = LazyLock::new(|| tokio::sync::Mutex::new(()));
pub async fn home_lock() -> tokio::sync::MutexGuard<'static, ()> {
HOME_LOCK.lock().await
}
pub enum Judges {
Unanimous,
Split,
}
pub struct Fixture {
pub tmp: tempfile::TempDir,
pub repo: PathBuf,
pub config: Config,
}
const MOCK: &str = r#"#!/bin/sh
# Mock agent. Dispatches on the prompt magi generated for this seat.
set -e
p="$MAGI_PROMPT_FILE"
seat="$MAGI_SEAT"
# Attribution trail. `magi task add` turns MAGI_RUN / MAGI_NODE into a task's
# source, so a run whose agents never receive them would silently attribute
# agent-filed work to a passing human. Recording them here is what lets a test
# assert the plumbing from inside a real graph run.
printf '%s %s %s\n' "$MAGI_RUN" "$MAGI_NODE" "$seat" \
>> "$(dirname "$p")/attribution.log"
# Rate-limit simulation: a matching seat reports the same error shape a real
# claude prints on quota exhaustion, and exits non-zero. The graph must read
# this as "rate limited" — not a normal failure, not retried.
#
# Deliberately NOT gated on which prompt arrived. A quota is a property of the
# account, not of the question: an exhausted seat answers every prompt the same
# way, including a retry nudge. Gating it on the judging prompt let the nudge
# fall through to the implementation branch below, so a seat that had just been
# rate limited answered a retry with a SUMMARY block claiming it created
# note.txt — which looks like success, and hid the very retry this forbids.
if [ -n "$MOCK_QUOTA_SEAT" ] && { case ",$MOCK_QUOTA_SEAT," in *",$seat,"*) true ;; *) false ;; esac; }; then
printf '{"is_error":true,"terminal_reason":"api_error","result":"You'\''ve hit your session limit","session_id":"quota-test"}\n'
exit 1
fi
# Dropped-stream simulation: a matching implement seat's first reply is the
# "billed work, nothing delivered" shape a CLI leaves when it hangs up on its
# own stream mid-response (see `agent::dropped_stream`). The resumed call is
# recognisable by the words `prompt::resume_after_drop` actually sends, and
# falls through to the ordinary implementation branch at the bottom so it
# always succeeds.
if [ -n "$MOCK_DROPPED_SEAT" ] && { case ",$MOCK_DROPPED_SEAT," in *",$seat,"*) true ;; *) false ;; esac; } && ! grep -q "Your last reply never reached me" "$p"; then
printf '{"conversation_id":"mock-convo-%s","status":"ERROR","response":"","error":"subscriber fell behind updates, stalled for 5s","usage":{"output_tokens":500}}\n' "$seat"
exit 1
fi
# Ordinary-failure simulation: a matching judge seat produces no usable output
# and exits non-zero — distinctly NOT the rate-limit shape above, so the graph
# must treat it as a plain failure (retried the configured number of times),
# meanwhile still collapsing the quorum if enough seats drop.
if [ -n "$MOCK_FAILED_SEAT" ] && { case ",$MOCK_FAILED_SEAT," in *",$seat,"*) true ;; *) false ;; esac; } && grep -q "independent judges" "$p"; then
echo 'not a ranking at all'
exit 1
fi
if grep -q "Final vote" "$p"; then
printf '```json\n{"vote":"%s","reason":"mock final vote"}\n```\n' "$MOCK_VOTE"
exit 0
fi
# Dropped-stream simulation, deliberation-only: a matching judge's
# *deliberation round* reply (never its initial ranking or its vote) is the
# same "billed work, nothing delivered" shape as above. Regression coverage
# for `deliberate()`'s own `AgentOutcome` handling, which must skip the turn
# rather than record the CLI's raw error JSON as the judge's position.
if [ -n "$MOCK_DROPPED_DELIBERATE_SEAT" ] && { case ",$MOCK_DROPPED_DELIBERATE_SEAT," in *",$seat,"*) true ;; *) false ;; esac; } && grep -q "deliberation round" "$p"; then
printf '{"conversation_id":"mock-convo-%s","status":"ERROR","response":"","error":"subscriber fell behind updates, stalled for 5s","usage":{"output_tokens":500}}\n' "$seat"
exit 1
fi
if grep -q "deliberation round" "$p"; then
printf '## POSITION\nThe mock argues for %s and cites nothing.\n\n' "$MOCK_VOTE"
printf '```json\n{"tentative":"%s"}\n```\n' "$MOCK_VOTE"
exit 0
fi
if grep -q "independent judges" "$p"; then
n="${seat#judge-}"
if [ "$MOCK_JUDGES" = "split" ]; then
case "$n" in
1) r='["A","B","C"]' ;;
2) r='["B","C","A"]' ;;
*) r='["C","A","B"]' ;;
esac
else
r='["A","B","C"]'
fi
printf '```json\n{"ranking":%s,"reasons":{"A":"mock"},"confidence":4}\n```\n' "$r"
exit 0
fi
if grep -q "reviewers of" "$p"; then
if [ -n "$MOCK_FINDING" ] && [ ! -f fixed.txt ]; then
printf '{"summary":"mock review","findings":[{"severity":"blocker","file":"note.txt","line":1,"title":"needs a fixed marker","detail":"create fixed.txt"}]}\n'
else
printf '{"summary":"mock review: clean","findings":[]}\n'
fi
exit 0
fi
if grep -q "Your patch was reviewed" "$p"; then
echo "fixed" > fixed.txt
git add -A >/dev/null 2>&1
git commit -q -m "address review findings" >/dev/null 2>&1
id=$(grep -o 'R[0-9]*-[0-9]*-[0-9]*' "$p" | head -1)
printf '{"addressed":["%s"],"rejected":[],"notes":"created fixed.txt"}\n' "$id"
exit 0
fi
# Anything else is the implementation node.
echo "content from $seat" > note.txt
git add -A >/dev/null 2>&1
git commit -q -m "add note from $seat" >/dev/null 2>&1
printf '## SUMMARY\n- created note.txt\n- no risks\n'
"#;
fn run_git(repo: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.args(args)
.current_dir(repo)
.output()
.expect("spawn git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
pub fn fixture(judges: Judges, require_fix: bool) -> Fixture {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
run_git(&repo, &["init", "-b", "main"]);
run_git(&repo, &["config", "user.name", "magi test"]);
run_git(&repo, &["config", "user.email", "magi@example.com"]);
std::fs::write(repo.join("README.md"), "# fixture\n").unwrap();
run_git(&repo, &["add", "-A"]);
run_git(&repo, &["commit", "-m", "init"]);
let script = tmp.path().join("mock-agent.sh");
std::fs::write(&script, MOCK).unwrap();
magi::run::set_home(tmp.path().join("magi-home"));
let (judge_mode, vote) = match judges {
Judges::Unanimous => ("unanimous", "A"),
Judges::Split => ("split", "B"),
};
let mut env = BTreeMap::from([
("MOCK_JUDGES".to_owned(), judge_mode.to_owned()),
("MOCK_VOTE".to_owned(), vote.to_owned()),
]);
if require_fix {
env.insert("MOCK_FINDING".to_owned(), "1".to_owned());
}
let agent = |id: &str| AgentSpec {
id: id.to_owned(),
kind: AgentKind::Command,
model: None,
command: vec!["sh".to_owned(), script.to_string_lossy().into_owned()],
extra_args: Vec::new(),
env: env.clone(),
prompt_delivery: None,
};
let config = Config {
agents: vec![agent("alpha"), agent("beta"), agent("gamma")],
roles: Roles::default(),
graph: Graph {
candidates: 3,
judges: 3,
deliberate_rounds: 1,
reviewers: 2,
review_rounds: 3,
max_parallel: 3,
language: "en".to_owned(),
sessions: true,
timeout_implement: 120,
timeout_judge: 120,
timeout_review: 120,
timeout_fix: 120,
retries: 1,
worktree_root: Some(tmp.path().join("wt")),
..Graph::default()
},
blind: Blind {
seed: Some(20_260_830),
..Blind::default()
},
verify: Verify {
e2e: vec!["test -f note.txt".to_owned()],
gate: vec!["test -f note.txt".to_owned()],
shell: Some(vec!["sh".to_owned(), "-c".to_owned()]),
},
merge: Merge {
mode: MergeMode::None,
base: None,
remote: "origin".to_owned(),
},
update: Update {
mode: UpdateMode::Off,
interval: None,
},
..Config::default()
};
Fixture { tmp, repo, config }
}
pub fn fixture_with_quota(quota_seats: &[&str]) -> Fixture {
let mut fx = fixture(Judges::Unanimous, false);
let value = quota_seats.join(",");
for a in &mut fx.config.agents {
a.env.insert("MOCK_QUOTA_SEAT".to_owned(), value.clone());
}
fx
}
pub fn fixture_with_failure(failed_seats: &[&str]) -> Fixture {
let mut fx = fixture(Judges::Unanimous, false);
let value = failed_seats.join(",");
for a in &mut fx.config.agents {
a.env.insert("MOCK_FAILED_SEAT".to_owned(), value.clone());
}
fx
}
pub fn fixture_with_dropped_stream(seats: &[&str]) -> Fixture {
let mut fx = fixture(Judges::Unanimous, false);
let value = seats.join(",");
for a in &mut fx.config.agents {
a.env.insert("MOCK_DROPPED_SEAT".to_owned(), value.clone());
}
fx
}
pub fn fixture_with_dropped_deliberation(seats: &[&str]) -> Fixture {
let mut fx = fixture(Judges::Split, false);
let value = seats.join(",");
for a in &mut fx.config.agents {
a.env
.insert("MOCK_DROPPED_DELIBERATE_SEAT".to_owned(), value.clone());
}
fx
}