horus 0.5.2

A small, modular Rust framework for building coding agents
Documentation
use super::*;

struct PanickingTool;

struct ApprovalRequiredTool;

struct InterruptibleTool {
    name: &'static str,
    interruptible: bool,
}

impl Tool for InterruptibleTool {
    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            name: self.name.into(),
            description: String::new(),
            parameters: serde_json::json!({}),
        }
    }

    fn interrupt_on_active_input(&self) -> bool {
        self.interruptible
    }

    fn call<'a>(
        &'a self,
        _context: ToolContext,
        _arguments: Value,
    ) -> BoxFuture<'a, Result<String>> {
        Box::pin(async { Ok(String::new()) })
    }
}

impl Tool for PanickingTool {
    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            name: "panicking".into(),
            description: String::new(),
            parameters: serde_json::json!({}),
        }
    }

    fn execution_mode(&self) -> ExecutionMode {
        ExecutionMode::Parallel
    }

    fn call<'a>(
        &'a self,
        _context: ToolContext,
        _arguments: Value,
    ) -> BoxFuture<'a, Result<String>> {
        panic!("intentional tool panic")
    }
}

impl Tool for ApprovalRequiredTool {
    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            name: "approval_required".into(),
            description: String::new(),
            parameters: serde_json::json!({}),
        }
    }

    fn approval(&self) -> ApprovalRequirement {
        ApprovalRequirement::Always
    }

    fn call<'a>(
        &'a self,
        _context: ToolContext,
        _arguments: Value,
    ) -> BoxFuture<'a, Result<String>> {
        Box::pin(async { Ok("executed".into()) })
    }
}

#[tokio::test]
async fn parallel_tool_panic_preserves_call_identity() {
    let mut catalog = Catalog::default();
    catalog
        .register(Arc::new(PanickingTool))
        .expect("register tool");
    let calls = [ToolCall {
        call_id: "call-1".into(),
        name: "panicking".into(),
        arguments: serde_json::json!({}),
    }];
    let backend =
        Arc::new(crate::backend::sandbox::local::LocalSandbox::new(".").expect("local sandbox"));
    let sandbox = Arc::new(crate::backend::sandbox::Sandbox::new(
        backend,
        crate::backend::sandbox::ApprovalPolicy::Ask,
    ));
    let permissions = SandboxPermissions::restore(
        "session",
        crate::backend::sandbox::NetworkAccess::Denied,
        Vec::new(),
    );

    assert_eq!(
        execute_batch(&catalog, &calls, sandbox, &permissions).await,
        vec![ToolResult {
            call_id: "call-1".into(),
            name: "panicking".into(),
            output: "tool panicked".into(),
            is_error: true,
        }]
    );
}

#[tokio::test]
async fn approval_required_handler_cannot_run_without_exact_call_authority() {
    let mut catalog = Catalog::default();
    catalog
        .register(Arc::new(ApprovalRequiredTool))
        .expect("register tool");
    let calls = [ToolCall {
        call_id: "blocked".into(),
        name: "approval_required".into(),
        arguments: serde_json::json!({}),
    }];
    let sandbox = Arc::new(crate::backend::sandbox::Sandbox::new(
        Arc::new(crate::backend::sandbox::local::LocalSandbox::new(".").expect("sandbox")),
        crate::backend::sandbox::ApprovalPolicy::Ask,
    ));
    let permissions = SandboxPermissions::restore(
        "session",
        crate::backend::sandbox::NetworkAccess::Allowed,
        ["different-call".into()],
    );

    let result = execute_batch(&catalog, &calls, sandbox, &permissions)
        .await
        .pop()
        .expect("tool result");

    assert!(result.is_error);
    assert_eq!(result.output, "tool call is not authorized to mutate state");
}

#[test]
fn only_wholly_interruptible_batches_stop_for_active_input() {
    let mut catalog = Catalog::default();
    for (name, interruptible) in [("wait", true), ("write", false)] {
        catalog
            .register(Arc::new(InterruptibleTool {
                name,
                interruptible,
            }))
            .expect("register tool");
    }
    let call = |name: &str| ToolCall {
        call_id: name.into(),
        name: name.into(),
        arguments: serde_json::json!({}),
    };

    assert!(catalog.interrupts_on_active_input(&[call("wait")]));
    assert!(!catalog.interrupts_on_active_input(&[call("wait"), call("write"),]));
    assert!(!catalog.interrupts_on_active_input(&[]));
}

