symbi-runtime 2.0.1

Agent Runtime System for the Symbi platform
use super::*;
use crate::reasoning::conversation::{ConversationMessage, ToolCall};
use crate::reasoning::prompt_cache::CacheTtl;
use serde_json::json;
use serial_test::serial;

fn provider(kind: &str) -> CloudInferenceProvider {
    let names = [
        "ANTHROPIC_API_KEY",
        "ANTHROPIC_MODEL",
        "ANTHROPIC_BASE_URL",
        "OPENROUTER_API_KEY",
        "OPENROUTER_MODEL",
        "OPENROUTER_BASE_URL",
        "OPENAI_API_KEY",
        "OPENAI_MODEL",
        "OPENAI_BASE_URL",
        "BEDROCK_MODEL_ID",
    ];
    let previous: Vec<_> = names
        .iter()
        .map(|key| (*key, std::env::var_os(key)))
        .collect();
    for key in names {
        std::env::remove_var(key);
    }
    std::env::set_var(format!("{kind}_API_KEY"), "synthetic-cache-fixture");
    std::env::set_var(
        format!("{kind}_MODEL"),
        if kind == "OPENROUTER" {
            "anthropic/fixture"
        } else {
            "fixture"
        },
    );
    let result = CloudInferenceProvider::from_env();
    for (key, value) in previous {
        match value {
            Some(value) => std::env::set_var(key, value),
            None => std::env::remove_var(key),
        }
    }
    result.unwrap()
}

fn options(mode: PromptCache, ttl: CacheTtl) -> InferenceOptions {
    InferenceOptions::default().with_prompt_cache(PromptCacheConfig { mode, ttl })
}

#[test]
#[serial]
fn cache_controls_preserve_legacy_bodies_until_explicitly_selected() {
    let conversation = Conversation::with_system("Stable policy");
    for kind in ["ANTHROPIC", "OPENROUTER", "OPENAI"] {
        let client = provider(kind);
        let options = InferenceOptions::default();
        let legacy = if kind == "ANTHROPIC" {
            client.build_anthropic_body(&conversation, &options)
        } else {
            client.build_openai_body(&conversation, &options)
        };
        assert_eq!(
            client.build_request_body(&conversation, &options).unwrap(),
            legacy
        );
    }
}

#[test]
#[serial]
fn cache_modes_and_ttls_only_use_api_marker_positions() {
    for kind in ["ANTHROPIC", "OPENROUTER"] {
        let client = provider(kind);
        for with_system in [false, true] {
            for mode in [PromptCache::Off, PromptCache::Prefix, PromptCache::Rolling] {
                for ttl in [CacheTtl::FiveMinutes, CacheTtl::OneHour] {
                    let mut conv = if with_system {
                        Conversation::with_system("Stable policy")
                    } else {
                        Conversation::new()
                    };
                    conv.push(ConversationMessage::user("Review evidence"));
                    conv.push(ConversationMessage::assistant_tool_calls(vec![ToolCall {
                        id: "one".into(),
                        name: "lookup".into(),
                        arguments: r#"{"cache_control":"business data"}"#.into(),
                    }]));
                    conv.push(ConversationMessage::tool_result("one", "lookup", "Result"));
                    let mut opts = options(mode, ttl);
                    opts.tool_definitions.push(ToolDefinition {
                        name: "lookup".into(),
                        description: "Lookup".into(),
                        parameters: json!({"type":"object"}),
                    });
                    let body = client.build_request_body(&conv, &opts).unwrap();
                    assert!(body.get(OPTIONS_KEY).is_none());
                    let expected = (mode == PromptCache::Rolling).then(|| ttl.marker());
                    assert_eq!(body.get("cache_control"), expected.as_ref());
                    if kind == "ANTHROPIC" {
                        let prefix = if with_system {
                            &body["system"][0]
                        } else {
                            &body["tools"][0]
                        };
                        assert_eq!(
                            prefix.get("cache_control"),
                            if mode == PromptCache::Off {
                                None
                            } else {
                                Some(ttl.marker())
                            }
                            .as_ref()
                        );
                        assert_eq!(
                            body["messages"][1]["content"][0]["input"]["cache_control"],
                            "business data"
                        );
                    } else if with_system {
                        assert_eq!(
                            body["messages"][0]["content"].is_array(),
                            mode != PromptCache::Off
                        );
                    }
                }
            }
        }
    }
}

#[test]
#[serial]
fn cache_policy_rejects_overrides_and_unsupported_targets_before_dispatch() {
    let client = provider("ANTHROPIC");
    for key in ["cache_control", "system", "messages", "tools", "model"] {
        let mut opts = options(PromptCache::Off, CacheTtl::FiveMinutes);
        opts.extra.insert(key.into(), json!({"type":"ephemeral"}));
        assert!(client
            .input_token_reservation(&Conversation::new(), &opts)
            .is_err());
    }
    let client = provider("OPENROUTER");
    let mut opts = options(PromptCache::Rolling, CacheTtl::FiveMinutes);
    for model in ["another/model", "prefix/anthropic/fixture"] {
        opts.model = Some(model.into());
        assert!(client
            .build_request_body(&Conversation::new(), &opts)
            .is_err());
    }
    opts.model = Some("~anthropic/fixture".into());
    assert!(client
        .build_request_body(&Conversation::new(), &opts)
        .is_ok());
    assert!(provider("OPENAI")
        .build_request_body(
            &Conversation::new(),
            &options(PromptCache::Rolling, CacheTtl::FiveMinutes)
        )
        .is_err());
}

#[test]
#[serial]
fn prepared_cache_policy_is_idempotent_and_changes_contract_and_provider_identity() {
    let plain = provider("ANTHROPIC");
    let before = plain.configuration_identity();
    let cached = plain.with_prompt_cache(PromptCacheConfig {
        mode: PromptCache::Rolling,
        ttl: CacheTtl::FiveMinutes,
    });
    assert_ne!(before, cached.configuration_identity());
    let mut options = InferenceOptions::default();
    let original = crate::reasoning::prepared::digest_json(&json!(options)).unwrap();
    cached.prepare_options(&mut options).unwrap();
    let prepared = json!(options);
    cached.prepare_options(&mut options).unwrap();
    assert_eq!(prepared, json!(options));
    assert_ne!(
        original,
        crate::reasoning::prepared::digest_json(&json!(options)).unwrap()
    );
}

#[test]
#[serial]
fn rolling_conversation_prefix_stays_stable_and_empty_turns_get_no_explicit_marker() {
    let client = provider("ANTHROPIC");
    let opts = options(PromptCache::Rolling, CacheTtl::FiveMinutes);
    let mut conv = Conversation::with_system("Stable policy");
    conv.push(ConversationMessage::user("First turn"));
    let first = client.build_request_body(&conv, &opts).unwrap();
    conv.push(ConversationMessage::assistant("Acknowledged"));
    conv.push(ConversationMessage::user(""));
    let second = client.build_request_body(&conv, &opts).unwrap();
    assert_eq!(first["messages"][0], second["messages"][0]);
    assert!(second["messages"][2].get("cache_control").is_none());
}