rho-coding-agent 1.4.1

A lightweight agent harness inspired by Pi
Documentation
use std::path::Path;

use tempfile::TempDir;

use super::*;

fn write_preset(root: &Path, relative: &str, contents: &str) {
    let path = root.join(relative);
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    std::fs::write(path, contents).unwrap();
}

#[test]
fn discovers_builtin_presets() {
    let root = TempDir::new().unwrap();

    let presets = discover_with_home(root.path(), None);

    let names: Vec<_> = presets.iter().map(|preset| preset.name.as_str()).collect();
    assert!(names.contains(&"explorer"));
    assert!(names.contains(&"reviewer"));
    assert!(names.contains(&"worker"));
    let explorer = presets.iter().find(|p| p.name == "explorer").unwrap();
    assert_eq!(explorer.source, PresetSource::BuiltIn);
    assert!(explorer
        .tools
        .as_ref()
        .unwrap()
        .contains(&"read_file".into()));
    assert!(!explorer.tools.as_ref().unwrap().contains(&"bash".into()));
    assert_eq!(explorer.reasoning, Some(ReasoningLevel::Low));
    assert!(!explorer.prompt.is_empty());
    let reviewer = presets.iter().find(|p| p.name == "reviewer").unwrap();
    assert_eq!(reviewer.source, PresetSource::BuiltIn);
    assert!(reviewer.tools.as_ref().unwrap().contains(&"bash".into()));
    assert_eq!(reviewer.reasoning, Some(ReasoningLevel::High));
    assert!(!reviewer.prompt.is_empty());
    let worker = presets.iter().find(|p| p.name == "worker").unwrap();
    assert_eq!(worker.tools, None);
}

#[test]
fn parses_full_frontmatter() {
    let root = TempDir::new().unwrap();
    write_preset(
        root.path(),
        ".rho/agents/reviewer.md",
        "---\ndescription: Reviews diffs\nmodel: some-model\nprovider: anthropic\nreasoning: high\ntools: [read_file, bash]\n---\nReview carefully.\n",
    );

    let presets = discover_with_home(root.path(), Some(root.path()));
    let reviewer = presets.iter().find(|p| p.name == "reviewer").unwrap();

    assert_eq!(reviewer.description, "Reviews diffs");
    assert_eq!(reviewer.model.as_deref(), Some("some-model"));
    assert_eq!(reviewer.provider.as_deref(), Some("anthropic"));
    assert_eq!(reviewer.reasoning, Some(ReasoningLevel::High));
    assert_eq!(
        reviewer.tools,
        Some(vec!["read_file".to_string(), "bash".to_string()])
    );
    assert_eq!(reviewer.prompt, "Review carefully.");
}

#[test]
fn parses_yaml_tool_sequence() {
    let root = TempDir::new().unwrap();
    write_preset(
        root.path(),
        ".rho/agents/reviewer.md",
        "---\ndescription: Reviews diffs\ntools:\n  - read_file\n  - bash\n---\nReview carefully.\n",
    );

    let presets = discover_with_home(root.path(), Some(root.path()));
    let reviewer = presets.iter().find(|p| p.name == "reviewer").unwrap();

    assert_eq!(
        reviewer.tools,
        Some(vec!["read_file".to_string(), "bash".to_string()])
    );
}

#[test]
fn parses_unindented_yaml_tool_sequence() {
    let root = TempDir::new().unwrap();
    write_preset(
        root.path(),
        ".rho/agents/reviewer.md",
        "---\ndescription: Reviews diffs\ntools:\n- read_file\n- skill\n---\nReview carefully.\n",
    );

    let presets = discover_with_home(root.path(), Some(root.path()));
    let reviewer = presets.iter().find(|p| p.name == "reviewer").unwrap();

    assert_eq!(
        reviewer.tools,
        Some(vec!["read_file".to_string(), "skill".to_string()])
    );
}

#[test]
fn user_preset_overrides_builtin() {
    let root = TempDir::new().unwrap();
    write_preset(
        root.path(),
        ".rho/agents/explorer.md",
        "---\ndescription: custom explorer\n---\nbody\n",
    );

    let presets = discover_with_home(root.path(), Some(root.path()));

    let explorers: Vec<_> = presets.iter().filter(|p| p.name == "explorer").collect();
    assert_eq!(explorers.len(), 1);
    assert_eq!(explorers[0].description, "custom explorer");
    assert!(matches!(explorers[0].source, PresetSource::File(_)));
}

