capo-agent 0.10.1

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]

//! Validated, indexed runtime view of loaded extensions.

use std::collections::{HashMap, HashSet};

use crate::extensions::{
    diagnostic::{DiagnosticSeverity, ExtensionDiagnostic},
    manifest::{ExtensionEntry, ExtensionManifestFile},
    wire::EventName,
};

/// Built-in slash command names that cannot be claimed by extensions.
const BUILTIN_COMMANDS: &[&str] = &[
    "help",
    "quit",
    "new",
    "compact",
    "clone",
    "model",
    "resume",
    "fork",
    "tree",
    "image",
    "extensions",
];

const DEFAULT_TIMEOUT_HOT_MS: u64 = 500;
const DEFAULT_TIMEOUT_RARE_MS: u64 = 2000;

#[derive(Debug, Clone, Default)]
pub struct ExtensionRegistry {
    pub extensions: Vec<RegisteredExtension>,
    pub command_index: HashMap<String, usize>,
    pub hook_index: HashMap<EventName, Vec<usize>>,
}

#[derive(Debug, Clone)]
pub struct RegisteredExtension {
    pub entry: ExtensionEntry,
    pub effective_timeout_ms: u64,
}

impl ExtensionRegistry {
    /// Validate `manifest` and produce an indexed registry. Diagnostics
    /// for skipped/conflicting entries are appended to `diagnostics_out`;
    /// the registry does not store its own diagnostic list (callers
    /// surface them via `/extensions`).
    pub fn build(
        manifest: ExtensionManifestFile,
        diagnostics_out: &mut Vec<ExtensionDiagnostic>,
    ) -> Self {
        let mut registry = Self::default();
        let mut seen_names: HashSet<String> = HashSet::new();

        for entry in manifest.extensions {
            if entry.name.is_empty() {
                diagnostics_out.push(ExtensionDiagnostic {
                    extension_name: "<manifest>".into(),
                    severity: DiagnosticSeverity::Error,
                    message: "extension entry with empty `name` skipped".into(),
                });
                continue;
            }

            let name = entry.name.clone();
            if entry.command.is_empty() {
                diagnostics_out.push(ExtensionDiagnostic {
                    extension_name: name.clone(),
                    severity: DiagnosticSeverity::Error,
                    message: format!("extension `{name}` has empty `command`; skipped"),
                });
                continue;
            }

            if !seen_names.insert(name.clone()) {
                diagnostics_out.push(ExtensionDiagnostic {
                    extension_name: name.clone(),
                    severity: DiagnosticSeverity::Error,
                    message: format!(
                        "duplicate extension `name = \"{name}\"`; subsequent entry skipped"
                    ),
                });
                continue;
            }

            let subscribes_to_hot = entry.hooks.iter().any(|h| h == "before_user_message");
            let effective_timeout_ms = entry.timeout_ms.unwrap_or(if subscribes_to_hot {
                DEFAULT_TIMEOUT_HOT_MS
            } else {
                DEFAULT_TIMEOUT_RARE_MS
            });

            for hook in &entry.hooks {
                if !is_known_hook(hook) {
                    diagnostics_out.push(ExtensionDiagnostic {
                        extension_name: name.clone(),
                        severity: DiagnosticSeverity::Warn,
                        message: format!(
                            "extension `{name}` subscribes to unknown hook `{hook}`; will never fire"
                        ),
                    });
                }
            }

            if entry.hooks.is_empty() && entry.commands.is_empty() {
                diagnostics_out.push(ExtensionDiagnostic {
                    extension_name: name.clone(),
                    severity: DiagnosticSeverity::Warn,
                    message: format!(
                        "extension `{name}` registers no hooks or commands; it will never spawn"
                    ),
                });
            }

            let idx = registry.extensions.len();
            for cmd in entry.commands.iter().cloned() {
                if BUILTIN_COMMANDS.contains(&cmd.as_str()) {
                    diagnostics_out.push(ExtensionDiagnostic {
                        extension_name: name.clone(),
                        severity: DiagnosticSeverity::Error,
                        message: format!(
                            "extension `{name}` cannot claim built-in command `/{cmd}`; skipped"
                        ),
                    });
                    continue;
                }
                if registry.command_index.contains_key(&cmd) {
                    diagnostics_out.push(ExtensionDiagnostic {
                        extension_name: name.clone(),
                        severity: DiagnosticSeverity::Error,
                        message: format!(
                            "extension `{name}` cannot claim command `/{cmd}`; already owned by an earlier extension"
                        ),
                    });
                    continue;
                }
                registry.command_index.insert(cmd, idx);
            }

            for hook in &entry.hooks {
                if let Some(event) = parse_known_hook(hook) {
                    registry.hook_index.entry(event).or_default().push(idx);
                }
            }

            registry.extensions.push(RegisteredExtension {
                entry,
                effective_timeout_ms,
            });
        }

        registry
    }
}

