Add tests for tool_approval and agent-cli

- 5 session tests exercising ToolApprovalFn callback (deny, allow,
  arg capture, None passthrough, error event emission)
- 18 unit tests for agent-cli pure functions (tool_category,
  is_auto_approved, default_model, validate_api_key,
  build_tool_approval, build_profile)
- 4 integration tests for the agent binary (usage, help, missing
  API key, invalid permissions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-23 11:05:44 -05:00
parent 0edc93e1c3
commit eaf934f355
3 changed files with 371 additions and 1 deletions

View file

@ -50,7 +50,7 @@ fn default_model(provider: &str) -> &'static str {
"openai" => "gpt-5.2",
"gemini" => "gemini-3-pro-preview",
// anthropic and unknown providers
_ => "claude-sonnet-4-5-20250514",
_ => "claude-sonnet-4-5",
}
}
@ -202,6 +202,7 @@ impl llm::middleware::Middleware for DebugMiddleware {
}
async fn run() -> anyhow::Result<()> {
let _ = dotenvy::dotenv();
let cli = Cli::parse();
// Validate provider API key
@ -288,3 +289,134 @@ async fn main() -> ExitCode {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// tool_category tests
#[test]
fn tool_category_read_tools() {
assert_eq!(tool_category("read_file"), "read");
assert_eq!(tool_category("read_many_files"), "read");
assert_eq!(tool_category("grep"), "read");
assert_eq!(tool_category("glob"), "read");
assert_eq!(tool_category("list_dir"), "read");
}
#[test]
fn tool_category_write_tools() {
assert_eq!(tool_category("write_file"), "write");
assert_eq!(tool_category("edit_file"), "write");
assert_eq!(tool_category("apply_patch"), "write");
}
#[test]
fn tool_category_shell() {
assert_eq!(tool_category("shell"), "shell");
}
#[test]
fn tool_category_unknown_defaults_to_shell() {
assert_eq!(tool_category("some_random_tool"), "shell");
}
// is_auto_approved tests
#[test]
fn is_auto_approved_read_only() {
assert!(is_auto_approved(PermissionLevel::ReadOnly, "read"));
assert!(!is_auto_approved(PermissionLevel::ReadOnly, "write"));
assert!(!is_auto_approved(PermissionLevel::ReadOnly, "shell"));
}
#[test]
fn is_auto_approved_read_write() {
assert!(is_auto_approved(PermissionLevel::ReadWrite, "read"));
assert!(is_auto_approved(PermissionLevel::ReadWrite, "write"));
assert!(!is_auto_approved(PermissionLevel::ReadWrite, "shell"));
}
#[test]
fn is_auto_approved_full() {
assert!(is_auto_approved(PermissionLevel::Full, "read"));
assert!(is_auto_approved(PermissionLevel::Full, "write"));
assert!(is_auto_approved(PermissionLevel::Full, "shell"));
}
// default_model tests
#[test]
fn default_model_anthropic() {
assert_eq!(default_model("anthropic"), "claude-sonnet-4-5");
}
#[test]
fn default_model_openai() {
assert_eq!(default_model("openai"), "gpt-5.2");
}
#[test]
fn default_model_gemini() {
assert_eq!(default_model("gemini"), "gemini-3-pro-preview");
}
// validate_api_key tests
#[test]
fn validate_api_key_unknown_provider() {
assert!(!validate_api_key("unknown"));
}
// build_tool_approval non-interactive tests
#[test]
fn build_tool_approval_read_only_allows_read() {
let approval_fn = build_tool_approval(PermissionLevel::ReadOnly, false);
assert!(approval_fn("read_file", &json!({})).is_ok());
}
#[test]
fn build_tool_approval_read_only_denies_write() {
let approval_fn = build_tool_approval(PermissionLevel::ReadOnly, false);
let result = approval_fn("write_file", &json!({}));
assert!(result.is_err());
assert!(result.unwrap_err().contains("denied"));
}
#[test]
fn build_tool_approval_read_write_denies_shell() {
let approval_fn = build_tool_approval(PermissionLevel::ReadWrite, false);
let result = approval_fn("shell", &json!({}));
assert!(result.is_err());
assert!(result.unwrap_err().contains("denied"));
}
#[test]
fn build_tool_approval_full_allows_shell() {
let approval_fn = build_tool_approval(PermissionLevel::Full, false);
assert!(approval_fn("shell", &json!({})).is_ok());
}
// build_profile tests
#[test]
fn build_profile_anthropic() {
let profile = build_profile("anthropic", "model");
assert_eq!(profile.id(), "anthropic");
}
#[test]
fn build_profile_openai() {
let profile = build_profile("openai", "model");
assert_eq!(profile.id(), "openai");
}
#[test]
fn build_profile_gemini() {
let profile = build_profile("gemini", "model");
assert_eq!(profile.id(), "gemini");
}
}

View file

@ -0,0 +1,66 @@
use std::process::Command;
#[test]
fn no_args_prints_usage() {
let output = Command::new(env!("CARGO_BIN_EXE_agent"))
.env_clear()
.output()
.expect("failed to execute");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Usage:"),
"expected stderr to contain 'Usage:', got: {stderr}"
);
}
#[test]
fn help_flag_prints_help() {
let output = Command::new(env!("CARGO_BIN_EXE_agent"))
.env_clear()
.arg("--help")
.output()
.expect("failed to execute");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Task prompt"),
"expected stdout to contain 'Task prompt', got: {stdout}"
);
}
#[test]
fn missing_api_key_exits_with_error() {
let tmp = std::env::temp_dir();
let output = Command::new(env!("CARGO_BIN_EXE_agent"))
.env_clear()
.current_dir(&tmp)
.arg("test prompt")
.output()
.expect("failed to execute");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("API key not set"),
"expected stderr to contain 'API key not set', got: {stderr}"
);
}
#[test]
fn invalid_permissions_value() {
let output = Command::new(env!("CARGO_BIN_EXE_agent"))
.env_clear()
.args(["--permissions", "bogus", "test prompt"])
.output()
.expect("failed to execute");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("invalid value"),
"expected stderr to contain 'invalid value', got: {stderr}"
);
}

