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::SessionEvent;
use github_copilot_sdk::generated::session_events::SessionEventType;

use super::support::{
    assistant_message_content, collect_until_idle, event_types, with_e2e_context,
};

#[tokio::test]
async fn should_use_tool_results_from_previous_turns() {
    with_e2e_context(
        "multi_turn",
        "should_use_tool_results_from_previous_turns",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                std::fs::write(ctx.work_dir().join("secret.txt"), "The magic number is 42.")
                    .expect("write secret");
                let client = ctx.start_client().await;
                let session = client
                    .create_session(ctx.approve_all_session_config())
                    .await
                    .expect("create session");

                let first_events = session.subscribe();
                let first = session
                    .send_and_wait(
                        "Read the file 'secret.txt' and tell me what the magic number is.",
                    )
                    .await
                    .expect("first send")
                    .expect("assistant message");
                assert!(assistant_message_content(&first).contains("42"));
                assert_tool_turn_ordering(
                    &collect_until_idle(first_events).await,
                    "file read turn",
                );

                let second = session
                    .send_and_wait("What is that magic number multiplied by 2?")
                    .await
                    .expect("second send")
                    .expect("assistant message");
                assert!(assistant_message_content(&second).contains("84"));

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

#[tokio::test]
async fn should_handle_file_creation_then_reading_across_turns() {
    with_e2e_context(
        "multi_turn",
        "should_handle_file_creation_then_reading_across_turns",
        |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 create_events = session.subscribe();
                session
                    .send_and_wait(
                        "Create a file called 'greeting.txt' with the content \
                         'Hello from multi-turn test'.",
                    )
                    .await
                    .expect("create file turn");
                assert_eq!(
                    std::fs::read_to_string(ctx.work_dir().join("greeting.txt"))
                        .expect("read greeting"),
                    "Hello from multi-turn test"
                );
                assert_tool_turn_ordering(
                    &collect_until_idle(create_events).await,
                    "file creation turn",
                );

                let read_events = session.subscribe();
                let answer = session
                    .send_and_wait("Read the file 'greeting.txt' and tell me its exact contents.")
                    .await
                    .expect("read file turn")
                    .expect("assistant message");
                assert!(assistant_message_content(&answer).contains("Hello from multi-turn test"));
                assert_tool_turn_ordering(&collect_until_idle(read_events).await, "file read turn");

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

fn assert_tool_turn_ordering(events: &[SessionEvent], turn_description: &str) {
    let observed_types = event_types(events).join(", ");
    let user_message = index_of(events, SessionEventType::UserMessage, 0);
    let tool_starts: Vec<_> = events
        .iter()
        .enumerate()
        .filter(|(_, event)| event.parsed_type() == SessionEventType::ToolExecutionStart)
        .collect();
    let tool_completes: Vec<_> = events
        .iter()
        .enumerate()
        .filter(|(_, event)| event.parsed_type() == SessionEventType::ToolExecutionComplete)
        .collect();

    assert!(
        user_message.is_some(),
        "expected user.message in {turn_description}; observed: {observed_types}"
    );
    assert!(
        !tool_starts.is_empty(),
        "expected tool.execution_start in {turn_description}; observed: {observed_types}"
    );
    assert!(
        !tool_completes.is_empty(),
        "expected tool.execution_complete in {turn_description}; observed: {observed_types}"
    );
    assert!(user_message.unwrap() < tool_starts[0].0);

    let last_tool_complete = tool_completes
        .last()
        .map(|(index, _)| *index)
        .expect("last tool completion");
    let assistant = index_of(
        events,
        SessionEventType::AssistantMessage,
        last_tool_complete + 1,
    )
    .expect("assistant.message after tools");
    let idle = index_of(events, SessionEventType::SessionIdle, assistant + 1)
        .expect("session.idle after assistant");
    assert!(last_tool_complete < assistant);
    assert!(assistant < idle);
}

fn index_of(
    events: &[SessionEvent],
    event_type: SessionEventType,
    start_index: usize,
) -> Option<usize> {
    events
        .iter()
        .enumerate()
        .skip(start_index)
        .find_map(|(index, event)| (event.parsed_type() == event_type).then_some(index))
}