518 lines
18 KiB
Rust
518 lines
18 KiB
Rust
use std::fs;
|
|
use std::path::Path;
|
|
|
|
use warp_multi_agent_api as api;
|
|
|
|
use super::{base_dir, materialize_tasks_to_yaml};
|
|
use crate::test_util::ai_agent_tasks::{
|
|
create_api_subtask, create_api_task, create_message, create_subagent_tool_call_message,
|
|
};
|
|
|
|
/// Lists filenames (not full paths) in a directory, sorted.
|
|
fn list_dir_sorted(dir: &Path) -> Vec<String> {
|
|
let mut entries: Vec<String> = fs::read_dir(dir)
|
|
.unwrap()
|
|
.filter_map(|e| e.ok())
|
|
.map(|e| e.file_name().to_string_lossy().into_owned())
|
|
.collect();
|
|
entries.sort();
|
|
entries
|
|
}
|
|
|
|
fn make_user_query_message(id: &str, task_id: &str, query: &str) -> api::Message {
|
|
api::Message {
|
|
fetched_memories: vec![],
|
|
id: id.to_string(),
|
|
task_id: task_id.to_string(),
|
|
server_message_data: String::new(),
|
|
citations: vec![],
|
|
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
|
|
query: query.to_string(),
|
|
context: None,
|
|
mode: None,
|
|
referenced_attachments: Default::default(),
|
|
intended_agent: Default::default(),
|
|
})),
|
|
request_id: String::new(),
|
|
timestamp: None,
|
|
}
|
|
}
|
|
|
|
fn make_tool_call_message(
|
|
id: &str,
|
|
task_id: &str,
|
|
tool_call_id: &str,
|
|
tool: api::message::tool_call::Tool,
|
|
) -> api::Message {
|
|
api::Message {
|
|
fetched_memories: vec![],
|
|
id: id.to_string(),
|
|
task_id: task_id.to_string(),
|
|
server_message_data: String::new(),
|
|
citations: vec![],
|
|
message: Some(api::message::Message::ToolCall(api::message::ToolCall {
|
|
tool_call_id: tool_call_id.to_string(),
|
|
tool: Some(tool),
|
|
})),
|
|
request_id: String::new(),
|
|
timestamp: None,
|
|
}
|
|
}
|
|
|
|
fn make_tool_call_result_message(
|
|
id: &str,
|
|
task_id: &str,
|
|
tool_call_id: &str,
|
|
result: api::message::tool_call_result::Result,
|
|
) -> api::Message {
|
|
api::Message {
|
|
fetched_memories: vec![],
|
|
id: id.to_string(),
|
|
task_id: task_id.to_string(),
|
|
server_message_data: String::new(),
|
|
citations: vec![],
|
|
message: Some(api::message::Message::ToolCallResult(
|
|
api::message::ToolCallResult {
|
|
tool_call_id: tool_call_id.to_string(),
|
|
result: Some(result),
|
|
context: None,
|
|
},
|
|
)),
|
|
request_id: String::new(),
|
|
timestamp: None,
|
|
}
|
|
}
|
|
|
|
fn cleanup_dir(path: &str) {
|
|
let _ = fs::remove_dir_all(path);
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_message_types_produce_sequentially_indexed_files() {
|
|
let task_id = "root";
|
|
let tasks = vec![create_api_task(
|
|
task_id,
|
|
vec![
|
|
make_user_query_message("m1", task_id, "hello"),
|
|
// AgentOutput via create_message helper
|
|
create_message("m2", task_id),
|
|
make_tool_call_message(
|
|
"m3",
|
|
task_id,
|
|
"tc1",
|
|
api::message::tool_call::Tool::Grep(api::message::tool_call::Grep {
|
|
queries: vec!["foo".into()],
|
|
path: "/src".into(),
|
|
}),
|
|
),
|
|
],
|
|
)];
|
|
|
|
let dir = materialize_tasks_to_yaml(&tasks).unwrap();
|
|
assert!(
|
|
Path::new(&dir).starts_with(base_dir()),
|
|
"returned path should be under temp_dir(), got: {dir}",
|
|
);
|
|
// Verify no mixed separators: on Windows the path should use only '\',
|
|
// on Unix only '/'. This catches the original bug where tempdir_in
|
|
// joined a forward-slash parent with a native backslash separator.
|
|
assert!(
|
|
!dir.contains('/') || !dir.contains('\\'),
|
|
"returned path has mixed separators: {dir}",
|
|
);
|
|
let files = list_dir_sorted(Path::new(&dir));
|
|
|
|
assert_eq!(files.len(), 3);
|
|
assert!(files[0].starts_with("000.m1.user_query"));
|
|
assert!(files[1].starts_with("001.m2.agent_output"));
|
|
assert!(files[2].starts_with("002.m3.tool_call.tc1.grep"));
|
|
|
|
// Verify user_query content is searchable.
|
|
let content = fs::read_to_string(Path::new(&dir).join(&files[0])).unwrap();
|
|
assert!(content.contains("type: user_query"));
|
|
assert!(content.contains("hello"));
|
|
|
|
cleanup_dir(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn run_agents_result_serializes_agent_ids() {
|
|
let task_id = "root";
|
|
let tasks = vec![create_api_task(
|
|
task_id,
|
|
vec![make_tool_call_result_message(
|
|
"m1",
|
|
task_id,
|
|
"tc_run_agents",
|
|
api::message::tool_call_result::Result::RunAgentsResult(api::RunAgentsResult {
|
|
outcome: Some(api::run_agents_result::Outcome::Launched(
|
|
api::run_agents_result::Launched {
|
|
resolved_model_id: "auto".to_string(),
|
|
resolved_harness: Some(api::Harness {
|
|
variant: Some(api::harness::Variant::Oz(api::harness::Oz {})),
|
|
}),
|
|
resolved_execution_mode: Some(
|
|
api::run_agents_result::launched::ResolvedExecutionMode::Local(
|
|
api::run_agents::Local {},
|
|
),
|
|
),
|
|
agents: vec![
|
|
api::run_agents_result::AgentOutcome {
|
|
name: "child".to_string(),
|
|
result: Some(
|
|
api::run_agents_result::agent_outcome::Result::Launched(
|
|
api::run_agents_result::LaunchedAgent {
|
|
agent_id: "agent-123".to_string(),
|
|
},
|
|
),
|
|
),
|
|
},
|
|
api::run_agents_result::AgentOutcome {
|
|
name: "other".to_string(),
|
|
result: Some(
|
|
api::run_agents_result::agent_outcome::Result::Failed(
|
|
api::run_agents_result::FailedAgent {
|
|
error: "failed to start".to_string(),
|
|
},
|
|
),
|
|
),
|
|
},
|
|
],
|
|
},
|
|
)),
|
|
}),
|
|
)],
|
|
)];
|
|
|
|
let dir = materialize_tasks_to_yaml(&tasks).unwrap();
|
|
let files = list_dir_sorted(Path::new(&dir));
|
|
let content = fs::read_to_string(Path::new(&dir).join(&files[0])).unwrap();
|
|
|
|
assert!(content.contains("status: launched"));
|
|
assert!(content.contains("agent_count: 2"));
|
|
assert!(content.contains("name: \"child\""));
|
|
assert!(content.contains("agent_id: agent-123"));
|
|
assert!(content.contains("name: \"other\""));
|
|
assert!(content.contains("error: \"failed to start\""));
|
|
assert!(content.contains("Use send_message_to_agent with the existing agent_id"));
|
|
|
|
cleanup_dir(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn subagent_file_and_subdirectory_share_same_index() {
|
|
let root_id = "root";
|
|
let subtask_id = "subtask1";
|
|
|
|
let root_task = create_api_task(
|
|
root_id,
|
|
vec![
|
|
make_user_query_message("m1", root_id, "search my conversation"),
|
|
create_subagent_tool_call_message(
|
|
"m2",
|
|
root_id,
|
|
subtask_id,
|
|
Some(
|
|
api::message::tool_call::subagent::Metadata::ConversationSearch(
|
|
Default::default(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
let subtask = create_api_subtask(
|
|
subtask_id,
|
|
root_id,
|
|
vec![create_message("sub_m1", subtask_id)],
|
|
);
|
|
|
|
let dir = materialize_tasks_to_yaml(&[root_task, subtask]).unwrap();
|
|
let entries = list_dir_sorted(Path::new(&dir));
|
|
|
|
// Should have: 000.m1.user_query.yaml, 001.m2.subagent.*.yaml, 001.subtask1/ (directory)
|
|
assert_eq!(entries.len(), 3);
|
|
|
|
// The subagent YAML file and its subdirectory must share the same "001" prefix.
|
|
let subagent_file = entries
|
|
.iter()
|
|
.find(|e| e.contains("subagent") && e.ends_with(".yaml"))
|
|
.expect("should have subagent yaml file");
|
|
let subdir = entries
|
|
.iter()
|
|
.find(|e| e.contains(subtask_id) && !e.ends_with(".yaml"))
|
|
.expect("should have subtask directory");
|
|
|
|
let file_prefix: String = subagent_file.chars().take(3).collect();
|
|
let dir_prefix: String = subdir.chars().take(3).collect();
|
|
assert_eq!(
|
|
file_prefix, dir_prefix,
|
|
"subagent file ({subagent_file}) and directory ({subdir}) must share the same index prefix"
|
|
);
|
|
assert_eq!(file_prefix, "001");
|
|
|
|
// Verify subtask directory contains the subtask's messages.
|
|
let sub_entries = list_dir_sorted(&Path::new(&dir).join(subdir));
|
|
assert_eq!(sub_entries.len(), 1);
|
|
assert!(sub_entries[0].contains("sub_m1"));
|
|
|
|
cleanup_dir(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_subtask_in_task_map_produces_file_but_no_directory() {
|
|
let root_id = "root";
|
|
|
|
// Subagent references subtask "missing_task" which is not in the task list.
|
|
let root_task = create_api_task(
|
|
root_id,
|
|
vec![create_subagent_tool_call_message(
|
|
"m1",
|
|
root_id,
|
|
"missing_task",
|
|
Some(api::message::tool_call::subagent::Metadata::Cli(
|
|
Default::default(),
|
|
)),
|
|
)],
|
|
);
|
|
|
|
let dir = materialize_tasks_to_yaml(&[root_task]).unwrap();
|
|
let entries = list_dir_sorted(Path::new(&dir));
|
|
|
|
// Should have just the YAML file, no subdirectory since the subtask is missing.
|
|
assert_eq!(entries.len(), 1);
|
|
assert!(entries[0].ends_with(".yaml"));
|
|
assert!(entries[0].contains("subagent"));
|
|
|
|
cleanup_dir(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_task_list_returns_error() {
|
|
let result = materialize_tasks_to_yaml(&[]);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("No root task found"));
|
|
}
|
|
|
|
#[test]
|
|
fn tool_call_result_resolves_tool_name_from_matching_call() {
|
|
let task_id = "root";
|
|
let tasks = vec![create_api_task(
|
|
task_id,
|
|
vec![
|
|
make_tool_call_message(
|
|
"m1",
|
|
task_id,
|
|
"tc1",
|
|
api::message::tool_call::Tool::Grep(api::message::tool_call::Grep {
|
|
queries: vec!["pattern".into()],
|
|
path: "/src".into(),
|
|
}),
|
|
),
|
|
make_tool_call_result_message(
|
|
"m2",
|
|
task_id,
|
|
"tc1",
|
|
api::message::tool_call_result::Result::Grep(api::GrepResult {
|
|
result: Some(api::grep_result::Result::Success(
|
|
api::grep_result::Success {
|
|
matched_files: vec![api::grep_result::success::GrepFileMatch {
|
|
file_path: "foo.rs".into(),
|
|
matched_lines: vec![
|
|
api::grep_result::success::grep_file_match::GrepLineMatch {
|
|
line_number: 42,
|
|
},
|
|
],
|
|
}],
|
|
},
|
|
)),
|
|
}),
|
|
),
|
|
],
|
|
)];
|
|
|
|
let dir = materialize_tasks_to_yaml(&tasks).unwrap();
|
|
let files = list_dir_sorted(Path::new(&dir));
|
|
|
|
assert_eq!(files.len(), 2);
|
|
// The result file should contain "grep" in its name, resolved from the tool call.
|
|
assert!(
|
|
files[1].contains("grep"),
|
|
"result filename should contain tool name 'grep', got: {}",
|
|
files[1]
|
|
);
|
|
|
|
// Verify line numbers are serialized.
|
|
let content = fs::read_to_string(Path::new(&dir).join(&files[1])).unwrap();
|
|
assert!(content.contains("foo.rs"), "should contain file path");
|
|
assert!(content.contains("42"), "should contain line number");
|
|
|
|
cleanup_dir(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn server_tool_calls_are_skipped() {
|
|
let task_id = "root";
|
|
let tasks = vec![create_api_task(
|
|
task_id,
|
|
vec![
|
|
make_user_query_message("m1", task_id, "hello"),
|
|
make_tool_call_message(
|
|
"m2",
|
|
task_id,
|
|
"tc_server",
|
|
api::message::tool_call::Tool::Server(api::message::tool_call::Server {
|
|
payload: String::new(),
|
|
}),
|
|
),
|
|
create_message("m3", task_id),
|
|
],
|
|
)];
|
|
|
|
let dir = materialize_tasks_to_yaml(&tasks).unwrap();
|
|
let files = list_dir_sorted(Path::new(&dir));
|
|
|
|
// Server tool call should be skipped; only user_query and agent_output.
|
|
assert_eq!(files.len(), 2);
|
|
assert!(files[0].contains("user_query"));
|
|
assert!(files[1].contains("agent_output"));
|
|
// Index should still be sequential (000, 001) since server call was skipped.
|
|
assert!(files[0].starts_with("000"));
|
|
assert!(files[1].starts_with("001"));
|
|
|
|
cleanup_dir(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn start_agent_v2_tool_call_serializes_name_and_prompt() {
|
|
let task_id = "root";
|
|
let tasks = vec![create_api_task(
|
|
task_id,
|
|
vec![make_tool_call_message(
|
|
"m1",
|
|
task_id,
|
|
"tc_start_agent_v2",
|
|
api::message::tool_call::Tool::StartAgentV2(api::StartAgentV2 {
|
|
name: "Remote child".to_string(),
|
|
prompt: "Investigate the build failure".to_string(),
|
|
execution_mode: None,
|
|
lifecycle_subscription: None,
|
|
}),
|
|
)],
|
|
)];
|
|
|
|
let dir = materialize_tasks_to_yaml(&tasks).unwrap();
|
|
let files = list_dir_sorted(Path::new(&dir));
|
|
let content = fs::read_to_string(Path::new(&dir).join(&files[0])).unwrap();
|
|
|
|
assert!(content.contains("tool_name: start_agent"));
|
|
assert!(content.contains("name: \"Remote child\""));
|
|
assert!(content.contains("prompt: |"));
|
|
assert!(content.contains("Investigate the build failure"));
|
|
|
|
cleanup_dir(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn start_agent_v2_tool_call_result_serializes_agent_id_and_error() {
|
|
let task_id = "root";
|
|
let tasks = vec![create_api_task(
|
|
task_id,
|
|
vec![
|
|
make_tool_call_message(
|
|
"m1",
|
|
task_id,
|
|
"tc_start_agent_v2",
|
|
api::message::tool_call::Tool::StartAgentV2(api::StartAgentV2 {
|
|
name: "Remote child".to_string(),
|
|
prompt: "Investigate the build failure".to_string(),
|
|
execution_mode: None,
|
|
lifecycle_subscription: None,
|
|
}),
|
|
),
|
|
make_tool_call_result_message(
|
|
"m2",
|
|
task_id,
|
|
"tc_start_agent_v2",
|
|
api::message::tool_call_result::Result::StartAgentV2(api::StartAgentV2Result {
|
|
result: Some(api::start_agent_v2_result::Result::Success(
|
|
api::start_agent_v2_result::Success {
|
|
agent_id: "agent-123".to_string(),
|
|
},
|
|
)),
|
|
}),
|
|
),
|
|
make_tool_call_result_message(
|
|
"m3",
|
|
task_id,
|
|
"tc_start_agent_v2",
|
|
api::message::tool_call_result::Result::StartAgentV2(api::StartAgentV2Result {
|
|
result: Some(api::start_agent_v2_result::Result::Error(
|
|
api::start_agent_v2_result::Error {
|
|
error: "child failed".to_string(),
|
|
},
|
|
)),
|
|
}),
|
|
),
|
|
],
|
|
)];
|
|
|
|
let dir = materialize_tasks_to_yaml(&tasks).unwrap();
|
|
let files = list_dir_sorted(Path::new(&dir));
|
|
let success_content = fs::read_to_string(Path::new(&dir).join(&files[1])).unwrap();
|
|
let error_content = fs::read_to_string(Path::new(&dir).join(&files[2])).unwrap();
|
|
|
|
assert!(success_content.contains("agent_id: agent-123"));
|
|
assert!(error_content.contains("error: child failed"));
|
|
|
|
cleanup_dir(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn upload_file_artifact_tool_call_result_serializes_only_supported_success_fields() {
|
|
let task_id = "root";
|
|
let tasks = vec![create_api_task(
|
|
task_id,
|
|
vec![
|
|
make_tool_call_message(
|
|
"m1",
|
|
task_id,
|
|
"tc_upload_file_artifact",
|
|
api::message::tool_call::Tool::UploadFileArtifact(api::UploadFileArtifact {
|
|
file: Some(api::FilePathReference {
|
|
file_path: "outputs/report.txt".to_string(),
|
|
}),
|
|
description: "Daily summary".to_string(),
|
|
}),
|
|
),
|
|
make_tool_call_result_message(
|
|
"m2",
|
|
task_id,
|
|
"tc_upload_file_artifact",
|
|
api::message::tool_call_result::Result::UploadFileArtifact(
|
|
api::UploadFileArtifactResult {
|
|
result: Some(api::upload_file_artifact_result::Result::Success(
|
|
api::upload_file_artifact_result::Success {
|
|
artifact_uid: "artifact-123".to_string(),
|
|
mime_type: "text/plain".to_string(),
|
|
size_bytes: 42,
|
|
},
|
|
)),
|
|
},
|
|
),
|
|
),
|
|
],
|
|
)];
|
|
|
|
let dir = materialize_tasks_to_yaml(&tasks).unwrap();
|
|
let files = list_dir_sorted(Path::new(&dir));
|
|
let success_content = fs::read_to_string(Path::new(&dir).join(&files[1])).unwrap();
|
|
|
|
assert!(success_content.contains("artifact_uid: artifact-123"));
|
|
assert!(success_content.contains("mime_type: text/plain"));
|
|
assert!(success_content.contains("size_bytes: 42"));
|
|
assert!(!success_content.contains("filepath:"));
|
|
assert!(!success_content.contains("description:"));
|
|
|
|
cleanup_dir(&dir);
|
|
}
|