github-copilot-sdk 1.0.0-beta.4

Rust SDK for programmatic control of the GitHub Copilot CLI via JSON-RPC. Technical preview, pre-1.0.
Documentation
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

use async_trait::async_trait;
use github_copilot_sdk::{
    CliProgram, Client, ClientOptions, ConnectionState, Error, ListModelsHandler, Model,
    ModelCapabilities, Transport,
};

use super::support::with_e2e_context;

#[tokio::test]
async fn should_start_ping_and_stop_stdio_client() {
    with_e2e_context("client", "should_start_ping_and_stop_stdio_client", |ctx| {
        Box::pin(async move {
            let client = ctx.start_client().await;
            assert_eq!(client.state(), ConnectionState::Connected);

            let response = client.ping(Some("hello from rust")).await.expect("ping");
            assert_eq!(response.message, "pong: hello from rust");
            assert!(response.timestamp > 0);

            client.stop().await.expect("stop client");
            assert_eq!(client.state(), ConnectionState::Disconnected);
        })
    })
    .await;
}

#[tokio::test]
async fn should_start_ping_and_stop_tcp_client() {
    with_e2e_context("client", "should_start_ping_and_stop_tcp_client", |ctx| {
        Box::pin(async move {
            let client = Client::start(
                ctx.client_options_with_transport(Transport::Tcp { port: 0 })
                    .with_tcp_connection_token("tcp-e2e-token"),
            )
            .await
            .expect("start TCP client");
            assert_eq!(client.state(), ConnectionState::Connected);

            let response = client.ping(Some("tcp hello")).await.expect("ping");
            assert_eq!(response.message, "pong: tcp hello");

            client.stop().await.expect("stop client");
            assert_eq!(client.state(), ConnectionState::Disconnected);
        })
    })
    .await;
}

#[tokio::test]
async fn should_get_status() {
    with_e2e_context("client", "should_get_status", |ctx| {
        Box::pin(async move {
            let client = ctx.start_client().await;
            let status = client.get_status().await.expect("status");

            assert!(!status.version.is_empty());
            assert!(status.protocol_version > 0);

            client.stop().await.expect("stop client");
        })
    })
    .await;
}

#[tokio::test]
async fn should_get_authenticated_status() {
    with_e2e_context("client", "should_get_authenticated_status", |ctx| {
        Box::pin(async move {
            ctx.set_default_copilot_user();
            let client = Client::start(
                ctx.client_options()
                    .with_github_token(super::support::DEFAULT_TEST_TOKEN),
            )
            .await
            .expect("start client");
            let status = client.get_auth_status().await.expect("auth status");

            assert!(status.is_authenticated);

            client.stop().await.expect("stop client");
        })
    })
    .await;
}

#[tokio::test]
async fn should_list_models_when_authenticated() {
    with_e2e_context("client", "should_list_models_when_authenticated", |ctx| {
        Box::pin(async move {
            ctx.set_default_copilot_user();
            let client = Client::start(
                ctx.client_options()
                    .with_github_token(super::support::DEFAULT_TEST_TOKEN),
            )
            .await
            .expect("start client");
            let models = client.list_models().await.expect("list models");

            assert!(
                models.iter().any(|model| model.id == "claude-sonnet-4.5"),
                "expected default replay model in {models:?}"
            );

            client.stop().await.expect("stop client");
        })
    })
    .await;
}

#[tokio::test]
async fn should_stop_client_with_active_session() {
    with_e2e_context("client", "should_stop_client_with_active_session", |ctx| {
        Box::pin(async move {
            ctx.set_default_copilot_user();
            let client = ctx.start_client().await;
            let _session = client
                .create_session(ctx.approve_all_session_config())
                .await
                .expect("create session");

            client.stop().await.expect("stop client");
            assert_eq!(client.state(), ConnectionState::Disconnected);
        })
    })
    .await;
}

