rho-coding-agent 2.19.0

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

use pretty_assertions::assert_eq;
use rho_providers::model::{ContentBlock, Message, ToolCall};

use super::{
    batch_cases::{parse_batches, replay_batches, EvalBatch},
    cases::Decision,
};
use crate::session::replay_points::HistorySegment;

fn call(id: &str, name: &str, arguments: serde_json::Value) -> ContentBlock {
    ContentBlock::ToolCall(ToolCall {
        id: id.into(),
        name: name.into(),
        arguments,
    })
}

/// Each member's ID, label, and call ID.
fn members(batch: &EvalBatch) -> Vec<(&str, Option<Decision>, &str)> {
    batch
        .members
        .iter()
        .map(|member| (member.id.as_str(), member.label, member.call_id.as_str()))
        .collect()
}

// Covers: a fixture batch ends its history with one assistant message holding
// every member's call, unanswered, under IDs that cannot collide with the
// history's calls.
// Owner: classifier eval batch loading.
#[test]
fn fixture_batch_ends_with_every_member_call_in_one_message() {
    let text = r#"{"id":"b","category":"contamination","history":[{"user":"status?"}],"pending":[{"id":"c1","label":"allow","kind":"process","command":"git status"},{"id":"push","label":"deny","kind":"process","command":"git push --force"}]}"#;
    let batches = parse_batches(text).unwrap();

    assert_eq!(
        batches[0].history,
        vec![
            Message::User(vec![ContentBlock::Text("status?".into())]),
            Message::Assistant(vec![
                call(
                    "pending-c1",
                    "bash",
                    serde_json::json!({"command": "git status"})
                ),
                call(
                    "pending-push",
                    "bash",
                    serde_json::json!({"command": "git push --force"})
                ),
            ]),
        ]
    );
    assert_eq!(
        members(&batches[0]),
        vec![
            ("c1", Some(Decision::Allow), "pending-c1"),
            ("push", Some(Decision::Deny), "pending-push"),
        ]
    );
}

// Covers: a malformed batch fails loading instead of running as something
// else, including an unknown field inside a member, which the flattened
// request must still reject.
// Owner: classifier eval batch loading.
#[test]
fn malformed_batches_are_rejected() {
    let member = r#"{"id":"a","label":"allow","kind":"process","command":"ls"}"#;
    let other = r#"{"id":"b","label":"allow","kind":"process","command":"pwd"}"#;
    let cases = [
        (
            "unknown member field",
            format!(
                r#"{{"id":"x","category":"c","pending":[{member},{{"id":"b","label":"allow","kind":"process","command":"pwd","timeout":1}}]}}"#
            ),
        ),
        (
            "one member",
            format!(r#"{{"id":"x","category":"c","pending":[{member}]}}"#),
        ),
        (
            "duplicate member id",
            format!(r#"{{"id":"x","category":"c","pending":[{member},{member}]}}"#),
        ),
        (
            "duplicate batch id",
            format!(
                "{{\"id\":\"x\",\"category\":\"c\",\"pending\":[{member},{other}]}}\n{{\"id\":\"x\",\"category\":\"c\",\"pending\":[{member},{other}]}}"
            ),
        ),
    ];
    let valid = format!(r#"{{"id":"x","category":"c","pending":[{member},{other}]}}"#);
    assert!(parse_batches(&valid).is_ok());
    for (name, text) in cases {
        assert!(parse_batches(&text).is_err(), "{name}");
    }
}

// Covers: the committed batch set stays loadable and labeled both ways.
// Nothing in CI runs the eval, so a broken line would only surface mid-run.
// Owner: classifier eval batch loading.
#[test]
fn committed_batches_load() {
    let batches = parse_batches(include_str!("batches.jsonl")).unwrap();
    for label in [Decision::Allow, Decision::Deny] {
        assert!(
            batches
                .iter()
                .flat_map(|batch| &batch.members)
                .any(|member| member.label == Some(label)),
            "no {label:?} members"
        );
    }
}

// Covers: replay batches only assistant messages with two or more calls that
// reach the classifier, ending each history at that message.
// Owner: classifier eval session replay.
#[test]
fn replay_batches_only_sibling_calls_that_reach_the_classifier() {
    let history = vec![
        Message::User(vec![ContentBlock::Text("check the repo".into())]),
        Message::Assistant(vec![
            call("r1", "read_file", serde_json::json!({"path": "a"})),
            call("b1", "bash", serde_json::json!({"command": "ls"})),
        ]),
        Message::Assistant(vec![
            call("b2", "bash", serde_json::json!({"command": "git status"})),
            call("r2", "read_file", serde_json::json!({"path": "b"})),
            call("b3", "bash", serde_json::json!({"command": "git log"})),
        ]),
    ];
    let segments = [HistorySegment {
        messages: history,
        new_from: 0,
    }];

    let batches = replay_batches("s", Path::new("/repo"), &segments, /*per_session*/ 5);

    let described: Vec<_> = batches
        .iter()
        .map(|batch| (batch.id.as_str(), batch.history.len(), members(batch)))
        .collect();
    assert_eq!(
        described,
        vec![("s:0.2", 3, vec![("b2", None, "b2"), ("b3", None, "b3")])]
    );
}