#[test]
fn discovers_project_presets() {
    let home = TempDir::new().unwrap();
    let project = TempDir::new().unwrap();
    std::fs::create_dir(project.path().join(".git")).unwrap();
    write_preset(
        project.path(),
        ".agents/agents/project-agent.md",
        "---\ndescription: project scoped\n---\nbody\n",
    );

    let presets = discover_with_home(project.path(), Some(home.path()));

    assert!(presets.iter().any(|p| p.name == "project-agent"));
}

#[test]
fn rejects_preset_without_description() {
    let root = TempDir::new().unwrap();
    write_preset(
        root.path(),
        ".rho/agents/bad.md",
        "---\nmodel: x\n---\nbody\n",
    );

    let presets = discover_with_home(root.path(), Some(root.path()));

    assert!(!presets.iter().any(|p| p.name == "bad"));
}

#[test]
fn rejects_invalid_names() {
    let root = TempDir::new().unwrap();
    write_preset(
        root.path(),
        ".rho/agents/Bad--Name.md",
        "---\ndescription: d\n---\n",
    );

    let presets = discover_with_home(root.path(), Some(root.path()));

    assert!(!presets
        .iter()
        .any(|p| p.name.eq_ignore_ascii_case("bad--name")));
}

#[test]
fn parses_tool_list_variants() {
    assert_eq!(
        parse_tool_list("[read_file, \"bash\", 'skill']"),
        vec!["read_file", "bash", "skill"]
    );
    assert_eq!(
        parse_tool_list("read_file, bash"),
        vec!["read_file", "bash"]
    );
    assert_eq!(parse_tool_list("[]"), Vec::<String>::new());
}

#[test]
fn find_reports_unknown_preset() {
    let root = TempDir::new().unwrap();

    let error = find(root.path(), "nonexistent").unwrap_err();

    assert!(error.to_string().contains("unknown subagent preset"));
}

#[test]
fn status_file_roundtrip() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("nested").join("result.json");
    let status = RunStatus {
        state: RunState::Running,
        preset: Some("explorer".into()),
        turns: 3,
        input_tokens: 1000,
        output_tokens: 50,
        last_activity: Some("tool: bash".into()),
        last_text: Some("found it".into()),
        result: None,
        error: None,
        attachment_error: None,
    };

    write_status(&path, &status).unwrap();

    assert_eq!(read_status(&path), Some(status));
    let temp_files = std::fs::read_dir(path.parent().unwrap())
        .unwrap()
        .filter_map(Result::ok)
        .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
        .count();
    assert_eq!(temp_files, 0);
}

#[test]
fn read_status_tolerates_missing_and_invalid_files() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("result.json");

    assert_eq!(read_status(&path), None);

    std::fs::write(&path, "not json").unwrap();
    assert_eq!(read_status(&path), None);
}

#[test]
fn subagent_directory_rejects_invalid_ids() {
    for id in ["", "abc12", "abc1234", "../../x", "xyzxyz"] {
        assert!(directory(id).is_err(), "accepted invalid id {id:?}");
    }
}

#[cfg(unix)]
#[test]
fn subagent_artifacts_are_owner_only_and_reject_symlink_directories() {
    use std::os::unix::fs::{symlink, PermissionsExt};

    let root = TempDir::new().unwrap();
    let directory = root.path().join("run");
    create_private_directory(&directory).unwrap();
    assert_eq!(
        std::fs::metadata(&directory).unwrap().permissions().mode() & 0o777,
        0o700
    );

    let file = directory.join("events.jsonl");
    create_private_file(&file).unwrap();
    assert_eq!(
        std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
        0o600
    );

    let link = root.path().join("linked-run");
    symlink(&directory, &link).unwrap();
    assert!(secure_directory(&link).is_err());
}

#[test]
fn run_state_terminality() {
    assert!(!RunState::Starting.is_terminal());
    assert!(!RunState::Running.is_terminal());
    assert!(RunState::Ok.is_terminal());
    assert!(RunState::Error.is_terminal());
    assert!(RunState::Stopped.is_terminal());
}