unified-agent-api-codex 0.2.2

Async wrapper around the Codex CLI for programmatic prompting
Documentation
#![cfg(unix)]

use std::{
    fs,
    os::unix::fs::PermissionsExt,
    path::{Path, PathBuf},
};

use codex::{
    AppServerCodegenRequest, CodexClient, DebugAppServerSendMessageV2Request,
    FeaturesDisableRequest, FeaturesEnableRequest,
};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Invocation {
    argv: Vec<String>,
}

#[tokio::test]
async fn features_enable_disable_spawn_expected_subcommands(
) -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempfile::tempdir()?;
    let log_path = temp.path().join("invocations.jsonl");
    let fake_codex = write_fake_codex(&log_path)?;

    let client = CodexClient::builder()
        .binary(&fake_codex)
        .mirror_stdout(false)
        .quiet(true)
        .build();

    client
        .features_enable(FeaturesEnableRequest::new("unified_exec"))
        .await?;
    client
        .features_disable(FeaturesDisableRequest::new("unified_exec"))
        .await?;

    let invocations = read_invocations(&log_path)?;
    assert!(
        invocations
            .iter()
            .any(|inv| inv.argv == ["features", "enable", "unified_exec"]),
        "missing features enable invocation: {:?}",
        invocations
            .iter()
            .map(|inv| inv.argv.as_slice())
            .collect::<Vec<_>>()
    );
    assert!(
        invocations
            .iter()
            .any(|inv| inv.argv == ["features", "disable", "unified_exec"]),
        "missing features disable invocation: {:?}",
        invocations
            .iter()
            .map(|inv| inv.argv.as_slice())
            .collect::<Vec<_>>()
    );

    Ok(())
}

#[tokio::test]
async fn debug_app_server_send_message_v2_spawns_expected_subcommand(
) -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempfile::tempdir()?;
    let log_path = temp.path().join("invocations.jsonl");
    let fake_codex = write_fake_codex(&log_path)?;

    let client = CodexClient::builder()
        .binary(&fake_codex)
        .mirror_stdout(false)
        .quiet(true)
        .build();

    client
        .debug_app_server_send_message_v2(DebugAppServerSendMessageV2Request::new("hello"))
        .await?;

    let invocations = read_invocations(&log_path)?;
    assert!(
        invocations
            .iter()
            .any(|inv| inv.argv == ["debug", "app-server", "send-message-v2", "hello"]),
        "missing debug send-message-v2 invocation: {:?}",
        invocations
            .iter()
            .map(|inv| inv.argv.as_slice())
            .collect::<Vec<_>>()
    );
    Ok(())
}

#[tokio::test]
async fn app_server_codegen_experimental_emits_flag() -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempfile::tempdir()?;
    let log_path = temp.path().join("invocations.jsonl");
    let fake_codex = write_fake_codex(&log_path)?;

    let client = CodexClient::builder()
        .binary(&fake_codex)
        .mirror_stdout(false)
        .quiet(true)
        .build();

    let out_dir = temp.path().join("app-server-schema");
    client
        .generate_app_server_bindings(
            AppServerCodegenRequest::json_schema(&out_dir).experimental(true),
        )
        .await?;

    let invocations = read_invocations(&log_path)?;
    let invocation = invocations
        .iter()
        .find(|inv| inv.argv.first().map(|v| v.as_str()) == Some("app-server"))
        .expect("expected an app-server invocation");

    assert!(
        invocation.argv.iter().any(|arg| arg == "--experimental"),
        "--experimental missing from argv: {:?}",
        invocation.argv
    );

    Ok(())
}

fn write_fake_codex(log_path: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let script_path = log_path
        .parent()
        .unwrap_or_else(|| Path::new("."))
        .join("fake_codex.sh");
    let script = format!(
        r#"#!/usr/bin/env bash
set -euo pipefail

LOG_PATH="{log}"

python3 - "$LOG_PATH" "$@" <<'PY'
import json
import sys

log_path = sys.argv[1]
argv = sys.argv[2:]

with open(log_path, 'a', encoding='utf-8') as handle:
    handle.write(json.dumps({{'argv': argv}}))
    handle.write('\n')
PY

if [[ $# -ge 2 && $1 == "features" && ( $2 == "enable" || $2 == "disable" ) ]]; then
  echo "features-ok"
  exit 0
fi

if [[ $# -ge 4 && $1 == "debug" && $2 == "app-server" && $3 == "send-message-v2" ]]; then
  echo "debug-ok"
  exit 0
fi

if [[ $# -ge 2 && $1 == "app-server" && ( $2 == "generate-ts" || $2 == "generate-json-schema" ) ]]; then
  echo "app-server-ok"
  exit 0
fi

echo "unknown command: $@" >&2
exit 1
"#,
        log = log_path.display()
    );

    fs::write(&script_path, script)?;
    let mut permissions = fs::metadata(&script_path)?.permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(&script_path, permissions)?;
    Ok(script_path)
}

fn read_invocations(log_path: &Path) -> Result<Vec<Invocation>, Box<dyn std::error::Error>> {
    let content = fs::read_to_string(log_path)?;
    let mut invocations = Vec::new();
    for line in content.lines() {
        if line.trim().is_empty() {
            continue;
        }
        invocations.push(serde_json::from_str(line)?);
    }
    Ok(invocations)
}