Files

1693 lines
63 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::sync::Arc;
use anyhow::Result;
use futures::StreamExt;
use goose::agents::{Agent, AgentEvent, GoosePlatform};
use goose::config::extensions::{set_extension, ExtensionEntry};
#[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::agents::AgentConfig;
use goose::config::permission::PermissionManager;
use goose::config::GooseMode;
use goose::scheduler::{ScheduledJob, SchedulerError};
use goose::scheduler_trait::SchedulerTrait;
use goose::session::{Session, SessionManager};
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::TempDir;
struct MockScheduler {
jobs: tokio::sync::Mutex<Vec<ScheduledJob>>,
}
impl MockScheduler {
fn new() -> Self {
Self {
jobs: tokio::sync::Mutex::new(Vec::new()),
}
}
}
#[async_trait]
impl SchedulerTrait for MockScheduler {
async fn add_scheduled_job(
&self,
job: ScheduledJob,
_copy: bool,
) -> Result<(), SchedulerError> {
let mut jobs = self.jobs.lock().await;
jobs.push(job);
Ok(())
}
async fn schedule_recipe(
&self,
_recipe_path: PathBuf,
_cron_schedule: Option<String>,
) -> Result<(), SchedulerError> {
Ok(())
}
async fn list_scheduled_jobs(&self) -> Vec<ScheduledJob> {
let jobs = self.jobs.lock().await;
jobs.clone()
}
async fn remove_scheduled_job(
&self,
id: &str,
_remove: bool,
) -> Result<(), SchedulerError> {
let mut jobs = self.jobs.lock().await;
if let Some(pos) = jobs.iter().position(|job| job.id == id) {
jobs.remove(pos);
Ok(())
} else {
Err(SchedulerError::JobNotFound(id.to_string()))
}
}
async fn pause_schedule(&self, _id: &str) -> Result<(), SchedulerError> {
Ok(())
}
async fn unpause_schedule(&self, _id: &str) -> Result<(), SchedulerError> {
Ok(())
}
async fn run_now(&self, _id: &str) -> Result<String, SchedulerError> {
Ok("test_session_123".to_string())
}
async fn sessions(
&self,
_sched_id: &str,
_limit: usize,
) -> Result<Vec<(String, Session)>, SchedulerError> {
Ok(vec![])
}
async fn update_schedule(
&self,
_sched_id: &str,
_new_cron: String,
) -> Result<(), SchedulerError> {
Ok(())
}
async fn kill_running_job(&self, _sched_id: &str) -> Result<(), SchedulerError> {
Ok(())
}
async fn get_running_job_info(
&self,
_sched_id: &str,
) -> Result<Option<(String, DateTime<Utc>)>, SchedulerError> {
Ok(None)
}
}
#[tokio::test]
async fn test_schedule_management_tool_list() {
let temp_dir = TempDir::new().unwrap();
let data_dir = temp_dir.path().to_path_buf();
let session_manager = Arc::new(SessionManager::new(data_dir.clone()));
let permission_manager = Arc::new(PermissionManager::new(data_dir));
let mock_scheduler = Arc::new(MockScheduler::new());
let config = AgentConfig::new(
session_manager,
permission_manager,
Some(mock_scheduler),
GooseMode::Auto,
false,
GoosePlatform::GooseCli,
);
let agent = Agent::with_config(config);
let tools = agent.list_tools("test-session-id", 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 goose's internal scheduled recipe execution"));
}
#[tokio::test]
async fn test_no_schedule_management_tool_without_scheduler() {
let agent = Agent::new();
let tools = agent.list_tools("test-session-id", None).await;
let schedule_tool = tools
.iter()
.find(|tool| tool.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME);
assert!(schedule_tool.is_none());
}
#[tokio::test]
async fn test_schedule_management_tool_in_platform_tools() {
let temp_dir = TempDir::new().unwrap();
let data_dir = temp_dir.path().to_path_buf();
let session_manager = Arc::new(SessionManager::new(data_dir.clone()));
let permission_manager = Arc::new(PermissionManager::new(data_dir));
let mock_scheduler = Arc::new(MockScheduler::new());
let config = AgentConfig::new(
session_manager,
permission_manager,
Some(mock_scheduler),
GooseMode::Auto,
false,
GoosePlatform::GooseCli,
);
let agent = Agent::with_config(config);
let tools = agent
.list_tools("test-session-id", 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 goose's internal scheduled recipe execution"));
// Verify the tool has the expected actions in its schema
if let Some(properties) = tool.input_schema.get("properties") {
if let Some(action_prop) = properties.get("action") {
if let Some(enum_values) = action_prop.get("enum") {
let actions: Vec<String> = enum_values
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
// Check that our session_content action is included
assert!(actions.contains(&"session_content".to_string()));
assert!(actions.contains(&"list".to_string()));
assert!(actions.contains(&"create".to_string()));
assert!(actions.contains(&"sessions".to_string()));
}
}
}
}
#[tokio::test]
async fn test_schedule_management_tool_schema_validation() {
let temp_dir = TempDir::new().unwrap();
let data_dir = temp_dir.path().to_path_buf();
let session_manager = Arc::new(SessionManager::new(data_dir.clone()));
let permission_manager = Arc::new(PermissionManager::new(data_dir));
let mock_scheduler = Arc::new(MockScheduler::new());
let config = AgentConfig::new(
session_manager,
permission_manager,
Some(mock_scheduler),
GooseMode::Auto,
false,
GoosePlatform::GooseCli,
);
let agent = Agent::with_config(config);
let tools = agent.list_tools("test-session-id", 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 retry_tests {
use super::*;
use goose::agents::types::{RetryConfig, SuccessCheck};
#[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::agents::SessionConfig;
use goose::config::GooseMode;
use goose::conversation::message::{Message, MessageContent};
use goose::model::ModelConfig;
use goose::providers::base::{
stream_from_single_message, MessageStream, Provider, ProviderDef, ProviderMetadata,
};
use goose::session::session_manager::SessionType;
use goose_providers::conversation::token_usage::{ProviderUsage, Usage};
use goose_providers::errors::ProviderError;
use rmcp::model::{CallToolRequestParams, Tool};
use rmcp::object;
use std::path::PathBuf;
struct MockToolProvider {}
impl MockToolProvider {
fn new() -> Self {
Self {}
}
}
impl ProviderDef for MockToolProvider {
type Provider = Self;
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![],
setup_steps: vec![],
model_selection_hint: None,
}
}
fn from_env(
_model: ModelConfig,
_extensions: Vec<goose::config::ExtensionConfig>,
) -> futures::future::BoxFuture<'static, anyhow::Result<Self>> {
Box::pin(async { Ok(Self::new()) })
}
}
#[async_trait]
impl Provider for MockToolProvider {
async fn stream(
&self,
_model_config: &ModelConfig,
_session_id: &str,
_system_prompt: &str,
_messages: &[Message],
_tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let tool_call = CallToolRequestParams::new("test_tool")
.with_arguments(object!({"param": "value"}));
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(stream_from_single_message(message, usage))
}
fn get_model_config(&self) -> ModelConfig {
ModelConfig::new("mock-model").unwrap()
}
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());
let user_message = Message::user().with_text("Hello");
let session = agent
.config
.session_manager
.create_session(
PathBuf::default(),
"max-turn-test".to_string(),
SessionType::Hidden,
GooseMode::default(),
)
.await?;
agent.update_provider(provider, &session.id).await?;
let session_config = SessionConfig {
id: session.id,
schedule_id: None,
max_turns: Some(1),
retry_config: None,
};
let reply_stream = agent.reply(user_message, session_config, 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::ActionRequired(action)) =
response.content.first()
{
if let goose::conversation::message::ActionRequiredData::ToolConfirmation { id, .. } = &action.data {
agent.handle_confirmation(
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::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 tool_pair_summarization_tests {
use super::*;
use async_trait::async_trait;
use goose::agents::SessionConfig;
use goose::config::base::Config;
use goose::config::GooseMode;
use goose::conversation::message::Message;
use goose::model::ModelConfig;
use goose::providers::base::{
stream_from_single_message, MessageStream, Provider, ProviderDef, ProviderMetadata,
};
use goose::session::session_manager::SessionType;
use goose_providers::conversation::token_usage::{ProviderUsage, Usage};
use goose_providers::errors::ProviderError;
use rmcp::model::{AnnotateAble, CallToolRequestParams, CallToolResult, RawContent, Tool};
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
/// Mock provider that returns text for the main reply and summaries for
/// summarization calls. Distinguishes by checking if tools are empty
/// (summarization calls pass no tools).
struct SummarizationTestProvider {
summary_count: AtomicUsize,
}
impl SummarizationTestProvider {
fn new() -> Self {
Self {
summary_count: AtomicUsize::new(0),
}
}
}
impl ProviderDef for SummarizationTestProvider {
type Provider = Self;
fn metadata() -> ProviderMetadata {
ProviderMetadata {
name: "mock-summarization".to_string(),
display_name: "Mock Summarization Provider".to_string(),
description: "Mock provider for summarization tests".to_string(),
default_model: "mock-model".to_string(),
known_models: vec![],
model_doc_link: "".to_string(),
config_keys: vec![],
setup_steps: vec![],
model_selection_hint: None,
}
}
fn from_env(
_model: ModelConfig,
_extensions: Vec<goose::config::ExtensionConfig>,
) -> futures::future::BoxFuture<'static, anyhow::Result<Self>> {
Box::pin(async { Ok(Self::new()) })
}
}
#[async_trait]
impl Provider for SummarizationTestProvider {
async fn stream(
&self,
_model_config: &ModelConfig,
_session_id: &str,
system_prompt: &str,
_messages: &[Message],
_tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let message = if system_prompt.contains("summarize a tool call") {
// Summarization call — return a unique summary
let n = self.summary_count.fetch_add(1, Ordering::SeqCst);
Message::assistant().with_text(format!("Summary of tool call #{}", n))
} else {
// Main agent reply — return plain text so the loop exits
Message::assistant().with_text("Done processing.")
};
let usage = ProviderUsage::new(
"mock-model".to_string(),
Usage::new(Some(10), Some(5), Some(15)),
);
Ok(stream_from_single_message(message, usage))
}
fn get_model_config(&self) -> ModelConfig {
ModelConfig::new("mock-model").unwrap()
}
fn get_name(&self) -> &str {
"mock-summarization"
}
}
/// Test that batch tool pair summarization preserves all summaries.
///
/// Pre-populates a session with enough tool call/response pairs to trigger
/// batch summarization, runs agent.reply(), then verifies:
/// - All 10 summaries are present in the final conversation
/// - The original tool pairs are marked invisible
#[tokio::test]
async fn test_batch_summarization_preserves_all_summaries() -> Result<()> {
// Set a low cutoff so we don't need hundreds of tool pairs.
// cutoff=2 means we need >2+10=12 visible tool pairs to trigger.
Config::global()
.set_param("GOOSE_TOOL_CALL_CUTOFF", 2)
.unwrap();
let agent = Agent::new();
let session_manager = agent.config.session_manager.clone();
let provider = Arc::new(SummarizationTestProvider::new());
let session = session_manager
.create_session(
PathBuf::from("."),
"summarization-test".to_string(),
SessionType::Hidden,
GooseMode::Auto,
)
.await?;
agent.update_provider(provider, &session.id).await?;
// Pre-populate 13 tool pairs (need > cutoff + batch_size = 12 to trigger).
// Timestamps in the past so DB ordering places summaries before current turn.
let base_ts = chrono::Utc::now().timestamp() - 100;
let mut initial_msg = Message::user().with_text("help me read some files");
initial_msg.created = base_ts;
session_manager
.add_message(&session.id, &initial_msg)
.await?;
for i in 0..13 {
let call_id = format!("precall_{}", i);
let mut req_msg = Message::assistant()
.with_tool_request(&call_id, Ok(CallToolRequestParams::new("read_file")))
.with_generated_id();
req_msg.created = base_ts + i as i64 + 1;
session_manager.add_message(&session.id, &req_msg).await?;
let mut resp_msg = Message::user()
.with_tool_response(
&call_id,
Ok(CallToolResult::success(vec![RawContent::text(format!(
"content of file {}",
i
))
.no_annotation()])),
)
.with_generated_id();
resp_msg.created = base_ts + i as i64 + 1;
session_manager.add_message(&session.id, &resp_msg).await?;
}
// Send a user message to trigger the reply loop
let user_message = Message::user().with_text("summarize what you found");
let session_config = SessionConfig {
id: session.id.clone(),
schedule_id: None,
max_turns: Some(1),
retry_config: None,
};
let reply_stream = agent.reply(user_message, session_config, None).await?;
tokio::pin!(reply_stream);
// Drain the stream
while let Some(event) = reply_stream.next().await {
match event {
Ok(AgentEvent::Message(_)) => {}
Ok(_) => {}
Err(e) => return Err(e),
}
}
// Load the final session and inspect the conversation
let final_session = session_manager.get_session(&session.id, true).await?;
let conversation = final_session
.conversation
.expect("Session should have a conversation");
let messages = conversation.messages();
// Count summaries: messages that are agent-visible, not user-visible,
// and contain our summary text pattern
let summaries: Vec<&Message> = messages
.iter()
.filter(|m| {
m.metadata.agent_visible
&& !m.metadata.user_visible
&& m.as_concat_text().starts_with("Summary of tool call #")
})
.collect();
assert_eq!(
summaries.len(),
10,
"Expected 10 summaries (one full batch), got {}. Summary texts: {:?}",
summaries.len(),
summaries
.iter()
.map(|m| m.as_concat_text())
.collect::<Vec<_>>()
);
// Verify each summary is unique
let summary_texts: std::collections::HashSet<String> =
summaries.iter().map(|m| m.as_concat_text()).collect();
assert_eq!(summary_texts.len(), 10, "All 10 summaries should be unique");
// Count invisible tool pairs: original pairs that were summarized
// should have agent_visible=false
let invisible_tool_msgs: Vec<&Message> = messages
.iter()
.filter(|m| !m.metadata.agent_visible && (m.is_tool_call() || m.is_tool_response()))
.collect();
// Each summarized pair = 2 invisible messages (request + response)
assert_eq!(
invisible_tool_msgs.len(),
20, // 10 pairs × 2 messages
"Expected 20 invisible tool messages (10 summarized pairs), got {}",
invisible_tool_msgs.len()
);
// Summaries must appear before the current turn's reply, not after it
let agent_visible: Vec<&Message> = messages
.iter()
.filter(|m| m.metadata.agent_visible)
.collect();
let last_summary_pos = agent_visible
.iter()
.rposition(|m| m.as_concat_text().starts_with("Summary of tool call #"))
.expect("Should have at least one summary");
let agent_reply_pos = agent_visible
.iter()
.position(|m| m.as_concat_text().contains("Done processing."))
.expect("Should have the agent reply");
assert!(
last_summary_pos < agent_reply_pos,
"Summaries appeared after the current turn's reply: last_summary={}, reply={}",
last_summary_pos,
agent_reply_pos,
);
// Clean up the config override
Config::global().delete("GOOSE_TOOL_CALL_CUTOFF").unwrap();
Ok(())
}
}
#[cfg(test)]
mod extension_manager_tests {
use super::*;
use goose::agents::extension::ExtensionConfig;
use goose::agents::platform_extensions::{
MANAGE_EXTENSIONS_TOOL_NAME, SEARCH_AVAILABLE_EXTENSIONS_TOOL_NAME,
};
use goose::agents::AgentConfig;
use goose::config::permission::PermissionManager;
use goose::config::GooseMode;
use goose::session::SessionManager;
async fn setup_agent_with_extension_manager() -> (Agent, String) {
use goose::session::session_manager::SessionType;
// 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(),
display_name: Some("Todo".to_string()),
bundled: Some(true),
available_tools: vec![],
},
};
set_extension(todo_extension_entry);
// Create agent with session_id from the start
let temp_dir = tempfile::tempdir().unwrap();
let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf()));
let config = AgentConfig::new(
session_manager.clone(),
PermissionManager::instance(),
None,
GooseMode::default(),
false,
GoosePlatform::GooseCli,
);
let agent = Agent::with_config(config);
let session = session_manager
.create_session(
std::path::PathBuf::from("."),
"Test Session".to_string(),
SessionType::Hidden,
GooseMode::default(),
)
.await
.expect("Failed to create session");
let session_id = session.id;
// Now add the extension manager platform extension
let ext_config = ExtensionConfig::Platform {
name: "extensionmanager".to_string(),
description: "Extension Manager".to_string(),
display_name: Some("Extension Manager".to_string()),
bundled: Some(true),
available_tools: vec![],
};
agent
.add_extension(ext_config, &session_id)
.await
.expect("Failed to add extension manager");
(agent, session_id)
}
#[tokio::test]
async fn test_extension_manager_tools_available() {
let (agent, session_id) = setup_agent_with_extension_manager().await;
let tools = agent.list_tools(&session_id, 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"
);
}
}
#[cfg(test)]
mod streaming_persistence_tests {
use super::*;
use async_trait::async_trait;
use goose::agents::{AgentConfig, SessionConfig};
use goose::config::permission::PermissionManager;
use goose::config::GooseMode;
use goose::conversation::message::Message;
use goose::model::ModelConfig;
use goose::providers::base::{MessageStream, Provider, ProviderDef, ProviderMetadata};
use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use goose_providers::conversation::token_usage::{ProviderUsage, Usage};
use goose_providers::errors::ProviderError;
use rmcp::model::{CallToolRequestParams, Role, Tool};
use rmcp::object;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio_util::sync::CancellationToken;
struct MultiStepProvider {
call_count: AtomicUsize,
cancel_token: CancellationToken,
}
impl MultiStepProvider {
fn new(cancel_token: CancellationToken) -> Self {
Self {
call_count: AtomicUsize::new(0),
cancel_token,
}
}
}
impl ProviderDef for MultiStepProvider {
type Provider = Self;
fn metadata() -> ProviderMetadata {
ProviderMetadata {
name: "multi-step-mock".to_string(),
display_name: "Multi-Step Mock".to_string(),
description: "Mock provider for streaming persistence tests".to_string(),
default_model: "mock-model".to_string(),
known_models: vec![],
model_doc_link: "".to_string(),
config_keys: vec![],
setup_steps: vec![],
model_selection_hint: None,
}
}
fn from_env(
_model: ModelConfig,
_extensions: Vec<goose::config::ExtensionConfig>,
) -> futures::future::BoxFuture<'static, anyhow::Result<Self>> {
unimplemented!()
}
}
#[async_trait]
impl Provider for MultiStepProvider {
async fn stream(
&self,
_model_config: &ModelConfig,
_session_id: &str,
_system_prompt: &str,
_messages: &[Message],
_tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let call = self.call_count.fetch_add(1, Ordering::SeqCst);
let usage = ProviderUsage::new(
"mock-model".to_string(),
Usage::new(Some(10), Some(5), Some(15)),
);
match call {
0 => {
let tool_call = CallToolRequestParams::new("test_tool")
.with_arguments(object!({"param": "value"}));
let message =
Message::assistant().with_tool_request("call_1", Ok(tool_call));
let stream =
futures::stream::once(async move { Ok((Some(message), Some(usage))) });
Ok(Box::pin(stream))
}
1 => {
let msg_id = format!("msg_{}", uuid::Uuid::new_v4());
let tokens = vec!["Hello", " world", ", how", " are", " you?"];
let stream = futures::stream::iter(tokens.into_iter().enumerate().map(
move |(i, token)| {
let msg = Message::assistant()
.with_text(token)
.with_id(msg_id.clone());
let u = if i == 4 { Some(usage.clone()) } else { None };
Ok((Some(msg), u))
},
));
Ok(Box::pin(stream))
}
_ => {
let cancel = self.cancel_token.clone();
let msg_id = format!("msg_{}", uuid::Uuid::new_v4());
let tokens = vec!["This ", "should ", "be ", "cancelled ", "soon."];
let stream = futures::stream::iter(tokens.into_iter().enumerate().map(
move |(i, token)| {
if i == 1 {
cancel.cancel();
}
let msg = Message::assistant()
.with_text(token)
.with_id(msg_id.clone());
let u = if i == 4 { Some(usage.clone()) } else { None };
Ok((Some(msg), u))
},
));
Ok(Box::pin(stream))
}
}
}
fn get_model_config(&self) -> ModelConfig {
ModelConfig::new("mock-model").unwrap()
}
fn get_name(&self) -> &str {
"multi-step-mock"
}
}
#[tokio::test]
async fn test_streaming_text_not_persisted_per_token() -> Result<()> {
let cancel_token = CancellationToken::new();
let temp_dir = tempfile::tempdir()?;
let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf()));
let config = AgentConfig::new(
session_manager.clone(),
PermissionManager::instance(),
None,
GooseMode::Auto,
true, // disable session naming so it doesn't consume a provider call
GoosePlatform::GooseCli,
);
let agent = Agent::with_config(config);
let provider = Arc::new(MultiStepProvider::new(cancel_token.clone()));
let session = session_manager
.create_session(
PathBuf::default(),
"streaming-test".to_string(),
SessionType::Hidden,
GooseMode::default(),
)
.await?;
let session_id = session.id.clone();
agent.update_provider(provider, &session_id).await?;
// ── Single reply: tool call (call 0) → text stream (call 1) → cancelled text (call 2)
// max_turns=3 allows all three provider calls within one reply().
// call 0: tool call → agent executes tool, loops
// call 1: 5 text deltas → no tools called, agent exits loop
// call 2: 5 text deltas, cancel token fired after 1st → agent interrupted
//
// Because call 1 ends the agent loop (no_tools_called=true → exit),
// call 2 is NOT reached in the same reply. We issue a second reply()
// with the cancel token so the provider triggers cancellation.
let session_config = SessionConfig {
id: session_id.clone(),
schedule_id: None,
max_turns: Some(2),
retry_config: None,
};
let reply_stream = agent
.reply(
Message::user().with_text("Do something then say hello"),
session_config,
None,
)
.await?;
tokio::pin!(reply_stream);
while let Some(event) = reply_stream.next().await {
match event {
Ok(AgentEvent::Message(_)) => {}
Ok(_) => {}
Err(e) => return Err(e),
}
}
// ── Check persisted state after reply 1 ─────────────────
let reloaded = session_manager.get_session(&session_id, true).await?;
let messages = reloaded
.conversation
.expect("should have conversation")
.messages()
.to_vec();
let user_count = messages.iter().filter(|m| m.role == Role::User).count();
let asst_count = messages
.iter()
.filter(|m| m.role == Role::Assistant)
.count();
// Expected: user(prompt) + assistant(tool-req) + user(tool-resp) + assistant(text)
assert_eq!(
user_count, 2,
"Expected 2 user messages (prompt + tool response), got {user_count}",
);
assert_eq!(
asst_count, 2,
"Expected 2 assistant messages (tool request + text reply), got {asst_count} \
— streaming text deltas are being persisted as separate messages",
);
// ── Reply 2: text stream with provider-triggered cancellation (call 2)
let session_config2 = SessionConfig {
id: session_id.clone(),
schedule_id: None,
max_turns: Some(2),
retry_config: None,
};
let reply_stream2 = agent
.reply(
Message::user().with_text("Tell me more"),
session_config2,
Some(cancel_token),
)
.await?;
tokio::pin!(reply_stream2);
while let Some(event) = reply_stream2.next().await {
match event {
Ok(_) => {}
Err(e) => return Err(e),
}
}
// ── Check persisted state after cancellation ────────────
let reloaded2 = session_manager.get_session(&session_id, true).await?;
let messages2 = reloaded2
.conversation
.expect("should have conversation")
.messages()
.to_vec();
let user_count2 = messages2.iter().filter(|m| m.role == Role::User).count();
let asst_count2 = messages2
.iter()
.filter(|m| m.role == Role::Assistant)
.count();
// Reply 2 added 1 user message. The cancelled stream should
// have persisted at most 1 (partial) assistant message.
assert_eq!(
user_count2, 3,
"Expected 3 user messages (2 from reply 1 + follow-up), got {user_count2}",
);
assert!(
asst_count2 <= 3,
"Expected at most 3 assistant messages (2 from reply 1 + at most 1 partial \
from cancelled reply 2), got {asst_count2} \
— streaming deltas are leaking into persistence",
);
Ok(())
}
}
#[cfg(test)]
mod goal_checking_tests {
use super::*;
use async_trait::async_trait;
use goose::agents::AgentConfig;
use goose::agents::SessionConfig;
use goose::config::permission::PermissionManager;
use goose::config::GooseMode;
use goose::conversation::message::Message;
use goose::model::ModelConfig;
use goose::providers::base::{
stream_from_single_message, MessageStream, Provider, ProviderDef, ProviderMetadata,
};
use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use goose_providers::conversation::token_usage::{ProviderUsage, Usage};
use goose_providers::errors::ProviderError;
use rmcp::model::Tool;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use tempfile::TempDir;
struct GoalTextProvider {
call_count: AtomicU32,
}
impl GoalTextProvider {
fn new() -> Self {
Self {
call_count: AtomicU32::new(0),
}
}
}
impl ProviderDef for GoalTextProvider {
type Provider = Self;
fn metadata() -> ProviderMetadata {
ProviderMetadata {
name: "goal-mock".to_string(),
display_name: "Goal Mock Provider".to_string(),
description: "Mock provider for goal testing".to_string(),
default_model: "mock-model".to_string(),
known_models: vec![],
model_doc_link: "".to_string(),
config_keys: vec![],
setup_steps: vec![],
model_selection_hint: None,
}
}
fn from_env(
_model: ModelConfig,
_extensions: Vec<goose::config::ExtensionConfig>,
) -> futures::future::BoxFuture<'static, anyhow::Result<Self>> {
Box::pin(async { Ok(Self::new()) })
}
}
#[async_trait]
impl Provider for GoalTextProvider {
async fn stream(
&self,
_model_config: &ModelConfig,
_session_id: &str,
_system_prompt: &str,
_messages: &[Message],
_tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
let text = format!("Response number {count}");
let message = Message::assistant().with_text(&text);
let usage = ProviderUsage::new(
"mock-model".to_string(),
Usage::new(Some(10), Some(5), Some(15)),
);
Ok(stream_from_single_message(message, usage))
}
fn get_model_config(&self) -> ModelConfig {
ModelConfig::new("mock-model").unwrap()
}
fn get_name(&self) -> &str {
"goal-mock"
}
}
fn create_agent_with_session_naming_disabled(
session_manager: Arc<SessionManager>,
) -> Agent {
let config = AgentConfig::new(
session_manager,
PermissionManager::instance(),
None,
GooseMode::Auto,
true,
GoosePlatform::GooseCli,
);
Agent::with_config(config)
}
#[tokio::test]
async fn test_goal_nudges_agent_before_exit() -> Result<()> {
let temp_dir = TempDir::new()?;
let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf()));
let agent = create_agent_with_session_naming_disabled(session_manager.clone());
let provider = Arc::new(GoalTextProvider::new());
let session = session_manager
.create_session(
PathBuf::default(),
"goal-test".to_string(),
SessionType::Hidden,
GooseMode::default(),
)
.await?;
agent.update_provider(provider.clone(), &session.id).await?;
agent
.set_goal(Some("Ensure the sky is blue".to_string()))
.await;
let session_config = SessionConfig {
id: session.id.clone(),
schedule_id: None,
max_turns: Some(10),
retry_config: None,
};
let reply_stream = agent
.reply(Message::user().with_text("Hello"), session_config, None)
.await?;
tokio::pin!(reply_stream);
let mut messages = Vec::new();
while let Some(event) = reply_stream.next().await {
match event {
Ok(AgentEvent::Message(msg)) => messages.push(msg),
Ok(_) => {}
Err(e) => return Err(e),
}
}
let call_count = provider.call_count.load(Ordering::SeqCst);
assert!(
call_count > 1,
"Expected provider to be called more than once due to goal checking, got {call_count}"
);
assert!(
call_count <= 3,
"Expected at most 3 provider calls (1 initial + 1 goal check + 1 exit), got {call_count}"
);
// The goal nudge should NOT appear in yielded events (it's internal)
let nudge_messages: Vec<_> = messages
.iter()
.filter(|m| {
m.as_concat_text()
.contains("check whether the following goal")
})
.collect();
assert!(
nudge_messages.is_empty(),
"Goal nudge should be hidden from user, but found {} in events",
nudge_messages.len()
);
// Goal should be cleared after being met
assert_eq!(
agent.get_goal().await,
None,
"Goal should be cleared after the agent finishes with it met"
);
Ok(())
}
#[tokio::test]
async fn test_no_goal_exits_immediately() -> Result<()> {
let temp_dir = TempDir::new()?;
let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf()));
let agent = create_agent_with_session_naming_disabled(session_manager.clone());
let provider = Arc::new(GoalTextProvider::new());
let session = session_manager
.create_session(
PathBuf::default(),
"no-goal-test".to_string(),
SessionType::Hidden,
GooseMode::default(),
)
.await?;
agent.update_provider(provider.clone(), &session.id).await?;
let session_config = SessionConfig {
id: session.id.clone(),
schedule_id: None,
max_turns: Some(10),
retry_config: None,
};
let reply_stream = agent
.reply(Message::user().with_text("Hello"), session_config, None)
.await?;
tokio::pin!(reply_stream);
while let Some(event) = reply_stream.next().await {
match event {
Ok(_) => {}
Err(e) => return Err(e),
}
}
let call_count = provider.call_count.load(Ordering::SeqCst);
assert_eq!(
call_count, 1,
"Without a goal, provider should be called exactly once, got {call_count}"
);
Ok(())
}
#[tokio::test]
async fn test_goal_command_set_and_clear() -> Result<()> {
let temp_dir = TempDir::new()?;
let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf()));
let agent = create_agent_with_session_naming_disabled(session_manager.clone());
let session = session_manager
.create_session(
PathBuf::default(),
"goal-cmd-test".to_string(),
SessionType::Hidden,
GooseMode::default(),
)
.await?;
// No goal initially
let result = agent.execute_command("/goal", &session.id).await?.unwrap();
assert!(result.as_concat_text().contains("No goal set"));
// Set a goal
let result = agent
.execute_command("/goal make all tests pass", &session.id)
.await?
.unwrap();
assert!(result.as_concat_text().contains("Goal set"));
assert_eq!(
agent.get_goal().await,
Some("make all tests pass".to_string())
);
// Query it
let result = agent.execute_command("/goal", &session.id).await?.unwrap();
assert!(result.as_concat_text().contains("make all tests pass"));
// Clear it
let result = agent
.execute_command("/goal off", &session.id)
.await?
.unwrap();
assert!(result.as_concat_text().contains("cleared"));
assert_eq!(agent.get_goal().await, None);
Ok(())
}
}
mod cumulative_token_tests {
use super::*;
use async_trait::async_trait;
use goose::agents::{AgentConfig, SessionConfig};
use goose::config::permission::PermissionManager;
use goose::config::GooseMode;
use goose::conversation::message::Message;
use goose::model::ModelConfig;
use goose::providers::base::{stream_from_single_message, MessageStream, Provider};
use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use goose_providers::conversation::token_usage::{ProviderUsage, Usage};
use goose_providers::errors::ProviderError;
use rmcp::model::Tool;
use std::path::PathBuf;
use std::sync::Arc;
struct FixedUsageProvider {
input_tokens: i32,
output_tokens: i32,
}
#[async_trait]
impl Provider for FixedUsageProvider {
async fn stream(
&self,
_model_config: &ModelConfig,
_session_id: &str,
_system_prompt: &str,
_messages: &[Message],
_tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let total = self.input_tokens + self.output_tokens;
let usage = ProviderUsage::new(
"mock-model".to_string(),
Usage::new(
Some(self.input_tokens),
Some(self.output_tokens),
Some(total),
),
);
let message = Message::assistant().with_text("Hello");
Ok(stream_from_single_message(message, usage))
}
fn get_model_config(&self) -> ModelConfig {
ModelConfig::new("mock-model").unwrap()
}
fn get_name(&self) -> &str {
"fixed-usage-mock"
}
}
async fn run_turn(agent: &Agent, session_id: &str, text: &str) -> Result<()> {
let session_config = SessionConfig {
id: session_id.to_string(),
schedule_id: None,
max_turns: Some(1),
retry_config: None,
};
let stream = agent
.reply(Message::user().with_text(text), session_config, None)
.await?;
tokio::pin!(stream);
while let Some(event) = stream.next().await {
let _ = event?;
}
Ok(())
}
#[tokio::test]
async fn test_accumulated_total_tokens_across_multiple_turns() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf()));
let config = AgentConfig::new(
session_manager.clone(),
PermissionManager::instance(),
None,
GooseMode::Auto,
true,
GoosePlatform::GooseCli,
);
let agent = Agent::with_config(config);
let provider = Arc::new(FixedUsageProvider {
input_tokens: 10,
output_tokens: 5,
});
let session = session_manager
.create_session(
PathBuf::default(),
"cumulative-token-test".to_string(),
SessionType::Hidden,
GooseMode::default(),
)
.await?;
let session_id = session.id.clone();
agent.update_provider(provider.clone(), &session_id).await?;
run_turn(&agent, &session_id, "Turn 1").await?;
let after_1 = session_manager.get_session(&session_id, false).await?;
assert_eq!(after_1.accumulated_total_tokens, Some(15));
run_turn(&agent, &session_id, "Turn 2").await?;
let after_2 = session_manager.get_session(&session_id, false).await?;
assert_eq!(after_2.accumulated_total_tokens, Some(30));
assert_eq!(after_2.total_tokens, Some(15));
Ok(())
}
}
mod frontend_extension_tests {
use super::*;
use goose::agents::{AgentConfig, ExtensionConfig};
use goose::config::permission::PermissionManager;
use goose::config::GooseMode;
use goose::session::session_manager::SessionType;
use goose::session::{
EnabledExtensionsState, ExtensionData, ExtensionState, SessionManager,
};
use rmcp::model::Tool;
use rmcp::object;
use tempfile::TempDir;
fn frontend_extension_with_tool(name: &str, tool_name: &str) -> ExtensionConfig {
ExtensionConfig::Frontend {
name: name.to_string(),
description: format!("Frontend test extension {name}"),
tools: vec![Tool::new(
tool_name.to_string(),
format!("Run {tool_name} from the frontend"),
object!({
"type": "object",
"properties": {
"message": { "type": "string" }
},
"required": ["message"]
}),
)],
instructions: Some(format!("Use the {tool_name} tool.")),
bundled: None,
available_tools: vec![],
}
}
fn frontend_extension() -> ExtensionConfig {
frontend_extension_with_tool("frontend-e2e", "frontend__echo")
}
#[tokio::test]
async fn test_frontend_extensions_are_persisted_listed_and_removed() {
let temp_dir = TempDir::new().unwrap();
let data_dir = temp_dir.path().to_path_buf();
let session_manager = Arc::new(SessionManager::new(data_dir.clone()));
let permission_manager = Arc::new(PermissionManager::new(data_dir));
let agent = Agent::with_config(AgentConfig::new(
session_manager.clone(),
permission_manager,
None,
GooseMode::default(),
false,
GoosePlatform::GooseDesktop,
));
let session = session_manager
.create_session(
std::env::current_dir().unwrap(),
"frontend-extension-test".to_string(),
SessionType::Hidden,
GooseMode::default(),
)
.await
.unwrap();
agent
.add_extension(frontend_extension(), &session.id)
.await
.unwrap();
let listed_tools = agent.list_tools(&session.id, None).await;
assert!(listed_tools
.iter()
.any(|tool| tool.name == "frontend__echo"));
let filtered_tools = agent
.list_tools(&session.id, Some("frontend-e2e".to_string()))
.await;
assert_eq!(filtered_tools.len(), 1);
assert_eq!(filtered_tools[0].name, "frontend__echo");
let extension_names = agent.list_extensions().await;
assert!(extension_names.iter().any(|name| name == "frontend-e2e"));
let persisted_session = session_manager
.get_session(&session.id, false)
.await
.unwrap();
let persisted_extensions =
EnabledExtensionsState::from_extension_data(&persisted_session.extension_data)
.unwrap()
.extensions;
assert!(persisted_extensions
.iter()
.any(|extension| extension.name() == "frontend-e2e"));
agent
.remove_extension("frontend-e2e", &session.id)
.await
.unwrap();
let listed_tools = agent.list_tools(&session.id, None).await;
assert!(!listed_tools
.iter()
.any(|tool| tool.name == "frontend__echo"));
let persisted_session = session_manager
.get_session(&session.id, false)
.await
.unwrap();
let persisted_extensions =
EnabledExtensionsState::from_extension_data(&persisted_session.extension_data)
.unwrap()
.extensions;
assert!(persisted_extensions
.iter()
.all(|extension| extension.name() != "frontend-e2e"));
}
#[tokio::test]
async fn test_concurrent_frontend_session_load_keeps_all_tools() {
let temp_dir = TempDir::new().unwrap();
let data_dir = temp_dir.path().to_path_buf();
let session_manager = Arc::new(SessionManager::new(data_dir.clone()));
let permission_manager = Arc::new(PermissionManager::new(data_dir));
let agent = Arc::new(Agent::with_config(AgentConfig::new(
session_manager.clone(),
permission_manager,
None,
GooseMode::default(),
false,
GoosePlatform::GooseDesktop,
)));
let session = session_manager
.create_session(
std::env::current_dir().unwrap(),
"frontend-extension-load-test".to_string(),
SessionType::Hidden,
GooseMode::default(),
)
.await
.unwrap();
let expected_tools = (0..12)
.map(|index| format!("frontend__tool_{index}"))
.collect::<Vec<_>>();
let extensions = expected_tools
.iter()
.enumerate()
.map(|(index, tool_name)| {
frontend_extension_with_tool(&format!("frontend-{index}"), tool_name)
})
.collect::<Vec<_>>();
let mut extension_data = ExtensionData::new();
EnabledExtensionsState::new(extensions)
.to_extension_data(&mut extension_data)
.unwrap();
session_manager
.update(&session.id)
.extension_data(extension_data)
.apply()
.await
.unwrap();
let session = session_manager
.get_session(&session.id, false)
.await
.unwrap();
let load_results = agent.load_extensions_from_session(&session).await;
assert!(
load_results.iter().all(|result| result.success),
"failed to load frontend extensions: {load_results:?}",
);
let listed_tools = agent.list_tools(&session.id, None).await;
for tool_name in expected_tools {
assert!(
listed_tools.iter().any(|tool| tool.name == tool_name),
"expected listed frontend tool {tool_name}",
);
assert!(
agent.is_frontend_tool(&tool_name).await,
"expected frontend dispatch state for {tool_name}",
);
}
}
}
}