rho-coding-agent 1.5.0

A lightweight agent harness inspired by Pi
Documentation
use std::str::FromStr;

use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;

use rho_sdk::{
    model::{ContentBlock, ModelIdentity, ModelResponse, ToolCall},
    provider::{ScriptedProvider, ScriptedTurn},
    tool::{OperationKind, ToolErrorKind, ToolInvocation},
    Rho, RunEvent, ScopedWorkspacePolicy, SessionOptions, ToolCallId, ToolCompletion, UserInput,
    Workspace,
};

use super::*;

fn call_id() -> ToolCallId {
    ToolCallId::from_str("call-1").unwrap()
}

fn invocation(args: serde_json::Value) -> ToolInvocation {
    ToolInvocation::new(call_id(), args)
}

fn workspace(dir: &TempDir) -> Workspace {
    Workspace::new(dir.path()).unwrap()
}

#[test]
fn coding_tools_register_without_granting_capabilities() {
    let mut registry = ToolRegistry::new();
    register_coding_tools(&mut registry, CodingToolOptions::default()).unwrap();

    let names = registry
        .specs()
        .into_iter()
        .map(|spec| spec.name)
        .collect::<Vec<_>>();
    assert_eq!(names, ["edit_file", "list_dir", "read_file", "write_file"]);
    assert_eq!(
        CodingToolOptions::new()
            .max_output_bytes(8_000)
            .output_budget(),
        8_000
    );
    assert_eq!(CodingToolOptions::default().output_budget(), 12_000);
}

#[tokio::test]
async fn default_context_denies_read_without_policy() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("note.txt"), "secret").unwrap();
    let tool = coding_tools(CodingToolOptions::default())
        .into_iter()
        .find(|tool| tool.spec().name == "read_file")
        .unwrap();
    let (context, _progress) = deny_context(Some(workspace(&dir)));

    let error = tool
        .call(invocation(json!({"path": "note.txt"})), context)
        .await
        .unwrap_err();

    assert_eq!(error.kind(), ToolErrorKind::PolicyDenied);
    assert!(error.message().contains("denied"));
    assert_eq!(
        std::fs::read_to_string(dir.path().join("note.txt")).unwrap(),
        "secret"
    );
}

#[tokio::test]
async fn missing_workspace_is_rejected() {
    let tool = coding_tools(CodingToolOptions::default())
        .into_iter()
        .find(|tool| tool.spec().name == "list_dir")
        .unwrap();
    let (context, _progress) = deny_context(None);

    let error = tool
        .call(invocation(json!({"path": "."})), context)
        .await
        .unwrap_err();

    assert_eq!(error.kind(), ToolErrorKind::Execution);
    assert!(error.message().contains("workspace is required"));
}

#[tokio::test]
async fn allowed_policy_reads_and_reports_metadata() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("note.txt"), "hello\nworld\n").unwrap();
    let runtime = build_runtime_with_coding_tools(
        ScriptedProvider::new(
            ModelIdentity::new("scripted", "test", "model"),
            [
                ScriptedTurn::completed(ModelResponse::Assistant(vec![ContentBlock::ToolCall(
                    ToolCall {
                        id: "call-1".into(),
                        name: "read_file".into(),
                        arguments: json!({"path": "note.txt", "offset": 1, "limit": 1}),
                    },
                )])),
                ScriptedTurn::completed(ModelResponse::Assistant(vec![ContentBlock::Text(
                    "done".into(),
                )])),
            ],
        ),
        workspace(&dir),
        ScopedWorkspacePolicy::new().allow_read_paths(),
        CodingToolOptions::default(),
    );
    let session = runtime.session(SessionOptions::default()).await.unwrap();
    let mut run = session
        .start(UserInput::text("read the note"))
        .await
        .unwrap();

    let mut read_output = None;
    while let Some(event) = run.next_event().await {
        match event {
            RunEvent::ToolFinished { result, .. } => match result {
                ToolCompletion::Success(output) => {
                    assert_eq!(output.content(), "hello\n");
                    assert_eq!(
                        output.presentation().operation_kind(),
                        Some(&OperationKind::Read)
                    );
                    assert_eq!(
                        output.presentation().affected_paths(),
                        [std::path::PathBuf::from("note.txt:1-1")]
                    );
                    read_output = Some(output);
                }
                other => panic!("unexpected tool result: {other:?}"),
            },
            RunEvent::Completed { outcome } => {
                assert_eq!(outcome.text(), "done");
                break;
            }
            _ => {}
        }
    }
    assert!(read_output.is_some());
}

