github-copilot-sdk 1.0.0-beta.8

Rust SDK for programmatic control of the GitHub Copilot CLI via JSON-RPC. Technical preview, pre-1.0.
use std::sync::Arc;

use async_trait::async_trait;
use github_copilot_sdk::canvas::{CanvasDeclaration, CanvasHandler, CanvasResult};
use github_copilot_sdk::generated::api_types::{
    CanvasAction, CanvasProviderCloseRequest, CanvasProviderInvokeActionRequest,
    CanvasProviderOpenRequest, CanvasProviderOpenResult,
};
use github_copilot_sdk::types::ExtensionInfo;
use parking_lot::Mutex;
use serde_json::{Value, json};

use super::support::with_e2e_context;

struct TestCanvasHandler {
    open_calls: Mutex<Vec<CanvasProviderOpenRequest>>,
    close_calls: Mutex<Vec<CanvasProviderCloseRequest>>,
    action_calls: Mutex<Vec<CanvasProviderInvokeActionRequest>>,
}

impl TestCanvasHandler {
    fn new() -> Self {
        Self {
            open_calls: Mutex::new(Vec::new()),
            close_calls: Mutex::new(Vec::new()),
            action_calls: Mutex::new(Vec::new()),
        }
    }
}

#[async_trait]
impl CanvasHandler for TestCanvasHandler {
    async fn on_open(
        &self,
        ctx: CanvasProviderOpenRequest,
    ) -> CanvasResult<CanvasProviderOpenResult> {
        self.open_calls.lock().push(ctx.clone());
        Ok(CanvasProviderOpenResult {
            url: Some(format!("https://example.com/counter/{}", ctx.instance_id)),
            title: Some(format!("Counter {}", ctx.instance_id)),
            status: Some("ready".to_string()),
        })
    }

    async fn on_action(&self, ctx: CanvasProviderInvokeActionRequest) -> CanvasResult<Value> {
        self.action_calls.lock().push(ctx.clone());
        Ok(json!({ "newValue": 42 }))
    }

    async fn on_close(&self, ctx: CanvasProviderCloseRequest) -> CanvasResult<()> {
        self.close_calls.lock().push(ctx.clone());
        Ok(())
    }
}

fn canvas_session_config(
    ctx: &super::support::E2eContext,
    handler: Arc<TestCanvasHandler>,
) -> github_copilot_sdk::types::SessionConfig {
    let mut decl = CanvasDeclaration::new("counter", "Counter", "Tracks a counter value.");
    decl.actions = Some(vec![CanvasAction {
        name: "increment".to_string(),
        description: Some("Increments the counter.".to_string()),
        input_schema: None,
    }]);

    ctx.approve_all_session_config()
        .with_request_canvas_renderer(true)
        .with_request_extensions(true)
        .with_extension_info(ExtensionInfo::new("rust-sdk-tests", "canvas-provider"))
        .with_canvases([decl])
        .with_canvas_handler(handler)
}

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

            let result = session.rpc().canvas().list().await.expect("list canvases");

            assert_eq!(result.canvases.len(), 1);
            assert_eq!(result.canvases[0].canvas_id, "counter");
            assert_eq!(result.canvases[0].display_name, "Counter");
            assert_eq!(result.canvases[0].description, "Tracks a counter value.");

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

