capo-agent 0.2.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
#![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};
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("run echo hello".into())
        .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'."));
}