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