#[tokio::test]
async fn canvas_open_round_trip() {
    with_e2e_context("canvas", "canvas_open_round_trip", |ctx| {
        Box::pin(async move {
            ctx.set_default_copilot_user();
            let client = ctx.start_client().await;
            let handler = Arc::new(TestCanvasHandler::new());
            let session = client
                .create_session(canvas_session_config(ctx, handler.clone()))
                .await
                .expect("create session");

            let canvas_list = session.rpc().canvas().list().await.expect("list canvases");
            let canvas = &canvas_list.canvases[0];

            let open_result = session
                .rpc()
                .canvas()
                .open(
                    github_copilot_sdk::generated::api_types::CanvasOpenRequest {
                        canvas_id: "counter".to_string(),
                        instance_id: "counter-1".to_string(),
                        extension_id: Some(canvas.extension_id.clone()),
                        input: Some(json!({ "start": 41 })),
                    },
                )
                .await
                .expect("open canvas");

            assert_eq!(open_result.instance_id, "counter-1");
            assert_eq!(open_result.title.as_deref(), Some("Counter counter-1"));
            assert_eq!(open_result.status.as_deref(), Some("ready"));
            assert_eq!(
                open_result.url.as_deref(),
                Some("https://example.com/counter/counter-1")
            );

            {
                let opens = handler.open_calls.lock();
                assert_eq!(opens.len(), 1);
                assert_eq!(opens[0].canvas_id, "counter");
                assert_eq!(opens[0].instance_id, "counter-1");
            }

            let open_list = session
                .rpc()
                .canvas()
                .list_open()
                .await
                .expect("list open canvases");
            assert_eq!(open_list.open_canvases.len(), 1);
            assert_eq!(open_list.open_canvases[0].instance_id, "counter-1");

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

#[tokio::test]
async fn canvas_invoke_action_round_trip() {
    with_e2e_context("canvas", "canvas_invoke_action_round_trip", |ctx| {
        Box::pin(async move {
            ctx.set_default_copilot_user();
            let client = ctx.start_client().await;
            let handler = Arc::new(TestCanvasHandler::new());
            let session = client
                .create_session(canvas_session_config(ctx, handler.clone()))
                .await
                .expect("create session");

            let canvas_list = session.rpc().canvas().list().await.expect("list canvases");
            let canvas = &canvas_list.canvases[0];

            session
                .rpc()
                .canvas()
                .open(
                    github_copilot_sdk::generated::api_types::CanvasOpenRequest {
                        canvas_id: "counter".to_string(),
                        instance_id: "counter-2".to_string(),
                        extension_id: Some(canvas.extension_id.clone()),
                        input: Some(json!({})),
                    },
                )
                .await
                .expect("open canvas");

            let result = session
                .rpc()
                .canvas()
                .invoke_action(
                    github_copilot_sdk::generated::api_types::CanvasInvokeActionRequest {
                        instance_id: "counter-2".to_string(),
                        action_name: "increment".to_string(),
                        input: Some(json!({ "delta": 1 })),
                    },
                )
                .await
                .expect("invoke action");

            assert_eq!(result.result, Some(json!({ "newValue": 42 })));

            {
                let actions = handler.action_calls.lock();
                assert_eq!(actions.len(), 1);
                assert_eq!(actions[0].canvas_id, "counter");
                assert_eq!(actions[0].instance_id, "counter-2");
                assert_eq!(actions[0].action_name, "increment");
                assert_eq!(actions[0].input, Some(json!({ "delta": 1 })));
            }

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

#[tokio::test]
async fn canvas_close_round_trip() {
    with_e2e_context("canvas", "canvas_close_round_trip", |ctx| {
        Box::pin(async move {
            ctx.set_default_copilot_user();
            let client = ctx.start_client().await;
            let handler = Arc::new(TestCanvasHandler::new());
            let session = client
                .create_session(canvas_session_config(ctx, handler.clone()))
                .await
                .expect("create session");

            let canvas_list = session.rpc().canvas().list().await.expect("list canvases");
            let canvas = &canvas_list.canvases[0];

            session
                .rpc()
                .canvas()
                .open(
                    github_copilot_sdk::generated::api_types::CanvasOpenRequest {
                        canvas_id: "counter".to_string(),
                        instance_id: "counter-3".to_string(),
                        extension_id: Some(canvas.extension_id.clone()),
                        input: Some(json!({})),
                    },
                )
                .await
                .expect("open canvas");

            assert!(handler.close_calls.lock().is_empty());

            session
                .rpc()
                .canvas()
                .close(
                    github_copilot_sdk::generated::api_types::CanvasCloseRequest {
                        instance_id: "counter-3".to_string(),
                    },
                )
                .await
                .expect("close canvas");

            {
                let closes = handler.close_calls.lock();
                assert_eq!(closes.len(), 1);
                assert_eq!(closes[0].canvas_id, "counter");
                assert_eq!(closes[0].instance_id, "counter-3");
            }

            let open_list = session
                .rpc()
                .canvas()
                .list_open()
                .await
                .expect("list open canvases");
            assert!(open_list.open_canvases.is_empty());

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