mod cli;
mod commands;
mod commands_dev;
mod commands_file;
mod commands_git;
mod commands_project;
mod commands_search;
mod commands_session;
mod docs;
mod format;
mod git;
mod help;
mod memory;
mod prompt;
mod repl;
mod setup;
use cli::*;
use format::*;
use prompt::*;
use std::io::{self, IsTerminal, Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use yoagent::agent::Agent;
use yoagent::context::{ContextConfig, ExecutionLimits};
use yoagent::openapi::{OpenApiConfig, OperationFilter};
use yoagent::provider::{
AnthropicProvider, GoogleProvider, ModelConfig, OpenAiCompat, OpenAiCompatProvider,
StreamProvider,
};
use yoagent::sub_agent::SubAgentTool;
use yoagent::tools::bash::ConfirmFn;
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::*;
static CHECKPOINT_TRIGGERED: AtomicBool = AtomicBool::new(false);
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>,
max_chars: usize,
}
fn truncate_result(
mut result: yoagent::types::ToolResult,
max_chars: usize,
) -> 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, 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, self.max_chars))
}
}
fn with_truncation(tool: Box<dyn AgentTool>, max_chars: usize) -> Box<dyn AgentTool> {
Box::new(TruncatingTool {
inner: tool,
max_chars,
})
}
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)")
}
"rename_symbol" => {
let old_name = params
.get("old_name")
.and_then(|v| v.as_str())
.unwrap_or("<unknown>");
let new_name = params
.get("new_name")
.and_then(|v| v.as_str())
.unwrap_or("<unknown>");
let scope = params
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("project");
format!("rename: {old_name} → {new_name} (in {scope})")
}
_ => 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 struct StreamingBashTool {
pub cwd: Option<String>,
pub timeout: Duration,
pub max_output_bytes: usize,
pub deny_patterns: Vec<String>,
pub confirm_fn: Option<ConfirmFn>,
pub update_interval: Duration,
pub lines_per_update: usize,
}
impl Default for StreamingBashTool {
fn default() -> Self {
Self {
cwd: None,
timeout: Duration::from_secs(120),
max_output_bytes: 256 * 1024, deny_patterns: vec![
"rm -rf /".into(),
"rm -rf /*".into(),
"mkfs".into(),
"dd if=".into(),
":(){:|:&};:".into(), ],
confirm_fn: None,
update_interval: Duration::from_millis(500),
lines_per_update: 20,
}
}
}
impl StreamingBashTool {
pub fn with_confirm(mut self, f: impl Fn(&str) -> bool + Send + Sync + 'static) -> Self {
self.confirm_fn = Some(Box::new(f));
self
}
}
fn emit_update(ctx: &yoagent::types::ToolContext, output: &str) {
if let Some(ref on_update) = ctx.on_update {
on_update(yoagent::types::ToolResult {
content: vec![yoagent::types::Content::Text {
text: output.to_string(),
}],
details: serde_json::json!({"streaming": true}),
});
}
}
#[async_trait::async_trait]
impl AgentTool for StreamingBashTool {
fn name(&self) -> &str {
"bash"
}
fn label(&self) -> &str {
"Execute Command"
}
fn description(&self) -> &str {
"Execute a bash command and return stdout/stderr. Use for running scripts, installing packages, checking system state, etc."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute"
}
},
"required": ["command"]
})
}
async fn execute(
&self,
params: serde_json::Value,
ctx: yoagent::types::ToolContext,
) -> Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
use tokio::io::AsyncBufReadExt;
use yoagent::types::{Content, ToolError, ToolResult as TR};
let cancel = ctx.cancel.clone();
let command = params["command"]
.as_str()
.ok_or_else(|| ToolError::InvalidArgs("missing 'command' parameter".into()))?;
for pattern in &self.deny_patterns {
if command.contains(pattern.as_str()) {
return Err(ToolError::Failed(format!(
"Command blocked by safety policy: contains '{}'. This pattern is denied for safety.",
pattern
)));
}
}
if let Some(ref confirm) = self.confirm_fn {
if !confirm(command) {
return Err(ToolError::Failed(
"Command was not confirmed by the user.".into(),
));
}
}
let mut cmd = tokio::process::Command::new("bash");
cmd.arg("-c").arg(command);
if let Some(ref cwd) = self.cwd {
cmd.current_dir(cwd);
}
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let timeout = self.timeout;
let max_bytes = self.max_output_bytes;
let update_interval = self.update_interval;
let lines_per_update = self.lines_per_update;
let mut child = cmd
.spawn()
.map_err(|e| ToolError::Failed(format!("Failed to spawn: {e}")))?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let accumulated = Arc::new(tokio::sync::Mutex::new(String::new()));
let truncated = Arc::new(AtomicBool::new(false));
let acc_clone = Arc::clone(&accumulated);
let trunc_clone = Arc::clone(&truncated);
let cancel_clone = cancel.clone();
let ctx_clone = ctx.clone();
let reader_handle = tokio::spawn(async move {
let stdout_reader = stdout.map(tokio::io::BufReader::new);
let stderr_reader = stderr.map(tokio::io::BufReader::new);
let mut stdout_lines = stdout_reader.map(|r| r.lines());
let mut stderr_lines = stderr_reader.map(|r| r.lines());
let mut lines_since_update: usize = 0;
let mut last_update = tokio::time::Instant::now();
let mut stdout_done = stdout_lines.is_none();
let mut stderr_done = stderr_lines.is_none();
loop {
if cancel_clone.is_cancelled() {
break;
}
if stdout_done && stderr_done {
break;
}
let line = tokio::select! {
biased;
result = async {
match stdout_lines.as_mut() {
Some(lines) => lines.next_line().await,
None => std::future::pending().await,
}
}, if !stdout_done => {
match result {
Ok(Some(line)) => Some(line),
Ok(None) => { stdout_done = true; None }
Err(_) => { stdout_done = true; None }
}
}
result = async {
match stderr_lines.as_mut() {
Some(lines) => lines.next_line().await,
None => std::future::pending().await,
}
}, if !stderr_done => {
match result {
Ok(Some(line)) => Some(line),
Ok(None) => { stderr_done = true; None }
Err(_) => { stderr_done = true; None }
}
}
};
if let Some(line) = line {
let mut acc = acc_clone.lock().await;
if acc.len() < max_bytes {
if !acc.is_empty() {
acc.push('\n');
}
acc.push_str(&line);
if acc.len() > max_bytes {
acc.truncate(max_bytes);
acc.push_str("\n... (output truncated)");
trunc_clone.store(true, Ordering::Relaxed);
}
}
lines_since_update += 1;
drop(acc);
let elapsed = last_update.elapsed();
if elapsed >= update_interval || lines_since_update >= lines_per_update {
let snapshot = acc_clone.lock().await.clone();
emit_update(&ctx_clone, &snapshot);
lines_since_update = 0;
last_update = tokio::time::Instant::now();
}
}
}
});
let exit_status = tokio::select! {
_ = cancel.cancelled() => {
let _ = child.kill().await;
reader_handle.abort();
return Err(yoagent::types::ToolError::Cancelled);
}
_ = tokio::time::sleep(timeout) => {
let _ = child.kill().await;
reader_handle.abort();
return Err(ToolError::Failed(format!(
"Command timed out after {}s",
timeout.as_secs()
)));
}
status = child.wait() => {
status.map_err(|e| ToolError::Failed(format!("Failed to wait: {e}")))?
}
};
let _ = tokio::time::timeout(Duration::from_secs(2), reader_handle).await;
let exit_code = exit_status.code().unwrap_or(-1);
let output = accumulated.lock().await.clone();
emit_update(&ctx, &output);
let formatted = format!("Exit code: {exit_code}\n{output}");
Ok(TR {
content: vec![Content::Text { text: formatted }],
details: serde_json::json!({ "exit_code": exit_code, "success": exit_code == 0 }),
})
}
}
struct RenameSymbolTool;
#[async_trait::async_trait]
impl AgentTool for RenameSymbolTool {
fn name(&self) -> &str {
"rename_symbol"
}
fn label(&self) -> &str {
"Rename"
}
fn description(&self) -> &str {
"Rename a symbol across the project. Performs word-boundary-aware find-and-replace \
in all git-tracked files. More reliable than multiple edit_file calls for renames. \
Returns a preview of changes and the number of files modified."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"old_name": {
"type": "string",
"description": "The current name of the symbol to rename"
},
"new_name": {
"type": "string",
"description": "The new name for the symbol"
},
"path": {
"type": "string",
"description": "Optional: limit rename to a specific file or directory (default: entire project)"
}
},
"required": ["old_name", "new_name"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: yoagent::types::ToolContext,
) -> Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
use yoagent::types::{Content, ToolError, ToolResult as TR};
let old_name = params["old_name"]
.as_str()
.ok_or_else(|| ToolError::InvalidArgs("missing 'old_name' parameter".into()))?;
let new_name = params["new_name"]
.as_str()
.ok_or_else(|| ToolError::InvalidArgs("missing 'new_name' parameter".into()))?;
let scope = params["path"].as_str();
match commands_project::rename_in_project(old_name, new_name, scope) {
Ok(result) => {
let summary = format!(
"Renamed '{}' → '{}': {} replacement{} across {} file{}.\n\nFiles changed:\n{}\n\n{}",
old_name,
new_name,
result.total_replacements,
if result.total_replacements == 1 { "" } else { "s" },
result.files_changed.len(),
if result.files_changed.len() == 1 { "" } else { "s" },
result.files_changed.iter().map(|f| format!(" - {f}")).collect::<Vec<_>>().join("\n"),
result.preview,
);
Ok(TR {
content: vec![Content::Text { text: summary }],
details: serde_json::json!({}),
})
}
Err(msg) => Err(ToolError::Failed(msg)),
}
}
}
pub struct AskUserTool;
#[async_trait::async_trait]
impl AgentTool for AskUserTool {
fn name(&self) -> &str {
"ask_user"
}
fn label(&self) -> &str {
"ask_user"
}
fn description(&self) -> &str {
"Ask the user a question to get clarification or input. Use this when you need \
specific information to proceed, like a preference, a decision, or context that \
isn't available in the codebase. The user sees your question and types a response."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The question to ask the user. Be specific and concise."
}
},
"required": ["question"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: yoagent::types::ToolContext,
) -> Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
use yoagent::types::{Content, ToolError, ToolResult as TR};
let question = params
.get("question")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidArgs("Missing 'question' parameter".into()))?;
eprintln!("\n{YELLOW} ❓ {question}{RESET}");
eprint!("{GREEN} → {RESET}");
io::stderr().flush().ok();
use std::io::BufRead;
let mut response = String::new();
let stdin = io::stdin();
match stdin.lock().read_line(&mut response) {
Ok(0) | Err(_) => {
return Ok(TR {
content: vec![Content::Text {
text: "(user provided no response)".to_string(),
}],
details: serde_json::Value::Null,
});
}
_ => {}
}
let response = response.trim().to_string();
if response.is_empty() {
return Ok(TR {
content: vec![Content::Text {
text: "(user provided empty response)".to_string(),
}],
details: serde_json::Value::Null,
});
}
Ok(TR {
content: vec![Content::Text { text: response }],
details: serde_json::Value::Null,
})
}
}
pub struct TodoTool;
#[async_trait::async_trait]
impl AgentTool for TodoTool {
fn name(&self) -> &str {
"todo"
}
fn label(&self) -> &str {
"todo"
}
fn description(&self) -> &str {
"Manage a task list to track progress on complex multi-step operations. \
Use this to plan work, check off completed steps, and see what's remaining. \
Available actions: list, add, done, wip, remove, clear."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["list", "add", "done", "wip", "remove", "clear"],
"description": "Action: list (show all), add (create task), done (mark complete), wip (mark in-progress), remove (delete task), clear (delete all)"
},
"description": {
"type": "string",
"description": "Task description (required for 'add')"
},
"id": {
"type": "integer",
"description": "Task ID number (required for 'done', 'wip', 'remove')"
}
},
"required": ["action"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: yoagent::types::ToolContext,
) -> Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
use yoagent::types::{Content, ToolError, ToolResult as TR};
let action = params
.get("action")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidArgs("Missing required 'action' parameter".into()))?;
let text =
match action {
"list" => {
let items = commands_project::todo_list();
if items.is_empty() {
"No tasks. Use action 'add' to create one.".to_string()
} else {
commands_project::format_todo_list(&items)
}
}
"add" => {
let desc = params
.get("description")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidArgs("Missing 'description' for add action".into())
})?;
let id = commands_project::todo_add(desc);
format!("Added task #{id}: {desc}")
}
"done" => {
let id = params.get("id").and_then(|v| v.as_u64()).ok_or_else(|| {
ToolError::InvalidArgs("Missing 'id' for done action".into())
})? as usize;
commands_project::todo_update(id, commands_project::TodoStatus::Done)
.map_err(ToolError::Failed)?;
format!("Task #{id} marked as done ✓")
}
"wip" => {
let id = params.get("id").and_then(|v| v.as_u64()).ok_or_else(|| {
ToolError::InvalidArgs("Missing 'id' for wip action".into())
})? as usize;
commands_project::todo_update(id, commands_project::TodoStatus::InProgress)
.map_err(ToolError::Failed)?;
format!("Task #{id} marked as in-progress")
}
"remove" => {
let id = params.get("id").and_then(|v| v.as_u64()).ok_or_else(|| {
ToolError::InvalidArgs("Missing 'id' for remove action".into())
})? as usize;
let item = commands_project::todo_remove(id).map_err(ToolError::Failed)?;
format!("Removed task #{id}: {}", item.description)
}
"clear" => {
commands_project::todo_clear();
"All tasks cleared.".to_string()
}
other => {
return Err(ToolError::InvalidArgs(format!(
"Unknown action '{other}'. Use: list, add, done, wip, remove, clear"
)));
}
};
Ok(TR {
content: vec![Content::Text { text }],
details: serde_json::Value::Null,
})
}
}
pub fn build_tools(
auto_approve: bool,
permissions: &cli::PermissionConfig,
dir_restrictions: &cli::DirectoryRestrictions,
max_tool_output: usize,
) -> Vec<Box<dyn AgentTool>> {
let always_approved = Arc::new(AtomicBool::new(false));
let bash = if auto_approve {
StreamingBashTool::default()
} else {
let flag = Arc::clone(&always_approved);
let perms = permissions.clone();
StreamingBashTool::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,
)
};
let rename_tool: Box<dyn AgentTool> = if auto_approve {
Box::new(RenameSymbolTool)
} else {
maybe_confirm(Box::new(RenameSymbolTool), &always_approved, permissions)
};
let mut tools = vec![
with_truncation(Box::new(bash), max_tool_output),
with_truncation(
maybe_guard(Box::new(ReadFileTool::default()), dir_restrictions),
max_tool_output,
),
with_truncation(write_tool, max_tool_output),
with_truncation(edit_tool, max_tool_output),
with_truncation(
maybe_guard(Box::new(ListFilesTool::default()), dir_restrictions),
max_tool_output,
),
with_truncation(
maybe_guard(Box::new(SearchTool::default()), dir_restrictions),
max_tool_output,
),
with_truncation(rename_tool, max_tool_output),
];
if std::io::stdin().is_terminal() {
tools.push(Box::new(AskUserTool));
}
tools.push(Box::new(TodoTool));
tools
}
fn build_sub_agent_tool(config: &AgentConfig) -> SubAgentTool {
let child_tools: Vec<Arc<dyn AgentTool>> = vec![
Arc::new(yoagent::tools::bash::BashTool::default()),
Arc::new(ReadFileTool::default()),
Arc::new(WriteFileTool::new()),
Arc::new(EditFileTool::new()),
Arc::new(ListFilesTool::default()),
Arc::new(SearchTool::default()),
];
let provider: Arc<dyn StreamProvider> = match config.provider.as_str() {
"anthropic" => Arc::new(AnthropicProvider),
"google" => Arc::new(GoogleProvider),
_ => Arc::new(OpenAiCompatProvider),
};
SubAgentTool::new("sub_agent", provider)
.with_description(
"Delegate a subtask to a fresh sub-agent with its own context window. \
Use for complex, self-contained subtasks like: researching a codebase, \
running a series of tests, or implementing a well-scoped change. \
The sub-agent has bash, file read/write/edit, list, and search tools. \
It starts with a clean context and returns a summary of what it did.",
)
.with_system_prompt(
"You are a focused sub-agent. Complete the given task efficiently \
using the tools available. Be thorough but concise in your final \
response — summarize what you did, what you found, and any issues.",
)
.with_model(&config.model)
.with_api_key(&config.api_key)
.with_tools(child_tools)
.with_thinking(config.thinking)
.with_max_turns(25)
}
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
}
"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,
pub context_strategy: cli::ContextStrategy,
pub context_window: Option<u32>,
}
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
},
));
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 {
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)
}
}
}
#[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.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 image_path = config.image_path;
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,
};
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) {
std::env::set_var(env_var, &result.api_key);
}
} else {
cli::print_welcome();
return;
}
}
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, mime_type },
];
run_prompt_with_content(
&mut agent,
content_blocks,
&mut session_total,
&agent_config.model,
)
.await
}
Err(e) => {
eprintln!("{RED} error: {e}{RESET}");
std::process::exit(1);
}
}
} else {
run_prompt(
&mut agent,
prompt_text.trim(),
&mut session_total,
&agent_config.model,
)
.await
};
format::maybe_ring_bell(prompt_start.elapsed());
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 response = run_prompt(&mut agent, input, &mut session_total, &agent_config.model).await;
format::maybe_ring_bell(prompt_start.elapsed());
write_output_file(&output_path, &response.text);
if CHECKPOINT_TRIGGERED.load(Ordering::SeqCst) {
std::process::exit(2);
}
return;
}
repl::run_repl(
&mut agent_config,
&mut agent,
mcp_count,
openapi_count,
continue_session,
)
.await;
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
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_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);
let tools_confirm = build_tools(false, &perms, &dirs, TOOL_OUTPUT_MAX_CHARS);
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"));
}
#[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);
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,
};
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,
};
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,
};
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,
};
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,
};
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,
};
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,
};
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,
};
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, TOOL_OUTPUT_MAX_CHARS);
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);
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,
};
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_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,
};
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,
};
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,
}
}
#[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_project::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);
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!("line {i}"))
.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);
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);
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,
};
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,
};
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,
};
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);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(
names.contains(&"todo"),
"build_tools should include todo, got: {names:?}"
);
}
}