synaps 0.1.4

Terminal-native AI agent runtime — parallel orchestration, reactive subagents, MCP, autonomous supervision
Documentation
//! Extension contract tests for manifest validation and event safety.

use std::collections::HashSet;
use std::sync::Mutex;

use serde_json::Value;
use synaps_cli::extensions::hooks::events::{HookEvent, HookKind};
use synaps_cli::extensions::manager::ExtensionManager;
use synaps_cli::extensions::manifest::HookMatcher;
use synaps_cli::extensions::permissions::{Permission, PermissionSet};

const ALL_HOOK_KINDS: [HookKind; 7] = [
    HookKind::BeforeToolCall,
    HookKind::AfterToolCall,
    HookKind::BeforeMessage,
    HookKind::OnMessageComplete,
    HookKind::OnCompaction,
    HookKind::OnSessionStart,
    HookKind::OnSessionEnd,
];

const ALL_PERMISSIONS: [Permission; 5] = [
    Permission::ToolsIntercept,
    Permission::LlmContent,
    Permission::SessionLifecycle,
    Permission::ToolsRegister,
    Permission::ProvidersRegister,
];

const RESERVED_PERMISSIONS: [Permission; 1] = [
    Permission::ToolsOverride,
];

fn extension_contract() -> Value {
    serde_json::from_str(include_str!("../docs/extensions/contract.json"))
        .expect("docs/extensions/contract.json should be valid JSON")
}

#[test]
fn contract_json_matches_rust_hook_and_permission_catalogs() {
    let contract = extension_contract();
    let hooks = contract
        .get("hooks")
        .and_then(Value::as_object)
        .expect("contract should define hooks object");
    let permissions = contract
        .get("permissions")
        .and_then(Value::as_array)
        .expect("contract should define permissions array");

    let rust_hooks: HashSet<&'static str> = ALL_HOOK_KINDS
        .iter()
        .map(HookKind::as_str)
        .collect();
    let contract_hooks: HashSet<&str> = hooks.keys().map(String::as_str).collect();
    assert_eq!(contract_hooks, rust_hooks);

    let reserved_permissions = contract
        .get("reserved_permissions")
        .and_then(Value::as_array)
        .expect("contract should define reserved_permissions array");

    for hook in ALL_HOOK_KINDS {
        assert_eq!(HookKind::from_str(hook.as_str()), Some(hook));
        let hook_contract = hooks
            .get(hook.as_str())
            .expect("hook should be present in contract");
        let contract_permission = hook_contract
            .get("permission")
            .and_then(Value::as_str)
            .expect("hook should declare required permission");
        assert_eq!(contract_permission, hook.required_permission().as_str());
        assert_eq!(
            hook_contract.get("tool_filter").and_then(Value::as_bool),
            Some(hook.allows_tool_filter())
        );
        let contract_actions: Vec<&str> = hook_contract
            .get("actions")
            .and_then(Value::as_array)
            .expect("hook should declare actions")
            .iter()
            .map(|action| action.as_str().expect("action should be a string"))
            .collect();
        assert_eq!(contract_actions, hook.allowed_action_names());
        assert!(permissions.iter().any(|permission| {
            permission.as_str() == Some(contract_permission)
        }));
    }

    let rust_permissions: HashSet<&'static str> = ALL_PERMISSIONS
        .iter()
        .map(Permission::as_str)
        .collect();
    let contract_permissions: HashSet<&str> = permissions
        .iter()
        .map(|permission| permission.as_str().expect("permission should be a string"))
        .collect();
    assert_eq!(contract_permissions, rust_permissions);

    for permission in ALL_PERMISSIONS {
        assert_eq!(Permission::parse(permission.as_str()), Some(permission));
    }

    let rust_reserved_permissions: HashSet<&'static str> = RESERVED_PERMISSIONS
        .iter()
        .map(Permission::as_str)
        .collect();
    let contract_reserved_permissions: HashSet<&str> = reserved_permissions
        .iter()
        .map(|permission| permission.as_str().expect("permission should be a string"))
        .collect();
    assert_eq!(contract_reserved_permissions, rust_reserved_permissions);

    let matchers = contract
        .get("matchers")
        .and_then(Value::as_object)
        .expect("contract should define matchers object");
    let contract_matchers: HashSet<&str> = matchers.keys().map(String::as_str).collect();
    let rust_matchers: HashSet<&'static str> = HookMatcher::SUPPORTED_KEYS.iter().copied().collect();
    assert_eq!(contract_matchers, rust_matchers);
}

#[test]
fn permission_set_rejects_unknown_permissions() {
    let result = PermissionSet::try_from_strings(&[
        "tools.intercept".to_string(),
        "tools.typo".to_string(),
    ]);

    assert!(result.is_err());
    assert!(
        result
            .unwrap_err()
            .contains("Unknown extension permission: tools.typo")
    );
}

