codex-app-server-sdk 0.5.1

Tokio Rust SDK for Codex App Server
Documentation
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use codex_app_server_sdk::client::StdioConfig;
use codex_app_server_sdk::events::{ServerEvent, ServerNotification};
use codex_app_server_sdk::protocol::requests::{
    ClientInfo, InitializeParams, ModelListParams, ThreadStartParams, TurnStartParams,
};
use codex_app_server_sdk::{ClientError, CodexClient};
use serde_json::json;

const TEST_TIMEOUT: Duration = Duration::from_secs(90);

fn isolated_codex_home() -> PathBuf {
    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("time")
        .as_nanos();
    let path = std::env::temp_dir().join(format!(
        "codex-sdk-rs-integration-home-{}-{stamp}",
        std::process::id()
    ));
    fs::create_dir_all(path.join(".codex")).expect("create isolated codex home");
    path
}

fn isolate_home_enabled() -> bool {
    std::env::var("CODEX_SDK_TEST_ISOLATE_HOME")
        .map(|value| {
            let normalized = value.trim().to_ascii_lowercase();
            normalized == "1" || normalized == "true" || normalized == "yes"
        })
        .unwrap_or(false)
}

fn isolated_stdio_config() -> StdioConfig {
    let mut config = StdioConfig::default();
    let mut env = HashMap::new();
    if isolate_home_enabled() {
        let isolated_home = isolated_codex_home();
        env.insert(
            "HOME".to_string(),
            isolated_home.to_string_lossy().to_string(),
        );
        env.insert(
            "CODEX_HOME".to_string(),
            isolated_home.to_string_lossy().to_string(),
        );
    }
    if let Ok(api_key) = std::env::var("OPENAI_API_KEY") {
        env.insert("OPENAI_API_KEY".to_string(), api_key);
    }
    config.env = env;
    config
}

async fn spawn_initialized_client() -> Result<CodexClient, Box<dyn std::error::Error>> {
    let client = CodexClient::spawn_stdio(isolated_stdio_config()).await?;
    client
        .initialize(InitializeParams::new(ClientInfo::new(
            "integration_test",
            "Integration Test",
            env!("CARGO_PKG_VERSION"),
        )))
        .await?;
    client.initialized().await?;
    Ok(client)
}

#[tokio::test]
async fn initialize_over_stdio() -> Result<(), Box<dyn std::error::Error>> {
    let client = CodexClient::spawn_stdio(isolated_stdio_config()).await?;

    client
        .initialize(InitializeParams::new(ClientInfo::new(
            "integration_test",
            "Integration Test",
            env!("CARGO_PKG_VERSION"),
        )))
        .await?;

    client.initialized().await?;

    Ok(())
}

#[tokio::test]
async fn rejects_requests_before_initialized_notification() -> Result<(), Box<dyn std::error::Error>>
{
    let client = CodexClient::spawn_stdio(isolated_stdio_config()).await?;
    client
        .initialize(InitializeParams::new(ClientInfo::new(
            "integration_test",
            "Integration Test",
            env!("CARGO_PKG_VERSION"),
        )))
        .await?;

    let err = client
        .model_list(ModelListParams::default())
        .await
        .expect_err("request should fail before initialized() is sent");

    match err {
        ClientError::NotReady { method } => {
            assert_eq!(method, "model/list");
        }
        other => panic!("unexpected error: {other}"),
    }

    Ok(())
}

#[tokio::test]
async fn model_list_typed_matches_raw() -> Result<(), Box<dyn std::error::Error>> {
    let client = spawn_initialized_client().await?;

    let typed = client.model_list(ModelListParams::default()).await?;
    assert!(
        !typed.data.is_empty(),
        "typed model list should not be empty"
    );

    let raw = client
        .send_raw_request("model/list", json!({}), Some(Duration::from_secs(30)))
        .await?;

    let raw_data = raw
        .get("data")
        .and_then(|v| v.as_array())
        .expect("raw model list must include array data");

    assert_eq!(typed.data.len(), raw_data.len());

    let typed_first = &typed.data[0].id;
    let raw_first = raw_data[0]
        .get("id")
        .and_then(|v| v.as_str())
        .expect("raw model entry must include id");

    assert_eq!(typed_first, raw_first);
    Ok(())
}

#[tokio::test]
async fn thread_and_turn_complete_over_event_stream() -> Result<(), Box<dyn std::error::Error>> {
    let client = spawn_initialized_client().await?;

    let thread = client.thread_start(ThreadStartParams::default()).await?;
    let turn = client
        .turn_start(TurnStartParams::text(
            thread.thread.id.clone(),
            "Reply with exactly: ok",
        ))
        .await?;

    let target_turn_id = turn.turn.id;
    let deadline = Instant::now() + TEST_TIMEOUT;
    let mut saw_agent_delta = false;
    let mut saw_agent_item = false;

    while Instant::now() < deadline {
        let event = match tokio::time::timeout(Duration::from_secs(2), client.next_event()).await {
            Ok(Ok(event)) => event,
            Ok(Err(err)) => return Err(format!("event stream error: {err}").into()),
            Err(_) => continue,
        };

        match event {
            ServerEvent::Notification(ServerNotification::ItemAgentMessageDelta(delta)) => {
                let text = delta.delta.or(delta.text).unwrap_or_default();
                if !text.is_empty() {
                    saw_agent_delta = true;
                }
            }
            ServerEvent::Notification(ServerNotification::ItemCompleted(item)) => {
                if item
                    .item
                    .get("type")
                    .and_then(|v| v.as_str())
                    .is_some_and(|kind| kind == "agentMessage")
                {
                    saw_agent_item = true;
                }
            }
            ServerEvent::Notification(ServerNotification::TurnCompleted(done)) => {
                if done.turn.id == target_turn_id {
                    let status = done.turn.status.unwrap_or_default();
                    assert!(
                        status == "completed" || status == "interrupted",
                        "unexpected final turn status: {status}"
                    );
                    assert!(
                        saw_agent_delta || saw_agent_item,
                        "expected agent output before completion"
                    );
                    return Ok(());
                }
            }
            _ => {}
        }
    }

    Err("timed out waiting for turn/completed".into())
}