unified-agent-api 0.2.3

Agent-agnostic facade and registry for wrapper backends
Documentation
use std::{
    collections::BTreeMap,
    task::{Context, Poll},
};

use futures_util::{task::noop_waker, StreamExt};
use serde_json::json;

use crate::{
    AgentWrapperBackend, AgentWrapperError, AgentWrapperEventKind, AgentWrapperRunRequest,
};

use super::support::{backend_with_env, capture_json, request};

#[tokio::test]
async fn opencode_backend_maps_request_into_canonical_argv_and_working_dir() {
    let capture_dir = tempfile::tempdir().expect("create capture dir");
    let capture_path = capture_dir.path().join("capture.json");
    let working_dir = tempfile::tempdir().expect("create working dir");
    let mut env = BTreeMap::new();
    env.insert(
        "FAKE_OPENCODE_SCENARIO".to_string(),
        "capture_args".to_string(),
    );
    env.insert(
        "FAKE_OPENCODE_CAPTURE".to_string(),
        capture_path.display().to_string(),
    );

    let backend = backend_with_env(env);
    let handle = backend
        .run(request("Reply with OK.", Some(working_dir.path())))
        .await
        .expect("run should start");

    let mut events = handle.events;
    while events.next().await.is_some() {}

    let completion = handle.completion.await.expect("completion succeeds");
    assert!(completion.status.success());
    assert_eq!(completion.final_text.as_deref(), Some("OK"));

    let capture = capture_json(&capture_path);
    let argv = capture["argv"].as_array().expect("argv array");
    let argv = argv
        .iter()
        .map(|value| value.as_str().expect("argv string"))
        .collect::<Vec<_>>();

    assert_eq!(
        argv,
        vec![
            "run",
            "--format",
            "json",
            "--dir",
            working_dir.path().to_str().expect("working dir path"),
            "Reply with OK.",
        ]
    );
}

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

    let backend = backend_with_env(env);
    let handle = backend
        .run(request("Reply with OK.", None))
        .await
        .expect("run should start");

    let mut saw_error = false;
    let mut events = handle.events;
    while let Some(event) = events.next().await {
        if event.kind == AgentWrapperEventKind::Error {
            let message = event.message.as_deref().unwrap_or("");
            assert!(!message.contains("VERY_SECRET_SHOULD_NOT_APPEAR"));
            saw_error = true;
        }
    }

    assert!(saw_error, "expected a surfaced parse error event");

    let completion = handle.completion.await.expect("completion succeeds");
    assert!(completion.status.success());
    assert_eq!(completion.final_text.as_deref(), Some("OK"));
}

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

    let backend = backend_with_env(env);
    let handle = backend
        .run(request("Reply with OK.", None))
        .await
        .expect("run should start");

    let mut events = handle.events;
    let mut completion = handle.completion;

    let first = tokio::select! {
        biased;
        result = completion.as_mut() => panic!("completion resolved before first event: {result:?}"),
        item = events.next() => item.expect("event stream open"),
    };

    assert_eq!(first.kind, AgentWrapperEventKind::Status);

    let waker = noop_waker();
    let mut cx = Context::from_waker(&waker);
    assert!(matches!(completion.as_mut().poll(&mut cx), Poll::Pending));

    while events.next().await.is_some() {}

    let completion = completion.await.expect("completion succeeds");
    assert!(completion.status.success());
    assert_eq!(completion.final_text.as_deref(), Some("OK"));
}

#[tokio::test]
async fn opencode_backend_maps_model_and_fork_id_into_canonical_argv() {
    let capture_dir = tempfile::tempdir().expect("create capture dir");
    let capture_path = capture_dir.path().join("capture.json");
    let working_dir = tempfile::tempdir().expect("create working dir");
    let mut env = BTreeMap::new();
    env.insert(
        "FAKE_OPENCODE_SCENARIO".to_string(),
        "capture_args".to_string(),
    );
    env.insert(
        "FAKE_OPENCODE_CAPTURE".to_string(),
        capture_path.display().to_string(),
    );

    let backend = backend_with_env(env);
    let handle = backend
        .run(AgentWrapperRunRequest {
            prompt: "Reply with OK.".to_string(),
            working_dir: Some(working_dir.path().to_path_buf()),
            extensions: [
                (
                    "agent_api.config.model.v1".to_string(),
                    json!("opencode/gpt-5-nano"),
                ),
                (
                    "agent_api.session.fork.v1".to_string(),
                    json!({"selector": "id", "id": "session-123"}),
                ),
            ]
            .into_iter()
            .collect(),
            ..Default::default()
        })
        .await
        .expect("run should start");

    let mut events = handle.events;
    while events.next().await.is_some() {}

    let completion = handle.completion.await.expect("completion succeeds");
    assert!(completion.status.success());

    let capture = capture_json(&capture_path);
    let argv = capture["argv"].as_array().expect("argv array");
    let argv = argv
        .iter()
        .map(|value| value.as_str().expect("argv string"))
        .collect::<Vec<_>>();

    assert_eq!(
        argv,
        vec![
            "run",
            "--format",
            "json",
            "--model",
            "opencode/gpt-5-nano",
            "--session",
            "session-123",
            "--fork",
            "--dir",
            working_dir.path().to_str().expect("working dir path"),
            "Reply with OK.",
        ]
    );
}