#[tokio::test]
async fn should_force_stop_client() {
    with_e2e_context("client", "should_force_stop_client", |ctx| {
        Box::pin(async move {
            let client = ctx.start_client().await;
            assert_eq!(client.state(), ConnectionState::Connected);

            client.force_stop();
            assert_eq!(client.state(), ConnectionState::Disconnected);
        })
    })
    .await;
}

#[tokio::test]
async fn should_report_error_with_stderr_when_cli_fails_to_start() {
    let err = Client::start(
        ClientOptions::new()
            .with_program(CliProgram::Path(std::path::PathBuf::from(
                "definitely-not-copilot-cli-for-rust-e2e",
            )))
            .with_use_logged_in_user(false),
    )
    .await
    .expect_err("start should fail for missing CLI");

    let message = err.to_string();
    assert!(
        !message.trim().is_empty(),
        "missing CLI start failure should include an error message"
    );
}

#[tokio::test]
async fn listmodels_withcustomhandler_callshandler() {
    with_e2e_context(
        "client",
        "listmodels_withcustomhandler_callshandler",
        |ctx| {
            Box::pin(async move {
                let handler = CountingModelsHandler::default();
                let calls = Arc::clone(&handler.calls);
                let client = Client::start(
                    ctx.client_options()
                        .with_list_models_handler(handler)
                        .with_use_logged_in_user(false),
                )
                .await
                .expect("start client");

                let models = client.list_models().await.expect("list models");

                assert_eq!(calls.load(Ordering::SeqCst), 1);
                assert_eq!(models.len(), 1);
                assert_eq!(models[0].id, "custom-handler-model");

                client.stop().await.expect("stop client");
            })
        },
    )
    .await;
}

#[tokio::test]
async fn should_not_throw_when_disposing_session_after_stopping_client() {
    with_e2e_context(
        "client",
        "should_not_throw_when_disposing_session_after_stopping_client",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let client = ctx.start_client().await;
                let session = client
                    .create_session(ctx.approve_all_session_config())
                    .await
                    .expect("create session");

                client.stop().await.expect("stop client");
                drop(session);
            })
        },
    )
    .await;
}

#[tokio::test]
async fn listmodels_withcustomhandler_cachesresults() {
    with_e2e_context(
        "client",
        "listmodels_withcustomhandler_cachesresults",
        |ctx| {
            Box::pin(async move {
                let handler = CountingModelsHandler::default();
                let calls = Arc::clone(&handler.calls);
                let client = Client::start(
                    ctx.client_options()
                        .with_list_models_handler(handler)
                        .with_use_logged_in_user(false),
                )
                .await
                .expect("start client");

                let first = client.list_models().await.expect("list models first");
                let second = client.list_models().await.expect("list models second");

                assert_eq!(calls.load(Ordering::SeqCst), 1);
                assert_eq!(first[0].id, second[0].id);

                client.stop().await.expect("stop client");
            })
        },
    )
    .await;
}

#[tokio::test]
async fn listmodels_withcustomhandler_workswithoutstart() {
    let handler = CountingModelsHandler::default();
    let models = handler.list_models().await.expect("list models");

    assert_eq!(handler.calls.load(Ordering::SeqCst), 1);
    assert_eq!(models[0].id, "custom-handler-model");
}

#[derive(Default)]
struct CountingModelsHandler {
    calls: Arc<AtomicUsize>,
}

#[async_trait]
impl ListModelsHandler for CountingModelsHandler {
    async fn list_models(&self) -> Result<Vec<Model>, Error> {
        self.calls.fetch_add(1, Ordering::SeqCst);
        Ok(vec![Model {
            billing: None,
            capabilities: ModelCapabilities {
                limits: None,
                supports: None,
            },
            default_reasoning_effort: None,
            id: "custom-handler-model".to_string(),
            model_picker_category: None,
            model_picker_price_category: None,
            name: "Custom Handler Model".to_string(),
            policy: None,
            supported_reasoning_efforts: Vec::new(),
        }])
    }
}