capo-agent 0.2.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
use motosan_agent_tool::ToolResult;
use serde_json::Value;

#[derive(Debug, Clone)]
pub enum ProgressChunk {
    Stdout(Vec<u8>),
    Stderr(Vec<u8>),
    Status(String),
}

impl From<crate::tools::ToolProgressChunk> for ProgressChunk {
    fn from(c: crate::tools::ToolProgressChunk) -> Self {
        use crate::tools::ToolProgressChunk as TPC;
        match c {
            TPC::Stdout(b) => Self::Stdout(b),
            TPC::Stderr(b) => Self::Stderr(b),
            TPC::Status(s) => Self::Status(s),
        }
    }
}

/// User's answer to a `PermissionRequested` prompt, carried back from the
/// front-end to the binary so the binary can update the session cache.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionResolution {
    pub tool: String,
    pub args: serde_json::Value,
    pub choice: PermissionChoice,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionChoice {
    AllowOnce,
    AllowSession,
    Deny,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
    SendUserMessage(String),
    CancelAgent,
    Quit,
    ResolvePermission(PermissionResolution),
    /// `!cmd` / `!!cmd` — run a shell command typed directly by the user.
    /// `send_to_llm` is true for `!cmd` (output becomes a user message),
    /// false for `!!cmd` (output shown in the transcript only).
    RunInlineBash {
        command: String,
        send_to_llm: bool,
    },
    /// Manual context compaction, triggered by the `/compact` slash command.
    Compact,
    /// `/new` — replace the live session with a fresh empty one.
    NewSession,
    /// `/model` — rebuild the live session under a different model.
    SwitchModel(crate::model::ModelId),
    /// `/resume` — replace the live session with a stored one, by id.
    LoadSession(String),
}

#[derive(Debug)]
pub enum UiEvent {
    AgentTurnStarted,
    AgentThinking,
    AgentTextDelta(String),
    AgentMessageComplete(String),
    ToolCallStarted {
        id: String,
        name: String,
        args: Value,
    },
    ToolCallProgress {
        id: String,
        chunk: ProgressChunk,
    },
    ToolCallCompleted {
        id: String,
        result: UiToolResult,
    },
    AgentTurnComplete,
    PermissionRequested {
        tool: String,
        args: serde_json::Value,
        resolver: tokio::sync::oneshot::Sender<crate::permissions::Decision>,
    },
    /// Output of a `!!cmd` inline-bash run, for transcript display.
    InlineBashOutput {
        command: String,
        output: String,
    },
    /// The session was replaced (`/new` cleared it, or `/resume` loaded a
    /// stored one). Carries the new transcript so the TUI can rebuild
    /// `state.messages`. Empty Vec = a fresh session.
    SessionReplaced(Vec<motosan_agent_loop::Message>),
    /// The active model changed successfully.
    ModelSwitched(crate::model::ModelId),
    Error(String),
}

#[derive(Debug, Clone)]
pub struct UiToolResult {
    pub is_error: bool,
    pub text: String,
}

impl From<&ToolResult> for UiToolResult {
    fn from(r: &ToolResult) -> Self {
        Self {
            is_error: r.is_error,
            text: format!("{r:?}"),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn compact_command_is_constructible() {
        let c = Command::Compact;
        assert_eq!(c, Command::Compact);
        assert!(format!("{c:?}").contains("Compact"));
    }

    #[test]
    fn d2_command_and_event_variants_construct() {
        let _ = Command::NewSession;
        let _ = Command::SwitchModel(crate::model::ModelId::from("m"));
        let _ = Command::LoadSession("sess-id".into());
        let e = UiEvent::SessionReplaced(Vec::new());
        assert!(format!("{e:?}").contains("SessionReplaced"));
        let e = UiEvent::ModelSwitched(crate::model::ModelId::from("m"));
        assert!(format!("{e:?}").contains("ModelSwitched"));
    }

    #[test]
    fn run_inline_bash_command_is_constructible() {
        let c = Command::RunInlineBash {
            command: "ls".into(),
            send_to_llm: true,
        };
        assert!(format!("{c:?}").contains("RunInlineBash"));
    }

    #[test]
    fn permission_protocol_variants_are_constructible() {
        let command = Command::ResolvePermission(PermissionResolution {
            tool: "bash".into(),
            args: serde_json::json!({"command": "echo hi"}),
            choice: PermissionChoice::AllowSession,
        });
        assert!(format!("{command:?}").contains("ResolvePermission"));
        assert!(format!("{command:?}").contains("AllowSession"));

        let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
        let event = UiEvent::PermissionRequested {
            tool: "bash".into(),
            args: serde_json::json!({"command": "echo hi"}),
            resolver,
        };
        assert!(format!("{event:?}").contains("PermissionRequested"));
    }
}