use std::collections::HashMap; use std::path::PathBuf; use ai::diff_validation::ParsedDiff; use ai::skills::{SkillPathOrigin, SkillReference}; use galaxy_agent_core::ToolCall; use super::{action_from_tool_call, MCPToolTarget}; use crate::ai::agent::{AIAgentActionType, FileEdit, RunAgentsExecutionMode}; fn call(name: &str, arguments: serde_json::Value) -> ToolCall { ToolCall { id: "call-1".to_string(), name: name.to_string(), arguments, } } #[test] fn shell_calls_become_domain_actions_without_a_proto_round_trip() { let action = action_from_tool_call( "task-1", &call( "run_shell_command", serde_json::json!({ "command": "cargo test", "is_read_only": true, "is_risky": false }), ), &SkillPathOrigin::Local, &HashMap::new(), ) .unwrap(); assert_eq!(action.id.to_string(), "call-1"); assert_eq!(action.task_id.to_string(), "task-1"); assert!(matches!( action.action, AIAgentActionType::RequestCommandOutput { command, is_read_only: Some(true), is_risky: Some(false), .. } if command == "cargo test" )); } #[test] fn transfer_control_calls_become_domain_actions() { let action = action_from_tool_call( "task-1", &call( "transfer_shell_command_control_to_user", serde_json::json!({"reason": "The command needs interactive input"}), ), &SkillPathOrigin::Local, &HashMap::new(), ) .unwrap(); assert!(matches!( action.action, AIAgentActionType::TransferShellCommandControlToUser { reason } if reason == "The command needs interactive input" )); } #[test] fn create_plan_calls_become_document_actions() { let action = action_from_tool_call( "task-1", &call( "create_plan", serde_json::json!({ "documents": [{ "title": "Duplicate content items", "content": "# Implementation plan" }] }), ), &SkillPathOrigin::Local, &HashMap::new(), ) .unwrap(); let AIAgentActionType::CreateDocuments(request) = action.action else { panic!("expected create-documents action"); }; assert_eq!(request.documents.len(), 1); assert_eq!(request.documents[0].title, "Duplicate content items"); assert_eq!(request.documents[0].content, "# Implementation plan"); } #[test] fn read_files_converts_advertised_inclusive_ranges_to_half_open_ranges() { let action = action_from_tool_call( "task-1", &call( "read_files", serde_json::json!({ "files": [{ "path": "/tmp/example.rs", "line_ranges": [ {"start": 1, "end": 1}, {"start": 10, "end": 25} ] }] }), ), &SkillPathOrigin::Local, &HashMap::new(), ) .unwrap(); let AIAgentActionType::ReadFiles(request) = action.action else { panic!("expected read-files action"); }; assert_eq!(request.locations[0].lines, vec![1..2, 10..26]); } #[test] fn known_tools_reject_malformed_required_inputs() { let cases = [ ("read_files", serde_json::json!({}), "files"), ( "read_files", serde_json::json!({"files": "not-an-array"}), "expected an array", ), ( "read_files", serde_json::json!({"files": [{"path": "/tmp/a", "line_ranges": [{"start": 0, "end": 1}]}]}), "positive integer", ), ( "read_files", serde_json::json!({"files": [{"path": "/tmp/a", "line_ranges": [{"start": 3, "end": 2}]}]}), "greater than or equal", ), ( "read_files", serde_json::json!({"files": [{"path": "/tmp/a", "line_ranges": [{"start": 1, "end": u64::MAX}]}]}), "inclusive end is too large", ), ( "grep", serde_json::json!({"queries": ["ok", 7]}), "queries[1]", ), ( "file_glob", serde_json::json!({"patterns": false}), "expected an array", ), ( "search_codebase", serde_json::json!({"query": 42}), "expected a string", ), ( "apply_file_diffs", serde_json::json!({"summary": "edit", "diffs": [{"file_path": "/tmp/a", "search": "x"}]}), "replace", ), ( "apply_file_diffs", serde_json::json!({"summary": "Nothing to do"}), "at least one diff", ), ( "run_shell_command", serde_json::json!({"command": 42}), "expected a string", ), ( "run_shell_command", serde_json::json!({"command": " "}), "non-empty string", ), ( "run_shell_command", serde_json::json!({"command": "pwd", "is_read_only": "yes"}), "expected a boolean", ), ( "write_to_long_running_shell_command", serde_json::json!({"command_id": "command-1", "input": "yes", "mode": "words"}), "mode", ), ( "interrupt_shell_command", serde_json::json!({}), "command_id", ), ( "read_shell_command_output", serde_json::json!({"command_id": 12}), "expected a string", ), ( "read_shell_command_output", serde_json::json!({"command_id": "command-1", "wait_seconds": 11}), "no greater than", ), ( "run_agents", serde_json::json!({"summary": "Investigate", "agent_run_configs": []}), "at least one item", ), ( "run_agents", serde_json::json!({"summary": "Investigate", "agent_run_configs": [{"name": "one"}]}), "prompt", ), ( "run_agents", serde_json::json!({"summary": "Investigate", "agent_run_configs": [{"name": "one", "prompt": "Inspect"}], "execution_mode": {"type": "other"}}), "execution_mode.type", ), ( "run_agents", serde_json::json!({"summary": "Investigate", "agent_run_configs": [{"name": "one", "prompt": "Inspect"}], "skills": [{"skill": "test", "reference_type": "other"}]}), "skills[0].reference_type", ), ( "start_agent", serde_json::json!({"name": "worker"}), "prompt", ), ( "transfer_shell_command_control_to_user", serde_json::json!({"reason": false}), "expected a string", ), ( "wait_for_events", serde_json::json!({"idle_timeout_seconds": -1}), "non-negative", ), ( "create_plan", serde_json::json!({"documents": [{"title": "Plan"}]}), "content", ), ( "read_skill", serde_json::json!({"skill": "/tmp/SKILL.md", "reference_type": "other"}), "reference_type", ), ( "fetch_conversation", serde_json::json!({"conversation_id": null}), "expected a string", ), ]; for (name, arguments, expected_error) in cases { let error = action_from_tool_call( "task-1", &call(name, arguments), &SkillPathOrigin::Local, &HashMap::new(), ) .unwrap_err(); assert!( error.contains(expected_error), "{name} error {error:?} did not contain {expected_error:?}" ); } } #[test] fn known_tools_preserve_legitimate_optional_defaults() { let cases = [ ("grep", serde_json::json!({"queries": ["needle"]})), ("file_glob", serde_json::json!({"patterns": ["**/*.rs"]})), ( "ask_user_question", serde_json::json!({"question": "Continue?"}), ), ( "apply_file_diffs", serde_json::json!({"summary": "Create file", "new_files": [{"file_path": "/tmp/new", "content": ""}]}), ), ("run_shell_command", serde_json::json!({"command": "pwd"})), ( "write_to_long_running_shell_command", serde_json::json!({"command_id": "command-1", "input": ""}), ), ( "read_shell_command_output", serde_json::json!({"command_id": "command-1"}), ), ( "run_agents", serde_json::json!({ "summary": "Investigate", "agent_run_configs": [{"name": "worker", "prompt": "Inspect"}] }), ), ("wait_for_events", serde_json::json!({})), ]; for (name, arguments) in cases { action_from_tool_call( "task-1", &call(name, arguments), &SkillPathOrigin::Local, &HashMap::new(), ) .unwrap_or_else(|error| panic!("{name} rejected optional defaults: {error}")); } } #[test] fn edit_calls_preserve_file_edits_in_the_domain_model() { let action = action_from_tool_call( "task-1", &call( "apply_file_diffs", serde_json::json!({ "summary": "Update greeting", "diffs": [{ "file_path": "/tmp/greeting.txt", "search": "hello", "replace": "hello galaxy" }] }), ), &SkillPathOrigin::Local, &HashMap::new(), ) .unwrap(); let AIAgentActionType::RequestFileEdits { file_edits, title } = action.action else { panic!("expected file-edit action"); }; assert_eq!(title.as_deref(), Some("Update greeting")); assert!(matches!( &file_edits[0], FileEdit::Edit(ParsedDiff::StrReplaceEdit { file: Some(file), search: Some(search), replace: Some(replace), }) if file == "/tmp/greeting.txt" && search == "hello" && replace == "hello galaxy" )); } #[test] fn grouped_mcp_calls_keep_the_installation_uuid_and_json_input() { let action = action_from_tool_call( "task-1", &call( "mcp__11111111-1111-4111-8111-111111111111__echo", serde_json::json!({"message": "hello"}), ), &SkillPathOrigin::Local, &HashMap::new(), ) .unwrap(); assert!(matches!( action.action, AIAgentActionType::CallMCPTool { server_id: Some(server_id), name, input, } if server_id.to_string() == "11111111-1111-4111-8111-111111111111" && name == "echo" && input == serde_json::json!({"message": "hello"}) )); } #[test] fn provider_safe_mcp_aliases_resolve_to_the_original_tool() { let server_id = uuid::Uuid::parse_str("10804e3a-859e-4474-bf89-80e98d1dd086").unwrap(); let alias = "mcp__performance_analyze_insight__0123456789abcdef"; let aliases = HashMap::from([( alias.to_string(), MCPToolTarget { server_id: Some(server_id), name: "performance_analyze_insight".to_string(), }, )]); let action = action_from_tool_call( "task-1", &call(alias, serde_json::json!({"trace_id": "trace-1"})), &SkillPathOrigin::Local, &aliases, ) .unwrap(); assert!(matches!( action.action, AIAgentActionType::CallMCPTool { server_id: Some(actual_server_id), name, input, } if actual_server_id == server_id && name == "performance_analyze_insight" && input == serde_json::json!({"trace_id": "trace-1"}) )); } #[test] fn local_skill_paths_preserve_the_session_origin() { let action = action_from_tool_call( "task-1", &call( "read_skill", serde_json::json!({ "skill": "/tmp/example/SKILL.md", "reference_type": "path" }), ), &SkillPathOrigin::Local, &HashMap::new(), ) .unwrap(); assert!(matches!( action.action, AIAgentActionType::ReadSkill(request) if request.skill == SkillReference::Path( galaxy_util::local_or_remote_path::LocalOrRemotePath::Local(PathBuf::from( "/tmp/example/SKILL.md" )) ) )); } #[test] fn run_agents_calls_decode_to_local_domain_requests_with_safe_defaults() { let action = action_from_tool_call( "task-1", &call( "run_agents", serde_json::json!({ "summary": "Parallel investigation", "base_prompt": "Inspect before changing files.", "agent_run_configs": [ { "name": "runtime", "prompt": "Inspect runtime behavior", "title": "Runtime investigator", "model_id": "strong-model" }, { "name": "tests", "prompt": "Design focused tests" } ] }), ), &SkillPathOrigin::Local, &HashMap::new(), ) .unwrap(); let AIAgentActionType::RunAgents(request) = action.action else { panic!("expected run-agents action"); }; assert_eq!(request.summary, "Parallel investigation"); assert_eq!(request.base_prompt, "Inspect before changing files."); assert!(request.skills.is_empty()); assert!(request.model_id.is_empty()); assert!(request.harness_type.is_empty()); assert_eq!(request.execution_mode, RunAgentsExecutionMode::Local); assert!(request.plan_id.is_empty()); assert!(request.harness_auth_secret_name.is_none()); assert_eq!(request.agent_run_configs.len(), 2); assert_eq!(request.agent_run_configs[0].name, "runtime"); assert_eq!( request.agent_run_configs[0].prompt, "Inspect runtime behavior" ); assert_eq!(request.agent_run_configs[0].title, "Runtime investigator"); assert_eq!(request.agent_run_configs[0].model_id, "strong-model"); assert_eq!(request.agent_run_configs[1].name, "tests"); assert_eq!(request.agent_run_configs[1].prompt, "Design focused tests"); assert!(request.agent_run_configs[1].title.is_empty()); assert!(request.agent_run_configs[1].model_id.is_empty()); } #[test] fn run_agents_calls_preserve_remote_config_and_skills() { let action = action_from_tool_call( "task-1", &call( "run_agents", serde_json::json!({ "summary": "Remote investigation", "model_id": "remote-model", "harness_type": "codex", "execution_mode": { "type": "remote", "environment_id": "env-1", "worker_host": "worker.example", "computer_use_enabled": true }, "skills": [ {"skill": "galaxyctrl", "reference_type": "bundled"}, {"skill": "/repo/SKILL.md", "reference_type": "path"} ], "agent_run_configs": [{"name": "remote", "prompt": "Inspect"}], "plan_id": "plan-1" }), ), &SkillPathOrigin::Local, &HashMap::new(), ) .unwrap(); let AIAgentActionType::RunAgents(request) = action.action else { panic!("expected run-agents action"); }; assert_eq!(request.model_id, "remote-model"); assert_eq!(request.harness_type, "codex"); assert_eq!(request.plan_id, "plan-1"); assert_eq!( request.skills, vec![ SkillReference::BundledSkillId("galaxyctrl".to_string()), SkillReference::Path(galaxy_util::local_or_remote_path::LocalOrRemotePath::Local( PathBuf::from("/repo/SKILL.md") )), ] ); assert_eq!( request.execution_mode, RunAgentsExecutionMode::Remote { environment_id: "env-1".to_string(), worker_host: "worker.example".to_string(), computer_use_enabled: true, } ); } #[test] fn unknown_tools_are_rejected_before_the_permission_boundary() { let error = action_from_tool_call( "task-1", &call("invented_tool", serde_json::json!({})), &SkillPathOrigin::Local, &HashMap::new(), ) .unwrap_err(); assert!(error.contains("unsupported Rig tool proposal")); }