#[test]
fn after_tool_call_truncates_utf8_safely() {
    let output = format!("{}é", "a".repeat(32 * 1024 - 1));

    let event = HookEvent::after_tool_call("bash", serde_json::json!({}), output);

    let truncated = event.tool_output.expect("after_tool_call should carry output");
    assert!(truncated.contains("[truncated"));
}

#[test]
fn on_message_complete_event_carries_assistant_content_as_message() {
    let event = HookEvent::on_message_complete("Final answer", serde_json::json!({"content_block_count": 1}));

    assert_eq!(event.kind, HookKind::OnMessageComplete);
    assert_eq!(event.message.as_deref(), Some("Final answer"));
    assert_eq!(event.data["content_block_count"], 1);
    assert!(event.tool_input.is_none());
    assert!(event.tool_output.is_none());
    assert!(event.session_id.is_none());
    assert!(event.transcript.is_none());
}

#[test]
fn on_compaction_event_carries_summary_metadata_without_transcript() {
    let event = HookEvent::on_compaction(
        "old-session",
        "new-session",
        "summary text",
        42,
        serde_json::json!({"source": "manual"}),
    );

    assert_eq!(event.kind, HookKind::OnCompaction);
    assert!(event.message.as_deref().unwrap().contains("summary text"));
    assert_eq!(event.session_id.as_deref(), Some("new-session"));
    assert_eq!(event.data["old_session_id"], "old-session");
    assert_eq!(event.data["new_session_id"], "new-session");
    assert_eq!(event.data["message_count"], 42);
    assert_eq!(event.data["source"], "manual");
    assert!(event.transcript.is_none());
    assert!(event.tool_input.is_none());
    assert!(event.tool_output.is_none());
}

#[tokio::test]
async fn manager_rejects_bad_manifest_before_spawning_process() {
    use std::sync::Arc;
    use synaps_cli::extensions::hooks::HookBus;
    use synaps_cli::extensions::manifest::{
        ExtensionManifest, ExtensionRuntime, HookSubscription,
    };

    let bus = Arc::new(HookBus::new());
    let mut manager = ExtensionManager::new(bus);
    let manifest = ExtensionManifest {
        protocol_version: 1,
        runtime: ExtensionRuntime::Process,
        command: "/definitely/not/a/real/extension-binary".to_string(),
        setup: None,
        prebuilt: ::std::collections::HashMap::new(),
        args: vec![],
        permissions: vec!["tools.typo".to_string()],
        hooks: vec![HookSubscription {
            hook: "before_tool_call".to_string(),
            tool: Some("bash".to_string()),
            matcher: None,
        }],
        config: vec![],
    };

    let err = manager.load("bad-ext", &manifest).await.unwrap_err();

    assert!(err.contains("Unknown extension permission: tools.typo"));
    assert_eq!(manager.count(), 0);
}

static BASE_DIR_TEST_LOCK: Mutex<()> = Mutex::new(());

#[tokio::test(flavor = "current_thread")]
async fn discovery_reports_malformed_extension_and_spawn_failures() {
    let _guard = BASE_DIR_TEST_LOCK.lock().unwrap();
    use std::fs;
    use std::sync::Arc;
    use synaps_cli::config;
    use synaps_cli::extensions::hooks::HookBus;

    let home = tempfile::tempdir().unwrap();
    config::set_base_dir_for_tests(home.path().to_path_buf());

    let malformed_manifest = home.path().join("plugins/malformed/.synaps-plugin/plugin.json");
    fs::create_dir_all(malformed_manifest.parent().unwrap()).unwrap();
    fs::write(
        &malformed_manifest,
        r#"{"extension":{"protocol_version":1,"runtime":"process","command":7}}"#,
    )
    .unwrap();

    let spawn_manifest = home.path().join("plugins/spawn-fail/.synaps-plugin/plugin.json");
    fs::create_dir_all(spawn_manifest.parent().unwrap()).unwrap();
    fs::write(
        &spawn_manifest,
        r#"{
  "extension": {
    "protocol_version": 1,
    "runtime": "process",
    "command": "/definitely/not/a/real/extension-binary",
    "permissions": ["tools.intercept"],
    "hooks": [{"hook": "before_tool_call"}]
  }
}"#,
    )
    .unwrap();

    let bus = Arc::new(HookBus::new());
    let mut manager = ExtensionManager::new(bus);
    let (_loaded, failed) = manager.discover_and_load().await;

    assert_eq!(failed.len(), 2);
    let malformed = failed.iter().find(|f| f.plugin == "malformed").unwrap();
    assert_eq!(malformed.manifest_path.as_deref(), Some(malformed_manifest.as_path()));
    assert!(malformed.reason.contains("Failed to parse extension manifest"));
    assert!(malformed.hint.contains("plugin validate"));

    let spawn = failed.iter().find(|f| f.plugin == "spawn-fail").unwrap();
    assert_eq!(spawn.manifest_path.as_deref(), Some(spawn_manifest.as_path()));
    assert!(spawn.reason.contains("Failed to spawn extension"));
    assert!(spawn.hint.contains("extension command is installed"));
}