cydonia 0.1.13

Desktop workspace for the ACP agents you run, keeping what they produce as files on your disk
Documentation
use artifact::session::chat::ChatItem;
use cydonia::{
    agent::acp::{self, Event, Launch, Session},
    model::settings,
};
use std::time::Duration;

const AGENT: &str = r#"
import json, sys
for line in sys.stdin:
    req = json.loads(line)
    method = req.get('method')
    if 'id' not in req:
        continue
    if method == 'initialize':
        result = {'protocolVersion': 1, 'agentCapabilities': {'loadSession': True}, 'authMethods': []}
    elif method == 'session/new':
        result = {'sessionId': 'fork'}
    elif method == 'session/load':
        if req['params']['sessionId'] == 'gone':
            print(json.dumps({'jsonrpc': '2.0', 'id': req['id'], 'error': {'code': -32002, 'message': 'missing'}}), flush=True)
            continue
        result = {}
    elif method == 'session/prompt':
        assert req['params']['sessionId'] == 'fork'
        print(json.dumps({'jsonrpc': '2.0', 'method': 'session/update', 'params': {
            'sessionId': 'fork', 'update': {'sessionUpdate': 'agent_message_chunk', 'content': {
                'type': 'text', 'text': json.dumps(req['params']['prompt'])}}}}), flush=True)
        result = {'stopReason': 'end_turn'}
    else:
        print(json.dumps({'jsonrpc': '2.0', 'id': req['id'], 'error': {'code': -32601, 'message': 'unsupported'}}), flush=True)
        continue
    print(json.dumps({'jsonrpc': '2.0', 'id': req['id'], 'result': result}), flush=True)
"#;

fn agent() -> settings::Agent {
    settings::Agent {
        name: "fake".into(),
        id: None,
        command: "/usr/bin/python3".into(),
        args: vec!["-u".into(), "-c".into(), AGENT.into()],
        env: Default::default(),
    }
}

async fn answer(events: &mut acp::Events) -> String {
    tokio::time::timeout(Duration::from_secs(10), async {
        let mut text = String::new();
        loop {
            match events.recv().await.expect("agent closed") {
                Event::Update(cacp::schema::SessionUpdate::AgentMessageChunk(chunk)) => {
                    if let cacp::schema::ContentBlock::Text(body) = chunk.content {
                        text.push_str(&body.text);
                    }
                }
                Event::TurnDone(result) => {
                    result.unwrap();
                    return text;
                }
                _ => {}
            }
        }
    })
    .await
    .expect("prompt timed out")
}

#[test]
fn fork_context_is_sent_once_and_completed_forks_resume_without_reimporting() {
    acp::runtime().block_on(async {
        let history = acp::history(&[
            ChatItem::User("unique prior question".into()),
            ChatItem::Agent("unique prior answer".into()),
        ]);
        let (tx, mut events) = acp::channel();
        let session = Session::spawn(
            &agent(),
            Launch {
                history: Some(history.clone()),
                history_pending: true,
                ..Launch::new(std::env::temp_dir())
            },
            tx,
        )
        .await
        .unwrap();
        assert!(!session.loaded);
        assert!(
            events.try_recv().is_err(),
            "opening a fork must not run a turn"
        );
        session.prompt("edited question");
        let text = answer(&mut events).await;
        assert!(text.contains("unique prior question"));
        assert!(text.contains("unique prior answer"));
        assert!(text.contains("edited question"));
        session.prompt("next question");
        let text = answer(&mut events).await;
        assert!(!text.contains("unique prior question"));
        drop(session);

        let (tx, mut events) = acp::channel();
        let loaded = Session::spawn(
            &agent(),
            Launch {
                previous: Some("fork".into()),
                history: Some(history),
                history_pending: false,
                ..Launch::new(std::env::temp_dir())
            },
            tx,
        )
        .await
        .unwrap();
        assert!(loaded.loaded);
        loaded.prompt("after reopening");
        assert!(!answer(&mut events).await.contains("unique prior question"));
    });
}

#[test]
fn tool_history_is_context_in_order() {
    let items = vec![
        ChatItem::User("question".into()),
        ChatItem::Process {
            command: "echo example".into(),
            output: "example".into(),
        },
        ChatItem::Agent("answer".into()),
    ];
    let history = acp::history(&items);
    assert_eq!(history.len(), items.len());
    assert_eq!(history[1].role, cacp::client::HistoryRole::Tool);
    assert_eq!(
        history[1].content,
        vec![cacp::schema::ContentBlock::from("echo example\nexample")]
    );
}

#[test]
fn unsent_fork_reimports_after_loading_and_missing_agent_state_uses_saved_history() {
    acp::runtime().block_on(async {
        for (previous, pending, loaded) in [("fork", true, true), ("gone", false, false)] {
            let (tx, mut events) = acp::channel();
            let session = Session::spawn(
                &agent(),
                Launch {
                    previous: Some(previous.into()),
                    history: Some(acp::history(&[ChatItem::Agent("preserved context".into())])),
                    history_pending: pending,
                    ..Launch::new(std::env::temp_dir())
                },
                tx,
            )
            .await
            .unwrap();
            assert_eq!(session.loaded, loaded);
            session.prompt("continue");
            assert!(answer(&mut events).await.contains("preserved context"));
        }
    });
}

#[test]
fn an_empty_fork_is_saved_with_its_draft_before_any_prompt() {
    use artifact::{
        project::{Project as _, fs::Project},
        session::record::Record,
    };
    use cydonia::model::session::ChatSession;
    let path = std::env::temp_dir().join(format!(
        "cydonia-fork-{}-{}",
        std::process::id(),
        artifact::stamp::now()
    ));
    std::fs::create_dir_all(&path).unwrap();
    let record: Record = serde_json::from_value(serde_json::json!({
        "id": "source", "agent": "fake", "title": "Original", "name": null,
        "updated": 1, "items": [{"User": "editable prompt"}]
    }))
    .unwrap();
    let source = ChatSession::restore(1, path.clone(), agent(), record);
    let mut fork = source.fork_at(2, 0).unwrap();
    fork.flush();
    let records = Project::new(&path).sessions();
    assert_eq!(records.len(), 1);
    let saved = records.into_iter().next().unwrap();
    assert_ne!(saved.id, "source");
    let restored = ChatSession::restore(3, path.clone(), agent(), saved);
    assert!(restored.items.is_empty());
    assert_eq!(restored.draft, "editable prompt");
    assert!(restored.fork.unwrap().pending);
    std::fs::remove_dir_all(path).unwrap();
}