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::handler::ApproveAllHandler;
use github_copilot_sdk::tool::{ToolHandler, ToolHandlerRouter};
use github_copilot_sdk::{
    Client, Error, OtelExporterType, SessionConfig, TelemetryConfig, Tool, ToolInvocation,
    ToolResult,
};
use serde_json::json;

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

#[tokio::test]
async fn should_export_file_telemetry_for_sdk_interactions() {
    with_e2e_context(
        "telemetry",
        "should_export_file_telemetry_for_sdk_interactions",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let telemetry_path = ctx.work_dir().join("rust-telemetry-e2e.jsonl");
                let source_name = "rust-sdk-telemetry-e2e";
                let tool_name = "echo_telemetry_marker";
                let marker = "copilot-sdk-telemetry-e2e";
                let prompt = format!(
                    "Use the {tool_name} tool with value '{marker}', then respond with TELEMETRY_E2E_DONE."
                );

                let client = Client::start(ctx.client_options().with_telemetry(
                    TelemetryConfig::new()
                        .with_file_path(&telemetry_path)
                        .with_exporter_type(OtelExporterType::File)
                        .with_source_name(source_name)
                        .with_capture_content(true),
                ))
                .await
                .expect("start client");
                let router = ToolHandlerRouter::new(
                    vec![Box::new(EchoTelemetryTool {
                        name: tool_name.to_string(),
                    })],
                    Arc::new(ApproveAllHandler),
                );
                let tools = router.tools();
                let session = client
                    .create_session(
                        SessionConfig::default()
                            .with_github_token(super::support::DEFAULT_TEST_TOKEN)
                            .with_handler(Arc::new(router))
                            .with_tools(tools),
                    )
                    .await
                    .expect("create session");

                let answer = session
                    .send_and_wait(prompt.as_str())
                    .await
                    .expect("send")
                    .expect("assistant message");
                assert!(assistant_message_content(&answer).contains("TELEMETRY_E2E_DONE"));

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

                let entries = read_telemetry_entries(&telemetry_path).await;
                let spans: Vec<_> = entries
                    .iter()
                    .filter(|entry| string_property(entry, "type") == Some("span"))
                    .collect();
                assert!(!spans.is_empty(), "expected telemetry spans in {entries:?}");
                assert!(spans.iter().all(|span| {
                    span.get("instrumentationScope")
                        .and_then(|scope| string_property(scope, "name"))
                        == Some(source_name)
                }));

                let trace_ids: std::collections::HashSet<_> = spans
                    .iter()
                    .filter_map(|span| string_property(span, "traceId"))
                    .collect();
                assert_eq!(trace_ids.len(), 1);
                assert!(spans.iter().all(|span| status_code(span) != Some(2)));

                let invoke_agent = find_span(&spans, "invoke_agent");
                assert_eq!(
                    string_attribute(invoke_agent, "gen_ai.conversation.id").as_deref(),
                    Some(session.id().as_str())
                );
                let invoke_agent_span_id =
                    string_property(invoke_agent, "spanId").expect("invoke_agent span id");
                assert!(is_root_span(invoke_agent));

                let chat_spans: Vec<_> = spans
                    .iter()
                    .copied()
                    .filter(|span| {
                        string_attribute(span, "gen_ai.operation.name").as_deref() == Some("chat")
                    })
                    .collect();
                assert!(!chat_spans.is_empty());
                assert!(chat_spans.iter().all(|span| {
                    string_property(span, "parentSpanId") == Some(invoke_agent_span_id)
                }));
                assert!(chat_spans.iter().any(|span| string_attribute(
                    span,
                    "gen_ai.input.messages"
                )
                .is_some_and(|messages| messages.contains(&prompt))));
                assert!(chat_spans.iter().any(|span| string_attribute(
                    span,
                    "gen_ai.output.messages"
                )
                .is_some_and(|messages| messages.contains("TELEMETRY_E2E_DONE"))));

                let tool_span = find_span(&spans, "execute_tool");
                assert_eq!(
                    string_property(tool_span, "parentSpanId"),
                    Some(invoke_agent_span_id)
                );
                assert_eq!(
                    string_attribute(tool_span, "gen_ai.tool.name").as_deref(),
                    Some(tool_name)
                );
                assert_eq!(
                    string_attribute(tool_span, "gen_ai.tool.call.arguments").as_deref(),
                    Some(format!("{{\"value\":\"{marker}\"}}").as_str())
                );
                assert_eq!(
                    string_attribute(tool_span, "gen_ai.tool.call.result").as_deref(),
                    Some(marker)
                );
            })
        },
    )
    .await;
}

