rho-coding-agent 2.30.0

A fast Rust agent harness with a small footprint and opinionated defaults
use std::path::{Path, PathBuf};

use super::*;
use crate::workspace::ProjectTrust;

// Covers: one bad user definition must not stop discovery (and so startup),
// must report its file and field, and must not fall back to a lower-precedence
// agent with the id the user meant to override.
// Owner: agent catalog
#[test]
fn skips_invalid_user_files_and_blocks_their_ids() {
    struct Case {
        name: &'static str,
        files: &'static [(&'static str, &'static str)],
        skipped: &'static str,
        field: &'static str,
        message: &'static str,
        blocked_id: Option<&'static str>,
    }
    let cases = [
        Case {
            name: "unknown tool shadows a built-in",
            files: &[(
                "reviewer.md",
                "---\ndescription: bad\ntools: [teleport]\n---\n",
            )],
            skipped: "reviewer.md",
            field: "tools",
            message: "unknown tool 'teleport'",
            blocked_id: Some("reviewer"),
        },
        Case {
            name: "same-tier duplicates",
            files: &[
                ("one.md", "---\nid: duplicate\ndescription: one\n---\n"),
                ("two.md", "---\nid: duplicate\ndescription: two\n---\n"),
            ],
            skipped: "two.md",
            field: "id",
            message: "duplicate agent ID",
            blocked_id: Some("duplicate"),
        },
        Case {
            name: "reserved internal id",
            files: &[("session-title.md", "---\ndescription: shadow\n---\n")],
            skipped: "session-title.md",
            field: "id",
            message: "reserved for an internal agent",
            blocked_id: None,
        },
    ];

    for case in cases {
        let root = tempfile::tempdir().unwrap();
        let agents = root.path().join(".rho/agents");
        std::fs::create_dir_all(&agents).unwrap();
        for (file, contents) in case.files {
            std::fs::write(agents.join(file), contents).unwrap();
        }
        std::fs::write(agents.join("ok.md"), "---\ndescription: ok\n---\n").unwrap();

        let catalog = AgentCatalog::discover_with_home(root.path(), Some(root.path()))
            .unwrap_or_else(|error| panic!("{}: discovery failed: {error}", case.name));

        let [skipped] = catalog.skipped() else {
            panic!(
                "{}: expected one skipped file, got {:?}",
                case.name,
                catalog.skipped()
            );
        };
        assert_eq!(skipped.path, agents.join(case.skipped), "{}", case.name);
        assert_eq!(skipped.field.as_deref(), Some(case.field), "{}", case.name);
        assert!(
            skipped.message.contains(case.message),
            "{}: {}",
            case.name,
            skipped.message
        );
        assert!(
            catalog.find("ok").is_ok(),
            "{}: valid file still loads",
            case.name
        );
        if let Some(id) = case.blocked_id {
            assert_eq!(
                catalog.find(id).unwrap_err().path,
                skipped.path,
                "{}",
                case.name
            );
        }
    }
}

#[test]
fn semantic_fingerprint_ignores_formatting_and_source() {
    let a = parse_definition(
        Path::new("a.md"),
        "worker",
        "---\ndescription: work\ntools: [read_file, write]\n---\nship it\n",
    )
    .unwrap();
    let b = parse_definition(
        Path::new("elsewhere.md"),
        "worker",
        "---\nid: worker\ndescription: work\ntools:\n  - write\n  - read_file\n---\n\nship it\n",
    )
    .unwrap();
    assert_eq!(a.fingerprint(), b.fingerprint());
}

#[test]
fn edit_capability_format_names_match_edit() {
    let canonical = parse_definition(
        Path::new("a.md"),
        "worker",
        "---\ndescription: work\ntools: [edit]\n---\n",
    )
    .unwrap();
    for name in ["apply_patch", "hashline", "str_replace"] {
        let format_named = parse_definition(
            Path::new("b.md"),
            "worker",
            &format!("---\ndescription: work\ntools: [{name}]\n---\n"),
        )
        .unwrap();
        assert_eq!(
            canonical.fingerprint(),
            format_named.fingerprint(),
            "format name {name}"
        );
        assert_eq!(
            ToolCapability::parse(name.into()).as_str(),
            "edit",
            "format name {name}"
        );
    }
}

