unified-agent-api 0.2.3

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

use futures_util::StreamExt;

use super::support::*;

#[tokio::test]
async fn claude_runtime_model_rejection_is_safely_redacted_and_parity_is_preserved() {
    let _env_lock = test_env_lock();

    let prompt = "hello world";
    let requested_model = "request-model";
    let secret = "MODEL_RUNTIME_REJECTION_SECRET_DO_NOT_LEAK";

    let env = BTreeMap::from([
        (
            "FAKE_CLAUDE_SCENARIO".to_string(),
            "model_runtime_rejection_after_init".to_string(),
        ),
        ("FAKE_CLAUDE_EXPECT_PROMPT".to_string(), prompt.to_string()),
        (
            "FAKE_CLAUDE_EXPECT_MODEL".to_string(),
            requested_model.to_string(),
        ),
        (
            "FAKE_CLAUDE_EXPECT_NO_FALLBACK_MODEL".to_string(),
            "true".to_string(),
        ),
        (
            "FAKE_CLAUDE_MODEL_RUNTIME_REJECTION_SECRET".to_string(),
            secret.to_string(),
        ),
    ]);

    let adapter = new_adapter_with_config(ClaudeCodeBackendConfig {
        binary: Some(fake_claude_binary()),
        ..Default::default()
    });

    let spawned = adapter
        .spawn(crate::backend_harness::NormalizedRequest {
            agent_kind: adapter.kind(),
            prompt: prompt.to_string(),
            model_id: Some(requested_model.to_string()),
            working_dir: None,
            effective_timeout: None,
            env,
            policy: super::super::harness::ClaudeExecPolicy {
                non_interactive: true,
                external_sandbox: false,
                resume: None,
                fork: None,
                resolved_working_dir: None,
                add_dirs: Vec::new(),
            },
        })
        .await
        .expect("spawn succeeds");

    let (backend_events, completion) = tokio::time::timeout(Duration::from_secs(2), async move {
        let events_fut = async move {
            spawned
                .events
                .map(|result| result.expect("backend event stream is infallible for fake Claude"))
                .collect::<Vec<_>>()
                .await
        };
        let completion_fut = async move {
            spawned
                .completion
                .await
                .expect("completion is Ok for fake Claude")
        };
        tokio::join!(events_fut, completion_fut)
    })
    .await
    .expect("spawned events and completion resolve");

    let mapped_events: Vec<_> = backend_events
        .into_iter()
        .flat_map(|event| adapter.map_event(event))
        .collect();

    let error_messages: Vec<_> = mapped_events
        .iter()
        .filter(|event| event.kind == crate::AgentWrapperEventKind::Error)
        .filter_map(|event| event.message.as_deref())
        .collect();

    assert_eq!(error_messages.len(), 1, "events: {mapped_events:?}");
    assert_eq!(
        error_messages[0],
        super::super::util::PINNED_MODEL_RUNTIME_REJECTION
    );
    assert!(!error_messages[0].contains(secret));
    assert!(!error_messages[0].contains(requested_model));

    for event in &mapped_events {
        let Some(message) = event.message.as_deref() else {
            continue;
        };
        assert!(
            !message.contains(secret),
            "leaked secret in event: {event:?}"
        );
        assert!(
            !message.contains(requested_model),
            "leaked model id in event: {event:?}"
        );
    }

    let err = adapter
        .map_completion(completion)
        .expect_err("runtime rejection must map to Backend error");
    match err {
        crate::AgentWrapperError::Backend { message } => {
            assert_eq!(message, super::super::util::PINNED_MODEL_RUNTIME_REJECTION);
            assert!(!message.contains(secret));
            assert!(!message.contains(requested_model));
        }
        other => panic!("expected Backend error, got: {other:?}"),
    }
}