553 lines
17 KiB
Rust
553 lines
17 KiB
Rust
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"));
|
|
}
|