github-copilot-sdk 1.0.0

Rust SDK for programmatic control of the GitHub Copilot CLI via JSON-RPC.
use github_copilot_sdk::rpc::{
    HistoryTruncateRequest, ModeSetRequest, NameSetRequest, PlanUpdateRequest,
    WorkspacesCreateFileRequest,
};
use github_copilot_sdk::session_events::{
    PlanChangedOperation, SessionEventType, SessionMode, SessionModeChangedData,
    SessionPlanChangedData, SessionSnapshotRewindData, SessionTitleChangedData,
    SessionWorkspaceFileChangedData,
};

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

#[tokio::test]
async fn should_emit_mode_changed_event_when_mode_set() {
    with_e2e_context(
        "rpc_event_side_effects",
        "should_emit_mode_changed_event_when_mode_set",
        |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 changed = wait_for_event(session.subscribe(), "mode changed", |event| {
                    if event.parsed_type() != SessionEventType::SessionModeChanged {
                        return false;
                    }
                    let data = event
                        .typed_data::<SessionModeChangedData>()
                        .expect("mode changed data");
                    data.previous_mode == SessionMode::Interactive
                        && data.new_mode == SessionMode::Plan
                });
                session
                    .rpc()
                    .mode()
                    .set(ModeSetRequest {
                        mode: SessionMode::Plan,
                    })
                    .await
                    .expect("set mode");
                changed.await;

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

#[tokio::test]
async fn should_emit_plan_changed_event_for_update_and_delete() {
    with_e2e_context(
        "rpc_event_side_effects",
        "should_emit_plan_changed_event_for_update_and_delete",
        |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 = wait_for_plan_event(&session, PlanChangedOperation::Create);
                session
                    .rpc()
                    .plan()
                    .update(PlanUpdateRequest {
                        content: "# Test plan\n- item".to_string(),
                    })
                    .await
                    .expect("create plan");
                create.await;

                let delete = wait_for_plan_event(&session, PlanChangedOperation::Delete);
                session.rpc().plan().delete().await.expect("delete plan");
                delete.await;

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

#[tokio::test]
async fn should_emit_plan_changed_update_operation_on_second_update() {
    with_e2e_context(
        "rpc_event_side_effects",
        "should_emit_plan_changed_update_operation_on_second_update",
        |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");

                session
                    .rpc()
                    .plan()
                    .update(PlanUpdateRequest {
                        content: "# initial".to_string(),
                    })
                    .await
                    .expect("create plan");
                let update = wait_for_plan_event(&session, PlanChangedOperation::Update);
                session
                    .rpc()
                    .plan()
                    .update(PlanUpdateRequest {
                        content: "# updated".to_string(),
                    })
                    .await
                    .expect("update plan");
                update.await;

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

#[tokio::test]
async fn should_emit_workspace_file_changed_event_when_file_created() {
    with_e2e_context(
        "rpc_event_side_effects",
        "should_emit_workspace_file_changed_event_when_file_created",
        |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 path = "side-effect-rust.txt";

                let changed =
                    wait_for_event(session.subscribe(), "workspace file changed", |event| {
                        if event.parsed_type() != SessionEventType::SessionWorkspaceFileChanged {
                            return false;
                        }
                        event
                            .typed_data::<SessionWorkspaceFileChangedData>()
                            .expect("workspace file changed data")
                            .path
                            == path
                    });
                session
                    .rpc()
                    .workspaces()
                    .create_file(WorkspacesCreateFileRequest {
                        path: path.to_string(),
                        content: "hello".to_string(),
                    })
                    .await
                    .expect("create workspace file");
                changed.await;

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

#[tokio::test]
async fn should_emit_title_changed_event_when_name_set() {
    with_e2e_context(
        "rpc_event_side_effects",
        "should_emit_title_changed_event_when_name_set",
        |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 title = "Renamed-Rust";

                let changed = wait_for_event(session.subscribe(), "title changed", |event| {
                    if event.parsed_type() != SessionEventType::SessionTitleChanged {
                        return false;
                    }
                    event
                        .typed_data::<SessionTitleChangedData>()
                        .expect("title changed data")
                        .title
                        == title
                });
                session
                    .rpc()
                    .name()
                    .set(NameSetRequest {
                        name: title.to_string(),
                    })
                    .await
                    .expect("set name");
                changed.await;

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

#[tokio::test]
async fn should_emit_snapshot_rewind_event_and_remove_events_on_truncate() {
    with_e2e_context(
        "rpc_event_side_effects",
        "should_emit_snapshot_rewind_event_and_remove_events_on_truncate",
        |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 answer = session
                    .send_and_wait("Say SNAPSHOT_REWIND_TARGET exactly.")
                    .await
                    .expect("send")
                    .expect("assistant message");
                assert!(assistant_message_content(&answer).contains("SNAPSHOT_REWIND_TARGET"));
                let user_event = session
                    .get_events()
                    .await
                    .expect("messages")
                    .into_iter()
                    .find(|event| event.parsed_type() == SessionEventType::UserMessage)
                    .expect("user.message event");
                let target_event_id = user_event.id.clone();

                let rewind = wait_for_event(session.subscribe(), "snapshot rewind", |event| {
                    if event.parsed_type() != SessionEventType::SessionSnapshotRewind {
                        return false;
                    }
                    event
                        .typed_data::<SessionSnapshotRewindData>()
                        .expect("snapshot rewind data")
                        .up_to_event_id
                        == target_event_id
                });
                let result = session
                    .rpc()
                    .history()
                    .truncate(HistoryTruncateRequest {
                        event_id: target_event_id.clone(),
                    })
                    .await
                    .expect("truncate history");
                assert!(result.events_removed >= 1);
                rewind.await;

                let remaining = session.get_events().await.expect("messages after truncate");
                assert!(!remaining.iter().any(|event| event.id == target_event_id));

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

#[tokio::test]
async fn should_allow_session_use_after_truncate() {
    with_e2e_context(
        "rpc_event_side_effects",
        "should_allow_session_use_after_truncate",
        |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");

                session
                    .send_and_wait("Say SNAPSHOT_REWIND_TARGET exactly.")
                    .await
                    .expect("send");
                let user_event = session
                    .get_events()
                    .await
                    .expect("messages")
                    .into_iter()
                    .find(|event| event.parsed_type() == SessionEventType::UserMessage)
                    .expect("user.message event");

                let result = session
                    .rpc()
                    .history()
                    .truncate(HistoryTruncateRequest {
                        event_id: user_event.id,
                    })
                    .await
                    .expect("truncate history");
                assert!(result.events_removed >= 1);
                session
                    .rpc()
                    .mode()
                    .get()
                    .await
                    .expect("mode after truncate");
                session
                    .rpc()
                    .workspaces()
                    .get_workspace()
                    .await
                    .expect("workspace after truncate");

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

fn wait_for_plan_event(
    session: &github_copilot_sdk::session::Session,
    operation: PlanChangedOperation,
) -> impl std::future::Future<Output = github_copilot_sdk::SessionEvent> {
    let events = session.subscribe();
    wait_for_event(events, "plan changed", move |event| {
        if event.parsed_type() != SessionEventType::SessionPlanChanged {
            return false;
        }
        event
            .typed_data::<SessionPlanChangedData>()
            .expect("plan changed data")
            .operation
            == operation
    })
}