capo-cli 0.5.0

Capo — a Rust-native coding agent CLI.
#![allow(clippy::expect_used, clippy::unwrap_used)]

use std::sync::Arc;

use async_trait::async_trait;
use capo_agent::{AppBuilder, Command, Config, UiEvent};
use capo_tui::state::AppState;
use futures::StreamExt;
use motosan_agent_loop::{ChatOutput, LlmClient, LlmResponse, Message};
use motosan_agent_tool::ToolDef;
use tempfile::tempdir;
use tokio::sync::mpsc;

struct EchoLlm;

#[async_trait]
impl LlmClient for EchoLlm {
    async fn chat(
        &self,
        _messages: &[Message],
        _tools: &[ToolDef],
    ) -> motosan_agent_loop::Result<ChatOutput> {
        Ok(ChatOutput::new(LlmResponse::Message("ok".into())))
    }
}

#[tokio::test]
async fn user_message_routes_through_tui_to_agent_and_back() {
    let dir = tempdir().unwrap();
    let mut cfg = Config::default();
    cfg.anthropic.api_key = Some("sk-unused".into());
    let (ui_tx, ui_rx) = mpsc::channel::<UiEvent>(32);

    let app = AppBuilder::new()
        .with_config(cfg)
        .with_cwd(dir.path())
        .with_builtin_tools()
        .with_llm(Arc::new(EchoLlm))
        .with_ui_channel(ui_tx.clone())
        .build()
        .await
        .expect("build");
    let app = Arc::new(app);

    let (cmd_tx, mut cmd_rx) = mpsc::channel::<Command>(16);
    cmd_tx
        .send(Command::SendUserMessage("hi".into()))
        .await
        .unwrap();
    drop(cmd_tx);

    {
        let app = Arc::clone(&app);
        let ui_tx = ui_tx.clone();
        tokio::spawn(async move {
            while let Some(cmd) = cmd_rx.recv().await {
                if let Command::SendUserMessage(text) = cmd {
                    let mut stream = Box::pin(app.send_user_message(text));
                    while let Some(ev) = stream.next().await {
                        if ui_tx.send(ev).await.is_err() {
                            break;
                        }
                    }
                    break;
                }
            }
        });
    }
    drop(app);
    drop(ui_tx);

    let ui_rx_stream = Box::pin(futures::stream::unfold(ui_rx, |mut rx| async move {
        rx.recv().await.map(|event| (event, rx))
    }));
    let (cmd_out_tx, _cmd_out_rx) = mpsc::channel::<Command>(16);
    let state = tokio::time::timeout(
        std::time::Duration::from_secs(3),
        capo_tui::runtime::drive_headless(AppState::default(), ui_rx_stream, cmd_out_tx),
    )
    .await
    .expect("drive_headless timeout")
    .expect("drive_headless error");

    assert!(
        state
            .messages
            .iter()
            .any(|m| matches!(m, capo_tui::state::MessageBlock::Assistant(t) if t == "ok")),
        "assistant message missing: {:?}",
        state.messages
    );
}