#[tokio::test]
async fn apply_patch_returns_a_unified_diff_after_writing() {
    let workspace = tempfile::tempdir().expect("workspace");
    let path = workspace.path().join("note.txt");
    std::fs::write(&path, "first\nold\nlast\n").expect("write fixture");
    let mut catalog = Catalog::default();
    catalog
        .register(Arc::new(ApplyPatch))
        .expect("register patch tool");
    let sandbox = Arc::new(Sandbox::new(
        Arc::new(
            crate::backend::sandbox::local::LocalSandbox::new(workspace.path())
                .expect("local sandbox"),
        ),
        crate::backend::sandbox::ApprovalPolicy::Ask,
    ));
    let permissions = SandboxPermissions::restore(
        "session",
        crate::backend::sandbox::NetworkAccess::Denied,
        ["call-1".into(), "call-2".into()],
    );

    let result = execute_batch(
        &catalog,
        &[ToolCall {
            call_id: "call-1".into(),
            name: "apply_patch".into(),
            arguments: serde_json::json!({
                "path": "note.txt",
                "patch": "--- ignored.txt\n+++ ignored.txt\n@@ -1,3 +1,3 @@\n first\n-old\n+new\n last\n"
            }),
        }],
        Arc::clone(&sandbox),
        &permissions,
    )
    .await
    .pop()
    .expect("tool result");

    assert!(!result.is_error, "{}", result.output);
    let patch = diffy::Patch::from_str(&result.output).expect("unified diff");
    assert_eq!(
        (patch.original(), patch.modified()),
        (Some("note.txt"), Some("note.txt"))
    );
    assert!(result.output.contains("-old\n+new\n"));
    assert_eq!(
        diffy::apply("first\nold\nlast\n", &patch).expect("apply generated patch"),
        "first\nnew\nlast\n"
    );
    assert_eq!(
        std::fs::read_to_string(path).expect("read edited file"),
        "first\nnew\nlast\n"
    );

    let no_op = execute_batch(
        &catalog,
        &[ToolCall {
            call_id: "call-2".into(),
            name: "apply_patch".into(),
            arguments: serde_json::json!({
                "path": "note.txt",
                "patch": "--- ignored.txt\n+++ ignored.txt\n@@ -1,3 +1,3 @@\n first\n-new\n+new\n last\n"
            }),
        }],
        sandbox,
        &permissions,
    )
    .await
    .pop()
    .expect("no-op result");
    assert!(no_op.is_error);
    assert_eq!(
        std::fs::read_to_string(workspace.path().join("note.txt")).expect("read unchanged file"),
        "first\nnew\nlast\n"
    );
}

#[test]
fn apply_patch_rejects_pathological_fuzzy_matching() {
    let content = "same\n".repeat(20_000);
    let mut patch = String::from("--- ignored\n+++ ignored\n@@ -1,1000 +1,1000 @@\n");
    patch.push_str(&" same\n".repeat(999));
    patch.push_str("-same\n+changed\n");
    let patch = Patch::from_str(&patch).expect("patch");

    assert!(validate_patch_complexity(&content, &patch).is_err());
}

#[tokio::test]
async fn apply_patch_cannot_make_a_file_unreadable() {
    let workspace = tempfile::tempdir().expect("workspace");
    let path = workspace.path().join("full.txt");
    std::fs::write(&path, "x".repeat(crate::backend::sandbox::MAX_FILE_BYTES))
        .expect("write fixture");
    let sandbox = Arc::new(Sandbox::new(
        Arc::new(
            crate::backend::sandbox::local::LocalSandbox::new(workspace.path())
                .expect("local sandbox"),
        ),
        crate::backend::sandbox::ApprovalPolicy::Ask,
    ));
    let context = ToolContext {
        sandbox,
        permissions: SandboxPermissions::restore(
            "session",
            crate::backend::sandbox::NetworkAccess::Denied,
            ["patch".into()],
        )
        .for_call("patch"),
    };

    let error = ApplyPatch
        .call(
            context,
            serde_json::json!({
                "path": "full.txt",
                "patch": "--- ignored\n+++ ignored\n@@ -0,0 +1 @@\n+y\n"
            }),
        )
        .await
        .expect_err("oversized result");

    assert!(error.to_string().contains("write limit"));
    assert_eq!(
        std::fs::metadata(path).expect("metadata").len(),
        1024 * 1024
    );
}

