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 github_copilot_sdk::CustomAgentConfig;
use github_copilot_sdk::generated::api_types::{AgentInfo, AgentSelectRequest};
use github_copilot_sdk::generated::session_events::SessionEventType;
use serde_json::json;

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

#[tokio::test]
async fn should_list_available_custom_agents() {
    with_e2e_context("rpc_agents", "should_list_available_custom_agents", |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()
                        .with_custom_agents(create_custom_agents()),
                )
                .await
                .expect("create session");

            let result = session.rpc().agent().list().await.expect("agent list");
            assert_agent(&result.agents, "test-agent", "Test Agent", "A test agent");
            assert_agent(
                &result.agents,
                "another-agent",
                "Another Agent",
                "Another test agent",
            );

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

#[tokio::test]
async fn should_return_null_when_no_agent_is_selected() {
    with_e2e_context(
        "rpc_agents",
        "should_return_null_when_no_agent_is_selected",
        |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()
                            .with_custom_agents([create_custom_agents().remove(0)]),
                    )
                    .await
                    .expect("create session");

                let value = client
                    .call(
                        "session.agent.getCurrent",
                        Some(json!({ "sessionId": session.id() })),
                    )
                    .await
                    .expect("get current agent");
                assert!(value.get("agent").is_some_and(serde_json::Value::is_null));

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

#[tokio::test]
async fn should_select_and_get_current_agent() {
    with_e2e_context("rpc_agents", "should_select_and_get_current_agent", |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()
                        .with_custom_agents([create_custom_agents().remove(0)]),
                )
                .await
                .expect("create session");

            let selected = session
                .rpc()
                .agent()
                .select(AgentSelectRequest {
                    name: "test-agent".to_string(),
                })
                .await
                .expect("select agent");
            assert_eq!(selected.agent.name, "test-agent");
            assert_eq!(selected.agent.display_name, "Test Agent");

            let current = session
                .rpc()
                .agent()
                .get_current()
                .await
                .expect("get selected agent");
            assert_eq!(current.agent.name, "test-agent");

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

#[tokio::test]
async fn should_emit_subagent_selected_and_deselected_events() {
    with_e2e_context(
        "rpc_agents",
        "should_emit_subagent_selected_and_deselected_events",
        |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()
                            .with_custom_agents([create_custom_agents().remove(0)]),
                    )
                    .await
                    .expect("create session");

                let selected_event =
                    wait_for_event(session.subscribe(), "subagent selected", |event| {
                        event.parsed_type() == SessionEventType::SubagentSelected
                    });
                session
                    .rpc()
                    .agent()
                    .select(AgentSelectRequest {
                        name: "test-agent".to_string(),
                    })
                    .await
                    .expect("select agent");
                let selected = selected_event.await;
                assert_eq!(
                    selected
                        .data
                        .get("agentName")
                        .and_then(serde_json::Value::as_str),
                    Some("test-agent")
                );
                assert_eq!(
                    selected
                        .data
                        .get("agentDisplayName")
                        .and_then(serde_json::Value::as_str),
                    Some("Test Agent")
                );

                let deselected_event =
                    wait_for_event(session.subscribe(), "subagent deselected", |event| {
                        event.parsed_type() == SessionEventType::SubagentDeselected
                    });
                session
                    .rpc()
                    .agent()
                    .deselect()
                    .await
                    .expect("deselect agent");
                deselected_event.await;

                let value = client
                    .call(
                        "session.agent.getCurrent",
                        Some(json!({ "sessionId": session.id() })),
                    )
                    .await
                    .expect("get current agent after deselect");
                assert!(value.get("agent").is_some_and(serde_json::Value::is_null));

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

#[tokio::test]
async fn should_deselect_current_agent() {
    with_e2e_context("rpc_agents", "should_deselect_current_agent", |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()
                        .with_custom_agents([create_custom_agents().remove(0)]),
                )
                .await
                .expect("create session");

            session
                .rpc()
                .agent()
                .select(AgentSelectRequest {
                    name: "test-agent".to_string(),
                })
                .await
                .expect("select agent");
            session
                .rpc()
                .agent()
                .deselect()
                .await
                .expect("deselect agent");
            let value = client
                .call(
                    "session.agent.getCurrent",
                    Some(json!({ "sessionId": session.id() })),
                )
                .await
                .expect("get current agent");
            assert!(value.get("agent").is_some_and(serde_json::Value::is_null));

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

#[tokio::test]
async fn should_return_empty_list_when_no_custom_agents_configured() {
    with_e2e_context(
        "rpc_agents",
        "should_return_empty_list_when_no_custom_agents_configured",
        |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 result = session.rpc().agent().list().await.expect("agent list");
                assert!(result.agents.is_empty());

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

#[tokio::test]
async fn should_call_agent_reload() {
    with_e2e_context("rpc_agents", "should_call_agent_reload", |ctx| {
        Box::pin(async move {
            ctx.set_default_copilot_user();
            let reload_agent =
                CustomAgentConfig::new("reload-test-agent-rust", "You are a reload test agent.")
                    .with_display_name("Reload Test Agent")
                    .with_description("Used by the agent reload RPC test.");
            let client = ctx.start_client().await;
            let session = client
                .create_session(
                    ctx.approve_all_session_config()
                        .with_custom_agents([reload_agent.clone()]),
                )
                .await
                .expect("create session");

            assert_agent(
                &session
                    .rpc()
                    .agent()
                    .list()
                    .await
                    .expect("list before")
                    .agents,
                "reload-test-agent-rust",
                "Reload Test Agent",
                "Used by the agent reload RPC test.",
            );
            let reloaded = session.rpc().agent().reload().await.expect("reload agents");
            let current = session.rpc().agent().list().await.expect("list after");
            assert_eq!(
                agent_names(&reloaded.agents),
                agent_names(&current.agents),
                "reload result should match current list"
            );

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

fn create_custom_agents() -> Vec<CustomAgentConfig> {
    vec![
        CustomAgentConfig::new("test-agent", "You are a test agent.")
            .with_display_name("Test Agent")
            .with_description("A test agent"),
        CustomAgentConfig::new("another-agent", "You are another agent.")
            .with_display_name("Another Agent")
            .with_description("Another test agent"),
    ]
}

fn assert_agent(agents: &[AgentInfo], name: &str, display_name: &str, description: &str) {
    let agent = agents
        .iter()
        .find(|agent| agent.name == name)
        .unwrap_or_else(|| panic!("missing agent {name}; actual agents: {agents:?}"));
    assert_eq!(agent.display_name, display_name);
    assert_eq!(agent.description, description);
}

fn agent_names(agents: &[AgentInfo]) -> Vec<&str> {
    let mut names: Vec<_> = agents.iter().map(|agent| agent.name.as_str()).collect();
    names.sort_unstable();
    names
}