/// Golden v1 fingerprints for builtin agents, so resume keeps accepting the
/// values sessions stored before the runtime axis.
///
/// Editing a builtin definition changes its fingerprint and stops older
/// sessions for that agent from resuming. Update a value here only alongside a
/// deliberate change to the matching `builtin_agents/*.md`.
#[test]
fn golden_legacy_v1_fingerprints_for_builtin_rho_agents() {
    let root = tempfile::tempdir().unwrap();
    let catalog = AgentCatalog::discover_with_home(root.path(), None).unwrap();
    let expected = [
        (
            "default",
            "ffc3f694800c9e3d284e457e63b2a61ad97f361f84ce3493314cc9c69892826d",
        ),
        (
            "explorer",
            "b393602c7cb63eca98273b1b43640767f31c88a42abfbfe04dc387acfd68f3fc",
        ),
        (
            "reviewer",
            "e3df0b76cab91c24944c5e30547b9f4fa08bdf4d38ad31786629a04b069be460",
        ),
        (
            "worker",
            "3974c0730d03a37c063e0a9cb43496f17aab9bb152c97397b52d9425c1329079",
        ),
    ];
    for (id, expected_legacy) in expected {
        let definition = &catalog.find(id).unwrap().definition;
        assert!(
            matches!(definition.runtime, AgentRuntimeSpec::Rho { .. }),
            "{id} must remain default Rho for legacy resume"
        );
        let legacy = definition
            .legacy_v1_fingerprint()
            .unwrap_or_else(|| panic!("{id} should expose legacy v1"))
            .to_string();
        assert_eq!(legacy, expected_legacy, "legacy v1 drift for {id}");
        assert_ne!(
            definition.fingerprint().to_string(),
            legacy,
            "{id} current fingerprint must be v2"
        );
        assert!(definition.accepts_stored_fingerprint(&legacy));
    }
}

#[test]
fn real_definition_change_still_rejects_resume() {
    let original = parse_definition(
        Path::new("worker.md"),
        "worker",
        "---\ndescription: work\ntools: [read_file]\n---\nship it\n",
    )
    .unwrap();
    let changed = parse_definition(
        Path::new("worker.md"),
        "worker",
        "---\ndescription: work\ntools: [read_file, write]\n---\nship it\n",
    )
    .unwrap();
    let stored_v2 = original.fingerprint().to_string();
    let stored_v1 = original.legacy_v1_fingerprint().unwrap().to_string();
    assert!(!changed.accepts_stored_fingerprint(&stored_v2));
    assert!(!changed.accepts_stored_fingerprint(&stored_v1));
}

#[test]
fn claude_definitions_have_no_legacy_v1_fingerprint() {
    let definition = parse_definition(
        Path::new("claude.md"),
        "claude",
        "---\ndescription: demo\nruntime: claude-cli\ntools: [Read]\n---\n",
    )
    .unwrap();
    assert!(definition.legacy_v1_fingerprint().is_none());
    assert!(!definition.accepts_stored_fingerprint("anything"));
    assert!(definition.accepts_stored_fingerprint(&definition.fingerprint().to_string()));
}

#[test]
fn internal_agents_are_visible_but_not_selectable() {
    let root = tempfile::tempdir().unwrap();
    let catalog = AgentCatalog::discover_with_home(root.path(), None).unwrap();

    for id in [
        SESSION_TITLE_AGENT_ID,
        GOAL_JUDGE_AGENT_ID,
        ADVISOR_AGENT_ID,
    ] {
        assert!(catalog.find(id).is_err());
    }
    assert!(catalog
        .iter()
        .all(|entry| entry.metadata.origin != AgentOrigin::Internal));
    let internal_count = internal_definitions().len();
    let origins = catalog
        .iter_with_internal()
        .map(|entry| entry.metadata.origin)
        .collect::<Vec<_>>();
    assert!(origins[..internal_count]
        .iter()
        .all(|origin| *origin == AgentOrigin::Internal));
    assert!(origins[internal_count..]
        .iter()
        .all(|origin| *origin != AgentOrigin::Internal));
}

#[test]
fn project_definitions_require_explicit_trust() {
    let project = tempfile::tempdir().unwrap();
    let agents = project.path().join(".agents/agents");
    std::fs::create_dir_all(&agents).unwrap();
    std::fs::write(
        agents.join("project.md"),
        "---\ndescription: project agent\n---\n",
    )
    .unwrap();

    let untrusted = AgentCatalog::discover_with_home_and_trust(
        project.path(),
        None,
        ProjectTrust::Untrusted,
        &crate::plugins::PluginAgentTiers::default(),
    )
    .unwrap();
    assert!(untrusted.find("project").is_err());
    let trusted = AgentCatalog::discover_with_home_and_trust(
        project.path(),
        None,
        ProjectTrust::Trusted,
        &crate::plugins::PluginAgentTiers::default(),
    )
    .unwrap();
    assert_eq!(
        trusted.find("project").unwrap().metadata.origin,
        AgentOrigin::Project
    );
}

