github-copilot-sdk 1.0.6

Rust SDK for programmatic control of the GitHub Copilot CLI via JSON-RPC.
Documentation
use github_copilot_sdk::Client;
use github_copilot_sdk::rpc::{
    AccountLoginRequest, AccountLogoutRequest, AgentRegistrySpawnRequest,
    SendAttachmentsToMessageParams, SessionsOpenStatus, UserSettingsSetRequest,
};
use serde_json::{Map, Value, json};

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

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

            client
                .rpc()
                .user()
                .settings()
                .reload()
                .await
                .expect("reload user settings");

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

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

                let initial = client
                    .rpc()
                    .user()
                    .settings()
                    .get()
                    .await
                    .expect("get initial user settings");
                let (key, value) = initial
                    .settings
                    .iter()
                    .find_map(|(key, setting)| {
                        setting.value.as_bool().map(|value| (key.clone(), value))
                    })
                    .expect("at least one boolean user setting");
                let toggled = !value;

                let set = client
                    .rpc()
                    .user()
                    .settings()
                    .set(UserSettingsSetRequest {
                        settings: setting_patch(&key, json!(toggled)),
                    })
                    .await
                    .expect("set user setting");
                assert!(set.shadowed_keys.is_empty());
                client
                    .rpc()
                    .user()
                    .settings()
                    .reload()
                    .await
                    .expect("reload after set");
                let after_set = client
                    .rpc()
                    .user()
                    .settings()
                    .get()
                    .await
                    .expect("get after set");
                let metadata = after_set.settings.get(&key).expect("updated setting");
                assert_eq!(metadata.value, json!(toggled));
                assert!(!metadata.is_default);

                let clear = client
                    .rpc()
                    .user()
                    .settings()
                    .set(UserSettingsSetRequest {
                        settings: setting_patch(&key, Value::Null),
                    })
                    .await
                    .expect("clear user setting");
                assert!(clear.shadowed_keys.is_empty());
                client
                    .rpc()
                    .user()
                    .settings()
                    .reload()
                    .await
                    .expect("reload after clear");
                let after_clear = client
                    .rpc()
                    .user()
                    .settings()
                    .get()
                    .await
                    .expect("get after clear");
                assert!(
                    after_clear
                        .settings
                        .get(&key)
                        .expect("cleared setting")
                        .is_default
                );

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

#[tokio::test]
async fn should_login_list_getcurrentauth_and_logout_account() {
    with_e2e_context(
        "rpc_server_misc",
        "should_login_list_getcurrentauth_and_logout_account",
        |ctx| {
            Box::pin(async move {
                ctx.set_copilot_user_by_token_with_login("rust-account-token", "rust-account-user");
                let client = Client::start(ctx.client_options().with_use_logged_in_user(false))
                    .await
                    .expect("start no-token client");

                let initial = client
                    .rpc()
                    .account()
                    .get_current_auth()
                    .await
                    .expect("get initial auth");
                assert!(initial.auth_info.is_none());

                let login = client
                    .rpc()
                    .account()
                    .login(AccountLoginRequest {
                        host: "https://github.com".to_string(),
                        login: "rust-account-user".to_string(),
                        token: "rust-account-token".to_string(),
                    })
                    .await
                    .expect("account login");
                let _stored_in_vault = login.stored_in_vault;

                let current = client
                    .rpc()
                    .account()
                    .get_current_auth()
                    .await
                    .expect("get current auth after login");
                let auth_info = current.auth_info.expect("auth info after login");
                assert_eq!(auth_info["login"], json!("rust-account-user"));
                assert_eq!(auth_info["host"], json!("https://github.com"));

                let users = client
                    .rpc()
                    .account()
                    .get_all_users()
                    .await
                    .expect("get all users");
                if let Some(user) = users
                    .iter()
                    .find(|user| user.auth_info["login"] == json!("rust-account-user"))
                {
                    user.token
                        .as_deref()
                        .filter(|token| *token == "rust-account-token")
                        .unwrap_or_else(|| {
                            panic!("expected stored account token, got {:?}", user.token)
                        });
                }

                let logout = client
                    .rpc()
                    .account()
                    .logout(AccountLogoutRequest { auth_info })
                    .await
                    .expect("account logout");
                assert!(!logout.has_more_users);
                assert!(
                    client
                        .rpc()
                        .account()
                        .get_current_auth()
                        .await
                        .expect("get auth after logout")
                        .auth_info
                        .is_none()
                );

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

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

                let err = client
                    .rpc()
                    .agent_registry()
                    .spawn(AgentRegistrySpawnRequest {
                        agent_name: None,
                        cwd: ctx.work_dir().to_string_lossy().to_string(),
                        initial_prompt: None,
                        model: None,
                        name: None,
                        permission_mode: None,
                    })
                    .await
                    .expect_err("agent registry spawn should be gated");

                let message = err.to_string();
                assert_not_unhandled(&message);
                let lower = message.to_ascii_lowercase();
                assert!(lower.contains("agentregistry.spawn"), "{message}");
                assert!(
                    lower.contains("not enabled") || lower.contains("no delegate"),
                    "{message}"
                );

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

#[tokio::test]
async fn should_shut_down_owned_runtime() {
    with_e2e_context("rpc_server_misc", "should_shut_down_owned_runtime", |ctx| {
        Box::pin(async move {
            let client = Client::start(ctx.client_options())
                .await
                .expect("start dedicated client");

            client
                .rpc()
                .user()
                .settings()
                .reload()
                .await
                .expect("runtime should start live");

            client
                .rpc()
                .runtime()
                .shutdown()
                .await
                .expect("shut down runtime");

            wait_for_condition("runtime to stop serving RPCs", || async {
                client.rpc().user().settings().reload().await.is_err()
            })
            .await;

            let _ = client.stop().await;
        })
    })
    .await;
}

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

                let result = client
                    .rpc()
                    .sessions()
                    .open()
                    .await
                    .expect("open session without context");

                assert_eq!(result.status, SessionsOpenStatus::NotFound);
                assert!(result.session_id.is_none());

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

#[tokio::test]
async fn should_reject_send_attachments_from_non_extension_connection() {
    with_e2e_context(
        "rpc_server_misc",
        "should_reject_send_attachments_from_non_extension_connection",
        |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");

                let err = session
                    .rpc()
                    .extensions()
                    .send_attachments_to_message(SendAttachmentsToMessageParams {
                        attachments: Vec::new(),
                        instance_id: None,
                    })
                    .await
                    .expect_err("normal session connection should be rejected");
                let message = err.to_string();
                assert_not_unhandled(&message);
                assert!(
                    message.to_ascii_lowercase().contains("extension"),
                    "{message}"
                );

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

fn assert_not_unhandled(message: &str) {
    assert!(
        !message.to_ascii_lowercase().contains("unhandled method"),
        "{message}"
    );
}

fn setting_patch(key: &str, value: Value) -> Value {
    let mut settings = Map::new();
    settings.insert(key.to_string(), value);
    Value::Object(settings)
}