96ded37e15
Co-authored-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Michael Neale <michael.neale@gmail.com>
535 lines
19 KiB
Rust
535 lines
19 KiB
Rust
use goose::agents::recipe_tools::dynamic_task_tools::{
|
|
create_dynamic_task, task_params_to_inline_recipe,
|
|
};
|
|
use serde_json::json;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// Helper function to create a list of loaded extensions for testing
|
|
fn test_loaded_extensions() -> Vec<String> {
|
|
vec!["developer".to_string(), "memory".to_string()]
|
|
}
|
|
|
|
#[test]
|
|
fn test_minimal_task_with_instructions() {
|
|
let params = json!({
|
|
"instructions": "Test task"
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
|
assert_eq!(recipe.instructions, Some("Test task".to_string()));
|
|
assert_eq!(recipe.title, "Dynamic Task");
|
|
assert_eq!(recipe.description, "Inline recipe task");
|
|
}
|
|
|
|
#[test]
|
|
fn test_minimal_task_with_prompt() {
|
|
let params = json!({
|
|
"prompt": "Test prompt"
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
|
assert_eq!(recipe.prompt, Some("Test prompt".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_missing_required_fields() {
|
|
let params = json!({
|
|
"title": "Test"
|
|
});
|
|
|
|
let result = task_params_to_inline_recipe(¶ms, &test_loaded_extensions());
|
|
assert!(result.is_err());
|
|
assert!(result
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("instructions' or 'prompt"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_with_recipe_fields() {
|
|
let params = json!({
|
|
"instructions": "Test",
|
|
"title": "Custom Title",
|
|
"description": "Custom Description",
|
|
"retry": {
|
|
"max_retries": 3,
|
|
"checks": [
|
|
{
|
|
"type": "shell",
|
|
"command": "echo test"
|
|
}
|
|
]
|
|
},
|
|
"response": {
|
|
"json_schema": {
|
|
"type": "object"
|
|
}
|
|
}
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
|
assert_eq!(recipe.title, "Custom Title");
|
|
assert_eq!(recipe.description, "Custom Description");
|
|
assert!(recipe.retry.is_some());
|
|
assert!(recipe.response.is_some());
|
|
|
|
// Verify retry config details
|
|
let retry = recipe.retry.unwrap();
|
|
assert_eq!(retry.max_retries, 3);
|
|
assert_eq!(retry.checks.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_security_validation() {
|
|
let params = json!({
|
|
"instructions": format!("Test{}", '\u{E0041}') // Harmful Unicode tag
|
|
});
|
|
|
|
let result = task_params_to_inline_recipe(¶ms, &test_loaded_extensions());
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("harmful"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_create_multiple_tasks() {
|
|
use goose::agents::subagent_execution_tool::tasks_manager::TasksManager;
|
|
|
|
let tasks_manager = TasksManager::new();
|
|
let params = json!({
|
|
"task_parameters": [
|
|
{"instructions": "Task 1"},
|
|
{"prompt": "Task 2"}
|
|
]
|
|
});
|
|
|
|
let result = create_dynamic_task(params, &tasks_manager, test_loaded_extensions()).await;
|
|
|
|
// Check that the result is successful by awaiting the future
|
|
let tool_result = result.result.await;
|
|
assert!(tool_result.is_ok());
|
|
let contents = tool_result.unwrap();
|
|
assert!(!contents.is_empty());
|
|
|
|
// Parse the returned JSON to verify task creation
|
|
if let Some(text_content) = contents.first().and_then(|c| c.as_text()) {
|
|
let task_payload: serde_json::Value = serde_json::from_str(&text_content.text).unwrap();
|
|
assert!(task_payload.get("task_ids").is_some());
|
|
let task_ids = task_payload.get("task_ids").unwrap().as_array().unwrap();
|
|
assert_eq!(task_ids.len(), 2);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_return_last_only_flag() {
|
|
let params_with_flag = json!({
|
|
"instructions": "Test task",
|
|
"return_last_only": true
|
|
});
|
|
|
|
let recipe =
|
|
task_params_to_inline_recipe(¶ms_with_flag, &test_loaded_extensions()).unwrap();
|
|
assert_eq!(recipe.instructions, Some("Test task".to_string()));
|
|
|
|
// The flag should not affect the recipe itself, only the task payload
|
|
// We can't test the task creation here without async context
|
|
|
|
let params_without_flag = json!({
|
|
"instructions": "Test task"
|
|
});
|
|
|
|
let recipe2 =
|
|
task_params_to_inline_recipe(¶ms_without_flag, &test_loaded_extensions()).unwrap();
|
|
assert_eq!(recipe2.instructions, Some("Test task".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_text_instruction_not_supported() {
|
|
use goose::agents::subagent_execution_tool::tasks_manager::TasksManager;
|
|
|
|
let tasks_manager = TasksManager::new();
|
|
let params = json!({
|
|
"task_parameters": [
|
|
{"text_instruction": "Legacy task"}
|
|
]
|
|
});
|
|
|
|
let result = create_dynamic_task(params, &tasks_manager, test_loaded_extensions()).await;
|
|
|
|
// Check that the result fails since text_instruction is no longer supported
|
|
let tool_result = result.result.await;
|
|
assert!(tool_result.is_err());
|
|
|
|
// Verify the error message indicates missing required fields
|
|
if let Err(err) = tool_result {
|
|
let error_msg = err.message.to_string();
|
|
assert!(error_msg.contains("instructions") || error_msg.contains("prompt"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_with_extensions() {
|
|
let params = json!({
|
|
"instructions": "Test",
|
|
"extensions": [
|
|
{
|
|
"type": "builtin",
|
|
"name": "developer",
|
|
"description": "developer"
|
|
}
|
|
]
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
|
assert!(recipe.extensions.is_some());
|
|
let extensions = recipe.extensions.unwrap();
|
|
assert_eq!(extensions.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_with_context_and_activities() {
|
|
let params = json!({
|
|
"instructions": "Test",
|
|
"context": ["context1", "context2"],
|
|
"activities": ["activity1", "activity2"]
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
|
assert!(recipe.context.is_some());
|
|
assert!(recipe.activities.is_some());
|
|
assert_eq!(recipe.context.unwrap(), vec!["context1", "context2"]);
|
|
assert_eq!(recipe.activities.unwrap(), vec!["activity1", "activity2"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_retry_config() {
|
|
// Test with max_retries = 0 (invalid)
|
|
let params = json!({
|
|
"instructions": "Test",
|
|
"retry": {
|
|
"max_retries": 0, // Invalid: must be > 0
|
|
"checks": []
|
|
}
|
|
});
|
|
|
|
let result = task_params_to_inline_recipe(¶ms, &test_loaded_extensions());
|
|
assert!(result.is_err());
|
|
assert!(result
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("Invalid retry config"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_retry_config_missing_checks() {
|
|
// Test with missing required field 'checks'
|
|
let params = json!({
|
|
"instructions": "Test",
|
|
"retry": {
|
|
"max_retries": 3
|
|
// Missing 'checks' field
|
|
}
|
|
});
|
|
|
|
let result = task_params_to_inline_recipe(¶ms, &test_loaded_extensions());
|
|
// This should fail during deserialization since 'checks' is required
|
|
assert!(result.is_ok()); // But retry field will be None due to failed deserialization
|
|
let recipe = result.unwrap();
|
|
assert!(recipe.retry.is_none());
|
|
}
|
|
|
|
// Additional edge case tests
|
|
|
|
#[test]
|
|
fn test_both_instructions_and_prompt() {
|
|
// Test that both instructions and prompt can be provided
|
|
let params = json!({
|
|
"instructions": "Test instructions",
|
|
"prompt": "Test prompt"
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
|
assert_eq!(recipe.instructions, Some("Test instructions".to_string()));
|
|
assert_eq!(recipe.prompt, Some("Test prompt".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_task_parameters_array() {
|
|
// This test is for the create_dynamic_task function
|
|
// We can't test it here without async, but we document the expected behavior
|
|
// Empty task_parameters array should return an error
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_json_in_optional_fields() {
|
|
// Test that invalid JSON in optional fields is gracefully ignored
|
|
let params = json!({
|
|
"instructions": "Test",
|
|
"settings": "not an object", // Invalid: should be object
|
|
"extensions": "not an array", // Invalid: should be array
|
|
"context": {"not": "an array"}, // Invalid: should be array
|
|
"activities": 123 // Invalid: should be array
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
|
assert_eq!(recipe.instructions, Some("Test".to_string()));
|
|
// Invalid fields should be ignored (None)
|
|
assert!(recipe.settings.is_none());
|
|
assert!(recipe.extensions.is_none());
|
|
assert!(recipe.context.is_none());
|
|
assert!(recipe.activities.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_with_settings() {
|
|
let params = json!({
|
|
"instructions": "Test",
|
|
"settings": {
|
|
"goose_provider": "openai",
|
|
"goose_model": "gpt-4",
|
|
"temperature": 0.7
|
|
}
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
|
assert!(recipe.settings.is_some());
|
|
let settings = recipe.settings.unwrap();
|
|
assert_eq!(settings.goose_provider, Some("openai".to_string()));
|
|
assert_eq!(settings.goose_model, Some("gpt-4".to_string()));
|
|
assert_eq!(settings.temperature, Some(0.7));
|
|
}
|
|
|
|
#[test]
|
|
fn test_with_parameters() {
|
|
let params = json!({
|
|
"instructions": "Test",
|
|
"parameters": [
|
|
{
|
|
"key": "test_param",
|
|
"input_type": "string",
|
|
"requirement": "required",
|
|
"description": "A test parameter"
|
|
}
|
|
]
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
|
assert!(recipe.parameters.is_some());
|
|
let parameters = recipe.parameters.unwrap();
|
|
assert_eq!(parameters.len(), 1);
|
|
assert_eq!(parameters[0].key, "test_param");
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_strings_for_required_fields() {
|
|
// Empty strings should be valid for instructions/prompt
|
|
let params = json!({
|
|
"instructions": ""
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
|
assert_eq!(recipe.instructions, Some("".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_very_long_instruction() {
|
|
// Test with a very long instruction string
|
|
let long_instruction = "a".repeat(10000);
|
|
let params = json!({
|
|
"instructions": long_instruction.clone()
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
|
assert_eq!(recipe.instructions, Some(long_instruction));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mixed_valid_and_invalid_tasks() {
|
|
use goose::agents::subagent_execution_tool::tasks_manager::TasksManager;
|
|
|
|
let tasks_manager = TasksManager::new();
|
|
let params = json!({
|
|
"task_parameters": [
|
|
{"instructions": "Valid task"},
|
|
{"title": "Invalid - missing instruction"}, // This should cause error
|
|
]
|
|
});
|
|
|
|
let result = create_dynamic_task(params, &tasks_manager, test_loaded_extensions()).await;
|
|
|
|
// Should fail on the invalid task
|
|
let tool_result = result.result.await;
|
|
assert!(tool_result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_unicode_in_non_instruction_fields() {
|
|
// Unicode tags should be allowed in non-instruction fields
|
|
let params = json!({
|
|
"instructions": "Test",
|
|
"title": format!("Title with unicode {}", '\u{E0041}'),
|
|
"description": format!("Description with unicode {}", '\u{E0041}')
|
|
});
|
|
|
|
// This should succeed - only instructions/prompt/activities are checked for security
|
|
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
|
assert!(recipe.title.contains('\u{E0041}'));
|
|
assert!(recipe.description.contains('\u{E0041}'));
|
|
}
|
|
|
|
#[test]
|
|
fn test_extension_shortnames() {
|
|
// Test that extension shortnames are properly resolved
|
|
// Note: This test now depends on actual config, so it may not find all extensions
|
|
// if they're not configured in the test environment
|
|
let loaded_exts = vec!["developer".to_string(), "memory".to_string()];
|
|
let params = json!({
|
|
"instructions": "Test",
|
|
"extensions": ["developer", "memory"]
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &loaded_exts).unwrap();
|
|
assert!(recipe.extensions.is_some());
|
|
let extensions = recipe.extensions.unwrap();
|
|
// We can't guarantee both extensions exist in config during tests
|
|
// Just check that we got some extensions and they have the right structure
|
|
assert!(extensions.len() <= 2);
|
|
if !extensions.is_empty() {
|
|
// Check that the first one is a valid ExtensionConfig
|
|
assert!(matches!(
|
|
&extensions[0],
|
|
goose::agents::extension::ExtensionConfig::Builtin { .. }
|
|
| goose::agents::extension::ExtensionConfig::Stdio { .. }
|
|
| goose::agents::extension::ExtensionConfig::Sse { .. }
|
|
| goose::agents::extension::ExtensionConfig::StreamableHttp { .. }
|
|
| goose::agents::extension::ExtensionConfig::Frontend { .. }
|
|
| goose::agents::extension::ExtensionConfig::InlinePython { .. }
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_mixed_extension_formats() {
|
|
// Test mixing shortnames and full configs
|
|
// Note: Shortnames depend on config being present, which may not exist in CI
|
|
let loaded_exts = vec!["developer".to_string(), "memory".to_string()];
|
|
let params = json!({
|
|
"instructions": "Test",
|
|
"extensions": [
|
|
"developer", // Shortname - may not resolve in CI
|
|
{
|
|
"type": "stdio",
|
|
"name": "custom",
|
|
"description": "Custom stdio",
|
|
"cmd": "echo",
|
|
"args": ["test"]
|
|
}
|
|
]
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &loaded_exts).unwrap();
|
|
assert!(recipe.extensions.is_some());
|
|
let extensions = recipe.extensions.unwrap();
|
|
// At minimum we should get the full config (stdio), shortname may not resolve
|
|
assert!(!extensions.is_empty() && extensions.len() <= 2);
|
|
// The last one should always be the stdio config we provided
|
|
if let Some(last) = extensions.last() {
|
|
match last {
|
|
goose::agents::extension::ExtensionConfig::Stdio { name, .. } => {
|
|
assert_eq!(name, "custom");
|
|
}
|
|
_ => {
|
|
// If we got 2 extensions, the second should be stdio
|
|
if extensions.len() == 2 {
|
|
panic!("Expected stdio extension config for 'custom'");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_unknown_extension_shortname() {
|
|
// Test that unknown extension shortnames are skipped while valid configs are kept
|
|
let loaded_exts = vec!["developer".to_string()];
|
|
let params = json!({
|
|
"instructions": "Test",
|
|
"extensions": [
|
|
"unknown_extension_1", // Full config should always work
|
|
{
|
|
"type": "builtin",
|
|
"name": "test_builtin",
|
|
"display_name": "Test Builtin",
|
|
"description": "Test extension"
|
|
},
|
|
"unknown_extension_2" // Should be skipped
|
|
]
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &loaded_exts).unwrap();
|
|
assert!(recipe.extensions.is_some());
|
|
let extensions = recipe.extensions.unwrap();
|
|
// Should only get the full config, unknown shortnames should be skipped
|
|
assert_eq!(extensions.len(), 1);
|
|
// Verify it's the builtin we provided
|
|
match &extensions[0] {
|
|
goose::agents::extension::ExtensionConfig::Builtin { name, .. } => {
|
|
assert_eq!(name, "test_builtin");
|
|
}
|
|
_ => panic!("Expected builtin extension config"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_extensions_array() {
|
|
// Test that an empty extensions array results in no extensions
|
|
let loaded_exts = vec!["developer".to_string(), "memory".to_string()];
|
|
let params = json!({
|
|
"instructions": "Test",
|
|
"extensions": []
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &loaded_exts).unwrap();
|
|
assert!(recipe.extensions.is_some());
|
|
let extensions = recipe.extensions.unwrap();
|
|
// Empty array should mean no extensions
|
|
assert_eq!(extensions.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_omitted_extensions_field() {
|
|
// Test that omitting the extensions field results in None (use all)
|
|
let loaded_exts = vec!["developer".to_string(), "memory".to_string()];
|
|
let params = json!({
|
|
"instructions": "Test"
|
|
// No extensions field
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &loaded_exts).unwrap();
|
|
// When extensions field is omitted, recipe.extensions should be None
|
|
assert!(recipe.extensions.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_null_values_in_optional_fields() {
|
|
// Test that null values in optional fields are handled gracefully
|
|
let params = json!({
|
|
"instructions": "Test",
|
|
"title": null,
|
|
"description": null,
|
|
"extensions": null,
|
|
"settings": null
|
|
});
|
|
|
|
let recipe = task_params_to_inline_recipe(¶ms, &test_loaded_extensions()).unwrap();
|
|
assert_eq!(recipe.instructions, Some("Test".to_string()));
|
|
// Null values should use defaults or be None
|
|
assert_eq!(recipe.title, "Dynamic Task"); // Should use default
|
|
assert_eq!(recipe.description, "Inline recipe task"); // Should use default
|
|
assert!(recipe.extensions.is_none());
|
|
assert!(recipe.settings.is_none());
|
|
}
|
|
}
|