use clap::Args;
use std::path::PathBuf;
use crate::lint::{LintEnv, LintFinding, LintSeverity, lint_manifest};
#[derive(Args)]
pub struct ValidateArgs {
#[arg(default_value = ".")]
pub(crate) path: String,
#[arg(long)]
pub(crate) deny_warnings: bool,
#[arg(long)]
pub(crate) json: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct BlueprintSummary {
pub name: String,
pub version: String,
pub description: String,
pub entry_stage: Option<String>,
pub stages: Vec<String>,
pub accepts_task: bool,
pub inputs: Vec<InputSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct InputSummary {
pub key: String,
pub region: String,
pub required: bool,
}
fn input_summaries(blueprint: &leviath_core::Blueprint) -> Vec<InputSummary> {
blueprint
.context_layout
.regions
.iter()
.filter_map(|r| match &r.seed {
Some(leviath_core::layout::RegionSeed::CallerInput { name }) => Some(InputSummary {
key: name.clone(),
region: r.name.clone(),
required: r.required,
}),
_ => None,
})
.collect()
}
fn input_lines(blueprint: &leviath_core::Blueprint) -> Vec<String> {
let inputs = input_summaries(blueprint);
if inputs.is_empty() {
return vec![
" Inputs: none - this agent takes no --task or other caller input".to_string(),
];
}
let flags: Vec<String> = inputs
.iter()
.map(|i| {
let mut flag = format!("--{}", i.key);
let mut notes = Vec::new();
if i.required {
notes.push("required".to_string());
}
if i.key != i.region {
notes.push(format!("seeds region '{}'", i.region));
}
if !notes.is_empty() {
flag.push_str(&format!(" ({})", notes.join(", ")));
}
flag
})
.collect();
let mut lines = vec![format!(" Inputs: {}", flags.join(", "))];
if !blueprint.accepts_task() {
lines.push(format!(
" Note: this agent takes no --task; give it input via {}",
inputs
.iter()
.map(|i| format!("--{}", i.key))
.collect::<Vec<_>>()
.join(", ")
));
}
lines
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct ValidateReport {
pub valid: bool,
pub blueprint: Option<BlueprintSummary>,
pub error: Option<String>,
pub findings: Vec<LintFinding>,
pub errors: usize,
pub warnings: usize,
pub notes: usize,
}
impl ValidateReport {
fn linted(
blueprint: &leviath_core::Blueprint,
findings: Vec<LintFinding>,
deny_warnings: bool,
) -> Self {
let count = |want: LintSeverity| findings.iter().filter(|f| f.severity == want).count();
let (errors, warnings) = (count(LintSeverity::Error), count(LintSeverity::Warning));
Self {
valid: errors == 0 && !(deny_warnings && warnings > 0),
blueprint: Some(BlueprintSummary {
name: blueprint.name.clone(),
version: blueprint.version.clone(),
description: blueprint.description.clone(),
entry_stage: blueprint.entry_stage.clone(),
stages: blueprint.stages.iter().map(|s| s.name.clone()).collect(),
accepts_task: blueprint.accepts_task(),
inputs: input_summaries(blueprint),
}),
error: None,
errors,
warnings,
notes: count(LintSeverity::Note),
findings,
}
}
fn failed(error: String) -> Self {
Self {
valid: false,
blueprint: None,
error: Some(error),
findings: Vec::new(),
errors: 1,
warnings: 0,
notes: 0,
}
}
fn print(&self) {
println!(
"{}",
serde_json::to_string_pretty(self).expect("a validate report serializes")
);
}
}
#[derive(Debug)]
enum ManifestCheckError {
Io(anyhow::Error),
Parse(String),
Validation(String),
}
#[derive(Debug)]
struct CheckedManifest {
blueprint: leviath_core::Blueprint,
content: String,
agent_dir: PathBuf,
}
fn manifest_path_for(path: &std::path::Path) -> std::path::PathBuf {
if path.is_file() {
path.to_path_buf()
} else {
path.join("agent.leviath")
}
}
fn check_manifest(path: &std::path::Path) -> Result<CheckedManifest, ManifestCheckError> {
let manifest_path = manifest_path_for(path);
if !manifest_path.exists() {
return Err(ManifestCheckError::Io(anyhow::anyhow!(
"No agent.leviath found at {}",
path.display()
)));
}
let content = std::fs::read_to_string(&manifest_path).map_err(|e| {
ManifestCheckError::Io(anyhow::anyhow!(
"Failed to read {}: {}",
manifest_path.display(),
e
))
})?;
let blueprint = leviath_core::manifest::parse_manifest(&content)
.map_err(|e| ManifestCheckError::Parse(e.to_string()))?;
blueprint
.validate()
.map_err(|e| ManifestCheckError::Validation(e.to_string()))?;
crate::daemon::spawn::resolve_region_scripts(&blueprint, &manifest_path.to_string_lossy())
.map_err(ManifestCheckError::Validation)?;
let agent_dir = manifest_path
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_default();
Ok(CheckedManifest {
blueprint,
content,
agent_dir,
})
}
fn print_success(blueprint: &leviath_core::Blueprint) {
println!("✓ Blueprint '{}' is valid.", blueprint.name);
println!(
" {} stages, version {}",
blueprint.stages.len(),
blueprint.version
);
for line in input_lines(blueprint) {
println!("{line}");
}
let is_graph = blueprint.stages.iter().any(|s| s.transitions.is_some());
if is_graph {
let entry = blueprint.resolve_entry_stage_name();
println!(" Graph mode: entry stage '{}'", entry);
for stage in &blueprint.stages {
let transitions_info = match &stage.transitions {
Some(t) if !t.is_empty() => {
let targets: Vec<&str> = t.keys().map(|k| k.as_str()).collect();
format!(" → {}", targets.join(", "))
}
Some(_) => " (terminal)".to_string(),
None => " (linear)".to_string(),
};
let revisits = stage
.max_revisits
.map(|n| format!(" (max_revisits: {})", n))
.unwrap_or_default();
println!(" - {}{}{}", stage.name, transitions_info, revisits);
}
} else {
println!(
" Linear mode: {}",
blueprint
.stages
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>()
.join(" → ")
);
}
}
#[derive(Debug)]
enum ValidateOutcome {
Success,
ParseError(String),
ValidationError(String),
LintFailed {
errors: usize,
warnings: usize,
},
}
fn print_findings(findings: &[LintFinding]) -> (usize, usize) {
let mut errors = 0;
let mut warnings = 0;
for finding in findings {
match finding.severity {
LintSeverity::Error => errors += 1,
LintSeverity::Warning => warnings += 1,
LintSeverity::Note => {}
}
println!(
" {} {} [{}]",
finding.severity.label(),
finding.one_line(),
finding.code
);
if let Some(fix) = &finding.fix {
println!(" {fix}");
}
}
(errors, warnings)
}
fn execute_reporting_outcome(
args: &ValidateArgs,
config: Option<&crate::config::Config>,
) -> anyhow::Result<ValidateOutcome> {
let path = PathBuf::from(&args.path);
let checked = match check_manifest(&path) {
Ok(c) => c,
Err(ManifestCheckError::Io(e)) => return Err(e),
Err(ManifestCheckError::Parse(e)) => {
if args.json {
ValidateReport::failed(format!("parse error: {e}")).print();
}
return Ok(ValidateOutcome::ParseError(e));
}
Err(ManifestCheckError::Validation(e)) => {
if args.json {
ValidateReport::failed(format!("validation failed: {e}")).print();
}
return Ok(ValidateOutcome::ValidationError(e));
}
};
if !args.json {
print_success(&checked.blueprint);
print_script_tool_report(&path);
}
let mut env = LintEnv::offline(&checked.agent_dir);
if let Some(config) = config {
let workdir = crate::commands::resolve_cwd().unwrap_or_default();
env = env
.with_providers(&checked.blueprint, config)
.with_read_paths(&checked.blueprint, config, &workdir);
}
let findings = lint_manifest(&checked.content, &checked.blueprint, &env);
let (errors, warnings) = match args.json {
true => {
let report = ValidateReport::linted(&checked.blueprint, findings, args.deny_warnings);
report.print();
(report.errors, report.warnings)
}
false => print_findings(&findings),
};
if errors > 0 || (args.deny_warnings && warnings > 0) {
return Ok(ValidateOutcome::LintFailed { errors, warnings });
}
Ok(ValidateOutcome::Success)
}
fn lint_failure_message(errors: usize, warnings: usize, deny_warnings: bool) -> String {
let mut parts = Vec::new();
if errors > 0 {
parts.push(format!("{errors} error{}", plural(errors)));
}
if deny_warnings && warnings > 0 {
parts.push(format!(
"{warnings} warning{} (--deny-warnings)",
plural(warnings)
));
}
format!("✗ Blueprint has {}", parts.join(" and "))
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
fn print_script_tool_report(path: &std::path::Path) {
let agent_dir = if path.is_file() {
path.parent().unwrap_or(path).to_path_buf()
} else {
path.to_path_buf()
};
let tools_dir = agent_dir.join("tools");
if !tools_dir.is_dir() {
return;
}
let (set, skipped) = leviath_scripting::ScriptToolSet::discover(&[tools_dir]);
if !set.is_empty() {
println!(" {} script tool(s) in tools/", set.len());
}
for meta in set.metas() {
if !crate::daemon::spawn::current_platform_satisfies(&meta.required_caps) {
println!(
" âš Warning: script tool '{}' won't load here (unsatisfiable @requires: {})",
meta.name,
meta.required_caps.join(", ")
);
}
}
for s in &skipped {
println!(
" âš Warning: script tool '{}' skipped: {}",
s.path.display(),
s.reason
);
}
}
pub async fn execute(args: ValidateArgs) -> anyhow::Result<()> {
let config = crate::config::Config::load().ok();
let stale = || {
crate::bundled::stale_install_suffix(
&manifest_path_for(std::path::Path::new(&args.path)),
crate::bundled::real_agents_dir_opt().as_deref(),
"\n\n",
)
};
match execute_reporting_outcome(&args, config.as_ref())? {
ValidateOutcome::Success => Ok(()),
ValidateOutcome::ParseError(e) => anyhow::bail!("✗ Parse error: {}{}", e, stale()),
ValidateOutcome::ValidationError(e) => {
anyhow::bail!("✗ Validation failed: {}{}", e, stale())
}
ValidateOutcome::LintFailed { errors, warnings } => {
anyhow::bail!(lint_failure_message(errors, warnings, args.deny_warnings))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::write_test_agent;
const CLEAN_MANIFEST: &str = r#"
[agent]
name = "ok-agent"
version = "0.1.0"
description = "Valid"
[stages.main]
mode = "autonomous"
model = { models = [{ provider = "anthropic", model = "claude-sonnet-5" }, { provider = "ollama", model = "qwen3.5:9b" }] }
description = "Main"
max_iterations = 5
[context.regions]
system = { kind = "pinned", max_tokens = 1000 }
conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
"#;
fn write_manifest(dir: &std::path::Path, content: &str) -> std::path::PathBuf {
let path = dir.join("agent.leviath");
std::fs::write(&path, content).unwrap();
path
}
fn args_for(dir: &std::path::Path) -> ValidateArgs {
ValidateArgs {
path: dir.to_str().unwrap().to_string(),
deny_warnings: false,
json: false,
}
}
fn parse(toml: &str) -> leviath_core::Blueprint {
leviath_core::manifest::parse_manifest(toml).unwrap()
}
fn make_blueprint_toml(stages_toml: &str) -> String {
format!(
r#"
[agent]
name = "test"
version = "0.1.0"
description = "test blueprint"
{stages_toml}
[context.regions]
system = {{ kind = "pinned", max_tokens = 1000 }}
conversation = {{ kind = "sliding_window", max_items = 50, max_tokens = 10000 }}
"#
)
}
#[test]
fn print_success_linear_mode_no_panic() {
let toml = make_blueprint_toml(
r#"
[stages.main]
mode = "autonomous"
model = { provider = "anthropic", model = "claude-sonnet-4-6" }
description = "Main stage"
max_iterations = 5
[stages.review]
mode = "autonomous"
model = { provider = "anthropic", model = "claude-sonnet-4-6" }
description = "Review stage"
max_iterations = 5
"#,
);
print_success(&parse(&toml));
}
#[test]
fn print_success_graph_mode_with_terminal_and_revisits_no_panic() {
let toml = make_blueprint_toml(
r#"
[stages.a]
mode = "autonomous"
model = { provider = "anthropic", model = "claude-sonnet-4-6" }
description = "A"
max_iterations = 5
max_revisits = 3
[stages.a.transitions]
b = "true"
[stages.b]
mode = "autonomous"
model = { provider = "anthropic", model = "claude-sonnet-4-6" }
description = "B"
max_iterations = 5
"#,
);
print_success(&parse(&toml));
}
#[test]
fn print_success_graph_mode_terminal_stage_no_panic() {
let toml = make_blueprint_toml(
r#"
[stages.a]
mode = "autonomous"
model = { provider = "anthropic", model = "claude-sonnet-4-6" }
description = "A"
max_iterations = 5
[stages.a.transitions]
b = "true"
[stages.b]
mode = "autonomous"
model = { provider = "anthropic", model = "claude-sonnet-4-6" }
description = "B"
max_iterations = 5
[stages.b.transitions]
"#,
);
let bp = parse(&toml);
let b = bp.find_stage("b").unwrap();
assert!(matches!(&b.transitions, Some(t) if t.is_empty()));
print_success(&bp);
}
const NAMED_INPUTS_MANIFEST: &str = r#"
[agent]
name = "inputs-agent"
version = "0.1.0"
description = "Named inputs"
[stages.main]
mode = "autonomous"
model = { provider = "anthropic", model = "claude-sonnet-5" }
description = "Main"
max_iterations = 5
[context.regions]
system = { kind = "pinned", max_tokens = 1000 }
patch = { kind = "pinned", max_tokens = 2000, required = true, seed = "diff" }
review_criteria = { kind = "pinned", max_tokens = 1000, seed = "criteria" }
focus = { kind = "pinned", max_tokens = 500, seed = "input" }
conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
"#;
#[test]
fn input_lines_name_every_flag_and_the_missing_task() {
let lines = input_lines(&parse(NAMED_INPUTS_MANIFEST));
assert_eq!(
lines,
vec![
" Inputs: --diff (required, seeds region 'patch'), \
--criteria (seeds region 'review_criteria'), --focus"
.to_string(),
" Note: this agent takes no --task; give it input via --diff, \
--criteria, --focus"
.to_string(),
]
);
}
#[test]
fn input_lines_of_a_task_taking_agent_skip_the_refusal_note() {
let toml = CLEAN_MANIFEST.replace(
"[context.regions]",
"[context.regions]\ntask = { kind = \"pinned\", max_tokens = 2000, \
required = true, seed = \"task\" }",
);
let blueprint = parse(&toml);
assert!(blueprint.accepts_task());
assert_eq!(
input_lines(&blueprint),
vec![" Inputs: --task (required)".to_string()],
"an agent that takes a task needs no note about refusing one"
);
}
#[test]
fn input_lines_without_any_caller_input_say_so() {
assert_eq!(
input_lines(&parse(CLEAN_MANIFEST)),
vec![" Inputs: none - this agent takes no --task or other caller input".to_string()]
);
}
#[test]
fn input_summaries_carry_key_region_and_required() {
let summaries = input_summaries(&parse(NAMED_INPUTS_MANIFEST));
assert_eq!(
summaries,
vec![
InputSummary {
key: "diff".to_string(),
region: "patch".to_string(),
required: true,
},
InputSummary {
key: "criteria".to_string(),
region: "review_criteria".to_string(),
required: false,
},
InputSummary {
key: "focus".to_string(),
region: "focus".to_string(),
required: false,
},
]
);
}
#[test]
fn print_success_prints_the_input_lines_without_panicking() {
print_success(&parse(NAMED_INPUTS_MANIFEST));
}
#[test]
fn print_findings_counts_errors_and_warnings_but_not_notes() {
let findings = [
(LintSeverity::Error, "e"),
(LintSeverity::Error, "e2"),
(LintSeverity::Warning, "w"),
(LintSeverity::Note, "n"),
]
.map(|(severity, code)| LintFinding {
severity,
code,
stage: Some("main".to_string()),
message: "something".to_string(),
fix: (code == "e").then(|| "do the thing".to_string()),
});
assert_eq!(print_findings(&findings), (2, 1));
}
#[test]
fn print_findings_on_an_empty_list_reports_nothing() {
assert_eq!(print_findings(&[]), (0, 0));
}
#[test]
fn lint_failure_message_pluralizes_and_names_the_flag() {
assert_eq!(lint_failure_message(1, 0, false), "✗ Blueprint has 1 error");
assert_eq!(
lint_failure_message(2, 5, false),
"✗ Blueprint has 2 errors",
"warnings are not counted unless they were asked to be"
);
assert_eq!(
lint_failure_message(0, 1, true),
"✗ Blueprint has 1 warning (--deny-warnings)"
);
assert_eq!(
lint_failure_message(1, 2, true),
"✗ Blueprint has 1 error and 2 warnings (--deny-warnings)"
);
}
#[tokio::test]
async fn execute_parse_error_returns_error() {
crate::config::with_isolated_config_path_async("validate-parse-error", |_| async {
let dir = tempfile::tempdir().unwrap();
write_manifest(dir.path(), "not valid toml [[[");
let err = execute(args_for(dir.path())).await.unwrap_err();
assert!(err.to_string().contains("Parse error"));
})
.await;
}
#[tokio::test]
async fn execute_validation_error_returns_error() {
crate::config::with_isolated_config_path_async("validate-validation-error", |_| async {
let dir = tempfile::tempdir().unwrap();
let manifest = r#"
[agent]
name = "bad-entry-agent"
version = "0.1.0"
description = "Entry stage does not exist"
entry_stage = "does-not-exist"
[stages.main]
mode = "autonomous"
model = { provider = "anthropic", model = "claude-sonnet-4-6" }
description = "Main"
max_iterations = 5
[context.regions]
system = { kind = "pinned", max_tokens = 1000 }
"#;
write_manifest(dir.path(), manifest);
let err = execute(args_for(dir.path())).await.unwrap_err();
assert!(err.to_string().contains("Validation failed"));
})
.await;
}
#[tokio::test]
async fn execute_lint_error_fails_the_command() {
crate::config::with_isolated_config_path_async("validate-lint-error", |_| async {
let dir = tempfile::tempdir().unwrap();
write_manifest(
dir.path(),
&CLEAN_MANIFEST.replace(
"max_iterations = 5",
"max_iterations = 5\navailable_tools = [\"raed_file\"]",
),
);
let err = execute(args_for(dir.path())).await.unwrap_err();
assert_eq!(err.to_string(), "✗ Blueprint has 1 error");
})
.await;
}
#[tokio::test]
async fn warnings_only_fail_when_denied() {
crate::config::with_isolated_config_path_async("validate-deny-warnings", |_| async {
let dir = tempfile::tempdir().unwrap();
write_manifest(
dir.path(),
&CLEAN_MANIFEST.replace("max_iterations = 5", ""),
);
let mut args = args_for(dir.path());
assert!(execute_reporting_outcome(&args, None).unwrap().is_success());
args.deny_warnings = true;
let err = execute(args).await.unwrap_err();
assert_eq!(
err.to_string(),
"✗ Blueprint has 1 warning (--deny-warnings)"
);
})
.await;
}
#[tokio::test]
async fn execute_no_manifest_errors() {
crate::config::with_isolated_config_path_async("validate-no-manifest", |_| async {
let dir = tempfile::tempdir().unwrap();
assert!(execute(args_for(dir.path())).await.is_err());
})
.await;
}
#[tokio::test]
async fn execute_valid_manifest_file_path() {
crate::config::with_isolated_config_path_async("validate-file-path", |_| async {
let dir = tempfile::tempdir().unwrap();
let manifest_path = write_manifest(dir.path(), CLEAN_MANIFEST);
let args = ValidateArgs {
path: manifest_path.to_str().unwrap().to_string(),
deny_warnings: false,
json: false,
};
assert!(execute(args).await.is_ok());
})
.await;
}
#[tokio::test]
async fn execute_valid_manifest_directory_path() {
crate::config::with_isolated_config_path_async("validate-dir-path", |_| async {
let dir = tempfile::tempdir().unwrap();
write_test_agent(dir.path(), CLEAN_MANIFEST);
assert!(execute(args_for(dir.path())).await.is_ok());
})
.await;
}
impl ValidateOutcome {
fn is_success(&self) -> bool {
matches!(self, Self::Success)
}
fn is_parse_error(&self) -> bool {
matches!(self, Self::ParseError(_))
}
fn is_validation_error(&self) -> bool {
matches!(self, Self::ValidationError(_))
}
}
#[test]
fn outcome_predicates_distinguish_the_variants() {
assert!(ValidateOutcome::Success.is_success());
assert!(!ValidateOutcome::Success.is_parse_error());
assert!(!ValidateOutcome::Success.is_validation_error());
assert!(ValidateOutcome::ParseError(String::new()).is_parse_error());
assert!(ValidateOutcome::ValidationError(String::new()).is_validation_error());
assert!(
!ValidateOutcome::LintFailed {
errors: 1,
warnings: 0
}
.is_success()
);
}
fn json_args_for(dir: &std::path::Path) -> ValidateArgs {
ValidateArgs {
json: true,
..args_for(dir)
}
}
fn finding(severity: LintSeverity, code: &'static str) -> LintFinding {
LintFinding {
severity,
code,
stage: None,
message: format!("{code} message"),
fix: None,
}
}
#[test]
fn json_report_of_a_clean_manifest_is_valid_and_names_its_stages() {
let blueprint = parse(CLEAN_MANIFEST);
let report = ValidateReport::linted(&blueprint, Vec::new(), false);
assert!(report.valid);
assert_eq!(report.error, None);
let summary = report.blueprint.expect("a parsed manifest has a summary");
assert_eq!(summary.name, "ok-agent");
assert_eq!(summary.stages, vec!["main".to_string()]);
assert!(!summary.accepts_task);
assert_eq!(summary.inputs, Vec::new());
assert_eq!((report.errors, report.warnings, report.notes), (0, 0, 0));
}
#[test]
fn json_report_counts_each_severity_separately() {
let blueprint = parse(CLEAN_MANIFEST);
let findings = vec![
finding(LintSeverity::Error, "a"),
finding(LintSeverity::Warning, "b"),
finding(LintSeverity::Note, "c"),
];
let report = ValidateReport::linted(&blueprint, findings, false);
assert_eq!((report.errors, report.warnings, report.notes), (1, 1, 1));
assert!(!report.valid);
}
#[test]
fn json_report_is_valid_with_a_warning_until_deny_warnings() {
let blueprint = parse(CLEAN_MANIFEST);
let warning = || vec![finding(LintSeverity::Warning, "b")];
assert!(ValidateReport::linted(&blueprint, warning(), false).valid);
assert!(!ValidateReport::linted(&blueprint, warning(), true).valid);
}
#[test]
fn json_report_of_a_note_stays_valid_under_deny_warnings() {
let blueprint = parse(CLEAN_MANIFEST);
let notes = vec![finding(LintSeverity::Note, "c")];
assert!(ValidateReport::linted(&blueprint, notes, true).valid);
}
#[test]
fn json_report_of_a_broken_manifest_carries_the_error_and_no_blueprint() {
let report = ValidateReport::failed("parse error: boom".to_string());
assert!(!report.valid);
assert!(report.blueprint.is_none());
assert_eq!(report.error.as_deref(), Some("parse error: boom"));
}
#[test]
fn json_report_serializes_every_key_a_caller_reads() {
let blueprint = parse(CLEAN_MANIFEST);
let report = ValidateReport::linted(
&blueprint,
vec![finding(LintSeverity::Error, "unknown-tool")],
false,
);
let value: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
assert_eq!(value["valid"], serde_json::json!(false));
assert_eq!(value["blueprint"]["name"], serde_json::json!("ok-agent"));
assert_eq!(value["error"], serde_json::Value::Null);
assert_eq!(
value["findings"][0]["code"],
serde_json::json!("unknown-tool")
);
assert_eq!(value["findings"][0]["severity"], serde_json::json!("error"));
}
#[test]
fn json_report_names_the_accepted_inputs() {
let blueprint = parse(NAMED_INPUTS_MANIFEST);
let report = ValidateReport::linted(&blueprint, Vec::new(), false);
let value: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
assert_eq!(value["blueprint"]["accepts_task"], serde_json::json!(false));
assert_eq!(
value["blueprint"]["inputs"][0],
serde_json::json!({"key": "diff", "region": "patch", "required": true})
);
assert_eq!(
value["blueprint"]["inputs"][1]["key"],
serde_json::json!("criteria")
);
}
#[test]
fn json_mode_still_reports_a_parse_error_through_the_outcome() {
let dir = tempfile::tempdir().unwrap();
write_manifest(dir.path(), "not valid toml [[[");
assert!(
execute_reporting_outcome(&json_args_for(dir.path()), None)
.unwrap()
.is_parse_error()
);
}
#[test]
fn json_mode_still_reports_a_validation_error_through_the_outcome() {
let dir = tempfile::tempdir().unwrap();
write_manifest(
dir.path(),
r#"
[agent]
name = "bad-entry-agent"
version = "0.1.0"
description = "Entry stage does not exist"
entry_stage = "does-not-exist"
[stages.main]
mode = "autonomous"
model = { provider = "anthropic", model = "claude-sonnet-4-6" }
description = "Main"
max_iterations = 5
[context.regions]
system = { kind = "pinned", max_tokens = 1000 }
"#,
);
assert!(
execute_reporting_outcome(&json_args_for(dir.path()), None)
.unwrap()
.is_validation_error()
);
}
#[test]
fn json_mode_still_succeeds_on_a_clean_manifest() {
let dir = tempfile::tempdir().unwrap();
write_manifest(dir.path(), CLEAN_MANIFEST);
assert!(
execute_reporting_outcome(&json_args_for(dir.path()), None)
.unwrap()
.is_success()
);
}
#[test]
fn execute_reporting_outcome_malformed_toml_is_parse_error() {
let dir = tempfile::tempdir().unwrap();
write_manifest(dir.path(), "not valid toml [[[");
assert!(
execute_reporting_outcome(&args_for(dir.path()), None)
.unwrap()
.is_parse_error()
);
}
#[test]
fn execute_reporting_outcome_bad_entry_stage_is_validation_error() {
let dir = tempfile::tempdir().unwrap();
let manifest = r#"
[agent]
name = "bad-entry-agent"
version = "0.1.0"
description = "Entry stage does not exist"
entry_stage = "does-not-exist"
[stages.main]
mode = "autonomous"
model = { provider = "anthropic", model = "claude-sonnet-4-6" }
description = "Main"
max_iterations = 5
[context.regions]
system = { kind = "pinned", max_tokens = 1000 }
"#;
write_manifest(dir.path(), manifest);
assert!(
execute_reporting_outcome(&args_for(dir.path()), None)
.unwrap()
.is_validation_error()
);
}
#[test]
fn execute_reporting_outcome_missing_manifest_is_io_error() {
let dir = tempfile::tempdir().unwrap();
assert!(execute_reporting_outcome(&args_for(dir.path()), None).is_err());
}
#[test]
fn execute_reporting_outcome_valid_manifest_is_success() {
let dir = tempfile::tempdir().unwrap();
write_manifest(dir.path(), CLEAN_MANIFEST);
assert!(
execute_reporting_outcome(&args_for(dir.path()), None)
.unwrap()
.is_success()
);
}
#[test]
fn command_seed_regions_are_noted_without_failing() {
let dir = tempfile::tempdir().unwrap();
let manifest = r#"
[agent]
name = "scanner"
version = "0.1.0"
[stages.main]
mode = "autonomous"
model = { provider = "anthropic", model = "claude-sonnet-5" }
description = "Main stage"
max_iterations = 5
[context.regions]
facts = { kind = "pinned", max_tokens = 1000, seed = { command = "git ls-files" } }
conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
"#;
write_manifest(dir.path(), manifest);
let args = ValidateArgs {
path: dir.path().to_str().unwrap().to_string(),
deny_warnings: true,
json: false,
};
assert!(execute_reporting_outcome(&args, None).unwrap().is_success());
}
#[test]
fn execute_reporting_outcome_reports_agent_script_tools() {
let dir = tempfile::tempdir().unwrap();
write_manifest(dir.path(), CLEAN_MANIFEST);
let tools = dir.path().join("tools");
std::fs::create_dir(&tools).unwrap();
std::fs::write(tools.join("ok.rhai"), "// @tool ok\nparams.x").unwrap();
std::fs::write(tools.join("bad.rhai"), "no directive\nlet").unwrap();
std::fs::write(tools.join("gpu.rhai"), "// @tool gpu\n// @requires gpu\n1").unwrap();
assert!(
execute_reporting_outcome(&args_for(dir.path()), None)
.unwrap()
.is_success()
);
}
#[test]
fn an_agents_own_script_tool_resolves() {
let dir = tempfile::tempdir().unwrap();
write_manifest(
dir.path(),
&CLEAN_MANIFEST.replace(
"max_iterations = 5",
"max_iterations = 5\navailable_tools = [\"stub_search\"]",
),
);
let tools = dir.path().join("tools");
std::fs::create_dir(&tools).unwrap();
std::fs::write(
tools.join("stub_search.rhai"),
"// @tool stub_search\n// @description searches\n\"found\"",
)
.unwrap();
assert!(
execute_reporting_outcome(&args_for(dir.path()), None)
.unwrap()
.is_success()
);
}
#[test]
fn print_script_tool_report_no_tools_dir_is_silent() {
let dir = tempfile::tempdir().unwrap();
let manifest = write_manifest(dir.path(), "unused");
print_script_tool_report(&manifest);
}
#[test]
fn print_script_tool_report_only_broken_scripts_warns_without_count() {
let dir = tempfile::tempdir().unwrap();
let tools = dir.path().join("tools");
std::fs::create_dir(&tools).unwrap();
std::fs::write(tools.join("bad.rhai"), "no directive\nlet").unwrap();
print_script_tool_report(dir.path());
}
#[test]
fn check_manifest_verifies_custom_region_scripts() {
let dir = tempfile::tempdir().unwrap();
let toml = r#"
[agent]
name = "custom-validate"
version = "0.1.0"
description = "d"
[stages.main]
mode = "autonomous"
model = { provider = "anthropic", model = "claude-sonnet-5" }
description = "Main stage"
[context.regions]
system = { kind = "pinned", max_tokens = 1000 }
conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
brain = { kind = "custom", script = "hooks/brain.rhai", max_tokens = 1000 }
"#;
let manifest_path = write_manifest(dir.path(), toml);
let err = format!("{:?}", check_manifest(&manifest_path).unwrap_err());
assert!(err.starts_with("Validation"), "{err}");
assert!(err.contains("region 'brain'"), "{err}");
std::fs::create_dir(dir.path().join("hooks")).unwrap();
std::fs::write(
dir.path().join("hooks/brain.rhai"),
"fn render(ctx) { \"ok\" }",
)
.unwrap();
let checked = check_manifest(&manifest_path).unwrap();
assert_eq!(checked.blueprint.name, "custom-validate");
assert!(checked.content.contains("custom-validate"));
assert_eq!(checked.agent_dir, dir.path());
}
fn unwrap_io_err(err: ManifestCheckError) -> anyhow::Error {
let ManifestCheckError::Io(e) = err else {
panic!("expected ManifestCheckError::Io, got {err:?}");
};
e
}
#[test]
#[should_panic(expected = "expected ManifestCheckError::Io")]
fn unwrap_io_err_panics_on_parse_variant() {
let dir = tempfile::tempdir().unwrap();
write_manifest(dir.path(), "not valid toml [[[");
let err = check_manifest(dir.path()).unwrap_err();
unwrap_io_err(err);
}
#[test]
fn check_manifest_missing_directory_manifest_is_io_error() {
let dir = tempfile::tempdir().unwrap();
let err = check_manifest(dir.path()).unwrap_err();
let e = unwrap_io_err(err);
assert!(e.to_string().contains("No agent.leviath found"));
}
#[test]
fn check_manifest_unreadable_file_path_is_io_error() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nonexistent-subdir");
let err = check_manifest(&missing).unwrap_err();
unwrap_io_err(err);
}
#[test]
fn check_manifest_unreadable_file_is_io_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("agent.leviath")).unwrap();
let err = check_manifest(dir.path()).unwrap_err();
let e = unwrap_io_err(err);
assert!(e.to_string().contains("Failed to read"));
}
impl ManifestCheckError {
fn is_parse(&self) -> bool {
matches!(self, Self::Parse(_))
}
}
#[test]
fn check_manifest_malformed_toml_is_parse_error() {
let dir = tempfile::tempdir().unwrap();
write_manifest(dir.path(), "not valid toml [[[");
assert!(check_manifest(dir.path()).unwrap_err().is_parse());
let empty = tempfile::tempdir().unwrap();
assert!(!check_manifest(empty.path()).unwrap_err().is_parse());
}
#[test]
fn check_manifest_direct_file_path_is_accepted() {
let dir = tempfile::tempdir().unwrap();
let manifest_path = write_manifest(dir.path(), CLEAN_MANIFEST);
let checked = check_manifest(&manifest_path).unwrap();
assert_eq!(checked.blueprint.name, "ok-agent");
}
}