#[test]
fn tools_do_not_claim_footer_space() {
    assert!(Tools::coding().frontend().widgets.is_empty());
}

#[test]
fn coding_renderer_preserves_patch_diff_blocks() {
    let diff = "--- a/note.txt\n+++ b/note.txt\n@@ -1 +1 @@\n-old\n+new\n";
    let block = Tools::coding()
        .render(&EventMsg::ToolCallEnd(crate::protocol::ToolCallEndEvent {
            turn_id: "turn".into(),
            call_id: "call".into(),
            name: "apply_patch".into(),
            output: diff.into(),
            is_error: false,
        }))
        .expect("patch rendering");

    assert_eq!(block.format, FrontendBlockFormat::UnifiedDiff);
    assert!(!block.append);
    assert_eq!(block.text, diff);
}

#[test]
fn coding_renderer_groups_read_lifecycle() {
    let tools = Tools::coding();
    let begin = tools
        .render(&EventMsg::ToolCallBegin(
            crate::protocol::ToolCallBeginEvent {
                turn_id: "turn".into(),
                call_id: "call".into(),
                name: "read_file".into(),
                arguments: serde_json::json!({"path": "note.txt"}),
            },
        ))
        .expect("read begin rendering");
    let end = tools
        .render(&EventMsg::ToolCallEnd(crate::protocol::ToolCallEndEvent {
            turn_id: "turn".into(),
            call_id: "call".into(),
            name: "read_file".into(),
            output: "contents".into(),
            is_error: false,
        }))
        .expect("read end rendering");

    assert_eq!(begin.group.as_deref(), Some("read:turn"));
    assert_eq!(end.group.as_deref(), Some("read:turn"));
}

#[test]
fn generic_tool_renderer_does_not_infer_coding_presentation() {
    let diff = "--- a/note.txt\n+++ b/note.txt\n@@ -1 +1 @@\n-old\n+new\n";
    let block = render_tool_event(
        &EventMsg::ToolCallEnd(crate::protocol::ToolCallEndEvent {
            turn_id: "turn".into(),
            call_id: "call".into(),
            name: "load_skill".into(),
            output: diff.into(),
            is_error: false,
        }),
        |name| name == "load_skill",
        |_, _| String::new(),
    )
    .expect("generic rendering");

    assert_eq!(block.format, FrontendBlockFormat::PlainText);
    assert_eq!(block.group, None);
    assert!(block.append);
}

#[test]
fn only_starting_a_background_command_requires_approval() {
    let mut catalog = Catalog::default();
    catalog
        .register(Arc::new(StartCommand))
        .expect("start command");
    catalog
        .register(Arc::new(PollCommand))
        .expect("poll command");
    catalog
        .register(Arc::new(StopCommand))
        .expect("stop command");

    assert!(catalog.requires_approval("start_command"));
    assert!(!catalog.requires_approval("poll_command"));
    assert!(!catalog.requires_approval("stop_command"));
}

#[test]
fn background_output_remains_valid_json_at_its_limit() {
    let rendered = background_output(BackgroundCommandPoll {
        status: crate::backend::sandbox::BackgroundCommandStatus::Running,
        exit_code: None,
        stdout: "\0".repeat(6_000),
        stderr: String::new(),
        truncated: false,
        error: Some("\0".repeat(512)),
    });

    assert!(rendered.len() <= MAX_TOOL_OUTPUT_BYTES);
    let value: Value = serde_json::from_str(&rendered).expect("valid JSON");
    assert_eq!(value["status"], "running");
}