Skip to main content

oxi/
extensions.rs

1//! Extension system for oxi
2//!
3//! Extensions allow custom tools, commands, and event hooks to be loaded
4//! dynamically at runtime. Extensions can be loaded from shared libraries
5//! (.so/.dll/.dylib) via the `-e`/`--extension` CLI flag.
6//!
7//! # Architecture
8//!
9//! The extension system is modeled after pi-mono's extension API and provides:
10//!
11//! - **Extension manifest** — metadata, permissions, configuration schema
12//! - **Extension lifecycle hooks** — `on_load`, `on_unload`, message/tool/session events
13//! - **Extension context** — access to settings, session state, tool registration, messaging
14//! - **Extension error handling** — graceful degradation with logging
15//! - **Extension registry** — name-based lookup, enable/disable, hot-reload
16
17use anyhow::{bail, Context, Result};
18use libloading::{Library, Symbol};
19use oxi_agent::{AgentEvent, AgentTool, AgentToolResult};
20use parking_lot::RwLock;
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23use std::collections::HashMap;
24use std::ffi::OsStr;
25use std::fmt;
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28
29// ═══════════════════════════════════════════════════════════════════════════
30// Extension Permissions
31// ═══════════════════════════════════════════════════════════════════════════
32
33/// Permissions that an extension may request.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum ExtensionPermission {
37    /// Read files from the filesystem
38    FileRead,
39    /// Write/modify files on the filesystem
40    FileWrite,
41    /// Execute shell commands via bash
42    Bash,
43    /// Make network requests
44    Network,
45}
46
47impl fmt::Display for ExtensionPermission {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            ExtensionPermission::FileRead => write!(f, "file_read"),
51            ExtensionPermission::FileWrite => write!(f, "file_write"),
52            ExtensionPermission::Bash => write!(f, "bash"),
53            ExtensionPermission::Network => write!(f, "network"),
54        }
55    }
56}
57
58// ═══════════════════════════════════════════════════════════════════════════
59// Extension Manifest
60// ═══════════════════════════════════════════════════════════════════════════
61
62/// Metadata describing an extension.
63///
64/// Every extension must provide a manifest (either statically via the trait
65/// or loaded from a manifest file alongside the shared library).
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ExtensionManifest {
68    /// Unique extension name (e.g. "my-deploy-tool")
69    pub name: String,
70    /// Semantic version string (e.g. "1.2.0")
71    pub version: String,
72    /// Human-readable description
73    #[serde(default)]
74    pub description: String,
75    /// Author / maintainer
76    #[serde(default)]
77    pub author: String,
78    /// Permissions required by this extension
79    #[serde(default)]
80    pub permissions: Vec<ExtensionPermission>,
81    /// Optional JSON Schema for extension-specific configuration
82    #[serde(default)]
83    pub config_schema: Option<Value>,
84}
85
86impl ExtensionManifest {
87    /// Create a minimal manifest with just a name and version.
88    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
89        Self {
90            name: name.into(),
91            version: version.into(),
92            description: String::new(),
93            author: String::new(),
94            permissions: Vec::new(),
95            config_schema: None,
96        }
97    }
98
99    /// Builder: set description.
100    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
101        self.description = desc.into();
102        self
103    }
104
105    /// Builder: set author.
106    pub fn with_author(mut self, author: impl Into<String>) -> Self {
107        self.author = author.into();
108        self
109    }
110
111    /// Builder: add a permission.
112    pub fn with_permission(mut self, perm: ExtensionPermission) -> Self {
113        if !self.permissions.contains(&perm) {
114            self.permissions.push(perm);
115        }
116        self
117    }
118
119    /// Builder: set config schema.
120    pub fn with_config_schema(mut self, schema: Value) -> Self {
121        self.config_schema = Some(schema);
122        self
123    }
124
125    /// Check whether the extension requests a particular permission.
126    pub fn has_permission(&self, perm: ExtensionPermission) -> bool {
127        self.permissions.contains(&perm)
128    }
129}
130
131// ═══════════════════════════════════════════════════════════════════════════
132// Extension Error Handling
133// ═══════════════════════════════════════════════════════════════════════════
134
135/// Errors that can occur during extension operations.
136#[derive(Debug, thiserror::Error)]
137pub enum ExtensionError {
138    /// The extension was not found in the registry.
139    #[error("Extension '{name}' not found")]
140    NotFound { name: String },
141
142    /// The extension failed to load.
143    #[error("Failed to load extension '{name}': {reason}")]
144    LoadFailed { name: String, reason: String },
145
146    /// The extension failed during a lifecycle hook.
147    #[error("Extension '{name}' hook '{hook}' failed: {error}")]
148    HookFailed {
149        name: String,
150        hook: String,
151        error: String,
152    },
153
154    /// A required permission was not granted.
155    #[error("Extension '{name}' requires permission '{permission}'")]
156    PermissionDenied {
157        name: String,
158        permission: ExtensionPermission,
159    },
160
161    /// The extension is disabled and cannot be used.
162    #[error("Extension '{name}' is disabled")]
163    Disabled { name: String },
164
165    /// A hot-reload operation failed.
166    #[error("Hot-reload of extension '{name}' failed: {reason}")]
167    HotReloadFailed { name: String, reason: String },
168
169    /// Configuration validation failed.
170    #[error("Invalid configuration for extension '{name}': {reason}")]
171    InvalidConfig { name: String, reason: String },
172}
173
174/// Recorded extension error for diagnostics and logging.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct ExtensionErrorRecord {
177    /// Name of the extension that caused the error.
178    pub extension_name: String,
179    /// The event or hook during which the error occurred.
180    pub event: String,
181    /// Error message.
182    pub error: String,
183    /// Optional stack trace (best-effort).
184    #[serde(default)]
185    pub stack: Option<String>,
186    /// Timestamp of the error.
187    pub timestamp: i64,
188}
189
190impl ExtensionErrorRecord {
191    /// Create a new error record with the current timestamp.
192    pub fn new(extension_name: impl Into<String>, event: impl Into<String>, error: impl Into<String>) -> Self {
193        Self {
194            extension_name: extension_name.into(),
195            event: event.into(),
196            error: error.into(),
197            stack: None,
198            timestamp: chrono::Utc::now().timestamp_millis(),
199        }
200    }
201}
202
203// ═══════════════════════════════════════════════════════════════════════════
204// Extension Context
205// ═══════════════════════════════════════════════════════════════════════════
206
207/// Context provided to extension lifecycle hooks and event handlers.
208///
209/// This is the primary interface through which extensions interact with the
210/// host application.
211pub struct ExtensionContext {
212    /// Current working directory.
213    pub cwd: PathBuf,
214    /// Read-only access to application settings.
215    settings: Arc<RwLock<crate::settings::Settings>>,
216    /// Extension-specific configuration (validated against manifest schema).
217    pub config: Value,
218    /// Session ID for the current session (if any).
219    pub session_id: Option<String>,
220    /// Whether the agent is currently idle (not streaming).
221    idle: Arc<RwLock<bool>>,
222    /// Tool registration callback.
223    tool_registrar: Arc<dyn Fn(Arc<dyn AgentTool>) + Send + Sync>,
224    /// Message sending callback.
225    message_sender: Arc<dyn Fn(&str) + Send + Sync>,
226    /// Pending errors recorded by extensions.
227    errors: Arc<RwLock<Vec<ExtensionErrorRecord>>>,
228}
229
230impl fmt::Debug for ExtensionContext {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        f.debug_struct("ExtensionContext")
233            .field("cwd", &self.cwd)
234            .field("session_id", &self.session_id)
235            .field("idle", &self.idle.read())
236            .finish()
237    }
238}
239
240impl ExtensionContext {
241    /// Create a new extension context.
242    ///
243    /// Most callers should use [`ExtensionContextBuilder`] instead.
244    pub fn new(
245        cwd: PathBuf,
246        settings: Arc<RwLock<crate::settings::Settings>>,
247        config: Value,
248        session_id: Option<String>,
249        idle: Arc<RwLock<bool>>,
250        tool_registrar: Arc<dyn Fn(Arc<dyn AgentTool>) + Send + Sync>,
251        message_sender: Arc<dyn Fn(&str) + Send + Sync>,
252        errors: Arc<RwLock<Vec<ExtensionErrorRecord>>>,
253    ) -> Self {
254        Self {
255            cwd,
256            settings,
257            config,
258            session_id,
259            idle,
260            tool_registrar,
261            message_sender,
262            errors,
263        }
264    }
265
266    /// Read the current application settings.
267    pub fn settings(&self) -> crate::settings::Settings {
268        self.settings.read().clone()
269    }
270
271    /// Whether the agent is currently idle (not streaming).
272    pub fn is_idle(&self) -> bool {
273        *self.idle.read()
274    }
275
276    /// Register a tool that the agent can call.
277    pub fn register_tool(&self, tool: Arc<dyn AgentTool>) {
278        (self.tool_registrar)(tool);
279    }
280
281    /// Send a text message to the agent / conversation.
282    pub fn send_message(&self, text: &str) {
283        (self.message_sender)(text);
284    }
285
286    /// Record an error that occurred inside an extension hook.
287    pub fn record_error(&self, extension_name: &str, event: &str, error: &str) {
288        let record = ExtensionErrorRecord::new(extension_name, event, error);
289        tracing::warn!(
290            extension = extension_name,
291            event = event,
292            error = error,
293            "Extension error recorded"
294        );
295        self.errors.write().push(record);
296    }
297
298    /// Get all recorded extension errors.
299    pub fn errors(&self) -> Vec<ExtensionErrorRecord> {
300        self.errors.read().clone()
301    }
302
303    /// Clear all recorded extension errors.
304    pub fn clear_errors(&self) {
305        self.errors.write().clear();
306    }
307
308    /// Read the extension-specific configuration value at the given path.
309    ///
310    /// Returns `None` if the path doesn't exist.
311    pub fn config_get(&self, path: &str) -> Option<Value> {
312        let mut current = &self.config;
313        for key in path.split('.') {
314            match current {
315                Value::Object(map) => current = map.get(key)?,
316                _ => return None,
317            }
318        }
319        Some(current.clone())
320    }
321
322    /// Read the filesystem — access files relative to cwd.
323    pub fn read_file(&self, relative_path: &Path) -> Result<String> {
324        let full_path = self.cwd.join(relative_path);
325        std::fs::read_to_string(&full_path)
326            .with_context(|| format!("Failed to read file: {}", full_path.display()))
327    }
328}
329
330/// Builder for [`ExtensionContext`].
331pub struct ExtensionContextBuilder {
332    cwd: PathBuf,
333    settings: Option<Arc<RwLock<crate::settings::Settings>>>,
334    config: Value,
335    session_id: Option<String>,
336    idle: Arc<RwLock<bool>>,
337    tool_registrar: Option<Arc<dyn Fn(Arc<dyn AgentTool>) + Send + Sync>>,
338    message_sender: Option<Arc<dyn Fn(&str) + Send + Sync>>,
339    errors: Option<Arc<RwLock<Vec<ExtensionErrorRecord>>>>,
340}
341
342impl ExtensionContextBuilder {
343    /// Start building a context with the given working directory.
344    pub fn new(cwd: PathBuf) -> Self {
345        Self {
346            cwd,
347            settings: None,
348            config: Value::Null,
349            session_id: None,
350            idle: Arc::new(RwLock::new(true)),
351            tool_registrar: None,
352            message_sender: None,
353            errors: None,
354        }
355    }
356
357    /// Set the settings reference.
358    pub fn settings(mut self, settings: Arc<RwLock<crate::settings::Settings>>) -> Self {
359        self.settings = Some(settings);
360        self
361    }
362
363    /// Set extension-specific configuration.
364    pub fn config(mut self, config: Value) -> Self {
365        self.config = config;
366        self
367    }
368
369    /// Set the session ID.
370    pub fn session_id(mut self, id: impl Into<String>) -> Self {
371        self.session_id = Some(id.into());
372        self
373    }
374
375    /// Set the idle-state handle.
376    pub fn idle(mut self, idle: Arc<RwLock<bool>>) -> Self {
377        self.idle = idle;
378        self
379    }
380
381    /// Set the tool registrar callback.
382    pub fn tool_registrar(mut self, registrar: Arc<dyn Fn(Arc<dyn AgentTool>) + Send + Sync>) -> Self {
383        self.tool_registrar = Some(registrar);
384        self
385    }
386
387    /// Set the message sender callback.
388    pub fn message_sender(mut self, sender: Arc<dyn Fn(&str) + Send + Sync>) -> Self {
389        self.message_sender = Some(sender);
390        self
391    }
392
393    /// Set the shared error buffer.
394    pub fn errors(mut self, errors: Arc<RwLock<Vec<ExtensionErrorRecord>>>) -> Self {
395        self.errors = Some(errors);
396        self
397    }
398
399    /// Build the context, falling back to no-op callbacks where necessary.
400    pub fn build(self) -> ExtensionContext {
401        ExtensionContext {
402            cwd: self.cwd,
403            settings: self.settings.unwrap_or_else(|| {
404                Arc::new(RwLock::new(crate::settings::Settings::default()))
405            }),
406            config: self.config,
407            session_id: self.session_id,
408            idle: self.idle,
409            tool_registrar: self.tool_registrar.unwrap_or_else(|| {
410                Arc::new(|_tool| {
411                    tracing::debug!("Tool registration attempted with no registrar");
412                })
413            }),
414            message_sender: self.message_sender.unwrap_or_else(|| {
415                Arc::new(|_msg| {
416                    tracing::debug!("Message send attempted with no sender");
417                })
418            }),
419            errors: self.errors.unwrap_or_default(),
420        }
421    }
422}
423
424// ═══════════════════════════════════════════════════════════════════════════
425// Commands
426// ═══════════════════════════════════════════════════════════════════════════
427
428/// A simple command definition for the CLI.
429#[derive(Debug, Clone)]
430pub struct Command {
431    /// Slash-command name (e.g. "deploy")
432    pub name: String,
433    /// Short description shown in /help
434    pub description: String,
435    /// Usage string (e.g. "/deploy <target>")
436    pub usage: String,
437}
438
439impl Command {
440    pub fn new(
441        name: impl Into<String>,
442        description: impl Into<String>,
443        usage: impl Into<String>,
444    ) -> Self {
445        Self {
446            name: name.into(),
447            description: description.into(),
448            usage: usage.into(),
449        }
450    }
451}
452
453// ═══════════════════════════════════════════════════════════════════════════
454// Extension Lifecycle Trait
455// ═══════════════════════════════════════════════════════════════════════════
456
457/// Core trait that every oxi extension must implement.
458///
459/// Extensions can register custom tools, custom slash-commands, hook
460/// into the agent event stream, and respond to lifecycle events.
461///
462/// All lifecycle hooks provide default no-op implementations so that
463/// extensions only need to override the hooks they care about.
464pub trait Extension: Send + Sync {
465    // ── Identity ─────────────────────────────────────────────────────
466
467    /// Unique name of the extension.
468    fn name(&self) -> &str;
469
470    /// Human-readable description.
471    fn description(&self) -> &str;
472
473    /// Return the extension manifest for metadata, permissions, and config.
474    ///
475    /// The default implementation builds a minimal manifest from
476    /// [`name`](Extension::name) and [`description`](Extension::description).
477    fn manifest(&self) -> ExtensionManifest {
478        ExtensionManifest::new(self.name(), "0.0.0")
479            .with_description(self.description())
480    }
481
482    // ── Registration ─────────────────────────────────────────────────
483
484    /// Return custom tools this extension contributes.
485    fn register_tools(&self) -> Vec<Arc<dyn AgentTool>> {
486        vec![]
487    }
488
489    /// Return custom slash-commands this extension contributes.
490    fn register_commands(&self) -> Vec<Command> {
491        vec![]
492    }
493
494    // ── Lifecycle hooks ──────────────────────────────────────────────
495
496    /// Called once when the extension is loaded and before any other hooks.
497    ///
498    /// Use this to perform initialization such as reading configuration,
499    /// establishing connections, or validating permissions.
500    fn on_load(&self, _ctx: &ExtensionContext) {}
501
502    /// Called when the extension is about to be unloaded.
503    ///
504    /// Use this to release resources, flush buffers, or perform cleanup.
505    fn on_unload(&self) {}
506
507    // ── Message hooks ────────────────────────────────────────────────
508
509    /// Called after a user message is sent to the agent.
510    fn on_message_sent(&self, _msg: &str) {}
511
512    /// Called when an assistant message is received.
513    fn on_message_received(&self, _msg: &str) {}
514
515    // ── Tool hooks ───────────────────────────────────────────────────
516
517    /// Called before a tool is executed. The tool name and raw parameters
518    /// are provided. This can be used for logging, auditing, or preprocessing.
519    fn on_tool_call(&self, _tool: &str, _params: &Value) {}
520
521    /// Called after a tool finishes execution.
522    fn on_tool_result(&self, _tool: &str, _result: &AgentToolResult) {}
523
524    // ── Session hooks ────────────────────────────────────────────────
525
526    /// Called when a new session starts.
527    fn on_session_start(&self, _session_id: &str) {}
528
529    /// Called when a session ends.
530    fn on_session_end(&self, _session_id: &str) {}
531
532    // ── Settings hook ────────────────────────────────────────────────
533
534    /// Called when settings have changed (e.g. user ran `oxi config`).
535    fn on_settings_changed(&self, _settings: &crate::settings::Settings) {}
536
537    // ── Agent event hook ─────────────────────────────────────────────
538
539    /// Called when the agent emits an event.
540    ///
541    /// This is the low-level catch-all. Prefer the typed hooks above
542    /// when possible.
543    fn on_event(&self, _event: &AgentEvent) {}
544}
545
546// ═══════════════════════════════════════════════════════════════════════════
547// Loaded Extension Entry
548// ═══════════════════════════════════════════════════════════════════════════
549
550/// Internal representation of a loaded extension in the registry.
551struct LoadedExtension {
552    /// The extension trait object.
553    extension: Arc<dyn Extension>,
554    /// Whether the extension is currently enabled.
555    enabled: bool,
556    /// Path the extension was loaded from (for hot-reload).
557    source_path: Option<PathBuf>,
558}
559
560// ═══════════════════════════════════════════════════════════════════════════
561// Extension Registry
562// ═══════════════════════════════════════════════════════════════════════════
563
564/// Manages a collection of loaded extensions.
565///
566/// Supports:
567/// - Registering / unregistering extensions by name
568/// - Enabling / disabling at runtime
569/// - Hot-reloading from the original source path
570/// - Broadcasting events to all enabled extensions with graceful error handling
571/// - Collecting tools and commands from enabled extensions
572pub struct ExtensionRegistry {
573    /// Name → loaded extension entry.
574    entries: HashMap<String, LoadedExtension>,
575    /// Shared error buffer for recording extension errors.
576    errors: Arc<RwLock<Vec<ExtensionErrorRecord>>>,
577    /// Keep dynamically loaded libraries alive so vtables stay valid.
578    #[allow(dead_code)]
579    libraries: Vec<Library>,
580}
581
582impl Default for ExtensionRegistry {
583    fn default() -> Self {
584        Self::new()
585    }
586}
587
588impl ExtensionRegistry {
589    /// Create an empty registry.
590    pub fn new() -> Self {
591        Self {
592            entries: HashMap::new(),
593            errors: Arc::new(RwLock::new(Vec::new())),
594            libraries: Vec::new(),
595        }
596    }
597
598    // ── Registration ─────────────────────────────────────────────────
599
600    /// Register an extension (in-memory).
601    ///
602    /// If an extension with the same name already exists it is replaced.
603    pub fn register(&mut self, ext: Arc<dyn Extension>) {
604        let name = ext.name().to_string();
605        tracing::info!(name = %name, "extension registered");
606        self.entries.insert(
607            name,
608            LoadedExtension {
609                extension: ext,
610                enabled: true,
611                source_path: None,
612            },
613        );
614    }
615
616    /// Register an extension that was loaded from a shared library,
617    /// keeping the library handle alive for hot-reload.
618    pub fn register_with_library(
619        &mut self,
620        ext: Arc<dyn Extension>,
621        source_path: PathBuf,
622        library: Library,
623    ) {
624        let name = ext.name().to_string();
625        tracing::info!(name = %name, path = %source_path.display(), "extension registered (dynamic)");
626        self.libraries.push(library);
627        self.entries.insert(
628            name,
629            LoadedExtension {
630                extension: ext,
631                enabled: true,
632                source_path: Some(source_path),
633            },
634        );
635    }
636
637    /// Unregister an extension by name.
638    ///
639    /// Calls `on_unload` on the extension before removing it.
640    /// Returns `false` if the extension was not found.
641    pub fn unregister(&mut self, name: &str) -> bool {
642        if let Some(entry) = self.entries.remove(name) {
643            self.call_hook_safe(name, "on_unload", || {
644                entry.extension.on_unload();
645            });
646            tracing::info!(name = %name, "extension unregistered");
647            true
648        } else {
649            false
650        }
651    }
652
653    // ── Enable / Disable ─────────────────────────────────────────────
654
655    /// Disable an extension at runtime.
656    ///
657    /// Disabled extensions are skipped during event broadcasting and
658    /// tool/command collection, but remain loaded.
659    pub fn disable(&mut self, name: &str) -> Result<(), ExtensionError> {
660        let ext = {
661            let entry = self
662                .entries
663                .get_mut(name)
664                .ok_or_else(|| ExtensionError::NotFound {
665                    name: name.to_string(),
666                })?;
667            if !entry.enabled {
668                return Ok(());
669            }
670            entry.enabled = false;
671            Arc::clone(&entry.extension)
672        };
673        self.call_hook_safe(name, "on_unload", || {
674            ext.on_unload();
675        });
676        tracing::info!(name = %name, "extension disabled");
677        Ok(())
678    }
679
680    /// Enable a previously disabled extension.
681    pub fn enable(&mut self, name: &str, ctx: &ExtensionContext) -> Result<(), ExtensionError> {
682        let ext = {
683            let entry = self
684                .entries
685                .get_mut(name)
686                .ok_or_else(|| ExtensionError::NotFound {
687                    name: name.to_string(),
688                })?;
689            if entry.enabled {
690                return Ok(());
691            }
692            entry.enabled = true;
693            Arc::clone(&entry.extension)
694        };
695        self.call_hook_safe(name, "on_load", || {
696            ext.on_load(ctx);
697        });
698        tracing::info!(name = %name, "extension enabled");
699        Ok(())
700    }
701
702    /// Check whether an extension is currently enabled.
703    pub fn is_enabled(&self, name: &str) -> bool {
704        self.entries
705            .get(name)
706            .map(|e| e.enabled)
707            .unwrap_or(false)
708    }
709
710    // ── Hot Reload ───────────────────────────────────────────────────
711
712    /// Hot-reload an extension from its original source path.
713    ///
714    /// The old extension is unloaded, the shared library is re-opened,
715    /// and the new extension is loaded in its place. Tools and commands
716    /// from the old extension are no longer returned.
717    pub fn hot_reload(&mut self, name: &str, ctx: &ExtensionContext) -> Result<(), ExtensionError> {
718        let source_path = {
719            let entry = self
720                .entries
721                .get(name)
722                .ok_or_else(|| ExtensionError::NotFound {
723                    name: name.to_string(),
724                })?;
725            entry.source_path.clone()
726        };
727
728        let source_path = source_path.ok_or_else(|| ExtensionError::HotReloadFailed {
729            name: name.to_string(),
730            reason: "no source path recorded (in-memory extension)".to_string(),
731        })?;
732
733        // Unload old
734        self.unregister(name);
735
736        // Load new
737        let new_ext = load_extension(&source_path).map_err(|e| ExtensionError::HotReloadFailed {
738            name: name.to_string(),
739            reason: e.to_string(),
740        })?;
741
742        let library = unsafe {
743            Library::new(&source_path)
744                .map_err(|e| ExtensionError::HotReloadFailed {
745                    name: name.to_string(),
746                    reason: format!("Failed to re-open library: {}", e),
747                })?
748        };
749
750        // Call on_load on the new extension
751        self.call_hook_safe(name, "on_load", || {
752            new_ext.on_load(ctx);
753        });
754
755        self.register_with_library(new_ext, source_path, library);
756        tracing::info!(name = %name, "extension hot-reloaded");
757        Ok(())
758    }
759
760    // ── Tool & Command Collection ────────────────────────────────────
761
762    /// Collect all tools from every enabled extension.
763    pub fn all_tools(&self) -> Vec<Arc<dyn AgentTool>> {
764        self.entries
765            .values()
766            .filter(|e| e.enabled)
767            .flat_map(|e| e.extension.register_tools())
768            .collect()
769    }
770
771    /// Collect all commands from every enabled extension.
772    pub fn all_commands(&self) -> Vec<Command> {
773        self.entries
774            .values()
775            .filter(|e| e.enabled)
776            .flat_map(|e| e.extension.register_commands())
777            .collect()
778    }
779
780    // ── Event Broadcasting ───────────────────────────────────────────
781
782    /// Broadcast `on_load` to all enabled extensions.
783    pub fn emit_load(&self, ctx: &ExtensionContext) {
784        for entry in self.entries.values().filter(|e| e.enabled) {
785            let name = entry.extension.name();
786            self.call_hook_safe(name, "on_load", || {
787                entry.extension.on_load(ctx);
788            });
789        }
790    }
791
792    /// Broadcast `on_unload` to all enabled extensions.
793    pub fn emit_unload(&self) {
794        for entry in self.entries.values().filter(|e| e.enabled) {
795            let name = entry.extension.name();
796            self.call_hook_safe(name, "on_unload", || {
797                entry.extension.on_unload();
798            });
799        }
800    }
801
802    /// Broadcast `on_message_sent` to all enabled extensions.
803    pub fn emit_message_sent(&self, msg: &str) {
804        for entry in self.entries.values().filter(|e| e.enabled) {
805            let name = entry.extension.name();
806            self.call_hook_safe(name, "on_message_sent", || {
807                entry.extension.on_message_sent(msg);
808            });
809        }
810    }
811
812    /// Broadcast `on_message_received` to all enabled extensions.
813    pub fn emit_message_received(&self, msg: &str) {
814        for entry in self.entries.values().filter(|e| e.enabled) {
815            let name = entry.extension.name();
816            self.call_hook_safe(name, "on_message_received", || {
817                entry.extension.on_message_received(msg);
818            });
819        }
820    }
821
822    /// Broadcast `on_tool_call` to all enabled extensions.
823    pub fn emit_tool_call(&self, tool: &str, params: &Value) {
824        for entry in self.entries.values().filter(|e| e.enabled) {
825            let name = entry.extension.name();
826            self.call_hook_safe(name, "on_tool_call", || {
827                entry.extension.on_tool_call(tool, params);
828            });
829        }
830    }
831
832    /// Broadcast `on_tool_result` to all enabled extensions.
833    pub fn emit_tool_result(&self, tool: &str, result: &AgentToolResult) {
834        for entry in self.entries.values().filter(|e| e.enabled) {
835            let name = entry.extension.name();
836            self.call_hook_safe(name, "on_tool_result", || {
837                entry.extension.on_tool_result(tool, result);
838            });
839        }
840    }
841
842    /// Broadcast `on_session_start` to all enabled extensions.
843    pub fn emit_session_start(&self, session_id: &str) {
844        for entry in self.entries.values().filter(|e| e.enabled) {
845            let name = entry.extension.name();
846            self.call_hook_safe(name, "on_session_start", || {
847                entry.extension.on_session_start(session_id);
848            });
849        }
850    }
851
852    /// Broadcast `on_session_end` to all enabled extensions.
853    pub fn emit_session_end(&self, session_id: &str) {
854        for entry in self.entries.values().filter(|e| e.enabled) {
855            let name = entry.extension.name();
856            self.call_hook_safe(name, "on_session_end", || {
857                entry.extension.on_session_end(session_id);
858            });
859        }
860    }
861
862    /// Broadcast `on_settings_changed` to all enabled extensions.
863    pub fn emit_settings_changed(&self, settings: &crate::settings::Settings) {
864        for entry in self.entries.values().filter(|e| e.enabled) {
865            let name = entry.extension.name();
866            self.call_hook_safe(name, "on_settings_changed", || {
867                entry.extension.on_settings_changed(settings);
868            });
869        }
870    }
871
872    /// Broadcast an agent event to every enabled extension.
873    pub fn emit_event(&self, event: &AgentEvent) {
874        for entry in self.entries.values().filter(|e| e.enabled) {
875            let name = entry.extension.name();
876            self.call_hook_safe(name, "on_event", || {
877                entry.extension.on_event(event);
878            });
879        }
880    }
881
882    // ── Querying ─────────────────────────────────────────────────────
883
884    /// Get a reference to an extension by name.
885    pub fn get(&self, name: &str) -> Option<Arc<dyn Extension>> {
886        self.entries
887            .get(name)
888            .map(|e| Arc::clone(&e.extension))
889    }
890
891    /// Iterate over registered extension names.
892    pub fn names(&self) -> impl Iterator<Item = &str> {
893        self.entries.keys().map(|s| s.as_str())
894    }
895
896    /// Iterate over registered extensions.
897    pub fn extensions(&self) -> impl Iterator<Item = &Arc<dyn Extension>> {
898        self.entries.values().map(|e| &e.extension)
899    }
900
901    /// Get the manifest for an extension by name.
902    pub fn manifest(&self, name: &str) -> Option<ExtensionManifest> {
903        self.entries.get(name).map(|e| e.extension.manifest())
904    }
905
906    /// Number of registered extensions.
907    pub fn len(&self) -> usize {
908        self.entries.len()
909    }
910
911    /// Whether any extensions are registered.
912    pub fn is_empty(&self) -> bool {
913        self.entries.is_empty()
914    }
915
916    /// Get all recorded errors.
917    pub fn errors(&self) -> Vec<ExtensionErrorRecord> {
918        self.errors.read().clone()
919    }
920
921    /// Clear all recorded errors.
922    pub fn clear_errors(&self) {
923        self.errors.write().clear();
924    }
925
926    // ── Internal ─────────────────────────────────────────────────────
927
928    /// Call a hook on an extension, catching any panics/errors and
929    /// recording them for diagnostics.  This provides **graceful
930    /// degradation** — a failing extension never crashes the host.
931    fn call_hook_safe<F>(&self, ext_name: &str, hook: &str, f: F)
932    where
933        F: FnOnce(),
934    {
935        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
936        if let Err(payload) = result {
937            let msg = if let Some(s) = payload.downcast_ref::<&str>() {
938                s.to_string()
939            } else if let Some(s) = payload.downcast_ref::<String>() {
940                s.clone()
941            } else {
942                "unknown panic".to_string()
943            };
944            tracing::error!(
945                extension = ext_name,
946                hook = hook,
947                error = %msg,
948                "Extension hook panicked — graceful degradation"
949            );
950            self.errors.write().push(ExtensionErrorRecord::new(
951                ext_name,
952                hook,
953                &format!("panic: {}", msg),
954            ));
955        }
956    }
957}
958
959impl fmt::Debug for ExtensionRegistry {
960    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
961        f.debug_struct("ExtensionRegistry")
962            .field("count", &self.entries.len())
963            .field(
964                "names",
965                &self.entries.keys().cloned().collect::<Vec<_>>(),
966            )
967            .finish()
968    }
969}
970
971// ═══════════════════════════════════════════════════════════════════════════
972// Dynamic Loading
973// ═══════════════════════════════════════════════════════════════════════════
974
975/// Expected symbol name inside a shared-library extension.
976const ENTRY_SYMBOL: &[u8] = b"oxi_extension_create\0";
977
978/// Function signature that a shared library must export.
979///
980/// The library must expose:
981///
982/// ```c,ignore
983/// extern "C" fn oxi_extension_create() -> *mut dyn Extension
984/// ```
985type CreateFn = unsafe fn() -> *mut dyn Extension;
986
987/// Load an extension from a shared library (.so / .dll / .dylib).
988///
989/// The library **must** export an `oxi_extension_create` entry-point that
990/// returns a heap-allocated trait object.
991pub fn load_extension(path: &Path) -> Result<Arc<dyn Extension>> {
992    let extension = load_extension_inner(path)?;
993    Ok(extension)
994}
995
996fn load_extension_inner(path: &Path) -> Result<Arc<dyn Extension>> {
997    // Validate file extension
998    let ext = path.extension().and_then(OsStr::to_str).unwrap_or("");
999
1000    let valid = matches!(ext, "so" | "dylib" | "dll");
1001    if !valid {
1002        bail!(
1003            "Unsupported extension file format: .{}. Expected .so, .dylib, or .dll",
1004            ext
1005        );
1006    }
1007
1008    if !path.exists() {
1009        bail!("Extension file not found: {}", path.display());
1010    }
1011
1012    // Safety: loading a shared library is inherently unsafe. We trust the
1013    // user-provided library to be well-behaved.
1014    let library = unsafe {
1015        Library::new(path).with_context(|| format!("Failed to load library: {}", path.display()))?
1016    };
1017
1018    let create: Symbol<CreateFn> = unsafe {
1019        library.get(ENTRY_SYMBOL).with_context(|| {
1020            format!(
1021                "Symbol `oxi_extension_create` not found in {}",
1022                path.display()
1023            )
1024        })?
1025    };
1026
1027    let raw_ptr = unsafe { create() };
1028    if raw_ptr.is_null() {
1029        bail!("oxi_extension_create returned null in {}", path.display());
1030    }
1031
1032    // Wrap the raw pointer in an Arc directly via Box
1033    let boxed: Box<dyn Extension> = unsafe { Box::from_raw(raw_ptr) };
1034    Ok(Arc::from(boxed))
1035}
1036
1037/// Load multiple extensions from file paths, collecting errors.
1038pub fn load_extensions(paths: &[&Path]) -> (Vec<Arc<dyn Extension>>, Vec<anyhow::Error>) {
1039    let mut loaded = Vec::with_capacity(paths.len());
1040    let mut errors = Vec::new();
1041
1042    for &path in paths {
1043        match load_extension(path) {
1044            Ok(ext) => loaded.push(ext),
1045            Err(e) => {
1046                errors.push(e.context(format!("Failed to load extension: {}", path.display())))
1047            }
1048        }
1049    }
1050
1051    (loaded, errors)
1052}
1053
1054// ═══════════════════════════════════════════════════════════════════════════
1055// Built-in "noop" extension for testing
1056// ═══════════════════════════════════════════════════════════════════════════
1057
1058/// A minimal extension that does nothing — useful as a template and for tests.
1059pub struct NoopExtension;
1060
1061impl Extension for NoopExtension {
1062    fn name(&self) -> &str {
1063        "noop"
1064    }
1065
1066    fn description(&self) -> &str {
1067        "Built-in no-op extension"
1068    }
1069}
1070
1071// ═══════════════════════════════════════════════════════════════════════════
1072// Test Helpers
1073// ═══════════════════════════════════════════════════════════════════════════
1074
1075/// A test extension that records lifecycle hook invocations.
1076#[cfg(test)]
1077pub struct RecordingExtension {
1078    pub name: String,
1079    pub calls: std::sync::Mutex<Vec<String>>,
1080}
1081
1082#[cfg(test)]
1083impl RecordingExtension {
1084    pub fn new(name: impl Into<String>) -> Self {
1085        Self {
1086            name: name.into(),
1087            calls: std::sync::Mutex::new(Vec::new()),
1088        }
1089    }
1090
1091    pub fn push(&self, call: &str) {
1092        self.calls.lock().unwrap().push(call.to_string());
1093    }
1094
1095    pub fn calls(&self) -> Vec<String> {
1096        self.calls.lock().unwrap().clone()
1097    }
1098}
1099
1100#[cfg(test)]
1101impl Extension for RecordingExtension {
1102    fn name(&self) -> &str {
1103        &self.name
1104    }
1105
1106    fn description(&self) -> &str {
1107        "recording test extension"
1108    }
1109
1110    fn on_load(&self, _ctx: &ExtensionContext) {
1111        self.push("on_load");
1112    }
1113
1114    fn on_unload(&self) {
1115        self.push("on_unload");
1116    }
1117
1118    fn on_message_sent(&self, msg: &str) {
1119        self.push(&format!("on_message_sent({})", msg));
1120    }
1121
1122    fn on_message_received(&self, msg: &str) {
1123        self.push(&format!("on_message_received({})", msg));
1124    }
1125
1126    fn on_tool_call(&self, tool: &str, _params: &Value) {
1127        self.push(&format!("on_tool_call({})", tool));
1128    }
1129
1130    fn on_tool_result(&self, tool: &str, _result: &AgentToolResult) {
1131        self.push(&format!("on_tool_result({})", tool));
1132    }
1133
1134    fn on_session_start(&self, session_id: &str) {
1135        self.push(&format!("on_session_start({})", session_id));
1136    }
1137
1138    fn on_session_end(&self, session_id: &str) {
1139        self.push(&format!("on_session_end({})", session_id));
1140    }
1141
1142    fn on_settings_changed(&self, _settings: &crate::settings::Settings) {
1143        self.push("on_settings_changed");
1144    }
1145
1146    fn on_event(&self, _event: &AgentEvent) {
1147        self.push("on_event");
1148    }
1149}
1150
1151// ═══════════════════════════════════════════════════════════════════════════
1152// Tests
1153// ═══════════════════════════════════════════════════════════════════════════
1154
1155#[cfg(test)]
1156mod tests {
1157    use super::*;
1158    use crate::settings::Settings;
1159
1160    // ── Manifest tests ───────────────────────────────────────────────
1161
1162    #[test]
1163    fn test_manifest_builder() {
1164        let manifest = ExtensionManifest::new("my-ext", "1.0.0")
1165            .with_description("A test extension")
1166            .with_author("test-author")
1167            .with_permission(ExtensionPermission::FileRead)
1168            .with_permission(ExtensionPermission::Bash)
1169            .with_config_schema(serde_json::json!({
1170                "type": "object",
1171                "properties": {
1172                    "api_key": { "type": "string" }
1173                }
1174            }));
1175
1176        assert_eq!(manifest.name, "my-ext");
1177        assert_eq!(manifest.version, "1.0.0");
1178        assert_eq!(manifest.description, "A test extension");
1179        assert_eq!(manifest.author, "test-author");
1180        assert!(manifest.has_permission(ExtensionPermission::FileRead));
1181        assert!(manifest.has_permission(ExtensionPermission::Bash));
1182        assert!(!manifest.has_permission(ExtensionPermission::Network));
1183        assert!(manifest.config_schema.is_some());
1184    }
1185
1186    #[test]
1187    fn test_manifest_serialization() {
1188        let manifest = ExtensionManifest::new("test", "0.1.0")
1189            .with_permission(ExtensionPermission::Network);
1190
1191        let json = serde_json::to_string(&manifest).unwrap();
1192        let parsed: ExtensionManifest = serde_json::from_str(&json).unwrap();
1193        assert_eq!(parsed.name, "test");
1194        assert_eq!(parsed.version, "0.1.0");
1195        assert!(parsed.has_permission(ExtensionPermission::Network));
1196    }
1197
1198    #[test]
1199    fn test_permission_display() {
1200        assert_eq!(ExtensionPermission::FileRead.to_string(), "file_read");
1201        assert_eq!(ExtensionPermission::FileWrite.to_string(), "file_write");
1202        assert_eq!(ExtensionPermission::Bash.to_string(), "bash");
1203        assert_eq!(ExtensionPermission::Network.to_string(), "network");
1204    }
1205
1206    // ── Error tests ──────────────────────────────────────────────────
1207
1208    #[test]
1209    fn test_extension_error_display() {
1210        let err = ExtensionError::NotFound {
1211            name: "test".to_string(),
1212        };
1213        assert!(err.to_string().contains("test"));
1214        assert!(err.to_string().contains("not found"));
1215
1216        let err = ExtensionError::LoadFailed {
1217            name: "bad".to_string(),
1218            reason: "missing symbol".to_string(),
1219        };
1220        assert!(err.to_string().contains("bad"));
1221        assert!(err.to_string().contains("missing symbol"));
1222
1223        let err = ExtensionError::HookFailed {
1224            name: "ext".to_string(),
1225            hook: "on_load".to_string(),
1226            error: "boom".to_string(),
1227        };
1228        assert!(err.to_string().contains("on_load"));
1229
1230        let err = ExtensionError::PermissionDenied {
1231            name: "ext".to_string(),
1232            permission: ExtensionPermission::Network,
1233        };
1234        assert!(err.to_string().contains("network"));
1235
1236        let err = ExtensionError::Disabled {
1237            name: "ext".to_string(),
1238        };
1239        assert!(err.to_string().contains("disabled"));
1240
1241        let err = ExtensionError::HotReloadFailed {
1242            name: "ext".to_string(),
1243            reason: "no path".to_string(),
1244        };
1245        assert!(err.to_string().contains("Hot-reload"));
1246    }
1247
1248    #[test]
1249    fn test_error_record() {
1250        let record = ExtensionErrorRecord::new("my-ext", "on_load", "something broke");
1251        assert_eq!(record.extension_name, "my-ext");
1252        assert_eq!(record.event, "on_load");
1253        assert_eq!(record.error, "something broke");
1254        assert!(record.timestamp > 0);
1255    }
1256
1257    #[test]
1258    fn test_error_record_serialization() {
1259        let record = ExtensionErrorRecord::new("ext", "hook", "err");
1260        let json = serde_json::to_string(&record).unwrap();
1261        let parsed: ExtensionErrorRecord = serde_json::from_str(&json).unwrap();
1262        assert_eq!(parsed.extension_name, "ext");
1263        assert_eq!(parsed.event, "hook");
1264    }
1265
1266    // ── Context tests ────────────────────────────────────────────────
1267
1268    #[test]
1269    fn test_context_builder_minimal() {
1270        let ctx = ExtensionContextBuilder::new(PathBuf::from("/tmp"))
1271            .build();
1272        assert_eq!(ctx.cwd, PathBuf::from("/tmp"));
1273        assert!(ctx.session_id.is_none());
1274        assert!(ctx.is_idle());
1275    }
1276
1277    #[test]
1278    fn test_context_builder_full() {
1279        let settings = Arc::new(RwLock::new(Settings::default()));
1280        let errors = Arc::new(RwLock::new(Vec::new()));
1281        let tools_registered = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1282        let messages_sent = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1283        let tools_ref = tools_registered.clone();
1284        let msgs_ref = messages_sent.clone();
1285
1286        let ctx = ExtensionContextBuilder::new(PathBuf::from("/home"))
1287            .settings(settings)
1288            .config(serde_json::json!({"key": "value"}))
1289            .session_id("sess-123")
1290            .errors(errors)
1291            .tool_registrar(Arc::new(move |tool: Arc<dyn AgentTool>| {
1292                tools_ref.lock().unwrap().push(tool.name().to_string());
1293            }))
1294            .message_sender(Arc::new(move |msg: &str| {
1295                msgs_ref.lock().unwrap().push(msg.to_string());
1296            }))
1297            .build();
1298
1299        assert_eq!(ctx.cwd, PathBuf::from("/home"));
1300        assert_eq!(ctx.session_id, Some("sess-123".to_string()));
1301        assert!(ctx.is_idle());
1302
1303        // Config access
1304        assert_eq!(
1305            ctx.config_get("key"),
1306            Some(serde_json::json!("value"))
1307        );
1308        assert_eq!(ctx.config_get("missing"), None);
1309    }
1310
1311    #[test]
1312    fn test_context_config_nested() {
1313        let ctx = ExtensionContextBuilder::new(PathBuf::from("/tmp"))
1314            .config(serde_json::json!({
1315                "database": {
1316                    "host": "localhost",
1317                    "port": 5432
1318                }
1319            }))
1320            .build();
1321
1322        assert_eq!(
1323            ctx.config_get("database.host"),
1324            Some(serde_json::json!("localhost"))
1325        );
1326        assert_eq!(
1327            ctx.config_get("database.port"),
1328            Some(serde_json::json!(5432))
1329        );
1330        assert_eq!(ctx.config_get("database.missing"), None);
1331    }
1332
1333    #[test]
1334    fn test_context_tool_registration() {
1335        let registered = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1336        let reg_ref = registered.clone();
1337
1338        let ctx = ExtensionContextBuilder::new(PathBuf::from("/tmp"))
1339            .tool_registrar(Arc::new(move |tool: Arc<dyn AgentTool>| {
1340                reg_ref.lock().unwrap().push(tool.name().to_string());
1341            }))
1342            .build();
1343
1344        // Use an existing built-in tool to test registration callback
1345        ctx.register_tool(Arc::new(oxi_agent::ReadTool::new()));
1346        assert_eq!(registered.lock().unwrap()[0], "read");
1347    }
1348
1349    #[test]
1350    fn test_context_message_sending() {
1351        let sent = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1352        let sent_ref = sent.clone();
1353
1354        let ctx = ExtensionContextBuilder::new(PathBuf::from("/tmp"))
1355            .message_sender(Arc::new(move |msg: &str| {
1356                sent_ref.lock().unwrap().push(msg.to_string());
1357            }))
1358            .build();
1359
1360        ctx.send_message("hello");
1361        ctx.send_message("world");
1362        assert_eq!(*sent.lock().unwrap(), vec!["hello", "world"]);
1363    }
1364
1365    #[test]
1366    fn test_context_error_recording() {
1367        let ctx = ExtensionContextBuilder::new(PathBuf::from("/tmp")).build();
1368        assert!(ctx.errors().is_empty());
1369
1370        ctx.record_error("ext1", "on_load", "fail");
1371        ctx.record_error("ext2", "on_tool_call", "oops");
1372
1373        let errs = ctx.errors();
1374        assert_eq!(errs.len(), 2);
1375        assert_eq!(errs[0].extension_name, "ext1");
1376        assert_eq!(errs[1].extension_name, "ext2");
1377
1378        ctx.clear_errors();
1379        assert!(ctx.errors().is_empty());
1380    }
1381
1382    #[test]
1383    fn test_context_settings() {
1384        let settings = Arc::new(RwLock::new(Settings::default()));
1385        let ctx = ExtensionContextBuilder::new(PathBuf::from("/tmp"))
1386            .settings(settings.clone())
1387            .build();
1388
1389        let s = ctx.settings();
1390        assert_eq!(s.version, Settings::default().version);
1391    }
1392
1393    #[test]
1394    fn test_context_noop_callbacks() {
1395        // Test that no-op callbacks don't panic
1396        let ctx = ExtensionContextBuilder::new(PathBuf::from("/tmp")).build();
1397        ctx.register_tool(Arc::new(oxi_agent::ReadTool::new()));
1398        ctx.send_message("test");
1399    }
1400
1401    // ── Registry basic tests ─────────────────────────────────────────
1402
1403    #[test]
1404    fn test_registry_register_and_collect() {
1405        let mut reg = ExtensionRegistry::new();
1406        reg.register(Arc::new(NoopExtension));
1407
1408        assert_eq!(reg.len(), 1);
1409        assert!(!reg.is_empty());
1410        assert!(reg.all_tools().is_empty());
1411        assert!(reg.all_commands().is_empty());
1412    }
1413
1414    #[test]
1415    fn test_registry_names() {
1416        let mut reg = ExtensionRegistry::new();
1417        reg.register(Arc::new(NoopExtension));
1418        let names: Vec<&str> = reg.names().collect();
1419        assert_eq!(names, vec!["noop"]);
1420    }
1421
1422    #[test]
1423    fn test_registry_get() {
1424        let mut reg = ExtensionRegistry::new();
1425        reg.register(Arc::new(NoopExtension));
1426
1427        assert!(reg.get("noop").is_some());
1428        assert!(reg.get("nonexistent").is_none());
1429    }
1430
1431    #[test]
1432    fn test_registry_manifest() {
1433        let mut reg = ExtensionRegistry::new();
1434        reg.register(Arc::new(NoopExtension));
1435
1436        let m = reg.manifest("noop").unwrap();
1437        assert_eq!(m.name, "noop");
1438        assert!(reg.manifest("missing").is_none());
1439    }
1440
1441    #[test]
1442    fn test_registry_unregister() {
1443        let mut reg = ExtensionRegistry::new();
1444        reg.register(Arc::new(NoopExtension));
1445        assert_eq!(reg.len(), 1);
1446
1447        assert!(reg.unregister("noop"));
1448        assert!(reg.is_empty());
1449        assert!(!reg.unregister("noop")); // already removed
1450    }
1451
1452    // ── Enable / Disable tests ───────────────────────────────────────
1453
1454    #[test]
1455    fn test_registry_enable_disable() {
1456        let mut reg = ExtensionRegistry::new();
1457        let ext = Arc::new(RecordingExtension::new("rec"));
1458        reg.register(ext);
1459
1460        let ctx = ExtensionContextBuilder::new(PathBuf::from("/tmp")).build();
1461
1462        // Initially enabled
1463        assert!(reg.is_enabled("rec"));
1464
1465        // Disable
1466        reg.disable("rec").unwrap();
1467        assert!(!reg.is_enabled("rec"));
1468
1469        // Tools/commands should not be collected from disabled extensions
1470        assert!(reg.all_tools().is_empty());
1471
1472        // Enable
1473        reg.enable("rec", &ctx).unwrap();
1474        assert!(reg.is_enabled("rec"));
1475    }
1476
1477    #[test]
1478    fn test_registry_disable_not_found() {
1479        let mut reg = ExtensionRegistry::new();
1480        let result = reg.disable("nonexistent");
1481        assert!(result.is_err());
1482        match result {
1483            Err(ExtensionError::NotFound { name }) => assert_eq!(name, "nonexistent"),
1484            _ => panic!("Expected NotFound error"),
1485        }
1486    }
1487
1488    #[test]
1489    fn test_registry_enable_not_found() {
1490        let mut reg = ExtensionRegistry::new();
1491        let ctx = ExtensionContextBuilder::new(PathBuf::from("/tmp")).build();
1492        let result = reg.enable("nonexistent", &ctx);
1493        assert!(result.is_err());
1494    }
1495
1496    #[test]
1497    fn test_registry_disable_already_disabled() {
1498        let mut reg = ExtensionRegistry::new();
1499        reg.register(Arc::new(NoopExtension));
1500        reg.disable("noop").unwrap();
1501        // Second disable is a no-op
1502        reg.disable("noop").unwrap();
1503        assert!(!reg.is_enabled("noop"));
1504    }
1505
1506    #[test]
1507    fn test_registry_enable_already_enabled() {
1508        let mut reg = ExtensionRegistry::new();
1509        reg.register(Arc::new(NoopExtension));
1510        let ctx = ExtensionContextBuilder::new(PathBuf::from("/tmp")).build();
1511        // Already enabled — no-op
1512        reg.enable("noop", &ctx).unwrap();
1513        assert!(reg.is_enabled("noop"));
1514    }
1515
1516    // ── Lifecycle hook broadcast tests ───────────────────────────────
1517
1518    #[test]
1519    fn test_emit_load() {
1520        let mut reg = ExtensionRegistry::new();
1521        let ext = Arc::new(RecordingExtension::new("rec"));
1522        reg.register(ext.clone());
1523        let ctx = ExtensionContextBuilder::new(PathBuf::from("/tmp")).build();
1524
1525        reg.emit_load(&ctx);
1526        assert_eq!(ext.calls(), vec!["on_load"]);
1527    }
1528
1529    #[test]
1530    fn test_emit_unload() {
1531        let mut reg = ExtensionRegistry::new();
1532        let ext = Arc::new(RecordingExtension::new("rec"));
1533        reg.register(ext.clone());
1534
1535        reg.emit_unload();
1536        assert_eq!(ext.calls(), vec!["on_unload"]);
1537    }
1538
1539    #[test]
1540    fn test_emit_message_sent() {
1541        let mut reg = ExtensionRegistry::new();
1542        let ext = Arc::new(RecordingExtension::new("rec"));
1543        reg.register(ext.clone());
1544
1545        reg.emit_message_sent("hello");
1546        assert_eq!(ext.calls(), vec!["on_message_sent(hello)"]);
1547    }
1548
1549    #[test]
1550    fn test_emit_message_received() {
1551        let mut reg = ExtensionRegistry::new();
1552        let ext = Arc::new(RecordingExtension::new("rec"));
1553        reg.register(ext.clone());
1554
1555        reg.emit_message_received("world");
1556        assert_eq!(ext.calls(), vec!["on_message_received(world)"]);
1557    }
1558
1559    #[test]
1560    fn test_emit_tool_call() {
1561        let mut reg = ExtensionRegistry::new();
1562        let ext = Arc::new(RecordingExtension::new("rec"));
1563        reg.register(ext.clone());
1564
1565        reg.emit_tool_call("bash", &serde_json::json!({"command": "ls"}));
1566        assert_eq!(ext.calls(), vec!["on_tool_call(bash)"]);
1567    }
1568
1569    #[test]
1570    fn test_emit_tool_result() {
1571        let mut reg = ExtensionRegistry::new();
1572        let ext = Arc::new(RecordingExtension::new("rec"));
1573        reg.register(ext.clone());
1574
1575        let result = AgentToolResult::success("done");
1576        reg.emit_tool_result("bash", &result);
1577        assert_eq!(ext.calls(), vec!["on_tool_result(bash)"]);
1578    }
1579
1580    #[test]
1581    fn test_emit_session_start() {
1582        let mut reg = ExtensionRegistry::new();
1583        let ext = Arc::new(RecordingExtension::new("rec"));
1584        reg.register(ext.clone());
1585
1586        reg.emit_session_start("sess-1");
1587        assert_eq!(ext.calls(), vec!["on_session_start(sess-1)"]);
1588    }
1589
1590    #[test]
1591    fn test_emit_session_end() {
1592        let mut reg = ExtensionRegistry::new();
1593        let ext = Arc::new(RecordingExtension::new("rec"));
1594        reg.register(ext.clone());
1595
1596        reg.emit_session_end("sess-1");
1597        assert_eq!(ext.calls(), vec!["on_session_end(sess-1)"]);
1598    }
1599
1600    #[test]
1601    fn test_emit_settings_changed() {
1602        let mut reg = ExtensionRegistry::new();
1603        let ext = Arc::new(RecordingExtension::new("rec"));
1604        reg.register(ext.clone());
1605
1606        let settings = Settings::default();
1607        reg.emit_settings_changed(&settings);
1608        assert_eq!(ext.calls(), vec!["on_settings_changed"]);
1609    }
1610
1611    #[test]
1612    fn test_emit_event() {
1613        let mut reg = ExtensionRegistry::new();
1614        let ext = Arc::new(RecordingExtension::new("rec"));
1615        reg.register(ext.clone());
1616
1617        reg.emit_event(&AgentEvent::Thinking);
1618        assert_eq!(ext.calls(), vec!["on_event"]);
1619    }
1620
1621    // ── Disabled extension skipped during broadcast ──────────────────
1622
1623    #[test]
1624    fn test_disabled_extension_skips_broadcasts() {
1625        let mut reg = ExtensionRegistry::new();
1626        let ext = Arc::new(RecordingExtension::new("rec"));
1627        reg.register(ext.clone());
1628        reg.disable("rec").unwrap();
1629
1630        // disable triggers on_unload — drain it
1631        {
1632            let mut calls = ext.calls.lock().unwrap();
1633            calls.clear();
1634        }
1635
1636        reg.emit_message_sent("hello");
1637        reg.emit_event(&AgentEvent::Thinking);
1638        reg.emit_session_start("s1");
1639
1640        // No broadcast hooks should have been called after the disable
1641        assert!(ext.calls().is_empty());
1642    }
1643
1644    // ── Graceful degradation (panic catching) ────────────────────────
1645
1646    #[test]
1647    fn test_graceful_degradation_on_panic() {
1648        struct PanickingExtension;
1649        impl Extension for PanickingExtension {
1650            fn name(&self) -> &str { "panicker" }
1651            fn description(&self) -> &str { "Panics" }
1652            fn on_load(&self, _ctx: &ExtensionContext) {
1653                panic!("intentional panic in on_load");
1654            }
1655            fn on_message_sent(&self, _msg: &str) {
1656                panic!("intentional panic in on_message_sent");
1657            }
1658        }
1659
1660        let mut reg = ExtensionRegistry::new();
1661        reg.register(Arc::new(PanickingExtension));
1662        let ctx = ExtensionContextBuilder::new(PathBuf::from("/tmp")).build();
1663
1664        // Should not panic — graceful degradation
1665        reg.emit_load(&ctx);
1666        reg.emit_message_sent("hello");
1667
1668        // Error should be recorded
1669        let errors = reg.errors();
1670        assert_eq!(errors.len(), 2);
1671        assert_eq!(errors[0].event, "on_load");
1672        assert!(errors[0].error.contains("intentional panic"));
1673        assert_eq!(errors[1].event, "on_message_sent");
1674    }
1675
1676    // ── Command tests ────────────────────────────────────────────────
1677
1678    #[test]
1679    fn test_command_new() {
1680        let cmd = Command::new("deploy", "Deploy the project", "/deploy <target>");
1681        assert_eq!(cmd.name, "deploy");
1682        assert_eq!(cmd.description, "Deploy the project");
1683        assert_eq!(cmd.usage, "/deploy <target>");
1684    }
1685
1686    // ── Dynamic loading tests ────────────────────────────────────────
1687
1688    #[test]
1689    fn test_load_extension_missing_file() {
1690        let result = load_extension(Path::new("/nonexistent/extension.so"));
1691        assert!(result.is_err());
1692    }
1693
1694    #[test]
1695    fn test_load_extension_wrong_extension() {
1696        let result = load_extension(Path::new("something.txt"));
1697        assert!(result.is_err());
1698        let msg = match result {
1699            Err(e) => e.to_string(),
1700            Ok(_) => panic!("Expected error"),
1701        };
1702        assert!(msg.contains("Unsupported extension file format"));
1703    }
1704
1705    #[test]
1706    fn test_load_extensions_collects_errors() {
1707        let paths: Vec<&Path> = vec![Path::new("/nonexistent1.so"), Path::new("/nonexistent2.so")];
1708        let (loaded, errors) = load_extensions(&paths);
1709        assert!(loaded.is_empty());
1710        assert_eq!(errors.len(), 2);
1711    }
1712
1713    // ── Registry debug ───────────────────────────────────────────────
1714
1715    #[test]
1716    fn test_registry_debug() {
1717        let reg = ExtensionRegistry::new();
1718        let debug_str = format!("{:?}", reg);
1719        assert!(debug_str.contains("count"));
1720    }
1721
1722    // ── Hot reload (error path) ──────────────────────────────────────
1723
1724    #[test]
1725    fn test_hot_reload_no_source_path() {
1726        let mut reg = ExtensionRegistry::new();
1727        reg.register(Arc::new(NoopExtension));
1728        let ctx = ExtensionContextBuilder::new(PathBuf::from("/tmp")).build();
1729
1730        let result = reg.hot_reload("noop", &ctx);
1731        assert!(result.is_err());
1732        match result {
1733            Err(ExtensionError::HotReloadFailed { name, reason }) => {
1734                assert_eq!(name, "noop");
1735                assert!(reason.contains("no source path"));
1736            }
1737            _ => panic!("Expected HotReloadFailed error"),
1738        }
1739    }
1740
1741    #[test]
1742    fn test_hot_reload_not_found() {
1743        let mut reg = ExtensionRegistry::new();
1744        let ctx = ExtensionContextBuilder::new(PathBuf::from("/tmp")).build();
1745
1746        let result = reg.hot_reload("nonexistent", &ctx);
1747        assert!(result.is_err());
1748    }
1749
1750    // ── Multi-extension broadcast ordering ───────────────────────────
1751
1752    #[test]
1753    fn test_broadcast_to_multiple_extensions() {
1754        let mut reg = ExtensionRegistry::new();
1755        let ext1 = Arc::new(RecordingExtension::new("ext1"));
1756        let ext2 = Arc::new(RecordingExtension::new("ext2"));
1757        reg.register(ext1.clone());
1758        reg.register(ext2.clone());
1759
1760        reg.emit_message_sent("hello");
1761
1762        assert!(ext1.calls().contains(&"on_message_sent(hello)".to_string()));
1763        assert!(ext2.calls().contains(&"on_message_sent(hello)".to_string()));
1764    }
1765
1766    #[test]
1767    fn test_unregister_calls_on_unload() {
1768        let mut reg = ExtensionRegistry::new();
1769        let ext = Arc::new(RecordingExtension::new("rec"));
1770        reg.register(ext.clone());
1771
1772        reg.unregister("rec");
1773        assert_eq!(ext.calls(), vec!["on_unload"]);
1774    }
1775
1776    #[test]
1777    fn test_registry_errors() {
1778        let reg = ExtensionRegistry::new();
1779        assert!(reg.errors().is_empty());
1780        reg.clear_errors(); // no-op
1781    }
1782
1783    #[test]
1784    fn test_emit_event_does_not_panic() {
1785        let mut reg = ExtensionRegistry::new();
1786        reg.register(Arc::new(NoopExtension));
1787        reg.emit_event(&AgentEvent::Thinking);
1788    }
1789
1790    #[test]
1791    fn test_multiple_lifecycle_hooks() {
1792        let mut reg = ExtensionRegistry::new();
1793        let ext = Arc::new(RecordingExtension::new("rec"));
1794        reg.register(ext.clone());
1795
1796        let ctx = ExtensionContextBuilder::new(PathBuf::from("/tmp")).build();
1797        reg.emit_load(&ctx);
1798        reg.emit_session_start("s1");
1799        reg.emit_message_sent("hello");
1800        reg.emit_tool_call("bash", &serde_json::json!({}));
1801        let result = AgentToolResult::success("ok");
1802        reg.emit_tool_result("bash", &result);
1803        reg.emit_message_received("response");
1804        reg.emit_session_end("s1");
1805        reg.emit_unload();
1806
1807        let calls = ext.calls();
1808        assert!(calls.contains(&"on_load".to_string()));
1809        assert!(calls.contains(&"on_session_start(s1)".to_string()));
1810        assert!(calls.contains(&"on_message_sent(hello)".to_string()));
1811        assert!(calls.contains(&"on_tool_call(bash)".to_string()));
1812        assert!(calls.contains(&"on_tool_result(bash)".to_string()));
1813        assert!(calls.contains(&"on_message_received(response)".to_string()));
1814        assert!(calls.contains(&"on_session_end(s1)".to_string()));
1815        assert!(calls.contains(&"on_unload".to_string()));
1816    }
1817}