github-copilot-sdk 1.0.1

Rust SDK for programmatic control of the GitHub Copilot CLI via JSON-RPC.
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;

use github_copilot_sdk::RequestId;
use github_copilot_sdk::rpc::{ShellCancelUserRequestedRequest, ShellExecuteUserRequestedRequest};

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

#[tokio::test]
async fn should_execute_user_requested_shell_command() {
    with_e2e_context(
        "rpc_shell_user_requested",
        "should_execute_user_requested_shell_command",
        |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 marker = format!("copilotusershell{}", uuid::Uuid::new_v4().simple());
                let request_id = RequestId::new(format!("req-{}", uuid::Uuid::new_v4().simple()));

                let result = session
                    .rpc()
                    .shell()
                    .execute_user_requested(ShellExecuteUserRequestedRequest {
                        request_id,
                        command: format!("echo {marker}"),
                    })
                    .await
                    .expect("execute user-requested shell command");

                assert!(
                    result.success,
                    "expected shell command to succeed: {:?}",
                    result.error
                );
                assert_eq!(result.exit_code, Some(0));
                assert!(result.output.contains(&marker));
                assert!(!result.tool_call_id.trim().is_empty());

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

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

                let missing = session
                    .rpc()
                    .shell()
                    .cancel_user_requested(ShellCancelUserRequestedRequest {
                        request_id: RequestId::new(format!(
                            "missing-{}",
                            uuid::Uuid::new_v4().simple()
                        )),
                    })
                    .await
                    .expect("cancel missing request");
                assert!(!missing.cancelled);

                let request_id = RequestId::new(format!("req-{}", uuid::Uuid::new_v4().simple()));
                let marker_dir = tempfile::Builder::new()
                    .prefix("shell-cancel-")
                    .tempdir()
                    .expect("create shell cancel marker directory");
                let marker_path = marker_dir.path().join("marker.txt");
                let command = create_marker_then_sleep_command(&marker_path, 60);
                let execute_session = Arc::clone(&session);
                let execute_request_id = request_id.clone();
                let mut execute_task = tokio::spawn(async move {
                    execute_session
                        .rpc()
                        .shell()
                        .execute_user_requested(ShellExecuteUserRequestedRequest {
                            request_id: execute_request_id,
                            command,
                        })
                        .await
                });

                wait_for_file_text(&marker_path, "running").await;
                wait_for_condition("user-requested shell command cancellation", || {
                    let session = Arc::clone(&session);
                    let request_id = request_id.clone();
                    async move {
                        session
                            .rpc()
                            .shell()
                            .cancel_user_requested(ShellCancelUserRequestedRequest { request_id })
                            .await
                            .expect("cancel running request")
                            .cancelled
                    }
                })
                .await;

                // Await the spawned task by mutable reference so a timeout can abort it instead of
                // dropping the handle. A dropped JoinHandle detaches the task, leaving the shell
                // command running in the background where it can keep file handles open and
                // destabilize later tests.
                let result =
                    match tokio::time::timeout(Duration::from_secs(30), &mut execute_task).await {
                        Ok(joined) => joined
                            .expect("shell execution task should not panic")
                            .expect("execute user-requested shell command"),
                        Err(_elapsed) => {
                            execute_task.abort();
                            panic!("cancelled shell command did not finish within 30s");
                        }
                    };
                assert!(!result.success);

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

async fn wait_for_file_text(path: &Path, expected: &'static str) {
    wait_for_condition("shell marker text", || async {
        std::fs::read_to_string(path).is_ok_and(|content| content.contains(expected))
    })
    .await;
}

#[cfg(windows)]
fn create_marker_then_sleep_command(marker_path: &Path, seconds: u64) -> String {
    format!(
        "Set-Content -LiteralPath {} -Value 'running'; Start-Sleep -Seconds {seconds}",
        powershell_quote(marker_path)
    )
}

#[cfg(not(windows))]
fn create_marker_then_sleep_command(marker_path: &Path, seconds: u64) -> String {
    format!(
        "echo running > {}; sleep {seconds}",
        posix_shell_quote(marker_path)
    )
}

#[cfg(windows)]
fn powershell_quote(path: &Path) -> String {
    format!("'{}'", path.display().to_string().replace('\'', "''"))
}

#[cfg(not(windows))]
fn posix_shell_quote(path: &Path) -> String {
    format!("'{}'", path.display().to_string().replace('\'', "'\\''"))
}