Skip to main content

oxi/extensions/
mod.rs

1//! Extension system for oxi
2//!
3//! Extensions allow custom tools, commands, and event hooks to be loaded dynamically at runtime.
4
5pub mod context;
6#[allow(missing_docs)]
7pub mod ext_cli;
8pub mod loading;
9pub mod registry;
10pub mod types;
11#[allow(missing_docs)]
12pub mod wasm;
13pub mod wasm_hooks;
14pub mod wasm_tool;
15
16// Re-export types from submodules
17pub use crate::extensions::context::{ExtensionContext, ExtensionContextBuilder};
18pub use crate::extensions::loading::{
19    discover_extensions, discover_extensions_in_dir, load_extension, load_extensions,
20    validate_extension, ValidatedExtension, SHARED_LIB_EXTENSION,
21};
22pub use crate::extensions::registry::{ExtensionErrorHandle, ExtensionRegistry, ExtensionRunner};
23pub use crate::extensions::types::{
24    AfterProviderResponseEvent, BashEvent, BeforeProviderRequestEvent, Command, ContextEmitResult,
25    ContextEvent, ExtensionError, ExtensionErrorListener, ExtensionErrorRecord, ExtensionManifest,
26    ExtensionPermission, ExtensionState, InputEvent, InputEventResult, InputSource,
27    ModelSelectEvent, ModelSelectSource, ProviderRequestEmitResult, SessionBeforeCompactEvent,
28    SessionBeforeEmitResult, SessionBeforeForkEvent, SessionBeforeSwitchEvent,
29    SessionBeforeTreeEvent, SessionCompactEvent, SessionShutdownEvent, SessionShutdownReason,
30    SessionSwitchReason, SessionTreeEvent, ThinkingLevelSelectEvent, ToolCallEmitResult,
31    ToolResultEmitResult,
32};
33pub use crate::extensions::wasm::{
34    ExtensionInfo, WasmCommandDef, WasmExtensionManager, WasmToolDef,
35};
36pub use crate::extensions::wasm_tool::WasmTool;
37
38// Re-export from oxi-agent
39pub use oxi_agent::{AgentEvent, AgentTool, AgentToolResult};
40
41// The Extension trait
42/// Core trait that every oxi extension must implement.
43pub trait Extension: Send + Sync {
44    /// TODO: document.
45    fn name(&self) -> &str;
46    /// TODO: document.
47    fn description(&self) -> &str;
48    /// TODO: document.
49    fn manifest(&self) -> ExtensionManifest {
50        ExtensionManifest::new(self.name(), "0.0.0").with_description(self.description())
51    }
52    /// TODO: document.
53    fn register_tools(&self) -> Vec<std::sync::Arc<dyn oxi_agent::AgentTool>> {
54        vec![]
55    }
56    /// TODO: document.
57    fn register_commands(&self) -> Vec<Command> {
58        vec![]
59    }
60    /// TODO: document.
61    fn on_load(&self, _ctx: &ExtensionContext) {}
62    /// TODO: document.
63    fn on_unload(&self) {}
64    /// TODO: document.
65    fn on_message_sent(&self, _msg: &str) {}
66    /// TODO: document.
67    fn on_message_received(&self, _msg: &str) {}
68    /// TODO: document.
69    fn on_tool_call(&self, _tool: &str, _params: &serde_json::Value) {}
70    /// TODO: document.
71    fn on_tool_result(&self, _tool: &str, _result: &oxi_agent::AgentToolResult) {}
72    /// TODO: document.
73    fn on_session_start(&self, _session_id: &str) {}
74    /// TODO: document.
75    fn on_session_end(&self, _session_id: &str) {}
76    /// TODO: document.
77    fn on_settings_changed(&self, _settings: &oxi_store::settings::Settings) {}
78    /// TODO: document.
79    fn on_event(&self, _event: &oxi_agent::AgentEvent) {}
80    /// TODO: document.
81    fn on_before_tool_call(
82        &self,
83        _tool: &str,
84        _args: &serde_json::Value,
85    ) -> Result<(), anyhow::Error> {
86        Ok(())
87    }
88    /// TODO: document.
89    fn on_after_tool_call(
90        &self,
91        _tool: &str,
92        _result: &oxi_agent::AgentToolResult,
93    ) -> Result<(), anyhow::Error> {
94        Ok(())
95    }
96    /// TODO: document.
97    fn on_before_compaction(&self, _ctx: &crate::CompactionContext) -> Result<(), anyhow::Error> {
98        Ok(())
99    }
100    /// TODO: document.
101    fn on_after_compaction(&self, _summary: &str) -> Result<(), anyhow::Error> {
102        Ok(())
103    }
104    /// TODO: document.
105    fn on_error(&self, _error: &anyhow::Error) -> Result<(), anyhow::Error> {
106        Ok(())
107    }
108    /// TODO: document.
109    fn session_before_switch(
110        &self,
111        _event: &crate::extensions::types::SessionBeforeSwitchEvent,
112    ) -> Result<(), anyhow::Error> {
113        Ok(())
114    }
115    /// TODO: document.
116    fn session_before_fork(
117        &self,
118        _event: &crate::extensions::types::SessionBeforeForkEvent,
119    ) -> Result<(), anyhow::Error> {
120        Ok(())
121    }
122    /// TODO: document.
123    fn session_before_compact(
124        &self,
125        _event: &crate::extensions::types::SessionBeforeCompactEvent,
126    ) -> Result<(), anyhow::Error> {
127        Ok(())
128    }
129    /// TODO: document.
130    fn session_compact(
131        &self,
132        _event: &crate::extensions::types::SessionCompactEvent,
133    ) -> Result<(), anyhow::Error> {
134        Ok(())
135    }
136    /// TODO: document.
137    fn session_shutdown(&self, _event: &crate::extensions::types::SessionShutdownEvent) {}
138    /// TODO: document.
139    fn session_before_tree(
140        &self,
141        _event: &crate::extensions::types::SessionBeforeTreeEvent,
142    ) -> Result<(), anyhow::Error> {
143        Ok(())
144    }
145    /// TODO: document.
146    fn session_tree(&self, _event: &crate::extensions::types::SessionTreeEvent) {}
147    /// TODO: document.
148    fn context(
149        &self,
150        _event: &mut crate::extensions::types::ContextEvent,
151    ) -> Result<(), anyhow::Error> {
152        Ok(())
153    }
154    /// TODO: document.
155    fn before_provider_request(
156        &self,
157        _event: &mut crate::extensions::types::BeforeProviderRequestEvent,
158    ) -> Result<(), anyhow::Error> {
159        Ok(())
160    }
161    /// TODO: document.
162    fn after_provider_response(
163        &self,
164        _event: &crate::extensions::types::AfterProviderResponseEvent,
165    ) -> Result<(), anyhow::Error> {
166        Ok(())
167    }
168    /// TODO: document.
169    fn model_select(&self, _event: &crate::extensions::types::ModelSelectEvent) {}
170    /// TODO: document.
171    fn thinking_level_select(&self, _event: &crate::extensions::types::ThinkingLevelSelectEvent) {}
172    /// TODO: document.
173    fn bash(&self, _event: &crate::extensions::types::BashEvent) {}
174    /// TODO: document.
175    fn input(
176        &self,
177        _event: &crate::extensions::types::InputEvent,
178    ) -> crate::extensions::types::InputEventResult {
179        crate::extensions::types::InputEventResult::Continue
180    }
181}
182
183// Built-in "noop" extension
184/// pub.
185pub struct NoopExtension;
186impl Extension for NoopExtension {
187    fn name(&self) -> &str {
188        "noop"
189    }
190    fn description(&self) -> &str {
191        "Built-in no-op extension"
192    }
193}
194
195// Test helpers
196#[cfg(test)]
197pub struct RecordingExtension {
198    pub name: String,
199    pub calls: std::sync::Mutex<Vec<String>>,
200}
201#[cfg(test)]
202impl RecordingExtension {
203    pub fn new(name: impl Into<String>) -> Self {
204        Self {
205            name: name.into(),
206            calls: std::sync::Mutex::new(Vec::new()),
207        }
208    }
209    pub fn push(&self, call: &str) {
210        self.calls.lock().unwrap().push(call.to_string());
211    }
212    pub fn calls(&self) -> Vec<String> {
213        self.calls.lock().unwrap().clone()
214    }
215}
216#[cfg(test)]
217impl Extension for RecordingExtension {
218    fn name(&self) -> &str {
219        &self.name
220    }
221    fn description(&self) -> &str {
222        "recording test extension"
223    }
224    fn on_load(&self, _ctx: &ExtensionContext) {
225        self.push("on_load");
226    }
227    fn on_unload(&self) {
228        self.push("on_unload");
229    }
230    fn on_message_sent(&self, msg: &str) {
231        self.push(&format!("on_message_sent({})", msg));
232    }
233    fn on_message_received(&self, msg: &str) {
234        self.push(&format!("on_message_received({})", msg));
235    }
236    fn on_tool_call(&self, tool: &str, _params: &serde_json::Value) {
237        self.push(&format!("on_tool_call({})", tool));
238    }
239    fn on_tool_result(&self, tool: &str, _result: &oxi_agent::AgentToolResult) {
240        self.push(&format!("on_tool_result({})", tool));
241    }
242    fn on_session_start(&self, session_id: &str) {
243        self.push(&format!("on_session_start({})", session_id));
244    }
245    fn on_session_end(&self, session_id: &str) {
246        self.push(&format!("on_session_end({})", session_id));
247    }
248    fn on_settings_changed(&self, _settings: &oxi_store::settings::Settings) {
249        self.push("on_settings_changed");
250    }
251    fn on_event(&self, _event: &oxi_agent::AgentEvent) {
252        self.push("on_event");
253    }
254}
255
256// Tests
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use oxi_store::settings::Settings;
261    use std::sync::Arc;
262
263    #[test]
264    fn test_manifest_builder() {
265        let manifest = ExtensionManifest::new("my-ext", "1.0.0")
266            .with_description("A test extension")
267            .with_author("test-author")
268            .with_permission(ExtensionPermission::FileRead)
269            .with_permission(ExtensionPermission::Bash)
270            .with_config_schema(serde_json::json!({"type": "object", "properties": {"api_key": {"type": "string"}}}));
271
272        assert_eq!(manifest.name, "my-ext");
273        assert_eq!(manifest.version, "1.0.0");
274        assert_eq!(manifest.description, "A test extension");
275        assert_eq!(manifest.author, "test-author");
276        assert!(manifest.has_permission(ExtensionPermission::FileRead));
277        assert!(manifest.has_permission(ExtensionPermission::Bash));
278        assert!(!manifest.has_permission(ExtensionPermission::Network));
279    }
280
281    #[test]
282    fn test_permission_display() {
283        assert_eq!(ExtensionPermission::FileRead.to_string(), "file_read");
284        assert_eq!(ExtensionPermission::Bash.to_string(), "bash");
285    }
286
287    #[test]
288    fn test_context_builder_minimal() {
289        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
290        assert_eq!(ctx.cwd, std::path::PathBuf::from("/tmp"));
291        assert!(ctx.session_id.is_none());
292        assert!(ctx.is_idle());
293    }
294
295    #[test]
296    fn test_context_builder_full() {
297        use parking_lot::RwLock;
298        let settings = Arc::new(RwLock::new(Settings::default()));
299        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/home"))
300            .settings(settings)
301            .config(serde_json::json!({"key": "value"}))
302            .session_id("sess-123")
303            .build();
304
305        assert_eq!(ctx.cwd, std::path::PathBuf::from("/home"));
306        assert_eq!(ctx.session_id, Some("sess-123".to_string()));
307        assert_eq!(ctx.config_get("key"), Some(serde_json::json!("value")));
308    }
309
310    #[test]
311    fn test_registry_register_and_collect() {
312        let mut reg = ExtensionRegistry::new();
313        reg.register(Arc::new(NoopExtension));
314        assert_eq!(reg.len(), 1);
315        assert!(!reg.is_empty());
316    }
317
318    #[test]
319    fn test_registry_enable_disable() {
320        let mut reg = ExtensionRegistry::new();
321        let ext = Arc::new(RecordingExtension::new("rec"));
322        reg.register(ext);
323        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
324        assert!(reg.is_enabled("rec"));
325        reg.disable("rec").unwrap();
326        assert!(!reg.is_enabled("rec"));
327        reg.enable("rec", &ctx).unwrap();
328        assert!(reg.is_enabled("rec"));
329    }
330
331    #[test]
332    fn test_emit_load() {
333        let mut reg = ExtensionRegistry::new();
334        let ext = Arc::new(RecordingExtension::new("rec"));
335        reg.register(ext.clone());
336        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
337        reg.emit_load(&ctx);
338        assert_eq!(ext.calls(), vec!["on_load"]);
339    }
340
341    #[test]
342    fn test_graceful_degradation_on_panic() {
343        struct PanickingExtension;
344        impl Extension for PanickingExtension {
345            fn name(&self) -> &str {
346                "panicker"
347            }
348            fn description(&self) -> &str {
349                "Panics"
350            }
351            fn on_load(&self, _ctx: &ExtensionContext) {
352                panic!("intentional panic in on_load");
353            }
354            fn on_message_sent(&self, _msg: &str) {
355                panic!("intentional panic in on_message_sent");
356            }
357        }
358
359        let mut reg = ExtensionRegistry::new();
360        reg.register(Arc::new(PanickingExtension));
361        let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
362        reg.emit_load(&ctx);
363        reg.emit_message_sent("hello");
364        let errors = reg.errors();
365        assert_eq!(errors.len(), 2);
366    }
367
368    #[test]
369    fn test_extension_state_display() {
370        assert_eq!(ExtensionState::Pending.to_string(), "pending");
371        assert_eq!(ExtensionState::Active.to_string(), "active");
372    }
373
374    #[test]
375    fn test_tool_call_emit_result_default() {
376        let result = ToolCallEmitResult::default();
377        assert!(!result.blocked);
378        assert!(result.errors.is_empty());
379    }
380
381    #[test]
382    fn test_runner_new() {
383        let runner = ExtensionRunner::new(std::path::PathBuf::from("/tmp"));
384        assert!(runner.is_empty());
385        assert_eq!(runner.len(), 0);
386    }
387}