fn is_known_hook(name: &str) -> bool {
    parse_known_hook(name).is_some()
}

fn parse_known_hook(name: &str) -> Option<EventName> {
    match name {
        "session_before_switch" => Some(EventName::SessionBeforeSwitch),
        "before_user_message" => Some(EventName::BeforeUserMessage),
        "command" => Some(EventName::Command),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::extensions::{
        diagnostic::DiagnosticSeverity, manifest::ExtensionManifestFile, ExtensionEntry,
    };
    use pretty_assertions::assert_eq;

    fn entry(name: &str, command: &str) -> ExtensionEntry {
        ExtensionEntry {
            name: name.into(),
            command: command.into(),
            args: Vec::new(),
            env: std::collections::HashMap::new(),
            timeout_ms: None,
            hooks: Vec::new(),
            commands: Vec::new(),
        }
    }

    fn manifest_of(entries: Vec<ExtensionEntry>) -> ExtensionManifestFile {
        ExtensionManifestFile {
            extensions: entries,
        }
    }

    #[test]
    fn empty_manifest_produces_empty_registry() {
        let mut diags = Vec::new();
        let reg = ExtensionRegistry::build(manifest_of(Vec::new()), &mut diags);
        assert!(reg.extensions.is_empty());
        assert!(reg.command_index.is_empty());
        assert!(reg.hook_index.is_empty());
        assert!(diags.is_empty());
    }

    #[test]
    fn entry_with_empty_name_is_skipped() {
        let mut diags = Vec::new();
        let e = entry("", "/bin/x");
        let reg = ExtensionRegistry::build(manifest_of(vec![e]), &mut diags);
        assert!(reg.extensions.is_empty());
        assert_eq!(diags.len(), 1);
        assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
        assert!(diags[0].message.contains("empty"));
    }

    #[test]
    fn entry_with_empty_command_is_skipped() {
        let mut diags = Vec::new();
        let e = entry("foo", "");
        let reg = ExtensionRegistry::build(manifest_of(vec![e]), &mut diags);
        assert!(reg.extensions.is_empty());
        assert_eq!(diags.len(), 1);
    }

    #[test]
    fn duplicate_name_skips_subsequent_entries() {
        let mut diags = Vec::new();
        let first = ExtensionEntry {
            hooks: vec!["session_before_switch".into()],
            ..entry("foo", "/bin/a")
        };
        let second = ExtensionEntry {
            hooks: vec!["session_before_switch".into()],
            ..entry("foo", "/bin/b")
        };
        let reg = ExtensionRegistry::build(manifest_of(vec![first, second]), &mut diags);
        assert_eq!(reg.extensions.len(), 1);
        assert_eq!(reg.extensions[0].entry.command, "/bin/a");
        assert_eq!(diags.len(), 1);
        assert!(diags[0].message.contains("duplicate"));
    }

    #[test]
    fn duplicate_commands_skip_subsequent_registrations() {
        let mut diags = Vec::new();
        let a = ExtensionEntry {
            commands: vec!["todo".into()],
            ..entry("a", "/bin/a")
        };
        let b = ExtensionEntry {
            commands: vec!["todo".into()],
            ..entry("b", "/bin/b")
        };
        let reg = ExtensionRegistry::build(manifest_of(vec![a, b]), &mut diags);
        assert_eq!(reg.extensions.len(), 2);
        assert_eq!(reg.command_index.get("todo"), Some(&0));
        assert!(diags.iter().any(|d| d.message.contains("todo")));
    }

    #[test]
    fn commands_colliding_with_builtin_skipped() {
        for builtin in [
            "help",
            "quit",
            "new",
            "compact",
            "clone",
            "model",
            "resume",
            "fork",
            "tree",
            "image",
            "extensions",
        ] {
            let mut diags = Vec::new();
            let e = ExtensionEntry {
                commands: vec![builtin.into()],
                ..entry("ext", "/bin/x")
            };
            let reg = ExtensionRegistry::build(manifest_of(vec![e]), &mut diags);
            assert_eq!(reg.extensions.len(), 1);
            assert!(
                reg.command_index.is_empty(),
                "builtin {builtin} must not be claimed"
            );
            assert!(diags.iter().any(|d| d.message.contains(builtin)));
        }
    }

    #[test]
    fn unknown_hook_name_warns_but_extension_still_loads() {
        let mut diags = Vec::new();
        let e = ExtensionEntry {
            hooks: vec!["before_user_message".into(), "future_event_v2".into()],
            ..entry("ext", "/bin/x")
        };
        let reg = ExtensionRegistry::build(manifest_of(vec![e]), &mut diags);
        assert_eq!(reg.extensions.len(), 1);
        assert!(diags.iter().any(|d| {
            d.severity == DiagnosticSeverity::Warn && d.message.contains("future_event_v2")
        }));
    }

    #[test]
    fn inert_entry_no_hooks_no_commands_warns_but_loads() {
        let mut diags = Vec::new();
        let e = entry("inert", "/bin/x");
        let reg = ExtensionRegistry::build(manifest_of(vec![e]), &mut diags);
        assert_eq!(reg.extensions.len(), 1);
        assert!(diags
            .iter()
            .any(|d| d.message.contains("no hooks or commands")));
    }

    #[test]
    fn hook_index_built_correctly() {
        let mut diags = Vec::new();
        let a = ExtensionEntry {
            hooks: vec!["session_before_switch".into()],
            ..entry("a", "/bin/a")
        };
        let b = ExtensionEntry {
            hooks: vec!["session_before_switch".into(), "before_user_message".into()],
            ..entry("b", "/bin/b")
        };
        let reg = ExtensionRegistry::build(manifest_of(vec![a, b]), &mut diags);
        assert_eq!(
            reg.hook_index
                .get(&crate::extensions::EventName::SessionBeforeSwitch),
            Some(&vec![0, 1])
        );
        assert_eq!(
            reg.hook_index
                .get(&crate::extensions::EventName::BeforeUserMessage),
            Some(&vec![1])
        );
    }

    #[test]
    fn effective_timeout_defaults_per_hook_class() {
        let mut diags = Vec::new();

        let hot = ExtensionEntry {
            hooks: vec!["before_user_message".into()],
            ..entry("hot", "/bin/hot")
        };

        let rare = ExtensionEntry {
            hooks: vec!["session_before_switch".into()],
            ..entry("rare", "/bin/rare")
        };

        let overridden = ExtensionEntry {
            hooks: vec!["before_user_message".into()],
            timeout_ms: Some(1500),
            ..entry("override", "/bin/o")
        };

        let reg = ExtensionRegistry::build(manifest_of(vec![hot, rare, overridden]), &mut diags);
        assert_eq!(reg.extensions[0].effective_timeout_ms, 500);
        assert_eq!(reg.extensions[1].effective_timeout_ms, 2000);
        assert_eq!(reg.extensions[2].effective_timeout_ms, 1500);
    }
}