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 std::sync::Arc;

use async_trait::async_trait;
use github_copilot_sdk::hooks::{
    HookContext, PostToolUseInput, PreToolUseInput, PreToolUseOutput, SessionHooks,
};
use tokio::sync::mpsc;

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

#[tokio::test]
async fn should_invoke_pretooluse_hook_when_model_runs_a_tool() {
    with_e2e_context(
        "hooks",
        "should_invoke_pretooluse_hook_when_model_runs_a_tool",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                std::fs::write(ctx.work_dir().join("hello.txt"), "Hello from the test!")
                    .expect("write hello");
                let (pre_tx, mut pre_rx) = mpsc::unbounded_channel();
                let client = ctx.start_client().await;
                let session = client
                    .create_session(ctx.approve_all_session_config().with_hooks(Arc::new(
                        RecordingHooks {
                            pre_tx: Some(pre_tx),
                            post_tx: None,
                            deny: false,
                        },
                    )))
                    .await
                    .expect("create session");

                session
                    .send_and_wait("Read the contents of hello.txt and tell me what it says")
                    .await
                    .expect("send");

                let input = recv_with_timeout(&mut pre_rx, "preToolUse hook").await;
                assert_eq!(input.0, *session.id());
                assert!(!input.1.is_empty());

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

#[tokio::test]
async fn should_invoke_posttooluse_hook_after_model_runs_a_tool() {
    with_e2e_context(
        "hooks",
        "should_invoke_posttooluse_hook_after_model_runs_a_tool",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                std::fs::write(ctx.work_dir().join("world.txt"), "World from the test!")
                    .expect("write world");
                let (post_tx, mut post_rx) = mpsc::unbounded_channel();
                let client = ctx.start_client().await;
                let session = client
                    .create_session(ctx.approve_all_session_config().with_hooks(Arc::new(
                        RecordingHooks {
                            pre_tx: None,
                            post_tx: Some(post_tx),
                            deny: false,
                        },
                    )))
                    .await
                    .expect("create session");

                session
                    .send_and_wait("Read the contents of world.txt and tell me what it says")
                    .await
                    .expect("send");

                let input = recv_with_timeout(&mut post_rx, "postToolUse hook").await;
                assert_eq!(input.0, *session.id());
                assert!(!input.1.is_empty());
                assert!(input.2);

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

#[tokio::test]
async fn should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_call() {
    with_e2e_context(
        "hooks",
        "should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_call",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                std::fs::write(ctx.work_dir().join("both.txt"), "Testing both hooks!")
                    .expect("write both");
                let (pre_tx, mut pre_rx) = mpsc::unbounded_channel();
                let (post_tx, mut post_rx) = mpsc::unbounded_channel();
                let client = ctx.start_client().await;
                let session = client
                    .create_session(ctx.approve_all_session_config().with_hooks(Arc::new(
                        RecordingHooks {
                            pre_tx: Some(pre_tx),
                            post_tx: Some(post_tx),
                            deny: false,
                        },
                    )))
                    .await
                    .expect("create session");

                session
                    .send_and_wait("Read the contents of both.txt")
                    .await
                    .expect("send");

                let pre = recv_with_timeout(&mut pre_rx, "preToolUse hook").await;
                let post = recv_with_timeout(&mut post_rx, "postToolUse hook").await;
                assert_eq!(pre.0, *session.id());
                assert_eq!(post.0, *session.id());
                assert_eq!(pre.1, post.1);

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

#[tokio::test]
async fn should_deny_tool_execution_when_pretooluse_returns_deny() {
    with_e2e_context(
        "hooks",
        "should_deny_tool_execution_when_pretooluse_returns_deny",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let original_content = "Original content that should not be modified";
                let protected_path = ctx.work_dir().join("protected.txt");
                std::fs::write(&protected_path, original_content).expect("write protected");
                let (pre_tx, mut pre_rx) = mpsc::unbounded_channel();
                let client = ctx.start_client().await;
                let session = client
                    .create_session(ctx.approve_all_session_config().with_hooks(Arc::new(
                        RecordingHooks {
                            pre_tx: Some(pre_tx),
                            post_tx: None,
                            deny: true,
                        },
                    )))
                    .await
                    .expect("create session");

                session
                    .send_and_wait("Edit protected.txt and replace 'Original' with 'Modified'")
                    .await
                    .expect("send");

                let pre = recv_with_timeout(&mut pre_rx, "preToolUse hook").await;
                assert_eq!(pre.0, *session.id());
                assert_eq!(
                    std::fs::read_to_string(protected_path).expect("read protected"),
                    original_content
                );

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

struct RecordingHooks {
    pre_tx: Option<mpsc::UnboundedSender<(github_copilot_sdk::SessionId, String)>>,
    post_tx: Option<mpsc::UnboundedSender<(github_copilot_sdk::SessionId, String, bool)>>,
    deny: bool,
}

#[async_trait]
impl SessionHooks for RecordingHooks {
    async fn on_pre_tool_use(
        &self,
        input: PreToolUseInput,
        ctx: HookContext,
    ) -> Option<PreToolUseOutput> {
        if let Some(pre_tx) = &self.pre_tx {
            let _ = pre_tx.send((ctx.session_id, input.tool_name));
        }
        Some(PreToolUseOutput {
            permission_decision: Some(if self.deny { "deny" } else { "allow" }.to_string()),
            ..PreToolUseOutput::default()
        })
    }

    async fn on_post_tool_use(
        &self,
        input: PostToolUseInput,
        ctx: HookContext,
    ) -> Option<github_copilot_sdk::hooks::PostToolUseOutput> {
        if let Some(post_tx) = &self.post_tx {
            let _ = post_tx.send((
                ctx.session_id,
                input.tool_name,
                !input.tool_result.is_null(),
            ));
        }
        None
    }
}