use std::{
fs,
ops::Deref,
path::{Path, PathBuf},
process::{Command, Output},
};
fn binary() -> &'static str {
env!("CARGO_BIN_EXE_shepherd")
}
struct FixtureDir {
_guard: tempfile::TempDir,
root: PathBuf,
}
impl Deref for FixtureDir {
type Target = Path;
fn deref(&self) -> &Self::Target {
&self.root
}
}
impl AsRef<Path> for FixtureDir {
fn as_ref(&self) -> &Path {
&self.root
}
}
fn repository(label: &str) -> FixtureDir {
let guard = tempfile::Builder::new()
.prefix(&format!("shepherd-wave-a-models-{label}-"))
.tempdir()
.expect("create fixture root");
let root = guard.path().to_path_buf();
let status = Command::new("git")
.args(["init", "--quiet"])
.current_dir(&root)
.status()
.expect("initialize fixture repository");
assert!(status.success());
FixtureDir {
_guard: guard,
root,
}
}
fn write_pi_targets(root: &Path) {
let config_dir = root.join(".shepherd");
fs::create_dir_all(&config_dir).expect("create native configuration directory");
fs::write(
config_dir.join("shepherd.pi.toml"),
"[model_targets.pi]\ninherit-caller = \"inherit\"\nreasoning-high = \"openai-codex/gpt-5.6-sol:xhigh\"\nstandard = \"openai-codex/gpt-5.6-luna:max\"\neconomy = \"openai-codex/gpt-5.6-luna:max\"\n",
)
.expect("write Pi model targets");
}
fn command(root: &Path) -> Command {
let mut command = Command::new(binary());
command
.current_dir(root)
.env("SHEPHERD_HOME", root.join("isolated-home"))
.env_remove("SHEPHERD_HARNESS")
.env_remove("CLAUDECODE")
.env_remove("CLAUDE_PLUGIN_ROOT")
.env_remove("CODEX_HOME");
command
}
fn run(root: &Path, args: &[&str]) -> Output {
command(root).args(args).output().expect("run shepherd")
}
fn run_under_harness(root: &Path, harness: &str, args: &[&str]) -> Output {
command(root)
.env("SHEPHERD_HARNESS", harness)
.args(args)
.output()
.expect("run shepherd under harness")
}
/// A bare temporary directory, no git repository -- for commands (like
/// `compile --content-dir`) that never touch project discovery.
fn tmp_dir(label: &str) -> FixtureDir {
let guard = tempfile::Builder::new()
.prefix(&format!("shepherd-wave-a-models-{label}-"))
.tempdir()
.expect("create temp root");
let root = guard.path().to_path_buf();
FixtureDir {
_guard: guard,
root,
}
}
const ROLES: [&str; 9] = [
"root",
"planter",
"engineer",
"conductor",
"critic",
"discovery",
"coder",
"auditor",
"worker",
];
#[test]
fn role_class_resolve_and_show_use_provider_neutral_defaults() {
let root = repository("defaults");
let resolve = run(&root, &["models", "resolve", "coder"]);
assert!(
resolve.status.success(),
"stderr={}",
String::from_utf8_lossy(&resolve.stderr)
);
assert_eq!(resolve.stdout, b"bulk-work\n");
assert!(resolve.stderr.is_empty());
let show = run(&root, &["models", "show", "--json"]);
assert!(
show.status.success(),
"stderr={}",
String::from_utf8_lossy(&show.stderr)
);
assert_eq!(
show.stdout,
br#"{
"root": {"model": "root", "source": "default"},
"planter": {"model": "root", "source": "default"},
"engineer": {"model": "team-lead", "source": "default"},
"conductor": {"model": "team-lead", "source": "default"},
"critic": {"model": "adversarial-review", "source": "default"},
"discovery": {"model": "bulk-work", "source": "default"},
"coder": {"model": "bulk-work", "source": "default"},
"auditor": {"model": "adversarial-review", "source": "default"},
"worker": {"model": "bulk-work", "source": "default"}
}
"#
);
assert!(show.stderr.is_empty());
}
#[test]
fn role_class_resolve_uses_each_adapter_default_and_pi_fails_closed() {
let root = repository("harness-profiles");
for (harness, expected) in [
("claude", b"opus[1m]\n".as_slice()),
("codex", b"gpt-5.6-sol\n".as_slice()),
] {
for role in ["planter", "engineer", "conductor"] {
let output = run(&root, &["models", "resolve", role, "--harness", harness]);
assert!(
output.status.success(),
"{harness}/{role}: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(output.stdout, expected, "{harness}/{role}");
}
}
let missing = run(&root, &["models", "resolve", "engineer", "--harness", "pi"]);
assert_eq!(missing.status.code(), Some(2));
assert_eq!(
String::from_utf8_lossy(&missing.stderr),
"ERROR: Pi model target missing for class `team-lead`. Add it to `[[harness]] kind = \"pi\"` and `[harness.models]` in Shepherd configuration.\n"
);
assert!(missing.stdout.is_empty());
}
#[test]
fn models_resolve_pi_uses_the_configured_concrete_target_map() {
let root = repository("pi-target-map");
let config_dir = root.join(".shepherd");
fs::create_dir_all(&config_dir).expect("create native configuration directory");
fs::write(
config_dir.join("shepherd.pi.toml"),
"[model_targets.pi]\ninherit-caller = \"inherit\"\nreasoning-high = \"openai-codex/gpt-5.6-sol:xhigh\"\nstandard = \"openai-codex/gpt-5.6-luna:max\"\neconomy = \"openai-codex/gpt-5.6-luna:max\"\n",
)
.expect("write Pi model targets");
for (role, expected) in [
("planter", b"openai-codex/gpt-5.6-sol:xhigh\n".as_slice()),
("critic", b"openai-codex/gpt-5.6-luna:max\n".as_slice()),
("discovery", b"openai-codex/gpt-5.6-luna:max\n".as_slice()),
("engineer", b"openai-codex/gpt-5.6-sol:xhigh\n".as_slice()),
("conductor", b"openai-codex/gpt-5.6-sol:xhigh\n".as_slice()),
] {
let output = run(&root, &["models", "resolve", role, "--harness", "pi"]);
assert!(
output.status.success(),
"{role}: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(output.stdout, expected, "{role}");
}
}
#[test]
fn requested_harnesses_load_project_model_overrides_for_resolve_and_show() {
let root = repository("requested-harness-overrides");
let config_dir = root.join(".shepherd");
fs::create_dir_all(&config_dir).expect("create native configuration directory");
fs::write(
config_dir.join("shepherd.toml"),
"[models]\nengineer = \"economy\"\n\n[model_targets.pi]\nreasoning-high = \"openai-codex/gpt-5.6-sol:xhigh\"\nstandard = \"openai-codex/gpt-5.6-luna:max\"\neconomy = \"openai-codex/gpt-5.6-luna:max\"\n",
)
.expect("write project model override");
for (harness, expected) in [
("claude", "haiku"),
("codex", "gpt-5.6-luna"),
("pi", "openai-codex/gpt-5.6-luna:max"),
] {
let resolved = run(
&root,
&["models", "resolve", "engineer", "--harness", harness],
);
assert!(
resolved.status.success(),
"{harness}: {}",
String::from_utf8_lossy(&resolved.stderr)
);
assert_eq!(
resolved.stdout,
format!("{expected}\n").as_bytes(),
"{harness}"
);
let shown = run(&root, &["models", "show", "--harness", harness, "--json"]);
assert!(
shown.status.success(),
"{harness}: {}",
String::from_utf8_lossy(&shown.stderr)
);
assert!(
String::from_utf8_lossy(&shown.stdout).contains(&format!(
"\"engineer\": {{\"model\": \"{expected}\", \"source\": \"config\"}}"
)),
"{harness}: {}",
String::from_utf8_lossy(&shown.stdout)
);
}
}
#[test]
fn explicit_harness_config_is_validated_under_the_requested_harness() {
let root = repository("explicit-requested-harness");
let config_dir = root.join(".shepherd");
fs::create_dir_all(&config_dir).expect("create native configuration directory");
fs::write(
config_dir.join("shepherd.claude.toml"),
"[models]\nengineer = \"standard\"\n",
)
.expect("write explicit Claude configuration");
let resolved = run(
&root,
&[
"--config",
".shepherd/shepherd.claude.toml",
"models",
"resolve",
"engineer",
"--harness",
"claude",
"--json",
],
);
assert!(
resolved.status.success(),
"stderr={}",
String::from_utf8_lossy(&resolved.stderr)
);
assert_eq!(
resolved.stdout,
b"{\n \"role\": \"engineer\",\n \"class\": \"team-lead\",\n \"model\": \"sonnet\",\n \"source\": \"config\",\n \"harness\": \"claude\"\n}\n"
);
}
#[test]
fn no_target_preserves_the_environment_harness_for_config_selection() {
let root = repository("environment-harness");
let config_dir = root.join(".shepherd");
fs::create_dir_all(&config_dir).expect("create native configuration directory");
fs::write(
config_dir.join("shepherd.claude.toml"),
"[models]\nconductor = \"economy\"\n",
)
.expect("write environment-selected Claude configuration");
let resolved = run_under_harness(
&root,
"claude",
&["models", "resolve", "conductor", "--json"],
);
assert!(
resolved.status.success(),
"stderr={}",
String::from_utf8_lossy(&resolved.stderr)
);
assert_eq!(
resolved.stdout,
b"{\n \"role\": \"conductor\",\n \"class\": \"team-lead\",\n \"model\": \"economy\",\n \"source\": \"config\"\n}\n"
);
}
#[test]
fn context_free_missing_config_keeps_defaults_and_pi_fails_closed() {
let root = tmp_dir("context-free-missing-config");
for (harness, expected) in [
("claude", b"opus[1m]\n".as_slice()),
("codex", b"gpt-5.6-sol\n".as_slice()),
] {
let resolved = run(
&root,
&["models", "resolve", "engineer", "--harness", harness],
);
assert!(
resolved.status.success(),
"{harness}: {}",
String::from_utf8_lossy(&resolved.stderr)
);
assert_eq!(resolved.stdout, expected, "{harness}");
}
let pi = run(&root, &["models", "resolve", "engineer", "--harness", "pi"]);
assert_eq!(pi.status.code(), Some(2));
assert_eq!(
pi.stderr,
b"ERROR: Pi model target missing for class `team-lead`. Add it to `[[harness]] kind = \"pi\"` and `[harness.models]` in Shepherd configuration.\n"
);
}
#[test]
fn malformed_repository_never_falls_back_to_context_free_defaults() {
let root = tmp_dir("malformed-repository");
fs::create_dir(root.join(".git")).expect("create malformed repository marker");
let resolved = run(
&root,
&["models", "resolve", "engineer", "--harness", "claude"],
);
assert!(!resolved.status.success());
assert!(resolved.stdout.is_empty());
assert!(
String::from_utf8_lossy(&resolved.stderr)
.contains("cannot resolve primary repository root"),
"stderr={}",
String::from_utf8_lossy(&resolved.stderr)
);
}
#[test]
fn models_resolve_uses_an_explicit_canonical_config_and_tracks_its_source() {
let root = repository("config");
let config_dir = root.join(".shepherd");
fs::create_dir_all(&config_dir).expect("create native configuration directory");
fs::write(
config_dir.join("shepherd.toml"),
"[models]\ncoder = \"native-coder\"\n",
)
.expect("write native configuration");
let resolve = run(
&root,
&[
"--config",
".shepherd/shepherd.toml",
"models",
"resolve",
"coder",
"--json",
],
);
assert!(
resolve.status.success(),
"stderr={}",
String::from_utf8_lossy(&resolve.stderr)
);
assert_eq!(
resolve.stdout,
b"{\n \"role\": \"coder\",\n \"class\": \"bulk-work\",\n \"model\": \"native-coder\",\n \"source\": \"config\"\n}\n"
);
assert!(resolve.stderr.is_empty());
}
#[test]
fn models_negative_inputs_keep_the_oracle_messages_and_exit_code() {
let root = repository("negative");
let missing = run(&root, &["models", "resolve"]);
assert_eq!(missing.status.code(), Some(2));
assert!(missing.stdout.is_empty());
assert_eq!(
missing.stderr,
b"ERROR: usage: shepherd models resolve <role>\n"
);
let unknown = run(&root, &["models", "resolve", "invalid"]);
assert_eq!(unknown.status.code(), Some(2));
assert!(unknown.stdout.is_empty());
assert_eq!(
unknown.stderr,
b"ERROR: unknown role: invalid (valid: root planter engineer conductor critic discovery coder auditor worker; alias: shepherd -> root)\n"
);
}
#[test]
fn models_resolve_shepherd_alias_matches_the_canonical_root_output_across_every_harness() {
let root = repository("shepherd-alias");
write_pi_targets(&root);
// Compare live outputs rather than hardcoding a hint value: the value
// that answers `resolve root` is config/profile-derived (see
// `models_resolve_delegates_harness_translation_to_the_compiler_profiles`
// above, which already covers what each harness spells root as), and
// hardcoding it here would just be a second fitted assertion of the same
// fact the `root` row already encodes.
for harness in [None, Some("claude"), Some("codex"), Some("pi")] {
let mut shepherd_args = vec!["models", "resolve", "shepherd"];
let mut root_args = vec!["models", "resolve", "root"];
if let Some(harness) = harness {
shepherd_args.extend(["--harness", harness]);
root_args.extend(["--harness", harness]);
}
let shepherd_out = run(&root, &shepherd_args);
let root_out = run(&root, &root_args);
assert!(
shepherd_out.status.success(),
"{harness:?}: stderr={}",
String::from_utf8_lossy(&shepherd_out.stderr)
);
assert!(
root_out.status.success(),
"{harness:?}: stderr={}",
String::from_utf8_lossy(&root_out.stderr)
);
assert_eq!(
shepherd_out.stdout, root_out.stdout,
"{harness:?}: `resolve shepherd` must print byte-identical stdout to `resolve root`"
);
assert!(shepherd_out.stderr.is_empty(), "{harness:?}");
assert!(root_out.stderr.is_empty(), "{harness:?}");
}
}
#[test]
fn models_show_lists_exactly_the_canonical_roles_and_never_a_shepherd_row() {
let root = repository("show-no-shepherd-row");
write_pi_targets(&root);
let plain = run(&root, &["models", "show"]);
assert!(
plain.status.success(),
"stderr={}",
String::from_utf8_lossy(&plain.stderr)
);
let plain_text = String::from_utf8_lossy(&plain.stdout);
// Table rows are the only lines with the renderer's two-space indent
// (`" {role:<10} {model:<10} (…)"`); the footer's `root is advisory...`
// sentence starts flush left and must not be miscounted as a row.
let plain_role_lines = plain_text
.lines()
.filter(|line| {
line.strip_prefix(" ")
.is_some_and(|rest| ROLES.iter().any(|role| rest.starts_with(role)))
})
.count();
assert_eq!(plain_role_lines, ROLES.len(), "{plain_text}");
// Row-shaped, not a bare substring check: the table title itself is
// "shepherd model map (resolved)", which legitimately contains the word
// without being a role row.
assert!(
!plain_text.lines().any(|line| line
.strip_prefix(" ")
.is_some_and(|rest| rest.starts_with("shepherd"))),
"{plain_text}"
);
let markdown = run(&root, &["models", "show", "--md"]);
assert!(
markdown.status.success(),
"stderr={}",
String::from_utf8_lossy(&markdown.stderr)
);
let markdown_text = String::from_utf8_lossy(&markdown.stdout);
let markdown_role_rows = markdown_text
.lines()
.filter(|line| {
line.starts_with('|')
&& ROLES
.iter()
.any(|role| line.contains(&format!("| {role} |")))
})
.count();
assert_eq!(markdown_role_rows, ROLES.len(), "{markdown_text}");
assert!(!markdown_text.contains("shepherd"), "{markdown_text}");
let json = run(&root, &["models", "show", "--json"]);
assert!(
json.status.success(),
"stderr={}",
String::from_utf8_lossy(&json.stderr)
);
let json_text = String::from_utf8_lossy(&json.stdout);
let json_role_keys = json_text
.lines()
.filter(|line| {
let trimmed = line.trim_start();
ROLES
.iter()
.any(|role| trimmed.starts_with(&format!("\"{role}\":")))
})
.count();
assert_eq!(json_role_keys, ROLES.len(), "{json_text}");
assert!(!json_text.contains("\"shepherd\""), "{json_text}");
for harness in ["claude", "codex", "pi"] {
let harnessed = run(&root, &["models", "show", "--harness", harness, "--json"]);
assert!(
harnessed.status.success(),
"{harness}: stderr={}",
String::from_utf8_lossy(&harnessed.stderr)
);
let harnessed_text = String::from_utf8_lossy(&harnessed.stdout);
let harnessed_role_keys = harnessed_text
.lines()
.filter(|line| {
let trimmed = line.trim_start();
ROLES
.iter()
.any(|role| trimmed.starts_with(&format!("\"{role}\":")))
})
.count();
assert_eq!(
harnessed_role_keys,
ROLES.len(),
"{harness}: {harnessed_text}"
);
assert!(
!harnessed_text.contains("\"shepherd\""),
"{harness}: {harnessed_text}"
);
}
}
#[test]
fn models_show_harness_translates_every_role_to_the_harness_native_spelling() {
let root = repository("show-harness");
write_pi_targets(&root);
// Root and persistent leads stay on lead-capable routes, bounded work uses
// the bulk route, and Critic/Auditor retain a distinct adversarial route.
// Each harness adapter translates the shared classes to native targets.
for (harness, root_tier, lead_tier, bulk_tier, review_tier, source) in [
(
"claude", "opus[1m]", "opus[1m]", "haiku", "sonnet", "default",
),
(
"codex",
"gpt-5.6-sol",
"gpt-5.6-sol",
"gpt-5.6-luna",
"gpt-5.6-terra",
"default",
),
(
"pi",
"openai-codex/gpt-5.6-sol:xhigh",
"openai-codex/gpt-5.6-sol:xhigh",
"openai-codex/gpt-5.6-luna:max",
"openai-codex/gpt-5.6-luna:max",
"config",
),
] {
let show = run(&root, &["models", "show", "--harness", harness, "--json"]);
assert!(
show.status.success(),
"{harness}: stderr={}",
String::from_utf8_lossy(&show.stderr)
);
let expected = format!(
"{{\n \"root\": {{\"model\": \"{root_tier}\", \"source\": \"{source}\"}},\n \"planter\": {{\"model\": \"{root_tier}\", \"source\": \"{source}\"}},\n \"engineer\": {{\"model\": \"{lead_tier}\", \"source\": \"{source}\"}},\n \"conductor\": {{\"model\": \"{lead_tier}\", \"source\": \"{source}\"}},\n \"critic\": {{\"model\": \"{review_tier}\", \"source\": \"{source}\"}},\n \"discovery\": {{\"model\": \"{bulk_tier}\", \"source\": \"{source}\"}},\n \"coder\": {{\"model\": \"{bulk_tier}\", \"source\": \"{source}\"}},\n \"auditor\": {{\"model\": \"{review_tier}\", \"source\": \"{source}\"}},\n \"worker\": {{\"model\": \"{bulk_tier}\", \"source\": \"{source}\"}}\n}}\n"
);
assert_eq!(String::from_utf8_lossy(&show.stdout), expected, "{harness}");
assert!(show.stderr.is_empty(), "{harness}");
}
// The exact invocation shape the operator names: `--harness` composes
// with `--md` and reuses the same renderer, byte-identical in shape to
// the unharnessed table.
let markdown = run(&root, &["models", "show", "--harness", "claude", "--md"]);
assert!(
markdown.status.success(),
"stderr={}",
String::from_utf8_lossy(&markdown.stderr)
);
let markdown_text = String::from_utf8_lossy(&markdown.stdout);
assert!(
markdown_text.starts_with("| role | model | source |\n|---|---|---|\n"),
"{markdown_text}"
);
assert!(
markdown_text.contains("| root | `opus[1m]` | default |"),
"{markdown_text}"
);
assert!(
markdown_text.contains("| conductor | `opus[1m]` | default |"),
"{markdown_text}"
);
assert!(
markdown_text.contains("| engineer | `opus[1m]` | default |"),
"{markdown_text}"
);
assert!(
markdown_text.contains("| coder | `haiku` | default |"),
"{markdown_text}"
);
assert!(
markdown_text.contains("| discovery | `haiku` | default |"),
"{markdown_text}"
);
}
#[test]
fn models_show_harness_rejects_an_unknown_harness_with_the_resolve_message_shape() {
let root = repository("show-harness-negative");
// Before this change, `--harness` was not a recognized flag on `show` at
// all: `error: unexpected argument '--harness' found`. Now it is
// recognized and validated exactly like `resolve --harness`.
let bad = run(&root, &["models", "show", "--harness", "bogus"]);
assert_eq!(bad.status.code(), Some(2));
assert!(bad.stdout.is_empty());
assert_eq!(
bad.stderr,
b"ERROR: unknown harness: bogus (valid: claude codex pi)\n"
);
}
#[test]
fn models_show_explicit_default_value_still_reports_source_config() {
let root = repository("explicit-default-value");
let config_dir = root.join(".shepherd");
fs::create_dir_all(&config_dir).expect("create native configuration directory");
// The legacy `ModelsConfig` field default remains `standard`, but an
// unconfigured coder now resolves through the `bulk-work` class. Setting
// `[models].coder` explicitly must therefore be detected from key
// provenance, never by comparing merged values or inferring intent.
fs::write(
config_dir.join("shepherd.toml"),
"[models]\ncoder = \"standard\"\n",
)
.expect("write native configuration");
let show = run(
&root,
&[
"--config",
".shepherd/shepherd.toml",
"models",
"show",
"--json",
],
);
assert!(
show.status.success(),
"stderr={}",
String::from_utf8_lossy(&show.stderr)
);
let stdout = String::from_utf8_lossy(&show.stdout);
assert!(
stdout.contains("\"coder\": {\"model\": \"standard\", \"source\": \"config\"}"),
"an explicitly configured role must report source: config even when its \
value equals the default: {stdout}"
);
assert!(
stdout.contains("\"root\": {\"model\": \"root\", \"source\": \"default\"}"),
"an unconfigured role must still report source: default: {stdout}"
);
}
#[test]
fn models_resolve_all_nine_roles_and_three_harnesses_accept_the_economy_opt_down() {
let root = repository("economy-opt-down");
let config_dir = root.join(".shepherd");
fs::create_dir_all(&config_dir).expect("create native configuration directory");
let mut body = String::from("[models]\n");
for role in ROLES {
body.push_str(&format!("{role} = \"economy\"\n"));
}
body.push_str(
"\n[model_targets.pi]\ninherit-caller = \"inherit\"\nreasoning-high = \"openai-codex/gpt-5.6-sol:xhigh\"\nstandard = \"openai-codex/gpt-5.6-luna:max\"\neconomy = \"openai-codex/gpt-5.6-luna:max\"\n",
);
fs::write(config_dir.join("shepherd.toml"), body).expect("write native configuration");
for role in ROLES {
for (harness, expected) in [
("claude", "haiku"),
("codex", "gpt-5.6-luna"),
("pi", "openai-codex/gpt-5.6-luna:max"),
] {
let resolve = run(
&root,
&[
"--config",
".shepherd/shepherd.toml",
"models",
"resolve",
role,
"--harness",
harness,
],
);
assert!(
resolve.status.success(),
"{role}/{harness}: stderr={}",
String::from_utf8_lossy(&resolve.stderr)
);
assert_eq!(
resolve.stdout,
format!("{expected}\n").into_bytes(),
"{role}/{harness}"
);
}
}
}
#[test]
fn codex_agent_types_never_names_an_undispatchable_role() {
// `[agent_types]` is the set of roles Codex may SPAWN, so it must contain
// exactly the `dispatchable: true` roles. It used to key on
// `model_hint == "inherit-caller"`, a proxy that was wrong in both
// directions: `planter` is `dispatchable: false` and appeared here anyway
// because its hint is `reasoning-high`, so Codex advertised the
// operator-escalation role as spawnable; and any dispatchable role adopting
// `inherit-caller` would have silently vanished from the table instead.
//
// Pinned against the LIVE authored content rather than a snapshot, so it
// fails the moment an edit changes which roles are dispatchable.
let content_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("crates/cli has two ancestors up to the repository root")
.join("content");
assert!(
content_dir.join("roles/shepherd.md").is_file(),
"resolved content dir does not look like the repository's content/: {}",
content_dir.display()
);
let out = tmp_dir("codex-root-exclusion-pin");
let status = Command::new(binary())
.arg("compile")
.args(["--target", "codex"])
.arg("--content-dir")
.arg(&content_dir)
.arg("--out")
.arg(out.to_path_buf())
.status()
.expect("run shepherd compile");
assert!(status.success());
let manifest =
fs::read_to_string(out.join("shepherd.codex.toml")).expect("read generated codex carrier");
let agent_types = manifest
.split("[agent_types]\n")
.nth(1)
.and_then(|rest| rest.split("\n[models]").next())
.expect("[agent_types] section exists in the generated codex carrier");
for undispatchable in ["shepherd", "planter"] {
assert!(
!agent_types
.lines()
.any(|line| line.trim_start().starts_with(&format!("{undispatchable} "))),
"`{undispatchable}` is dispatchable: false and must never appear in the \
codex [agent_types] table:\n{agent_types}"
);
}
// The reasoning leads remain dispatchable and must stay in the table.
for lead in ["engineer", "conductor"] {
assert!(
agent_types
.lines()
.any(|line| line.trim_start().starts_with(&format!("{lead} "))),
"`{lead}` is dispatchable and must appear in the codex [agent_types] \
table:\n{agent_types}"
);
}
}
#[test]
fn role_class_defaults_keep_leads_bulk_work_and_adversarial_review_distinct() {
let root = repository("role-class-defaults");
for (role, portable, claude, codex) in [
("engineer", "team-lead", "opus[1m]", "gpt-5.6-sol"),
("conductor", "team-lead", "opus[1m]", "gpt-5.6-sol"),
("worker", "bulk-work", "haiku", "gpt-5.6-luna"),
("critic", "adversarial-review", "sonnet", "gpt-5.6-terra"),
("auditor", "adversarial-review", "sonnet", "gpt-5.6-terra"),
] {
let unresolved = run(&root, &["models", "resolve", role]);
assert!(
unresolved.status.success(),
"{role}: {}",
String::from_utf8_lossy(&unresolved.stderr)
);
assert_eq!(
unresolved.stdout,
format!("{portable}\n").as_bytes(),
"{role}"
);
for (harness, target) in [("claude", claude), ("codex", codex)] {
let resolved = run(
&root,
&["models", "resolve", role, "--harness", harness, "--json"],
);
assert!(
resolved.status.success(),
"{role}/{harness}: {}",
String::from_utf8_lossy(&resolved.stderr)
);
let json: serde_json::Value =
serde_json::from_slice(&resolved.stdout).expect("typed model resolution JSON");
assert_eq!(json["role"], role, "{role}/{harness}");
assert_eq!(json["class"], portable, "{role}/{harness}");
assert_eq!(json["model"], target, "{role}/{harness}");
assert_eq!(json["source"], "default", "{role}/{harness}");
assert_eq!(json["harness"], harness, "{role}/{harness}");
if harness == "codex" {
assert_eq!(
json["effort"],
if role == "worker" { "max" } else { "high" },
"{role}/{harness}"
);
} else {
assert!(json.get("effort").is_none(), "{role}/{harness}: {json}");
}
}
}
}
#[test]
fn role_class_configuration_supports_open_routes_and_harness_agents() {
let root = repository("role-class-open-routes");
let config_dir = root.join(".shepherd");
fs::create_dir_all(&config_dir).expect("create native configuration directory");
fs::write(
config_dir.join("shepherd.codex.toml"),
r#"[role_classes]
root = "root-route"
team-lead = "lead-route"
bulk-work = "bulk-route"
adversarial-review = "review-route"
[[harness]]
kind = "codex"
[harness.models]
root-route = "operator/root"
lead-route = "operator/lead"
bulk-route = "operator/bulk"
review-route = "operator/review"
[[harness.agents]]
name = "engineer"
model = "operator/special-lead"
effort = "xhigh"
[[harness.agents]]
name = "specialist"
model = "operator/specialist"
effort = "max"
"#,
)
.expect("write open role-class configuration");
for (role, expected) in [
("root", "operator/root"),
("conductor", "operator/lead"),
("worker", "operator/bulk"),
("auditor", "operator/review"),
("specialist", "operator/specialist"),
] {
let output = run(&root, &["models", "resolve", role, "--harness", "codex"]);
assert!(
output.status.success(),
"{role}: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(output.stdout, format!("{expected}\n").as_bytes(), "{role}");
}
let engineer = run(
&root,
&[
"models",
"resolve",
"engineer",
"--harness",
"codex",
"--json",
],
);
assert!(
engineer.status.success(),
"stderr={}",
String::from_utf8_lossy(&engineer.stderr)
);
let json: serde_json::Value =
serde_json::from_slice(&engineer.stdout).expect("typed engineer resolution");
assert_eq!(json["class"], "team-lead");
assert_eq!(json["model"], "operator/special-lead");
assert_eq!(json["effort"], "xhigh");
assert_eq!(json["source"], "config");
let show = run(&root, &["models", "show", "--harness", "codex", "--json"]);
assert!(
show.status.success(),
"stderr={}",
String::from_utf8_lossy(&show.stderr)
);
let shown: serde_json::Value = serde_json::from_slice(&show.stdout).expect("typed model table");
assert_eq!(shown["specialist"]["model"], "operator/specialist");
assert_eq!(shown["specialist"]["source"], "config");
}
#[test]
fn role_class_configuration_accepts_the_documented_concrete_to_alias_map() {
let root = repository("role-class-alias-direction");
let config_dir = root.join(".shepherd");
fs::create_dir_all(&config_dir).expect("create native configuration directory");
fs::write(
config_dir.join("shepherd.codex.toml"),
r#"[role_classes]
team-lead = "sol"
[[harness]]
kind = "codex"
[harness.models]
"gpt-5.6-sol" = "sol"
"#,
)
.expect("write concrete-to-alias mapping");
let resolved = run(
&root,
&["models", "resolve", "conductor", "--harness", "codex"],
);
assert!(
resolved.status.success(),
"stderr={}",
String::from_utf8_lossy(&resolved.stderr)
);
assert_eq!(resolved.stdout, b"gpt-5.6-sol\n");
}
#[test]
fn role_class_precedence_and_command_line_override_are_observable() {
let root = repository("role-class-precedence");
let project = root.join(".shepherd");
let user = root.join("operator-home");
fs::create_dir_all(&project).expect("create project configuration directory");
fs::create_dir_all(&user).expect("create user configuration directory");
let layers = [
(
user.join("shepherd.toml"),
"user-base",
"operator/user-base",
),
(
user.join("shepherd.codex.toml"),
"user-harness",
"operator/user-harness",
),
(
user.join("shepherd.local.toml"),
"user-local",
"operator/user-local",
),
(
project.join("shepherd.toml"),
"project-base",
"operator/project-base",
),
(
project.join("shepherd.codex.toml"),
"project-harness",
"operator/project-harness",
),
(
project.join("shepherd.local.toml"),
"project-local",
"operator/project-local",
),
];
for (path, route, target) in &layers {
fs::write(
path,
format!(
"[role_classes]\nteam-lead = {route:?}\n\n[[harness]]\nkind = \"codex\"\n[harness.models]\n{route} = {target:?}\n"
),
)
.expect("write precedence layer");
}
for (remaining, expected) in [
(6, "operator/project-local"),
(5, "operator/project-harness"),
(4, "operator/project-base"),
(3, "operator/user-local"),
(2, "operator/user-harness"),
(1, "operator/user-base"),
] {
for (path, _, _) in &layers[remaining..] {
let _ = fs::remove_file(path);
}
let output = command(&root)
.env("SHEPHERD_HOME", &user)
.args(["models", "resolve", "engineer", "--harness", "codex"])
.output()
.expect("resolve precedence layer");
assert!(
output.status.success(),
"remaining={remaining}: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
output.stdout,
format!("{expected}\n").as_bytes(),
"remaining={remaining}"
);
}
let overridden = command(&root)
.env("SHEPHERD_HOME", &user)
.args([
"models",
"resolve",
"engineer",
"--harness",
"codex",
"--model",
"operator/terminal-override",
"--json",
])
.output()
.expect("resolve command-line override");
assert!(
overridden.status.success(),
"stderr={}",
String::from_utf8_lossy(&overridden.stderr)
);
let json: serde_json::Value =
serde_json::from_slice(&overridden.stdout).expect("typed override resolution");
assert_eq!(json["model"], "operator/terminal-override");
assert_eq!(json["source"], "command-line");
}
#[test]
fn role_class_configuration_names_inert_json_instead_of_silently_ignoring_it() {
let root = repository("role-class-inert-json");
let config_dir = root.join(".shepherd");
fs::create_dir_all(&config_dir).expect("create native configuration directory");
fs::write(config_dir.join("settings.json"), "{}\n").expect("write inert legacy config");
let output = run(&root, &["models", "resolve", "engineer"]);
assert_eq!(output.status.code(), Some(2));
assert!(output.stdout.is_empty());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains(".shepherd/settings.json"), "{stderr}");
assert!(stderr.contains(".shepherd/shepherd.toml"), "{stderr}");
assert!(stderr.contains("shepherd migrate"), "{stderr}");
}
#[test]
fn role_class_configuration_rejects_ambiguous_or_split_authority() {
let root = repository("role-class-falsification");
let config_dir = root.join(".shepherd");
fs::create_dir_all(&config_dir).expect("create native configuration directory");
fs::write(
config_dir.join("shepherd.codex.toml"),
r#"[role_classes]
team-lead = "lead"
[[harness]]
kind = "codex"
[harness.models]
"operator/lead-a" = "lead"
"operator/lead-b" = "lead"
"#,
)
.expect("write ambiguous alias map");
let ambiguous = run(
&root,
&["models", "resolve", "engineer", "--harness", "codex"],
);
assert_eq!(ambiguous.status.code(), Some(2));
assert!(ambiguous.stdout.is_empty());
assert!(
String::from_utf8_lossy(&ambiguous.stderr)
.contains("ambiguous harness model alias `lead` for codex"),
"{}",
String::from_utf8_lossy(&ambiguous.stderr)
);
fs::write(
config_dir.join("shepherd.pi.toml"),
r#"[model_targets.pi]
economy = "operator/legacy:max"
[[harness]]
kind = "pi"
[harness.models]
economy = "operator/current:max"
"#,
)
.expect("write split legacy and current authority");
let split = run(&root, &["models", "resolve", "worker", "--harness", "pi"]);
assert!(!split.status.success());
assert!(split.stdout.is_empty());
let stderr = String::from_utf8_lossy(&split.stderr);
assert!(stderr.contains("model_targets.pi"), "{stderr}");
assert!(stderr.contains("cannot coexist"), "{stderr}");
assert!(stderr.contains("shepherd migrate"), "{stderr}");
let invalid_override = run(
&root,
&["models", "resolve", "engineer", "--model", " padded "],
);
assert_eq!(invalid_override.status.code(), Some(2));
assert!(invalid_override.stdout.is_empty());
assert!(
String::from_utf8_lossy(&invalid_override.stderr)
.contains("--model requires a non-empty target"),
"{}",
String::from_utf8_lossy(&invalid_override.stderr)
);
}
#[test]
fn role_class_configuration_rejects_empty_builtins_and_input_only_alias_rows() {
for (name, config, diagnostic) in [
(
"empty-built-in",
"[models]\nengineer = \"\"\n",
"models.engineer",
),
(
"root-model-alias",
"[models]\nshepherd = \"custom-route\"\n",
"models.shepherd",
),
(
"root-agent-alias",
"[[harness]]\nkind = \"codex\"\n\n[[harness.agents]]\nname = \"shepherd\"\nmodel = \"custom-model\"\n",
"harness.codex.agents name",
),
] {
let root = repository(name);
let config_dir = root.join(".shepherd");
fs::create_dir_all(&config_dir).expect("create native configuration directory");
fs::write(config_dir.join("shepherd.toml"), config)
.expect("write invalid role-class configuration");
let output = run(&root, &["models", "show", "--harness", "codex", "--json"]);
assert_eq!(output.status.code(), Some(1), "{name}");
assert!(output.stdout.is_empty(), "{name}");
assert!(
String::from_utf8_lossy(&output.stderr).contains(diagnostic),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
#[test]
fn role_class_per_role_override_can_select_another_class_route() {
let root = repository("role-class-per-role-opt-down");
let config_dir = root.join(".shepherd");
fs::create_dir_all(&config_dir).expect("create native configuration directory");
fs::write(
config_dir.join("shepherd.toml"),
"[models]\nengineer = \"bulk-work\"\nspecialist = \"bulk-work\"\n",
)
.expect("write class-route override");
let resolved = run(
&root,
&[
"models",
"resolve",
"engineer",
"--harness",
"codex",
"--json",
],
);
assert!(
resolved.status.success(),
"stderr={}",
String::from_utf8_lossy(&resolved.stderr)
);
let json: serde_json::Value =
serde_json::from_slice(&resolved.stdout).expect("typed opt-down resolution");
assert_eq!(json["class"], "team-lead");
assert_eq!(json["model"], "gpt-5.6-luna");
assert_eq!(json["effort"], "max");
assert_eq!(json["source"], "config");
let specialist = run(
&root,
&["models", "resolve", "specialist", "--harness", "codex"],
);
assert!(
specialist.status.success(),
"stderr={}",
String::from_utf8_lossy(&specialist.stderr)
);
assert_eq!(specialist.stdout, b"gpt-5.6-luna\n");
}