unified-agent-api 0.2.2

Agent-agnostic facade and registry for wrapper backends
Documentation
use super::support::*;
use serde_json::json;

#[cfg(windows)]
use std::path::{Component, Path, Prefix};

#[test]
fn codex_harness_supported_extension_keys_include_agent_api_config_model_v1() {
    let adapter = test_adapter();
    assert!(adapter
        .supported_extension_keys()
        .contains(&crate::EXT_AGENT_API_CONFIG_MODEL_V1));
}

#[test]
fn codex_normalize_request_accepts_agent_api_config_model_v1_and_trims_it() {
    let adapter = test_adapter();
    let defaults = BackendDefaults::default();
    let mut request = AgentWrapperRunRequest {
        prompt: "hello".to_string(),
        ..Default::default()
    };
    request.extensions.insert(
        crate::EXT_AGENT_API_CONFIG_MODEL_V1.to_string(),
        json!("  gpt-5-codex  "),
    );

    let normalized = crate::backend_harness::normalize_request(&adapter, &defaults, request)
        .expect("model-selection key should be accepted for codex");

    assert_eq!(normalized.model_id.as_deref(), Some("gpt-5-codex"));
}

#[test]
fn codex_backend_does_not_advertise_external_sandbox_exec_by_default() {
    assert!(!CodexBackendConfig::default().allow_external_sandbox_exec);

    let backend = CodexBackend::new(CodexBackendConfig::default());
    let capabilities = backend.capabilities();
    assert!(!capabilities.contains(EXT_EXTERNAL_SANDBOX_V1));

    let adapter = test_adapter();
    assert!(!adapter
        .supported_extension_keys()
        .contains(&EXT_EXTERNAL_SANDBOX_V1));
}

#[test]
fn codex_backend_advertises_external_sandbox_exec_when_opted_in_and_normalize_allows_key() {
    let config = CodexBackendConfig {
        allow_external_sandbox_exec: true,
        ..Default::default()
    };

    let backend = CodexBackend::new(config.clone());
    let capabilities = backend.capabilities();
    assert!(capabilities.contains(EXT_EXTERNAL_SANDBOX_V1));

    let adapter = test_adapter_with_config(config);
    assert!(adapter
        .supported_extension_keys()
        .contains(&EXT_EXTERNAL_SANDBOX_V1));

    let defaults = BackendDefaults::default();
    let mut request = AgentWrapperRunRequest {
        prompt: "hello".to_string(),
        ..Default::default()
    };
    request
        .extensions
        .insert(EXT_EXTERNAL_SANDBOX_V1.to_string(), json!(true));

    crate::backend_harness::normalize_request(&adapter, &defaults, request)
        .expect("expected external sandbox key to pass allowlist gate when opted in");
}

#[test]
fn external_sandbox_extension_key_fails_closed_when_opt_in_disabled() {
    let adapter = test_adapter();
    let defaults = BackendDefaults::default();
    let mut request = AgentWrapperRunRequest {
        prompt: "hello".to_string(),
        ..Default::default()
    };
    request
        .extensions
        .insert(EXT_EXTERNAL_SANDBOX_V1.to_string(), json!(true));

    let err = match crate::backend_harness::normalize_request(&adapter, &defaults, request) {
        Ok(_) => panic!("expected UnsupportedCapability when opt-in is disabled"),
        Err(err) => err,
    };
    match err {
        AgentWrapperError::UnsupportedCapability { capability, .. } => {
            assert_eq!(capability, EXT_EXTERNAL_SANDBOX_V1);
        }
        other => panic!("expected UnsupportedCapability, got: {other:?}"),
    }
}

#[tokio::test]
async fn external_sandbox_spawn_failure_emits_warning_before_terminal_error() {
    let adapter = test_adapter_with_config(CodexBackendConfig {
        allow_external_sandbox_exec: true,
        ..Default::default()
    });

    let spawned = adapter
        .spawn(crate::backend_harness::NormalizedRequest {
            agent_kind: adapter.kind(),
            prompt: "hello".to_string(),
            model_id: None,
            working_dir: None,
            effective_timeout: None,
            env: std::collections::BTreeMap::new(),
            policy: CodexExecPolicy {
                add_dirs: Vec::new(),
                non_interactive: true,
                external_sandbox: true,
                approval_policy: None,
                sandbox_mode: CodexSandboxMode::WorkspaceWrite,
                resume: None,
                fork: None,
            },
        })
        .await
        .expect("startup failure should still return a stream when external sandbox is enabled");

    let backend_events: Vec<CodexBackendEvent> = spawned
        .events
        .map(|result| result.expect("synthetic startup-failure events should be infallible"))
        .collect()
        .await;
    assert_eq!(backend_events.len(), 2);
    assert!(matches!(
        backend_events[0],
        CodexBackendEvent::ExternalSandboxWarning
    ));
    assert!(matches!(
        &backend_events[1],
        CodexBackendEvent::TerminalError { message }
        if message == "codex backend failed to resolve working directory"
    ));

    let mapped: Vec<_> = backend_events
        .into_iter()
        .flat_map(|event| adapter.map_event(event))
        .collect();
    assert_eq!(mapped.len(), 2);
    assert_eq!(mapped[0].kind, AgentWrapperEventKind::Status);
    assert_eq!(
        mapped[0].message.as_deref(),
        Some(PINNED_EXTERNAL_SANDBOX_WARNING)
    );
    assert_eq!(mapped[1].kind, AgentWrapperEventKind::Error);
    assert_eq!(
        mapped[1].message.as_deref(),
        Some("codex backend failed to resolve working directory")
    );

    let err = spawned
        .completion
        .await
        .expect_err("startup failure completion should preserve the backend error");
    assert!(matches!(err, CodexBackendError::WorkingDirectoryUnresolved));
}

