use std::path::Path;
use pretty_assertions::assert_eq;
use super::parse_definition;
use crate::agent::{
AgentRuntimeSpec, ModelPolicy, ModelSelection, PromptPolicy, ToolCapability, ToolPolicy,
};
fn parse(contents: &str) -> Result<crate::agent::AgentDefinition, crate::agent::AgentCatalogError> {
parse_definition(Path::new("agent.md"), "agent", contents)
}
#[test]
fn defaults_runtime_to_rho() {
let definition = parse("---\ndescription: demo\n---\nbody\n").unwrap();
assert_eq!(
definition.runtime,
AgentRuntimeSpec::Rho {
tools: ToolPolicy::All,
model: ModelPolicy::Inherit,
reasoning: None,
}
);
}
#[test]
fn parses_explicit_rho_runtime() {
let definition =
parse("---\ndescription: demo\nruntime: rho\ntools: [read_file]\n---\n").unwrap();
assert_eq!(
definition.runtime,
AgentRuntimeSpec::Rho {
tools: ToolPolicy::Allow([ToolCapability::ReadFile].into_iter().collect()),
model: ModelPolicy::Inherit,
reasoning: None,
}
);
}
#[test]
fn parses_claude_cli_runtime_and_tools_independent_of_key_order() {
let tools_first = parse(
"---\ndescription: demo\ntools: [Read, Edit, \"Bash(git *)\"]\nruntime: claude-cli\n---\n",
)
.unwrap();
let runtime_first = parse(
"---\ndescription: demo\nruntime: claude-cli\ntools: [Read, Edit, \"Bash(git *)\"]\n---\n",
)
.unwrap();
assert_eq!(
tools_first.runtime,
AgentRuntimeSpec::ClaudeCli(crate::agent::ClaudeAgentConfig {
tools: crate::agent::ClaudeToolPolicy::Allow(vec![
"Read".into(),
"Edit".into(),
"Bash(git *)".into()
]),
inherit_claude_config: false,
model: None,
reasoning: None,
})
);
assert_eq!(tools_first.fingerprint(), runtime_first.fingerprint());
}
#[test]
fn rejects_unknown_runtime_values() {
let error = parse("---\ndescription: demo\nruntime: bun\n---\n").unwrap_err();
assert_eq!(error.field.as_deref(), Some("runtime"));
assert!(error.to_string().contains("expected rho or claude-cli"));
}
#[test]
fn rejects_claude_tools_on_rho_runtime() {
let error = parse("---\ndescription: demo\nruntime: rho\ntools: [Read]\n---\n").unwrap_err();
assert_eq!(error.field.as_deref(), Some("tools"));
assert!(error.to_string().contains("Claude Code tool name"));
assert!(error.to_string().contains("runtime is rho"));
assert!(error.to_string().contains("tools: [read_file, shell]"));
}
#[test]
fn rejects_rho_tools_on_claude_runtime() {
let error = parse("---\ndescription: demo\nruntime: claude-cli\ntools: [read_file]\n---\n")
.unwrap_err();
assert_eq!(error.field.as_deref(), Some("tools"));
assert!(error.to_string().contains("Rho capability"));
assert!(error.to_string().contains("runtime is claude-cli"));
assert!(error
.to_string()
.contains("tools: [Read, Edit, \"Bash(git *)\"]"));
}
#[test]
fn rejects_invalid_claude_tool_shape() {
let error = parse("---\ndescription: demo\nruntime: claude-cli\ntools: [\"Bash(git\"]\n---\n")
.unwrap_err();
assert_eq!(error.field.as_deref(), Some("tools"));
assert!(error.to_string().contains("specifier must end the name"));
}
#[test]
fn allows_nested_parentheses_inside_claude_tool_specifier() {
let definition = parse(
"---\ndescription: demo\nruntime: claude-cli\ntools: [\"Bash(git log --format=%(refname))\", \"Bash(git *)\"]\n---\n",
)
.unwrap();
assert_eq!(
definition.runtime,
AgentRuntimeSpec::ClaudeCli(crate::agent::ClaudeAgentConfig {
tools: crate::agent::ClaudeToolPolicy::Allow(vec![
"Bash(git log --format=%(refname))".into(),
"Bash(git *)".into(),
]),
inherit_claude_config: false,
model: None,
reasoning: None,
})
);
}
#[test]
fn rejects_claude_tool_patterns_with_commas() {
for contents in [
"---\ndescription: demo\nruntime: claude-cli\ntools: [\"Edit(path with , comma)\"]\n---\n",
"---\ndescription: demo\nruntime: claude-cli\ntools: [\"mcp__server__tool(a,b)\"]\n---\n",
] {
let error = parse(contents).unwrap_err();
assert_eq!(error.field.as_deref(), Some("tools"), "{contents}");
assert!(
error.to_string().contains("commas cannot round-trip"),
"{contents}: {error}"
);
}
}
#[test]
fn rejects_malformed_outer_claude_tool_names() {
for contents in [
"---\ndescription: demo\nruntime: claude-cli\ntools: [\"(no-name)\"]\n---\n",
"---\ndescription: demo\nruntime: claude-cli\ntools: [\"Bad Name\"]\n---\n",
"---\ndescription: demo\nruntime: claude-cli\ntools: [\"Tool)\"]\n---\n",
"---\ndescription: demo\nruntime: claude-cli\ntools: [\"\"]\n---\n",
] {
let error = parse(contents).unwrap_err();
assert_eq!(error.field.as_deref(), Some("tools"), "{contents}");
}
}
#[test]
fn rejects_empty_quoted_claude_model() {
let error =
parse("---\ndescription: demo\nruntime: claude-cli\nmodel: \"\"\ntools: [Read]\n---\n")
.unwrap_err();
assert_eq!(error.field.as_deref(), Some("model"));
assert!(error.to_string().contains("must not be empty"));
}
#[test]
fn rejects_claude_inherit_with_explicit_model_and_select_without_model() {
let inherit_with_model = parse(
"---\ndescription: demo\nruntime: claude-cli\nmodel-policy: inherit\nmodel: opus\ntools: [Read]\n---\n",
)
.unwrap_err();
assert_eq!(inherit_with_model.field.as_deref(), Some("model-policy"));
assert!(inherit_with_model
.to_string()
.contains("inherit cannot specify model"));
let select_without_model = parse(
"---\ndescription: demo\nruntime: claude-cli\nmodel-policy: select\ntools: [Read]\n---\n",
)
.unwrap_err();
assert_eq!(select_without_model.field.as_deref(), Some("model"));
assert!(select_without_model
.to_string()
.contains("required by model-policy 'select'"));
}
#[test]
fn rho_model_policy_inherit_with_model_still_rejected() {
let error =
parse("---\ndescription: demo\nruntime: rho\nmodel-policy: inherit\nmodel: gpt-5.5\n---\n")
.unwrap_err();
assert_eq!(error.field.as_deref(), Some("model-policy"));
assert!(error
.to_string()
.contains("inherit cannot specify model or provider"));
}
#[test]
fn omits_claude_tools_as_empty_allowlist() {
let definition = parse("---\ndescription: demo\nruntime: claude-cli\n---\n").unwrap();
assert_eq!(
definition.runtime,
AgentRuntimeSpec::ClaudeCli(crate::agent::ClaudeAgentConfig {
tools: crate::agent::ClaudeToolPolicy::None,
inherit_claude_config: false,
model: None,
reasoning: None,
})
);
}
#[test]
fn rejects_tools_all_on_claude_runtime() {
let error =
parse("---\ndescription: demo\nruntime: claude-cli\ntools: all\n---\n").unwrap_err();
assert_eq!(error.field.as_deref(), Some("tools"));
assert!(error.to_string().contains("does not support tools: all"));
}
#[test]
fn allows_model_and_rejects_provider_on_claude_runtime() {
let definition = parse(
"---\ndescription: demo\nruntime: claude-cli\nmodel: claude-opus-4-6\ntools: [Read]\n---\n",
)
.unwrap();
assert_eq!(
*definition.model_policy(),
ModelPolicy::Select(ModelSelection {
provider: None,
model: "claude-opus-4-6".into(),
})
);
match &definition.runtime {
AgentRuntimeSpec::ClaudeCli(config) => {
assert_eq!(config.model.as_deref(), Some("claude-opus-4-6"));
assert!(config.reasoning.is_none());
assert!(config.effort().is_none());
}
_ => panic!("expected claude runtime"),
}
let error = parse(
"---\ndescription: demo\nruntime: claude-cli\nprovider: anthropic\nmodel: claude-opus-4-6\ntools: [Read]\n---\n",
)
.unwrap_err();
assert_eq!(error.field.as_deref(), Some("provider"));
assert!(error
.to_string()
.contains("not valid with runtime: claude-cli"));
}
#[test]
fn inherit_claude_config_is_opt_in_for_claude_runtime_only() {
let definition = parse(
"---\ndescription: demo\nruntime: claude-cli\ninherit_claude_config: true\ntools: [Read]\n---\n",
)
.unwrap();
assert!(matches!(
definition.runtime,
AgentRuntimeSpec::ClaudeCli(crate::agent::ClaudeAgentConfig {
inherit_claude_config: true,
..
})
));
let rejected =
parse("---\ndescription: demo\nruntime: rho\ninherit_claude_config: true\n---\n")
.unwrap_err();
assert_eq!(rejected.field.as_deref(), Some("inherit_claude_config"));
assert!(rejected
.to_string()
.contains("only valid with runtime: claude-cli"));
let bad_bool = parse(
"---\ndescription: demo\nruntime: claude-cli\ninherit_claude_config: yes\ntools: [Read]\n---\n",
)
.unwrap_err();
assert_eq!(bad_bool.field.as_deref(), Some("inherit_claude_config"));
assert!(bad_bool.to_string().contains("expected true or false"));
}
#[test]
fn fingerprint_includes_runtime_tools_and_inherit_flag() {
let base = parse(
"---\ndescription: demo\nruntime: claude-cli\nmodel: opus\ntools: [Read]\n---\nbody\n",
)
.unwrap();
let runtime_change = parse(
"---\ndescription: demo\nruntime: rho\nmodel: opus\nprovider: anthropic\ntools: [read_file]\n---\nbody\n",
)
.unwrap();
let tools_change = parse(
"---\ndescription: demo\nruntime: claude-cli\nmodel: opus\ntools: [Read, Edit]\n---\nbody\n",
)
.unwrap();
let inherit_change = parse(
"---\ndescription: demo\nruntime: claude-cli\nmodel: opus\ninherit_claude_config: true\ntools: [Read]\n---\nbody\n",
)
.unwrap();
let same = parse(
"---\nid: agent\ndescription: demo\nruntime: claude-cli\nmodel: opus\ntools:\n - Read\n---\n\nbody\n",
)
.unwrap();
assert_ne!(base.fingerprint(), runtime_change.fingerprint());
assert_ne!(base.fingerprint(), tools_change.fingerprint());
assert_ne!(base.fingerprint(), inherit_change.fingerprint());
assert_eq!(base.fingerprint(), same.fingerprint());
}
#[test]
fn parses_claude_indented_tool_list_with_patterns() {
let definition = parse(
"---\ndescription: demo\nruntime: claude-cli\ntools:\n - Read\n - \"Bash(git *)\"\n---\n",
)
.unwrap();
assert_eq!(
definition.runtime,
AgentRuntimeSpec::ClaudeCli(crate::agent::ClaudeAgentConfig {
tools: crate::agent::ClaudeToolPolicy::Allow(vec!["Read".into(), "Bash(git *)".into()]),
inherit_claude_config: false,
model: None,
reasoning: None,
})
);
assert!(matches!(definition.prompt, PromptPolicy::Extend(_)));
}
#[test]
fn rejects_rho_style_alias_and_unsupported_reasoning_on_claude_runtime() {
let alias =
parse("---\ndescription: demo\nruntime: claude-cli\nmodel: @deep\ntools: [Read]\n---\n")
.unwrap_err();
assert_eq!(alias.field.as_deref(), Some("model"));
assert!(alias
.to_string()
.contains("does not resolve Rho model aliases"));
for level in ["off", "minimal"] {
let reasoning = parse(&format!(
"---\ndescription: demo\nruntime: claude-cli\nreasoning: {level}\ntools: [Read]\n---\n"
))
.unwrap_err();
assert_eq!(reasoning.field.as_deref(), Some("reasoning"));
assert!(
reasoning
.to_string()
.contains("not a Claude Code effort level"),
"{level}: {reasoning}"
);
}
}
#[test]
fn accepts_claude_effort_reasoning_levels() {
for level in ["low", "medium", "high", "xhigh", "max"] {
let definition = parse(&format!(
"---\ndescription: demo\nruntime: claude-cli\nreasoning: {level}\ntools: [Read]\n---\n"
))
.unwrap_or_else(|error| panic!("expected {level} to parse: {error}"));
assert_eq!(
definition
.reasoning()
.map(|value| value.to_string())
.as_deref(),
Some(level)
);
match &definition.runtime {
AgentRuntimeSpec::ClaudeCli(config) => {
assert_eq!(config.effort(), Some(level));
}
_ => panic!("expected claude runtime"),
}
}
}