#![allow(clippy::expect_used, clippy::unwrap_used)]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use async_trait::async_trait;
use capo_agent::{AppBuilder, Config, UiEvent, UserMessage};
use futures::StreamExt;
use motosan_agent_loop::{ChatOutput, LlmClient, LlmResponse, Message, ToolCallItem};
use motosan_agent_tool::ToolDef;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
struct ScriptedClient {
turn: AtomicUsize,
}
impl ScriptedClient {
fn new() -> Self {
Self {
turn: AtomicUsize::new(0),
}
}
}
#[async_trait]
impl LlmClient for ScriptedClient {
async fn chat(
&self,
_messages: &[Message],
_tools: &[ToolDef],
) -> motosan_agent_loop::Result<ChatOutput> {
let turn = self.turn.fetch_add(1, Ordering::SeqCst);
let response = match turn {
0 => LlmResponse::ToolCalls(vec![ToolCallItem {
id: "call_1".into(),
name: "bash".into(),
args: serde_json::json!({ "command": "echo hello" }),
}]),
_ => LlmResponse::Message("The command printed 'hello'.".into()),
};
Ok(ChatOutput::new(response))
}
}
#[tokio::test]
async fn agent_loop_invokes_bash_and_returns_summary() {
let dir = tempdir().expect("tempdir");
let mut config = Config::default();
config.anthropic.api_key = Some("sk-unused-in-this-test".into());
let app = AppBuilder::new()
.with_config(config)
.with_cwd(dir.path())
.with_builtin_tools()
.with_llm(Arc::new(ScriptedClient::new()) as Arc<dyn LlmClient>)
.build()
.await
.expect("build should succeed with injected LLM");
let events: Vec<UiEvent> = app
.send_user_message(UserMessage::text("run echo hello"))
.collect()
.await;
assert!(matches!(events.first(), Some(UiEvent::AgentTurnStarted)));
assert!(matches!(events.last(), Some(UiEvent::AgentTurnComplete)));
let final_text = events.iter().rev().find_map(|e| match e {
UiEvent::AgentMessageComplete(t) => Some(t.clone()),
_ => None,
});
assert_eq!(final_text.as_deref(), Some("The command printed 'hello'."));
}