View file

@ -1407,4 +1407,176 @@ mod tests {
"System prompt should contain user instructions"
);
}
#[tokio::test]
async fn tool_approval_denies_tool() {
let mut registry = ToolRegistry::new();
registry.register(make_echo_tool());
let responses = vec![
tool_call_response("echo", "call_1", serde_json::json!({"text": "hello"})),
text_response("OK after denial"),
];
let config = SessionConfig {
tool_approval: Some(Arc::new(|_name, _args| {
Err("denied by policy".to_string())
})),
..Default::default()
};
let mut session = make_session_with_tools_and_config(responses, registry, config).await;
session.process_input("Use echo").await.unwrap();
assert_eq!(session.state(), SessionState::Idle);
let turns = session.history().turns();
// User + Assistant(tool_call) + ToolResults + Assistant(text) = 4
assert_eq!(turns.len(), 4);
if let Turn::ToolResults { results, .. } = &turns[2] {
assert!(results[0].is_error);
let content_str = results[0].content.to_string();
assert!(
content_str.contains("denied by policy"),
"Expected denial message in content, got: {content_str}"
);
} else {
panic!("Expected ToolResults turn at index 2");
}
assert!(
matches!(&turns[3], Turn::Assistant { content, .. } if content == "OK after denial")
);
}
#[tokio::test]
async fn tool_approval_allows_tool() {
let mut registry = ToolRegistry::new();
registry.register(make_echo_tool());
let responses = vec![
tool_call_response("echo", "call_1", serde_json::json!({"text": "hello"})),
text_response("Done"),
];
let config = SessionConfig {
tool_approval: Some(Arc::new(|_name, _args| Ok(()))),
..Default::default()
};
let mut session = make_session_with_tools_and_config(responses, registry, config).await;
session.process_input("Use echo").await.unwrap();
let turns = session.history().turns();
if let Turn::ToolResults { results, .. } = &turns[2] {
assert!(!results[0].is_error);
let content_str = results[0].content.to_string();
assert!(
content_str.contains("echo: hello"),
"Expected echo output in content, got: {content_str}"
);
} else {
panic!("Expected ToolResults turn at index 2");
}
}
#[tokio::test]
async fn tool_approval_receives_correct_args() {
let mut registry = ToolRegistry::new();
registry.register(make_echo_tool());
let captured: Arc<Mutex<Option<(String, serde_json::Value)>>> =
Arc::new(Mutex::new(None));
let captured_clone = captured.clone();
let responses = vec![
tool_call_response("echo", "call_1", serde_json::json!({"text": "world"})),
text_response("Done"),
];
let config = SessionConfig {
tool_approval: Some(Arc::new(move |name, args| {
*captured_clone.lock().unwrap() = Some((name.to_string(), args.clone()));
Ok(())
})),
..Default::default()
};
let mut session = make_session_with_tools_and_config(responses, registry, config).await;
session.process_input("Use echo").await.unwrap();
let captured_value = captured.lock().unwrap();
let (name, args) = captured_value.as_ref().expect("approval fn should have been called");
assert_eq!(name, "echo");
assert_eq!(args, &serde_json::json!({"text": "world"}));
}
#[tokio::test]
async fn tool_approval_none_skips_check() {
let mut registry = ToolRegistry::new();
registry.register(make_echo_tool());
let responses = vec![
tool_call_response("echo", "call_1", serde_json::json!({"text": "hello"})),
text_response("Done"),
];
let config = SessionConfig {
tool_approval: None,
..Default::default()
};
let mut session = make_session_with_tools_and_config(responses, registry, config).await;
session.process_input("Use echo").await.unwrap();
let turns = session.history().turns();
if let Turn::ToolResults { results, .. } = &turns[2] {
assert!(!results[0].is_error);
let content_str = results[0].content.to_string();
assert!(
content_str.contains("echo: hello"),
"Expected echo output in content, got: {content_str}"
);
} else {
panic!("Expected ToolResults turn at index 2");
}
}
#[tokio::test]
async fn tool_approval_denial_emits_error_event() {
let mut registry = ToolRegistry::new();
registry.register(make_echo_tool());
let responses = vec![
tool_call_response("echo", "call_1", serde_json::json!({"text": "hello"})),
text_response("Done"),
];
let config = SessionConfig {
tool_approval: Some(Arc::new(|_name, _args| {
Err("not allowed".to_string())
})),
..Default::default()
};
let mut session = make_session_with_tools_and_config(responses, registry, config).await;
let mut rx = session.subscribe();
session.process_input("Use echo").await.unwrap();
let mut tool_end_events = Vec::new();
while let Ok(event) = rx.try_recv() {
if event.kind == EventKind::ToolCallEnd {
tool_end_events.push(event);
}
}
assert_eq!(tool_end_events.len(), 1);
match &tool_end_events[0].data {
EventData::ToolCallEnd { is_error, .. } => {
assert!(is_error, "ToolCallEnd event should have is_error: true");
}
_ => panic!("Expected ToolCallEnd event data"),
}
}
}