capo-agent 0.5.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
#![cfg_attr(test, allow(clippy::expect_used))]

//! JSON/RPC wire protocol — line-delimited JSON encoding of the existing
//! `UiEvent` (out) and `Command` (in) enums. See `docs/json.md` and
//! `docs/rpc.md`.

use serde::ser::Serializer;
use serde::Serialize;

use crate::events::{Command, UiEvent};

/// Bumped when the wire schema changes incompatibly.
pub const PROTOCOL_VERSION: u32 = 1;

/// The first line emitted on any JSON/RPC stream.
#[derive(Debug, Serialize)]
pub struct SessionHeader {
    #[serde(rename = "type")]
    kind: &'static str,
    pub protocol_version: u32,
    pub session_id: String,
    pub cwd: String,
}

impl SessionHeader {
    pub fn new(session_id: String, cwd: String) -> Self {
        Self {
            kind: "session",
            protocol_version: PROTOCOL_VERSION,
            session_id,
            cwd,
        }
    }
}

/// A failure to decode a client command line.
#[derive(Debug)]
pub struct ProtocolError(pub String);

impl std::fmt::Display for ProtocolError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl std::error::Error for ProtocolError {}

/// Decode one JSONL line into a `Command`.
pub fn decode_command(line: &str) -> Result<Command, ProtocolError> {
    serde_json::from_str(line).map_err(|e| ProtocolError(e.to_string()))
}

/// Encode one `UiEvent` as a single JSON object (no trailing newline —
/// the stream writer adds `\n`). Encoding cannot realistically fail for
/// our derived `Serialize`; if it ever does, fall back to an `error` line
/// so the stream stays valid JSONL.
pub fn encode_event(event: &UiEvent) -> String {
    serde_json::to_string(event)
        .unwrap_or_else(|e| format!(r#"{{"type":"error","payload":"event encode failed: {e}"}}"#))
}

/// `#[serde(serialize_with = "…")]` target for `UiEvent::BranchTree`'s
/// payload — motosan's `BranchTree`/`BranchNode` are not `Serialize`, so
/// map them into a local serializable shape.
pub fn serialize_branch_tree<S>(
    tree: &motosan_agent_loop::BranchTree,
    s: S,
) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    #[derive(Serialize)]
    struct WireNode {
        id: String,
        parent: Option<usize>,
        children: Vec<usize>,
        label: String,
    }
    #[derive(Serialize)]
    struct WireTree {
        nodes: Vec<WireNode>,
        root: Option<usize>,
        active_leaf: Option<usize>,
    }
    let wire = WireTree {
        nodes: tree
            .nodes
            .iter()
            .map(|n| WireNode {
                id: n.id.clone(),
                parent: n.parent,
                children: n.children.clone(),
                label: n.label.clone(),
            })
            .collect(),
        root: tree.root,
        active_leaf: tree.active_leaf,
    };
    wire.serialize(s)
}

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

    #[test]
    fn session_header_serializes_with_version() {
        let h = SessionHeader::new("sess-1".into(), "/tmp/x".into());
        let line = serde_json::to_string(&h).expect("serialize");
        assert!(line.contains(r#""type":"session""#), "{line}");
        assert!(line.contains(r#""protocol_version":1"#), "{line}");
        assert!(line.contains(r#""session_id":"sess-1""#), "{line}");
    }

    #[test]
    fn decode_command_parses_a_valid_line() {
        let cmd = decode_command(r#"{"type":"cancel_agent"}"#).expect("decode");
        assert_eq!(cmd, crate::events::Command::CancelAgent);
    }

    #[test]
    fn decode_command_rejects_garbage() {
        let err = decode_command("not json").expect_err("should fail");
        assert!(!err.to_string().is_empty());
    }

    #[test]
    fn serialize_branch_tree_emits_nodes() {
        use motosan_agent_loop::{BranchNode, BranchTree};
        let tree = BranchTree {
            nodes: vec![BranchNode {
                id: "n0".into(),
                parent: None,
                children: vec![],
                label: "root".into(),
            }],
            root: Some(0),
            active_leaf: Some(0),
        };
        // Serialize via a tiny wrapper that uses serialize_with.
        #[derive(serde::Serialize)]
        struct W<'a>(#[serde(serialize_with = "serialize_branch_tree")] &'a BranchTree);
        let line = serde_json::to_string(&W(&tree)).expect("serialize");
        assert!(line.contains(r#""label":"root""#), "{line}");
        assert!(line.contains(r#""active_leaf":0"#), "{line}");
    }

    #[test]
    fn encode_event_produces_one_tagged_line() {
        use crate::events::UiEvent;
        assert_eq!(
            encode_event(&UiEvent::AgentThinking),
            r#"{"type":"agent_thinking"}"#
        );
        let line = encode_event(&UiEvent::AgentTextDelta("hi".into()));
        assert_eq!(line, r#"{"type":"agent_text_delta","payload":"hi"}"#);
        assert!(!line.contains('\n'), "encode_event must not add a newline");
    }
}