rho-coding-agent 1.25.0

A lightweight agent harness inspired by Pi
Documentation
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;

use super::super::{catalog::ProjectTrust, HookPipeline};
use super::*;

fn project_with_hook() -> TempDir {
    let project = TempDir::new().unwrap();
    std::fs::create_dir_all(project.path().join(".rho/hooks")).unwrap();
    std::fs::write(project.path().join(".rho/hooks/fmt"), "#!/bin/sh\n").unwrap();
    std::fs::write(
        project.path().join(".rho/hooks.toml"),
        "version = 1\n\n[[hook]]\nid = \"fmt\"\non = \"after_tool_use\"\ntools = [\"edit_file\"]\ncommand = [\"./.rho/hooks/fmt\"]\ntimeout = \"5s\"\n",
    )
    .unwrap();
    project
}

#[test]
fn a_session_without_hooks_reports_itself_disabled() {
    assert_eq!(
        serde_json::to_value(HookReport::disabled()).unwrap(),
        json!({
            "enabled": false,
            "files": [],
            "hooks": [],
            "recent_activity": [],
        })
    );
}

#[test]
fn the_contract_view_renders_argv_cwd_timeout_and_environment() {
    let project = project_with_hook();
    let catalog = HookCatalog::discover(None, Some(project.path()), ProjectTrust::Trusted).unwrap();

    let views = contract_views(&catalog);

    assert_eq!(views.len(), 1);
    assert_eq!(views[0].id, "project:fmt");
    assert_eq!(views[0].event, "after_tool_use");
    assert_eq!(views[0].tools, "edit_file");
    assert_eq!(views[0].timeout, "5s");
    assert_eq!(
        views[0].command,
        vec![crate::paths::display(
            &project.path().join(".rho/hooks/fmt")
        )]
    );
    assert!(views[0].environment.contains(&"PATH".to_owned()));
}

#[tokio::test]
async fn a_report_names_the_files_it_loaded_and_the_untrusted_file_it_skipped() {
    let project = project_with_hook();
    let untrusted =
        HookCatalog::discover(None, Some(project.path()), ProjectTrust::Untrusted).unwrap();
    assert!(untrusted.is_empty());

    let catalog = HookCatalog::discover(None, Some(project.path()), ProjectTrust::Trusted).unwrap();
    let runtime = HookPipeline::start(catalog, rho_sdk::CancellationToken::new())
        .expect("the fixture configures one hook");

    let report = HookInspector::new(&runtime).report();

    assert!(report.enabled);
    assert_eq!(
        report.files,
        vec![crate::paths::display(
            &project.path().join(".rho/hooks.toml")
        )]
    );
    assert_eq!(report.skipped_untrusted, None);
    assert_eq!(report.hooks.len(), 1);
    assert!(report.recent_activity.is_empty());
}

#[tokio::test]
async fn an_untrusted_project_file_is_named_in_the_report() {
    let project = project_with_hook();
    let home = TempDir::new().unwrap();
    std::fs::write(
        home.path().join("hooks.toml"),
        "version = 1\n\n[[hook]]\nid = \"log\"\non = \"run_completed\"\ncommand = [\"logger\"]\ntimeout = \"1s\"\n",
    )
    .unwrap();
    let catalog = HookCatalog::discover(
        Some(home.path()),
        Some(project.path()),
        ProjectTrust::Untrusted,
    )
    .unwrap();
    let runtime = HookPipeline::start(catalog, rho_sdk::CancellationToken::new()).unwrap();

    let report = HookInspector::new(&runtime).report();

    assert_eq!(
        report.skipped_untrusted,
        Some(crate::paths::display(
            &project.path().join(".rho/hooks.toml")
        ))
    );
}

#[test]
fn an_activity_view_carries_the_denial_reason() {
    let activity = HookActivity {
        hook_id: "user:no-force-push".into(),
        event: "before_tool_use",
        outcome: crate::hooks::activity::HookOutcome::Denied {
            reason: "force push".into(),
        },
        duration: Some(std::time::Duration::from_millis(7)),
        truncated: false,
    };

    assert_eq!(
        serde_json::to_value(HookActivityView::from(&activity)).unwrap(),
        json!({
            "hook": "user:no-force-push",
            "event": "before_tool_use",
            "outcome": "denied",
            "duration_ms": 7,
            "truncated": false,
            "detail": "force push",
        })
    );
}

#[test]
fn a_successful_activity_view_omits_the_absent_fields() {
    let activity = HookActivity {
        hook_id: "user:log".into(),
        event: "after_tool_use",
        outcome: crate::hooks::activity::HookOutcome::Observed,
        duration: None,
        truncated: false,
    };

    assert_eq!(
        serde_json::to_value(HookActivityView::from(&activity)).unwrap(),
        json!({
            "hook": "user:log",
            "event": "after_tool_use",
            "outcome": "observed",
            "truncated": false,
        })
    );
}

#[test]
fn a_session_with_no_hooks_files_renders_a_plain_notice() {
    assert_eq!(
        HookReport::disabled().render(),
        "no hooks files found\nno hooks are configured"
    );
}

#[test]
fn a_loaded_hook_renders_its_full_spawn_contract() {
    let report = HookReport {
        enabled: true,
        files: vec!["/home/rho/.rho/hooks.toml".into()],
        skipped_untrusted: None,
        skipped_untrusted_error: None,
        hooks: vec![HookContractView {
            active: true,
            id: "user:deny-force-push".into(),
            event: "before_tool_use".into(),
            tools: "bash".into(),
            command: vec!["/home/rho/bin/deny".into(), "--strict".into()],
            working_directory: "/work".into(),
            timeout: "2s".into(),
            environment: vec!["PATH".into(), "RHO_IN_HOOK".into()],
        }],
        recent_activity: Vec::new(),
    };

    assert_eq!(
        report.render(),
        "hooks files: /home/rho/.rho/hooks.toml\n\
         user:deny-force-push on before_tool_use (tools: bash)\n  \
         argv: /home/rho/bin/deny --strict\n  \
         cwd: /work\n  \
         timeout: 2s\n  \
         env: PATH, RHO_IN_HOOK"
    );
}

#[test]
fn an_untrusted_project_file_is_rendered_with_the_way_to_trust_it() {
    let mut report = HookReport::disabled();
    report.skipped_untrusted = Some("/work/.rho/hooks.toml".into());

    let rendered = report.render();

    assert!(rendered.contains("ignoring /work/.rho/hooks.toml"));
    assert!(rendered.contains("RHO_TRUST_PROJECT_HOOKS=1"));
}