#[tokio::test]
async fn allowed_policy_writes_with_diff_metadata_and_progress() {
    let dir = tempfile::tempdir().unwrap();
    let runtime = build_runtime_with_coding_tools(
        ScriptedProvider::new(
            ModelIdentity::new("scripted", "test", "model"),
            [
                ScriptedTurn::completed(ModelResponse::Assistant(vec![ContentBlock::ToolCall(
                    ToolCall {
                        id: "call-1".into(),
                        name: "write_file".into(),
                        arguments: json!({"path": "nested/out.txt", "content": "created"}),
                    },
                )])),
                ScriptedTurn::completed(ModelResponse::Assistant(vec![ContentBlock::Text(
                    "wrote".into(),
                )])),
            ],
        ),
        workspace(&dir),
        ScopedWorkspacePolicy::new()
            .allow_read_paths()
            .allow_write_paths(),
        CodingToolOptions::default(),
    );
    let session = runtime.session(SessionOptions::default()).await.unwrap();
    let mut run = session
        .start(UserInput::text("write a file"))
        .await
        .unwrap();

    let mut saw_progress = false;
    let mut write_metadata = None;
    while let Some(event) = run.next_event().await {
        match event {
            RunEvent::ToolUpdated { progress, .. } => {
                saw_progress = true;
                assert!(progress.text().contains("writing"));
                assert_eq!(
                    progress.presentation().operation_kind(),
                    Some(&OperationKind::Write)
                );
            }
            RunEvent::ToolFinished { result, .. } => match result {
                ToolCompletion::Success(output) => {
                    write_metadata = Some(output.presentation().clone());
                    assert!(output.content().contains("created"));
                    assert!(output.content().contains("+created"));
                }
                other => panic!("unexpected tool result: {other:?}"),
            },
            RunEvent::Completed { .. } => break,
            _ => {}
        }
    }

    assert!(saw_progress);
    let metadata = write_metadata.expect("write metadata");
    assert_eq!(metadata.operation_kind(), Some(&OperationKind::Write));
    assert_eq!(
        metadata.affected_paths(),
        [std::path::PathBuf::from("nested/out.txt")]
    );
    assert!(metadata.unified_diff().unwrap().contains("+created"));
    assert_eq!(
        std::fs::read_to_string(dir.path().join("nested/out.txt")).unwrap(),
        "created"
    );
}

#[tokio::test]
async fn write_only_policy_cannot_diff_existing_file_contents() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("secret.txt"), "old secret").unwrap();
    let runtime = build_runtime_with_coding_tools(
        ScriptedProvider::new(
            ModelIdentity::new("scripted", "test", "model"),
            [
                ScriptedTurn::completed(ModelResponse::Assistant(vec![ContentBlock::ToolCall(
                    ToolCall {
                        id: "call-1".into(),
                        name: "write_file".into(),
                        arguments: json!({"path": "secret.txt", "content": "new secret"}),
                    },
                )])),
                ScriptedTurn::completed(ModelResponse::Assistant(vec![ContentBlock::Text(
                    "blocked".into(),
                )])),
            ],
        ),
        workspace(&dir),
        ScopedWorkspacePolicy::new().allow_write_paths(),
        CodingToolOptions::default(),
    );
    let session = runtime.session(SessionOptions::default()).await.unwrap();
    let mut run = session
        .start(UserInput::text("overwrite a file"))
        .await
        .unwrap();

    let mut completion = None;
    while let Some(event) = run.next_event().await {
        if let RunEvent::ToolFinished { result, .. } = event {
            completion = Some(result);
        }
    }
    run.outcome().await.unwrap();

    let ToolCompletion::Failure(failure) = completion.expect("write tool result") else {
        panic!("write-only policy must deny existing-file diffs");
    };
    assert_eq!(failure.kind(), ToolErrorKind::PolicyDenied);
    assert_eq!(
        std::fs::read_to_string(dir.path().join("secret.txt")).unwrap(),
        "old secret"
    );
}

#[tokio::test]
async fn default_runtime_policy_keeps_coding_tools_inert() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("note.txt"), "safe").unwrap();
    let runtime = build_runtime_with_coding_tools(
        ScriptedProvider::new(
            ModelIdentity::new("scripted", "test", "model"),
            [
                ScriptedTurn::completed(ModelResponse::Assistant(vec![ContentBlock::ToolCall(
                    ToolCall {
                        id: "call-1".into(),
                        name: "read_file".into(),
                        arguments: json!({"path": "note.txt"}),
                    },
                )])),
                ScriptedTurn::completed(ModelResponse::Assistant(vec![ContentBlock::Text(
                    "denied".into(),
                )])),
            ],
        ),
        workspace(&dir),
        ScopedWorkspacePolicy::new(),
        CodingToolOptions::default(),
    );
    let session = runtime.session(SessionOptions::default()).await.unwrap();
    let outcome = session.complete("try to read").await.unwrap();
    assert_eq!(outcome.text(), "denied");
    assert_eq!(
        std::fs::read_to_string(dir.path().join("note.txt")).unwrap(),
        "safe"
    );
}

#[cfg(unix)]
#[tokio::test]
async fn hostile_paths_are_rejected_before_file_io() {
    use std::os::unix::fs::symlink;

    let root = tempfile::tempdir().unwrap();
    let outside = tempfile::tempdir().unwrap();
    std::fs::write(outside.path().join("secret.txt"), "unchanged").unwrap();
    symlink(outside.path(), root.path().join("escape")).unwrap();
    let tool = coding_tools(CodingToolOptions::default())
        .into_iter()
        .find(|tool| tool.spec().name == "write_file")
        .unwrap();

    for path in [
        "../secret.txt".to_string(),
        outside.path().join("secret.txt").display().to_string(),
        "escape/secret.txt".to_string(),
    ] {
        let (context, _progress) = deny_context(Some(workspace(&root)));
        let error = tool
            .call(
                invocation(json!({"path": path, "content": "overwritten"})),
                context,
            )
            .await
            .unwrap_err();
        assert_eq!(error.kind(), ToolErrorKind::PolicyDenied);
    }
    assert_eq!(
        std::fs::read_to_string(outside.path().join("secret.txt")).unwrap(),
        "unchanged"
    );
}

fn build_runtime_with_coding_tools(
    provider: ScriptedProvider,
    workspace: Workspace,
    policy: ScopedWorkspacePolicy,
    options: CodingToolOptions,
) -> Rho {
    let mut builder = Rho::builder()
        .provider(provider)
        .workspace(workspace)
        .workspace_policy(policy);
    for tool in coding_tools(options) {
        builder = builder.tool_shared(tool);
    }
    builder.build().unwrap()
}