mod cli;
mod commands;
mod commands_git;
mod commands_project;
mod commands_session;
mod docs;
mod format;
mod git;
mod memory;
mod prompt;
mod repl;
use cli::*;
use format::*;
use prompt::*;
use std::io::{self, IsTerminal, Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use yoagent::agent::Agent;
use yoagent::context::ExecutionLimits;
use yoagent::openapi::{OpenApiConfig, OperationFilter};
use yoagent::provider::{
AnthropicProvider, GoogleProvider, ModelConfig, OpenAiCompat, OpenAiCompatProvider,
};
use yoagent::tools::bash::BashTool;
use yoagent::tools::edit::EditFileTool;
use yoagent::tools::file::{ReadFileTool, WriteFileTool};
use yoagent::tools::list::ListFilesTool;
use yoagent::tools::search::SearchTool;
use yoagent::types::AgentTool;
use yoagent::*;
struct GuardedTool {
inner: Box<dyn AgentTool>,
restrictions: cli::DirectoryRestrictions,
}
#[async_trait::async_trait]
impl AgentTool for GuardedTool {
fn name(&self) -> &str {
self.inner.name()
}
fn label(&self) -> &str {
self.inner.label()
}
fn description(&self) -> &str {
self.inner.description()
}
fn parameters_schema(&self) -> serde_json::Value {
self.inner.parameters_schema()
}
async fn execute(
&self,
params: serde_json::Value,
ctx: yoagent::types::ToolContext,
) -> Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
if let Some(path) = params.get("path").and_then(|v| v.as_str()) {
if let Err(reason) = self.restrictions.check_path(path) {
return Err(yoagent::types::ToolError::Failed(reason));
}
}
self.inner.execute(params, ctx).await
}
}
struct TruncatingTool {
inner: Box<dyn AgentTool>,
}
fn truncate_result(mut result: yoagent::types::ToolResult) -> yoagent::types::ToolResult {
use yoagent::Content;
result.content = result
.content
.into_iter()
.map(|c| match c {
Content::Text { text } => Content::Text {
text: truncate_tool_output(&text, TOOL_OUTPUT_MAX_CHARS),
},
other => other,
})
.collect();
result
}
#[async_trait::async_trait]
impl AgentTool for TruncatingTool {
fn name(&self) -> &str {
self.inner.name()
}
fn label(&self) -> &str {
self.inner.label()
}
fn description(&self) -> &str {
self.inner.description()
}
fn parameters_schema(&self) -> serde_json::Value {
self.inner.parameters_schema()
}
async fn execute(
&self,
params: serde_json::Value,
ctx: yoagent::types::ToolContext,
) -> Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
let result = self.inner.execute(params, ctx).await?;
Ok(truncate_result(result))
}
}
fn with_truncation(tool: Box<dyn AgentTool>) -> Box<dyn AgentTool> {
Box::new(TruncatingTool { inner: tool })
}
fn maybe_guard(
tool: Box<dyn AgentTool>,
restrictions: &cli::DirectoryRestrictions,
) -> Box<dyn AgentTool> {
if restrictions.is_empty() {
tool
} else {
Box::new(GuardedTool {
inner: tool,
restrictions: restrictions.clone(),
})
}
}
struct ConfirmTool {
inner: Box<dyn AgentTool>,
always_approved: Arc<AtomicBool>,
permissions: cli::PermissionConfig,
}
pub fn describe_file_operation(tool_name: &str, params: &serde_json::Value) -> String {
match tool_name {
"write_file" => {
let path = params
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("<unknown>");
let line_count = params
.get("content")
.and_then(|v| v.as_str())
.map(|c| c.lines().count())
.unwrap_or(0);
let word = crate::format::pluralize(line_count, "line", "lines");
format!("write: {path} ({line_count} {word})")
}
"edit_file" => {
let path = params
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("<unknown>");
let old_text = params
.get("old_text")
.and_then(|v| v.as_str())
.unwrap_or("");
let new_text = params
.get("new_text")
.and_then(|v| v.as_str())
.unwrap_or("");
let old_lines = old_text.lines().count();
let new_lines = new_text.lines().count();
format!("edit: {path} ({old_lines} → {new_lines} lines)")
}
_ => format!("{tool_name}: file operation"),
}
}
pub fn confirm_file_operation(
description: &str,
path: &str,
always_approved: &Arc<AtomicBool>,
permissions: &cli::PermissionConfig,
) -> bool {
if always_approved.load(Ordering::Relaxed) {
eprintln!(
"{GREEN} ✓ Auto-approved: {RESET}{}",
truncate_with_ellipsis(description, 120)
);
return true;
}
if let Some(allowed) = permissions.check(path) {
if allowed {
eprintln!(
"{GREEN} ✓ Permitted: {RESET}{}",
truncate_with_ellipsis(description, 120)
);
return true;
} else {
eprintln!(
"{RED} ✗ Denied by permission rule: {RESET}{}",
truncate_with_ellipsis(description, 120)
);
return false;
}
}
use std::io::BufRead;
eprint!(
"{YELLOW} ⚠ Allow {RESET}{}{YELLOW} ? {RESET}({GREEN}y{RESET}/{RED}n{RESET}/{GREEN}a{RESET}lways) ",
truncate_with_ellipsis(description, 120)
);
io::stderr().flush().ok();
let mut response = String::new();
let stdin = io::stdin();
if stdin.lock().read_line(&mut response).is_err() {
return false;
}
let response = response.trim().to_lowercase();
let approved = matches!(response.as_str(), "y" | "yes" | "a" | "always");
if matches!(response.as_str(), "a" | "always") {
always_approved.store(true, Ordering::Relaxed);
eprintln!(
"{GREEN} ✓ All subsequent operations will be auto-approved this session.{RESET}"
);
}
approved
}
#[async_trait::async_trait]
impl AgentTool for ConfirmTool {
fn name(&self) -> &str {
self.inner.name()
}
fn label(&self) -> &str {
self.inner.label()
}
fn description(&self) -> &str {
self.inner.description()
}
fn parameters_schema(&self) -> serde_json::Value {
self.inner.parameters_schema()
}
async fn execute(
&self,
params: serde_json::Value,
ctx: yoagent::types::ToolContext,
) -> Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
let tool_name = self.inner.name();
let path = params
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("<unknown>");
let description = describe_file_operation(tool_name, ¶ms);
if !confirm_file_operation(&description, path, &self.always_approved, &self.permissions) {
return Err(yoagent::types::ToolError::Failed(format!(
"User denied {tool_name} on '{path}'"
)));
}
self.inner.execute(params, ctx).await
}
}
fn maybe_confirm(
tool: Box<dyn AgentTool>,
always_approved: &Arc<AtomicBool>,
permissions: &cli::PermissionConfig,
) -> Box<dyn AgentTool> {
Box::new(ConfirmTool {
inner: tool,
always_approved: Arc::clone(always_approved),
permissions: permissions.clone(),
})
}
pub fn build_tools(
auto_approve: bool,
permissions: &cli::PermissionConfig,
dir_restrictions: &cli::DirectoryRestrictions,
) -> Vec<Box<dyn AgentTool>> {
let always_approved = Arc::new(AtomicBool::new(false));
let bash = if auto_approve {
BashTool::default()
} else {
let flag = Arc::clone(&always_approved);
let perms = permissions.clone();
BashTool::default().with_confirm(move |cmd: &str| {
if flag.load(Ordering::Relaxed) {
eprintln!(
"{GREEN} ✓ Auto-approved: {RESET}{}",
truncate_with_ellipsis(cmd, 120)
);
return true;
}
if let Some(allowed) = perms.check(cmd) {
if allowed {
eprintln!(
"{GREEN} ✓ Permitted: {RESET}{}",
truncate_with_ellipsis(cmd, 120)
);
return true;
} else {
eprintln!(
"{RED} ✗ Denied by permission rule: {RESET}{}",
truncate_with_ellipsis(cmd, 120)
);
return false;
}
}
use std::io::BufRead;
eprint!(
"{YELLOW} ⚠ Allow: {RESET}{}{YELLOW} ? {RESET}({GREEN}y{RESET}/{RED}n{RESET}/{GREEN}a{RESET}lways) ",
truncate_with_ellipsis(cmd, 120)
);
io::stderr().flush().ok();
let mut response = String::new();
let stdin = io::stdin();
if stdin.lock().read_line(&mut response).is_err() {
return false;
}
let response = response.trim().to_lowercase();
let approved = matches!(response.as_str(), "y" | "yes" | "a" | "always");
if matches!(response.as_str(), "a" | "always") {
flag.store(true, Ordering::Relaxed);
eprintln!(
"{GREEN} ✓ All subsequent operations will be auto-approved this session.{RESET}"
);
}
approved
})
};
let write_tool: Box<dyn AgentTool> = if auto_approve {
maybe_guard(Box::new(WriteFileTool::new()), dir_restrictions)
} else {
maybe_guard(
maybe_confirm(
Box::new(WriteFileTool::new()),
&always_approved,
permissions,
),
dir_restrictions,
)
};
let edit_tool: Box<dyn AgentTool> = if auto_approve {
maybe_guard(Box::new(EditFileTool::new()), dir_restrictions)
} else {
maybe_guard(
maybe_confirm(Box::new(EditFileTool::new()), &always_approved, permissions),
dir_restrictions,
)
};
vec![
with_truncation(Box::new(bash)),
with_truncation(maybe_guard(
Box::new(ReadFileTool::default()),
dir_restrictions,
)),
with_truncation(write_tool),
with_truncation(edit_tool),
with_truncation(maybe_guard(
Box::new(ListFilesTool::default()),
dir_restrictions,
)),
with_truncation(maybe_guard(
Box::new(SearchTool::default()),
dir_restrictions,
)),
]
}
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
}
"custom" => {
let url = base_url.unwrap_or("http://localhost:8080/v1");
ModelConfig::local(url, model)
}
_ => {
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,
}
impl AgentConfig {
pub fn build_agent(&self) -> Agent {
let base_url = self.base_url.as_deref();
let mut agent = if self.provider == "anthropic" && base_url.is_none() {
let mut anthropic_config = ModelConfig::anthropic(&self.model, &self.model);
insert_client_headers(&mut anthropic_config);
Agent::new(AnthropicProvider)
.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,
))
.with_model_config(anthropic_config)
} else if self.provider == "google" {
let config = create_model_config(&self.provider, &self.model, base_url);
Agent::new(GoogleProvider)
.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,
))
.with_model_config(config)
} else {
let config = create_model_config(&self.provider, &self.model, base_url);
Agent::new(OpenAiCompatProvider)
.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,
))
.with_model_config(config)
};
if let Some(max) = self.max_tokens {
agent = agent.with_max_tokens(max);
}
if let Some(temp) = self.temperature {
agent.temperature = Some(temp);
}
if let Some(turns) = self.max_turns {
agent = agent.with_execution_limits(ExecutionLimits {
max_turns: turns,
..ExecutionLimits::default()
});
}
agent
}
}
#[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();
}
let Some(config) = parse_args(&args) else {
return; };
if config.verbose {
enable_verbose();
}
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 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,
};
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 response = run_prompt(
&mut agent,
prompt_text.trim(),
&mut session_total,
&agent_config.model,
)
.await;
write_output_file(&output_path, &response.text);
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 response = run_prompt(&mut agent, input, &mut session_total, &agent_config.model).await;
write_output_file(&output_path, &response.text);
return;
}
repl::run_repl(
&mut agent_config,
&mut agent,
mcp_count,
openapi_count,
continue_session,
)
.await;
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[test]
fn test_always_approve_flag_starts_false() {
let flag = Arc::new(AtomicBool::new(false));
assert!(!flag.load(Ordering::Relaxed));
}
#[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_six_tools() {
let perms = cli::PermissionConfig::default();
let dirs = cli::DirectoryRestrictions::default();
let tools_approved = build_tools(true, &perms, &dirs);
let tools_confirm = build_tools(false, &perms, &dirs);
assert_eq!(tools_approved.len(), 6);
assert_eq!(tools_confirm.len(), 6);
}
#[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(),
};
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(),
};
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(),
};
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(),
};
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(),
};
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(),
};
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(),
};
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(),
};
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("0 lines"));
}
#[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);
assert_eq!(tools.len(), 6);
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);
assert_eq!(tools.len(), 6);
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"));
}
#[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(),
};
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(),
};
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();
}
}