mod cli;
mod commands;
mod commands_dev;
mod commands_file;
mod commands_git;
mod commands_project;
mod commands_refactor;
mod commands_search;
mod commands_session;
mod docs;
mod format;
mod git;
mod help;
mod hooks;
mod memory;
mod prompt;
mod repl;
mod setup;
mod tools;
use cli::*;
use format::*;
use prompt::*;
use tools::{build_sub_agent_tool, build_tools};
use std::io::{self, IsTerminal, Read};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use yoagent::agent::Agent;
use yoagent::context::{ContextConfig, ExecutionLimits};
use yoagent::openapi::{OpenApiConfig, OperationFilter};
use yoagent::provider::{
AnthropicProvider, ApiProtocol, BedrockProvider, GoogleProvider, ModelConfig, OpenAiCompat,
OpenAiCompatProvider,
};
use yoagent::*;
static CHECKPOINT_TRIGGERED: AtomicBool = AtomicBool::new(false);
fn yoyo_user_agent() -> String {
format!("yoyo/{}", env!("CARGO_PKG_VERSION"))
}
fn insert_client_headers(config: &mut ModelConfig) {
config
.headers
.insert("User-Agent".to_string(), yoyo_user_agent());
if config.provider == "openrouter" {
config.headers.insert(
"HTTP-Referer".to_string(),
"https://github.com/yologdev/yoyo-evolve".to_string(),
);
config
.headers
.insert("X-Title".to_string(), "yoyo".to_string());
}
}
pub fn create_model_config(provider: &str, model: &str, base_url: Option<&str>) -> ModelConfig {
let mut config = match provider {
"openai" => {
let mut config = ModelConfig::openai(model, model);
if let Some(url) = base_url {
config.base_url = url.to_string();
}
config
}
"google" => {
let mut config = ModelConfig::google(model, model);
if let Some(url) = base_url {
config.base_url = url.to_string();
}
config
}
"ollama" => {
let url = base_url.unwrap_or("http://localhost:11434/v1");
ModelConfig::local(url, model)
}
"openrouter" => {
let mut config = ModelConfig::openai(model, model);
config.provider = "openrouter".into();
config.base_url = base_url
.unwrap_or("https://openrouter.ai/api/v1")
.to_string();
config.compat = Some(OpenAiCompat::openrouter());
config
}
"xai" => {
let mut config = ModelConfig::openai(model, model);
config.provider = "xai".into();
config.base_url = base_url.unwrap_or("https://api.x.ai/v1").to_string();
config.compat = Some(OpenAiCompat::xai());
config
}
"groq" => {
let mut config = ModelConfig::openai(model, model);
config.provider = "groq".into();
config.base_url = base_url
.unwrap_or("https://api.groq.com/openai/v1")
.to_string();
config.compat = Some(OpenAiCompat::groq());
config
}
"deepseek" => {
let mut config = ModelConfig::openai(model, model);
config.provider = "deepseek".into();
config.base_url = base_url
.unwrap_or("https://api.deepseek.com/v1")
.to_string();
config.compat = Some(OpenAiCompat::deepseek());
config
}
"mistral" => {
let mut config = ModelConfig::openai(model, model);
config.provider = "mistral".into();
config.base_url = base_url.unwrap_or("https://api.mistral.ai/v1").to_string();
config.compat = Some(OpenAiCompat::mistral());
config
}
"cerebras" => {
let mut config = ModelConfig::openai(model, model);
config.provider = "cerebras".into();
config.base_url = base_url.unwrap_or("https://api.cerebras.ai/v1").to_string();
config.compat = Some(OpenAiCompat::cerebras());
config
}
"zai" => {
let mut config = ModelConfig::zai(model, model);
if let Some(url) = base_url {
config.base_url = url.to_string();
}
config
}
"minimax" => {
let mut config = ModelConfig::minimax(model, model);
if let Some(url) = base_url {
config.base_url = url.to_string();
}
config
}
"bedrock" => {
let url = base_url.unwrap_or("https://bedrock-runtime.us-east-1.amazonaws.com");
ModelConfig {
id: model.into(),
name: model.into(),
api: ApiProtocol::BedrockConverseStream,
provider: "bedrock".into(),
base_url: url.to_string(),
reasoning: false,
context_window: 200_000,
max_tokens: 8192,
cost: Default::default(),
headers: std::collections::HashMap::new(),
compat: None,
}
}
"custom" => {
let url = base_url.unwrap_or("http://localhost:8080/v1");
ModelConfig::local(url, model)
}
_ => {
eprintln!(
"{}warning:{} treating unknown provider '{}' as OpenAI-compatible (localhost:8080)",
crate::format::YELLOW,
crate::format::RESET,
provider
);
let url = base_url.unwrap_or("http://localhost:8080/v1");
let mut config = ModelConfig::local(url, model);
config.provider = provider.to_string();
config
}
};
insert_client_headers(&mut config);
config
}
pub struct AgentConfig {
pub model: String,
pub api_key: String,
pub provider: String,
pub base_url: Option<String>,
pub skills: yoagent::skills::SkillSet,
pub system_prompt: String,
pub thinking: ThinkingLevel,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub max_turns: Option<usize>,
pub auto_approve: bool,
pub permissions: cli::PermissionConfig,
pub dir_restrictions: cli::DirectoryRestrictions,
pub context_strategy: cli::ContextStrategy,
pub context_window: Option<u32>,
pub shell_hooks: Vec<hooks::ShellHook>,
pub fallback_provider: Option<String>,
pub fallback_model: Option<String>,
}
impl AgentConfig {
fn configure_agent(&self, mut agent: Agent, model_context_window: u32) -> Agent {
let effective_window = self.context_window.unwrap_or(model_context_window);
let effective_tokens = (effective_window as u64) * 80 / 100;
cli::set_effective_context_tokens(effective_window as u64);
agent = agent
.with_system_prompt(&self.system_prompt)
.with_model(&self.model)
.with_api_key(&self.api_key)
.with_thinking(self.thinking)
.with_skills(self.skills.clone())
.with_tools(build_tools(
self.auto_approve,
&self.permissions,
&self.dir_restrictions,
if io::stdin().is_terminal() {
TOOL_OUTPUT_MAX_CHARS
} else {
TOOL_OUTPUT_MAX_CHARS_PIPED
},
is_audit_enabled(),
self.shell_hooks.clone(),
));
agent = agent.with_sub_agent(build_sub_agent_tool(self));
agent = agent.with_context_config(ContextConfig {
max_context_tokens: effective_tokens as usize,
system_prompt_tokens: 4_000,
keep_recent: 10,
keep_first: 2,
tool_output_max_lines: 50,
});
agent = agent.with_execution_limits(ExecutionLimits {
max_turns: self.max_turns.unwrap_or(200),
max_total_tokens: 1_000_000,
..ExecutionLimits::default()
});
if let Some(max) = self.max_tokens {
agent = agent.with_max_tokens(max);
}
if let Some(temp) = self.temperature {
agent.temperature = Some(temp);
}
if self.context_strategy == cli::ContextStrategy::Checkpoint {
let max_tokens = effective_tokens;
let threshold = cli::PROACTIVE_COMPACT_THRESHOLD; agent = agent.on_before_turn(move |messages, _turn| {
let used = yoagent::context::total_tokens(messages) as u64;
let ratio = used as f64 / max_tokens as f64;
if ratio > threshold {
eprintln!(
"\n⚡ Context at {:.0}% — checkpoint-restart triggered",
ratio * 100.0
);
CHECKPOINT_TRIGGERED.store(true, Ordering::SeqCst);
return false; }
true
});
}
agent
}
pub fn build_agent(&self) -> Agent {
let base_url = self.base_url.as_deref();
if self.provider == "anthropic" && base_url.is_none() {
let mut model_config = ModelConfig::anthropic(&self.model, &self.model);
insert_client_headers(&mut model_config);
let context_window = model_config.context_window;
let agent = Agent::new(AnthropicProvider).with_model_config(model_config);
self.configure_agent(agent, context_window)
} else if self.provider == "google" {
let model_config = create_model_config(&self.provider, &self.model, base_url);
let context_window = model_config.context_window;
let agent = Agent::new(GoogleProvider).with_model_config(model_config);
self.configure_agent(agent, context_window)
} else if self.provider == "bedrock" {
let model_config = create_model_config(&self.provider, &self.model, base_url);
let context_window = model_config.context_window;
let agent = Agent::new(BedrockProvider).with_model_config(model_config);
self.configure_agent(agent, context_window)
} else {
let model_config = create_model_config(&self.provider, &self.model, base_url);
let context_window = model_config.context_window;
let agent = Agent::new(OpenAiCompatProvider).with_model_config(model_config);
self.configure_agent(agent, context_window)
}
}
pub fn try_switch_to_fallback(&mut self) -> bool {
let fallback = match self.fallback_provider {
Some(ref f) => f.clone(),
None => return false,
};
if self.provider == fallback {
return false;
}
self.provider = fallback.clone();
self.model = self
.fallback_model
.clone()
.unwrap_or_else(|| cli::default_model_for_provider(&fallback));
if let Some(env_var) = cli::provider_api_key_env(&fallback) {
if let Ok(key) = std::env::var(env_var) {
self.api_key = key;
}
}
true
}
}
enum FallbackRetry<'a> {
Text(&'a str),
Content(Vec<Content>),
}
async fn try_fallback_prompt(
agent_config: &mut AgentConfig,
agent: &mut Agent,
retry: FallbackRetry<'_>,
session_total: &mut Usage,
original_response: PromptOutcome,
) -> (PromptOutcome, bool) {
if original_response.last_api_error.is_none() {
return (original_response, false);
}
let old_provider = agent_config.provider.clone();
let fallback_name = agent_config.fallback_provider.clone();
if !agent_config.try_switch_to_fallback() {
eprintln!("{RED} API error with no fallback configured. Exiting.{RESET}",);
return (original_response, true);
}
let fallback = fallback_name.as_deref().unwrap_or("unknown");
eprintln!(
"{YELLOW} ⚡ Primary provider '{}' failed. Switching to fallback '{}'...{RESET}",
old_provider, fallback
);
*agent = agent_config.build_agent();
eprintln!(
"{DIM} now using: {} / {}{RESET}",
agent_config.provider, agent_config.model
);
let retry_response = match retry {
FallbackRetry::Text(input) => {
run_prompt(agent, input, session_total, &agent_config.model).await
}
FallbackRetry::Content(blocks) => {
run_prompt_with_content(agent, blocks, session_total, &agent_config.model).await
}
};
if retry_response.last_api_error.is_some() {
eprintln!(
"{RED} Fallback provider '{}' also failed. Exiting.{RESET}",
fallback
);
return (retry_response, true);
}
(retry_response, false)
}
fn build_json_output(
response: &PromptOutcome,
model: &str,
usage: &Usage,
is_error: bool,
) -> String {
let cost_usd = estimate_cost(usage, model);
let json_obj = serde_json::json!({
"response": response.text,
"model": model,
"usage": {
"input_tokens": usage.input,
"output_tokens": usage.output,
},
"cost_usd": cost_usd,
"is_error": is_error,
});
serde_json::to_string(&json_obj).unwrap_or_else(|_| "{}".to_string())
}
#[tokio::main]
async fn main() {
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--no-color") || !io::stdout().is_terminal() {
disable_color();
}
if args.iter().any(|a| a == "--no-bell") {
disable_bell();
}
let Some(config) = parse_args(&args) else {
return; };
if config.print_system_prompt {
println!("{}", config.system_prompt);
return;
}
if config.verbose {
enable_verbose();
}
if config.audit {
prompt::enable_audit_log();
}
let continue_session = config.continue_session;
let output_path = config.output_path;
let mcp_servers = config.mcp_servers;
let openapi_specs = config.openapi_specs;
let image_path = config.image_path;
let no_update_check = config.no_update_check;
let json_output = config.json_output;
let is_interactive = io::stdin().is_terminal() && config.prompt_arg.is_none();
let auto_approve = config.auto_approve || !is_interactive;
let mut agent_config = AgentConfig {
model: config.model,
api_key: config.api_key,
provider: config.provider,
base_url: config.base_url,
skills: config.skills,
system_prompt: config.system_prompt,
thinking: config.thinking,
max_tokens: config.max_tokens,
temperature: config.temperature,
max_turns: config.max_turns,
auto_approve,
permissions: config.permissions,
dir_restrictions: config.dir_restrictions,
context_strategy: config.context_strategy,
context_window: config.context_window,
shell_hooks: config.shell_hooks,
fallback_provider: config.fallback_provider,
fallback_model: config.fallback_model,
};
if is_interactive && setup::needs_setup(&agent_config.provider) {
if let Some(result) = setup::run_setup_wizard() {
agent_config.provider = result.provider.clone();
agent_config.api_key = result.api_key.clone();
agent_config.model = result.model;
if result.base_url.is_some() {
agent_config.base_url = result.base_url;
}
if let Some(env_var) = cli::provider_api_key_env(&result.provider) {
unsafe {
std::env::set_var(env_var, &result.api_key);
}
}
} else {
cli::print_welcome();
return;
}
}
if agent_config.provider == "bedrock" && !agent_config.api_key.contains(':') {
let access_key = agent_config.api_key.clone();
if let Ok(secret) = std::env::var("AWS_SECRET_ACCESS_KEY") {
agent_config.api_key = match std::env::var("AWS_SESSION_TOKEN") {
Ok(token) if !token.is_empty() => format!("{access_key}:{secret}:{token}"),
_ => format!("{access_key}:{secret}"),
};
}
}
let mut agent = agent_config.build_agent();
let mut mcp_count = 0u32;
for mcp_cmd in &mcp_servers {
let parts: Vec<&str> = mcp_cmd.split_whitespace().collect();
if parts.is_empty() {
eprintln!("{YELLOW}warning:{RESET} Empty --mcp command, skipping");
continue;
}
let command = parts[0];
let args_slice: Vec<&str> = parts[1..].to_vec();
eprintln!("{DIM} mcp: connecting to {mcp_cmd}...{RESET}");
let result = agent
.with_mcp_server_stdio(command, &args_slice, None)
.await;
match result {
Ok(updated) => {
agent = updated;
mcp_count += 1;
eprintln!("{GREEN} ✓ mcp: {command} connected{RESET}");
}
Err(e) => {
eprintln!("{RED} ✗ mcp: failed to connect to '{mcp_cmd}': {e}{RESET}");
agent = agent_config.build_agent();
eprintln!("{DIM} mcp: agent rebuilt (previous MCP connections lost){RESET}");
}
}
}
let mut openapi_count = 0u32;
for spec_path in &openapi_specs {
eprintln!("{DIM} openapi: loading {spec_path}...{RESET}");
let result = agent
.with_openapi_file(spec_path, OpenApiConfig::default(), &OperationFilter::All)
.await;
match result {
Ok(updated) => {
agent = updated;
openapi_count += 1;
eprintln!("{GREEN} ✓ openapi: {spec_path} loaded{RESET}");
}
Err(e) => {
eprintln!("{RED} ✗ openapi: failed to load '{spec_path}': {e}{RESET}");
agent = agent_config.build_agent();
eprintln!("{DIM} openapi: agent rebuilt (previous connections lost){RESET}");
}
}
}
if continue_session {
let session_path = commands_session::continue_session_path();
match std::fs::read_to_string(session_path) {
Ok(json) => match agent.restore_messages(&json) {
Ok(_) => {
eprintln!(
"{DIM} resumed session: {} messages from {session_path}{RESET}",
agent.messages().len()
);
}
Err(e) => eprintln!("{YELLOW}warning:{RESET} Failed to restore session: {e}"),
},
Err(_) => eprintln!("{DIM} no previous session found ({session_path}){RESET}"),
}
}
if let Some(prompt_text) = config.prompt_arg {
if agent_config.provider != "anthropic" {
eprintln!(
"{DIM} yoyo (prompt mode) — provider: {}, model: {}{RESET}",
agent_config.provider, agent_config.model
);
} else {
eprintln!(
"{DIM} yoyo (prompt mode) — model: {}{RESET}",
agent_config.model
);
}
let mut session_total = Usage::default();
let prompt_start = Instant::now();
let response = if let Some(ref img_path) = image_path {
match commands_file::read_image_for_add(img_path) {
Ok((data, mime_type)) => {
let content_blocks = vec![
Content::Text {
text: prompt_text.trim().to_string(),
},
Content::Image {
data: data.clone(),
mime_type: mime_type.clone(),
},
];
let initial = run_prompt_with_content(
&mut agent,
content_blocks,
&mut session_total,
&agent_config.model,
)
.await;
let retry_blocks = vec![
Content::Text {
text: prompt_text.trim().to_string(),
},
Content::Image { data, mime_type },
];
let (final_response, should_exit_error) = try_fallback_prompt(
&mut agent_config,
&mut agent,
FallbackRetry::Content(retry_blocks),
&mut session_total,
initial,
)
.await;
if should_exit_error {
format::maybe_ring_bell(prompt_start.elapsed());
if json_output {
println!(
"{}",
build_json_output(
&final_response,
&agent_config.model,
&session_total,
true
)
);
} else {
write_output_file(&output_path, &final_response.text);
}
std::process::exit(1);
}
final_response
}
Err(e) => {
eprintln!("{RED} error: {e}{RESET}");
std::process::exit(1);
}
}
} else {
let initial = run_prompt(
&mut agent,
prompt_text.trim(),
&mut session_total,
&agent_config.model,
)
.await;
let (final_response, should_exit_error) = try_fallback_prompt(
&mut agent_config,
&mut agent,
FallbackRetry::Text(prompt_text.trim()),
&mut session_total,
initial,
)
.await;
if should_exit_error {
format::maybe_ring_bell(prompt_start.elapsed());
if json_output {
println!(
"{}",
build_json_output(
&final_response,
&agent_config.model,
&session_total,
true
)
);
} else {
write_output_file(&output_path, &final_response.text);
}
std::process::exit(1);
}
final_response
};
format::maybe_ring_bell(prompt_start.elapsed());
if json_output {
println!(
"{}",
build_json_output(&response, &agent_config.model, &session_total, false)
);
} else {
write_output_file(&output_path, &response.text);
}
if CHECKPOINT_TRIGGERED.load(Ordering::SeqCst) {
std::process::exit(2);
}
return;
}
if !io::stdin().is_terminal() {
let mut input = String::new();
io::stdin().read_to_string(&mut input).ok();
let input = input.trim();
if input.is_empty() {
eprintln!("No input on stdin.");
std::process::exit(1);
}
eprintln!(
"{DIM} yoyo (piped mode) — model: {}{RESET}",
agent_config.model
);
let mut session_total = Usage::default();
let prompt_start = Instant::now();
let initial = run_prompt(&mut agent, input, &mut session_total, &agent_config.model).await;
let (response, should_exit_error) = try_fallback_prompt(
&mut agent_config,
&mut agent,
FallbackRetry::Text(input),
&mut session_total,
initial,
)
.await;
format::maybe_ring_bell(prompt_start.elapsed());
if json_output {
println!(
"{}",
build_json_output(
&response,
&agent_config.model,
&session_total,
should_exit_error
)
);
} else {
write_output_file(&output_path, &response.text);
}
if should_exit_error {
std::process::exit(1);
}
if CHECKPOINT_TRIGGERED.load(Ordering::SeqCst) {
std::process::exit(2);
}
return;
}
let update_available = if !no_update_check {
cli::check_for_update()
} else {
None
};
repl::run_repl(
&mut agent_config,
&mut agent,
mcp_count,
openapi_count,
continue_session,
update_available,
)
.await;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::{
confirm_file_operation, describe_file_operation, truncate_result, AskUserTool,
RenameSymbolTool, StreamingBashTool, TodoTool,
};
use serial_test::serial;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
#[test]
fn test_always_approve_flag_starts_false() {
let flag = Arc::new(AtomicBool::new(false));
assert!(!flag.load(Ordering::Relaxed));
}
#[test]
fn test_checkpoint_triggered_flag_starts_false() {
assert!(!CHECKPOINT_TRIGGERED.load(Ordering::SeqCst));
}
#[test]
fn test_always_approve_flag_persists_across_clones() {
let always_approved = Arc::new(AtomicBool::new(false));
let flag_clone = Arc::clone(&always_approved);
assert!(!flag_clone.load(Ordering::Relaxed));
always_approved.store(true, Ordering::Relaxed);
assert!(flag_clone.load(Ordering::Relaxed));
}
#[test]
fn test_always_approve_response_matching() {
let responses_that_approve = ["y", "yes", "a", "always"];
let responses_that_deny = ["n", "no", "", "maybe", "nope"];
for r in &responses_that_approve {
let normalized = r.trim().to_lowercase();
assert!(
matches!(normalized.as_str(), "y" | "yes" | "a" | "always"),
"Expected '{}' to be approved",
r
);
}
for r in &responses_that_deny {
let normalized = r.trim().to_lowercase();
assert!(
!matches!(normalized.as_str(), "y" | "yes" | "a" | "always"),
"Expected '{}' to be denied",
r
);
}
}
#[test]
fn test_always_approve_only_on_a_or_always() {
let always_responses = ["a", "always"];
let single_responses = ["y", "yes"];
for r in &always_responses {
let normalized = r.trim().to_lowercase();
assert!(
matches!(normalized.as_str(), "a" | "always"),
"Expected '{}' to trigger always-approve",
r
);
}
for r in &single_responses {
let normalized = r.trim().to_lowercase();
assert!(
!matches!(normalized.as_str(), "a" | "always"),
"Expected '{}' NOT to trigger always-approve",
r
);
}
}
#[test]
fn test_always_approve_flag_used_in_confirm_simulation() {
let always_approved = Arc::new(AtomicBool::new(false));
let commands = ["ls", "echo hello", "cat file.txt"];
let user_responses = ["a", "", ""];
for (i, cmd) in commands.iter().enumerate() {
let approved = if always_approved.load(Ordering::Relaxed) {
true
} else {
let response = user_responses[i].trim().to_lowercase();
let result = matches!(response.as_str(), "y" | "yes" | "a" | "always");
if matches!(response.as_str(), "a" | "always") {
always_approved.store(true, Ordering::Relaxed);
}
result
};
match i {
0 => assert!(
approved,
"First command '{}' should be approved via 'a'",
cmd
),
1 => assert!(approved, "Second command '{}' should be auto-approved", cmd),
2 => assert!(approved, "Third command '{}' should be auto-approved", cmd),
_ => unreachable!(),
}
}
}
#[test]
fn test_build_tools_returns_eight_tools() {
let perms = cli::PermissionConfig::default();
let dirs = cli::DirectoryRestrictions::default();
let tools_approved = build_tools(true, &perms, &dirs, TOOL_OUTPUT_MAX_CHARS, false, vec![]);
let tools_confirm = build_tools(false, &perms, &dirs, TOOL_OUTPUT_MAX_CHARS, false, vec![]);
assert_eq!(tools_approved.len(), 8);
assert_eq!(tools_confirm.len(), 8);
}
#[test]
fn test_build_sub_agent_tool_returns_correct_name() {
let config = test_agent_config("anthropic", "claude-sonnet-4-20250514");
let tool = build_sub_agent_tool(&config);
assert_eq!(tool.name(), "sub_agent");
}
#[test]
fn test_build_sub_agent_tool_has_task_parameter() {
let config = test_agent_config("anthropic", "claude-sonnet-4-20250514");
let tool = build_sub_agent_tool(&config);
let schema = tool.parameters_schema();
assert!(
schema["properties"]["task"].is_object(),
"Should have 'task' parameter"
);
assert!(schema["required"]
.as_array()
.unwrap()
.contains(&serde_json::json!("task")));
}
#[test]
fn test_build_sub_agent_tool_all_providers() {
let _tool_anthropic =
build_sub_agent_tool(&test_agent_config("anthropic", "claude-sonnet-4-20250514"));
let _tool_google = build_sub_agent_tool(&test_agent_config("google", "gemini-2.0-flash"));
let _tool_openai = build_sub_agent_tool(&test_agent_config("openai", "gpt-4o"));
let _tool_bedrock = build_sub_agent_tool(&test_agent_config(
"bedrock",
"anthropic.claude-sonnet-4-20250514-v1:0",
));
}
#[test]
fn test_build_sub_agent_tool_inherits_dir_restrictions() {
let mut config = test_agent_config("anthropic", "claude-sonnet-4-20250514");
config.dir_restrictions = cli::DirectoryRestrictions {
allow: vec!["./src".to_string()],
deny: vec!["/etc".to_string()],
};
let tool = build_sub_agent_tool(&config);
assert_eq!(tool.name(), "sub_agent");
}
#[test]
fn test_build_sub_agent_tool_no_restrictions_still_works() {
let config = test_agent_config("anthropic", "claude-sonnet-4-20250514");
assert!(config.dir_restrictions.is_empty());
let tool = build_sub_agent_tool(&config);
assert_eq!(tool.name(), "sub_agent");
}
#[test]
fn test_build_tools_count_unchanged_with_sub_agent() {
let perms = cli::PermissionConfig::default();
let dirs = cli::DirectoryRestrictions::default();
let tools = build_tools(true, &perms, &dirs, TOOL_OUTPUT_MAX_CHARS, false, vec![]);
assert_eq!(
tools.len(),
8,
"build_tools must stay at 8 — SubAgentTool is added via with_sub_agent"
);
}
#[test]
fn test_agent_config_struct_fields() {
let config = AgentConfig {
model: "claude-opus-4-6".to_string(),
api_key: "test-key".to_string(),
provider: "anthropic".to_string(),
base_url: None,
skills: yoagent::skills::SkillSet::empty(),
system_prompt: "You are helpful.".to_string(),
thinking: ThinkingLevel::Off,
max_tokens: Some(4096),
temperature: Some(0.7),
max_turns: Some(10),
auto_approve: true,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
};
assert_eq!(config.model, "claude-opus-4-6");
assert_eq!(config.api_key, "test-key");
assert_eq!(config.provider, "anthropic");
assert!(config.base_url.is_none());
assert_eq!(config.system_prompt, "You are helpful.");
assert_eq!(config.thinking, ThinkingLevel::Off);
assert_eq!(config.max_tokens, Some(4096));
assert_eq!(config.temperature, Some(0.7));
assert_eq!(config.max_turns, Some(10));
assert!(config.auto_approve);
assert!(config.permissions.is_empty());
}
#[test]
fn test_agent_config_build_agent_anthropic() {
let config = AgentConfig {
model: "claude-opus-4-6".to_string(),
api_key: "test-key".to_string(),
provider: "anthropic".to_string(),
base_url: None,
skills: yoagent::skills::SkillSet::empty(),
system_prompt: "Test prompt.".to_string(),
thinking: ThinkingLevel::Off,
max_tokens: None,
temperature: None,
max_turns: None,
auto_approve: true,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
};
let agent = config.build_agent();
assert_eq!(agent.messages().len(), 0);
}
#[test]
fn test_agent_config_build_agent_openai() {
let config = AgentConfig {
model: "gpt-4o".to_string(),
api_key: "test-key".to_string(),
provider: "openai".to_string(),
base_url: None,
skills: yoagent::skills::SkillSet::empty(),
system_prompt: "Test.".to_string(),
thinking: ThinkingLevel::Off,
max_tokens: Some(2048),
temperature: Some(0.5),
max_turns: Some(20),
auto_approve: false,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
};
let agent = config.build_agent();
assert_eq!(agent.messages().len(), 0);
assert_eq!(agent.temperature, Some(0.5));
}
#[test]
fn test_agent_config_build_agent_google() {
let config = AgentConfig {
model: "gemini-2.0-flash".to_string(),
api_key: "test-key".to_string(),
provider: "google".to_string(),
base_url: None,
skills: yoagent::skills::SkillSet::empty(),
system_prompt: "Test.".to_string(),
thinking: ThinkingLevel::Off,
max_tokens: None,
temperature: None,
max_turns: None,
auto_approve: true,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
};
let agent = config.build_agent();
assert_eq!(agent.messages().len(), 0);
}
#[test]
fn test_agent_config_build_agent_with_base_url() {
let config = AgentConfig {
model: "claude-opus-4-6".to_string(),
api_key: "test-key".to_string(),
provider: "anthropic".to_string(),
base_url: Some("http://localhost:8080/v1".to_string()),
skills: yoagent::skills::SkillSet::empty(),
system_prompt: "Test.".to_string(),
thinking: ThinkingLevel::Off,
max_tokens: None,
temperature: None,
max_turns: None,
auto_approve: true,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
};
let agent = config.build_agent();
assert_eq!(agent.messages().len(), 0);
}
#[test]
fn test_agent_config_rebuild_produces_fresh_agent() {
let config = AgentConfig {
model: "claude-opus-4-6".to_string(),
api_key: "test-key".to_string(),
provider: "anthropic".to_string(),
base_url: None,
skills: yoagent::skills::SkillSet::empty(),
system_prompt: "Test.".to_string(),
thinking: ThinkingLevel::Off,
max_tokens: None,
temperature: None,
max_turns: None,
auto_approve: true,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
};
let agent1 = config.build_agent();
let agent2 = config.build_agent();
assert_eq!(agent1.messages().len(), 0);
assert_eq!(agent2.messages().len(), 0);
}
#[test]
fn test_agent_config_mutable_model_switch() {
let mut config = AgentConfig {
model: "claude-opus-4-6".to_string(),
api_key: "test-key".to_string(),
provider: "anthropic".to_string(),
base_url: None,
skills: yoagent::skills::SkillSet::empty(),
system_prompt: "Test.".to_string(),
thinking: ThinkingLevel::Off,
max_tokens: None,
temperature: None,
max_turns: None,
auto_approve: true,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
};
assert_eq!(config.model, "claude-opus-4-6");
config.model = "claude-haiku-35".to_string();
let _agent = config.build_agent();
assert_eq!(config.model, "claude-haiku-35");
}
#[test]
fn test_agent_config_mutable_thinking_switch() {
let mut config = AgentConfig {
model: "claude-opus-4-6".to_string(),
api_key: "test-key".to_string(),
provider: "anthropic".to_string(),
base_url: None,
skills: yoagent::skills::SkillSet::empty(),
system_prompt: "Test.".to_string(),
thinking: ThinkingLevel::Off,
max_tokens: None,
temperature: None,
max_turns: None,
auto_approve: true,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
};
assert_eq!(config.thinking, ThinkingLevel::Off);
config.thinking = ThinkingLevel::High;
let _agent = config.build_agent();
assert_eq!(config.thinking, ThinkingLevel::High);
}
#[test]
fn test_describe_write_file_operation() {
let params = serde_json::json!({
"path": "src/main.rs",
"content": "line1\nline2\nline3\n"
});
let desc = describe_file_operation("write_file", ¶ms);
assert!(desc.contains("write:"));
assert!(desc.contains("src/main.rs"));
assert!(desc.contains("3 lines")); }
#[test]
fn test_describe_write_file_empty_content() {
let params = serde_json::json!({
"path": "empty.txt",
"content": ""
});
let desc = describe_file_operation("write_file", ¶ms);
assert!(desc.contains("write:"));
assert!(desc.contains("empty.txt"));
assert!(
desc.contains("EMPTY content"),
"Empty content should show warning, got: {desc}"
);
}
#[test]
fn test_describe_write_file_missing_content() {
let params = serde_json::json!({
"path": "missing.txt"
});
let desc = describe_file_operation("write_file", ¶ms);
assert!(desc.contains("write:"));
assert!(desc.contains("missing.txt"));
assert!(
desc.contains("EMPTY content"),
"Missing content should show warning, got: {desc}"
);
}
#[test]
fn test_describe_write_file_normal_content() {
let params = serde_json::json!({
"path": "hello.txt",
"content": "hello world\n"
});
let desc = describe_file_operation("write_file", ¶ms);
assert!(desc.contains("write:"));
assert!(desc.contains("hello.txt"));
assert!(desc.contains("1 line"));
assert!(
!desc.contains("EMPTY"),
"Non-empty content should not show warning, got: {desc}"
);
}
#[test]
fn test_describe_edit_file_operation() {
let params = serde_json::json!({
"path": "src/cli.rs",
"old_text": "old line 1\nold line 2",
"new_text": "new line 1\nnew line 2\nnew line 3"
});
let desc = describe_file_operation("edit_file", ¶ms);
assert!(desc.contains("edit:"));
assert!(desc.contains("src/cli.rs"));
assert!(desc.contains("2 → 3 lines"));
}
#[test]
fn test_describe_edit_file_missing_params() {
let params = serde_json::json!({
"path": "test.rs"
});
let desc = describe_file_operation("edit_file", ¶ms);
assert!(desc.contains("edit:"));
assert!(desc.contains("test.rs"));
assert!(desc.contains("0 → 0 lines"));
}
#[test]
fn test_describe_unknown_tool() {
let params = serde_json::json!({});
let desc = describe_file_operation("unknown_tool", ¶ms);
assert!(desc.contains("unknown_tool"));
}
#[test]
fn test_confirm_file_operation_auto_approved_flag() {
let flag = Arc::new(AtomicBool::new(true));
let perms = cli::PermissionConfig::default();
let result = confirm_file_operation("write: test.rs (5 lines)", "test.rs", &flag, &perms);
assert!(
result,
"Should auto-approve when always_approved flag is set"
);
}
#[test]
fn test_confirm_file_operation_with_allow_pattern() {
let flag = Arc::new(AtomicBool::new(false));
let perms = cli::PermissionConfig {
allow: vec!["*.md".to_string()],
deny: vec![],
};
let result =
confirm_file_operation("write: README.md (10 lines)", "README.md", &flag, &perms);
assert!(result, "Should auto-approve paths matching allow pattern");
}
#[test]
fn test_confirm_file_operation_with_deny_pattern() {
let flag = Arc::new(AtomicBool::new(false));
let perms = cli::PermissionConfig {
allow: vec![],
deny: vec!["*.key".to_string()],
};
let result =
confirm_file_operation("write: secrets.key (1 line)", "secrets.key", &flag, &perms);
assert!(!result, "Should deny paths matching deny pattern");
}
#[test]
fn test_confirm_file_operation_deny_overrides_allow() {
let flag = Arc::new(AtomicBool::new(false));
let perms = cli::PermissionConfig {
allow: vec!["*".to_string()],
deny: vec!["*.key".to_string()],
};
let result =
confirm_file_operation("write: secrets.key (1 line)", "secrets.key", &flag, &perms);
assert!(!result, "Deny should override allow");
}
#[test]
fn test_confirm_file_operation_allow_src_pattern() {
let flag = Arc::new(AtomicBool::new(false));
let perms = cli::PermissionConfig {
allow: vec!["src/*".to_string()],
deny: vec![],
};
let result = confirm_file_operation(
"edit: src/main.rs (2 → 3 lines)",
"src/main.rs",
&flag,
&perms,
);
assert!(
result,
"Should auto-approve src/ files with 'src/*' pattern"
);
}
#[test]
fn test_build_tools_auto_approve_skips_confirmation() {
let perms = cli::PermissionConfig::default();
let dirs = cli::DirectoryRestrictions::default();
let tools = build_tools(true, &perms, &dirs, TOOL_OUTPUT_MAX_CHARS, false, vec![]);
assert_eq!(tools.len(), 8);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"write_file"));
assert!(names.contains(&"edit_file"));
assert!(names.contains(&"bash"));
}
#[test]
fn test_build_tools_no_approve_includes_confirmation() {
let perms = cli::PermissionConfig::default();
let dirs = cli::DirectoryRestrictions::default();
let tools = build_tools(false, &perms, &dirs, TOOL_OUTPUT_MAX_CHARS, false, vec![]);
assert_eq!(tools.len(), 8);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"write_file"));
assert!(names.contains(&"edit_file"));
assert!(names.contains(&"bash"));
assert!(names.contains(&"read_file"));
assert!(names.contains(&"list_files"));
assert!(names.contains(&"search"));
assert!(names.contains(&"todo"));
}
#[test]
fn test_always_approved_shared_between_bash_and_file_tools() {
let always_approved = Arc::new(AtomicBool::new(false));
let bash_flag = Arc::clone(&always_approved);
let file_flag = Arc::clone(&always_approved);
assert!(!bash_flag.load(Ordering::Relaxed));
assert!(!file_flag.load(Ordering::Relaxed));
bash_flag.store(true, Ordering::Relaxed);
assert!(
file_flag.load(Ordering::Relaxed),
"File tool should see always_approved after bash 'always'"
);
}
#[test]
fn test_yoyo_user_agent_format() {
let ua = yoyo_user_agent();
assert!(
ua.starts_with("yoyo/"),
"User-Agent should start with 'yoyo/'"
);
let version_part = &ua["yoyo/".len()..];
assert!(
version_part.contains('.'),
"User-Agent version should contain a dot: {ua}"
);
}
#[test]
fn test_client_headers_anthropic() {
let config = create_model_config("anthropic", "claude-sonnet-4-20250514", None);
assert_eq!(
config.headers.get("User-Agent").unwrap(),
&yoyo_user_agent(),
"Anthropic config should have User-Agent header"
);
assert!(
!config.headers.contains_key("HTTP-Referer"),
"Anthropic config should NOT have HTTP-Referer"
);
assert!(
!config.headers.contains_key("X-Title"),
"Anthropic config should NOT have X-Title"
);
}
#[test]
fn test_client_headers_openai() {
let config = create_model_config("openai", "gpt-4o", None);
assert_eq!(
config.headers.get("User-Agent").unwrap(),
&yoyo_user_agent(),
"OpenAI config should have User-Agent header"
);
assert!(
!config.headers.contains_key("HTTP-Referer"),
"OpenAI config should NOT have HTTP-Referer"
);
}
#[test]
fn test_client_headers_openrouter() {
let config = create_model_config("openrouter", "anthropic/claude-sonnet-4-20250514", None);
assert_eq!(
config.headers.get("User-Agent").unwrap(),
&yoyo_user_agent(),
"OpenRouter config should have User-Agent header"
);
assert_eq!(
config.headers.get("HTTP-Referer").unwrap(),
"https://github.com/yologdev/yoyo-evolve",
"OpenRouter config should have HTTP-Referer header"
);
assert_eq!(
config.headers.get("X-Title").unwrap(),
"yoyo",
"OpenRouter config should have X-Title header"
);
}
#[test]
fn test_client_headers_google() {
let config = create_model_config("google", "gemini-2.0-flash", None);
assert_eq!(
config.headers.get("User-Agent").unwrap(),
&yoyo_user_agent(),
"Google config should have User-Agent header"
);
}
#[test]
fn test_create_model_config_zai_defaults() {
let config = create_model_config("zai", "glm-4-plus", None);
assert_eq!(config.provider, "zai");
assert_eq!(config.id, "glm-4-plus");
assert_eq!(config.base_url, "https://api.z.ai/api/paas/v4");
assert_eq!(
config.headers.get("User-Agent").unwrap(),
&yoyo_user_agent(),
"ZAI config should have User-Agent header"
);
}
#[test]
fn test_create_model_config_zai_custom_base_url() {
let config =
create_model_config("zai", "glm-4-plus", Some("https://custom.zai.example/v1"));
assert_eq!(config.provider, "zai");
assert_eq!(config.base_url, "https://custom.zai.example/v1");
}
#[test]
fn test_agent_config_build_agent_zai() {
let config = AgentConfig {
model: "glm-4-plus".to_string(),
api_key: "test-key".to_string(),
provider: "zai".to_string(),
base_url: None,
skills: yoagent::skills::SkillSet::empty(),
system_prompt: "Test.".to_string(),
thinking: ThinkingLevel::Off,
max_tokens: None,
temperature: None,
max_turns: None,
auto_approve: true,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
};
let agent = config.build_agent();
assert_eq!(agent.messages().len(), 0);
}
#[test]
fn test_create_model_config_minimax_defaults() {
let config = create_model_config("minimax", "MiniMax-M2.7", None);
assert_eq!(config.provider, "minimax");
assert_eq!(config.id, "MiniMax-M2.7");
assert_eq!(
config.base_url, "https://api.minimaxi.chat/v1",
"MiniMax should use api.minimaxi.chat (not api.minimax.io)"
);
assert!(
config.compat.is_some(),
"MiniMax config should have compat flags set"
);
assert_eq!(
config.headers.get("User-Agent").unwrap(),
&yoyo_user_agent(),
"MiniMax config should have User-Agent header"
);
}
#[test]
fn test_create_model_config_minimax_custom_base_url() {
let config = create_model_config(
"minimax",
"MiniMax-M2.7",
Some("https://custom.minimax.example/v1"),
);
assert_eq!(config.provider, "minimax");
assert_eq!(config.base_url, "https://custom.minimax.example/v1");
}
#[test]
fn test_create_model_config_unknown_provider_falls_through() {
let config = create_model_config("typo_provider", "some-model", None);
assert_eq!(config.provider, "typo_provider");
assert_eq!(config.base_url, "http://localhost:8080/v1");
}
#[test]
fn test_create_model_config_unknown_provider_with_base_url() {
let config = create_model_config(
"typo_provider",
"some-model",
Some("https://my-server.com/v1"),
);
assert_eq!(config.provider, "typo_provider");
assert_eq!(config.base_url, "https://my-server.com/v1");
}
#[test]
fn test_agent_config_build_agent_minimax() {
let config = AgentConfig {
model: "MiniMax-M2.7".to_string(),
api_key: "test-key".to_string(),
provider: "minimax".to_string(),
base_url: None,
skills: yoagent::skills::SkillSet::empty(),
system_prompt: "Test.".to_string(),
thinking: ThinkingLevel::Off,
max_tokens: None,
temperature: None,
max_turns: None,
auto_approve: true,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
};
let agent = config.build_agent();
assert_eq!(agent.messages().len(), 0);
}
#[test]
fn test_bedrock_model_config() {
let config =
create_model_config("bedrock", "anthropic.claude-sonnet-4-20250514-v1:0", None);
assert_eq!(config.provider, "bedrock");
assert_eq!(
config.base_url,
"https://bedrock-runtime.us-east-1.amazonaws.com"
);
assert_eq!(format!("{}", config.api), "bedrock_converse_stream");
}
#[test]
fn test_bedrock_model_config_custom_url() {
let config = create_model_config(
"bedrock",
"anthropic.claude-sonnet-4-20250514-v1:0",
Some("https://bedrock-runtime.eu-west-1.amazonaws.com"),
);
assert_eq!(
config.base_url,
"https://bedrock-runtime.eu-west-1.amazonaws.com"
);
}
#[test]
fn test_build_agent_bedrock() {
let config = AgentConfig {
model: "anthropic.claude-sonnet-4-20250514-v1:0".to_string(),
api_key: "test-access:test-secret".to_string(),
provider: "bedrock".to_string(),
base_url: Some("https://bedrock-runtime.us-east-1.amazonaws.com".to_string()),
skills: yoagent::skills::SkillSet::empty(),
system_prompt: "test".to_string(),
thinking: ThinkingLevel::Off,
max_tokens: None,
temperature: None,
max_turns: None,
auto_approve: true,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
};
let agent = config.build_agent();
assert_eq!(agent.messages().len(), 0);
}
#[test]
fn test_client_headers_on_anthropic_build_agent() {
let agent_config = AgentConfig {
model: "claude-opus-4-6".to_string(),
api_key: "test-key".to_string(),
provider: "anthropic".to_string(),
base_url: None,
skills: yoagent::skills::SkillSet::empty(),
system_prompt: "Test.".to_string(),
thinking: ThinkingLevel::Off,
max_tokens: None,
temperature: None,
max_turns: None,
auto_approve: true,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
};
let mut anthropic_config = ModelConfig::anthropic("claude-opus-4-6", "claude-opus-4-6");
insert_client_headers(&mut anthropic_config);
assert_eq!(
anthropic_config.headers.get("User-Agent").unwrap(),
&yoyo_user_agent()
);
let _agent = agent_config.build_agent();
}
fn test_agent_config(provider: &str, model: &str) -> AgentConfig {
AgentConfig {
model: model.to_string(),
api_key: "test-key".to_string(),
provider: provider.to_string(),
base_url: None,
skills: yoagent::skills::SkillSet::empty(),
system_prompt: "Test prompt.".to_string(),
thinking: ThinkingLevel::Off,
max_tokens: None,
temperature: None,
max_turns: None,
auto_approve: true,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
}
}
#[test]
fn test_configure_agent_applies_all_settings() {
let config = AgentConfig {
max_tokens: Some(2048),
temperature: Some(0.5),
max_turns: Some(5),
..test_agent_config("anthropic", "claude-opus-4-6")
};
let agent = config.build_agent();
assert_eq!(agent.messages().len(), 0);
}
#[test]
fn test_build_agent_all_providers_build_cleanly() {
let providers = [
("anthropic", "claude-opus-4-6"),
("google", "gemini-2.5-pro"),
("openai", "gpt-4o"),
("deepseek", "deepseek-chat"),
];
for (provider, model) in &providers {
let config = test_agent_config(provider, model);
let agent = config.build_agent();
assert_eq!(
agent.messages().len(),
0,
"provider '{provider}' should produce a clean agent"
);
}
}
#[test]
fn test_build_agent_anthropic_with_base_url_uses_openai_compat() {
let config = AgentConfig {
base_url: Some("https://custom-api.example.com/v1".to_string()),
..test_agent_config("anthropic", "claude-opus-4-6")
};
let agent = config.build_agent();
assert_eq!(agent.messages().len(), 0);
}
fn test_tool_context(
updates: Option<Arc<tokio::sync::Mutex<Vec<yoagent::types::ToolResult>>>>,
) -> yoagent::types::ToolContext {
let on_update: Option<yoagent::types::ToolUpdateFn> = updates.map(|u| {
Arc::new(move |result: yoagent::types::ToolResult| {
if let Ok(mut guard) = u.try_lock() {
guard.push(result);
}
}) as yoagent::types::ToolUpdateFn
});
yoagent::types::ToolContext {
tool_call_id: "test-id".to_string(),
tool_name: "bash".to_string(),
cancel: tokio_util::sync::CancellationToken::new(),
on_update,
on_progress: None,
}
}
#[tokio::test]
async fn test_streaming_bash_deny_patterns() {
let tool = StreamingBashTool::default();
let ctx = test_tool_context(None);
let params = serde_json::json!({"command": "rm -rf /"});
let result = tool.execute(params, ctx).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("blocked by safety policy"),
"Expected deny pattern error, got: {err}"
);
}
#[tokio::test]
async fn test_streaming_bash_deny_pattern_fork_bomb() {
let tool = StreamingBashTool::default();
let ctx = test_tool_context(None);
let params = serde_json::json!({"command": ":(){:|:&};:"});
let result = tool.execute(params, ctx).await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("blocked by safety policy"));
}
#[tokio::test]
async fn test_streaming_bash_confirm_rejection() {
let tool = StreamingBashTool::default().with_confirm(|_cmd: &str| false);
let ctx = test_tool_context(None);
let params = serde_json::json!({"command": "echo hello"});
let result = tool.execute(params, ctx).await;
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("not confirmed"),
"Expected confirmation rejection"
);
}
#[tokio::test]
async fn test_streaming_bash_confirm_approval() {
let tool = StreamingBashTool::default().with_confirm(|_cmd: &str| true);
let ctx = test_tool_context(None);
let params = serde_json::json!({"command": "echo approved"});
let result = tool.execute(params, ctx).await;
assert!(result.is_ok());
let text = &result.unwrap().content[0];
match text {
yoagent::types::Content::Text { text } => {
assert!(text.contains("approved"));
assert!(text.contains("Exit code: 0"));
}
_ => panic!("Expected text content"),
}
}
#[tokio::test]
async fn test_streaming_bash_basic_execution() {
let tool = StreamingBashTool::default();
let ctx = test_tool_context(None);
let params = serde_json::json!({"command": "echo hello world"});
let result = tool.execute(params, ctx).await.unwrap();
match &result.content[0] {
yoagent::types::Content::Text { text } => {
assert!(text.contains("hello world"));
assert!(text.contains("Exit code: 0"));
}
_ => panic!("Expected text content"),
}
assert_eq!(result.details["exit_code"], 0);
assert_eq!(result.details["success"], true);
}
#[tokio::test]
async fn test_streaming_bash_captures_exit_code() {
let tool = StreamingBashTool::default();
let ctx = test_tool_context(None);
let params = serde_json::json!({"command": "exit 42"});
let result = tool.execute(params, ctx).await.unwrap();
assert_eq!(result.details["exit_code"], 42);
assert_eq!(result.details["success"], false);
}
#[tokio::test]
async fn test_streaming_bash_timeout() {
let tool = StreamingBashTool {
timeout: Duration::from_millis(200),
..Default::default()
};
let ctx = test_tool_context(None);
let params = serde_json::json!({"command": "sleep 30"});
let result = tool.execute(params, ctx).await;
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("timed out"),
"Expected timeout error"
);
}
#[tokio::test]
async fn test_streaming_bash_output_truncation() {
let tool = StreamingBashTool {
max_output_bytes: 100,
..Default::default()
};
let ctx = test_tool_context(None);
let params = serde_json::json!({"command": "for i in $(seq 1 100); do echo \"line number $i of the output\"; done"});
let result = tool.execute(params, ctx).await.unwrap();
match &result.content[0] {
yoagent::types::Content::Text { text } => {
assert!(
text.contains("truncated") || text.len() < 500,
"Output should be truncated or short, got {} bytes",
text.len()
);
}
_ => panic!("Expected text content"),
}
}
#[tokio::test]
async fn test_streaming_bash_emits_updates() {
let updates = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let tool = StreamingBashTool {
lines_per_update: 1,
update_interval: Duration::from_millis(10),
..Default::default()
};
let ctx = test_tool_context(Some(Arc::clone(&updates)));
let params = serde_json::json!({
"command": "for i in 1 2 3 4 5; do echo line$i; sleep 0.02; done"
});
let result = tool.execute(params, ctx).await.unwrap();
assert!(result.details["success"] == true);
let collected = updates.lock().await;
assert!(
!collected.is_empty(),
"Expected at least one streaming update, got none"
);
let last = &collected[collected.len() - 1];
match &last.content[0] {
yoagent::types::Content::Text { text } => {
assert!(
text.contains("line"),
"Update should contain partial output"
);
}
_ => panic!("Expected text content in update"),
}
}
#[tokio::test]
async fn test_streaming_bash_missing_command_param() {
let tool = StreamingBashTool::default();
let ctx = test_tool_context(None);
let params = serde_json::json!({});
let result = tool.execute(params, ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("missing"));
}
#[tokio::test]
async fn test_streaming_bash_captures_stderr() {
let tool = StreamingBashTool::default();
let ctx = test_tool_context(None);
let params = serde_json::json!({"command": "echo err_output >&2"});
let result = tool.execute(params, ctx).await.unwrap();
match &result.content[0] {
yoagent::types::Content::Text { text } => {
assert!(text.contains("err_output"), "Should capture stderr: {text}");
}
_ => panic!("Expected text content"),
}
}
#[test]
fn test_rename_symbol_tool_name() {
let tool = RenameSymbolTool;
assert_eq!(tool.name(), "rename_symbol");
}
#[test]
fn test_rename_symbol_tool_label() {
let tool = RenameSymbolTool;
assert_eq!(tool.label(), "Rename");
}
#[test]
fn test_rename_symbol_tool_schema() {
let tool = RenameSymbolTool;
let schema = tool.parameters_schema();
let props = schema["properties"].as_object().unwrap();
assert!(
props.contains_key("old_name"),
"schema should have old_name"
);
assert!(
props.contains_key("new_name"),
"schema should have new_name"
);
assert!(props.contains_key("path"), "schema should have path");
let required = schema["required"].as_array().unwrap();
let required_strs: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
assert!(required_strs.contains(&"old_name"));
assert!(required_strs.contains(&"new_name"));
assert!(!required_strs.contains(&"path"));
}
#[test]
fn test_rename_result_struct() {
let result = commands_refactor::RenameResult {
files_changed: vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
total_replacements: 5,
preview: "preview text".to_string(),
};
assert_eq!(result.files_changed.len(), 2);
assert_eq!(result.total_replacements, 5);
assert_eq!(result.preview, "preview text");
}
#[test]
fn test_rename_symbol_tool_in_build_tools() {
let perms = cli::PermissionConfig::default();
let dirs = cli::DirectoryRestrictions::default();
let tools = build_tools(true, &perms, &dirs, TOOL_OUTPUT_MAX_CHARS, false, vec![]);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(
names.contains(&"rename_symbol"),
"build_tools should include rename_symbol, got: {names:?}"
);
}
#[test]
fn test_describe_rename_symbol_operation() {
let params = serde_json::json!({
"old_name": "FooBar",
"new_name": "BazQux",
"path": "src/"
});
let desc = describe_file_operation("rename_symbol", ¶ms);
assert!(desc.contains("FooBar"), "Should contain old_name: {desc}");
assert!(desc.contains("BazQux"), "Should contain new_name: {desc}");
assert!(desc.contains("src/"), "Should contain scope: {desc}");
}
#[test]
fn test_describe_rename_symbol_no_path() {
let params = serde_json::json!({
"old_name": "Foo",
"new_name": "Bar"
});
let desc = describe_file_operation("rename_symbol", ¶ms);
assert!(
desc.contains("project"),
"Should default to 'project': {desc}"
);
}
#[test]
fn test_truncate_result_with_custom_limit() {
use yoagent::types::{Content, ToolResult};
let long_text = (0..200)
.map(|i| format!("T{i} data"))
.collect::<Vec<_>>()
.join("\n");
let result = ToolResult {
content: vec![Content::Text {
text: long_text.clone(),
}],
details: serde_json::Value::Null,
};
let truncated = truncate_result(result, 100);
let text = match &truncated.content[0] {
Content::Text { text } => text.clone(),
_ => panic!("Expected text content"),
};
assert!(
text.contains("[... truncated"),
"Result should be truncated with 100-char limit"
);
}
#[test]
fn test_truncate_result_preserves_under_limit() {
use yoagent::types::{Content, ToolResult};
let short_text = "hello world".to_string();
let result = ToolResult {
content: vec![Content::Text {
text: short_text.clone(),
}],
details: serde_json::Value::Null,
};
let truncated = truncate_result(result, TOOL_OUTPUT_MAX_CHARS);
let text = match &truncated.content[0] {
Content::Text { text } => text.clone(),
_ => panic!("Expected text content"),
};
assert_eq!(text, short_text, "Short text should be unchanged");
}
#[test]
fn test_build_tools_with_piped_limit() {
let perms = cli::PermissionConfig::default();
let dirs = cli::DirectoryRestrictions::default();
let tools = build_tools(
true,
&perms,
&dirs,
TOOL_OUTPUT_MAX_CHARS_PIPED,
false,
vec![],
);
assert_eq!(tools.len(), 8, "Should still have 8 tools with piped limit");
}
#[test]
fn test_ask_user_tool_schema() {
let tool = AskUserTool;
assert_eq!(tool.name(), "ask_user");
assert_eq!(tool.label(), "ask_user");
let schema = tool.parameters_schema();
assert!(schema["properties"]["question"].is_object());
assert!(schema["required"]
.as_array()
.unwrap()
.contains(&serde_json::json!("question")));
}
#[test]
fn test_ask_user_tool_not_in_non_terminal_mode() {
let perms = cli::PermissionConfig::default();
let dirs = cli::DirectoryRestrictions::default();
let tools = build_tools(true, &perms, &dirs, TOOL_OUTPUT_MAX_CHARS, false, vec![]);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(
!names.contains(&"ask_user"),
"ask_user should not be in non-terminal mode"
);
}
#[test]
fn test_configure_agent_sets_context_config() {
let config = AgentConfig {
model: "test-model".to_string(),
api_key: "test-key".to_string(),
provider: "anthropic".to_string(),
base_url: None,
skills: yoagent::skills::SkillSet::default(),
system_prompt: "test".to_string(),
thinking: yoagent::ThinkingLevel::Off,
max_tokens: None,
temperature: None,
max_turns: None,
auto_approve: true,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
};
let agent =
config.configure_agent(Agent::new(yoagent::provider::AnthropicProvider), 200_000);
let _ = agent;
}
#[test]
fn test_execution_limits_always_set() {
let config_no_turns = AgentConfig {
model: "test-model".to_string(),
api_key: "test-key".to_string(),
provider: "anthropic".to_string(),
base_url: None,
skills: yoagent::skills::SkillSet::default(),
system_prompt: "test".to_string(),
thinking: yoagent::ThinkingLevel::Off,
max_tokens: None,
temperature: None,
max_turns: None, auto_approve: true,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
};
let agent = config_no_turns
.configure_agent(Agent::new(yoagent::provider::AnthropicProvider), 200_000);
let _ = agent;
let config_with_turns = AgentConfig {
model: "test-model".to_string(),
api_key: "test-key".to_string(),
provider: "anthropic".to_string(),
base_url: None,
skills: yoagent::skills::SkillSet::default(),
system_prompt: "test".to_string(),
thinking: yoagent::ThinkingLevel::Off,
max_tokens: None,
temperature: None,
max_turns: Some(50),
auto_approve: true,
permissions: cli::PermissionConfig::default(),
dir_restrictions: cli::DirectoryRestrictions::default(),
context_strategy: cli::ContextStrategy::default(),
context_window: None,
shell_hooks: vec![],
fallback_provider: None,
fallback_model: None,
};
let agent = config_with_turns
.configure_agent(Agent::new(yoagent::provider::AnthropicProvider), 200_000);
let _ = agent;
}
#[test]
fn test_todo_tool_schema() {
let tool = TodoTool;
assert_eq!(tool.name(), "todo");
assert_eq!(tool.label(), "todo");
let schema = tool.parameters_schema();
assert!(schema["properties"]["action"].is_object());
assert!(schema["properties"]["description"].is_object());
assert!(schema["properties"]["id"].is_object());
}
#[tokio::test]
#[serial]
async fn test_todo_tool_list_empty() {
commands_project::todo_clear();
let tool = TodoTool;
let ctx = test_tool_context(None);
let result = tool
.execute(serde_json::json!({"action": "list"}), ctx)
.await;
assert!(result.is_ok());
let text = match &result.unwrap().content[0] {
yoagent::types::Content::Text { text } => text.clone(),
_ => panic!("Expected text content"),
};
assert!(text.contains("No tasks"));
}
#[tokio::test]
#[serial]
async fn test_todo_tool_add_and_list() {
commands_project::todo_clear();
let tool = TodoTool;
let ctx = test_tool_context(None);
let result = tool
.execute(
serde_json::json!({"action": "add", "description": "Write tests"}),
ctx,
)
.await;
assert!(result.is_ok());
let ctx = test_tool_context(None);
let result = tool
.execute(serde_json::json!({"action": "list"}), ctx)
.await;
let text = match &result.unwrap().content[0] {
yoagent::types::Content::Text { text } => text.clone(),
_ => panic!("Expected text content"),
};
assert!(text.contains("Write tests"));
}
#[tokio::test]
#[serial]
async fn test_todo_tool_done() {
commands_project::todo_clear();
let tool = TodoTool;
let ctx = test_tool_context(None);
tool.execute(
serde_json::json!({"action": "add", "description": "Task A"}),
ctx,
)
.await
.unwrap();
let ctx = test_tool_context(None);
let result = tool
.execute(serde_json::json!({"action": "done", "id": 1}), ctx)
.await;
let text = match &result.unwrap().content[0] {
yoagent::types::Content::Text { text } => text.clone(),
_ => panic!("Expected text content"),
};
assert!(text.contains("done ✓"));
}
#[tokio::test]
async fn test_todo_tool_invalid_action() {
let tool = TodoTool;
let ctx = test_tool_context(None);
let result = tool
.execute(serde_json::json!({"action": "explode"}), ctx)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_todo_tool_missing_description() {
let tool = TodoTool;
let ctx = test_tool_context(None);
let result = tool
.execute(serde_json::json!({"action": "add"}), ctx)
.await;
assert!(result.is_err());
}
#[test]
fn test_todo_tool_in_build_tools() {
let perms = cli::PermissionConfig::default();
let dirs = cli::DirectoryRestrictions::default();
let tools = build_tools(true, &perms, &dirs, TOOL_OUTPUT_MAX_CHARS, false, vec![]);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(
names.contains(&"todo"),
"build_tools should include todo, got: {names:?}"
);
}
#[test]
fn test_maybe_hook_skips_wrap_when_empty() {
let perms = cli::PermissionConfig::default();
let dirs = cli::DirectoryRestrictions::default();
let tools = build_tools(true, &perms, &dirs, TOOL_OUTPUT_MAX_CHARS, false, vec![]);
assert_eq!(tools.len(), 8, "Tool count should be 8 without audit hooks");
}
#[test]
fn test_build_tools_with_audit_preserves_tool_count() {
let perms = cli::PermissionConfig::default();
let dirs = cli::DirectoryRestrictions::default();
let tools_no_audit = build_tools(true, &perms, &dirs, TOOL_OUTPUT_MAX_CHARS, false, vec![]);
let tools_with_audit =
build_tools(true, &perms, &dirs, TOOL_OUTPUT_MAX_CHARS, true, vec![]);
assert_eq!(
tools_no_audit.len(),
tools_with_audit.len(),
"Audit hooks should wrap tools, not add new ones"
);
}
#[test]
fn test_build_tools_with_audit_preserves_tool_names() {
let perms = cli::PermissionConfig::default();
let dirs = cli::DirectoryRestrictions::default();
let tools_no_audit = build_tools(true, &perms, &dirs, TOOL_OUTPUT_MAX_CHARS, false, vec![]);
let tools_with_audit =
build_tools(true, &perms, &dirs, TOOL_OUTPUT_MAX_CHARS, true, vec![]);
let names_no: Vec<&str> = tools_no_audit.iter().map(|t| t.name()).collect();
let names_yes: Vec<&str> = tools_with_audit.iter().map(|t| t.name()).collect();
assert_eq!(
names_no, names_yes,
"Tool names should be identical with/without audit"
);
}
#[test]
fn test_fallback_switch_success() {
let mut config = AgentConfig {
fallback_provider: Some("google".to_string()),
fallback_model: Some("gemini-2.0-flash".to_string()),
..test_agent_config("anthropic", "claude-opus-4-6")
};
assert!(config.try_switch_to_fallback());
assert_eq!(config.provider, "google");
assert_eq!(config.model, "gemini-2.0-flash");
}
#[test]
fn test_fallback_switch_already_on_fallback() {
let mut config = AgentConfig {
fallback_provider: Some("anthropic".to_string()),
fallback_model: Some("claude-opus-4-6".to_string()),
..test_agent_config("anthropic", "claude-opus-4-6")
};
assert!(!config.try_switch_to_fallback());
assert_eq!(config.provider, "anthropic");
}
#[test]
fn test_fallback_switch_no_fallback_configured() {
let mut config = test_agent_config("anthropic", "claude-opus-4-6");
assert!(config.fallback_provider.is_none());
assert!(!config.try_switch_to_fallback());
assert_eq!(config.provider, "anthropic");
assert_eq!(config.model, "claude-opus-4-6");
}
#[test]
fn test_fallback_switch_derives_default_model() {
let mut config = AgentConfig {
fallback_provider: Some("openai".to_string()),
fallback_model: None,
..test_agent_config("anthropic", "claude-opus-4-6")
};
assert!(config.try_switch_to_fallback());
assert_eq!(config.provider, "openai");
assert_eq!(config.model, cli::default_model_for_provider("openai"));
}
#[test]
fn test_fallback_switch_uses_explicit_model() {
let mut config = AgentConfig {
fallback_provider: Some("openai".to_string()),
fallback_model: Some("gpt-4-turbo".to_string()),
..test_agent_config("anthropic", "claude-opus-4-6")
};
assert!(config.try_switch_to_fallback());
assert_eq!(config.provider, "openai");
assert_eq!(config.model, "gpt-4-turbo");
}
#[test]
#[serial]
fn test_fallback_switch_resolves_api_key() {
unsafe {
std::env::set_var("GOOGLE_API_KEY", "test-google-key-fallback");
}
let mut config = AgentConfig {
fallback_provider: Some("google".to_string()),
fallback_model: Some("gemini-2.0-flash".to_string()),
..test_agent_config("anthropic", "claude-opus-4-6")
};
assert_eq!(config.api_key, "test-key"); assert!(config.try_switch_to_fallback());
assert_eq!(config.api_key, "test-google-key-fallback");
unsafe {
std::env::remove_var("GOOGLE_API_KEY");
}
}
#[test]
fn test_fallback_switch_keeps_api_key_when_env_missing() {
unsafe {
std::env::remove_var("XAI_API_KEY");
}
let mut config = AgentConfig {
fallback_provider: Some("xai".to_string()),
fallback_model: Some("grok-3".to_string()),
..test_agent_config("anthropic", "claude-opus-4-6")
};
let original_key = config.api_key.clone();
assert!(config.try_switch_to_fallback());
assert_eq!(config.provider, "xai");
assert_eq!(config.api_key, original_key);
}
#[test]
fn test_fallback_switch_idempotent() {
let mut config = AgentConfig {
fallback_provider: Some("google".to_string()),
fallback_model: Some("gemini-2.0-flash".to_string()),
..test_agent_config("anthropic", "claude-opus-4-6")
};
assert!(config.try_switch_to_fallback());
assert_eq!(config.provider, "google");
assert!(!config.try_switch_to_fallback());
assert_eq!(config.provider, "google");
}
#[test]
fn test_fallback_prompt_no_api_error_passthrough() {
let config = AgentConfig {
fallback_provider: Some("google".to_string()),
fallback_model: Some("gemini-2.0-flash".to_string()),
..test_agent_config("anthropic", "claude-opus-4-6")
};
let response = PromptOutcome {
text: "success".to_string(),
last_tool_error: None,
was_overflow: false,
last_api_error: None,
};
assert!(response.last_api_error.is_none());
assert_eq!(config.provider, "anthropic"); }
#[test]
fn test_fallback_prompt_api_error_no_fallback_configured() {
let mut config = test_agent_config("anthropic", "claude-opus-4-6");
assert!(config.fallback_provider.is_none());
let response = PromptOutcome {
text: String::new(),
last_tool_error: None,
was_overflow: false,
last_api_error: Some("503 Service Unavailable".to_string()),
};
assert!(response.last_api_error.is_some());
assert!(!config.try_switch_to_fallback()); }
#[test]
fn test_fallback_prompt_api_error_with_fallback_switches() {
let mut config = AgentConfig {
fallback_provider: Some("google".to_string()),
fallback_model: Some("gemini-2.0-flash".to_string()),
..test_agent_config("anthropic", "claude-opus-4-6")
};
let response = PromptOutcome {
text: String::new(),
last_tool_error: None,
was_overflow: false,
last_api_error: Some("529 Overloaded".to_string()),
};
assert!(response.last_api_error.is_some());
assert!(config.try_switch_to_fallback());
assert_eq!(config.provider, "google");
assert_eq!(config.model, "gemini-2.0-flash");
}
#[test]
fn test_build_json_output_valid_json_with_expected_keys() {
let response = PromptOutcome {
text: "Hello, world!".to_string(),
last_tool_error: None,
was_overflow: false,
last_api_error: None,
};
let usage = Usage {
input: 100,
output: 50,
cache_read: 0,
cache_write: 0,
total_tokens: 150,
};
let result = build_json_output(&response, "claude-sonnet-4-20250514", &usage, false);
let parsed: serde_json::Value =
serde_json::from_str(&result).expect("build_json_output should produce valid JSON");
assert_eq!(parsed["response"], "Hello, world!");
assert_eq!(parsed["model"], "claude-sonnet-4-20250514");
assert_eq!(parsed["is_error"], false);
assert!(parsed["usage"].is_object());
assert_eq!(parsed["usage"]["input_tokens"], 100);
assert_eq!(parsed["usage"]["output_tokens"], 50);
assert!(parsed["cost_usd"].is_number());
}
#[test]
fn test_build_json_output_error_mode() {
let response = PromptOutcome {
text: "Something went wrong".to_string(),
last_tool_error: None,
was_overflow: false,
last_api_error: Some("API error".to_string()),
};
let usage = Usage {
input: 10,
output: 5,
cache_read: 0,
cache_write: 0,
total_tokens: 15,
};
let result = build_json_output(&response, "claude-sonnet-4-20250514", &usage, true);
let parsed: serde_json::Value = serde_json::from_str(&result)
.expect("build_json_output should produce valid JSON even in error mode");
assert_eq!(parsed["response"], "Something went wrong");
assert_eq!(parsed["is_error"], true);
assert!(parsed["usage"].is_object());
assert!(parsed["cost_usd"].is_number());
}
}