capo-agent 0.7.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
//! End-to-end dispatcher tests against real subprocess fixture scripts.
//! Unix-only — Windows uses different executable bits.

#![cfg(unix)]
#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]

use std::collections::HashMap;
use std::path::PathBuf;

use capo_agent::extensions::{
    dispatcher::{
        dispatch_before_user_message, dispatch_command, dispatch_session_before_switch,
        BeforeUserMessageOutcome, CommandOutcome, HookOutcome,
    },
    manifest::{ExtensionEntry, ExtensionManifestFile},
    ExtensionRegistry,
};

fn fixture_path(name: &str) -> String {
    let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    p.push("tests");
    p.push("fixtures");
    p.push("extensions");
    p.push(name);
    p.to_string_lossy().into_owned()
}

fn entry(name: &str, fixture: &str, hooks: Vec<&str>, timeout_ms: Option<u64>) -> ExtensionEntry {
    ExtensionEntry {
        name: name.into(),
        command: fixture_path(fixture),
        args: Vec::new(),
        env: HashMap::new(),
        timeout_ms,
        hooks: hooks.into_iter().map(String::from).collect(),
        commands: Vec::new(),
    }
}

fn registry_with(entries: Vec<ExtensionEntry>) -> ExtensionRegistry {
    let mut diags = Vec::new();
    ExtensionRegistry::build(
        ExtensionManifestFile {
            extensions: entries,
        },
        &mut diags,
    )
}

#[tokio::test]
async fn always_continue_yields_continue_outcome() {
    let reg = registry_with(vec![entry(
        "alwaysok",
        "always_continue.sh",
        vec!["session_before_switch"],
        None,
    )]);
    let outcome = dispatch_session_before_switch(&reg, "new", None).await;
    assert_eq!(outcome, HookOutcome::Continue);
}

#[tokio::test]
async fn cancel_short_circuits_chain() {
    let reg = registry_with(vec![
        entry(
            "first_cancels",
            "cancel_with_reason.sh",
            vec!["session_before_switch"],
            None,
        ),
        entry(
            "second_should_not_run",
            "always_continue.sh",
            vec!["session_before_switch"],
            None,
        ),
    ]);
    let outcome = dispatch_session_before_switch(&reg, "new", None).await;
    match outcome {
        HookOutcome::Cancelled {
            extension_name,
            reason,
        } => {
            assert_eq!(extension_name, "first_cancels");
            assert_eq!(reason.as_deref(), Some("fixture cancel reason"));
        }
        other => panic!("expected Cancelled; got {other:?}"),
    }
}

#[tokio::test]
async fn timeout_falls_back_to_continue() {
    let reg = registry_with(vec![entry(
        "slow",
        "slow.sh",
        vec!["session_before_switch"],
        Some(200),
    )]);
    let outcome = dispatch_session_before_switch(&reg, "new", None).await;
    assert_eq!(outcome, HookOutcome::Continue);
}

#[tokio::test]
async fn non_zero_exit_falls_back_to_continue() {
    let reg = registry_with(vec![entry(
        "exit_one",
        "exit_one.sh",
        vec!["session_before_switch"],
        None,
    )]);
    // exit_one's stdout is empty (its echo went to stderr), so we get
    // continue via "empty stdout → continue" path.
    let outcome = dispatch_session_before_switch(&reg, "new", None).await;
    assert_eq!(outcome, HookOutcome::Continue);
}

#[tokio::test]
async fn non_zero_exit_ignores_cancel_stdout() {
    let reg = registry_with(vec![entry(
        "cancel_exit_one",
        "cancel_exit_one.sh",
        vec!["session_before_switch"],
        None,
    )]);
    let outcome = dispatch_session_before_switch(&reg, "new", None).await;
    assert_eq!(outcome, HookOutcome::Continue);
}

#[tokio::test]
async fn stdout_cancel_from_hanging_extension_falls_back_to_continue() {
    let reg = registry_with(vec![entry(
        "cancel_then_sleep",
        "cancel_then_sleep.sh",
        vec!["session_before_switch"],
        None,
    )]);
    let outcome = dispatch_session_before_switch(&reg, "new", None).await;
    assert_eq!(outcome, HookOutcome::Continue);
}

#[tokio::test]
async fn extension_env_does_not_override_parent_path() {
    let ext = ExtensionEntry {
        env: HashMap::from([("PATH".into(), "/definitely/not/a/real/path".into())]),
        ..entry(
            "parent_path_wins",
            "cancel_with_reason.sh",
            vec!["session_before_switch"],
            None,
        )
    };
    let reg = registry_with(vec![ext]);
    let outcome = dispatch_session_before_switch(&reg, "new", None).await;
    match outcome {
        HookOutcome::Cancelled { extension_name, .. } => {
            assert_eq!(extension_name, "parent_path_wins");
        }
        other => panic!("expected parent PATH to let fixture run; got {other:?}"),
    }
}