#[tokio::test]
async fn opencode_backend_maps_resume_last_into_continue_flag() {
    let capture_dir = tempfile::tempdir().expect("create capture dir");
    let capture_path = capture_dir.path().join("capture.json");
    let mut env = BTreeMap::new();
    env.insert(
        "FAKE_OPENCODE_SCENARIO".to_string(),
        "capture_args".to_string(),
    );
    env.insert(
        "FAKE_OPENCODE_CAPTURE".to_string(),
        capture_path.display().to_string(),
    );

    let backend = backend_with_env(env);
    let mut run_request = request("Reply with OK.", None);
    run_request.extensions.insert(
        "agent_api.session.resume.v1".to_string(),
        json!({"selector": "last"}),
    );

    let handle = backend.run(run_request).await.expect("run should start");
    let mut events = handle.events;
    while events.next().await.is_some() {}

    let completion = handle.completion.await.expect("completion succeeds");
    assert!(completion.status.success());

    let capture = capture_json(&capture_path);
    let argv = capture["argv"].as_array().expect("argv array");
    let argv = argv
        .iter()
        .map(|value| value.as_str().expect("argv string"))
        .collect::<Vec<_>>();

    assert_eq!(
        argv,
        vec!["run", "--format", "json", "--continue", "Reply with OK.",]
    );
}

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

    let backend = backend_with_env(env);
    let mut run_request = request("Reply with OK.", None);
    run_request.extensions.insert(
        "agent_api.session.resume.v1".to_string(),
        json!({"selector": "last"}),
    );

    let handle = backend.run(run_request).await.expect("run should start");
    let mut events = handle.events;
    let first = events
        .next()
        .await
        .expect("terminal error event must surface");
    assert_eq!(first.kind, AgentWrapperEventKind::Error);
    assert_eq!(first.message.as_deref(), Some("no session found"));
    assert!(
        events.next().await.is_none(),
        "selection failure should close stream"
    );

    let error = handle
        .completion
        .await
        .expect_err("selection failure must map to backend error");
    match error {
        AgentWrapperError::Backend { message } => assert_eq!(message, "no session found"),
        other => panic!("expected Backend error, got {other:?}"),
    }
}

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

    let backend = backend_with_env(env);
    let mut run_request = request("Reply with OK.", None);
    run_request.extensions.insert(
        "agent_api.session.resume.v1".to_string(),
        json!({"selector": "id", "id": "session-123"}),
    );

    let handle = backend.run(run_request).await.expect("run should start");
    let mut events = handle.events;
    let first = events
        .next()
        .await
        .expect("terminal error event must surface");
    assert_eq!(first.kind, AgentWrapperEventKind::Error);
    assert_eq!(first.message.as_deref(), Some("session not found"));
    assert!(
        events.next().await.is_none(),
        "selection failure should close stream"
    );

    let error = handle
        .completion
        .await
        .expect_err("selection failure must map to backend error");
    match error {
        AgentWrapperError::Backend { message } => assert_eq!(message, "session not found"),
        other => panic!("expected Backend error, got {other:?}"),
    }
}

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

    let backend = backend_with_env(env);
    let handle = backend
        .run(request("Reply with OK.", None))
        .await
        .expect("run should start");

    let mut events = handle.events;
    let first = events
        .next()
        .await
        .expect("terminal error event must surface");
    assert_eq!(first.kind, AgentWrapperEventKind::Error);
    assert_eq!(first.message.as_deref(), Some("opencode run failed"));
    assert!(
        events.next().await.is_none(),
        "runtime failure should close stream"
    );

    let error = handle
        .completion
        .await
        .expect_err("runtime failure must map to backend error");
    match error {
        AgentWrapperError::Backend { message } => assert_eq!(message, "opencode run failed"),
        other => panic!("expected Backend error, got {other:?}"),
    }
}