use super::*;
use crate::config::Config;
use crate::testing::mock_api::MockLlmServer;
#[test]
fn confirm_response_requires_explicit_yolo_word() {
use super::{parse_confirm_response, ConfirmDecision};
assert_eq!(parse_confirm_response("y"), ConfirmDecision::ExecuteOnce);
assert_eq!(parse_confirm_response("YES"), ConfirmDecision::ExecuteOnce);
assert_eq!(parse_confirm_response("yolo"), ConfirmDecision::EnableYolo);
assert_eq!(
parse_confirm_response(" YOLO "),
ConfirmDecision::EnableYolo
);
assert_eq!(parse_confirm_response("s"), ConfirmDecision::Skip);
assert_eq!(parse_confirm_response("skip"), ConfirmDecision::Skip);
assert_eq!(parse_confirm_response("n"), ConfirmDecision::Skip);
assert_eq!(parse_confirm_response(""), ConfirmDecision::Skip);
}
#[test]
fn confirm_response_always_allow_is_tool_scoped_session_grant() {
use super::{parse_confirm_response, ConfirmDecision};
assert_eq!(parse_confirm_response("a"), ConfirmDecision::AlwaysAllow);
assert_eq!(parse_confirm_response("A"), ConfirmDecision::AlwaysAllow);
assert_eq!(
parse_confirm_response("always"),
ConfirmDecision::AlwaysAllow
);
assert_eq!(
parse_confirm_response(" Always "),
ConfirmDecision::AlwaysAllow
);
assert_ne!(parse_confirm_response("al"), ConfirmDecision::AlwaysAllow);
assert_eq!(parse_confirm_response("y"), ConfirmDecision::ExecuteOnce);
assert_eq!(parse_confirm_response("yolo"), ConfirmDecision::EnableYolo);
}
#[test]
fn test_shell_verification_matches_at_command_boundary() {
assert!(shell_command_is_verification("cargo check"));
assert!(shell_command_is_verification("cargo test --all"));
assert!(shell_command_is_verification("~/.cargo/bin/cargo check"));
assert!(shell_command_is_verification(
"/usr/bin/cargo check --message-format short"
));
assert!(shell_command_is_verification("cd crates/foo && cargo test"));
assert!(!shell_command_is_verification("cargo add serde"));
assert!(!shell_command_is_verification("echo cargo checkers"));
assert!(!shell_command_is_verification("ls -la"));
}
#[test]
fn test_shell_verification_credits_direct_test_script_runs() {
assert!(shell_command_is_verification("python3 test_calc.py"));
assert!(shell_command_is_verification("python test_calc.py"));
assert!(shell_command_is_verification("python3 tests/test_calc.py"));
assert!(shell_command_is_verification(
"python3 -c \"assert add(2, 2) == 4\""
));
assert!(shell_command_is_verification("node tests/smoke.test.js"));
assert!(shell_command_is_verification("node test_smoke.js"));
assert!(shell_command_is_verification("ruby test_foo.rb"));
assert!(shell_command_is_verification("bash tests/run.sh"));
assert!(shell_command_is_verification("./test_x.py"));
assert!(shell_command_is_verification("/usr/bin/python3 test_x.py"));
assert!(shell_command_is_verification("cd sub && python3 test_x.py"));
assert!(shell_command_is_verification("python3 -u test_x.py"));
assert!(!shell_command_is_verification("python3 app.py"));
assert!(!shell_command_is_verification("python3 -c \"print('hi')\""));
assert!(!shell_command_is_verification(
"python3 process.py test_data.csv"
));
assert!(!shell_command_is_verification("node server.js"));
assert!(!shell_command_is_verification("bash deploy.sh"));
}
#[test]
fn test_observational_includes_direct_test_script_runs() {
assert!(shell_command_is_observational("python3 test_calc.py"));
assert!(shell_command_is_observational(
"python3 -c \"assert x == 1\""
));
assert!(!tool_call_is_mutating(
"shell_exec",
&serde_json::json!({"command": "python3 test_calc.py"})
));
assert!(!shell_command_is_observational(
"python3 -c \"open('f','w').write('x')\""
));
assert!(!shell_command_is_observational(
"python3 test_calc.py > out.txt"
));
assert!(!shell_command_is_observational("python3 app.py"));
}
#[test]
fn test_pty_shell_verification_credited_like_shell_exec() {
let verification = r#"{"command":"python3 test_calc.py"}"#;
assert!(tool_call_is_verification("shell_exec", verification));
assert!(tool_call_is_verification("pty_shell", verification));
assert!(!tool_call_is_verification(
"pty_shell",
r#"{"command":"python3 app.py"}"#
));
}
#[tokio::test]
async fn passing_direct_python_test_run_is_credited_without_re_staling() {
let mut agent = Agent::new(test_config("http://127.0.0.1:1".to_string()))
.await
.expect("agent should build");
agent.note_mutating_tool_call();
assert_eq!(agent.mutation_sequence, 1);
let args = serde_json::json!({"command": "python3 test_calc.py"});
if tool_call_is_mutating("shell_exec", &args) {
agent.note_mutating_tool_call();
}
agent.note_verification_outcome("shell_exec", &args.to_string(), true, "1 passed");
assert_eq!(
agent.mutation_sequence, 1,
"a passing verification run must not bump the mutation sequence (re-stale the gate)"
);
assert_eq!(
agent.last_successful_verification_mutation_sequence, 1,
"the passing direct test run must be credited as verification"
);
assert!(agent.last_failed_verification_summary.is_none());
}
#[tokio::test]
async fn failing_direct_python_test_run_records_failure_summary() {
let mut agent = Agent::new(test_config("http://127.0.0.1:1".to_string()))
.await
.expect("agent should build");
agent.note_mutating_tool_call();
let args = serde_json::json!({"command": "python3 test_calc.py"});
agent.note_verification_outcome(
"shell_exec",
&args.to_string(),
false,
"FAILED test_calc.py::test_div",
);
assert_eq!(
agent.last_successful_verification_mutation_sequence, 0,
"a failing verification run must not be credited"
);
let summary = agent
.last_failed_verification_summary
.clone()
.expect("a failing verification run must be recorded");
assert!(summary.contains("shell_exec failed"), "{summary}");
}
#[test]
fn patch_target_paths_extracts_targets() {
let diff = "--- a/src/a.rs\n+++ b/src/a.rs\n@@ -1 +1 @@\n-x\n+y\n\
--- a/b.txt\n+++ b/b.txt\n@@ -0,0 +1,1 @@\n+z\n";
let paths = patch_target_paths(diff);
assert_eq!(
paths,
vec![
std::path::PathBuf::from("src/a.rs"),
std::path::PathBuf::from("b.txt")
]
);
let deleted = "--- a/gone.rs\n+++ /dev/null\n@@ -1 +0,0 @@\n-x\n";
assert!(patch_target_paths(deleted).is_empty());
}
#[tokio::test]
async fn multi_file_snapshot_captures_every_target_for_undo() {
let mut agent = Agent::new(test_config("http://127.0.0.1:1".to_string()))
.await
.expect("agent should build");
let dir = tempfile::tempdir().unwrap();
let f1 = dir.path().join("a.txt");
let f2 = dir.path().join("b.txt");
std::fs::write(&f1, "alpha\n").unwrap();
std::fs::write(&f2, "beta\n").unwrap();
Agent::snapshot_files_for_undo(
&mut agent.edit_history,
vec![f1.clone(), f2.clone()],
"patch_apply",
)
.await;
let checkpoint = agent
.edit_history
.current_checkpoint()
.expect("snapshot must create a checkpoint");
match &checkpoint.action {
crate::session::edit_history::EditAction::MultiFileEdit { paths, tool } => {
assert_eq!(tool, "patch_apply");
assert_eq!(paths.len(), 2);
}
other => panic!("expected MultiFileEdit, got {other:?}"),
}
assert_eq!(
checkpoint.files[&f1].content, "alpha\n",
"pre-edit content must be captured for /undo"
);
assert_eq!(checkpoint.files[&f2].content, "beta\n");
std::fs::write(&f1, "EDITED\n").unwrap();
for (path, snap) in &checkpoint.files {
std::fs::write(path, &snap.content).unwrap();
}
assert_eq!(std::fs::read_to_string(&f1).unwrap(), "alpha\n");
}
#[tokio::test]
async fn file_multi_edit_dispatch_snapshots_undo_and_clears_cache() {
let mut agent = Agent::new(test_config("http://127.0.0.1:1".to_string()))
.await
.expect("agent should build");
let dir = tempfile::tempdir().unwrap();
let f1 = dir.path().join("m1.txt");
let f2 = dir.path().join("m2.txt");
std::fs::write(&f1, "one\n").unwrap();
std::fs::write(&f2, "two\n").unwrap();
let status_args = serde_json::json!({"repo_path": "."});
agent
.cache_manager
.tool_cache
.set(
"git_status",
&status_args,
serde_json::json!({"branch": "old"}),
)
.await;
let args = serde_json::json!({
"edits": [
{"path": f1.to_str().unwrap(), "old_str": "one", "new_str": "ONE"},
{"path": f2.to_str().unwrap(), "old_str": "two", "new_str": "TWO"}
]
});
let args_str = args.to_string();
let (ok, result, _) = agent
.execute_single_tool(
"file_multi_edit",
&args_str,
&args,
std::time::Instant::now(),
)
.await
.expect("dispatch should run");
assert!(ok, "multi_edit should succeed: {result}");
let checkpoint = agent
.edit_history
.current_checkpoint()
.expect("file_multi_edit must capture a pre-edit checkpoint");
assert!(
matches!(
checkpoint.action,
crate::session::edit_history::EditAction::MultiFileEdit { .. }
),
"expected a MultiFileEdit checkpoint, got {:?}",
checkpoint.action
);
assert_eq!(checkpoint.files[&f1].content, "one\n");
assert_eq!(checkpoint.files[&f2].content, "two\n");
assert!(agent
.cache_manager
.tool_cache
.get("git_status", &status_args)
.await
.is_none());
}
fn test_config(endpoint: String) -> Config {
Config {
endpoint,
model: "mock-model".to_string(),
agent: crate::config::AgentConfig {
max_iterations: 50,
step_timeout_secs: 10,
streaming: false,
native_function_calling: false,
min_completion_steps: 0,
require_verification_before_completion: false,
..Default::default()
},
safety: crate::config::SafetyConfig {
allowed_paths: vec!["./**".to_string(), "/**".to_string()],
..Default::default()
},
execution_mode: crate::config::ExecutionMode::Yolo,
..Default::default()
}
}
#[test]
fn test_tool_error_kind_classify_safety_violation() {
assert_eq!(
ToolErrorKind::classify("safety check failed"),
ToolErrorKind::SafetyViolation
);
assert_eq!(
ToolErrorKind::classify("Operation blocked by safety policy"),
ToolErrorKind::SafetyViolation
);
assert_eq!(
ToolErrorKind::classify("BLOCKED: File access denied"),
ToolErrorKind::SafetyViolation
);
}
#[test]
fn test_tool_error_kind_classify_resource_not_found() {
assert_eq!(
ToolErrorKind::classify("File not found"),
ToolErrorKind::ResourceNotFound
);
assert_eq!(
ToolErrorKind::classify("No such file or directory"),
ToolErrorKind::ResourceNotFound
);
assert_eq!(
ToolErrorKind::classify("resource NOT FOUND"),
ToolErrorKind::ResourceNotFound
);
}
#[test]
fn test_tool_error_kind_classify_permission_denied() {
assert_eq!(
ToolErrorKind::classify("Permission denied"),
ToolErrorKind::PermissionDenied
);
assert_eq!(
ToolErrorKind::classify("Access denied"),
ToolErrorKind::PermissionDenied
);
assert_eq!(
ToolErrorKind::classify("operation not permitted"),
ToolErrorKind::PermissionDenied
);
}
#[test]
fn test_tool_error_kind_classify_argument_error() {
assert_eq!(
ToolErrorKind::classify("Failed to parse JSON"),
ToolErrorKind::ArgumentError
);
assert_eq!(
ToolErrorKind::classify("Invalid argument provided"),
ToolErrorKind::ArgumentError
);
assert_eq!(
ToolErrorKind::classify("JSON parsing error"),
ToolErrorKind::ArgumentError
);
assert_eq!(
ToolErrorKind::classify("parse error at line 5"),
ToolErrorKind::ArgumentError
);
}
#[test]
fn test_tool_error_kind_classify_timeout() {
assert_eq!(
ToolErrorKind::classify("Request timeout"),
ToolErrorKind::Timeout
);
assert_eq!(
ToolErrorKind::classify("Operation timed out after 30s"),
ToolErrorKind::Timeout
);
}
#[test]
fn test_tool_error_kind_classify_execution_error_fallback() {
assert_eq!(
ToolErrorKind::classify("Something went wrong"),
ToolErrorKind::ExecutionError
);
assert_eq!(
ToolErrorKind::classify("Unknown error occurred"),
ToolErrorKind::ExecutionError
);
assert_eq!(ToolErrorKind::classify(""), ToolErrorKind::ExecutionError);
}
#[test]
fn test_tool_error_kind_classify_case_insensitive() {
assert_eq!(
ToolErrorKind::classify("SAFETY VIOLATION"),
ToolErrorKind::SafetyViolation
);
assert_eq!(ToolErrorKind::classify("Timeout"), ToolErrorKind::Timeout);
assert_eq!(
ToolErrorKind::classify("JSON error"),
ToolErrorKind::ArgumentError
);
}
#[test]
fn test_tool_error_kind_as_str() {
assert_eq!(ToolErrorKind::SafetyViolation.as_str(), "SAFETY_VIOLATION");
assert_eq!(
ToolErrorKind::ResourceNotFound.as_str(),
"RESOURCE_NOT_FOUND"
);
assert_eq!(
ToolErrorKind::PermissionDenied.as_str(),
"PERMISSION_DENIED"
);
assert_eq!(ToolErrorKind::ArgumentError.as_str(), "ARGUMENT_ERROR");
assert_eq!(ToolErrorKind::Timeout.as_str(), "TIMEOUT");
assert_eq!(ToolErrorKind::ExecutionError.as_str(), "EXECUTION_ERROR");
}
#[test]
fn test_tool_error_kind_recovery_hint_safety() {
let hint = ToolErrorKind::SafetyViolation.recovery_hint();
assert!(hint.contains("protected files"));
assert!(!hint.is_empty());
}
#[test]
fn test_tool_error_kind_recovery_hint_resource_not_found() {
let hint = ToolErrorKind::ResourceNotFound.recovery_hint();
assert!(hint.contains("path exists"));
assert!(!hint.is_empty());
}
#[test]
fn test_tool_error_kind_recovery_hint_permission_denied() {
let hint = ToolErrorKind::PermissionDenied.recovery_hint();
assert!(hint.contains("sudo") || hint.contains("permissions"));
assert!(!hint.is_empty());
}
#[test]
fn test_tool_error_kind_recovery_hint_argument_error() {
let hint = ToolErrorKind::ArgumentError.recovery_hint();
assert!(hint.contains("schema") || hint.contains("arguments"));
assert!(!hint.is_empty());
}
#[test]
fn test_tool_error_kind_recovery_hint_timeout() {
let hint = ToolErrorKind::Timeout.recovery_hint();
assert!(hint.contains("smaller steps") || hint.contains("timeout"));
assert!(!hint.is_empty());
}
#[test]
fn test_tool_error_kind_recovery_hint_execution_error() {
let hint = ToolErrorKind::ExecutionError.recovery_hint();
assert!(hint.contains("adjust") || hint.contains("Review"));
assert!(!hint.is_empty());
}
#[test]
fn test_tool_error_kind_all_hints_are_non_empty() {
for kind in [
ToolErrorKind::SafetyViolation,
ToolErrorKind::ResourceNotFound,
ToolErrorKind::PermissionDenied,
ToolErrorKind::ArgumentError,
ToolErrorKind::Timeout,
ToolErrorKind::ExecutionError,
] {
let hint = kind.recovery_hint();
assert!(
hint.len() > 10,
"Recovery hint for {:?} should be meaningful, got: {}",
kind,
hint
);
}
}
#[test]
fn test_tool_error_kind_roundtrip_classification() {
let test_errors = vec![
("safety block triggered", ToolErrorKind::SafetyViolation),
("file not found error", ToolErrorKind::ResourceNotFound),
("permission denied on read", ToolErrorKind::PermissionDenied),
("invalid JSON format", ToolErrorKind::ArgumentError),
("connection timeout", ToolErrorKind::Timeout),
("unexpected failure", ToolErrorKind::ExecutionError),
];
for (error_msg, expected_kind) in test_errors {
let classified = ToolErrorKind::classify(error_msg);
assert_eq!(
classified, expected_kind,
"Failed to classify '{}' correctly",
error_msg
);
let _ = classified.as_str();
let _ = classified.recovery_hint();
}
}
#[test]
fn test_truncate_chars_short_string() {
let input = "short";
let result = truncate_chars(input, 100);
assert_eq!(result, input);
}
#[test]
fn test_truncate_chars_exact_length() {
let input = "exactly10";
let result = truncate_chars(input, 9);
assert_eq!(result, input);
}
#[test]
fn test_truncate_chars_long_string() {
let input = "this is a very long string";
let result = truncate_chars(input, 10);
assert_eq!(result, "this is a ...");
}
#[test]
fn test_truncate_chars_unicode() {
let input = "🎉🎊🎁🎄🎃🎅🤶🧑🎄";
let result = truncate_chars(input, 3);
assert_eq!(result, "🎉🎊🎁...");
}
#[test]
fn summarize_generic_preserves_tail_marker() {
let middle = "x".repeat(60_000);
let raw = format!(
"START\n{}\n<verification_failed>tests FAILED</verification_failed>",
middle
);
let summary = summarize_generic(&raw);
assert!(summary.contains("START"), "head kept");
assert!(
summary.contains("<verification_failed>") && summary.contains("FAILED"),
"tail failure marker must survive summarization: {}",
&summary[summary.len().saturating_sub(200)..]
);
assert!(summary.chars().count() < raw.chars().count());
assert!(summary.contains("omitted from the middle"));
}
#[test]
fn summarize_generic_keeps_small_input_verbatim() {
let raw = "short output\nline 2\n<verification_failed>nope</verification_failed>";
assert_eq!(summarize_generic(raw), raw);
}
#[tokio::test]
async fn summarize_and_spill_redacts_secrets_on_disk() {
let secret = format!("ghp_{}", "a".repeat(36)); let raw = format!(
"{{\"output\":\"export TOKEN={secret}\\n{}\"}}",
"x".repeat(60_000)
);
let call_id = "spillredacttest01";
let _summary = summarize_and_spill("shell_exec", call_id, &raw, 9999).await;
let spill_file = std::path::Path::new(TOOL_RESULTS_DIR).join(format!(
"shell_exec_{}.json",
call_id.chars().take(12).collect::<String>()
));
let on_disk = std::fs::read_to_string(&spill_file).expect("spill file should exist");
let _ = std::fs::remove_file(&spill_file);
assert!(
!on_disk.contains(&secret),
"secret leaked to the spill file on disk"
);
assert!(
on_disk.contains("[REDACTED]"),
"spill file should contain the redaction marker"
);
}
#[test]
fn test_canonicalize_tool_args_valid_json() {
let input = r#"{"key": "value", "num": 42}"#;
let result = canonicalize_tool_args(input);
assert!(result.contains("key"));
assert!(result.contains("value"));
}
#[test]
fn test_canonicalize_tool_args_invalid_json() {
let input = "not valid json";
let result = canonicalize_tool_args(input);
assert_eq!(result, input);
}
#[test]
fn test_hash_tool_args_consistency() {
let input = r#"{"key": "value"}"#;
let hash1 = hash_tool_args(input);
let hash2 = hash_tool_args(input);
assert_eq!(hash1, hash2);
}
#[test]
fn test_hash_tool_args_equivalent_json() {
let input1 = r#"{"a":1,"b":2}"#;
let input2 = r#"{"b":2,"a":1}"#;
let hash1 = hash_tool_args(input1);
let hash2 = hash_tool_args(input2);
let _ = (hash1, hash2);
}
#[test]
fn test_extract_explicit_allowed_tools_from_task_prompt() {
let task = "Use only these concrete tools for this task:\n- `file_read`\n- `file_edit`\n- `file_write`\n- `shell_exec`\n";
let allowed = extract_explicit_allowed_tools(task).expect("expected allowlist");
assert!(allowed.contains("file_read"));
assert!(allowed.contains("file_edit"));
assert!(allowed.contains("file_write"));
assert!(allowed.contains("shell_exec"));
assert_eq!(allowed.len(), 4);
}
#[test]
fn test_extract_explicit_requested_tools_detects_imperative_use() {
let required = extract_explicit_requested_tools(
"Use vision_analyze on ./sample.jpg and answer in one sentence.",
["vision_analyze", "file_read"].iter().copied(),
);
assert!(required.contains("vision_analyze"));
assert_eq!(required.len(), 1);
}
#[test]
fn test_extract_explicit_requested_tools_detects_backticked_tool() {
let required = extract_explicit_requested_tools(
"Please call `file_read` on Cargo.toml before answering.",
["vision_analyze", "file_read"].iter().copied(),
);
assert!(required.contains("file_read"));
}
#[test]
fn test_negated_tool_mention_is_not_a_required_tool() {
let required = extract_explicit_requested_tools(
"Create notes.txt, but don't use `shell_exec`.",
["shell_exec", "file_write"].iter().copied(),
);
assert!(!required.contains("shell_exec"));
}
#[test]
fn test_shell_category_denial_overrides_plain_tool_mention() {
let task =
"Create user-check_1+2=3.txt using file_write. Do not run shell commands or use pty_shell.";
let required = extract_explicit_requested_tools(
task,
["file_write", "shell_exec", "pty_shell"].iter().copied(),
);
assert!(required.contains("file_write"));
assert!(!required.contains("shell_exec"));
assert!(!required.contains("pty_shell"));
}
#[test]
fn test_shell_exec_verification_commands_are_observational() {
assert!(shell_command_is_observational("cargo test --quiet"));
assert!(shell_command_is_observational("cargo check"));
assert!(!shell_command_is_observational("cargo fmt"));
assert!(!shell_command_is_observational("mkdir tmp"));
}
#[test]
fn test_shell_redirect_writes_are_not_observational() {
assert!(!shell_command_is_observational("echo x>y"));
assert!(!shell_command_is_observational("cat>file"));
assert!(!shell_command_is_observational("echo hi > out.txt"));
assert!(!shell_command_is_observational("cat a >> b"));
assert!(!shell_command_is_observational("echo data >/etc/thing"));
}
#[test]
fn test_shell_fd_dup_and_quoted_gt_stay_observational() {
assert!(shell_command_is_observational("cargo test 2>&1"));
assert!(shell_command_is_observational("grep foo bar 2>&1"));
assert!(shell_command_is_observational(r#"grep "->" file"#));
assert!(shell_command_is_observational("echo 'a>b'"));
}
#[test]
fn test_tool_call_counts_shell_exec_state_changes_correctly() {
assert!(!tool_call_counts_as_state_change(
"shell_exec",
r#"{"command":"cargo test"}"#
));
assert!(tool_call_counts_as_state_change(
"shell_exec",
r#"{"command":"cargo fmt"}"#
));
assert!(!tool_call_counts_as_state_change("shell_exec", r#"{}"#));
}
#[tokio::test]
async fn test_task_tool_policy_blocks_unlisted_tools() {
let server = MockLlmServer::builder().with_response("done").build().await;
let config = test_config(format!("{}/v1", server.url()));
let mut agent = Agent::new(config).await.unwrap();
agent.current_task_context = "Use only these concrete tools for this task:\n- `file_read`\n- `file_edit`\n- `file_write`\n- `shell_exec`\nNever call `tool_search`.".to_string();
agent
.execute_tool_batch(vec![(
crate::tools::context::CONTEXT_BULK_READ.to_string(),
r#"{"pattern":"src/**/*.rs","max_files":2}"#.to_string(),
None,
)])
.await
.unwrap();
let last = agent
.messages
.last()
.expect("expected tool policy rejection");
assert!(last.content.text().contains("Task tool policy violation"));
assert!(last.content.text().contains("Allowed tools"));
assert!(agent
.recent_failed_tool_attempts
.back()
.is_some_and(|attempt| attempt.failure_kind == "task_policy"));
server.stop().await;
}
#[tokio::test]
async fn test_operator_denial_is_remembered_for_exact_retry() {
let server = MockLlmServer::builder().with_response("done").build().await;
let config = test_config(format!("{}/v1", server.url()));
let mut agent = Agent::new(config).await.unwrap();
let args = r#"{"path":"notes.txt","content":"hello"}"#;
agent.record_failed_tool_attempt(
"file_write",
args,
"operator_denied",
"Tool execution denied via TUI permission prompt",
);
let failure = agent
.recent_failed_tool_attempts
.back()
.expect("operator denial should be task-local retry memory");
assert_eq!(failure.failure_kind, "operator_denied");
let retry_message = agent.build_failed_tool_retry_suppressed_message(failure);
assert!(retry_message.contains("operator denied `file_write`"));
assert!(retry_message.contains("Do not ask for the same permission again"));
server.stop().await;
}
#[tokio::test]
async fn test_progress_guard_blocks_read_only_batches_after_threshold() {
let server = MockLlmServer::builder().with_response("done").build().await;
let config = test_config(format!("{}/v1", server.url()));
let mut agent = Agent::new(config).await.unwrap();
agent.current_task_context =
"Fix the failing tests, make code changes, and keep going until everything is green."
.to_string();
agent.consecutive_read_only_steps = 19;
agent
.execute_tool_batch(vec![(
"shell_exec".to_string(),
r#"{"command":"cargo test"}"#.to_string(),
None,
)])
.await
.unwrap();
assert!(agent
.messages
.iter()
.any(|msg| msg.content.text().contains("PROGRESS GUARD")));
let last = agent
.messages
.last()
.expect("expected follow-up progress directive");
assert!(last
.content
.text()
.contains("READ-LOOP FORCE-MUTATION MODE"));
assert!(last.content.text().contains("<name>file_edit</name>"));
assert_eq!(
agent.pending_synthesis.as_deref(),
Some("Fix the failing tests, make code changes, and keep going until everything is green.")
);
assert!(agent
.recent_failed_tool_attempts
.back()
.is_some_and(|attempt| attempt.failure_kind == "progress_guard"));
agent.consecutive_read_only_steps = 14;
agent
.execute_tool_batch(vec![(
"shell_exec".to_string(),
r#"{"command":"git status"}"#.to_string(),
None,
)])
.await
.unwrap();
agent.consecutive_read_only_steps = 15;
let err = agent
.execute_tool_batch(vec![(
"shell_exec".to_string(),
r#"{"command":"git status"}"#.to_string(),
None,
)])
.await
.unwrap_err();
assert!(err.to_string().contains("READ_LOOP_NO_EDIT"));
server.stop().await;
}
#[tokio::test]
async fn test_progress_guard_novel_reads_decrement_counter() {
let server = MockLlmServer::builder().with_response("done").build().await;
let config = test_config(format!("{}/v1", server.url()));
let mut agent = Agent::new(config).await.unwrap();
agent.current_task_context =
"Refactor the module: read many files, then make changes.".to_string();
agent.consecutive_read_only_steps = 5;
agent.update_read_only_step_tracking(
&[(
"file_read".to_string(),
r#"{"path":"src/main.rs"}"#.to_string(),
None,
)],
false,
);
assert_eq!(
agent.consecutive_read_only_steps, 4,
"novel read should decrement counter"
);
agent.update_read_only_step_tracking(
&[(
"file_read".to_string(),
r#"{"path":"src/lib.rs"}"#.to_string(),
None,
)],
false,
);
assert_eq!(
agent.consecutive_read_only_steps, 3,
"second novel read should decrement counter"
);
agent.update_read_only_step_tracking(
&[(
"file_read".to_string(),
r#"{"path":"src/main.rs"}"#.to_string(),
None,
)],
false,
);
assert_eq!(
agent.consecutive_read_only_steps, 4,
"redundant re-read should increment counter"
);
agent.update_read_only_step_tracking(
&[(
"file_read".to_string(),
r#"{"path":"src/main.rs"}"#.to_string(),
None,
)],
false,
);
assert_eq!(
agent.consecutive_read_only_steps, 5,
"second redundant re-read should increment counter"
);
agent.update_read_only_step_tracking(
&[(
"file_edit".to_string(),
r#"{"path":"src/main.rs"}"#.to_string(),
None,
)],
true,
);
assert_eq!(
agent.consecutive_read_only_steps, 0,
"write should reset counter to 0"
);
assert!(
agent.seen_read_targets.is_empty(),
"write should clear seen_read_targets"
);
server.stop().await;
}
#[test]
fn test_inject_runtime_tool_defaults_uses_vision_profile() {
let mut config = crate::config::Config::default();
config.models.insert(
"vision".to_string(),
crate::config::ModelProfile {
endpoint: "https://vision.example/v1".to_string(),
model: "remote-vision".to_string(),
api_key: None,
max_tokens: 192,
temperature: 0.0,
modalities: vec!["text".to_string(), "vision".to_string()],
context_length: 262_144,
extra_body: Some({
let mut map = serde_json::Map::new();
map.insert(
"chat_template_kwargs".to_string(),
serde_json::json!({ "enable_thinking": false }),
);
map
}),
native_function_calling: None,
},
);
let effective = inject_runtime_tool_defaults(
&config,
"vision_analyze",
r#"{"prompt":"describe","image_base64":"AAAA"}"#,
);
let parsed: serde_json::Value = serde_json::from_str(&effective).unwrap();
assert_eq!(parsed["endpoint"], "https://vision.example/v1");
assert_eq!(parsed["model"], "remote-vision");
assert_eq!(parsed["max_tokens"], 192);
assert_eq!(parsed["temperature"], 0.0);
assert_eq!(parsed["detail"], "low");
assert_eq!(
parsed["extra_body"]["chat_template_kwargs"]["enable_thinking"],
serde_json::json!(false)
);
}
#[test]
fn test_inject_runtime_tool_defaults_preserves_explicit_values() {
let mut config = crate::config::Config::default();
config.models.insert(
"vision".to_string(),
crate::config::ModelProfile {
endpoint: "https://vision.example/v1".to_string(),
model: "remote-vision".to_string(),
api_key: None,
max_tokens: 192,
temperature: 0.0,
modalities: vec!["text".to_string(), "vision".to_string()],
context_length: 262_144,
extra_body: None,
native_function_calling: None,
},
);
let effective = inject_runtime_tool_defaults(
&config,
"vision_compare",
r#"{"image_a":"a.png","image_b":"b.png","endpoint":"http://custom/v1","model":"custom-model","max_tokens":512,"temperature":0.5,"detail":"high"}"#,
);
let parsed: serde_json::Value = serde_json::from_str(&effective).unwrap();
assert_eq!(parsed["endpoint"], "http://custom/v1");
assert_eq!(parsed["model"], "custom-model");
assert_eq!(parsed["max_tokens"], 512);
assert_eq!(parsed["temperature"], 0.5);
assert_eq!(parsed["detail"], "high");
}
#[test]
fn test_inject_runtime_tool_defaults_ignores_text_only_default_profile() {
let mut config = crate::config::Config::default();
config.models.insert(
"default".to_string(),
crate::config::ModelProfile {
endpoint: "https://text.example/v1".to_string(),
model: "text-only".to_string(),
api_key: None,
max_tokens: 512,
temperature: 0.3,
modalities: vec!["text".to_string()],
context_length: 131_072,
extra_body: None,
native_function_calling: None,
},
);
let effective = inject_runtime_tool_defaults(
&config,
"vision_analyze",
r#"{"prompt":"describe","image_base64":"AAAA"}"#,
);
let parsed: serde_json::Value = serde_json::from_str(&effective).unwrap();
assert!(parsed.get("endpoint").is_none());
assert!(parsed.get("model").is_none());
}
#[test]
fn test_summarize_directory_tree_basic() {
let raw = serde_json::json!({
"root": "/home/user/project",
"total": 5,
"entries": [
{"path": "/home/user/project/src/main.rs", "type": "file", "size": 1024},
{"path": "/home/user/project/src/lib.rs", "type": "file", "size": 512},
{"path": "/home/user/project/src", "type": "directory", "size": 0},
{"path": "/home/user/project/Cargo.toml", "type": "file", "size": 256},
{"path": "/home/user/project/README.md", "type": "file", "size": 128}
]
});
let summary = summarize_directory_tree(&serde_json::to_string(&raw).unwrap());
assert!(summary.contains("/home/user/project"));
assert!(summary.contains("5 entries"));
}
#[test]
fn test_summarize_directory_tree_empty() {
let raw = serde_json::json!({"root": ".", "total": 0, "entries": []});
let summary = summarize_directory_tree(&serde_json::to_string(&raw).unwrap());
assert!(summary.contains("0 entries"));
}
#[test]
fn test_summarize_directory_tree_invalid_json() {
let summary = summarize_directory_tree("not json");
assert!(summary.contains("0 entries"));
}
#[test]
fn test_summarize_file_read_short() {
let raw = serde_json::json!({
"total_lines": 5,
"content": "line1\nline2\nline3\nline4\nline5"
});
let summary = summarize_file_read(&serde_json::to_string(&raw).unwrap());
assert!(summary.contains("5 total lines"));
assert!(summary.contains("line1"));
}
#[test]
fn test_summarize_file_read_long() {
let lines: String = (0..200)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.join("\n");
let raw = serde_json::json!({
"total_lines": 200,
"content": lines
});
let summary = summarize_file_read(&serde_json::to_string(&raw).unwrap());
assert!(summary.contains("200 total lines"));
assert!(summary.contains("First 100 lines"));
assert!(summary.contains("Last 50 lines"));
assert!(summary.contains("lines omitted"));
}
#[test]
fn test_summarize_file_read_empty() {
let raw = serde_json::json!({"total_lines": 0, "content": ""});
let summary = summarize_file_read(&serde_json::to_string(&raw).unwrap());
assert!(summary.contains("0 total lines"));
}
#[test]
fn test_summarize_file_read_150_boundary_no_silent_drop() {
let lines: String = (0..150)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.join("\n");
let raw = serde_json::json!({"total_lines": 150, "content": lines});
let summary = summarize_file_read(&serde_json::to_string(&raw).unwrap());
assert!(summary.contains("line 0"), "head present");
assert!(
summary.contains("line 149"),
"last line must not be dropped"
);
assert!(
summary.contains("line 120"),
"mid-tail line must be present"
);
assert!(
!summary.contains("lines omitted"),
"nothing is actually omitted at 150 lines"
);
}
#[test]
fn test_summarize_git_diff_single_file() {
let diff = "diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n+added line\n-removed line\n+another add";
let raw = serde_json::json!({"diff": diff});
let summary = summarize_git_diff(&serde_json::to_string(&raw).unwrap());
assert!(summary.contains("1 files changed"));
assert!(summary.contains("+2"));
assert!(summary.contains("-1"));
}
#[test]
fn test_summarize_git_diff_multiple_files() {
let diff = "diff --git a/a.rs b/a.rs\n+line1\ndiff --git a/b.rs b/b.rs\n-line2";
let raw = serde_json::json!({"diff": diff});
let summary = summarize_git_diff(&serde_json::to_string(&raw).unwrap());
assert!(summary.contains("2 files changed"));
}
#[test]
fn test_summarize_git_diff_empty() {
let raw = serde_json::json!({"diff": ""});
let summary = summarize_git_diff(&serde_json::to_string(&raw).unwrap());
assert!(summary.contains("0 files changed"));
}
#[test]
fn test_summarize_bulk_read() {
let raw = serde_json::json!({"loaded": 5, "skipped": 2, "tokens_added": 10000});
let summary = summarize_bulk_read(&serde_json::to_string(&raw).unwrap());
assert!(summary.contains("5 files loaded"));
assert!(summary.contains("2 skipped"));
assert!(summary.contains("10000 tokens"));
}
#[test]
fn test_summarize_bulk_read_empty() {
let raw = serde_json::json!({});
let summary = summarize_bulk_read(&serde_json::to_string(&raw).unwrap());
assert!(summary.contains("0 files loaded"));
}
#[test]
fn test_summarize_shell_exec_basic() {
let raw = serde_json::json!({
"exit_code": 0,
"stdout": "Hello World\nLine 2",
"stderr": ""
});
let summary = summarize_shell_exec(&serde_json::to_string(&raw).unwrap());
assert!(summary.contains("Exit code: 0"));
assert!(summary.contains("Hello World"));
}
#[test]
fn test_summarize_shell_exec_with_stderr() {
let raw = serde_json::json!({
"exit_code": 1,
"stdout": "",
"stderr": "error: something failed"
});
let summary = summarize_shell_exec(&serde_json::to_string(&raw).unwrap());
assert!(summary.contains("Exit code: 1"));
assert!(summary.contains("error: something failed"));
}
#[test]
fn test_summarize_generic_short() {
let summary = summarize_generic("hello world");
assert_eq!(summary, "hello world");
}
#[test]
fn test_summarize_generic_long() {
let long = "x".repeat(20000);
let summary = summarize_generic(&long);
assert!(summary.contains("see raw file"));
}
#[test]
fn test_task_requires_mutation_fix() {
assert!(task_requires_mutation("Fix the failing test"));
}
#[test]
fn test_task_requires_mutation_respects_negation() {
assert!(!task_requires_mutation(
"Review the codebase and produce a report. Do NOT edit any files."
));
assert!(!task_requires_mutation(
"Analyze src/ for dead code without modifying anything; output your findings."
));
assert!(task_requires_mutation(
"Fix the bug, but do not edit the tests."
));
assert!(task_requires_mutation("edit main.rs to add a field"));
}
#[test]
fn test_task_requires_mutation_make_imperative() {
assert!(task_requires_mutation(
"Make parse_port return Result<u16, String> instead of panicking"
));
assert!(task_requires_mutation("Make the function generic over T"));
assert!(!task_requires_mutation(
"Make sure you understand how the parser works"
));
assert!(!task_requires_mutation("Explain the makefile targets"));
assert!(!task_requires_mutation(
"Review the code but do not make any changes"
));
}
#[test]
fn test_task_requires_mutation_implement() {
assert!(task_requires_mutation("Implement the new feature"));
}
#[test]
fn test_task_requires_mutation_edit() {
assert!(task_requires_mutation("Edit the config file"));
}
#[test]
fn test_task_requires_mutation_modify() {
assert!(task_requires_mutation("Modify the agent loop"));
}
#[test]
fn test_task_requires_mutation_update() {
assert!(task_requires_mutation("Update the dependencies"));
}
#[test]
fn test_task_requires_mutation_write() {
assert!(task_requires_mutation("Write the new module"));
}
#[test]
fn test_task_requires_mutation_create() {
assert!(task_requires_mutation("Create a new tool"));
}
#[test]
fn test_task_requires_mutation_review_deliverable_is_read_only() {
assert!(!task_requires_mutation(
"Create a thorough code review of src/agent/verification.rs with line references"
));
assert!(!task_requires_mutation("Audit the auth module for issues"));
assert!(task_requires_mutation(
"Review the code and fix the bug in parser.rs"
));
assert!(task_requires_mutation("Create a new benchmark tool"));
}
#[test]
fn test_task_requires_mutation_prose_deliverable_is_read_only() {
assert!(!task_requires_mutation("Create a summary of the auth flow"));
assert!(!task_requires_mutation(
"Write a report on the test coverage"
));
assert!(!task_requires_mutation(
"Explain how the completion gate works"
));
assert!(!task_requires_mutation("Summarize the recent changes"));
assert!(task_requires_mutation("Write a report generator function"));
assert!(task_requires_mutation(
"Create a summary parser in parser.rs"
));
}
#[test]
fn test_task_requires_mutation_refactor() {
assert!(task_requires_mutation("Refactor the parser"));
}
#[test]
fn test_task_requires_mutation_rename() {
assert!(task_requires_mutation("Rename the variable"));
}
#[test]
fn test_task_requires_mutation_delete() {
assert!(task_requires_mutation("Delete the unused file"));
}
#[test]
fn test_task_requires_mutation_remove() {
assert!(task_requires_mutation("Remove dead code"));
}
#[test]
fn test_task_requires_mutation_make_tests_pass() {
assert!(task_requires_mutation("Make tests pass"));
}
#[test]
fn test_task_requires_mutation_until_green() {
assert!(task_requires_mutation("Keep going until green"));
}
#[test]
fn test_task_no_mutation_read() {
assert!(!task_requires_mutation("Read the log file"));
}
#[test]
fn test_task_no_mutation_explore() {
assert!(!task_requires_mutation("Explore the codebase structure"));
}
#[test]
fn test_task_no_mutation_understand() {
assert!(!task_requires_mutation("Understand how the system works"));
}
#[test]
fn test_observational_cargo_test() {
assert!(shell_command_is_observational("cargo test"));
}
#[test]
fn test_observational_cargo_check() {
assert!(shell_command_is_observational("cargo check"));
}
#[test]
fn test_observational_cargo_clippy() {
assert!(shell_command_is_observational("cargo clippy"));
}
#[test]
fn test_observational_git_status() {
assert!(shell_command_is_observational("git status"));
}
#[test]
fn test_observational_git_diff() {
assert!(shell_command_is_observational("git diff"));
}
#[test]
fn test_observational_git_log() {
assert!(shell_command_is_observational("git log"));
}
#[test]
fn test_observational_ls() {
assert!(shell_command_is_observational("ls"));
}
#[test]
fn test_observational_pwd() {
assert!(shell_command_is_observational("pwd"));
}
#[test]
fn test_observational_find() {
assert!(shell_command_is_observational("find . -name '*.rs'"));
}
#[test]
fn test_observational_grep() {
assert!(shell_command_is_observational("grep -r 'pattern'"));
}
#[test]
fn test_observational_cat() {
assert!(shell_command_is_observational("cat file.txt"));
}
#[test]
fn test_observational_head() {
assert!(shell_command_is_observational("head -20 file.txt"));
}
#[test]
fn test_observational_tail() {
assert!(shell_command_is_observational("tail -f log.txt"));
}
#[test]
fn test_observational_wc() {
assert!(shell_command_is_observational("wc -l file.txt"));
}
#[test]
fn test_observational_tree() {
assert!(shell_command_is_observational("tree src/"));
}
#[test]
fn test_observational_which() {
assert!(shell_command_is_observational("which cargo"));
}
#[test]
fn test_observational_echo() {
assert!(shell_command_is_observational("echo hello"));
}
#[test]
fn test_observational_pytest() {
assert!(shell_command_is_observational("pytest tests/"));
}
#[test]
fn test_observational_sed_n() {
assert!(shell_command_is_observational("sed -n '1,10p' file.txt"));
}
#[test]
fn test_not_observational_cargo_fmt() {
assert!(!shell_command_is_observational("cargo fmt"));
}
#[test]
fn test_not_observational_cargo_fix() {
assert!(!shell_command_is_observational("cargo fix"));
}
#[test]
fn test_not_observational_cargo_update() {
assert!(!shell_command_is_observational("cargo update"));
}
#[test]
fn test_not_observational_mkdir() {
assert!(!shell_command_is_observational("mkdir new_dir"));
}
#[test]
fn test_not_observational_touch() {
assert!(!shell_command_is_observational("touch file.txt"));
}
#[test]
fn test_not_observational_rm() {
assert!(!shell_command_is_observational("rm file.txt"));
}
#[test]
fn test_not_observational_mv() {
assert!(!shell_command_is_observational("mv a.txt b.txt"));
}
#[test]
fn test_not_observational_cp() {
assert!(!shell_command_is_observational("cp a.txt b.txt"));
}
#[test]
fn test_not_observational_sed_inplace() {
assert!(!shell_command_is_observational(
"sed -i 's/foo/bar/' file.txt"
));
}
#[test]
fn test_not_observational_git_add() {
assert!(!shell_command_is_observational("git add ."));
}
#[test]
fn test_not_observational_git_commit() {
assert!(!shell_command_is_observational("git commit -m 'msg'"));
}
#[test]
fn test_not_observational_redirect() {
assert!(!shell_command_is_observational("echo hi > file.txt"));
}
#[test]
fn test_not_observational_npm_install() {
assert!(!shell_command_is_observational("npm install express"));
}
#[test]
fn test_not_observational_pip_install() {
assert!(!shell_command_is_observational("pip install requests"));
}
#[test]
fn test_observational_empty() {
assert!(!shell_command_is_observational(""));
}
#[test]
fn test_observational_file_read() {
assert!(tool_call_is_observational("file_read", "{}"));
}
#[test]
fn test_observational_directory_tree() {
assert!(tool_call_is_observational("directory_tree", "{}"));
}
#[test]
fn test_observational_glob_find() {
assert!(tool_call_is_observational("glob_find", "{}"));
}
#[test]
fn test_observational_grep_search() {
assert!(tool_call_is_observational("grep_search", "{}"));
}
#[test]
fn test_observational_symbol_search() {
assert!(tool_call_is_observational("symbol_search", "{}"));
}
#[test]
fn test_observational_git_status_tool() {
assert!(tool_call_is_observational("git_status", "{}"));
}
#[test]
fn test_observational_cargo_check_tool() {
assert!(tool_call_is_observational("cargo_check", "{}"));
}
#[test]
fn test_observational_cargo_test_tool() {
assert!(tool_call_is_observational("cargo_test", "{}"));
}
#[test]
fn test_not_observational_file_write() {
assert!(!tool_call_is_observational("file_write", "{}"));
}
#[test]
fn test_not_observational_file_edit() {
assert!(!tool_call_is_observational("file_edit", "{}"));
}
#[test]
fn test_observational_shell_exec_read_only() {
assert!(tool_call_is_observational(
"shell_exec",
r#"{"command":"cargo test"}"#
));
}
#[test]
fn test_not_observational_shell_exec_mutating() {
assert!(!tool_call_is_observational(
"shell_exec",
r#"{"command":"cargo fmt"}"#
));
}
#[test]
fn test_not_observational_shell_exec_no_command() {
assert!(!tool_call_is_observational("shell_exec", "{}"));
}
#[test]
fn test_state_change_file_write() {
assert!(tool_call_counts_as_state_change("file_write", "{}"));
}
#[test]
fn test_state_change_file_edit() {
assert!(tool_call_counts_as_state_change("file_edit", "{}"));
}
#[test]
fn test_no_state_change_file_read() {
assert!(!tool_call_counts_as_state_change("file_read", "{}"));
}
#[test]
fn test_no_state_change_cargo_check() {
assert!(!tool_call_counts_as_state_change("cargo_check", "{}"));
}
#[test]
fn test_no_state_change_cargo_test() {
assert!(!tool_call_counts_as_state_change("cargo_test", "{}"));
}
#[test]
fn test_no_state_change_cargo_clippy() {
assert!(!tool_call_counts_as_state_change("cargo_clippy", "{}"));
}
#[test]
fn test_extract_backticked_tool_names_basic() {
let names = extract_backticked_tool_names("Use `file_read` and `file_edit`");
assert_eq!(names, vec!["file_read", "file_edit"]);
}
#[test]
fn test_extract_backticked_tool_names_empty() {
let names = extract_backticked_tool_names("no tools here");
assert!(names.is_empty());
}
#[test]
fn test_extract_backticked_tool_names_invalid_chars() {
let names = extract_backticked_tool_names("Use `File Read` and `hello-world`");
assert!(names.is_empty());
}
#[test]
fn test_extract_backticked_tool_names_single() {
let names = extract_backticked_tool_names("`shell_exec`");
assert_eq!(names, vec!["shell_exec"]);
}
#[test]
fn test_extract_backticked_tool_names_with_digits() {
let names = extract_backticked_tool_names("`tool_v2`");
assert_eq!(names, vec!["tool_v2"]);
}
#[test]
fn test_extract_allowed_tools_no_section() {
let task = "Just do something useful.";
assert!(extract_explicit_allowed_tools(task).is_none());
}
#[test]
fn test_extract_allowed_tools_with_bullets() {
let task = "Use only these concrete tools:\n- `file_read`\n- `shell_exec`\n\nDo the task.";
let allowed = extract_explicit_allowed_tools(task).unwrap();
assert!(allowed.contains("file_read"));
assert!(allowed.contains("shell_exec"));
assert_eq!(allowed.len(), 2);
}
#[test]
fn test_extract_allowed_tools_case_variations() {
let task = "Allowed tools:\n- `grep_search`\n- `glob_find`\n";
let allowed = extract_explicit_allowed_tools(task).unwrap();
assert!(allowed.contains("grep_search"));
assert!(allowed.contains("glob_find"));
}
#[test]
fn test_extract_disallowed_never_call() {
let task = "Never call `tool_search`.";
let disallowed = extract_explicit_disallowed_tools(task);
assert!(disallowed.contains("tool_search"));
}
#[test]
fn test_extract_disallowed_do_not_use() {
let task = "Do not use `file_delete`.";
let disallowed = extract_explicit_disallowed_tools(task);
assert!(disallowed.contains("file_delete"));
}
#[test]
fn test_extract_disallowed_dont_use() {
let task = "Don't use `shell_exec`.";
let disallowed = extract_explicit_disallowed_tools(task);
assert!(disallowed.contains("shell_exec"));
}
#[test]
fn test_extract_disallowed_avoid() {
let task = "Avoid `git_commit` for now.";
let disallowed = extract_explicit_disallowed_tools(task);
assert!(disallowed.contains("git_commit"));
}
#[test]
fn test_extract_disallowed_shell_category() {
let task = "Do not run shell commands or use pty_shell.";
let disallowed = extract_explicit_disallowed_tools(task);
assert!(disallowed.contains("shell_exec"));
assert!(disallowed.contains("pty_shell"));
}
#[test]
fn test_extract_disallowed_empty() {
let task = "Just do the task.";
let disallowed = extract_explicit_disallowed_tools(task);
assert!(disallowed.is_empty());
}
#[test]
fn test_insert_missing_arg_adds_when_absent() {
let mut obj = serde_json::Map::new();
let inserted = insert_missing_tool_arg(&mut obj, "key", serde_json::json!("value"));
assert!(inserted);
assert_eq!(obj["key"], "value");
}
#[test]
fn test_insert_missing_arg_skips_when_present() {
let mut obj = serde_json::Map::new();
obj.insert("key".to_string(), serde_json::json!("existing"));
let inserted = insert_missing_tool_arg(&mut obj, "key", serde_json::json!("new"));
assert!(!inserted);
assert_eq!(obj["key"], "existing");
}
#[test]
fn test_insert_missing_arg_replaces_null() {
let mut obj = serde_json::Map::new();
obj.insert("key".to_string(), serde_json::Value::Null);
let inserted = insert_missing_tool_arg(&mut obj, "key", serde_json::json!("value"));
assert!(inserted);
assert_eq!(obj["key"], "value");
}
fn classify_shell_as_mutating(name: &str, command: Option<&str>) -> bool {
if matches!(
name,
"file_edit" | "file_write" | "file_delete" | "file_fim_edit"
) {
return true;
}
if name == "shell_exec" {
if let Some(cmd) = command {
return !shell_command_is_observational(cmd);
}
}
false
}
#[test]
fn shell_exec_cargo_check_does_not_count_as_mutating() {
assert!(!classify_shell_as_mutating(
"shell_exec",
Some("cargo check")
));
assert!(!classify_shell_as_mutating(
"shell_exec",
Some("git status")
));
assert!(!classify_shell_as_mutating("shell_exec", Some("ls -la")));
}
#[test]
fn shell_exec_mutating_commands_count_as_mutating() {
assert!(classify_shell_as_mutating(
"shell_exec",
Some("git add src/")
));
assert!(classify_shell_as_mutating(
"shell_exec",
Some("rm /tmp/foo")
));
assert!(classify_shell_as_mutating("shell_exec", Some("cargo fmt")));
assert!(classify_shell_as_mutating(
"shell_exec",
Some("mv a.txt b.txt")
));
assert!(classify_shell_as_mutating(
"shell_exec",
Some("sed -i 's/a/b/' file.rs")
));
assert!(classify_shell_as_mutating("file_write", None));
assert!(classify_shell_as_mutating("file_edit", None));
}
#[tokio::test]
async fn tui_permission_response_denies_when_no_channel_wired() {
let server = MockLlmServer::builder().with_response("done").build().await;
let config = test_config(format!("{}/v1", server.url()));
let mut agent = Agent::new(config).await.unwrap();
assert!(!agent.await_tui_permission_response().await);
server.stop().await;
}
#[cfg(feature = "tui")]
#[tokio::test]
async fn tui_permission_response_relays_user_answer() {
let server = MockLlmServer::builder().with_response("done").build().await;
let config = test_config(format!("{}/v1", server.url()));
let mut agent = Agent::new(config).await.unwrap();
let (tx, rx) = std::sync::mpsc::channel();
agent = agent.with_permission_channel(rx);
tx.send(true).unwrap();
assert!(agent.await_tui_permission_response().await);
let (tx, rx) = std::sync::mpsc::channel();
agent = agent.with_permission_channel(rx);
tx.send(false).unwrap();
assert!(!agent.await_tui_permission_response().await);
server.stop().await;
}
#[cfg(feature = "tui")]
#[tokio::test]
async fn tui_permission_response_denies_when_sender_dropped() {
let server = MockLlmServer::builder().with_response("done").build().await;
let config = test_config(format!("{}/v1", server.url()));
let mut agent = Agent::new(config).await.unwrap();
let (tx, rx) = std::sync::mpsc::channel::<bool>();
agent = agent.with_permission_channel(rx);
drop(tx);
assert!(!agent.await_tui_permission_response().await);
server.stop().await;
}
#[tokio::test]
async fn yolo_gate_blocks_protected_path_write() {
let server = MockLlmServer::builder().with_response("done").build().await;
let config = test_config(format!("{}/v1", server.url()));
let mut agent = Agent::new(config).await.unwrap();
agent
.execute_tool_batch(vec![(
"file_write".to_string(),
r#"{"path":"/etc/selfware-test.conf","content":"x"}"#.to_string(),
None,
)])
.await
.unwrap();
let last = agent.messages.last().expect("expected a skip message");
assert!(last.content.text().contains("Blocked by YOLO safety gate"));
server.stop().await;
}
#[tokio::test]
async fn yolo_gate_applies_in_parallel_batch_too() {
let _g = crate::test_support::ExecGuard::hold();
let server = MockLlmServer::builder().with_response("done").build().await;
let config = test_config(format!("{}/v1", server.url()));
let mut agent = Agent::new(config).await.unwrap();
agent
.execute_tool_batch(vec![
(
"file_read".to_string(),
r#"{"path":"/etc/hostname"}"#.to_string(),
None,
),
(
"file_read".to_string(),
r#"{"path":"Cargo.toml"}"#.to_string(),
None,
),
])
.await
.unwrap();
let all_text: String = agent
.messages
.iter()
.map(|m| m.content.text())
.collect::<Vec<_>>()
.join("\n---\n");
assert!(
all_text.contains("Blocked by YOLO safety gate"),
"expected the /etc/hostname read to be blocked; got: {all_text}"
);
assert!(
all_text.contains("[package]"),
"expected the Cargo.toml read to succeed; got: {all_text}"
);
server.stop().await;
}
#[tokio::test]
async fn yolo_gate_denies_destructive_shell_without_operator() {
let server = MockLlmServer::builder().with_response("done").build().await;
let config = test_config(format!("{}/v1", server.url()));
let mut agent = Agent::new(config).await.unwrap();
agent
.execute_tool_batch(vec![(
"shell_exec".to_string(),
r#"{"command":"rm -rf ./scratch"}"#.to_string(),
None,
)])
.await
.unwrap();
let last = agent.messages.last().expect("expected a skip message");
assert!(last.content.text().contains("unattended session"));
server.stop().await;
}
#[tokio::test]
async fn yolo_gate_allows_non_destructive_shell_command() {
let server = MockLlmServer::builder().with_response("done").build().await;
let config = test_config(format!("{}/v1", server.url()));
let mut agent = Agent::new(config).await.unwrap();
agent
.execute_tool_batch(vec![(
"shell_exec".to_string(),
r#"{"command":"echo hello"}"#.to_string(),
None,
)])
.await
.unwrap();
let last = agent.messages.last().expect("expected a tool result");
assert!(!last.content.text().contains("Blocked by YOLO safety gate"));
assert!(!last.content.text().contains("unattended session"));
server.stop().await;
}
#[tokio::test]
async fn yolo_gate_denies_git_push_when_disallowed() {
let server = MockLlmServer::builder().with_response("done").build().await;
let mut config = test_config(format!("{}/v1", server.url()));
config.yolo.allow_git_push = false;
let mut agent = Agent::new(config).await.unwrap();
agent
.execute_tool_batch(vec![(
"git_push".to_string(),
r#"{"branch":"feature-branch"}"#.to_string(),
None,
)])
.await
.unwrap();
let last = agent.messages.last().expect("expected a skip message");
assert!(last.content.text().contains("unattended session"));
server.stop().await;
}
#[tokio::test]
async fn git_push_to_protected_branch_is_blocked_even_with_git_push_allowed() {
let server = MockLlmServer::builder().with_response("done").build().await;
let mut config = test_config(format!("{}/v1", server.url()));
config.yolo.allow_git_push = true;
let mut agent = Agent::new(config).await.unwrap();
agent
.execute_tool_batch(vec![(
"git_push".to_string(),
r#"{"branch":"main"}"#.to_string(),
None,
)])
.await
.unwrap();
let last = agent.messages.last().expect("expected a skip message");
assert!(last.content.text().contains("protected branch"));
server.stop().await;
}
#[tokio::test]
async fn confirmation_error_in_batch_still_pushes_tool_result() {
let server = MockLlmServer::builder().with_response("done").build().await;
let mut config = test_config(format!("{}/v1", server.url()));
config.execution_mode = crate::config::ExecutionMode::Normal;
let mut agent = Agent::new(config).await.unwrap();
agent
.execute_tool_batch(vec![
(
"file_write".to_string(),
r#"{"path":"/tmp/selfware-test-confirm.txt","content":"x"}"#.to_string(),
Some("call_confirm_err".to_string()),
),
(
"shell_exec".to_string(),
r#"{"command":"echo hello"}"#.to_string(),
Some("call_after_err".to_string()),
),
])
.await
.unwrap();
let all_text: String = agent
.messages
.iter()
.map(|m| m.content.text())
.collect::<Vec<_>>()
.join("\n---\n");
assert!(
all_text.contains("headless mode"),
"expected a synthetic error result for the confirmation-errored tool; got: {all_text}"
);
assert!(
all_text.contains("hello"),
"expected the second tool in the batch to still execute after the first errored; got: {all_text}"
);
server.stop().await;
}
#[tokio::test]
async fn run_tool_bounded_returns_result_when_fast() {
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
let cancel = Arc::new(AtomicBool::new(false));
let fut = async { Ok(serde_json::json!({"ok": true})) };
let out = run_tool_bounded(fut, std::time::Duration::from_secs(5), cancel).await;
assert!(out.is_ok());
assert!(out.unwrap().is_ok());
}
#[tokio::test]
async fn run_tool_bounded_times_out() {
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
let cancel = Arc::new(AtomicBool::new(false));
let slow = async {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
Ok(serde_json::json!({}))
};
let out = run_tool_bounded(slow, std::time::Duration::from_millis(50), cancel).await;
assert_eq!(out.unwrap_err(), ToolHalt::TimedOut);
}
#[tokio::test]
async fn run_tool_bounded_cancels_in_flight() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let cancel = Arc::new(AtomicBool::new(false));
let c2 = cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
c2.store(true, Ordering::Relaxed);
});
let slow = async {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
Ok(serde_json::json!({}))
};
let out = run_tool_bounded(slow, std::time::Duration::from_secs(10), cancel).await;
assert_eq!(out.unwrap_err(), ToolHalt::Cancelled);
}
#[tokio::test]
async fn run_tool_bounded_fast_path_already_cancelled() {
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
let cancel = Arc::new(AtomicBool::new(true));
let fut = async { Ok(serde_json::json!({})) };
let out = run_tool_bounded(fut, std::time::Duration::from_secs(5), cancel).await;
assert_eq!(out.unwrap_err(), ToolHalt::Cancelled);
}
#[test]
fn mutating_predicate_covers_all_real_editors() {
use serde_json::json;
let empty = json!({});
for t in [
"file_edit",
"file_write",
"file_delete",
"file_fim_edit",
"file_multi_edit",
"patch_apply",
] {
assert!(tool_call_is_mutating(t, &empty), "{t} should be mutating");
}
for t in ["git_commit", "git_add", "git_apply", "git_reset"] {
assert!(tool_call_is_mutating(t, &empty), "{t} should be mutating");
}
for t in [
"file_read",
"git_status",
"git_log",
"git_diff",
"grep",
"list_dir",
] {
assert!(
!tool_call_is_mutating(t, &empty),
"{t} should NOT be mutating"
);
}
assert!(tool_call_is_mutating(
"shell_exec",
&json!({"command": "rm -rf build"})
));
assert!(tool_call_is_mutating(
"shell_exec",
&json!({"command": "npm install"})
));
assert!(!tool_call_is_mutating(
"shell_exec",
&json!({"command": "cargo check"})
));
assert!(!tool_call_is_mutating(
"shell_exec",
&json!({"command": "git status"})
));
}
#[tokio::test]
async fn over_budget_batch_does_not_execute_tools() {
let server = MockLlmServer::builder().with_response("done").build().await;
let mut config = test_config(format!("{}/v1", server.url()));
config.agent.max_budget_tokens = Some(1);
let mut agent = Agent::new(config).await.unwrap();
agent.cumulative_token_usage.total = 100;
let marker = std::env::temp_dir().join(format!("sw_budget_guard_{}.tmp", std::process::id()));
let _ = std::fs::remove_file(&marker);
let result = agent
.execute_tool_batch(vec![(
"file_write".to_string(),
serde_json::json!({"path": marker.to_string_lossy(), "content": "x"}).to_string(),
None,
)])
.await;
assert!(
result.is_err(),
"over-budget batch must bail before executing tools"
);
assert!(
result
.unwrap_err()
.to_string()
.to_lowercase()
.contains("budget"),
"error should mention the budget"
);
assert!(
!marker.exists(),
"over-budget batch must NOT run the file_write"
);
let _ = std::fs::remove_file(&marker);
server.stop().await;
}