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::net::{Ipv4Addr, SocketAddrV4, TcpListener};

use github_copilot_sdk::{
    Client, ClientOptions, Error, LogLevel, MessageOptions, OtelExporterType, SessionConfig,
    TelemetryConfig, Transport,
};
use serde_json::json;

use super::support::{assistant_message_content, with_e2e_context};

#[tokio::test]
async fn should_use_client_cwd_for_default_workingdirectory() {
    with_e2e_context(
        "client_options",
        "should_use_client_cwd_for_default_workingdirectory",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let client_cwd = ctx.work_dir().join("client-cwd");
                std::fs::create_dir_all(&client_cwd).expect("create client cwd");
                std::fs::write(client_cwd.join("marker.txt"), "I am in the client cwd")
                    .expect("write marker");

                let client = Client::start(ctx.client_options().with_cwd(&client_cwd))
                    .await
                    .expect("start client");
                let session = client
                    .create_session(ctx.approve_all_session_config())
                    .await
                    .expect("create session");

                let answer = session
                    .send_and_wait("Read the file marker.txt and tell me what it says")
                    .await
                    .expect("send")
                    .expect("assistant message");
                assert!(assistant_message_content(&answer).contains("client cwd"));

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

#[tokio::test]
async fn should_listen_on_configured_tcp_port() {
    with_e2e_context(
        "client_options",
        "should_listen_on_configured_tcp_port",
        |ctx| {
            Box::pin(async move {
                let port = get_available_tcp_port();
                let client = Client::start(
                    ctx.client_options_with_transport(Transport::Tcp { port })
                        .with_tcp_connection_token("configured-port-token"),
                )
                .await
                .expect("start TCP client");

                let response = client.ping(Some("fixed-port")).await.expect("ping");

                assert_eq!(response.message, "pong: fixed-port");
                client.stop().await.expect("stop client");
            })
        },
    )
    .await;
}

#[tokio::test]
async fn should_forward_enablesessiontelemetry_in_wire_request() {
    let value = serde_json::to_value(
        SessionConfig::default()
            .with_enable_session_telemetry(false)
            .with_handler(std::sync::Arc::new(
                github_copilot_sdk::handler::ApproveAllHandler,
            )),
    )
    .expect("serialize session config");

    assert_eq!(value["enableSessionTelemetry"], json!(false));
}

#[tokio::test]
async fn should_omit_enablesessiontelemetry_when_not_set() {
    let value = serde_json::to_value(SessionConfig::default().with_handler(std::sync::Arc::new(
        github_copilot_sdk::handler::ApproveAllHandler,
    )))
    .expect("serialize session config");

    assert!(value.get("enableSessionTelemetry").is_none());
}

#[tokio::test]
async fn should_accept_githubtoken_option() {
    let options = ClientOptions::new().with_github_token("gho_test_token");

    assert_eq!(options.github_token.as_deref(), Some("gho_test_token"));
}

#[tokio::test]
async fn should_default_useloggedinuser_to_null() {
    let options = ClientOptions::new();

    assert!(options.use_logged_in_user.is_none());
}

#[tokio::test]
async fn should_allow_explicit_useloggedinuser_false() {
    let options = ClientOptions::new().with_use_logged_in_user(false);

    assert_eq!(options.use_logged_in_user, Some(false));
}

#[tokio::test]
async fn should_allow_explicit_useloggedinuser_true_with_githubtoken() {
    let options = ClientOptions::new()
        .with_github_token("gho_test_token")
        .with_use_logged_in_user(true);

    assert_eq!(options.github_token.as_deref(), Some("gho_test_token"));
    assert_eq!(options.use_logged_in_user, Some(true));
}

#[tokio::test]
async fn should_default_sessionidletimeoutseconds_to_null() {
    let options = ClientOptions::new();

    assert!(options.session_idle_timeout_seconds.is_none());
}

#[tokio::test]
async fn should_accept_sessionidletimeoutseconds_option() {
    let options = ClientOptions::new().with_session_idle_timeout_seconds(600);

    assert_eq!(options.session_idle_timeout_seconds, Some(600));
}

#[tokio::test]
async fn should_propagate_process_options_to_spawned_cli() {
    let telemetry = TelemetryConfig::new()
        .with_otlp_endpoint("http://127.0.0.1:4318")
        .with_file_path("telemetry.jsonl")
        .with_exporter_type(OtelExporterType::File)
        .with_source_name("rust-sdk-e2e")
        .with_capture_content(true);
    let options = ClientOptions::new()
        .with_github_token("process-option-token")
        .with_log_level(LogLevel::Debug)
        .with_session_idle_timeout_seconds(17)
        .with_telemetry(telemetry)
        .with_use_logged_in_user(false);

    assert_eq!(
        options.github_token.as_deref(),
        Some("process-option-token")
    );
    assert_eq!(options.log_level, Some(LogLevel::Debug));
    assert_eq!(options.session_idle_timeout_seconds, Some(17));
    assert_eq!(options.use_logged_in_user, Some(false));
    let telemetry = options.telemetry.as_ref().expect("telemetry");
    assert_eq!(
        telemetry.otlp_endpoint.as_deref(),
        Some("http://127.0.0.1:4318")
    );
    assert_eq!(telemetry.exporter_type, Some(OtelExporterType::File));
    assert_eq!(telemetry.source_name.as_deref(), Some("rust-sdk-e2e"));
    assert_eq!(telemetry.capture_content, Some(true));
}

#[tokio::test]
async fn should_propagate_activity_tracecontext_to_session_create_and_send() {
    let create = serde_json::to_value(
        SessionConfig::default()
            .with_handler(std::sync::Arc::new(
                github_copilot_sdk::handler::ApproveAllHandler,
            ))
            .with_github_token("token"),
    )
    .expect("serialize create config");
    let send = MessageOptions::new("Trace this message.")
        .with_traceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
        .with_tracestate("vendor=create-send");

    assert!(create.get("traceparent").is_none());
    assert_eq!(
        send.traceparent.as_deref(),
        Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
    );
    assert_eq!(send.tracestate.as_deref(), Some("vendor=create-send"));
}

#[tokio::test]
async fn auto_start_false_requires_explicit_start() {
    let options = ClientOptions::new();

    assert!(matches!(
        &options.program,
        github_copilot_sdk::CliProgram::Resolve
    ));
    assert!(options.copilot_home.is_none());
}

#[tokio::test]
async fn force_stop_does_not_rethrow_when_tcp_cli_drops_during_startup() {
    let options = ClientOptions::new().with_transport(Transport::Tcp { port: 0 });

    assert!(matches!(options.transport, Transport::Tcp { port: 0 }));
}

#[tokio::test]
async fn startasync_cleans_up_tcp_cli_process_when_connect_fails() {
    let options = ClientOptions::new().with_transport(Transport::External {
        host: "127.0.0.1".to_string(),
        port: get_available_tcp_port(),
    });

    assert!(matches!(options.transport, Transport::External { .. }));
}

#[tokio::test]
async fn should_propagate_activity_tracecontext_to_session_resume() {
    let message = MessageOptions::new("resume trace")
        .with_traceparent("00-11111111111111111111111111111111-2222222222222222-01")
        .with_tracestate("vendor=resume");

    assert_eq!(
        message.traceparent.as_deref(),
        Some("00-11111111111111111111111111111111-2222222222222222-01")
    );
    assert_eq!(message.tracestate.as_deref(), Some("vendor=resume"));
}

#[tokio::test]
async fn should_throw_when_githubtoken_used_with_cliurl() {
    let options = ClientOptions::new()
        .with_transport(Transport::External {
            host: "localhost".to_string(),
            port: 12345,
        })
        .with_github_token("token");

    let err = Client::start(options).await.unwrap_err();
    assert!(
        matches!(err, Error::InvalidConfig(_)),
        "expected InvalidConfig, got {err:?}"
    );
    let Error::InvalidConfig(msg) = err else {
        unreachable!()
    };
    assert!(
        msg.contains("github_token"),
        "error message should mention github_token, got: {msg}"
    );
}

#[tokio::test]
async fn should_throw_when_useloggedinuser_used_with_cliurl() {
    let options = ClientOptions::new()
        .with_transport(Transport::External {
            host: "localhost".to_string(),
            port: 12345,
        })
        .with_use_logged_in_user(true);

    let err = Client::start(options).await.unwrap_err();
    assert!(
        matches!(err, Error::InvalidConfig(_)),
        "expected InvalidConfig, got {err:?}"
    );
    let Error::InvalidConfig(msg) = err else {
        unreachable!()
    };
    assert!(
        msg.contains("use_logged_in_user"),
        "error message should mention use_logged_in_user, got: {msg}"
    );
}

fn get_available_tcp_port() -> u16 {
    let listener =
        TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).expect("bind ephemeral port");
    listener.local_addr().expect("local addr").port()
}