From 5a1f372612d0d9fddd3bd4a8b59966841ec1e319 Mon Sep 17 00:00:00 2001 From: jaberjaber23 Date: Sun, 19 Apr 2026 17:08:11 +0300 Subject: [PATCH] command registry --- Cargo.lock | 29 +- crates/openfang-api/src/ws.rs | 29 +- crates/openfang-channels/src/bridge.rs | 32 +- crates/openfang-cli/src/tui/mod.rs | 27 +- crates/openfang-types/Cargo.toml | 1 + crates/openfang-types/src/commands.rs | 679 +++++++++++++++++++++++++ crates/openfang-types/src/lib.rs | 1 + 7 files changed, 765 insertions(+), 33 deletions(-) create mode 100644 crates/openfang-types/src/commands.rs diff --git a/Cargo.lock b/Cargo.lock index 3e51ea7d..3785cad4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3959,7 +3959,7 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.5.9" +version = "0.5.10" dependencies = [ "argon2", "async-trait", @@ -4001,7 +4001,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.5.9" +version = "0.5.10" dependencies = [ "aes", "async-trait", @@ -4040,7 +4040,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.5.9" +version = "0.5.10" dependencies = [ "clap", "clap_complete", @@ -4068,7 +4068,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.5.9" +version = "0.5.10" dependencies = [ "axum", "open", @@ -4094,7 +4094,7 @@ dependencies = [ [[package]] name = "openfang-extensions" -version = "0.5.9" +version = "0.5.10" dependencies = [ "aes-gcm", "argon2", @@ -4122,7 +4122,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.5.9" +version = "0.5.10" dependencies = [ "chrono", "dashmap", @@ -4140,7 +4140,7 @@ dependencies = [ [[package]] name = "openfang-kernel" -version = "0.5.9" +version = "0.5.10" dependencies = [ "async-trait", "chrono", @@ -4179,7 +4179,7 @@ dependencies = [ [[package]] name = "openfang-memory" -version = "0.5.9" +version = "0.5.10" dependencies = [ "async-trait", "chrono", @@ -4199,7 +4199,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.5.9" +version = "0.5.10" dependencies = [ "chrono", "dirs 6.0.0", @@ -4218,7 +4218,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.5.9" +version = "0.5.10" dependencies = [ "anyhow", "async-trait", @@ -4254,7 +4254,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.5.9" +version = "0.5.10" dependencies = [ "chrono", "hex", @@ -4277,9 +4277,10 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.5.9" +version = "0.5.10" dependencies = [ "async-trait", + "bitflags 2.11.0", "chrono", "dirs 6.0.0", "ed25519-dalek", @@ -4296,7 +4297,7 @@ dependencies = [ [[package]] name = "openfang-wire" -version = "0.5.9" +version = "0.5.10" dependencies = [ "async-trait", "chrono", @@ -9230,7 +9231,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.5.9" +version = "0.5.10" [[package]] name = "yoke" diff --git a/crates/openfang-api/src/ws.rs b/crates/openfang-api/src/ws.rs index 56c3ed4e..e57eb28d 100644 --- a/crates/openfang-api/src/ws.rs +++ b/crates/openfang-api/src/ws.rs @@ -23,6 +23,7 @@ use openfang_runtime::kernel_handle::KernelHandle; use openfang_runtime::llm_driver::StreamEvent; use openfang_runtime::llm_errors; use openfang_types::agent::AgentId; +use openfang_types::commands::{self, Surfaces}; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::net::{IpAddr, SocketAddr}; @@ -853,8 +854,17 @@ async fn handle_command( args: &str, verbose: &Arc, ) -> serde_json::Value { - match cmd { - "new" | "reset" => match state.kernel.reset_session(agent_id) { + // Canonicalise through the unified command registry. This resolves aliases + // (e.g. `reset` -> `new`) and is case-insensitive. If the command is not + // registered on the WEB surface, fall through to the existing match so any + // legacy/un-registered handlers still work byte-identically. + let canonical: &str = commands::resolve(cmd) + .filter(|def| def.surfaces.contains(Surfaces::WEB)) + .map(|def| def.name) + .unwrap_or(cmd); + + match canonical { + "new" => match state.kernel.reset_session(agent_id) { Ok(()) => { serde_json::json!({"type": "command_result", "command": cmd, "message": "Session reset. Chat history cleared."}) } @@ -1023,7 +1033,20 @@ async fn handle_command( }; serde_json::json!({"type": "command_result", "command": cmd, "message": msg}) } - _ => serde_json::json!({"type": "error", "content": format!("Unknown command: {cmd}")}), + "help" => { + serde_json::json!({ + "type": "command_result", + "command": cmd, + "message": commands::render_help(Surfaces::WEB), + }) + } + _ => serde_json::json!({ + "type": "error", + "content": format!( + "Unknown command: /{cmd}\n\n{}", + commands::render_help(Surfaces::WEB) + ), + }), } } diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index 24b668c3..522f4b67 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -14,6 +14,7 @@ use dashmap::DashMap; use futures::StreamExt; use openfang_types::agent::AgentId; use openfang_types::approval::ApprovalRequest; +use openfang_types::commands::{self as slash_commands, Surfaces}; use openfang_types::config::{ChannelOverrides, DmPolicy, GroupPolicy, OutputFormat, PrefixStyle}; use openfang_types::message::ContentBlock; use std::sync::Arc; @@ -243,6 +244,22 @@ pub trait ChannelBridgeHandle: Send + Sync { // Default: no tracking } + /// Send a plain text message to a specific recipient via a registered + /// channel adapter. + /// + /// Used by the cron multi-destination delivery engine to fan out job + /// output across channels. `channel_type` is the adapter key (e.g. + /// `"telegram"`, `"slack"`). Default implementation returns an error so + /// test doubles don't accidentally claim success. + async fn send_channel_message( + &self, + _channel_type: &str, + _recipient: &str, + _message: &str, + ) -> Result<(), String> { + Err("send_channel_message not implemented on this bridge".to_string()) + } + /// Check if auto-reply is enabled and the message should trigger one. /// Returns Some(reply_text) if auto-reply fires, None otherwise. async fn check_auto_reply(&self, _agent_id: AgentId, _message: &str) -> Option { @@ -1599,7 +1616,15 @@ async fn handle_command( router: &Arc, sender: &ChannelUser, ) -> String { - match name { + // Canonicalise through the unified command registry: aliases resolve to + // their canonical name and matching is case-insensitive. If the command + // is not registered on CHANNEL the original string is passed through so + // any legacy / channel-specific names continue to work unchanged. + let canonical: &str = slash_commands::resolve(name) + .filter(|def| def.surfaces.contains(Surfaces::CHANNEL)) + .map(|def| def.name) + .unwrap_or(name); + match canonical { "start" => { let agents = handle.list_agents().await.unwrap_or_default(); let mut msg = "Welcome to OpenFang! I connect you to AI agents.\n\nAvailable agents:\n" @@ -1829,7 +1854,10 @@ async fn handle_command( "peers" => handle.peers_text().await, "a2a" => handle.a2a_agents_text().await, - _ => format!("Unknown command: /{name}"), + _ => format!( + "Unknown command: /{name}\n\n{}", + slash_commands::render_help(Surfaces::CHANNEL) + ), } } diff --git a/crates/openfang-cli/src/tui/mod.rs b/crates/openfang-cli/src/tui/mod.rs index 78a86fde..27b7fb19 100644 --- a/crates/openfang-cli/src/tui/mod.rs +++ b/crates/openfang-cli/src/tui/mod.rs @@ -11,6 +11,7 @@ use event::{AppEvent, BackendRef}; use openfang_kernel::OpenFangKernel; use openfang_runtime::llm_driver::StreamEvent; use openfang_types::agent::AgentId; +use openfang_types::commands::{self, Surfaces}; use screens::{ agents, audit, channels, chat, comms, dashboard, extensions, hands, logs, memory, peers, security, sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows, @@ -2003,22 +2004,19 @@ impl App { fn handle_slash_command(&mut self, cmd: &str) { let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); - match parts[0] { - "/exit" | "/quit" => self.handle_chat_action(chat::ChatAction::Back), + // Canonicalise through the unified command registry: `/quit` -> `exit`, + // `/NEW` -> `new`, etc. Unregistered commands fall through unchanged so + // this refactor does not break any existing surface-specific behaviour. + let canonical_head: String = commands::resolve(parts[0]) + .filter(|def| def.surfaces.contains(Surfaces::CLI)) + .map(|def| format!("/{}", def.name)) + .unwrap_or_else(|| parts[0].to_string()); + match canonical_head.as_str() { + "/exit" => self.handle_chat_action(chat::ChatAction::Back), "/help" => { self.chat.push_message( chat::Role::System, - [ - "/help \u{2014} show this help", - "/model \u{2014} open model picker (Ctrl+M)", - "/model \u{2014} switch to model directly", - "/status \u{2014} connection & agent info", - "/agents \u{2014} list running agents", - "/clear \u{2014} clear chat history", - "/kill \u{2014} kill the current agent", - "/exit \u{2014} end chat session", - ] - .join("\n"), + commands::render_help(Surfaces::CLI), ); } "/status" => { @@ -2185,9 +2183,10 @@ impl App { } }, _ => { + let help = commands::render_help(Surfaces::CLI); self.chat.push_message( chat::Role::System, - format!("Unknown command: {}. Type /help", parts[0]), + format!("Unknown command: {}\n\n{}", parts[0], help), ); } } diff --git a/crates/openfang-types/Cargo.toml b/crates/openfang-types/Cargo.toml index 67f23ef5..0c4ba712 100644 --- a/crates/openfang-types/Cargo.toml +++ b/crates/openfang-types/Cargo.toml @@ -18,6 +18,7 @@ ed25519-dalek = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } rand = { workspace = true } +bitflags = "2" [dev-dependencies] rmp-serde = { workspace = true } diff --git a/crates/openfang-types/src/commands.rs b/crates/openfang-types/src/commands.rs new file mode 100644 index 00000000..63eb0f22 --- /dev/null +++ b/crates/openfang-types/src/commands.rs @@ -0,0 +1,679 @@ +//! Unified slash command registry. +//! +//! This module is the single source of truth for every slash command that can be +//! dispatched across CLI, channel adapters (Telegram/Slack/etc.), and the web +//! chat (WebSocket). +//! +//! Each dispatch site (there are three: `openfang-cli/src/tui/mod.rs`, +//! `openfang-channels/src/bridge.rs`, `openfang-api/src/ws.rs`) retains its own +//! handler logic. The registry is added as a front-door so command names and +//! aliases can be canonicalised once and help / autocomplete is generated from +//! a single list. +//! +//! # Example +//! +//! ``` +//! use openfang_types::commands::{self, Surfaces}; +//! +//! let def = commands::resolve("NEW").expect("new is registered"); +//! assert_eq!(def.name, "new"); +//! assert!(def.surfaces.contains(Surfaces::CHANNEL)); +//! +//! // Autocomplete +//! let matches = commands::autocomplete("ne", Surfaces::CHANNEL); +//! assert!(matches.iter().any(|m| *m == "new")); +//! +//! // Unknown commands surface the help text so dispatch sites can return a +//! // helpful error message. +//! assert!(commands::resolve("not-a-real-command").is_none()); +//! let help = commands::render_help(Surfaces::CLI); +//! assert!(help.contains("/help")); +//! ``` + +use bitflags::bitflags; +use serde::Serialize; + +/// Command category used for help grouping. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize)] +pub enum CommandCategory { + /// Greet/welcome/help commands. + General, + /// Session-level commands (reset history, compact, switch model, cancel run, etc.). + Session, + /// Model selection & provider info. + Model, + /// Memory / storage commands. + Memory, + /// Control-plane commands (kill agent, spawn, stop). + Control, + /// Read-only information (status, list agents, list skills, etc.). + Info, + /// Automation: workflows, triggers, schedules, approvals. + Automation, + /// Monitoring: budget, peers, external agents. + Monitoring, +} + +impl CommandCategory { + /// Stable human-readable label (used for help section headers). + pub fn label(self) -> &'static str { + match self { + CommandCategory::General => "General", + CommandCategory::Session => "Session", + CommandCategory::Model => "Model", + CommandCategory::Memory => "Memory", + CommandCategory::Control => "Control", + CommandCategory::Info => "Info", + CommandCategory::Automation => "Automation", + CommandCategory::Monitoring => "Monitoring", + } + } +} + +bitflags! { + /// Which surfaces a command is visible / dispatchable on. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct Surfaces: u8 { + const CLI = 0b001; + const CHANNEL = 0b010; + const WEB = 0b100; + const ALL = Self::CLI.bits() | Self::CHANNEL.bits() | Self::WEB.bits(); + } +} + +/// A single command definition. +#[derive(Clone, Copy, Debug)] +pub struct CommandDef { + /// Canonical name (no leading slash). + pub name: &'static str, + /// Zero or more aliases resolving to the same command. + pub aliases: &'static [&'static str], + /// One-line description used for help rendering. + pub description: &'static str, + /// Grouping category. + pub category: CommandCategory, + /// Surfaces where the command is visible. + pub surfaces: Surfaces, + /// Whether the command requires an active agent in the current session. + pub requires_agent: bool, +} + +/// Every slash command registered in OpenFang. +/// +/// Keep this list in sync with the three dispatch sites: +/// - `openfang-cli/src/tui/mod.rs::handle_slash_command` +/// - `openfang-channels/src/bridge.rs::handle_command` +/// - `openfang-api/src/ws.rs::handle_command` +pub const COMMAND_REGISTRY: &[CommandDef] = &[ + // ── General ──────────────────────────────────────────────────────────── + CommandDef { + name: "help", + aliases: &[], + description: "Show available commands", + category: CommandCategory::General, + surfaces: Surfaces::ALL, + requires_agent: false, + }, + CommandDef { + name: "start", + aliases: &[], + description: "Show welcome message", + category: CommandCategory::General, + surfaces: Surfaces::CHANNEL, + requires_agent: false, + }, + CommandDef { + name: "exit", + aliases: &["quit"], + description: "End chat session / disconnect from agent", + category: CommandCategory::General, + surfaces: Surfaces::CLI, + requires_agent: false, + }, + // ── Session ──────────────────────────────────────────────────────────── + CommandDef { + name: "new", + aliases: &["reset"], + description: "Reset session (clear history)", + category: CommandCategory::Session, + surfaces: Surfaces::CHANNEL.union(Surfaces::WEB), + requires_agent: true, + }, + CommandDef { + name: "clear", + aliases: &[], + description: "Clear chat display", + category: CommandCategory::Session, + surfaces: Surfaces::CLI, + requires_agent: false, + }, + CommandDef { + name: "compact", + aliases: &[], + description: "Trigger LLM session compaction", + category: CommandCategory::Session, + surfaces: Surfaces::CHANNEL.union(Surfaces::WEB), + requires_agent: true, + }, + CommandDef { + name: "stop", + aliases: &[], + description: "Cancel current agent run", + category: CommandCategory::Session, + surfaces: Surfaces::CHANNEL.union(Surfaces::WEB), + requires_agent: true, + }, + CommandDef { + name: "usage", + aliases: &[], + description: "Show session token usage and cost", + category: CommandCategory::Session, + surfaces: Surfaces::CHANNEL.union(Surfaces::WEB), + requires_agent: true, + }, + CommandDef { + name: "think", + aliases: &[], + description: "Toggle extended thinking", + category: CommandCategory::Session, + surfaces: Surfaces::CHANNEL, + requires_agent: true, + }, + CommandDef { + name: "context", + aliases: &[], + description: "Show context window usage & pressure", + category: CommandCategory::Session, + surfaces: Surfaces::WEB, + requires_agent: true, + }, + CommandDef { + name: "verbose", + aliases: &[], + description: "Cycle tool detail level (off|on|full)", + category: CommandCategory::Session, + surfaces: Surfaces::WEB, + requires_agent: false, + }, + CommandDef { + name: "queue", + aliases: &[], + description: "Check if agent is processing", + category: CommandCategory::Session, + surfaces: Surfaces::WEB, + requires_agent: true, + }, + // ── Model ────────────────────────────────────────────────────────────── + CommandDef { + name: "model", + aliases: &[], + description: "Show or switch model", + category: CommandCategory::Model, + surfaces: Surfaces::ALL, + requires_agent: true, + }, + CommandDef { + name: "models", + aliases: &[], + description: "List available AI models", + category: CommandCategory::Model, + surfaces: Surfaces::CHANNEL, + requires_agent: false, + }, + CommandDef { + name: "providers", + aliases: &[], + description: "Show configured providers", + category: CommandCategory::Model, + surfaces: Surfaces::CHANNEL, + requires_agent: false, + }, + // ── Control ──────────────────────────────────────────────────────────── + CommandDef { + name: "kill", + aliases: &[], + description: "Kill the current agent", + category: CommandCategory::Control, + surfaces: Surfaces::CLI, + requires_agent: true, + }, + // ── Info ─────────────────────────────────────────────────────────────── + CommandDef { + name: "status", + aliases: &[], + description: "Show system/connection status", + category: CommandCategory::Info, + surfaces: Surfaces::CLI.union(Surfaces::CHANNEL), + requires_agent: false, + }, + CommandDef { + name: "agents", + aliases: &[], + description: "List running agents", + category: CommandCategory::Info, + surfaces: Surfaces::CLI.union(Surfaces::CHANNEL), + requires_agent: false, + }, + CommandDef { + name: "agent", + aliases: &[], + description: "Select agent (/agent )", + category: CommandCategory::Info, + surfaces: Surfaces::CHANNEL, + requires_agent: false, + }, + CommandDef { + name: "skills", + aliases: &[], + description: "List installed skills", + category: CommandCategory::Info, + surfaces: Surfaces::CHANNEL, + requires_agent: false, + }, + CommandDef { + name: "hands", + aliases: &[], + description: "List available and active hands", + category: CommandCategory::Info, + surfaces: Surfaces::CLI.union(Surfaces::CHANNEL), + requires_agent: false, + }, + // ── Automation ───────────────────────────────────────────────────────── + CommandDef { + name: "workflows", + aliases: &[], + description: "List workflows", + category: CommandCategory::Automation, + surfaces: Surfaces::CHANNEL, + requires_agent: false, + }, + CommandDef { + name: "workflow", + aliases: &[], + description: "Run workflow (/workflow run [input])", + category: CommandCategory::Automation, + surfaces: Surfaces::CHANNEL, + requires_agent: false, + }, + CommandDef { + name: "triggers", + aliases: &[], + description: "List event triggers", + category: CommandCategory::Automation, + surfaces: Surfaces::CHANNEL, + requires_agent: false, + }, + CommandDef { + name: "trigger", + aliases: &[], + description: "Manage triggers (/trigger add|del ...)", + category: CommandCategory::Automation, + surfaces: Surfaces::CHANNEL, + requires_agent: false, + }, + CommandDef { + name: "schedules", + aliases: &[], + description: "List cron jobs", + category: CommandCategory::Automation, + surfaces: Surfaces::CHANNEL, + requires_agent: false, + }, + CommandDef { + name: "schedule", + aliases: &[], + description: "Manage schedules (/schedule add|del|run ...)", + category: CommandCategory::Automation, + surfaces: Surfaces::CHANNEL, + requires_agent: false, + }, + CommandDef { + name: "approvals", + aliases: &[], + description: "List pending approvals", + category: CommandCategory::Automation, + surfaces: Surfaces::CHANNEL, + requires_agent: false, + }, + CommandDef { + name: "approve", + aliases: &[], + description: "Approve request (/approve )", + category: CommandCategory::Automation, + surfaces: Surfaces::CHANNEL, + requires_agent: false, + }, + CommandDef { + name: "reject", + aliases: &[], + description: "Reject request (/reject )", + category: CommandCategory::Automation, + surfaces: Surfaces::CHANNEL, + requires_agent: false, + }, + // ── Monitoring ───────────────────────────────────────────────────────── + CommandDef { + name: "budget", + aliases: &[], + description: "Show spending limits and costs", + category: CommandCategory::Monitoring, + surfaces: Surfaces::CHANNEL.union(Surfaces::WEB), + requires_agent: false, + }, + CommandDef { + name: "peers", + aliases: &[], + description: "Show OFP peer network status", + category: CommandCategory::Monitoring, + surfaces: Surfaces::CHANNEL.union(Surfaces::WEB), + requires_agent: false, + }, + CommandDef { + name: "a2a", + aliases: &[], + description: "List discovered external A2A agents", + category: CommandCategory::Monitoring, + surfaces: Surfaces::CHANNEL.union(Surfaces::WEB), + requires_agent: false, + }, +]; + +/// Strip a single leading `/` if present. +#[inline] +fn strip_leading_slash(s: &str) -> &str { + s.strip_prefix('/').unwrap_or(s) +} + +/// Look up a command by its canonical name or any alias (case-insensitive). +/// +/// A leading `/` on the input is tolerated so callers do not have to strip it. +/// +/// Returns `None` if the name doesn't match any registered command. +pub fn resolve(name: &str) -> Option<&'static CommandDef> { + let needle = strip_leading_slash(name).trim(); + if needle.is_empty() { + return None; + } + COMMAND_REGISTRY.iter().find(|def| { + def.name.eq_ignore_ascii_case(needle) + || def + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(needle)) + }) +} + +/// Iterator over every command visible on the given surface(s). +pub fn list_for_surface(s: Surfaces) -> impl Iterator { + COMMAND_REGISTRY + .iter() + .filter(move |def| def.surfaces.intersects(s)) +} + +/// Render a formatted help string grouped by [`CommandCategory`]. +/// +/// The output lists every command visible on `s` alongside its description and +/// aliases. Designed to be printed verbatim into chat. +pub fn render_help(s: Surfaces) -> String { + // Stable category order for consistent help output. + const CATEGORIES: &[CommandCategory] = &[ + CommandCategory::General, + CommandCategory::Session, + CommandCategory::Model, + CommandCategory::Control, + CommandCategory::Memory, + CommandCategory::Info, + CommandCategory::Automation, + CommandCategory::Monitoring, + ]; + + let mut out = String::from("Available commands:"); + for category in CATEGORIES { + let cmds: Vec<&CommandDef> = list_for_surface(s) + .filter(|def| def.category == *category) + .collect(); + if cmds.is_empty() { + continue; + } + out.push_str("\n\n"); + out.push_str(category.label()); + out.push_str(":\n"); + for def in cmds { + out.push_str(" /"); + out.push_str(def.name); + if !def.aliases.is_empty() { + out.push_str(" (aliases: "); + for (i, a) in def.aliases.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push('/'); + out.push_str(a); + } + out.push(')'); + } + out.push_str(" — "); + out.push_str(def.description); + out.push('\n'); + } + // Trim final newline from this category block. + out.pop(); + } + out +} + +/// Return every command name or alias on `s` that starts with `prefix` +/// (case-insensitive, no leading slash). +pub fn autocomplete(prefix: &str, s: Surfaces) -> Vec<&'static str> { + let needle = strip_leading_slash(prefix).to_ascii_lowercase(); + let mut out: Vec<&'static str> = Vec::new(); + for def in list_for_surface(s) { + if def.name.to_ascii_lowercase().starts_with(&needle) { + out.push(def.name); + } + for alias in def.aliases { + if alias.to_ascii_lowercase().starts_with(&needle) { + out.push(alias); + } + } + } + out.sort_unstable(); + out.dedup(); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + /// No command name or alias may appear twice across the registry. + #[test] + fn no_duplicate_names_or_aliases() { + let mut seen: HashSet<&'static str> = HashSet::new(); + for def in COMMAND_REGISTRY { + assert!( + seen.insert(def.name), + "duplicate command name in registry: /{}", + def.name + ); + for alias in def.aliases { + assert!( + seen.insert(alias), + "duplicate command alias in registry: /{} (for /{})", + alias, + def.name + ); + } + } + } + + #[test] + fn resolve_canonical_name() { + let def = resolve("new").expect("`new` must resolve"); + assert_eq!(def.name, "new"); + } + + #[test] + fn resolve_is_case_insensitive() { + let a = resolve("new").unwrap(); + let b = resolve("NEW").unwrap(); + let c = resolve("New").unwrap(); + assert_eq!(a.name, b.name); + assert_eq!(a.name, c.name); + } + + #[test] + fn resolve_tolerates_leading_slash() { + let a = resolve("/new").unwrap(); + let b = resolve("new").unwrap(); + assert_eq!(a.name, b.name); + } + + #[test] + fn resolve_alias_hits_same_command() { + // `reset` is registered as an alias of `new`. + let via_name = resolve("new").unwrap(); + let via_alias = resolve("reset").unwrap(); + assert_eq!(via_name.name, via_alias.name); + // And `quit` is an alias of `exit`. + let via_alias = resolve("quit").unwrap(); + assert_eq!(via_alias.name, "exit"); + } + + #[test] + fn resolve_unknown_returns_none() { + assert!(resolve("definitely-not-a-command").is_none()); + assert!(resolve("").is_none()); + assert!(resolve("/").is_none()); + } + + #[test] + fn list_for_surface_channel_only_includes_channel_commands() { + for def in list_for_surface(Surfaces::CHANNEL) { + assert!( + def.surfaces.contains(Surfaces::CHANNEL), + "/{} was returned for CHANNEL but doesn't declare CHANNEL surface", + def.name + ); + } + // Sanity: `start` is channel-only, `kill` is CLI-only. + let channel_names: Vec<&'static str> = list_for_surface(Surfaces::CHANNEL) + .map(|d| d.name) + .collect(); + assert!(channel_names.contains(&"start")); + assert!(!channel_names.contains(&"kill")); + } + + #[test] + fn list_for_surface_cli_includes_cli_only_commands() { + let cli: Vec<&'static str> = list_for_surface(Surfaces::CLI).map(|d| d.name).collect(); + assert!(cli.contains(&"kill")); + assert!(cli.contains(&"clear")); + assert!(cli.contains(&"exit")); + // `start` is channel-only, should not appear on CLI. + assert!(!cli.contains(&"start")); + } + + #[test] + fn list_for_surface_web_includes_web_only_commands() { + let web: Vec<&'static str> = list_for_surface(Surfaces::WEB).map(|d| d.name).collect(); + assert!(web.contains(&"context")); + assert!(web.contains(&"verbose")); + assert!(web.contains(&"queue")); + } + + #[test] + fn render_help_mentions_every_command_on_surface() { + for surface in [Surfaces::CLI, Surfaces::CHANNEL, Surfaces::WEB] { + let help = render_help(surface); + for def in list_for_surface(surface) { + let needle = format!("/{}", def.name); + assert!( + help.contains(&needle), + "help for {:?} is missing `{}`", + surface, + needle + ); + } + } + } + + #[test] + fn render_help_groups_by_category() { + let help = render_help(Surfaces::CHANNEL); + // At minimum we expect the general + session + info sections. + assert!(help.contains("General:")); + assert!(help.contains("Session:")); + assert!(help.contains("Info:")); + } + + #[test] + fn autocomplete_prefix_matches_canonical_name() { + let matches = autocomplete("ne", Surfaces::CHANNEL); + assert!( + matches.iter().any(|m| *m == "new"), + "autocomplete(`ne`) must include `new`, got {matches:?}" + ); + } + + #[test] + fn autocomplete_prefix_matches_alias() { + let matches = autocomplete("res", Surfaces::CHANNEL); + assert!( + matches.iter().any(|m| *m == "reset"), + "autocomplete(`res`) must include `reset` (alias of new), got {matches:?}" + ); + } + + #[test] + fn autocomplete_empty_prefix_returns_all_for_surface() { + let matches = autocomplete("", Surfaces::CLI); + let cli_total = list_for_surface(Surfaces::CLI).count() + + list_for_surface(Surfaces::CLI) + .map(|d| d.aliases.len()) + .sum::(); + assert_eq!(matches.len(), cli_total); + } + + #[test] + fn autocomplete_respects_surface_filter() { + // `kill` is CLI-only; it must not appear in CHANNEL autocomplete. + let channel_matches = autocomplete("ki", Surfaces::CHANNEL); + assert!(!channel_matches.iter().any(|m| *m == "kill")); + let cli_matches = autocomplete("ki", Surfaces::CLI); + assert!(cli_matches.iter().any(|m| *m == "kill")); + } + + #[test] + fn autocomplete_tolerates_leading_slash() { + let a = autocomplete("/new", Surfaces::CHANNEL); + let b = autocomplete("new", Surfaces::CHANNEL); + assert_eq!(a, b); + } + + /// Every command must declare at least one surface. + #[test] + fn every_command_has_at_least_one_surface() { + for def in COMMAND_REGISTRY { + assert!( + !def.surfaces.is_empty(), + "/{} declares no surfaces", + def.name + ); + } + } + + /// Unknown-command error path rendering — doc-example-style integration check. + #[test] + fn unknown_command_help_rendering_is_useful() { + // Simulated "dispatch unknown command" error path. + let name = "zzz-not-a-command"; + let def = resolve(name); + assert!(def.is_none()); + let help = render_help(Surfaces::CHANNEL); + let err = format!("Unknown command: /{name}\n\n{help}"); + assert!(err.contains("Unknown command:")); + assert!(err.contains("/help")); + } +} diff --git a/crates/openfang-types/src/lib.rs b/crates/openfang-types/src/lib.rs index fbfd88fa..de9df597 100644 --- a/crates/openfang-types/src/lib.rs +++ b/crates/openfang-types/src/lib.rs @@ -6,6 +6,7 @@ pub mod agent; pub mod approval; pub mod capability; +pub mod commands; pub mod comms; pub mod config; pub mod error;