Platform Tool for Scheduler: Allow Goose to Manage Its Own Schedule (#2944)

This commit is contained in:
tlongwell-block
2025-06-19 10:04:31 -04:00
committed by GitHub
parent 46a8b9218a
commit f0b627c96a
10 changed files with 2023 additions and 5 deletions
+187
View File
@@ -330,3 +330,190 @@ mod tests {
.await
}
}
#[cfg(test)]
mod schedule_tool_tests {
use super::*;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use goose::agents::platform_tools::PLATFORM_MANAGE_SCHEDULE_TOOL_NAME;
use goose::scheduler::{ScheduledJob, SchedulerError};
use goose::scheduler_trait::SchedulerTrait;
use goose::session::storage::SessionMetadata;
use std::sync::Arc;
// Mock scheduler for testing
struct MockScheduler {
jobs: tokio::sync::Mutex<Vec<ScheduledJob>>,
}
impl MockScheduler {
fn new() -> Self {
Self {
jobs: tokio::sync::Mutex::new(Vec::new()),
}
}
}
#[async_trait]
impl SchedulerTrait for MockScheduler {
async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError> {
let mut jobs = self.jobs.lock().await;
jobs.push(job);
Ok(())
}
async fn list_scheduled_jobs(&self) -> Result<Vec<ScheduledJob>, SchedulerError> {
let jobs = self.jobs.lock().await;
Ok(jobs.clone())
}
async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> {
let mut jobs = self.jobs.lock().await;
if let Some(pos) = jobs.iter().position(|job| job.id == id) {
jobs.remove(pos);
Ok(())
} else {
Err(SchedulerError::JobNotFound(id.to_string()))
}
}
async fn pause_schedule(&self, _id: &str) -> Result<(), SchedulerError> {
Ok(())
}
async fn unpause_schedule(&self, _id: &str) -> Result<(), SchedulerError> {
Ok(())
}
async fn run_now(&self, _id: &str) -> Result<String, SchedulerError> {
Ok("test_session_123".to_string())
}
async fn sessions(
&self,
_sched_id: &str,
_limit: usize,
) -> Result<Vec<(String, SessionMetadata)>, SchedulerError> {
Ok(vec![])
}
async fn update_schedule(
&self,
_sched_id: &str,
_new_cron: String,
) -> Result<(), SchedulerError> {
Ok(())
}
async fn kill_running_job(&self, _sched_id: &str) -> Result<(), SchedulerError> {
Ok(())
}
async fn get_running_job_info(
&self,
_sched_id: &str,
) -> Result<Option<(String, DateTime<Utc>)>, SchedulerError> {
Ok(None)
}
}
#[tokio::test]
async fn test_schedule_management_tool_list() {
let agent = Agent::new();
let mock_scheduler = Arc::new(MockScheduler::new());
agent.set_scheduler(mock_scheduler.clone()).await;
// Test that the schedule management tool is available in the tools list
let tools = agent.list_tools(None).await;
let schedule_tool = tools
.iter()
.find(|tool| tool.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME);
assert!(schedule_tool.is_some());
let tool = schedule_tool.unwrap();
assert!(tool
.description
.contains("Manage scheduled recipe execution"));
}
#[tokio::test]
async fn test_schedule_management_tool_no_scheduler() {
let agent = Agent::new();
// Don't set scheduler - test that the tool still appears in the list
// but would fail if actually called (which we can't test directly through public API)
let tools = agent.list_tools(None).await;
let schedule_tool = tools
.iter()
.find(|tool| tool.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME);
assert!(schedule_tool.is_some());
}
#[tokio::test]
async fn test_schedule_management_tool_in_platform_tools() {
let agent = Agent::new();
let tools = agent.list_tools(Some("platform".to_string())).await;
// Check that the schedule management tool is included in platform tools
let schedule_tool = tools
.iter()
.find(|tool| tool.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME);
assert!(schedule_tool.is_some());
let tool = schedule_tool.unwrap();
assert!(tool
.description
.contains("Manage scheduled recipe execution"));
// Verify the tool has the expected actions in its schema
if let Some(properties) = tool.input_schema.get("properties") {
if let Some(action_prop) = properties.get("action") {
if let Some(enum_values) = action_prop.get("enum") {
let actions: Vec<String> = enum_values
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
// Check that our session_content action is included
assert!(actions.contains(&"session_content".to_string()));
assert!(actions.contains(&"list".to_string()));
assert!(actions.contains(&"create".to_string()));
assert!(actions.contains(&"sessions".to_string()));
}
}
}
}
#[tokio::test]
async fn test_schedule_management_tool_schema_validation() {
let agent = Agent::new();
let tools = agent.list_tools(None).await;
let schedule_tool = tools
.iter()
.find(|tool| tool.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME);
assert!(schedule_tool.is_some());
let tool = schedule_tool.unwrap();
// Verify the tool schema has the session_id parameter for session_content action
if let Some(properties) = tool.input_schema.get("properties") {
assert!(properties.get("session_id").is_some());
if let Some(session_id_prop) = properties.get("session_id") {
assert_eq!(
session_id_prop.get("type").unwrap().as_str().unwrap(),
"string"
);
assert!(session_id_prop
.get("description")
.unwrap()
.as_str()
.unwrap()
.contains("Session identifier for session_content action"));
}
}
}
}
+901
View File
@@ -0,0 +1,901 @@
#![cfg(test)]
use mcp_core::{Content, ToolError};
use serde_json::json;
use goose::agents::platform_tools::PLATFORM_MANAGE_SCHEDULE_TOOL_NAME;
mod test_support;
use test_support::{
create_temp_recipe, create_test_session_metadata, MockBehavior, ScheduleToolTestBuilder,
};
// Test all actions of the scheduler platform tool
#[tokio::test]
async fn test_schedule_tool_list_action() {
// Create a test builder with existing jobs
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.with_existing_job("job2", "0 0 * * * *")
.await
.build()
.await;
// Test list action
let arguments = json!({
"action": "list"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Content::Text(text_content) = &content[0] {
assert!(text_content.text.contains("Scheduled Jobs:"));
assert!(text_content.text.contains("job1"));
assert!(text_content.text.contains("job2"));
} else {
panic!("Expected text content");
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"list_scheduled_jobs".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_list_action_empty() {
// Create a test builder with no jobs
let (agent, scheduler) = ScheduleToolTestBuilder::new().build().await;
// Test list action
let arguments = json!({
"action": "list"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Content::Text(text_content) = &content[0] {
assert!(text_content.text.contains("Scheduled Jobs:"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"list_scheduled_jobs".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_list_action_error() {
// Create a test builder with a list error
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_scheduler_behavior(
"list_scheduled_jobs",
MockBehavior::InternalError("Database error".to_string()),
)
.await
.build()
.await;
// Test list action
let arguments = json!({
"action": "list"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(ToolError::ExecutionError(msg)) = result {
assert!(msg.contains("Failed to list jobs"));
assert!(msg.contains("Database error"));
} else {
panic!("Expected ExecutionError");
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"list_scheduled_jobs".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_create_action() {
let (agent, scheduler) = ScheduleToolTestBuilder::new().build().await;
// Create a temporary recipe file
let temp_recipe = create_temp_recipe(true, "json");
// Test create action
let arguments = json!({
"action": "create",
"recipe_path": temp_recipe.path.to_str().unwrap(),
"cron_expression": "*/5 * * * * *"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Content::Text(text_content) = &content[0] {
assert!(text_content
.text
.contains("Successfully created scheduled job"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"add_scheduled_job".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_create_action_missing_params() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Test create action with missing recipe_path
let arguments = json!({
"action": "create",
"cron_expression": "*/5 * * * * *"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(ToolError::ExecutionError(msg)) = result {
assert!(msg.contains("Missing 'recipe_path' parameter"));
} else {
panic!("Expected ExecutionError");
}
// Test create action with missing cron_expression
let temp_recipe = create_temp_recipe(true, "json");
let arguments = json!({
"action": "create",
"recipe_path": temp_recipe.path.to_str().unwrap()
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(ToolError::ExecutionError(msg)) = result {
assert!(msg.contains("Missing 'cron_expression' parameter"));
} else {
panic!("Expected ExecutionError");
}
}
#[tokio::test]
async fn test_schedule_tool_create_action_nonexistent_recipe() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Test create action with nonexistent recipe
let arguments = json!({
"action": "create",
"recipe_path": "/nonexistent/recipe.json",
"cron_expression": "*/5 * * * * *"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(ToolError::ExecutionError(msg)) = result {
assert!(msg.contains("Recipe file not found"));
} else {
panic!("Expected ExecutionError");
}
}
#[tokio::test]
async fn test_schedule_tool_create_action_invalid_recipe() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Create an invalid recipe file
let temp_recipe = create_temp_recipe(false, "json");
// Test create action with invalid recipe
let arguments = json!({
"action": "create",
"recipe_path": temp_recipe.path.to_str().unwrap(),
"cron_expression": "*/5 * * * * *"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(ToolError::ExecutionError(msg)) = result {
assert!(msg.contains("Invalid JSON recipe"));
} else {
panic!("Expected ExecutionError");
}
}
#[tokio::test]
async fn test_schedule_tool_create_action_scheduler_error() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_scheduler_behavior(
"add_scheduled_job",
MockBehavior::AlreadyExists("job1".to_string()),
)
.await
.build()
.await;
// Create a temporary recipe file
let temp_recipe = create_temp_recipe(true, "json");
// Test create action
let arguments = json!({
"action": "create",
"recipe_path": temp_recipe.path.to_str().unwrap(),
"cron_expression": "*/5 * * * * *"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(ToolError::ExecutionError(msg)) = result {
assert!(msg.contains("Failed to create job"));
assert!(msg.contains("job1"));
} else {
panic!("Expected ExecutionError");
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"add_scheduled_job".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_run_now_action() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test run_now action
let arguments = json!({
"action": "run_now",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Content::Text(text_content) = &content[0] {
assert!(text_content
.text
.contains("Successfully started job 'job1'"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"run_now".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_run_now_action_missing_job_id() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Test run_now action with missing job_id
let arguments = json!({
"action": "run_now"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(ToolError::ExecutionError(msg)) = result {
assert!(msg.contains("Missing 'job_id' parameter"));
} else {
panic!("Expected ExecutionError");
}
}
#[tokio::test]
async fn test_schedule_tool_run_now_action_nonexistent_job() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_scheduler_behavior("run_now", MockBehavior::NotFound("nonexistent".to_string()))
.await
.build()
.await;
// Test run_now action with nonexistent job
let arguments = json!({
"action": "run_now",
"job_id": "nonexistent"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(ToolError::ExecutionError(msg)) = result {
assert!(msg.contains("Failed to run job"));
assert!(msg.contains("nonexistent"));
} else {
panic!("Expected ExecutionError");
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"run_now".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_pause_action() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test pause action
let arguments = json!({
"action": "pause",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Content::Text(text_content) = &content[0] {
assert!(text_content.text.contains("Successfully paused job 'job1'"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"pause_schedule".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_pause_action_missing_job_id() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Test pause action with missing job_id
let arguments = json!({
"action": "pause"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(ToolError::ExecutionError(msg)) = result {
assert!(msg.contains("Missing 'job_id' parameter"));
} else {
panic!("Expected ExecutionError");
}
}
#[tokio::test]
async fn test_schedule_tool_pause_action_running_job() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_scheduler_behavior(
"pause_schedule",
MockBehavior::JobCurrentlyRunning("job1".to_string()),
)
.await
.build()
.await;
// Test pause action with a running job
let arguments = json!({
"action": "pause",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(ToolError::ExecutionError(msg)) = result {
assert!(msg.contains("Failed to pause job"));
assert!(msg.contains("job1"));
} else {
panic!("Expected ExecutionError");
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"pause_schedule".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_unpause_action() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test unpause action
let arguments = json!({
"action": "unpause",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Content::Text(text_content) = &content[0] {
assert!(text_content
.text
.contains("Successfully unpaused job 'job1'"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"unpause_schedule".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_delete_action() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test delete action
let arguments = json!({
"action": "delete",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Content::Text(text_content) = &content[0] {
assert!(text_content
.text
.contains("Successfully deleted job 'job1'"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"remove_scheduled_job".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_kill_action() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.with_running_job("job1")
.await
.build()
.await;
// Test kill action
let arguments = json!({
"action": "kill",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Content::Text(text_content) = &content[0] {
assert!(text_content
.text
.contains("Successfully killed running job 'job1'"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"kill_running_job".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_kill_action_not_running() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test kill action with a job that's not running
let arguments = json!({
"action": "kill",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(ToolError::ExecutionError(msg)) = result {
assert!(msg.contains("Failed to kill job"));
} else {
panic!("Expected ExecutionError");
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"kill_running_job".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_inspect_action_running() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.with_running_job("job1")
.await
.build()
.await;
// Test inspect action
let arguments = json!({
"action": "inspect",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Content::Text(text_content) = &content[0] {
assert!(text_content
.text
.contains("Job 'job1' is currently running"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"get_running_job_info".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_inspect_action_not_running() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test inspect action with a job that's not running
let arguments = json!({
"action": "inspect",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Content::Text(text_content) = &content[0] {
assert!(text_content
.text
.contains("Job 'job1' is not currently running"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"get_running_job_info".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_sessions_action() {
// Create test session metadata
let sessions = vec![
(
"1234567890_session1".to_string(),
create_test_session_metadata(5, "/tmp"),
),
(
"0987654321_session2".to_string(),
create_test_session_metadata(10, "/home"),
),
];
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.with_sessions_data("job1", sessions)
.await
.build()
.await;
// Test sessions action
let arguments = json!({
"action": "sessions",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Content::Text(text_content) = &content[0] {
assert!(text_content.text.contains("Sessions for job 'job1'"));
assert!(text_content.text.contains("session1"));
assert!(text_content.text.contains("session2"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"sessions".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_sessions_action_with_limit() {
// Create test session metadata
let sessions = vec![
(
"1234567890_session1".to_string(),
create_test_session_metadata(5, "/tmp"),
),
(
"0987654321_session2".to_string(),
create_test_session_metadata(10, "/home"),
),
(
"5555555555_session3".to_string(),
create_test_session_metadata(15, "/usr"),
),
];
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.with_sessions_data("job1", sessions)
.await
.build()
.await;
// Test sessions action with limit
let arguments = json!({
"action": "sessions",
"job_id": "job1",
"limit": 2
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"sessions".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_sessions_action_empty() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test sessions action with no sessions
let arguments = json!({
"action": "sessions",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Content::Text(text_content) = &content[0] {
assert!(text_content
.text
.contains("No sessions found for job 'job1'"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"sessions".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_session_content_action() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Test with a non-existent session
let arguments = json!({
"action": "session_content",
"session_id": "non_existent_session"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(ToolError::ExecutionError(msg)) = result {
assert!(msg.contains("Session 'non_existent_session' not found"));
} else {
panic!("Expected ExecutionError");
}
}
#[tokio::test]
async fn test_schedule_tool_session_content_action_with_real_session() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Create a temporary session file in the proper session directory
let session_dir = goose::session::storage::ensure_session_dir().unwrap();
let session_id = "test_session_real";
let session_path = session_dir.join(format!("{}.jsonl", session_id));
// Create test metadata and messages
let metadata = create_test_session_metadata(2, "/tmp");
let messages = vec![
goose::message::Message::user().with_text("Hello"),
goose::message::Message::assistant().with_text("Hi there!"),
];
// Save the session file
goose::session::storage::save_messages_with_metadata(&session_path, &metadata, &messages)
.unwrap();
// Test the session_content action
let arguments = json!({
"action": "session_content",
"session_id": session_id
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
// Clean up the test session file
let _ = std::fs::remove_file(&session_path);
// Verify the result
assert!(result.is_ok());
if let Ok(content) = result {
assert_eq!(content.len(), 1);
if let mcp_core::Content::Text(text_content) = &content[0] {
assert!(text_content
.text
.contains("Session 'test_session_real' Content:"));
assert!(text_content.text.contains("Metadata:"));
assert!(text_content.text.contains("Messages:"));
assert!(text_content.text.contains("Hello"));
assert!(text_content.text.contains("Hi there!"));
assert!(text_content.text.contains("Test session"));
} else {
panic!("Expected text content");
}
} else {
panic!("Expected successful result");
}
}
#[tokio::test]
async fn test_schedule_tool_session_content_action_missing_session_id() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Test session_content action with missing session_id
let arguments = json!({
"action": "session_content"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(ToolError::ExecutionError(msg)) = result {
assert!(msg.contains("Missing 'session_id' parameter"));
} else {
panic!("Expected ExecutionError");
}
}
#[tokio::test]
async fn test_schedule_tool_unknown_action() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Test unknown action
let arguments = json!({
"action": "unknown_action"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(ToolError::ExecutionError(msg)) = result {
assert!(msg.contains("Unknown action"));
} else {
panic!("Expected ExecutionError");
}
}
#[tokio::test]
async fn test_schedule_tool_dispatch() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test that the tool is properly dispatched through dispatch_tool_call
let tool_call = mcp_core::tool::ToolCall {
name: PLATFORM_MANAGE_SCHEDULE_TOOL_NAME.to_string(),
arguments: json!({
"action": "list"
}),
};
let (request_id, result) = agent
.dispatch_tool_call(tool_call, "test_dispatch".to_string())
.await;
assert_eq!(request_id, "test_dispatch");
assert!(result.is_ok());
let tool_result = result.unwrap();
// The result should be a future that resolves to the tool output
let output = tool_result.result.await;
assert!(output.is_ok());
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"list_scheduled_jobs".to_string()));
}
@@ -0,0 +1,21 @@
//! Test-only utilities for the scheduler
#![cfg(test)]
use once_cell::sync::Lazy;
use std::sync::Arc;
use tokio::sync::Mutex;
use goose::providers::base::Provider as GooseProvider;
static TEST_PROVIDER: Lazy<Mutex<Option<Arc<dyn GooseProvider>>>> = Lazy::new(|| Mutex::new(None));
/// Register a default provider for scheduler job executions when running under tests.
/// The provider will be used by [`Scheduler`] when no provider_override is supplied.
pub async fn set_test_provider(p: Arc<dyn GooseProvider>) {
let mut guard = TEST_PROVIDER.lock().await;
*guard = Some(p);
}
pub async fn get_test_provider() -> Option<Arc<dyn GooseProvider>> {
TEST_PROVIDER.lock().await.clone()
}
+413
View File
@@ -0,0 +1,413 @@
#![cfg(test)]
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use tempfile::TempDir;
use tokio::sync::Mutex;
use goose::agents::Agent;
use goose::scheduler::{ScheduledJob, SchedulerError};
use goose::scheduler_trait::SchedulerTrait;
use goose::session::storage::SessionMetadata;
#[derive(Debug, Clone)]
pub enum MockBehavior {
Success,
NotFound(String),
AlreadyExists(String),
InternalError(String),
JobCurrentlyRunning(String),
}
#[derive(Clone)]
pub struct ConfigurableMockScheduler {
jobs: Arc<Mutex<HashMap<String, ScheduledJob>>>,
running_jobs: Arc<Mutex<HashSet<String>>>,
call_log: Arc<Mutex<Vec<String>>>,
behaviors: Arc<Mutex<HashMap<String, MockBehavior>>>,
sessions_data: Arc<Mutex<HashMap<String, Vec<(String, SessionMetadata)>>>>,
}
impl ConfigurableMockScheduler {
pub fn new() -> Self {
Self {
jobs: Arc::new(Mutex::new(HashMap::new())),
running_jobs: Arc::new(Mutex::new(HashSet::new())),
call_log: Arc::new(Mutex::new(Vec::new())),
behaviors: Arc::new(Mutex::new(HashMap::new())),
sessions_data: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn with_behavior(self, method: &str, behavior: MockBehavior) -> Self {
self.behaviors
.lock()
.await
.insert(method.to_string(), behavior);
self
}
pub async fn with_existing_job(self, job: ScheduledJob) -> Self {
self.jobs.lock().await.insert(job.id.clone(), job);
self
}
pub async fn with_running_job(self, job_id: &str) -> Self {
self.running_jobs.lock().await.insert(job_id.to_string());
self
}
pub async fn with_sessions_data(
self,
job_id: &str,
sessions: Vec<(String, SessionMetadata)>,
) -> Self {
self.sessions_data
.lock()
.await
.insert(job_id.to_string(), sessions);
self
}
pub async fn get_calls(&self) -> Vec<String> {
self.call_log.lock().await.clone()
}
async fn log_call(&self, method: &str) {
self.call_log.lock().await.push(method.to_string());
}
async fn get_behavior(&self, method: &str) -> MockBehavior {
self.behaviors
.lock()
.await
.get(method)
.cloned()
.unwrap_or(MockBehavior::Success)
}
}
#[async_trait]
impl SchedulerTrait for ConfigurableMockScheduler {
async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError> {
self.log_call("add_scheduled_job").await;
match self.get_behavior("add_scheduled_job").await {
MockBehavior::Success => {
let mut jobs = self.jobs.lock().await;
if jobs.contains_key(&job.id) {
return Err(SchedulerError::JobIdExists(job.id));
}
jobs.insert(job.id.clone(), job);
Ok(())
}
MockBehavior::AlreadyExists(id) => Err(SchedulerError::JobIdExists(id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(()),
}
}
async fn list_scheduled_jobs(&self) -> Result<Vec<ScheduledJob>, SchedulerError> {
self.log_call("list_scheduled_jobs").await;
match self.get_behavior("list_scheduled_jobs").await {
MockBehavior::Success => {
let jobs = self.jobs.lock().await;
Ok(jobs.values().cloned().collect())
}
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(vec![]),
}
}
async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> {
self.log_call("remove_scheduled_job").await;
match self.get_behavior("remove_scheduled_job").await {
MockBehavior::Success => {
let mut jobs = self.jobs.lock().await;
if jobs.remove(id).is_some() {
Ok(())
} else {
Err(SchedulerError::JobNotFound(id.to_string()))
}
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(()),
}
}
async fn pause_schedule(&self, id: &str) -> Result<(), SchedulerError> {
self.log_call("pause_schedule").await;
match self.get_behavior("pause_schedule").await {
MockBehavior::Success => {
let jobs = self.jobs.lock().await;
if jobs.contains_key(id) {
Ok(())
} else {
Err(SchedulerError::JobNotFound(id.to_string()))
}
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::JobCurrentlyRunning(job_id) => {
Err(SchedulerError::AnyhowError(anyhow::anyhow!(
"Cannot pause schedule '{}' while it's currently running",
job_id
)))
}
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(()),
}
}
async fn unpause_schedule(&self, id: &str) -> Result<(), SchedulerError> {
self.log_call("unpause_schedule").await;
match self.get_behavior("unpause_schedule").await {
MockBehavior::Success => {
let jobs = self.jobs.lock().await;
if jobs.contains_key(id) {
Ok(())
} else {
Err(SchedulerError::JobNotFound(id.to_string()))
}
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(()),
}
}
async fn run_now(&self, id: &str) -> Result<String, SchedulerError> {
self.log_call("run_now").await;
match self.get_behavior("run_now").await {
MockBehavior::Success => {
let jobs = self.jobs.lock().await;
if jobs.contains_key(id) {
Ok(format!("{}_session_{}", id, chrono::Utc::now().timestamp()))
} else {
Err(SchedulerError::JobNotFound(id.to_string()))
}
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok("mock_session_123".to_string()),
}
}
async fn sessions(
&self,
sched_id: &str,
limit: usize,
) -> Result<Vec<(String, SessionMetadata)>, SchedulerError> {
self.log_call("sessions").await;
match self.get_behavior("sessions").await {
MockBehavior::Success => {
let sessions_data = self.sessions_data.lock().await;
let sessions = sessions_data.get(sched_id).cloned().unwrap_or_default();
Ok(sessions.into_iter().take(limit).collect())
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(vec![]),
}
}
async fn update_schedule(
&self,
sched_id: &str,
_new_cron: String,
) -> Result<(), SchedulerError> {
self.log_call("update_schedule").await;
match self.get_behavior("update_schedule").await {
MockBehavior::Success => {
let jobs = self.jobs.lock().await;
if jobs.contains_key(sched_id) {
Ok(())
} else {
Err(SchedulerError::JobNotFound(sched_id.to_string()))
}
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(()),
}
}
async fn kill_running_job(&self, sched_id: &str) -> Result<(), SchedulerError> {
self.log_call("kill_running_job").await;
match self.get_behavior("kill_running_job").await {
MockBehavior::Success => {
let running_jobs = self.running_jobs.lock().await;
if running_jobs.contains(sched_id) {
Ok(())
} else {
Err(SchedulerError::AnyhowError(anyhow::anyhow!(
"Schedule '{}' is not currently running",
sched_id
)))
}
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(()),
}
}
async fn get_running_job_info(
&self,
sched_id: &str,
) -> Result<Option<(String, DateTime<Utc>)>, SchedulerError> {
self.log_call("get_running_job_info").await;
match self.get_behavior("get_running_job_info").await {
MockBehavior::Success => {
let running_jobs = self.running_jobs.lock().await;
if running_jobs.contains(sched_id) {
Ok(Some((format!("{}_session", sched_id), Utc::now())))
} else {
Ok(None)
}
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(None),
}
}
}
// Helper for creating temp recipe files
pub struct TempRecipe {
pub path: PathBuf,
_temp_dir: TempDir, // Keep alive
}
pub fn create_temp_recipe(valid: bool, format: &str) -> TempRecipe {
let temp_dir = tempfile::tempdir().unwrap();
let filename = format!("test_recipe.{}", format);
let path = temp_dir.path().join(filename);
let content = if valid {
match format {
"json" => {
r#"{
"version": "1.0.0",
"title": "Test Recipe",
"description": "A test recipe",
"prompt": "Hello world"
}"#
}
"yaml" | "yml" => {
r#"version: "1.0.0"
title: "Test Recipe"
description: "A test recipe"
prompt: "Hello world"
"#
}
_ => panic!("Unsupported format: {}", format),
}
} else {
match format {
"json" => r#"{"invalid": json syntax"#,
"yaml" | "yml" => "invalid:\n - yaml: syntax: error",
_ => "invalid content",
}
};
std::fs::write(&path, content).unwrap();
TempRecipe {
path,
_temp_dir: temp_dir,
}
}
// Test builder for easy setup
pub struct ScheduleToolTestBuilder {
scheduler: Arc<ConfigurableMockScheduler>,
}
impl ScheduleToolTestBuilder {
pub fn new() -> Self {
Self {
scheduler: Arc::new(ConfigurableMockScheduler::new()),
}
}
pub async fn with_scheduler_behavior(self, method: &str, behavior: MockBehavior) -> Self {
{
let mut behaviors = self.scheduler.behaviors.lock().await;
behaviors.insert(method.to_string(), behavior);
}
self
}
pub async fn with_existing_job(self, job_id: &str, cron: &str) -> Self {
let job = ScheduledJob {
id: job_id.to_string(),
source: "/tmp/test.json".to_string(),
cron: cron.to_string(),
last_run: None,
currently_running: false,
paused: false,
current_session_id: None,
process_start_time: None,
};
{
let mut jobs = self.scheduler.jobs.lock().await;
jobs.insert(job.id.clone(), job);
}
self
}
pub async fn with_running_job(self, job_id: &str) -> Self {
{
let mut running_jobs = self.scheduler.running_jobs.lock().await;
running_jobs.insert(job_id.to_string());
}
self
}
pub async fn with_sessions_data(
self,
job_id: &str,
sessions: Vec<(String, SessionMetadata)>,
) -> Self {
{
let mut sessions_data = self.scheduler.sessions_data.lock().await;
sessions_data.insert(job_id.to_string(), sessions);
}
self
}
pub async fn build(self) -> (Agent, Arc<ConfigurableMockScheduler>) {
let agent = Agent::new();
agent.set_scheduler(self.scheduler.clone()).await;
(agent, self.scheduler)
}
}
// Helper function to create test session metadata
pub fn create_test_session_metadata(message_count: usize, working_dir: &str) -> SessionMetadata {
SessionMetadata {
message_count,
working_dir: PathBuf::from(working_dir),
description: "Test session".to_string(),
schedule_id: Some("test_job".to_string()),
total_tokens: Some(100),
input_tokens: Some(50),
output_tokens: Some(50),
accumulated_total_tokens: Some(100),
accumulated_input_tokens: Some(50),
accumulated_output_tokens: Some(50),
}
}