github-copilot-sdk 1.0.0

Rust SDK for programmatic control of the GitHub Copilot CLI via JSON-RPC.
use std::net::TcpListener;
use std::sync::Arc;

use async_trait::async_trait;
use github_copilot_sdk::handler::{
    ApproveAllHandler, ElicitationHandler, PermissionHandler, PermissionResult,
};
use github_copilot_sdk::session_events::{
    CapabilitiesChangedData, CommandsChangedData, SessionEventType,
};
use github_copilot_sdk::{
    Client, CommandContext, CommandDefinition, CommandHandler, ElicitationRequest,
    ElicitationResult, RequestId, ResumeSessionConfig, SessionId, Transport,
};

use super::support::{DEFAULT_TEST_TOKEN, E2eContext, wait_for_event, with_e2e_context};

const SHARED_TOKEN: &str = "rust-multi-client-cmd-shared-token";

#[tokio::test]
async fn client_receives_commands_changed_when_another_client_joins_with_commands() {
    with_e2e_context(
        "multi_client_commands_elicitation",
        "client_receives_commands_changed_when_another_client_joins_with_commands",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let port = free_tcp_port();
                let server = start_tcp_server(ctx, port).await;
                let session1 = server
                    .create_session(ctx.approve_all_session_config())
                    .await
                    .expect("create session");
                let client2 = start_external_client(ctx, port).await;

                let commands_changed =
                    wait_for_event(session1.subscribe(), "commands changed", |event| {
                        if event.parsed_type() != SessionEventType::CommandsChanged {
                            return false;
                        }
                        let data = event
                            .typed_data::<CommandsChangedData>()
                            .expect("commands changed data");
                        data.commands.iter().any(|command| {
                            command.name == "deploy"
                                && command.description.as_deref() == Some("Deploy the app")
                        })
                    });
                let session2 = client2
                    .resume_session(resume_config(session1.id().clone()).with_commands(vec![
                            CommandDefinition::new("deploy", Arc::new(NoopCommandHandler))
                                .with_description("Deploy the app"),
                        ]))
                    .await
                    .expect("resume session from second client");
                commands_changed.await;

                session2
                    .disconnect()
                    .await
                    .expect("disconnect second session");
                client2.force_stop();
                session1
                    .disconnect()
                    .await
                    .expect("disconnect first session");
                server.stop().await.expect("stop server client");
            })
        },
    )
    .await;
}

#[tokio::test]
async fn capabilities_changed_fires_when_second_client_joins_with_elicitation_handler() {
    with_e2e_context(
        "multi_client_commands_elicitation",
        "capabilities_changed_fires_when_second_client_joins_with_elicitation_handler",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let port = free_tcp_port();
                let server = start_tcp_server(ctx, port).await;
                let session1 = server
                    .create_session(ctx.approve_all_session_config())
                    .await
                    .expect("create session");
                assert_ne!(
                    session1.capabilities().ui.and_then(|ui| ui.elicitation),
                    Some(true)
                );
                let client2 = start_external_client(ctx, port).await;

                let capabilities_changed =
                    wait_for_event(session1.subscribe(), "elicitation enabled", |event| {
                        if event.parsed_type() != SessionEventType::CapabilitiesChanged {
                            return false;
                        }
                        event
                            .typed_data::<CapabilitiesChangedData>()
                            .and_then(|data| data.ui.and_then(|ui| ui.elicitation))
                            == Some(true)
                    });
                let session2 = client2
                    .resume_session(
                        resume_config(session1.id().clone())
                            .with_permission_handler(Arc::new(ElicitationApproveHandler))
                            .with_elicitation_handler(Arc::new(ElicitationApproveHandler)),
                    )
                    .await
                    .expect("resume session with elicitation handler");
                capabilities_changed.await;
                assert_eq!(
                    session1.capabilities().ui.and_then(|ui| ui.elicitation),
                    Some(true)
                );

                session2
                    .disconnect()
                    .await
                    .expect("disconnect second session");
                client2.force_stop();
                session1
                    .disconnect()
                    .await
                    .expect("disconnect first session");
                server.stop().await.expect("stop server client");
            })
        },
    )
    .await;
}