// Covers: workflow planning must see agents shipped beside the entry file.
// Owner: agent catalog discovery.
#[test]
fn workflow_entry_loads_local_agents_directory() {
    let root = tempfile::tempdir().unwrap();
    let workflow_dir = root.path().join("review");
    let agents = workflow_dir.join("agents");
    std::fs::create_dir_all(&agents).unwrap();
    let entry = workflow_dir.join("workflow.star");
    std::fs::write(&entry, "def build(inputs):\n    pass\n").unwrap();
    std::fs::write(
        agents.join("boundary-reviewer.md"),
        "---\ndescription: workflow-local specialist\ntools: [read_file, grep, glob, list_dir]\n---\nReview boundaries only.\n",
    )
    .unwrap();

    let without = AgentCatalog::discover_with_home_and_trust(
        root.path(),
        None,
        ProjectTrust::Untrusted,
        &crate::plugins::PluginAgentTiers::default(),
    )
    .unwrap();
    assert!(without.find("boundary-reviewer").is_err());

    let with = AgentCatalog::discover_for_workflow_entry(
        root.path(),
        &entry,
        None,
        ProjectTrust::Untrusted,
        &crate::plugins::PluginAgentTiers::default(),
    )
    .unwrap();
    let entry = with.find("boundary-reviewer").unwrap();
    assert_eq!(entry.metadata.origin, AgentOrigin::Workflow);
    assert_eq!(entry.definition.description, "workflow-local specialist");
}

// Covers: plugin agents rank above built-ins but below every user file tier,
// project plugins override user plugins, a plugin file can neither replace
// nor (when broken) block a built-in agent, and plugins cannot claim ids Rho
// uses outside the catalog.
// Owner: agent catalog
#[test]
fn plugin_agents_rank_below_user_files_and_cannot_replace_builtins() {
    let source = |path: &str, description: &str| {
        AgentSource::read(
            PathBuf::from(path),
            format!("---\ndescription: {description}\n---\n"),
        )
    };
    let catalog = AgentCatalog::from_sources(AgentCatalogSources {
        user_plugins: vec![
            source("/user-plugin/auditor.md", "user plugin"),
            source("/user-plugin/reviewer.md", "replaces a built-in"),
            AgentSource::read(
                PathBuf::from("/user-plugin/worker.md"),
                "---\ndescription: broken\ntools: [teleport]\n---\n".into(),
            ),
            // An unreadable plugin file skips only itself.
            AgentSource {
                path: PathBuf::from("/user-plugin/garbled.md"),
                contents: Err("stream did not contain valid UTF-8".into()),
            },
            source("/user-plugin/shared.md", "user plugin"),
            source("/user-plugin/side.md", "claims the /side agent"),
        ],
        project_plugins: vec![source("/project-plugin/auditor.md", "project plugin")],
        agents_home: vec![source("/agents-home/shared.md", "agents home")],
        ..AgentCatalogSources::default()
    })
    .unwrap();

    let resolved = |id: &str| {
        let metadata = &catalog.find(id).unwrap().metadata;
        (metadata.origin, metadata.path.clone())
    };
    pretty_assertions::assert_eq!(
        ["auditor", "shared", "reviewer", "worker"].map(resolved),
        [
            (
                AgentOrigin::ProjectPlugin,
                Some(PathBuf::from("/project-plugin/auditor.md"))
            ),
            (
                AgentOrigin::AgentsHome,
                Some(PathBuf::from("/agents-home/shared.md"))
            ),
            (AgentOrigin::BuiltIn, None),
            (AgentOrigin::BuiltIn, None),
        ]
    );
    let skipped: Vec<_> = catalog.skipped().iter().map(|error| &error.path).collect();
    pretty_assertions::assert_eq!(
        skipped,
        [
            Path::new("/user-plugin/reviewer.md"),
            Path::new("/user-plugin/worker.md"),
            Path::new("/user-plugin/garbled.md"),
            Path::new("/user-plugin/side.md")
        ]
    );
    assert!(catalog.find("side").is_err());
}