struct EchoTelemetryTool {
    name: String,
}

#[async_trait]
impl ToolHandler for EchoTelemetryTool {
    fn tool(&self) -> Tool {
        Tool::new(&self.name)
            .with_description("Echoes a marker string for telemetry validation.")
            .with_parameters(json!({
                "type": "object",
                "properties": {
                    "value": { "type": "string" }
                },
                "required": ["value"]
            }))
    }

    async fn call(&self, invocation: ToolInvocation) -> Result<ToolResult, Error> {
        Ok(ToolResult::Text(
            invocation
                .arguments
                .get("value")
                .and_then(serde_json::Value::as_str)
                .unwrap_or_default()
                .to_string(),
        ))
    }
}

async fn read_telemetry_entries(path: &std::path::Path) -> Vec<serde_json::Value> {
    wait_for_condition("telemetry file to contain spans", || {
        let path = path.to_path_buf();
        async move {
            read_telemetry_entries_once(&path).is_ok_and(|entries| {
                entries.iter().any(|entry| {
                    string_property(entry, "type") == Some("span")
                        && string_attribute(entry, "gen_ai.operation.name").as_deref()
                            == Some("invoke_agent")
                })
            })
        }
    })
    .await;
    read_telemetry_entries_once(path).expect("read telemetry entries")
}

fn read_telemetry_entries_once(path: &std::path::Path) -> std::io::Result<Vec<serde_json::Value>> {
    if !path.exists() || path.metadata()?.len() == 0 {
        return Ok(Vec::new());
    }
    std::fs::read_to_string(path).map(|content| {
        content
            .lines()
            .filter(|line| !line.trim().is_empty())
            .map(|line| serde_json::from_str(line).expect("telemetry JSON line"))
            .collect()
    })
}

fn find_span<'a>(spans: &'a [&'a serde_json::Value], operation: &str) -> &'a serde_json::Value {
    spans
        .iter()
        .copied()
        .find(|span| string_attribute(span, "gen_ai.operation.name").as_deref() == Some(operation))
        .unwrap_or_else(|| panic!("span {operation} not found in {spans:?}"))
}

fn string_property<'a>(value: &'a serde_json::Value, name: &str) -> Option<&'a str> {
    value.get(name).and_then(serde_json::Value::as_str)
}

fn string_attribute(value: &serde_json::Value, name: &str) -> Option<String> {
    value
        .get("attributes")
        .and_then(|attributes| attributes.get(name))
        .map(|value| match value {
            serde_json::Value::String(value) => value.clone(),
            serde_json::Value::Number(_) | serde_json::Value::Bool(_) => value.to_string(),
            serde_json::Value::Array(_) | serde_json::Value::Object(_) => value.to_string(),
            serde_json::Value::Null => String::new(),
        })
}

fn status_code(value: &serde_json::Value) -> Option<i64> {
    value
        .get("status")
        .and_then(|status| status.get("code"))
        .and_then(serde_json::Value::as_i64)
}

fn is_root_span(value: &serde_json::Value) -> bool {
    string_property(value, "parentSpanId")
        .is_none_or(|parent| parent.is_empty() || parent == "0000000000000000")
}