unified-agent-api 0.3.5

Agent-agnostic facade and registry for wrapper backends
Documentation
use std::{collections::BTreeMap, path::PathBuf, time::Duration};

use futures_util::StreamExt;
use serde_json::json;

use crate::{backends::opencode::OpencodeBackendConfig, AgentWrapperBackend, AgentWrapperError};

use super::support::{backend_with_config, backend_with_env, backend_with_timeout, request};

#[tokio::test]
async fn opencode_backend_validation_uses_fake_binary_timeout_path_without_live_provider_state() {
    let mut env = BTreeMap::new();
    env.insert(
        "FAKE_OPENCODE_SCENARIO".to_string(),
        "slow_until_killed".to_string(),
    );

    let backend = backend_with_timeout(env, Duration::from_secs(1));
    let handle = backend
        .run(request("Reply with OK.", None))
        .await
        .expect("run should start");

    let mut events = handle.events;
    assert!(
        tokio::time::timeout(Duration::from_secs(2), events.next())
            .await
            .expect("first event should arrive before the harness timeout path completes")
            .is_some(),
        "fake-binary validation path should still surface an initial event"
    );
    while events.next().await.is_some() {}

    let err = handle
        .completion
        .await
        .expect_err("timeout validation path should surface a backend error");
    match err {
        AgentWrapperError::Backend { message } => {
            assert_eq!(message, "opencode backend error: timeout");
        }
        other => panic!("expected Backend timeout error, got {other:?}"),
    }
}

#[tokio::test]
async fn opencode_backend_missing_binary_surfaces_safe_spawn_error() {
    let backend = backend_with_config(OpencodeBackendConfig {
        binary: Some(PathBuf::from("/definitely/not/a/real/opencode-binary")),
        default_timeout: None,
        env: BTreeMap::new(),
    });

    let handle = backend
        .run(request("Reply with OK.", None))
        .await
        .expect("run handle should still be returned before startup failure surfaces");

    let mut events = handle.events;
    let first = events.next().await.expect("error event should surface");
    assert_eq!(first.kind, crate::AgentWrapperEventKind::Error);
    assert_eq!(
        first.message.as_deref(),
        Some("opencode backend error: binary not found")
    );
    assert!(
        events.next().await.is_none(),
        "startup failure should close the stream"
    );

    let err = handle
        .completion
        .await
        .expect_err("startup failure should resolve completion as backend error");
    match err {
        AgentWrapperError::Backend { message } => {
            assert_eq!(message, "opencode backend error: binary not found");
        }
        other => panic!("expected Backend spawn error, got {other:?}"),
    }
}

#[tokio::test]
async fn opencode_backend_rejects_mutually_exclusive_resume_and_fork_before_spawn() {
    let backend = backend_with_config(OpencodeBackendConfig {
        binary: Some(PathBuf::from("/definitely/not/a/real/opencode-binary")),
        default_timeout: None,
        env: BTreeMap::new(),
    });
    let mut run_request = request("Reply with OK.", None);
    run_request.extensions.insert(
        "agent_api.session.resume.v1".to_string(),
        json!({"selector": "last"}),
    );
    run_request.extensions.insert(
        "agent_api.session.fork.v1".to_string(),
        json!({"selector": "id", "id": "session-123"}),
    );

    let err = backend
        .run(run_request)
        .await
        .expect_err("mutually exclusive selectors must fail before spawn");
    match err {
        AgentWrapperError::InvalidRequest { message } => assert_eq!(
            message,
            "agent_api.session.resume.v1 and agent_api.session.fork.v1 are mutually exclusive"
        ),
        other => panic!("expected InvalidRequest, got {other:?}"),
    }
}

#[tokio::test]
async fn opencode_backend_keeps_add_dirs_fail_closed() {
    let backend = backend_with_env(Default::default());
    let mut run_request = request("Reply with OK.", None);
    run_request.extensions.insert(
        "agent_api.exec.add_dirs.v1".to_string(),
        json!({"dirs": ["."]}),
    );

    let err = backend
        .run(run_request)
        .await
        .expect_err("add_dirs must remain unsupported for opencode");
    match err {
        AgentWrapperError::UnsupportedCapability {
            agent_kind,
            capability,
        } => {
            assert_eq!(agent_kind, "opencode");
            assert_eq!(capability, "agent_api.exec.add_dirs.v1");
        }
        other => panic!("expected UnsupportedCapability, got {other:?}"),
    }
}