diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 4df791843c4..be1575cad80 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -19,6 +19,7 @@ use crate::commands::session::{handle_session_list, handle_session_remove}; use crate::recipes::extract_from_cli::extract_recipe_info_from_cli; use crate::recipes::recipe::{explain_recipe, render_recipe_as_yaml}; use crate::session::{build_session, SessionBuilderConfig, SessionSettings}; +use goose::session::session_manager::SessionType; use goose::session::SessionManager; use goose_bench::bench_config::BenchRunConfig; use goose_bench::runners::bench_runner::BenchRunner; @@ -86,9 +87,12 @@ async fn get_or_create_session_id( .ok_or_else(|| anyhow::anyhow!("No session found to resume"))?; Ok(Some(session_id)) } else { - let session = - SessionManager::create_session(std::env::current_dir()?, "CLI Session".to_string()) - .await?; + let session = SessionManager::create_session( + std::env::current_dir()?, + "CLI Session".to_string(), + SessionType::User, + ) + .await?; Ok(Some(session.id)) }; }; @@ -105,8 +109,12 @@ async fn get_or_create_session_id( .ok_or_else(|| anyhow::anyhow!("No session found with name '{}'", name))?; Ok(Some(session_id)) } else { - let session = - SessionManager::create_session(std::env::current_dir()?, name.clone()).await?; + let session = SessionManager::create_session( + std::env::current_dir()?, + name.clone(), + SessionType::User, + ) + .await?; SessionManager::update_session(&session.id) .user_provided_name(name) @@ -123,9 +131,12 @@ async fn get_or_create_session_id( .ok_or_else(|| anyhow::anyhow!("Could not extract session ID from path: {:?}", path))?; Ok(Some(session_id)) } else { - let session = - SessionManager::create_session(std::env::current_dir()?, "CLI Session".to_string()) - .await?; + let session = SessionManager::create_session( + std::env::current_dir()?, + "CLI Session".to_string(), + SessionType::User, + ) + .await?; Ok(Some(session.id)) } } @@ -977,7 +988,7 @@ pub async fn cli() -> anyhow::Result<()> { let exit_type = if result.is_ok() { "normal" } else { "error" }; let (total_tokens, message_count) = session - .get_metadata() + .get_session() .await .map(|m| (m.total_tokens.unwrap_or(0), m.message_count)) .unwrap_or((0, 0)); @@ -1198,7 +1209,7 @@ pub async fn cli() -> anyhow::Result<()> { let exit_type = if result.is_ok() { "normal" } else { "error" }; let (total_tokens, message_count) = session - .get_metadata() + .get_session() .await .map(|m| (m.total_tokens.unwrap_or(0), m.message_count)) .unwrap_or((0, 0)); diff --git a/crates/goose-cli/src/commands/acp.rs b/crates/goose-cli/src/commands/acp.rs index 95094ce7e90..dfdbe269b8d 100644 --- a/crates/goose-cli/src/commands/acp.rs +++ b/crates/goose-cli/src/commands/acp.rs @@ -3,11 +3,13 @@ use agent_client_protocol::{ ToolCallContent, }; use anyhow::Result; -use goose::agents::Agent; +use goose::agents::{Agent, SessionConfig}; use goose::config::{get_all_extensions, Config}; use goose::conversation::message::{Message, MessageContent}; use goose::conversation::Conversation; use goose::providers::create; +use goose::session::session_manager::SessionType; +use goose::session::SessionManager; use rmcp::model::{RawContent, ResourceContents}; use std::collections::{HashMap, HashSet}; use std::fs; @@ -19,17 +21,15 @@ use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; use url::Url; -/// Represents a single goose session for ACP -struct GooseSession { +struct GooseAcpSession { messages: Conversation, tool_call_ids: HashMap, // Maps internal tool IDs to ACP tool call IDs cancel_token: Option, // Active cancellation token for prompt processing } -/// goose ACP Agent implementation that connects to real goose agents struct GooseAcpAgent { - session_update_tx: mpsc::UnboundedSender<(acp::SessionNotification, oneshot::Sender<()>)>, - sessions: Arc>>, + session_update_tx: mpsc::UnboundedSender<(SessionNotification, oneshot::Sender<()>)>, + sessions: Arc>>, agent: Agent, // Shared agent instance } @@ -97,7 +97,6 @@ impl GooseAcpAgent { async fn new( session_update_tx: mpsc::UnboundedSender<(acp::SessionNotification, oneshot::Sender<()>)>, ) -> Result { - // Load config and create provider let config = Config::global(); let provider_name: String = config @@ -217,7 +216,7 @@ impl GooseAcpAgent { &self, content_item: &MessageContent, session_id: &acp::SessionId, - session: &mut GooseSession, + session: &mut GooseAcpSession, ) -> Result<(), acp::Error> { match content_item { MessageContent::Text(text) => { @@ -273,7 +272,7 @@ impl GooseAcpAgent { &self, tool_request: &goose::conversation::message::ToolRequest, session_id: &acp::SessionId, - session: &mut GooseSession, + session: &mut GooseAcpSession, ) -> Result<(), acp::Error> { // Generate ACP tool call ID and track mapping let acp_tool_id = format!("tool_{}", uuid::Uuid::new_v4()); @@ -341,7 +340,7 @@ impl GooseAcpAgent { &self, tool_response: &goose::conversation::message::ToolResponse, session_id: &acp::SessionId, - session: &mut GooseSession, + session: &mut GooseAcpSession, ) -> Result<(), acp::Error> { // Look up the ACP tool call ID if let Some(acp_tool_id) = session.tool_call_ids.get(&tool_response.id) { @@ -496,7 +495,7 @@ impl acp::Agent for GooseAcpAgent { // Generate a unique session ID let session_id = uuid::Uuid::new_v4().to_string(); - let session = GooseSession { + let session = GooseAcpSession { messages: Conversation::new_unvalidated(Vec::new()), tool_call_ids: HashMap::new(), cancel_token: None, @@ -544,30 +543,26 @@ impl acp::Agent for GooseAcpAgent { // Create and store cancellation token for this prompt let cancel_token = CancellationToken::new(); - // Convert ACP prompt to Goose message let user_message = self.convert_acp_prompt_to_message(args.prompt); - // Prepare for agent reply - let messages = { - let mut sessions = self.sessions.lock().await; - let session = sessions - .get_mut(&session_id) - .ok_or_else(acp::Error::invalid_params)?; - - // Add message to conversation - session.messages.push(user_message); - - // Store cancellation token - session.cancel_token = Some(cancel_token.clone()); + let session = SessionManager::create_session( + std::env::current_dir().unwrap_or_default(), + "ACP Session".to_string(), + SessionType::Hidden, + ) + .await?; - // Clone what we need for the reply call - session.messages.clone() + let session_config = SessionConfig { + id: session.id.clone(), + schedule_id: None, + max_turns: None, + retry_config: None, }; // Get agent's reply through the Goose agent let mut stream = self .agent - .reply(messages, None, Some(cancel_token.clone())) + .reply(user_message, session_config, Some(cancel_token.clone())) .await .map_err(|e| { error!("Error getting agent reply: {}", e); diff --git a/crates/goose-cli/src/commands/bench.rs b/crates/goose-cli/src/commands/bench.rs index d67b9191315..da6af17a78f 100644 --- a/crates/goose-cli/src/commands/bench.rs +++ b/crates/goose-cli/src/commands/bench.rs @@ -26,9 +26,7 @@ impl BenchBaseSession for CliSession { } fn get_session_id(&self) -> anyhow::Result { - self.session_id() - .cloned() - .ok_or_else(|| anyhow::anyhow!("No session ID available")) + Ok(self.session_id().to_string()) } } pub async fn agent_generator( diff --git a/crates/goose-cli/src/commands/schedule.rs b/crates/goose-cli/src/commands/schedule.rs index 2756fa2e44c..12f2c08684b 100644 --- a/crates/goose-cli/src/commands/schedule.rs +++ b/crates/goose-cli/src/commands/schedule.rs @@ -98,7 +98,6 @@ pub async fn handle_schedule_add( paused: false, current_session_id: None, process_start_time: None, - execution_mode: Some("background".to_string()), // Default to background for CLI }; let scheduler_storage_path = diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs index f49e44fa919..8fa49a6645f 100644 --- a/crates/goose-cli/src/commands/web.rs +++ b/crates/goose-cli/src/commands/web.rs @@ -15,6 +15,7 @@ use base64::Engine; use futures::{sink::SinkExt, stream::StreamExt}; use goose::agents::{Agent, AgentEvent}; use goose::conversation::message::Message as GooseMessage; +use goose::session::session_manager::SessionType; use goose::session::SessionManager; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -226,6 +227,7 @@ async fn serve_index() -> Result { let session = SessionManager::create_session( std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), "Web session".to_string(), + SessionType::User, ) .await .map_err(|err| (http::StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; @@ -467,21 +469,16 @@ async fn process_message_streaming( let session = SessionManager::get_session(&session_id, true).await?; let mut messages = session.conversation.unwrap_or_default(); - messages.push(user_message); + messages.push(user_message.clone()); let session_config = SessionConfig { id: session.id.clone(), - working_dir: session.working_dir, schedule_id: None, - execution_mode: None, max_turns: None, retry_config: None, }; - match agent - .reply(messages.clone(), Some(session_config), None) - .await - { + match agent.reply(user_message, session_config, None).await { Ok(mut stream) => { while let Some(result) = stream.next().await { match result { diff --git a/crates/goose-cli/src/scenario_tests/scenario_runner.rs b/crates/goose-cli/src/scenario_tests/scenario_runner.rs index 00a90778d3a..df09e5ebcf2 100644 --- a/crates/goose-cli/src/scenario_tests/scenario_runner.rs +++ b/crates/goose-cli/src/scenario_tests/scenario_runner.rs @@ -9,8 +9,10 @@ use anyhow::Result; use goose::agents::Agent; use goose::model::ModelConfig; use goose::providers::{create, testprovider::TestProvider}; +use goose::session::session_manager::SessionType; +use goose::session::SessionManager; use std::collections::{HashMap, HashSet}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -190,7 +192,6 @@ where ) }; - // Generate messages using the provider let messages = vec![message_generator(&*provider_arc)]; let mock_client = weather_client(); @@ -218,11 +219,17 @@ where .update_provider(provider_arc as Arc) .await?; - let mut session = CliSession::new(agent, None, false, None, None, None, None).await; + let session = SessionManager::create_session( + PathBuf::default(), + "scenario-runner".to_string(), + SessionType::Hidden, + ) + .await?; + let mut cli_session = CliSession::new(agent, session.id, false, None, None, None, None).await; let mut error = None; for message in &messages { - if let Err(e) = session + if let Err(e) = cli_session .process_message(message.clone(), CancellationToken::default()) .await { @@ -230,7 +237,7 @@ where break; } } - let updated_messages = session.message_history(); + let updated_messages = cli_session.message_history(); if let Some(ref err_msg) = error { if err_msg.contains("No recorded response found") { @@ -249,7 +256,7 @@ where validator(&result)?; - drop(session); + drop(cli_session); if let Some(provider) = provider_for_saving { if result.error.is_none() { diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 8c0811a4b41..bc15cdbc502 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -11,6 +11,7 @@ use goose::providers::create; use goose::recipe::{Response, SubRecipe}; use goose::agents::extension::PlatformExtensionContext; +use goose::session::session_manager::SessionType; use goose::session::SessionManager; use goose::session::{EnabledExtensionsState, ExtensionState}; use rustyline::EditMode; @@ -25,7 +26,7 @@ use tokio::task::JoinSet; /// including session identification, extension configuration, and debug settings. #[derive(Default, Clone, Debug)] pub struct SessionBuilderConfig { - /// Optional session ID for resuming or identifying an existing session + /// Session id, optional need to deduce from context pub session_id: Option, /// Whether to resume an existing session pub resume: bool, @@ -132,8 +133,14 @@ async fn offer_extension_debugging_help( } } - // Create the debugging session - let mut debug_session = CliSession::new(debug_agent, None, false, None, None, None, None).await; + let session = SessionManager::create_session( + std::env::current_dir()?, + "CLI Session".to_string(), + SessionType::Hidden, + ) + .await?; + let mut debug_session = + CliSession::new(debug_agent, session.id, false, None, None, None, None).await; // Process the debugging request println!("{}", style("Analyzing the extension failure...").yellow()); @@ -278,12 +285,20 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { process::exit(1); }); - let session_id: Option = if session_config.no_session { - None + let session_id: String = if session_config.no_session { + let working_dir = std::env::current_dir().expect("Could not get working directory"); + let session = SessionManager::create_session( + working_dir, + "CLI Session".to_string(), + SessionType::Hidden, + ) + .await + .expect("Could not create session"); + session.id } else if session_config.resume { if let Some(session_id) = session_config.session_id { match SessionManager::get_session(&session_id, false).await { - Ok(_) => Some(session_id), + Ok(_) => session_id, Err(_) => { output::render_error(&format!( "Cannot resume session {} - no such session exists", @@ -294,7 +309,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { } } else { match SessionManager::list_sessions().await { - Ok(sessions) if !sessions.is_empty() => Some(sessions[0].id.clone()), + Ok(sessions) if !sessions.is_empty() => sessions[0].id.clone(), _ => { output::render_error("Cannot resume - no previous sessions found"); process::exit(1); @@ -302,46 +317,44 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { } } } else { - session_config.session_id + session_config.session_id.unwrap() }; agent .extension_manager .set_context(PlatformExtensionContext { - session_id: session_id.clone(), + session_id: Some(session_id.clone()), extension_manager: Some(Arc::downgrade(&agent.extension_manager)), tool_route_manager: Some(Arc::downgrade(&agent.tool_route_manager)), }) .await; if session_config.resume { - if let Some(session_id) = session_id.as_ref() { - let metadata = SessionManager::get_session(session_id, false) - .await - .unwrap_or_else(|e| { - output::render_error(&format!("Failed to read session metadata: {}", e)); - process::exit(1); - }); + let session = SessionManager::get_session(&session_id, false) + .await + .unwrap_or_else(|e| { + output::render_error(&format!("Failed to read session metadata: {}", e)); + process::exit(1); + }); - let current_workdir = - std::env::current_dir().expect("Failed to get current working directory"); - if current_workdir != metadata.working_dir { - let change_workdir = cliclack::confirm(format!("{} The original working directory of this session was set to {}. Your current directory is {}. Do you want to switch back to the original working directory?", style("WARNING:").yellow(), style(metadata.working_dir.display()).cyan(), style(current_workdir.display()).cyan())) + let current_workdir = + std::env::current_dir().expect("Failed to get current working directory"); + if current_workdir != session.working_dir { + let change_workdir = cliclack::confirm(format!("{} The original working directory of this session was set to {}. Your current directory is {}. Do you want to switch back to the original working directory?", style("WARNING:").yellow(), style(session.working_dir.display()).cyan(), style(current_workdir.display()).cyan())) .initial_value(true) .interact().expect("Failed to get user input"); - if change_workdir { - if !metadata.working_dir.exists() { - output::render_error(&format!( - "Cannot switch to original working directory - {} no longer exists", - style(metadata.working_dir.display()).cyan() - )); - } else if let Err(e) = std::env::set_current_dir(&metadata.working_dir) { - output::render_error(&format!( - "Failed to switch to original working directory: {}", - e - )); - } + if change_workdir { + if !session.working_dir.exists() { + output::render_error(&format!( + "Cannot switch to original working directory - {} no longer exists", + style(session.working_dir.display()).cyan() + )); + } else if let Err(e) = std::env::set_current_dir(&session.working_dir) { + output::render_error(&format!( + "Failed to switch to original working directory: {}", + e + )); } } } @@ -354,22 +367,18 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { agent.disable_router_for_recipe().await; extensions.into_iter().collect() } else if session_config.resume { - if let Some(session_id) = session_id.as_ref() { - match SessionManager::get_session(session_id, false).await { - Ok(session_data) => { - if let Some(saved_state) = - EnabledExtensionsState::from_extension_data(&session_data.extension_data) - { - check_missing_extensions_or_exit(&saved_state.extensions); - saved_state.extensions - } else { - get_enabled_extensions() - } + match SessionManager::get_session(&session_id, false).await { + Ok(session_data) => { + if let Some(saved_state) = + EnabledExtensionsState::from_extension_data(&session_data.extension_data) + { + check_missing_extensions_or_exit(&saved_state.extensions); + saved_state.extensions + } else { + get_enabled_extensions() } - _ => get_enabled_extensions(), } - } else { - get_enabled_extensions() + _ => get_enabled_extensions(), } } else { get_enabled_extensions() @@ -560,23 +569,19 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { } } - if let Some(session_id) = session_id.as_ref() { - let session_config_for_save = SessionConfig { - id: session_id.clone(), - working_dir: std::env::current_dir().unwrap_or_default(), - schedule_id: None, - execution_mode: None, - max_turns: None, - retry_config: None, - }; + let session_config_for_save = SessionConfig { + id: session_id.clone(), + schedule_id: None, + max_turns: None, + retry_config: None, + }; - if let Err(e) = session - .agent - .save_extension_state(&session_config_for_save) - .await - { - tracing::warn!("Failed to save initial extension state: {}", e); - } + if let Err(e) = session + .agent + .save_extension_state(&session_config_for_save) + .await + { + tracing::warn!("Failed to save initial extension state: {}", e); } // Add CLI-specific system prompt extension @@ -603,7 +608,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { session_config.resume, &provider_name, &model_name, - &session_id, + &Some(session_id), Some(&provider_for_display), ); } diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index bed33e87fa9..9a3c5f5d66f 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -58,7 +58,7 @@ pub enum RunMode { pub struct CliSession { agent: Agent, messages: Conversation, - session_id: Option, + session_id: String, completion_cache: Arc>, debug: bool, run_mode: RunMode, @@ -122,21 +122,17 @@ pub async fn classify_planner_response( impl CliSession { pub async fn new( agent: Agent, - session_id: Option, + session_id: String, debug: bool, scheduled_job_id: Option, max_turns: Option, edit_mode: Option, retry_config: Option, ) -> Self { - let messages = if let Some(session_id) = &session_id { - SessionManager::get_session(session_id, true) - .await - .map(|session| session.conversation.unwrap_or_default()) - .unwrap() - } else { - Conversation::new_unvalidated(Vec::new()) - }; + let messages = SessionManager::get_session(&session_id, true) + .await + .map(|session| session.conversation.unwrap_or_default()) + .unwrap(); CliSession { agent, @@ -152,8 +148,8 @@ impl CliSession { } } - pub fn session_id(&self) -> Option<&String> { - self.session_id.as_ref() + pub fn session_id(&self) -> &String { + &self.session_id } /// Add a stdio extension to the session @@ -359,9 +355,6 @@ impl CliSession { cancel_token: CancellationToken, ) -> Result<()> { let cancel_token = cancel_token.clone(); - - // TODO(Douwe): Make sure we generate the description here still: - self.push_message(message); self.process_agent_response(false, cancel_token).await?; Ok(()) @@ -443,7 +436,7 @@ impl CliSession { // Track the current directory and last instruction in projects.json if let Err(e) = crate::project_tracker::update_project_tracker( Some(&content), - self.session_id.as_deref(), + Some(&self.session_id), ) { eprintln!("Warning: Failed to update project tracker with instruction: {}", e); } @@ -583,16 +576,14 @@ impl CliSession { input::InputResult::Clear => { save_history(&mut editor); - if let Some(session_id) = &self.session_id { - if let Err(e) = SessionManager::replace_conversation( - session_id, - &Conversation::default(), - ) - .await - { - output::render_error(&format!("Failed to clear session: {}", e)); - continue; - } + if let Err(e) = SessionManager::replace_conversation( + &self.session_id, + &Conversation::default(), + ) + .await + { + output::render_error(&format!("Failed to clear session: {}", e)); + continue; } self.messages.clear(); @@ -671,9 +662,10 @@ impl CliSession { } } - if let Some(id) = &self.session_id { - println!("Closing session. Session ID: {}", console::style(id).cyan()); - } + println!( + "Closing session. Session ID: {}", + console::style(&self.session_id).cyan() + ); Ok(()) } @@ -768,18 +760,20 @@ impl CliSession { ) -> Result<()> { let cancel_token_clone = cancel_token.clone(); - let session_config = self.session_id.as_ref().map(|session_id| SessionConfig { - id: session_id.clone(), - working_dir: std::env::current_dir().unwrap_or_default(), + let session_config = SessionConfig { + id: self.session_id.clone(), schedule_id: self.scheduled_job_id.clone(), - execution_mode: None, max_turns: self.max_turns, retry_config: self.retry_config.clone(), - }); + }; + let user_message = self + .messages + .last() + .ok_or_else(|| anyhow::anyhow!("No user message"))?; let mut stream = self .agent .reply( - self.messages.clone(), + user_message.clone(), session_config.clone(), Some(cancel_token.clone()), ) @@ -1224,16 +1218,13 @@ impl CliSession { ); } - pub async fn get_metadata(&self) -> Result { - match &self.session_id { - Some(id) => SessionManager::get_session(id, false).await, - None => Err(anyhow::anyhow!("No session available")), - } + pub async fn get_session(&self) -> Result { + SessionManager::get_session(&self.session_id, false).await } // Get the session's total token usage pub async fn get_total_token_usage(&self) -> Result> { - let metadata = self.get_metadata().await?; + let metadata = self.get_session().await?; Ok(metadata.total_tokens) } @@ -1265,7 +1256,7 @@ impl CliSession { } } - match self.get_metadata().await { + match self.get_session().await { Ok(metadata) => { let total_tokens = metadata.total_tokens.unwrap_or(0) as usize; diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 37fc4a77bc8..4667b93135a 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -6,7 +6,7 @@ use goose::config::ExtensionEntry; use goose::conversation::Conversation; use goose::permission::permission_confirmation::PrincipalType; use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata, ProviderType}; -use goose::session::{Session, SessionInsights}; +use goose::session::{Session, SessionInsights, SessionType}; use rmcp::model::{ Annotations, Content, EmbeddedResource, Icon, ImageContent, JsonObject, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ResourceContents, Role, @@ -444,6 +444,7 @@ derive_utoipa!(Icon as IconSchema); ModelInfo, Session, SessionInsights, + SessionType, Conversation, IconSchema, goose::session::extension_data::ExtensionData, diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 29898542b8a..031b7096b22 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -18,6 +18,7 @@ use goose::prompt_template::render_global_file; use goose::providers::{create, create_with_named_model}; use goose::recipe::Recipe; use goose::recipe_deeplink; +use goose::session::session_manager::SessionType; use goose::session::{Session, SessionManager}; use goose::{ agents::{extension::ToolInfo, extension_manager::get_parameter_names}, @@ -137,15 +138,16 @@ async fn start_agent( let counter = state.session_counter.fetch_add(1, Ordering::SeqCst) + 1; let name = format!("New session {}", counter); - let mut session = SessionManager::create_session(PathBuf::from(&working_dir), name) - .await - .map_err(|err| { - error!("Failed to create session: {}", err); - ErrorResponse { - message: format!("Failed to create session: {}", err), - status: StatusCode::BAD_REQUEST, - } - })?; + let mut session = + SessionManager::create_session(PathBuf::from(&working_dir), name, SessionType::User) + .await + .map_err(|err| { + error!("Failed to create session: {}", err); + ErrorResponse { + message: format!("Failed to create session: {}", err), + status: StatusCode::BAD_REQUEST, + } + })?; if let Some(recipe) = original_recipe { SessionManager::update_session(&session.id) diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 0748399991b..6c5425d9a04 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -257,17 +257,30 @@ pub async fn reply( let session_config = SessionConfig { id: session_id.clone(), - working_dir: session.working_dir.clone(), schedule_id: session.schedule_id.clone(), - execution_mode: None, max_turns: None, retry_config: None, }; + let user_message = match messages.last() { + Some(msg) => msg, + _ => { + let _ = stream_event( + MessageEvent::Error { + error: "Reply started with empty messages".to_string(), + }, + &task_tx, + &task_cancel, + ) + .await; + return; + } + }; + let mut stream = match agent .reply( - messages.clone(), - Some(session_config.clone()), + user_message.clone(), + session_config, Some(task_cancel.clone()), ) .await diff --git a/crates/goose-server/src/routes/schedule.rs b/crates/goose-server/src/routes/schedule.rs index b15e3f13148..077bf0e2c36 100644 --- a/crates/goose-server/src/routes/schedule.rs +++ b/crates/goose-server/src/routes/schedule.rs @@ -115,7 +115,6 @@ async fn create_schedule( paused: false, current_session_id: None, process_start_time: None, - execution_mode: req.execution_mode.or(Some("background".to_string())), // Default to background }; scheduler .add_scheduled_job(job.clone()) diff --git a/crates/goose/examples/agent.rs b/crates/goose/examples/agent.rs index d57226a7a79..014d63b302a 100644 --- a/crates/goose/examples/agent.rs +++ b/crates/goose/examples/agent.rs @@ -1,11 +1,13 @@ use dotenvy::dotenv; use futures::StreamExt; -use goose::agents::{Agent, AgentEvent, ExtensionConfig}; +use goose::agents::{Agent, AgentEvent, ExtensionConfig, SessionConfig}; use goose::config::{DEFAULT_EXTENSION_DESCRIPTION, DEFAULT_EXTENSION_TIMEOUT}; use goose::conversation::message::Message; -use goose::conversation::Conversation; use goose::providers::create_with_named_model; use goose::providers::databricks::DATABRICKS_DEFAULT_MODEL; +use goose::session::session_manager::SessionType; +use goose::session::SessionManager; +use std::path::PathBuf; #[tokio::main] async fn main() { @@ -32,11 +34,29 @@ async fn main() { println!(" {}", extension); } - let conversation = Conversation::new(vec![Message::user() - .with_text("can you summarize the readme.md in this dir using just a haiku?")]) - .unwrap(); + let session = SessionManager::create_session( + PathBuf::default(), + "max-turn-test".to_string(), + SessionType::Hidden, + ) + .await + .expect("session manager creation failed"); + + let session_config = SessionConfig { + id: session.id, + schedule_id: None, + max_turns: None, + retry_config: None, + }; + + let user_message = Message::user() + .with_text("can you summarize the readme.md in this dir using just a haiku?"); + + let mut stream = agent + .reply(user_message, session_config, None) + .await + .unwrap(); - let mut stream = agent.reply(conversation, None, None).await.unwrap(); while let Some(Ok(AgentEvent::Message(message))) = stream.next().await { println!("{}", serde_json::to_string_pretty(&message).unwrap()); println!("\n"); diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index f958397046c..9e80a08f816 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -61,7 +61,7 @@ use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DEC use crate::agents::subagent_task_config::TaskConfig; use crate::conversation::message::{Message, MessageContent, SystemNotificationType, ToolRequest}; use crate::session::extension_data::{EnabledExtensionsState, ExtensionState}; -use crate::session::SessionManager; +use crate::session::{Session, SessionManager}; const DEFAULT_MAX_TURNS: u32 = 1000; const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation..."; @@ -75,7 +75,6 @@ pub struct ReplyContext { pub system_prompt: String, pub goose_mode: GooseMode, pub initial_messages: Vec, - pub config: &'static Config, } pub struct ToolCategorizeResult { @@ -219,16 +218,20 @@ impl Agent { self.retry_manager.get_attempts().await } - /// Handle retry logic for the agent reply loop async fn handle_retry_logic( &self, messages: &mut Conversation, - session: &Option, + session_config: &SessionConfig, initial_messages: &[Message], ) -> Result { let result = self .retry_manager - .handle_retry_logic(messages, session, initial_messages, &self.final_output_tool) + .handle_retry_logic( + messages, + session_config, + initial_messages, + &self.final_output_tool, + ) .await?; match result { @@ -242,7 +245,6 @@ impl Agent { async fn prepare_reply_context( &self, unfixed_conversation: Conversation, - session: &Option, ) -> Result { let unfixed_messages = unfixed_conversation.messages().clone(); let (conversation, issues) = fix_conversation(unfixed_conversation.clone()); @@ -260,9 +262,8 @@ impl Agent { let config = Config::global(); let (tools, toolshim_tools, system_prompt) = self.prepare_tools_and_prompt().await?; - let goose_mode = Self::determine_goose_mode(session.as_ref(), config); + let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto); - // Update permission inspector mode to match the session mode self.tool_inspection_manager .update_permission_inspector_mode(goose_mode) .await; @@ -274,7 +275,6 @@ impl Agent { system_prompt, goose_mode, initial_messages, - config, }) } @@ -299,7 +299,7 @@ impl Agent { permission_check_result: &PermissionCheckResult, message_tool_response: Arc>, cancel_token: Option, - session: Option, + session: &Session, ) -> Result> { let mut tool_futures: Vec<(String, ToolStream)> = Vec::new(); @@ -311,7 +311,7 @@ impl Agent { tool_call, request.id.clone(), cancel_token.clone(), - session.clone(), + session, ) .await; @@ -392,7 +392,7 @@ impl Agent { tool_call: CallToolRequestParam, request_id: String, cancellation_token: Option, - session: Option, + session: &Session, ) -> (String, Result) { if tool_call.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME { let arguments = tool_call @@ -451,17 +451,13 @@ impl Agent { ); } }; - let (parent_session_id, parent_working_dir) = match session.as_ref() { - Some(s) => (Some(s.id.clone()), s.working_dir.clone()), - None => (None, std::env::current_dir().unwrap_or_default()), - }; // Get extensions from the agent's runtime state rather than global config // This ensures subagents inherit extensions that were dynamically enabled by the parent let extensions = self.get_extension_configs().await; let task_config = - TaskConfig::new(provider, parent_session_id, parent_working_dir, extensions); + TaskConfig::new(provider, &session.id, &session.working_dir, extensions); let arguments = match tool_call.arguments.clone() { Some(args) => Value::Object(args), @@ -731,117 +727,110 @@ impl Agent { } } - #[instrument(skip(self, unfixed_conversation, session), fields(user_message))] + #[instrument(skip(self, user_message, session_config), fields(user_message))] pub async fn reply( &self, - unfixed_conversation: Conversation, - session: Option, + user_message: Message, + session_config: SessionConfig, cancel_token: Option, ) -> Result>> { - let is_manual_compact = unfixed_conversation.messages().last().is_some_and(|msg| { - msg.content.iter().any(|c| { - if let MessageContent::Text(text) = c { - text.text.trim() == MANUAL_COMPACT_TRIGGER - } else { - false - } - }) + let is_manual_compact = user_message.content.iter().any(|c| { + if let MessageContent::Text(text) = c { + text.text.trim() == MANUAL_COMPACT_TRIGGER + } else { + false + } }); - if !is_manual_compact { - let session_metadata = if let Some(session_config) = &session { - SessionManager::get_session(&session_config.id, false) - .await - .ok() - } else { - None - }; + SessionManager::add_message(&session_config.id, &user_message).await?; + let session = SessionManager::get_session(&session_config.id, true).await?; - let needs_auto_compact = crate::context_mgmt::check_if_compaction_needed( - self, - &unfixed_conversation, - None, - session_metadata.as_ref(), - ) - .await?; + let conversation = session + .conversation + .clone() + .ok_or_else(|| anyhow::anyhow!("Session {} has no conversation", session_config.id))?; - if !needs_auto_compact { - return self - .reply_internal(unfixed_conversation, session, cancel_token) - .await; - } - } + let needs_auto_compact = + crate::context_mgmt::check_if_compaction_needed(self, &conversation, None, &session) + .await?; - let conversation_to_compact = unfixed_conversation.clone(); + let conversation_to_compact = conversation.clone(); Ok(Box::pin(async_stream::try_stream! { - if !is_manual_compact { - let config = crate::config::Config::global(); - let threshold = config - .get_param::("GOOSE_AUTO_COMPACT_THRESHOLD") - .unwrap_or(DEFAULT_COMPACTION_THRESHOLD); - let threshold_percentage = (threshold * 100.0) as u32; - - let inline_msg = format!( - "Exceeded auto-compact threshold of {}%. Performing auto-compaction...", - threshold_percentage - ); + let final_conversation = if !needs_auto_compact { + conversation + } else { + if !is_manual_compact { + let config = crate::config::Config::global(); + let threshold = config + .get_param::("GOOSE_AUTO_COMPACT_THRESHOLD") + .unwrap_or(DEFAULT_COMPACTION_THRESHOLD); + let threshold_percentage = (threshold * 100.0) as u32; + + let inline_msg = format!( + "Exceeded auto-compact threshold of {}%. Performing auto-compaction...", + threshold_percentage + ); + + yield AgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + inline_msg, + ) + ); + } yield AgentEvent::Message( Message::assistant().with_system_notification( - SystemNotificationType::InlineMessage, - inline_msg, + SystemNotificationType::ThinkingMessage, + COMPACTION_THINKING_TEXT, ) ); - } - yield AgentEvent::Message( - Message::assistant().with_system_notification( - SystemNotificationType::ThinkingMessage, - COMPACTION_THINKING_TEXT, - ) - ); - - match crate::context_mgmt::compact_messages(self, &conversation_to_compact, false).await { - Ok((compacted_conversation, summarization_usage)) => { - if let Some(session_to_store) = &session { - SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await?; - Self::update_session_metrics(session_to_store, &summarization_usage, true).await?; - } + match crate::context_mgmt::compact_messages(self, &conversation_to_compact, false).await { + Ok((compacted_conversation, summarization_usage)) => { + SessionManager::replace_conversation(&session_config.id, &compacted_conversation).await?; + Self::update_session_metrics(&session_config, &summarization_usage, true).await?; - yield AgentEvent::HistoryReplaced(compacted_conversation.clone()); + yield AgentEvent::HistoryReplaced(compacted_conversation.clone()); - yield AgentEvent::Message( - Message::assistant().with_system_notification( - SystemNotificationType::InlineMessage, - "Compaction complete", - ) - ); + yield AgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + "Compaction complete", + ) + ); - if !is_manual_compact { - let mut reply_stream = self.reply_internal(compacted_conversation, session, cancel_token).await?; - while let Some(event) = reply_stream.next().await { - yield event?; - } + compacted_conversation + } + Err(e) => { + yield AgentEvent::Message( + Message::assistant().with_text( + format!("Ran into this error trying to compact: {e}.\n\nPlease try again or create a new session") + ) + ); + return; } } - Err(e) => { - yield AgentEvent::Message(Message::assistant().with_text( - format!("Ran into this error trying to compact: {e}.\n\nPlease try again or create a new session") - )); + }; + + if !is_manual_compact { + let mut reply_stream = self.reply_internal(final_conversation, session_config, session, cancel_token).await?; + while let Some(event) = reply_stream.next().await { + yield event?; } } })) } - /// Main reply method that handles the actual agent processing async fn reply_internal( &self, conversation: Conversation, - session: Option, + session_config: SessionConfig, + session: Session, cancel_token: Option, ) -> Result>> { - let context = self.prepare_reply_context(conversation, &session).await?; + let context = self.prepare_reply_context(conversation).await?; let ReplyContext { mut conversation, mut tools, @@ -849,66 +838,22 @@ impl Agent { mut system_prompt, goose_mode, initial_messages, - config, } = context; let reply_span = tracing::Span::current(); self.reset_retry_attempts().await; - // This will need further refactoring. In the ideal world we pass the new message into - // reply and load the existing conversation. Until we get to that point, fetch the conversation - // so far and append the last (user) message that the caller already added. - if let Some(session_config) = &session { - let stored_conversation = SessionManager::get_session(&session_config.id, true) - .await? - .conversation - .ok_or_else(|| { - anyhow::anyhow!("Session {} has no conversation", session_config.id) - })?; - - match conversation.len().cmp(&stored_conversation.len()) { - std::cmp::Ordering::Equal => { - if conversation != stored_conversation { - warn!("Session messages mismatch - replacing with incoming"); - SessionManager::replace_conversation(&session_config.id, &conversation) - .await?; - } - } - std::cmp::Ordering::Greater - if conversation.len() == stored_conversation.len() + 1 => - { - let last_message = conversation.last().unwrap(); - if let Some(content) = last_message.content.first().and_then(|c| c.as_text()) { - debug!("user_message" = &content); - } - SessionManager::add_message(&session_config.id, last_message).await?; - } - _ => { - warn!( - "Unexpected session state: stored={}, incoming={}. Replacing.", - stored_conversation.len(), - conversation.len() - ); - SessionManager::replace_conversation(&session_config.id, &conversation).await?; - } + let provider = self.provider().await?; + let session_id = session_config.id.clone(); + tokio::spawn(async move { + if let Err(e) = SessionManager::maybe_update_name(&session_id, provider).await { + warn!("Failed to generate session description: {}", e); } - let provider = self.provider().await?; - let session_id = session_config.id.clone(); - tokio::spawn(async move { - if let Err(e) = SessionManager::maybe_update_name(&session_id, provider).await { - warn!("Failed to generate session description: {}", e); - } - }); - } + }); Ok(Box::pin(async_stream::try_stream! { let _ = reply_span.enter(); let mut turns_taken = 0u32; - let max_turns = session - .as_ref() - .and_then(|s| s.max_turns) - .unwrap_or_else(|| { - config.get_param("GOOSE_MAX_TURNS").unwrap_or(DEFAULT_MAX_TURNS) - }); + let max_turns = session_config.max_turns.unwrap_or(DEFAULT_MAX_TURNS); loop { if is_token_cancelled(&cancel_token) { @@ -989,11 +934,8 @@ impl Agent { } } - // Record usage for the session - if let Some(ref session_config) = &session { - if let Some(ref usage) = usage { - Self::update_session_metrics(session_config, usage, false).await?; - } + if let Some(ref usage) = usage { + Self::update_session_metrics(&session_config, usage, false).await?; } if let Some(response) = response { @@ -1078,18 +1020,17 @@ impl Agent { &permission_check_result, message_tool_response.clone(), cancel_token.clone(), - session.clone(), + &session, ).await?; let tool_futures_arc = Arc::new(Mutex::new(tool_futures)); - // Process tools requiring approval let mut tool_approval_stream = self.handle_approval_tool_requests( &permission_check_result.needs_approval, tool_futures_arc.clone(), message_tool_response.clone(), cancel_token.clone(), - session.clone(), + &session, &inspection_results, ); @@ -1136,10 +1077,8 @@ impl Agent { } if all_install_successful && !enable_extension_request_ids.is_empty() { - if let Some(ref session_config) = session { - if let Err(e) = self.save_extension_state(session_config).await { - warn!("Failed to save extension state after runtime changes: {}", e); - } + if let Err(e) = self.save_extension_state(&session_config).await { + warn!("Failed to save extension state after runtime changes: {}", e); } tools_updated = true; } @@ -1168,14 +1107,10 @@ impl Agent { match crate::context_mgmt::compact_messages(self, &conversation, true).await { Ok((compacted_conversation, usage)) => { - if let Some(session_to_store) = &session { - SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await?; - Self::update_session_metrics(session_to_store, &usage, true).await?; - } - + SessionManager::replace_conversation(&session_config.id, &compacted_conversation).await?; + Self::update_session_metrics(&session_config, &usage, true).await?; conversation = compacted_conversation; did_recovery_compact_this_iteration = true; - yield AgentEvent::HistoryReplaced(conversation.clone()); continue; } @@ -1221,7 +1156,7 @@ impl Agent { } else if did_recovery_compact_this_iteration { // Avoid setting exit_chat; continue from last user message in the conversation } else { - match self.handle_retry_logic(&mut conversation, &session, &initial_messages).await { + match self.handle_retry_logic(&mut conversation, &session_config, &initial_messages).await { Ok(should_retry) => { if should_retry { info!("Retry logic triggered, restarting agent loop"); @@ -1242,10 +1177,8 @@ impl Agent { } } - if let Some(session_config) = &session { - for msg in &messages_to_add { - SessionManager::add_message(&session_config.id, msg).await?; - } + for msg in &messages_to_add { + SessionManager::add_message(&session_config.id, msg).await?; } conversation.extend(messages_to_add); if exit_chat { @@ -1257,17 +1190,6 @@ impl Agent { })) } - fn determine_goose_mode(session: Option<&SessionConfig>, config: &Config) -> GooseMode { - let mode = session.and_then(|s| s.execution_mode.as_deref()); - - match mode { - Some("foreground") => GooseMode::Chat, - Some("background") => GooseMode::Auto, - _ => config.get_goose_mode().unwrap_or(GooseMode::Auto), - } - } - - /// Extend the system prompt with one line of additional instruction pub async fn extend_system_prompt(&self, instruction: String) { let mut prompt_manager = self.prompt_manager.lock().await; prompt_manager.add_system_prompt_extra(instruction); diff --git a/crates/goose/src/agents/platform_tools.rs b/crates/goose/src/agents/platform_tools.rs index 57bde5eaeb3..e0877bdb717 100644 --- a/crates/goose/src/agents/platform_tools.rs +++ b/crates/goose/src/agents/platform_tools.rs @@ -33,7 +33,6 @@ pub fn manage_schedule_tool() -> Tool { "job_id": {"type": "string", "description": "Job identifier for operations on existing jobs"}, "recipe_path": {"type": "string", "description": "Path to recipe file for create action"}, "cron_expression": {"type": "string", "description": "A cron expression for create action. Supports both 5-field (minute hour day month weekday) and 6-field (second minute hour day month weekday) formats. 5-field expressions are automatically converted to 6-field by prepending '0' for seconds."}, - "execution_mode": {"type": "string", "description": "Execution mode for create action: 'foreground' or 'background'", "enum": ["foreground", "background"], "default": "background"}, "limit": {"type": "integer", "description": "Limit for sessions list", "default": 50}, "session_id": {"type": "string", "description": "Session identifier for session_content action"} } diff --git a/crates/goose/src/agents/retry.rs b/crates/goose/src/agents/retry.rs index ffa53b65507..b39dbcfd2cf 100644 --- a/crates/goose/src/agents/retry.rs +++ b/crates/goose/src/agents/retry.rs @@ -108,18 +108,13 @@ impl RetryManager { } } - /// Handle retry logic for the agent reply loop pub async fn handle_retry_logic( &self, messages: &mut Conversation, - session: &Option, + session_config: &SessionConfig, initial_messages: &[Message], final_output_tool: &Arc>>, ) -> Result { - let Some(session_config) = session else { - return Ok(RetryResult::Skipped); - }; - let Some(retry_config) = &session_config.retry_config else { return Ok(RetryResult::Skipped); }; diff --git a/crates/goose/src/agents/schedule_tool.rs b/crates/goose/src/agents/schedule_tool.rs index 3fb98a9de56..3651c9bc7c9 100644 --- a/crates/goose/src/agents/schedule_tool.rs +++ b/crates/goose/src/agents/schedule_tool.rs @@ -186,7 +186,6 @@ impl Agent { paused: false, current_session_id: None, process_start_time: None, - execution_mode: Some(execution_mode.to_string()), }; match scheduler.add_scheduled_job(job).await { diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index 47ee99d0887..e02cf6427c4 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -1,8 +1,6 @@ +use crate::session::session_manager::SessionType; use crate::{ - agents::{ - extension::PlatformExtensionContext, subagent_task_config::TaskConfig, Agent, AgentEvent, - SessionConfig, - }, + agents::{subagent_task_config::TaskConfig, AgentEvent, SessionConfig}, conversation::{message::Message, Conversation}, execution::manager::AgentManager, session::SessionManager, @@ -10,8 +8,8 @@ use crate::{ use anyhow::{anyhow, Result}; use futures::StreamExt; use rmcp::model::{ErrorCode, ErrorData}; +use std::future::Future; use std::pin::Pin; -use std::{future::Future, sync::Arc}; use tracing::debug; /// Standalone function to run a complete subagent task with output options @@ -104,34 +102,18 @@ fn get_agent_messages( .map_err(|e| anyhow!("Failed to create AgentManager: {}", e))?; let parent_session_id = task_config.parent_session_id; let working_dir = task_config.parent_working_dir; - let (agent, session_id) = match parent_session_id { - Some(parent_session_id) => { - let session = SessionManager::create_session( - working_dir.clone(), - format!("Subagent task for: {}", parent_session_id), - ) - .await - .map_err(|e| anyhow!("Failed to create a session for sub agent: {}", e))?; + let session = SessionManager::create_session( + working_dir.clone(), + format!("Subagent task for: {}", parent_session_id), + SessionType::SubAgent, + ) + .await + .map_err(|e| anyhow!("Failed to create a session for sub agent: {}", e))?; - let agent = agent_manager - .get_or_create_agent(session.id.clone()) - .await - .map_err(|e| anyhow!("Failed to get sub agent session file path: {}", e))?; - (agent, Some(session.id)) - } - None => { - let agent = Arc::new(Agent::new()); - agent - .extension_manager - .set_context(PlatformExtensionContext { - session_id: None, - extension_manager: Some(Arc::downgrade(&agent.extension_manager)), - tool_route_manager: Some(Arc::downgrade(&agent.tool_route_manager)), - }) - .await; - (agent, None) - } - }; + let agent = agent_manager + .get_or_create_agent(session.id.clone()) + .await + .map_err(|e| anyhow!("Failed to get sub agent session file path: {}", e))?; agent .update_provider(task_config.provider) @@ -148,28 +130,18 @@ fn get_agent_messages( } } - let mut conversation = - Conversation::new_unvalidated( - vec![Message::user().with_text(text_instruction.clone())], - ); - let session_config = if let Some(session_id) = session_id { - Some(SessionConfig { - id: session_id, - working_dir, - schedule_id: None, - execution_mode: None, - max_turns: task_config.max_turns.map(|v| v as u32), - retry_config: None, - }) - } else { - None + let user_message = Message::user().with_text(text_instruction); + let mut conversation = Conversation::new_unvalidated(vec![user_message.clone()]); + + let session_config = SessionConfig { + id: session.id.clone(), + schedule_id: None, + max_turns: task_config.max_turns.map(|v| v as u32), + retry_config: None, }; - let session_id = session_config.as_ref().map(|s| s.id.clone()); - let mut stream = crate::session_context::with_session_id(session_id, async { - agent - .reply(conversation.clone(), session_config, None) - .await + let mut stream = crate::session_context::with_session_id(Some(session.id.clone()), async { + agent.reply(user_message, session_config, None).await }) .await .map_err(|e| anyhow!("Failed to get reply from agent: {}", e))?; diff --git a/crates/goose/src/agents/subagent_task_config.rs b/crates/goose/src/agents/subagent_task_config.rs index d3375c92be3..01c955d0c01 100644 --- a/crates/goose/src/agents/subagent_task_config.rs +++ b/crates/goose/src/agents/subagent_task_config.rs @@ -2,7 +2,7 @@ use crate::agents::ExtensionConfig; use crate::providers::base::Provider; use std::env; use std::fmt; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; /// Default maximum number of turns for task execution @@ -15,7 +15,7 @@ pub const GOOSE_SUBAGENT_MAX_TURNS_ENV_VAR: &str = "GOOSE_SUBAGENT_MAX_TURNS"; #[derive(Clone)] pub struct TaskConfig { pub provider: Arc, - pub parent_session_id: Option, + pub parent_session_id: String, pub parent_working_dir: PathBuf, pub extensions: Vec, pub max_turns: Option, @@ -34,17 +34,16 @@ impl fmt::Debug for TaskConfig { } impl TaskConfig { - /// Create a new TaskConfig with all required dependencies pub fn new( provider: Arc, - parent_session_id: Option, - parent_working_dir: PathBuf, + parent_session_id: &str, + parent_working_dir: &Path, extensions: Vec, ) -> Self { Self { provider, - parent_session_id, - parent_working_dir, + parent_session_id: parent_session_id.to_owned(), + parent_working_dir: parent_working_dir.to_owned(), extensions, max_turns: Some( env::var(GOOSE_SUBAGENT_MAX_TURNS_ENV_VAR) diff --git a/crates/goose/src/agents/tool_execution.rs b/crates/goose/src/agents/tool_execution.rs index 64ab6af3d99..87c1986a7f3 100644 --- a/crates/goose/src/agents/tool_execution.rs +++ b/crates/goose/src/agents/tool_execution.rs @@ -29,8 +29,9 @@ impl From>> for ToolCallResult { } use super::agent::{tool_stream, ToolStream}; -use crate::agents::{Agent, SessionConfig}; +use crate::agents::Agent; use crate::conversation::message::{Message, ToolRequest}; +use crate::session::Session; use crate::tool_inspection::get_security_finding_id_from_results; pub const DECLINED_RESPONSE: &str = "The user has declined to run this tool. \ @@ -53,7 +54,7 @@ impl Agent { tool_futures: Arc>>, message_tool_response: Arc>, cancellation_token: Option, - session: Option, + session: &'a Session, inspection_results: &'a [crate::tool_inspection::InspectionResult], ) -> BoxStream<'a, anyhow::Result> { try_stream! { @@ -93,7 +94,7 @@ impl Agent { } if confirmation.permission == Permission::AllowOnce || confirmation.permission == Permission::AlwaysAllow { - let (req_id, tool_result) = self.dispatch_tool_call(tool_call.clone(), request.id.clone(), cancellation_token.clone(), session.clone()).await; + let (req_id, tool_result) = self.dispatch_tool_call(tool_call.clone(), request.id.clone(), cancellation_token.clone(), session).await; let mut futures = tool_futures.lock().await; futures.push((req_id, match tool_result { diff --git a/crates/goose/src/agents/types.rs b/crates/goose/src/agents/types.rs index 027560179b2..0f2902bf9e1 100644 --- a/crates/goose/src/agents/types.rs +++ b/crates/goose/src/agents/types.rs @@ -2,7 +2,6 @@ use crate::mcp_utils::ToolResult; use crate::providers::base::Provider; use rmcp::model::{Content, Tool}; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; use std::sync::Arc; use tokio::sync::{mpsc, Mutex}; use utoipa::ToSchema; @@ -84,14 +83,10 @@ pub struct FrontendTool { /// Session configuration for an agent #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionConfig { - /// Unique identifier for the session + /// Identifier of the underlying Session pub id: String, - /// Working directory for the session - pub working_dir: PathBuf, /// ID of the schedule that triggered this session, if any pub schedule_id: Option, - /// Execution mode for scheduled jobs: "foreground" or "background" - pub execution_mode: Option, /// Maximum number of turns (iterations) allowed without user input pub max_turns: Option, /// Retry configuration for automated validation and recovery diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index 57e43a706a7..e0ac4368aa8 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -145,11 +145,10 @@ pub async fn check_if_compaction_needed( agent: &Agent, conversation: &Conversation, threshold_override: Option, - session_metadata: Option<&crate::session::Session>, + session: &crate::session::Session, ) -> Result { let messages = conversation.messages(); let config = Config::global(); - // TODO(Douwe): check the default here; it seems to reset to 0.3 sometimes let threshold = threshold_override.unwrap_or_else(|| { config .get_param::("GOOSE_AUTO_COMPACT_THRESHOLD") @@ -159,7 +158,7 @@ pub async fn check_if_compaction_needed( let provider = agent.provider().await?; let context_limit = provider.get_model_config().context_limit(); - let (current_tokens, token_source) = match session_metadata.and_then(|m| m.total_tokens) { + let (current_tokens, token_source) = match session.total_tokens { Some(tokens) => (tokens as usize, "session metadata"), None => { let token_counter = create_token_counter() diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 45eaeb13fa3..84ce29560a2 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -21,6 +21,7 @@ use crate::providers::base::Provider as GooseProvider; // Alias to avoid conflic use crate::providers::create; use crate::recipe::Recipe; use crate::scheduler_trait::SchedulerTrait; +use crate::session::session_manager::SessionType; use crate::session::{Session, SessionManager}; // Track running tasks with their abort handles @@ -152,8 +153,6 @@ pub struct ScheduledJob { pub current_session_id: Option, #[serde(default)] pub process_start_time: Option>, - #[serde(default)] - pub execution_mode: Option, // "foreground" or "background" } async fn persist_jobs_from_arc( @@ -1160,8 +1159,6 @@ async fn run_scheduled_job_internal( }); } tracing::info!("Agent configured with provider for job '{}'", job.id); - let execution_mode = job.execution_mode.as_deref().unwrap_or("background"); - tracing::info!("Job '{}' running in {} mode", job.id, execution_mode); let current_dir = match std::env::current_dir() { Ok(cd) => cd, @@ -1173,10 +1170,10 @@ async fn run_scheduled_job_internal( } }; - // Create session upfront let session = match SessionManager::create_session( current_dir.clone(), format!("Scheduled job: {}", job.id), + SessionType::Scheduled, ) .await { @@ -1204,23 +1201,19 @@ async fn run_scheduled_job_internal( .or(recipe.instructions.as_ref()) .unwrap(); - let mut conversation = - Conversation::new_unvalidated(vec![Message::user().with_text(prompt_text.clone())]); + let user_message = Message::user().with_text(prompt_text); + let mut conversation = Conversation::new_unvalidated(vec![user_message.clone()]); let session_config = SessionConfig { id: session.id.clone(), - working_dir: current_dir.clone(), schedule_id: Some(job.id.clone()), - execution_mode: job.execution_mode.clone(), max_turns: None, retry_config: None, }; let session_id = Some(session_config.id.clone()); match crate::session_context::with_session_id(session_id, async { - agent - .reply(conversation.clone(), Some(session_config.clone()), None) - .await + agent.reply(user_message, session_config, None).await }) .await { @@ -1455,7 +1448,6 @@ mod tests { paused: false, current_session_id: None, process_start_time: None, - execution_mode: Some("background".to_string()), // Default for test }; let mock_model_config = ModelConfig::new_or_fail("test_model"); diff --git a/crates/goose/src/session/mod.rs b/crates/goose/src/session/mod.rs index e15579bb83c..b8b7c8d5a28 100644 --- a/crates/goose/src/session/mod.rs +++ b/crates/goose/src/session/mod.rs @@ -6,4 +6,4 @@ pub mod session_manager; pub use diagnostics::generate_diagnostics; pub use extension_data::{EnabledExtensionsState, ExtensionData, ExtensionState, TodoState}; -pub use session_manager::{Session, SessionInsights, SessionManager}; +pub use session_manager::{Session, SessionInsights, SessionManager, SessionType}; diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index ce97c8ead3f..2f960cd8497 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -18,7 +18,47 @@ use tokio::sync::OnceCell; use tracing::{info, warn}; use utoipa::ToSchema; -const CURRENT_SCHEMA_VERSION: i32 = 4; +const CURRENT_SCHEMA_VERSION: i32 = 5; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SessionType { + User, + Scheduled, + SubAgent, + Hidden, +} + +impl Default for SessionType { + fn default() -> Self { + Self::User + } +} + +impl std::fmt::Display for SessionType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SessionType::User => write!(f, "user"), + SessionType::SubAgent => write!(f, "sub_agent"), + SessionType::Hidden => write!(f, "hidden"), + SessionType::Scheduled => write!(f, "scheduled"), + } + } +} + +impl std::str::FromStr for SessionType { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "user" => Ok(SessionType::User), + "sub_agent" => Ok(SessionType::SubAgent), + "hidden" => Ok(SessionType::Hidden), + "scheduled" => Ok(SessionType::Scheduled), + _ => Err(anyhow::anyhow!("Invalid session type: {}", s)), + } + } +} static SESSION_STORAGE: OnceCell> = OnceCell::const_new(); @@ -27,11 +67,12 @@ pub struct Session { pub id: String, #[schema(value_type = String)] pub working_dir: PathBuf, - // Allow importing session exports from before 'description' was renamed to 'name' #[serde(alias = "description")] pub name: String, #[serde(default)] pub user_set_name: bool, + #[serde(default)] + pub session_type: SessionType, pub created_at: DateTime, pub updated_at: DateTime, pub extension_data: ExtensionData, @@ -52,6 +93,7 @@ pub struct SessionUpdateBuilder { session_id: String, name: Option, user_set_name: Option, + session_type: Option, working_dir: Option, extension_data: Option, total_tokens: Option>, @@ -78,6 +120,7 @@ impl SessionUpdateBuilder { session_id, name: None, user_set_name: None, + session_type: None, working_dir: None, extension_data: None, total_tokens: None, @@ -110,6 +153,11 @@ impl SessionUpdateBuilder { self } + pub fn session_type(mut self, session_type: SessionType) -> Self { + self.session_type = Some(session_type); + self + } + pub fn working_dir(mut self, working_dir: PathBuf) -> Self { self.working_dir = Some(working_dir); self @@ -183,10 +231,14 @@ impl SessionManager { .map(Arc::clone) } - pub async fn create_session(working_dir: PathBuf, name: String) -> Result { + pub async fn create_session( + working_dir: PathBuf, + name: String, + session_type: SessionType, + ) -> Result { Self::instance() .await? - .create_session(working_dir, name) + .create_session(working_dir, name, session_type) .await } @@ -306,6 +358,7 @@ impl Default for Session { working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), name: String::new(), user_set_name: false, + session_type: SessionType::default(), created_at: Default::default(), updated_at: Default::default(), extension_data: ExtensionData::default(), @@ -353,11 +406,17 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { let user_set_name = row.try_get("user_set_name").unwrap_or(false); + let session_type_str: String = row + .try_get("session_type") + .unwrap_or_else(|_| "user".to_string()); + let session_type = session_type_str.parse().unwrap_or_default(); + Ok(Session { id: row.try_get("id")?, working_dir: PathBuf::from(row.try_get::("working_dir")?), name, user_set_name, + session_type, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, extension_data: serde_json::from_str(&row.try_get::("extension_data")?) @@ -446,6 +505,7 @@ impl SessionStorage { name TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', user_set_name BOOLEAN DEFAULT FALSE, + session_type TEXT NOT NULL DEFAULT 'user', working_dir TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -491,6 +551,9 @@ impl SessionStorage { sqlx::query("CREATE INDEX idx_sessions_updated ON sessions(updated_at DESC)") .execute(&pool) .await?; + sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)") + .execute(&pool) + .await?; Ok(Self { pool }) } @@ -553,31 +616,32 @@ impl SessionStorage { sqlx::query( r#" INSERT INTO sessions ( - id, name, user_set_name, working_dir, created_at, updated_at, extension_data, + id, name, user_set_name, session_type, working_dir, created_at, updated_at, extension_data, total_tokens, input_tokens, output_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, schedule_id, recipe_json, user_recipe_values_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, ) - .bind(&session.id) - .bind(&session.name) - .bind(session.user_set_name) - .bind(session.working_dir.to_string_lossy().as_ref()) - .bind(session.created_at) - .bind(session.updated_at) - .bind(serde_json::to_string(&session.extension_data)?) - .bind(session.total_tokens) - .bind(session.input_tokens) - .bind(session.output_tokens) - .bind(session.accumulated_total_tokens) - .bind(session.accumulated_input_tokens) - .bind(session.accumulated_output_tokens) - .bind(&session.schedule_id) - .bind(recipe_json) - .bind(user_recipe_values_json) - .execute(&self.pool) - .await?; + .bind(&session.id) + .bind(&session.name) + .bind(session.user_set_name) + .bind(session.session_type.to_string()) + .bind(session.working_dir.to_string_lossy().as_ref()) + .bind(session.created_at) + .bind(session.updated_at) + .bind(serde_json::to_string(&session.extension_data)?) + .bind(session.total_tokens) + .bind(session.input_tokens) + .bind(session.output_tokens) + .bind(session.accumulated_total_tokens) + .bind(session.accumulated_input_tokens) + .bind(session.accumulated_output_tokens) + .bind(&session.schedule_id) + .bind(recipe_json) + .bind(user_recipe_values_json) + .execute(&self.pool) + .await?; if let Some(conversation) = &session.conversation { self.replace_conversation(&session.id, conversation).await?; @@ -687,6 +751,19 @@ impl SessionStorage { .execute(&self.pool) .await?; } + 5 => { + sqlx::query( + r#" + ALTER TABLE sessions ADD COLUMN session_type TEXT NOT NULL DEFAULT 'user' + "#, + ) + .execute(&self.pool) + .await?; + + sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)") + .execute(&self.pool) + .await?; + } _ => { anyhow::bail!("Unknown migration version: {}", version); } @@ -695,11 +772,16 @@ impl SessionStorage { Ok(()) } - async fn create_session(&self, working_dir: PathBuf, name: String) -> Result { + async fn create_session( + &self, + working_dir: PathBuf, + name: String, + session_type: SessionType, + ) -> Result { let today = chrono::Utc::now().format("%Y%m%d").to_string(); Ok(sqlx::query_as( r#" - INSERT INTO sessions (id, name, user_set_name, working_dir, extension_data) + INSERT INTO sessions (id, name, user_set_name, session_type, working_dir, extension_data) VALUES ( ? || '_' || CAST(COALESCE(( SELECT MAX(CAST(SUBSTR(id, 10) AS INTEGER)) @@ -709,23 +791,25 @@ impl SessionStorage { ?, FALSE, ?, + ?, '{}' ) RETURNING * "#, ) - .bind(&today) - .bind(&today) - .bind(&name) - .bind(working_dir.to_string_lossy().as_ref()) - .fetch_one(&self.pool) - .await?) + .bind(&today) + .bind(&today) + .bind(&name) + .bind(session_type.to_string()) + .bind(working_dir.to_string_lossy().as_ref()) + .fetch_one(&self.pool) + .await?) } async fn get_session(&self, id: &str, include_messages: bool) -> Result { let mut session = sqlx::query_as::<_, Session>( r#" - SELECT id, working_dir, name, description, user_set_name, created_at, updated_at, extension_data, + SELECT id, working_dir, name, description, user_set_name, session_type, created_at, updated_at, extension_data, total_tokens, input_tokens, output_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, schedule_id, recipe_json, user_recipe_values_json @@ -733,10 +817,10 @@ impl SessionStorage { WHERE id = ? "#, ) - .bind(id) - .fetch_optional(&self.pool) - .await? - .ok_or_else(|| anyhow::anyhow!("Session not found"))?; + .bind(id) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| anyhow::anyhow!("Session not found"))?; if include_messages { let conv = self.get_conversation(&session.id).await?; @@ -773,6 +857,7 @@ impl SessionStorage { add_update!(builder.name, "name"); add_update!(builder.user_set_name, "user_set_name"); + add_update!(builder.session_type, "session_type"); add_update!(builder.working_dir, "working_dir"); add_update!(builder.extension_data, "extension_data"); add_update!(builder.total_tokens, "total_tokens"); @@ -803,6 +888,9 @@ impl SessionStorage { if let Some(user_set_name) = builder.user_set_name { q = q.bind(user_set_name); } + if let Some(session_type) = builder.session_type { + q = q.bind(session_type.to_string()); + } if let Some(wd) = builder.working_dir { q = q.bind(wd.to_string_lossy().to_string()); } @@ -872,7 +960,6 @@ impl SessionStorage { let mut message = Message::new(role, created_timestamp, content); message.metadata = metadata; - // TODO(Douwe): make id required message = message.with_id(format!("msg_{}_{}", session_id, idx)); messages.push(message); } @@ -942,20 +1029,21 @@ impl SessionStorage { async fn list_sessions(&self) -> Result> { sqlx::query_as::<_, Session>( r#" - SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.created_at, s.updated_at, s.extension_data, + SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.session_type, s.created_at, s.updated_at, s.extension_data, s.total_tokens, s.input_tokens, s.output_tokens, s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens, s.schedule_id, s.recipe_json, s.user_recipe_values_json, COUNT(m.id) as message_count FROM sessions s INNER JOIN messages m ON s.id = m.session_id + WHERE s.session_type = 'user' OR s.session_type = 'scheduled' GROUP BY s.id ORDER BY s.updated_at DESC "#, ) - .fetch_all(&self.pool) - .await - .map_err(Into::into) + .fetch_all(&self.pool) + .await + .map_err(Into::into) } async fn delete_session(&self, session_id: &str) -> Result<()> { @@ -1008,7 +1096,11 @@ impl SessionStorage { let import: Session = serde_json::from_str(json)?; let session = self - .create_session(import.working_dir.clone(), import.name.clone()) + .create_session( + import.working_dir.clone(), + import.name.clone(), + import.session_type, + ) .await?; let mut builder = SessionUpdateBuilder::new(session.id.clone()) @@ -1084,7 +1176,7 @@ mod tests { let description = format!("Test session {}", i); let session = session_storage - .create_session(working_dir.clone(), description) + .create_session(working_dir.clone(), description, SessionType::User) .await .unwrap(); @@ -1176,7 +1268,11 @@ mod tests { let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap()); let original = storage - .create_session(PathBuf::from("/tmp/test"), DESCRIPTION.to_string()) + .create_session( + PathBuf::from("/tmp/test"), + DESCRIPTION.to_string(), + SessionType::User, + ) .await .unwrap(); diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index 77e7359f698..7478119027f 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -4,1423 +4,497 @@ use anyhow::Result; use futures::StreamExt; use goose::agents::{Agent, AgentEvent}; use goose::config::extensions::{set_extension, ExtensionEntry}; -use goose::conversation::message::Message; -use goose::conversation::Conversation; -use goose::model::ModelConfig; -use goose::providers::base::Provider; -use goose::providers::{ - anthropic::AnthropicProvider, azure::AzureProvider, bedrock::BedrockProvider, - databricks::DatabricksProvider, gcpvertexai::GcpVertexAIProvider, google::GoogleProvider, - ollama::OllamaProvider, openai::OpenAiProvider, openrouter::OpenRouterProvider, - xai::XaiProvider, -}; - -#[derive(Debug, PartialEq)] -enum ProviderType { - Azure, - OpenAi, - #[allow(dead_code)] - Anthropic, - Bedrock, - Databricks, - GcpVertexAI, - Google, - Ollama, - OpenRouter, - Xai, -} -impl ProviderType { - fn required_env(&self) -> &'static [&'static str] { - match self { - ProviderType::Azure => &[ - "AZURE_OPENAI_API_KEY", - "AZURE_OPENAI_ENDPOINT", - "AZURE_OPENAI_DEPLOYMENT_NAME", - ], - ProviderType::OpenAi => &["OPENAI_API_KEY"], - ProviderType::Anthropic => &["ANTHROPIC_API_KEY"], - ProviderType::Bedrock => &["AWS_PROFILE"], - ProviderType::Databricks => &["DATABRICKS_HOST"], - ProviderType::Google => &["GOOGLE_API_KEY"], - ProviderType::Ollama => &[], - ProviderType::OpenRouter => &["OPENROUTER_API_KEY"], - ProviderType::GcpVertexAI => &["GCP_PROJECT_ID", "GCP_LOCATION"], - ProviderType::Xai => &["XAI_API_KEY"], +#[cfg(test)] +mod tests { + use super::*; + + #[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::Session; + use std::sync::Arc; + + struct MockScheduler { + jobs: tokio::sync::Mutex>, } - } - fn pre_check(&self) -> Result<()> { - match self { - ProviderType::Ollama => { - // Check if the `ollama ls` CLI command works - use std::process::Command; - let output = Command::new("ollama").arg("ls").output(); - if let Ok(output) = output { - if output.status.success() { - return Ok(()); // CLI is running - } + impl MockScheduler { + fn new() -> Self { + Self { + jobs: tokio::sync::Mutex::new(Vec::new()), } - println!("Skipping Ollama tests - `ollama ls` command not found or failed"); - Err(anyhow::anyhow!("Ollama CLI is not running")) } - _ => Ok(()), // Other providers don't need special pre-checks } - } - async fn create_provider(&self, model_config: ModelConfig) -> Result> { - Ok(match self { - ProviderType::Azure => Arc::new(AzureProvider::from_env(model_config).await?), - ProviderType::OpenAi => Arc::new(OpenAiProvider::from_env(model_config).await?), - ProviderType::Anthropic => Arc::new(AnthropicProvider::from_env(model_config).await?), - ProviderType::Bedrock => Arc::new(BedrockProvider::from_env(model_config).await?), - ProviderType::Databricks => Arc::new(DatabricksProvider::from_env(model_config).await?), - ProviderType::GcpVertexAI => { - Arc::new(GcpVertexAIProvider::from_env(model_config).await?) + #[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(()) } - ProviderType::Google => Arc::new(GoogleProvider::from_env(model_config).await?), - ProviderType::Ollama => Arc::new(OllamaProvider::from_env(model_config).await?), - ProviderType::OpenRouter => Arc::new(OpenRouterProvider::from_env(model_config).await?), - ProviderType::Xai => Arc::new(XaiProvider::from_env(model_config).await?), - }) - } -} -pub fn check_required_env_vars(required_vars: &[&str]) -> Result<()> { - let missing_vars: Vec<&str> = required_vars - .iter() - .filter(|&&var| std::env::var(var).is_err()) - .cloned() - .collect(); - - if !missing_vars.is_empty() { - println!( - "Skipping tests. Missing environment variables: {:?}", - missing_vars - ); - return Err(anyhow::anyhow!("Required environment variables not set")); - } - Ok(()) -} + async fn list_scheduled_jobs(&self) -> Result, SchedulerError> { + let jobs = self.jobs.lock().await; + Ok(jobs.clone()) + } -async fn run_truncate_test( - provider_type: ProviderType, - model: &str, - context_window: usize, -) -> Result<()> { - let model_config = ModelConfig::new(model) - .unwrap() - .with_context_limit(Some(context_window)) - .with_temperature(Some(0.0)); - let provider = provider_type.create_provider(model_config).await?; - - let agent = Agent::new(); - agent.update_provider(provider).await?; - let repeat_count = context_window + 10_000; - let large_message_content = "hello ".repeat(repeat_count); - let conversation = Conversation::new(vec![ - Message::user().with_text("hi there. what is 2 + 2?"), - Message::assistant().with_text("hey! I think it's 4."), - Message::user().with_text(&large_message_content), - Message::assistant().with_text("heyy!!"), - Message::user().with_text("what's the meaning of life?"), - Message::assistant().with_text("the meaning of life is 42"), - Message::user().with_text( - "did I ask you what's 2+2 in this message history? just respond with 'yes' or 'no'", - ), - ]) - .unwrap(); - - let reply_stream = agent.reply(conversation, None, None).await?; - tokio::pin!(reply_stream); - - let mut responses = Vec::new(); - while let Some(response_result) = reply_stream.next().await { - match response_result { - Ok(AgentEvent::Message(response)) => responses.push(response), - Ok(AgentEvent::McpNotification(n)) => { - println!("MCP Notification: {n:?}"); + 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())) + } } - Ok(AgentEvent::ModelChange { .. }) => { - // Model change events are informational, just continue + + async fn pause_schedule(&self, _id: &str) -> Result<(), SchedulerError> { + Ok(()) } - Ok(AgentEvent::HistoryReplaced(_updated_conversation)) => { - // Should update the conversation here, but we're not reading it + + async fn unpause_schedule(&self, _id: &str) -> Result<(), SchedulerError> { + Ok(()) } - Err(e) => { - println!("Error: {:?}", e); - return Err(e); + + async fn run_now(&self, _id: &str) -> Result { + Ok("test_session_123".to_string()) } - } - } - println!("Responses: {responses:?}\n"); - - // Ollama and OpenRouter truncate by default even when the context window is exceeded - // We don't have control over the truncation behavior in these providers. - // Skip the strict assertions for these providers. - if provider_type == ProviderType::Ollama || provider_type == ProviderType::OpenRouter { - println!( - "WARNING: Skipping test for {:?} because it truncates by default when the context window is exceeded", - provider_type - ); - return Ok(()); - } + async fn sessions( + &self, + _sched_id: &str, + _limit: usize, + ) -> Result, SchedulerError> { + Ok(vec![]) + } - assert_eq!(responses.len(), 1); + async fn update_schedule( + &self, + _sched_id: &str, + _new_cron: String, + ) -> Result<(), SchedulerError> { + Ok(()) + } - assert_eq!(responses[0].content.len(), 1); + async fn kill_running_job(&self, _sched_id: &str) -> Result<(), SchedulerError> { + Ok(()) + } - match responses[0].content[0] { - goose::conversation::message::MessageContent::Text(ref text_content) => { - assert!(text_content.text.to_lowercase().contains("no")); - assert!(!text_content.text.to_lowercase().contains("yes")); - } - _ => { - panic!( - "Unexpected message content type: {:?}", - responses[0].content[0] - ); + async fn get_running_job_info( + &self, + _sched_id: &str, + ) -> Result)>, SchedulerError> { + Ok(None) + } } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - #[derive(Debug)] - struct TestConfig { - provider_type: ProviderType, - model: &'static str, - context_window: usize, - } - - async fn run_test_with_config(config: TestConfig) -> Result<()> { - println!("Starting test for {config:?}"); - - // Check for required environment variables - if check_required_env_vars(config.provider_type.required_env()).is_err() { - return Ok(()); // Skip test if env vars are missing + #[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 + .clone() + .unwrap_or_default() + .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 + .clone() + .unwrap_or_default() + .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 = 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())); + } + } + } } - // Run provider-specific pre-checks - if config.provider_type.pre_check().is_err() { - return Ok(()); // Skip test if pre-check fails + #[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")); + } + } } - - // Run the truncate test - run_truncate_test(config.provider_type, config.model, config.context_window).await - } - - #[tokio::test] - async fn test_agent_with_openai() -> Result<()> { - run_test_with_config(TestConfig { - provider_type: ProviderType::OpenAi, - model: "o3-mini-low", - context_window: 200_000, - }) - .await - } - - #[tokio::test] - async fn test_agent_with_anthropic() -> Result<()> { - run_test_with_config(TestConfig { - provider_type: ProviderType::Anthropic, - model: "claude-sonnet-4", - context_window: 200_000, - }) - .await } - #[tokio::test] - async fn test_agent_with_azure() -> Result<()> { - run_test_with_config(TestConfig { - provider_type: ProviderType::Azure, - model: "gpt-4o-mini", - context_window: 128_000, - }) - .await - } - - #[tokio::test] - async fn test_agent_with_bedrock() -> Result<()> { - run_test_with_config(TestConfig { - provider_type: ProviderType::Bedrock, - model: "anthropic.claude-sonnet-4-20250514:0", - context_window: 200_000, - }) - .await - } - - #[tokio::test] - async fn test_agent_with_databricks() -> Result<()> { - run_test_with_config(TestConfig { - provider_type: ProviderType::Databricks, - model: "databricks-meta-llama-3-3-70b-instruct", - context_window: 128_000, - }) - .await - } - - #[tokio::test] - async fn test_agent_with_databricks_bedrock() -> Result<()> { - run_test_with_config(TestConfig { - provider_type: ProviderType::Databricks, - model: "claude-sonnet-4", - context_window: 200_000, - }) - .await - } - - #[tokio::test] - async fn test_agent_with_databricks_openai() -> Result<()> { - run_test_with_config(TestConfig { - provider_type: ProviderType::Databricks, - model: "gpt-4o-mini", - context_window: 128_000, - }) - .await - } - - #[tokio::test] - async fn test_agent_with_google() -> Result<()> { - run_test_with_config(TestConfig { - provider_type: ProviderType::Google, - model: "gemini-2.0-flash-exp", - context_window: 1_200_000, - }) - .await - } + #[cfg(test)] + mod retry_tests { + use super::*; + use goose::agents::types::{RetryConfig, SuccessCheck}; - #[tokio::test] - async fn test_agent_with_openrouter() -> Result<()> { - run_test_with_config(TestConfig { - provider_type: ProviderType::OpenRouter, - model: "deepseek/deepseek-r1", - context_window: 130_000, - }) - .await - } + #[tokio::test] + async fn test_retry_success_check_execution() -> Result<()> { + use goose::agents::retry::execute_success_checks; - #[tokio::test] - async fn test_agent_with_ollama() -> Result<()> { - run_test_with_config(TestConfig { - provider_type: ProviderType::Ollama, - model: "llama3.2", - context_window: 128_000, - }) - .await - } + let retry_config = RetryConfig { + max_retries: 3, + checks: vec![], + on_failure: None, + timeout_seconds: Some(30), + on_failure_timeout_seconds: Some(60), + }; - #[tokio::test] - async fn test_agent_with_gcpvertexai() -> Result<()> { - run_test_with_config(TestConfig { - provider_type: ProviderType::GcpVertexAI, - model: "claude-sonnet-4-20250514", - context_window: 200_000, - }) - .await - } + let success_checks = vec![SuccessCheck::Shell { + command: "echo 'test'".to_string(), + }]; - #[tokio::test] - async fn test_agent_with_xai() -> Result<()> { - run_test_with_config(TestConfig { - provider_type: ProviderType::Xai, - model: "grok-3", - context_window: 9_000, - }) - .await - } -} + let result = execute_success_checks(&success_checks, &retry_config).await; + assert!(result.is_ok(), "Success check should pass"); + assert!(result.unwrap(), "Command should succeed"); -#[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::Session; - use std::sync::Arc; - - struct MockScheduler { - jobs: tokio::sync::Mutex>, - } + let fail_checks = vec![SuccessCheck::Shell { + command: "false".to_string(), + }]; - impl MockScheduler { - fn new() -> Self { - Self { - jobs: tokio::sync::Mutex::new(Vec::new()), - } - } - } + let result = execute_success_checks(&fail_checks, &retry_config).await; + assert!(result.is_ok(), "Success check execution should not error"); + assert!(!result.unwrap(), "Command should fail"); - #[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, SchedulerError> { - let jobs = self.jobs.lock().await; - Ok(jobs.clone()) - } + #[tokio::test] + async fn test_retry_logic_with_validation_errors() -> Result<()> { + let invalid_retry_config = RetryConfig { + max_retries: 0, + checks: vec![], + on_failure: None, + timeout_seconds: Some(0), + on_failure_timeout_seconds: None, + }; - 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())) - } - } + let validation_result = invalid_retry_config.validate(); + assert!( + validation_result.is_err(), + "Should validate max_retries > 0" + ); + assert!(validation_result + .unwrap_err() + .contains("max_retries must be greater than 0")); - async fn pause_schedule(&self, _id: &str) -> Result<(), SchedulerError> { Ok(()) } - async fn unpause_schedule(&self, _id: &str) -> Result<(), SchedulerError> { - Ok(()) - } + #[tokio::test] + async fn test_retry_attempts_counter_reset() -> Result<()> { + let agent = Agent::new(); - async fn run_now(&self, _id: &str) -> Result { - Ok("test_session_123".to_string()) - } + agent.reset_retry_attempts().await; + let initial_attempts = agent.get_retry_attempts().await; + assert_eq!(initial_attempts, 0); - async fn sessions( - &self, - _sched_id: &str, - _limit: usize, - ) -> Result, SchedulerError> { - Ok(vec![]) - } + let new_attempts = agent.increment_retry_attempts().await; + assert_eq!(new_attempts, 1); - async fn update_schedule( - &self, - _sched_id: &str, - _new_cron: String, - ) -> Result<(), SchedulerError> { - Ok(()) - } + agent.reset_retry_attempts().await; + let reset_attempts = agent.get_retry_attempts().await; + assert_eq!(reset_attempts, 0); - async fn kill_running_job(&self, _sched_id: &str) -> Result<(), SchedulerError> { Ok(()) } - - async fn get_running_job_info( - &self, - _sched_id: &str, - ) -> Result)>, 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 - .clone() - .unwrap_or_default() - .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 - .clone() - .unwrap_or_default() - .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 = 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")); - } - } } -} -#[cfg(test)] -mod final_output_tool_tests { - use super::*; - use futures::stream; - use goose::agents::final_output_tool::FINAL_OUTPUT_TOOL_NAME; - use goose::conversation::Conversation; - use goose::providers::base::MessageStream; - use goose::recipe::Response; - use rmcp::model::CallToolRequestParam; - use rmcp::object; - - #[tokio::test] - async fn test_final_output_assistant_message_in_reply() -> Result<()> { + #[cfg(test)] + mod max_turns_tests { + use super::*; use async_trait::async_trait; - use goose::conversation::message::Message; + use goose::agents::SessionConfig; + use goose::conversation::message::{Message, MessageContent}; use goose::model::ModelConfig; - use goose::providers::base::{Provider, ProviderUsage, Usage}; + use goose::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage}; use goose::providers::errors::ProviderError; - use rmcp::model::Tool; - - #[derive(Clone)] - struct MockProvider { - model_config: ModelConfig, - } - - #[async_trait] - impl Provider for MockProvider { - fn metadata() -> goose::providers::base::ProviderMetadata { - goose::providers::base::ProviderMetadata::empty() - } - - fn get_name(&self) -> &str { - "mock-test" - } - - fn get_model_config(&self) -> ModelConfig { - self.model_config.clone() - } - - async fn complete( - &self, - _system: &str, - _messages: &[Message], - _tools: &[Tool], - ) -> anyhow::Result<(Message, ProviderUsage), ProviderError> { - Ok(( - Message::assistant().with_text("Task completed."), - ProviderUsage::new("mock".to_string(), Usage::default()), - )) - } - - async fn complete_with_model( - &self, - _model_config: &ModelConfig, - system: &str, - messages: &[Message], - tools: &[Tool], - ) -> anyhow::Result<(Message, ProviderUsage), ProviderError> { - self.complete(system, messages, tools).await - } - } - - let agent = Agent::new(); + use goose::session::session_manager::SessionType; + use goose::session::SessionManager; + use rmcp::model::{CallToolRequestParam, Tool}; + use rmcp::object; + use std::path::PathBuf; - let model_config = ModelConfig::new("test-model").unwrap(); - let mock_provider = Arc::new(MockProvider { model_config }); - agent.update_provider(mock_provider).await?; + struct MockToolProvider {} - let response = Response { - json_schema: Some(serde_json::json!({ - "type": "object", - "properties": { - "result": {"type": "string"} - }, - "required": ["result"] - })), - }; - agent.add_final_output_tool(response).await; - - // Simulate a final output tool call occurring. - let tool_call = CallToolRequestParam { - name: FINAL_OUTPUT_TOOL_NAME.into(), - arguments: Some(object!({ - "result": "Test output" - })), - }; - - let (_, result) = agent - .dispatch_tool_call(tool_call, "request_id".to_string(), None, None) - .await; - - assert!(result.is_ok(), "Tool call should succeed"); - let final_result = result.unwrap().result.await; - assert!(final_result.is_ok(), "Tool execution should succeed"); - - let content = final_result.unwrap(); - let text = content.first().unwrap().as_text().unwrap(); - assert!( - text.text.contains("Final output successfully collected."), - "Tool result missing expected content: {}", - text.text - ); - - // Simulate the reply stream continuing after the final output tool call. - let reply_stream = agent.reply(Conversation::empty(), None, None).await?; - tokio::pin!(reply_stream); - - let mut responses = Vec::new(); - while let Some(response_result) = reply_stream.next().await { - match response_result { - Ok(AgentEvent::Message(response)) => responses.push(response), - Ok(_) => {} - Err(e) => return Err(e), + impl MockToolProvider { + fn new() -> Self { + Self {} } } - assert!(!responses.is_empty(), "Should have received responses"); - let last_message = responses.last().unwrap(); - - // Check that the last message is an assistant message with our final output - assert_eq!(last_message.role, rmcp::model::Role::Assistant); - let message_text = last_message.as_concat_text(); - assert_eq!(message_text, r#"{"result":"Test output"}"#); - - Ok(()) - } - - #[tokio::test] - async fn test_when_final_output_not_called_in_reply() -> Result<()> { - use async_trait::async_trait; - use goose::agents::final_output_tool::FINAL_OUTPUT_CONTINUATION_MESSAGE; - use goose::conversation::message::Message; - use goose::model::ModelConfig; - use goose::providers::base::{Provider, ProviderUsage}; - use goose::providers::errors::ProviderError; - use rmcp::model::Tool; - - #[derive(Clone)] - struct MockProvider { - model_config: ModelConfig, - stream_round: std::sync::Arc>, - got_continuation_message: std::sync::Arc>, - } - #[async_trait] - impl Provider for MockProvider { - fn metadata() -> goose::providers::base::ProviderMetadata { - goose::providers::base::ProviderMetadata::empty() - } - - fn get_name(&self) -> &str { - "mock-test" - } - - fn get_model_config(&self) -> ModelConfig { - self.model_config.clone() - } - - fn supports_streaming(&self) -> bool { - true - } - - async fn stream( + impl Provider for MockToolProvider { + async fn complete( &self, - _system: &str, + _system_prompt: &str, _messages: &[Message], _tools: &[Tool], - ) -> Result { - if let Some(last_msg) = _messages.last() { - for content in &last_msg.content { - if let goose::conversation::message::MessageContent::Text(text_content) = - content - { - if text_content.text == FINAL_OUTPUT_CONTINUATION_MESSAGE { - let mut got_continuation = - self.got_continuation_message.lock().unwrap(); - *got_continuation = true; - } - } - } - } - - let mut round = self.stream_round.lock().unwrap(); - *round += 1; - - let deltas = if *round == 1 { - vec![ - Ok((Some(Message::assistant().with_text("Hello")), None)), - Ok((Some(Message::assistant().with_text("Hi!")), None)), - Ok(( - Some(Message::assistant().with_text("What is the final output?")), - None, - )), - ] - } else { - vec![Ok(( - Some(Message::assistant().with_text("Additional random delta")), - None, - ))] + ) -> Result<(Message, ProviderUsage), ProviderError> { + let tool_call = CallToolRequestParam { + name: "test_tool".into(), + arguments: Some(object!({"param": "value"})), }; + let message = Message::assistant().with_tool_request("call_123", Ok(tool_call)); - let stream = stream::iter(deltas.into_iter()); - Ok(Box::pin(stream)) - } + let usage = ProviderUsage::new( + "mock-model".to_string(), + Usage::new(Some(10), Some(5), Some(15)), + ); - async fn complete( - &self, - _system: &str, - _messages: &[Message], - _tools: &[Tool], - ) -> Result<(Message, ProviderUsage), ProviderError> { - Err(ProviderError::NotImplemented("Not implemented".to_string())) + Ok((message, usage)) } async fn complete_with_model( &self, _model_config: &ModelConfig, - system: &str, + system_prompt: &str, messages: &[Message], tools: &[Tool], ) -> anyhow::Result<(Message, ProviderUsage), ProviderError> { - self.complete(system, messages, tools).await + self.complete(system_prompt, messages, tools).await } - } - let agent = Agent::new(); - - let model_config = ModelConfig::new("test-model").unwrap(); - let mock_provider = Arc::new(MockProvider { - model_config, - stream_round: std::sync::Arc::new(std::sync::Mutex::new(0)), - got_continuation_message: std::sync::Arc::new(std::sync::Mutex::new(false)), - }); - let mock_provider_clone = mock_provider.clone(); - agent.update_provider(mock_provider).await?; - - let response = Response { - json_schema: Some(serde_json::json!({ - "type": "object", - "properties": { - "result": {"type": "string"} - }, - "required": ["result"] - })), - }; - agent.add_final_output_tool(response).await; - - // Simulate the reply stream being called. - let reply_stream = agent.reply(Conversation::empty(), None, None).await?; - tokio::pin!(reply_stream); - - let mut responses = Vec::new(); - let mut count = 0; - while let Some(response_result) = reply_stream.next().await { - match response_result { - Ok(AgentEvent::Message(response)) => { - responses.push(response); - count += 1; - if count >= 4 { - // Limit to 4 messages to avoid infinite loop due to mock provider - break; - } - } - Ok(_) => {} - Err(e) => return Err(e), + fn get_model_config(&self) -> ModelConfig { + ModelConfig::new("mock-model").unwrap() } - } - - assert!(!responses.is_empty(), "Should have received responses"); - let last_message = responses.last().unwrap(); - - // Check that the first 3 messages do not have FINAL_OUTPUT_CONTINUATION_MESSAGE - for (i, response) in responses.iter().take(3).enumerate() { - let message_text = response.as_concat_text(); - assert_ne!( - message_text, - FINAL_OUTPUT_CONTINUATION_MESSAGE, - "Message {} should not be the continuation message, got: '{}'", - i + 1, - message_text - ); - } - - // Check that the last message after the llm stream is the message directing the agent to continue - assert_eq!(last_message.role, rmcp::model::Role::User); - let message_text = last_message.as_concat_text(); - assert_eq!(message_text, FINAL_OUTPUT_CONTINUATION_MESSAGE); - // Continue streaming to consume any remaining content, this lets us verify the provider saw the continuation message - while let Some(response_result) = reply_stream.next().await { - match response_result { - Ok(AgentEvent::Message(_response)) => { - break; // Stop after receiving the next message - } - Ok(_) => {} - Err(e) => { - println!("Error while streaming remaining content: {:?}", e); - break; + fn metadata() -> ProviderMetadata { + ProviderMetadata { + name: "mock".to_string(), + display_name: "Mock Provider".to_string(), + description: "Mock provider for testing".to_string(), + default_model: "mock-model".to_string(), + known_models: vec![], + model_doc_link: "".to_string(), + config_keys: vec![], } } - } - - // Assert that the provider received the continuation message - let got_continuation = mock_provider_clone.got_continuation_message.lock().unwrap(); - assert!( - *got_continuation, - "Provider should have received the FINAL_OUTPUT_CONTINUATION_MESSAGE" - ); - - Ok(()) - } -} - -#[cfg(test)] -mod retry_tests { - use super::*; - use async_trait::async_trait; - use goose::agents::types::{RetryConfig, SuccessCheck}; - use goose::conversation::message::Message; - use goose::conversation::Conversation; - use goose::model::ModelConfig; - use goose::providers::base::{Provider, ProviderUsage, Usage}; - use goose::providers::errors::ProviderError; - use rmcp::model::Tool; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Arc; - - #[derive(Clone)] - struct MockRetryProvider { - model_config: ModelConfig, - call_count: Arc, - fail_until: usize, - } - - #[async_trait] - impl Provider for MockRetryProvider { - fn metadata() -> goose::providers::base::ProviderMetadata { - goose::providers::base::ProviderMetadata::empty() - } - - fn get_name(&self) -> &str { - "mock-test" - } - - fn get_model_config(&self) -> ModelConfig { - self.model_config.clone() - } - - async fn complete( - &self, - _system: &str, - _messages: &[Message], - _tools: &[Tool], - ) -> anyhow::Result<(Message, ProviderUsage), ProviderError> { - let count = self.call_count.fetch_add(1, Ordering::SeqCst); - - if count < self.fail_until { - Ok(( - Message::assistant().with_text("Task failed - will retry."), - ProviderUsage::new("mock".to_string(), Usage::default()), - )) - } else { - Ok(( - Message::assistant().with_text("Task completed successfully."), - ProviderUsage::new("mock".to_string(), Usage::default()), - )) - } - } - - async fn complete_with_model( - &self, - _model_config: &ModelConfig, - system: &str, - messages: &[Message], - tools: &[Tool], - ) -> anyhow::Result<(Message, ProviderUsage), ProviderError> { - self.complete(system, messages, tools).await - } - } - #[tokio::test] - async fn test_retry_config_validation_integration() -> Result<()> { - let agent = Agent::new(); - - let model_config = ModelConfig::new("test-model").unwrap(); - let mock_provider = Arc::new(MockRetryProvider { - model_config, - call_count: Arc::new(AtomicUsize::new(0)), - fail_until: 0, - }); - agent.update_provider(mock_provider.clone()).await?; - - let retry_config = RetryConfig { - max_retries: 3, - checks: vec![SuccessCheck::Shell { - command: "echo 'success check'".to_string(), - }], - on_failure: Some("echo 'cleanup executed'".to_string()), - timeout_seconds: Some(30), - on_failure_timeout_seconds: Some(60), - }; - - assert!( - retry_config.validate().is_ok(), - "Valid config should pass validation" - ); - - let conversation = - Conversation::new(vec![Message::user().with_text("Complete this task")]).unwrap(); - - let reply_stream = agent.reply(conversation, None, None).await?; - tokio::pin!(reply_stream); - - let mut responses = Vec::new(); - while let Some(response_result) = reply_stream.next().await { - match response_result { - Ok(AgentEvent::Message(response)) => responses.push(response), - Ok(_) => {} - Err(e) => return Err(e), + fn get_name(&self) -> &str { + "mock-test" } } - assert!(!responses.is_empty(), "Should have received responses"); - - Ok(()) - } - - #[tokio::test] - async fn test_retry_success_check_execution() -> Result<()> { - use goose::agents::retry::execute_success_checks; - - let retry_config = RetryConfig { - max_retries: 3, - checks: vec![], - on_failure: None, - timeout_seconds: Some(30), - on_failure_timeout_seconds: Some(60), - }; - - let success_checks = vec![SuccessCheck::Shell { - command: "echo 'test'".to_string(), - }]; - - let result = execute_success_checks(&success_checks, &retry_config).await; - assert!(result.is_ok(), "Success check should pass"); - assert!(result.unwrap(), "Command should succeed"); - - let fail_checks = vec![SuccessCheck::Shell { - command: "false".to_string(), - }]; - - let result = execute_success_checks(&fail_checks, &retry_config).await; - assert!(result.is_ok(), "Success check execution should not error"); - assert!(!result.unwrap(), "Command should fail"); - - Ok(()) - } - - #[tokio::test] - async fn test_retry_logic_with_validation_errors() -> Result<()> { - let invalid_retry_config = RetryConfig { - max_retries: 0, - checks: vec![], - on_failure: None, - timeout_seconds: Some(0), - on_failure_timeout_seconds: None, - }; - - let validation_result = invalid_retry_config.validate(); - assert!( - validation_result.is_err(), - "Should validate max_retries > 0" - ); - assert!(validation_result - .unwrap_err() - .contains("max_retries must be greater than 0")); - - Ok(()) - } - - #[tokio::test] - async fn test_retry_attempts_counter_reset() -> Result<()> { - let agent = Agent::new(); - - agent.reset_retry_attempts().await; - let initial_attempts = agent.get_retry_attempts().await; - assert_eq!(initial_attempts, 0); - - let new_attempts = agent.increment_retry_attempts().await; - assert_eq!(new_attempts, 1); - - agent.reset_retry_attempts().await; - let reset_attempts = agent.get_retry_attempts().await; - assert_eq!(reset_attempts, 0); - - Ok(()) - } -} - -#[cfg(test)] -mod max_turns_tests { - use super::*; - use async_trait::async_trait; - use goose::conversation::message::{Message, MessageContent}; - use goose::conversation::Conversation; - use goose::model::ModelConfig; - use goose::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage}; - use goose::providers::errors::ProviderError; - use rmcp::model::{CallToolRequestParam, Tool}; - use rmcp::object; - - struct MockToolProvider {} - - impl MockToolProvider { - fn new() -> Self { - Self {} - } - } - - #[async_trait] - impl Provider for MockToolProvider { - async fn complete( - &self, - _system_prompt: &str, - _messages: &[Message], - _tools: &[Tool], - ) -> Result<(Message, ProviderUsage), ProviderError> { - let tool_call = CallToolRequestParam { - name: "test_tool".into(), - arguments: Some(object!({"param": "value"})), + #[tokio::test] + async fn test_max_turns_limit() -> Result<()> { + let agent = Agent::new(); + let provider = Arc::new(MockToolProvider::new()); + agent.update_provider(provider).await?; + let user_message = Message::user().with_text("Hello"); + + let session = SessionManager::create_session( + PathBuf::default(), + "max-turn-test".to_string(), + SessionType::Hidden, + ) + .await?; + let session_config = SessionConfig { + id: session.id, + schedule_id: None, + max_turns: None, + retry_config: None, }; - let message = Message::assistant().with_tool_request("call_123", Ok(tool_call)); - - let usage = ProviderUsage::new( - "mock-model".to_string(), - Usage::new(Some(10), Some(5), Some(15)), - ); - - Ok((message, usage)) - } - async fn complete_with_model( - &self, - _model_config: &ModelConfig, - system_prompt: &str, - messages: &[Message], - tools: &[Tool], - ) -> anyhow::Result<(Message, ProviderUsage), ProviderError> { - self.complete(system_prompt, messages, tools).await - } - - fn get_model_config(&self) -> ModelConfig { - ModelConfig::new("mock-model").unwrap() - } - - fn metadata() -> ProviderMetadata { - ProviderMetadata { - name: "mock".to_string(), - display_name: "Mock Provider".to_string(), - description: "Mock provider for testing".to_string(), - default_model: "mock-model".to_string(), - known_models: vec![], - model_doc_link: "".to_string(), - config_keys: vec![], - } - } + let reply_stream = agent.reply(user_message, session_config, None).await?; + tokio::pin!(reply_stream); - fn get_name(&self) -> &str { - "mock-test" - } - } - - #[tokio::test] - async fn test_max_turns_limit() -> Result<()> { - let agent = Agent::new(); - let provider = Arc::new(MockToolProvider::new()); - agent.update_provider(provider).await?; - // The mock provider will call a non-existent tool, which will fail and allow the loop to continue - let conversation = Conversation::new(vec![Message::user().with_text("Hello")]).unwrap(); - - let reply_stream = agent.reply(conversation, None, None).await?; - tokio::pin!(reply_stream); - - let mut responses = Vec::new(); - while let Some(response_result) = reply_stream.next().await { - match response_result { - Ok(AgentEvent::Message(response)) => { - if let Some(MessageContent::ToolConfirmationRequest(ref req)) = - response.content.first() - { - agent.handle_confirmation( + let mut responses = Vec::new(); + while let Some(response_result) = reply_stream.next().await { + match response_result { + Ok(AgentEvent::Message(response)) => { + if let Some(MessageContent::ToolConfirmationRequest(ref req)) = + response.content.first() + { + agent.handle_confirmation( req.id.clone(), goose::permission::PermissionConfirmation { principal_type: goose::permission::permission_confirmation::PrincipalType::Tool, permission: goose::permission::Permission::AllowOnce, } ).await; + } + responses.push(response); + } + Ok(AgentEvent::McpNotification(_)) => {} + Ok(AgentEvent::ModelChange { .. }) => {} + Ok(AgentEvent::HistoryReplaced(_updated_conversation)) => { + // We should update the conversation here, but we're not reading it + } + Err(e) => { + return Err(e); } - responses.push(response); - } - Ok(AgentEvent::McpNotification(_)) => {} - Ok(AgentEvent::ModelChange { .. }) => {} - Ok(AgentEvent::HistoryReplaced(_updated_conversation)) => { - // We should update the conversation here, but we're not reading it - } - Err(e) => { - return Err(e); } } - } - - assert!( - !responses.is_empty(), - "Expected at least 1 response, got {}", - responses.len() - ); - - // Look for the max turns message as the last response - let last_response = responses.last().unwrap(); - let last_content = last_response.content.first().unwrap(); - if let MessageContent::Text(text_content) = last_content { - assert!(text_content.text.contains( - "I've reached the maximum number of actions I can do without user input" - )); - } else { - panic!("Expected text content in last message"); - } - Ok(()) - } -} -#[cfg(test)] -mod extension_manager_tests { - use super::*; - use goose::agents::extension::{ExtensionConfig, PlatformExtensionContext}; - use goose::agents::extension_manager_extension::{ - MANAGE_EXTENSIONS_TOOL_NAME, SEARCH_AVAILABLE_EXTENSIONS_TOOL_NAME, - }; - use rmcp::model::CallToolRequestParam; - use rmcp::object; - - async fn setup_agent_with_extension_manager() -> Agent { - // Add the TODO extension to the config so it can be discovered by search_available_extensions - // Set it as disabled initially so tests can enable it - let todo_extension_entry = ExtensionEntry { - enabled: false, - config: ExtensionConfig::Platform { - name: "todo".to_string(), - description: - "Enable a todo list for Goose so it can keep track of what it is doing" - .to_string(), - bundled: Some(true), - available_tools: vec![], - }, - }; - set_extension(todo_extension_entry); - - let agent = Agent::new(); - - agent - .extension_manager - .set_context(PlatformExtensionContext { - session_id: Some("test_session".to_string()), - extension_manager: Some(Arc::downgrade(&agent.extension_manager)), - tool_route_manager: Some(Arc::downgrade(&agent.tool_route_manager)), - }) - .await; - - // Now add the extension manager platform extension - let ext_config = ExtensionConfig::Platform { - name: "extensionmanager".to_string(), - description: "Extension Manager".to_string(), - bundled: Some(true), - available_tools: vec![], - }; - - agent - .add_extension(ext_config) - .await - .expect("Failed to add extension manager"); - agent - } - - #[tokio::test] - async fn test_extension_manager_tools_available() { - let agent = setup_agent_with_extension_manager().await; - let tools = agent.list_tools(None).await; - - // Note: Tool names are prefixed with the normalized extension name "extensionmanager" - // not the display name "Extension Manager" - let search_tool = tools.iter().find(|tool| { - tool.name == format!("extensionmanager__{SEARCH_AVAILABLE_EXTENSIONS_TOOL_NAME}") - }); - assert!( - search_tool.is_some(), - "search_available_extensions tool should be available" - ); - - let manage_tool = tools - .iter() - .find(|tool| tool.name == format!("extensionmanager__{MANAGE_EXTENSIONS_TOOL_NAME}")); - assert!( - manage_tool.is_some(), - "manage_extensions tool should be available" - ); - } - - #[tokio::test] - async fn test_search_available_extensions_tool_call() { - let agent = setup_agent_with_extension_manager().await; - - let tool_call = CallToolRequestParam { - name: format!("extensionmanager__{SEARCH_AVAILABLE_EXTENSIONS_TOOL_NAME}").into(), - arguments: Some(object!({})), - }; - - let (_, result) = agent - .dispatch_tool_call(tool_call, "request_1".to_string(), None, None) - .await; - - assert!(result.is_ok(), "search_available_extensions should succeed"); - let call_result = result.unwrap().result.await; - assert!( - call_result.is_ok(), - "search_available_extensions execution should succeed" - ); - - let content = call_result.unwrap(); - assert!(!content.is_empty(), "Should return some content"); - - // Verify the content contains expected text - let text = content.first().unwrap().as_text().unwrap(); - assert!( - text.text.contains("Extensions available to enable:"), - "Content should contain 'Extensions available to enable:'" - ); - } - - #[tokio::test] - async fn test_manage_extensions_enable_disable_success() { - let agent = setup_agent_with_extension_manager().await; - - let enable_call = CallToolRequestParam { - name: format!("extensionmanager__{MANAGE_EXTENSIONS_TOOL_NAME}").into(), - arguments: Some(object!({ - "action": "enable", - "extension_name": "todo" - })), - }; - let (_, result) = agent - .dispatch_tool_call(enable_call, "request_3a".to_string(), None, None) - .await; - assert!(result.is_ok()); - let call_result = result.unwrap().result.await; - assert!( - call_result.is_ok(), - "manage_extensions enable execution should succeed" - ); - - let content = call_result.unwrap(); - let text = content.first().unwrap().as_text().unwrap(); - assert!( - text.text.contains("todo") && text.text.contains("installed successfully"), - "Response should indicate success, got: {}", - text.text - ); - - // Now disable it - let disable_call = CallToolRequestParam { - name: format!("extensionmanager__{MANAGE_EXTENSIONS_TOOL_NAME}").into(), - arguments: Some(object!({ - "action": "disable", - "extension_name": "todo" - })), - }; - - let (_, result) = agent - .dispatch_tool_call(disable_call, "request_3b".to_string(), None, None) - .await; - - assert!(result.is_ok(), "manage_extensions disable should succeed"); - let call_result = result.unwrap().result.await; - assert!( - call_result.is_ok(), - "manage_extensions disable execution should succeed" - ); - - let content = call_result.unwrap(); - assert!(!content.is_empty(), "Should return confirmation message"); - - // Verify the message indicates success - let text = content.first().unwrap().as_text().unwrap(); - assert!( - text.text.contains("successfully") || text.text.contains("disabled"), - "Response should indicate success, got: {}", - text.text - ); - } - - #[tokio::test] - async fn test_manage_extensions_missing_parameters() { - let agent = setup_agent_with_extension_manager().await; - - // Call manage_extensions without action parameter - let tool_call = CallToolRequestParam { - name: format!("extensionmanager__{MANAGE_EXTENSIONS_TOOL_NAME}").into(), - arguments: Some(object!({ - "extension_name": "todo" - })), - }; + assert!( + !responses.is_empty(), + "Expected at least 1 response, got {}", + responses.len() + ); - let (_, result) = agent - .dispatch_tool_call(tool_call, "request_4".to_string(), None, None) - .await; - - assert!(result.is_ok(), "Tool call should return a result"); - let call_result = result.unwrap().result.await; - assert!( - call_result.is_ok(), - "Tool execution should return an error result" - ); - - let content = call_result.unwrap(); - let text = content.first().unwrap().as_text().unwrap(); - assert!( - text.text.contains("action") || text.text.contains("parameter"), - "Error should mention missing action parameter" - ); + // Look for the max turns message as the last response + let last_response = responses.last().unwrap(); + let last_content = last_response.content.first().unwrap(); + if let MessageContent::Text(text_content) = last_content { + assert!(text_content.text.contains( + "I've reached the maximum number of actions I can do without user input" + )); + } else { + panic!("Expected text content in last message"); + } + Ok(()) + } } - #[tokio::test] - async fn test_manage_extensions_invalid_action() { - let agent = setup_agent_with_extension_manager().await; - - let tool_call = CallToolRequestParam { - name: format!("extensionmanager__{MANAGE_EXTENSIONS_TOOL_NAME}").into(), - arguments: Some(object!({ - "action": "invalid_action", - "extension_name": "todo" - })), + #[cfg(test)] + mod extension_manager_tests { + use super::*; + use goose::agents::extension::{ExtensionConfig, PlatformExtensionContext}; + use goose::agents::extension_manager_extension::{ + MANAGE_EXTENSIONS_TOOL_NAME, SEARCH_AVAILABLE_EXTENSIONS_TOOL_NAME, }; - let (_, result) = agent - .dispatch_tool_call(tool_call, "request_6".to_string(), None, None) - .await; - - assert!(result.is_ok(), "Tool call should return a result"); - let call_result = result.unwrap().result.await; - assert!( - call_result.is_ok(), - "Tool execution should return an error result" - ); - - let content = call_result.unwrap(); - let text = content.first().unwrap().as_text().unwrap(); - assert!( - text.text.contains("Invalid action") - || text.text.contains("enable") - || text.text.contains("disable"), - "Error should mention invalid action, got: {}", - text.text - ); - } + async fn setup_agent_with_extension_manager() -> Agent { + // Add the TODO extension to the config so it can be discovered by search_available_extensions + // Set it as disabled initially so tests can enable it + let todo_extension_entry = ExtensionEntry { + enabled: false, + config: ExtensionConfig::Platform { + name: "todo".to_string(), + description: + "Enable a todo list for Goose so it can keep track of what it is doing" + .to_string(), + bundled: Some(true), + available_tools: vec![], + }, + }; + set_extension(todo_extension_entry); + + let agent = Agent::new(); + + agent + .extension_manager + .set_context(PlatformExtensionContext { + session_id: Some("test_session".to_string()), + extension_manager: Some(Arc::downgrade(&agent.extension_manager)), + tool_route_manager: Some(Arc::downgrade(&agent.tool_route_manager)), + }) + .await; + + // Now add the extension manager platform extension + let ext_config = ExtensionConfig::Platform { + name: "extensionmanager".to_string(), + description: "Extension Manager".to_string(), + bundled: Some(true), + available_tools: vec![], + }; - #[tokio::test] - async fn test_manage_extensions_extension_not_found() { - let agent = setup_agent_with_extension_manager().await; - - // Try to enable a non-existent extension - let tool_call = CallToolRequestParam { - name: format!("extensionmanager__{MANAGE_EXTENSIONS_TOOL_NAME}").into(), - arguments: Some(object!({ - "action": "enable", - "extension_name": "nonexistent_extension_12345" - })), - }; + agent + .add_extension(ext_config) + .await + .expect("Failed to add extension manager"); + agent + } + + #[tokio::test] + async fn test_extension_manager_tools_available() { + let agent = setup_agent_with_extension_manager().await; + let tools = agent.list_tools(None).await; + + // Note: Tool names are prefixed with the normalized extension name "extensionmanager" + // not the display name "Extension Manager" + let search_tool = tools.iter().find(|tool| { + tool.name == format!("extensionmanager__{SEARCH_AVAILABLE_EXTENSIONS_TOOL_NAME}") + }); + assert!( + search_tool.is_some(), + "search_available_extensions tool should be available" + ); - let (_, result) = agent - .dispatch_tool_call(tool_call, "request_7".to_string(), None, None) - .await; - - assert!(result.is_ok(), "Tool call should return a result"); - let call_result = result.unwrap().result.await; - assert!( - call_result.is_ok(), - "Tool execution should return an error result" - ); - - // Check that the error message indicates extension not found - let content = call_result.unwrap(); - let text = content.first().unwrap().as_text().unwrap(); - assert!( - text.text.contains("not found") || text.text.contains("Extension"), - "Error should mention extension not found, got: {}", - text.text - ); + let manage_tool = tools.iter().find(|tool| { + tool.name == format!("extensionmanager__{MANAGE_EXTENSIONS_TOOL_NAME}") + }); + assert!( + manage_tool.is_some(), + "manage_extensions tool should be available" + ); + } } } diff --git a/crates/goose/tests/private_tests.rs b/crates/goose/tests/private_tests.rs deleted file mode 100644 index 8ac97aa1504..00000000000 --- a/crates/goose/tests/private_tests.rs +++ /dev/null @@ -1,833 +0,0 @@ -#![cfg(test)] - -use rmcp::model::{CallToolRequestParam, ErrorCode}; -use rmcp::object; -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 Some(text_content) = content[0].as_text() { - 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 Some(text_content) = content[0].as_text() { - 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(err) = result { - assert_eq!(err.code, ErrorCode::INTERNAL_ERROR); - assert!(err.message.contains("Failed to list jobs")); - assert!(err.message.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 Some(text_content) = content[0].as_text() { - 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(err) = result { - assert_eq!(err.code, ErrorCode::INVALID_PARAMS); - assert!(err.message.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(err) = result { - assert_eq!(err.code, ErrorCode::INVALID_PARAMS); - assert!(err.message.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(err) = result { - assert_eq!(err.code, ErrorCode::INTERNAL_ERROR); - assert!(err.message.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(err) = result { - assert_eq!(err.code, ErrorCode::INTERNAL_ERROR); - assert!(err.message.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(err) = result { - assert_eq!(err.code, ErrorCode::INTERNAL_ERROR); - assert!(err.message.contains("Failed to create job")); - assert!(err.message.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 Some(text_content) = content[0].as_text() { - 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(err) = result { - assert_eq!(err.code, ErrorCode::INVALID_PARAMS); - assert!(err.message.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(err) = result { - assert!(err.message.contains("Failed to run job")); - assert!(err.message.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 Some(text_content) = content[0].as_text() { - 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(err) = result { - assert!(err.message.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(err) = result { - assert!(err.message.contains("Failed to pause job")); - assert!(err.message.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 Some(text_content) = content[0].as_text() { - 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 Some(text_content) = content[0].as_text() { - 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 Some(text_content) = content[0].as_text() { - 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(err) = result { - assert!(err.message.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 Some(text_content) = content[0].as_text() { - 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 Some(text_content) = content[0].as_text() { - 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 Some(text_content) = content[0].as_text() { - 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 Some(text_content) = content[0].as_text() { - 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_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(err) = result { - assert!(err.message.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(err) = result { - assert!(err.message.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 = CallToolRequestParam { - name: PLATFORM_MANAGE_SCHEDULE_TOOL_NAME.into(), - arguments: Some(object!({ - "action": "list" - })), - }; - - let (request_id, result) = agent - .dispatch_tool_call(tool_call, "test_dispatch".to_string(), None, None) - .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())); -} diff --git a/crates/goose/tests/test_support.rs b/crates/goose/tests/test_support.rs index 6bba89e9198..49dd89d885b 100644 --- a/crates/goose/tests/test_support.rs +++ b/crates/goose/tests/test_support.rs @@ -345,7 +345,6 @@ impl ScheduleToolTestBuilder { paused: false, current_session_id: None, process_start_time: None, - execution_mode: Some("background".to_string()), }; { let mut jobs = self.scheduler.jobs.lock().await; @@ -397,5 +396,6 @@ pub fn create_test_session_metadata(message_count: usize, working_dir: &str) -> conversation: None, message_count, user_recipe_values: None, + session_type: Default::default(), } } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index ea97a2c0b84..d4478cbad64 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4127,10 +4127,6 @@ "currently_running": { "type": "boolean" }, - "execution_mode": { - "type": "string", - "nullable": true - }, "id": { "type": "string" }, @@ -4226,6 +4222,9 @@ "type": "string", "nullable": true }, + "session_type": { + "$ref": "#/components/schemas/SessionType" + }, "total_tokens": { "type": "integer", "format": "int32", @@ -4344,6 +4343,15 @@ } } }, + "SessionType": { + "type": "string", + "enum": [ + "user", + "scheduled", + "sub_agent", + "hidden" + ] + }, "SessionsQuery": { "type": "object", "properties": { diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 67ee1410849..73f1474fb30 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -328,6 +328,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -697,6 +698,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -743,6 +745,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1182,6 +1185,7 @@ "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", @@ -2731,6 +2735,7 @@ "integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@inquirer/checkbox": "^3.0.1", "@inquirer/confirm": "^4.0.1", @@ -5960,8 +5965,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -6250,6 +6254,7 @@ "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6291,6 +6296,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6301,6 +6307,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6447,6 +6454,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -6848,6 +6856,7 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -7097,6 +7106,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7736,6 +7746,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -9071,8 +9082,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -9130,6 +9140,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", @@ -10145,6 +10156,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10604,6 +10616,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -12819,6 +12832,7 @@ "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", @@ -14043,7 +14057,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -14064,6 +14077,7 @@ "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", @@ -16438,6 +16452,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -16526,7 +16541,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -16542,7 +16556,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -16859,6 +16872,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16868,6 +16882,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16889,8 +16904,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-markdown": { "version": "10.1.0", @@ -17839,6 +17853,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -18832,7 +18847,8 @@ "version": "4.1.15", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz", "integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -19451,6 +19467,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19836,6 +19853,7 @@ "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -19949,6 +19967,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -20654,6 +20673,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 843822c14a5..6468fd3ee3c 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -667,7 +667,6 @@ export type ScheduledJob = { cron: string; current_session_id?: string | null; currently_running?: boolean; - execution_mode?: string | null; id: string; last_run?: string | null; paused?: boolean; @@ -689,6 +688,7 @@ export type Session = { output_tokens?: number | null; recipe?: Recipe | null; schedule_id?: string | null; + session_type?: SessionType; total_tokens?: number | null; updated_at: string; user_recipe_values?: { @@ -725,6 +725,8 @@ export type SessionListResponse = { sessions: Array; }; +export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden'; + export type SessionsQuery = { limit?: number; };