#[tokio::test]
async fn garbage_stdout_falls_back_to_continue() {
    let reg = registry_with(vec![entry(
        "garbage",
        "garbage.sh",
        vec!["session_before_switch"],
        None,
    )]);
    let outcome = dispatch_session_before_switch(&reg, "new", None).await;
    assert_eq!(outcome, HookOutcome::Continue);
}

#[tokio::test]
async fn missing_binary_falls_back_to_continue() {
    let reg = registry_with(vec![entry(
        "ghost",
        "does-not-exist-anywhere-12345.sh",
        vec!["session_before_switch"],
        None,
    )]);
    let outcome = dispatch_session_before_switch(&reg, "new", None).await;
    assert_eq!(outcome, HookOutcome::Continue);
}

#[tokio::test]
async fn no_extensions_subscribed_yields_continue() {
    let reg = registry_with(Vec::new());
    let outcome = dispatch_session_before_switch(&reg, "new", None).await;
    assert_eq!(outcome, HookOutcome::Continue);
}

#[tokio::test]
async fn before_user_message_no_subscribers_proceeds_unchanged() {
    let reg = registry_with(Vec::new());
    let outcome = dispatch_before_user_message(&reg, "hi".into(), Vec::new()).await;
    match outcome {
        BeforeUserMessageOutcome::Proceed { text, attachments } => {
            assert_eq!(text, "hi");
            assert!(attachments.is_empty());
        }
        other => panic!("expected Proceed; got {other:?}"),
    }
}

#[tokio::test]
async fn before_user_message_transform_text_chains_through_two_extensions() {
    let reg = registry_with(vec![
        entry(
            "first",
            "transform_text.sh",
            vec!["before_user_message"],
            None,
        ),
        entry(
            "second",
            "transform_text.sh",
            vec!["before_user_message"],
            None,
        ),
    ]);
    let outcome = dispatch_before_user_message(&reg, "hi".into(), Vec::new()).await;
    match outcome {
        BeforeUserMessageOutcome::Proceed { text, .. } => {
            // First extension prepends "[ext] hi" → "[ext] [ext] hi"
            assert_eq!(text, "[ext] [ext] hi");
        }
        other => panic!("expected Proceed; got {other:?}"),
    }
}

#[tokio::test]
async fn before_user_message_cancel_short_circuits() {
    let reg = registry_with(vec![
        entry(
            "cancels",
            "cancel_with_reason.sh",
            vec!["before_user_message"],
            None,
        ),
        entry(
            "should_not_run",
            "transform_text.sh",
            vec!["before_user_message"],
            None,
        ),
    ]);
    let outcome = dispatch_before_user_message(&reg, "hi".into(), Vec::new()).await;
    match outcome {
        BeforeUserMessageOutcome::Cancelled {
            extension_name,
            reason,
        } => {
            assert_eq!(extension_name, "cancels");
            assert_eq!(reason.as_deref(), Some("fixture cancel reason"));
        }
        other => panic!("expected Cancelled; got {other:?}"),
    }
}

#[tokio::test]
async fn command_unknown_command_yields_unknown_outcome() {
    let reg = registry_with(Vec::new());
    let outcome = dispatch_command(&reg, "todo", "args here").await;
    assert_eq!(outcome, CommandOutcome::Unknown);
}

#[tokio::test]
async fn command_reply_action_returns_reply_outcome() {
    let mut e = entry("todo_ext", "reply_command.sh", Vec::new(), None);
    e.commands = vec!["todo".into()];
    let reg = registry_with(vec![e]);
    let outcome = dispatch_command(&reg, "todo", "").await;
    match outcome {
        CommandOutcome::Reply { text } => assert_eq!(text, "reply from fixture"),
        other => panic!("expected Reply; got {other:?}"),
    }
}

#[tokio::test]
async fn command_send_action_returns_send_outcome() {
    let mut e = entry("todo_ext", "send_command.sh", Vec::new(), None);
    e.commands = vec!["todo".into()];
    let reg = registry_with(vec![e]);
    let outcome = dispatch_command(&reg, "todo", "").await;
    match outcome {
        CommandOutcome::Send { text, attachments } => {
            assert_eq!(text, "sent from fixture");
            assert!(attachments.is_empty());
        }
        other => panic!("expected Send; got {other:?}"),
    }
}

#[tokio::test]
async fn command_send_with_bad_attachment_returns_send_with_attachments() {
    // The dispatcher returns the attachments as-given. Validation
    // happens later when capo calls prepare_user_message — that's a
    // higher-layer integration test (see Task 9 / 10 e2e tests).
    let mut e = entry("bad_ext", "send_bad_attachment.sh", Vec::new(), None);
    e.commands = vec!["bad".into()];
    let reg = registry_with(vec![e]);
    let outcome = dispatch_command(&reg, "bad", "").await;
    match outcome {
        CommandOutcome::Send { text, attachments } => {
            assert_eq!(text, "see this");
            assert_eq!(attachments.len(), 1);
        }
        other => panic!("expected Send; got {other:?}"),
    }
}