rho-coding-agent 1.19.2

A lightweight agent harness inspired by Pi
Documentation
use std::collections::BTreeMap;

use pretty_assertions::assert_eq;

use super::{AliasTarget, ModelAliasResolutionError, ModelAliases, ResolvedModelReference};

fn entries(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
    pairs
        .iter()
        .map(|(name, value)| (name.to_string(), value.to_string()))
        .collect()
}

#[test]
fn parses_provider_qualified_bare_and_slash_qualified_targets() {
    let aliases = ModelAliases::from_entries(entries(&[
        ("deep", "anthropic/claude-opus-4-8"),
        ("fast", "gpt-5.5"),
        ("open", "openrouter/anthropic/claude-sonnet-4"),
    ]))
    .unwrap();

    assert_eq!(
        aliases.get("deep"),
        Some(&AliasTarget {
            provider: Some("anthropic".into()),
            model: "claude-opus-4-8".into(),
        })
    );
    assert_eq!(
        aliases.get("fast"),
        Some(&AliasTarget {
            provider: None,
            model: "gpt-5.5".into(),
        })
    );
    assert_eq!(
        aliases.get("open"),
        Some(&AliasTarget {
            provider: Some("openrouter".into()),
            model: "anthropic/claude-sonnet-4".into(),
        })
    );
    assert_eq!(aliases.get("missing"), None);
}

#[test]
fn resolves_explicit_aliases_with_provenance() {
    let aliases = ModelAliases::from_entries(entries(&[
        ("deep", "anthropic/claude-opus-4-8"),
        ("fast", "gpt-5.5"),
    ]))
    .unwrap();

    assert_eq!(
        aliases.resolve("@deep"),
        Ok(ResolvedModelReference {
            alias: Some("deep".into()),
            provider: Some("anthropic".into()),
            model: "claude-opus-4-8".into(),
        })
    );
    assert_eq!(
        aliases.resolve("@fast"),
        Ok(ResolvedModelReference {
            alias: Some("fast".into()),
            provider: None,
            model: "gpt-5.5".into(),
        })
    );
}

#[test]
fn undefined_explicit_alias_returns_typed_actionable_error() {
    let aliases = ModelAliases::default();
    let error = aliases.resolve("@missing").unwrap_err();

    assert_eq!(
        error,
        ModelAliasResolutionError::UndefinedAlias {
            name: "missing".into(),
        }
    );
    assert_eq!(
        error.to_string(),
        "model alias '@missing' is not defined; define it in [model.aliases] or use a concrete model reference"
    );
}

#[test]
fn ordinary_model_reference_is_concrete_even_when_it_shadows_an_alias() {
    let aliases =
        ModelAliases::from_entries(entries(&[("deep", "anthropic/claude-opus-4-8")])).unwrap();

    assert_eq!(
        aliases.resolve("deep"),
        Ok(ResolvedModelReference {
            alias: None,
            provider: None,
            model: "deep".into(),
        })
    );
}

#[test]
fn ordinary_provider_qualified_reference_remains_concrete_for_the_caller() {
    let aliases = ModelAliases::default();

    assert_eq!(
        aliases.resolve("openrouter/anthropic/claude-sonnet-4"),
        Ok(ResolvedModelReference {
            alias: None,
            provider: None,
            model: "openrouter/anthropic/claude-sonnet-4".into(),
        })
    );
}

#[test]
fn concrete_targets_may_share_alias_names_without_becoming_chains() {
    let aliases = ModelAliases::from_entries(entries(&[
        ("deep", "anthropic/claude-opus-4-8"),
        ("deeper", "deep"),
        ("qualified", "openrouter/deep"),
    ]))
    .unwrap();

    assert_eq!(
        aliases.resolve("@deeper"),
        Ok(ResolvedModelReference {
            alias: Some("deeper".into()),
            provider: None,
            model: "deep".into(),
        })
    );
    assert_eq!(
        aliases.resolve("@qualified"),
        Ok(ResolvedModelReference {
            alias: Some("qualified".into()),
            provider: Some("openrouter".into()),
            model: "deep".into(),
        })
    );
}

#[test]
fn rejects_malformed_names_and_values() {
    for (name, value) in [
        ("bad name", "gpt-5.5"),
        ("bad/name", "gpt-5.5"),
        ("bad@name", "gpt-5.5"),
        ("", "gpt-5.5"),
        ("deep", ""),
        ("deep", "anthropic/"),
        ("deep", "/claude-opus-4-8"),
        ("deep", "@other"),
        ("deep", "claude opus"),
    ] {
        let error = ModelAliases::from_entries(entries(&[(name, value)])).unwrap_err();
        assert!(error.contains("model alias"), "{name}={value}: {error}");
    }
}

#[test]
fn alias_values_must_not_use_alias_reference_syntax() {
    let error = ModelAliases::from_entries(entries(&[("deep", "@other")])).unwrap_err();

    assert_eq!(
        error,
        "invalid model alias value '@other': expected a concrete 'provider/model' or 'model' with no whitespace"
    );
}

#[test]
fn serialization_preserves_concrete_targets_in_a_round_trip() {
    let aliases = ModelAliases::from_entries(entries(&[
        ("deep", "anthropic/claude-opus-4-8"),
        ("fast", "gpt-5.5"),
        ("open", "openrouter/anthropic/claude-sonnet-4"),
    ]))
    .unwrap();

    let toml = toml::to_string(&aliases).unwrap();
    assert_eq!(
        toml,
        "deep = \"anthropic/claude-opus-4-8\"\nfast = \"gpt-5.5\"\nopen = \"openrouter/anthropic/claude-sonnet-4\"\n"
    );
    let round_tripped: ModelAliases = toml::from_str(&toml).unwrap();
    assert_eq!(round_tripped, aliases);
}

#[test]
fn deserialization_rejects_alias_reference_values() {
    let error = toml::from_str::<ModelAliases>("deep = \"@other\"\n").unwrap_err();

    assert!(
        error
            .to_string()
            .contains("invalid model alias value '@other'"),
        "{error}"
    );
}