#[tokio::test]
async fn capabilities_changed_fires_when_elicitation_provider_disconnects() {
    with_e2e_context(
        "multi_client_commands_elicitation",
        "capabilities_changed_fires_when_elicitation_provider_disconnects",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let port = free_tcp_port();
                let server = start_tcp_server(ctx, port).await;
                let session1 = server
                    .create_session(ctx.approve_all_session_config())
                    .await
                    .expect("create session");
                let client2 = start_external_client(ctx, port).await;
                let enabled =
                    wait_for_event(session1.subscribe(), "elicitation enabled", |event| {
                        if event.parsed_type() != SessionEventType::CapabilitiesChanged {
                            return false;
                        }
                        event
                            .typed_data::<CapabilitiesChangedData>()
                            .and_then(|data| data.ui.and_then(|ui| ui.elicitation))
                            == Some(true)
                    });
                let _session2 = client2
                    .resume_session(
                        resume_config(session1.id().clone())
                            .with_permission_handler(Arc::new(ElicitationApproveHandler))
                            .with_elicitation_handler(Arc::new(ElicitationApproveHandler)),
                    )
                    .await
                    .expect("resume session with elicitation handler");
                enabled.await;

                let disabled =
                    wait_for_event(session1.subscribe(), "elicitation disabled", |event| {
                        if event.parsed_type() != SessionEventType::CapabilitiesChanged {
                            return false;
                        }
                        event
                            .typed_data::<CapabilitiesChangedData>()
                            .and_then(|data| data.ui.and_then(|ui| ui.elicitation))
                            == Some(false)
                    });
                client2.force_stop();
                disabled.await;
                assert_ne!(
                    session1.capabilities().ui.and_then(|ui| ui.elicitation),
                    Some(true)
                );

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

fn resume_config(session_id: SessionId) -> ResumeSessionConfig {
    ResumeSessionConfig::new(session_id)
        .with_github_token(DEFAULT_TEST_TOKEN)
        .with_permission_handler(Arc::new(ApproveAllHandler))
        .with_suppress_resume_event(true)
}

async fn start_tcp_server(ctx: &E2eContext, port: u16) -> Client {
    Client::start(ctx.client_options_with_transport(Transport::Tcp {
        port,
        connection_token: Some(SHARED_TOKEN.to_string()),
    }))
    .await
    .expect("start TCP server client")
}

async fn start_external_client(ctx: &E2eContext, port: u16) -> Client {
    Client::start(ctx.client_options_with_transport(Transport::External {
        host: "127.0.0.1".to_string(),
        port,
        connection_token: Some(SHARED_TOKEN.to_string()),
    }))
    .await
    .expect("start external client")
}

fn free_tcp_port() -> u16 {
    let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind free TCP port");
    listener.local_addr().expect("local addr").port()
}

struct NoopCommandHandler;

#[async_trait]
impl CommandHandler for NoopCommandHandler {
    async fn on_command(&self, _ctx: CommandContext) -> Result<(), github_copilot_sdk::Error> {
        Ok(())
    }
}

struct ElicitationApproveHandler;

#[async_trait]
impl PermissionHandler for ElicitationApproveHandler {
    async fn handle(
        &self,
        _session_id: SessionId,
        _request_id: RequestId,
        _data: github_copilot_sdk::PermissionRequestData,
    ) -> PermissionResult {
        PermissionResult::approve_once()
    }
}

#[async_trait]
impl ElicitationHandler for ElicitationApproveHandler {
    async fn handle(
        &self,
        _session_id: SessionId,
        _request_id: RequestId,
        _request: ElicitationRequest,
    ) -> ElicitationResult {
        ElicitationResult {
            action: "accept".to_string(),
            content: Some(serde_json::json!({})),
        }
    }
}