scv-cli 0.1.0

A small, extensible terminal agent runtime with a TUI and headless server
use std::{
    io::{Read, Write},
    net::TcpListener,
    process::Stdio,
    thread,
};

use scv_protocol::{ClientMessage, PeerInfo, ServerEvent};
use tokio::{
    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
    process::Command,
    time::{Duration, timeout},
};

#[tokio::test]
async fn server_handshake_and_session_start() {
    let workspace = tempfile::tempdir().unwrap();
    let config_home = tempfile::tempdir().unwrap();
    let mut child = Command::new(env!("CARGO_BIN_EXE_scv-server"))
        .arg("--stdio")
        .env("OPENAI_API_KEY", "test-only")
        .env("XDG_CONFIG_HOME", config_home.path())
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .unwrap();
    let mut input = child.stdin.take().unwrap();
    let output = child.stdout.take().unwrap();
    input
        .write_all(
            format!(
                concat!(
                    "{{\"type\":\"initialize\",\"request_id\":\"1\",\"protocol_version\":1,\"client\":{{\"name\":\"test\",\"version\":\"0\"}}}}\n",
                    "{{\"type\":\"session.start\",\"request_id\":\"2\",\"cwd\":{:?}}}\n"
                ),
                workspace.path().display().to_string()
            )
            .as_bytes(),
        )
        .await
        .unwrap();
    input.shutdown().await.unwrap();
    drop(input);
    let mut lines = BufReader::new(output).lines();
    let first = timeout(Duration::from_secs(3), lines.next_line())
        .await
        .unwrap()
        .unwrap()
        .unwrap();
    let second = timeout(Duration::from_secs(3), lines.next_line())
        .await
        .unwrap()
        .unwrap()
        .unwrap();
    assert!(matches!(
        serde_json::from_str::<ServerEvent>(&first).unwrap(),
        ServerEvent::Initialized { .. }
    ));
    assert!(matches!(
        serde_json::from_str::<ServerEvent>(&second).unwrap(),
        ServerEvent::SessionStarted { .. }
    ));
    assert!(
        timeout(Duration::from_secs(3), child.wait())
            .await
            .unwrap()
            .unwrap()
            .success()
    );
}

#[tokio::test]
async fn server_completes_a_streamed_turn_with_a_fake_provider() {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let address = listener.local_addr().unwrap();
    let provider = thread::spawn(move || {
        let (mut stream, _) = listener.accept().unwrap();
        let mut request = [0u8; 64 * 1024];
        let read = stream.read(&mut request).unwrap();
        let request = String::from_utf8_lossy(&request[..read]);
        assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1"));
        let body = concat!(
            "data: {\"choices\":[{\"delta\":{\"content\":\"hello \"}}]}\n\n",
            "data: {\"choices\":[{\"delta\":{\"content\":\"from fake\"}}],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":3}}\n\n",
            "data: [DONE]\n\n"
        );
        let response = format!(
            "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
            body.len()
        );
        stream.write_all(response.as_bytes()).unwrap();
    });

    let workspace = tempfile::tempdir().unwrap();
    let config_home = tempfile::tempdir().unwrap();
    let mut child = Command::new(env!("CARGO_BIN_EXE_scv-server"))
        .args([
            "--stdio",
            "--model",
            "fake-model",
            "--base-url",
            &format!("http://{address}/v1"),
        ])
        .env("OPENAI_API_KEY", "test-only")
        .env("XDG_CONFIG_HOME", config_home.path())
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .unwrap();
    let mut input = child.stdin.take().unwrap();
    let output = child.stdout.take().unwrap();
    let mut lines = BufReader::new(output).lines();

    send(
        &mut input,
        &ClientMessage::Initialize {
            request_id: "init".into(),
            protocol_version: 1,
            client: PeerInfo {
                name: "integration-test".into(),
                version: "0".into(),
            },
        },
    )
    .await;
    send(
        &mut input,
        &ClientMessage::SessionStart {
            request_id: "session".into(),
            cwd: workspace.path().display().to_string(),
        },
    )
    .await;

    let initialized = next_event(&mut lines).await;
    assert!(matches!(initialized, ServerEvent::Initialized { .. }));
    let session_id = match next_event(&mut lines).await {
        ServerEvent::SessionStarted { session_id, .. } => session_id,
        event => panic!("expected session.started, received {event:?}"),
    };
    send(
        &mut input,
        &ClientMessage::TurnStart {
            request_id: "turn".into(),
            session_id,
            prompt: "say hello".into(),
        },
    )
    .await;

    let mut streamed = String::new();
    let usage = loop {
        match next_event(&mut lines).await {
            ServerEvent::AssistantDelta { content, .. } => streamed.push_str(&content),
            ServerEvent::TurnCompleted { usage, .. } => break usage,
            ServerEvent::TurnFailed { code, message, .. } => {
                panic!("turn failed with {code}: {message}")
            }
            _ => {}
        }
    };
    assert_eq!(streamed, "hello from fake");
    assert_eq!(usage.input_tokens, Some(7));
    assert_eq!(usage.output_tokens, Some(3));

    input.shutdown().await.unwrap();
    drop(input);
    provider.join().unwrap();
    assert!(
        timeout(Duration::from_secs(3), child.wait())
            .await
            .unwrap()
            .unwrap()
            .success()
    );
}

async fn send(input: &mut tokio::process::ChildStdin, message: &ClientMessage) {
    input
        .write_all(format!("{}\n", serde_json::to_string(message).unwrap()).as_bytes())
        .await
        .unwrap();
    input.flush().await.unwrap();
}

async fn next_event(
    lines: &mut tokio::io::Lines<BufReader<tokio::process::ChildStdout>>,
) -> ServerEvent {
    let line = timeout(Duration::from_secs(3), lines.next_line())
        .await
        .unwrap()
        .unwrap()
        .expect("server closed stdout before the expected event");
    serde_json::from_str(&line).unwrap()
}