#[cfg(windows)]
#[tokio::test]
async fn exec_cross_drive_drive_relative_working_dir_fails_before_spawn() {
    let run_start_cwd = std::env::temp_dir().join("codex-cross-drive-exec");
    let adapter = test_adapter_with_run_start_cwd(Some(run_start_cwd.clone()));

    let spawned = adapter
        .spawn(crate::backend_harness::NormalizedRequest {
            agent_kind: adapter.kind(),
            prompt: "hello".to_string(),
            model_id: None,
            working_dir: Some(windows_drive_relative_on_other_drive(
                "repo",
                &run_start_cwd,
            )),
            effective_timeout: None,
            env: std::collections::BTreeMap::new(),
            policy: CodexExecPolicy {
                add_dirs: Vec::new(),
                non_interactive: true,
                external_sandbox: false,
                approval_policy: None,
                sandbox_mode: CodexSandboxMode::WorkspaceWrite,
                resume: None,
                fork: None,
            },
        })
        .await
        .expect("startup failure should still return a stream");

    let backend_events: Vec<CodexBackendEvent> = spawned
        .events
        .map(|result| result.expect("synthetic startup-failure events should be infallible"))
        .collect()
        .await;
    assert_eq!(backend_events.len(), 1);
    assert!(matches!(
        &backend_events[0],
        CodexBackendEvent::TerminalError { message }
        if message == "codex backend failed to resolve working directory"
    ));

    let err = spawned
        .completion
        .await
        .expect_err("startup failure completion should preserve the backend error");
    assert!(matches!(err, CodexBackendError::WorkingDirectoryUnresolved));
}

#[cfg(windows)]
#[tokio::test]
async fn fork_cross_drive_drive_relative_working_dir_fails_before_app_server_start() {
    let run_start_cwd = std::env::temp_dir().join("codex-cross-drive-fork");
    let adapter = test_adapter_with_run_start_cwd(Some(run_start_cwd.clone()));

    let spawned = adapter
        .spawn(crate::backend_harness::NormalizedRequest {
            agent_kind: adapter.kind(),
            prompt: "hello".to_string(),
            model_id: None,
            working_dir: Some(windows_drive_relative_on_other_drive(
                "repo",
                &run_start_cwd,
            )),
            effective_timeout: None,
            env: std::collections::BTreeMap::new(),
            policy: CodexExecPolicy {
                add_dirs: Vec::new(),
                non_interactive: true,
                external_sandbox: false,
                approval_policy: None,
                sandbox_mode: CodexSandboxMode::WorkspaceWrite,
                resume: None,
                fork: Some(crate::backends::session_selectors::SessionSelectorV1::Last),
            },
        })
        .await
        .expect("startup failure should still return a stream");

    let backend_events: Vec<CodexBackendEvent> = spawned
        .events
        .map(|result| result.expect("synthetic startup-failure events should be infallible"))
        .collect()
        .await;
    assert_eq!(backend_events.len(), 1);
    assert!(matches!(
        &backend_events[0],
        CodexBackendEvent::TerminalError { message }
        if message == "codex backend failed to resolve working directory"
    ));

    let err = spawned
        .completion
        .await
        .expect_err("startup failure completion should preserve the backend error");
    assert!(matches!(err, CodexBackendError::WorkingDirectoryUnresolved));
}

#[cfg(windows)]
fn windows_drive_relative_on_other_drive(
    relative: &str,
    absolute_path: &Path,
) -> std::path::PathBuf {
    let current_drive = absolute_path
        .components()
        .find_map(|component| match component {
            Component::Prefix(value) => match value.kind() {
                Prefix::Disk(drive) | Prefix::VerbatimDisk(drive) => {
                    Some(drive.to_ascii_lowercase())
                }
                _ => None,
            },
            _ => None,
        })
        .expect("absolute windows path should include a disk prefix");
    let alternate_drive = if current_drive == b'c' { 'd' } else { 'c' };
    std::path::PathBuf::from(format!("{alternate_drive}:{relative}"))
}