1pub 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
17pub 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
39pub use oxi_agent::{AgentEvent, AgentTool, AgentToolResult};
41
42#[derive(Debug, Clone)]
44pub struct ExtensionShortcut {
45 pub key: String,
47 pub description: String,
49 pub action: String,
51}
52
53pub trait Extension: Send + Sync {
56 fn name(&self) -> &str;
58 fn description(&self) -> &str;
60 fn manifest(&self) -> ExtensionManifest {
64 ExtensionManifest::new(self.name(), "0.0.0").with_description(self.description())
65 }
66 fn register_tools(&self) -> Vec<std::sync::Arc<dyn oxi_agent::AgentTool>> {
70 vec![]
71 }
72 fn register_commands(&self) -> Vec<Command> {
76 vec![]
77 }
78 fn on_load(&self, _ctx: &ExtensionContext) {}
81 fn on_unload(&self) {}
84 fn on_message_sent(&self, _msg: &str) {}
87 fn on_message_received(&self, _msg: &str) {}
90 fn on_tool_call(&self, _tool: &str, _params: &serde_json::Value) {}
93 fn on_tool_result(&self, _tool: &str, _result: &oxi_agent::AgentToolResult) {}
96 fn on_session_start(&self, _session_id: &str) {}
99 fn on_session_end(&self, _session_id: &str) {}
102 fn on_settings_changed(&self, _settings: &oxi_store::settings::Settings) {}
104 fn on_event(&self, _event: &oxi_agent::AgentEvent) {}
106 fn on_before_tool_call(
108 &self,
109 _tool: &str,
110 _args: &serde_json::Value,
111 ) -> Result<(), anyhow::Error> {
112 Ok(())
113 }
114 fn on_after_tool_call(
116 &self,
117 _tool: &str,
118 _result: &oxi_agent::AgentToolResult,
119 ) -> Result<(), anyhow::Error> {
120 Ok(())
121 }
122 fn on_before_compaction(&self, _ctx: &crate::CompactionContext) -> Result<(), anyhow::Error> {
124 Ok(())
125 }
126 fn on_after_compaction(&self, _summary: &str) -> Result<(), anyhow::Error> {
128 Ok(())
129 }
130 fn on_error(&self, _error: &anyhow::Error) -> Result<(), anyhow::Error> {
132 Ok(())
133 }
134 fn session_before_switch(
136 &self,
137 _event: &crate::extensions::types::SessionBeforeSwitchEvent,
138 ) -> Result<(), anyhow::Error> {
139 Ok(())
140 }
141 fn session_before_fork(
143 &self,
144 _event: &crate::extensions::types::SessionBeforeForkEvent,
145 ) -> Result<(), anyhow::Error> {
146 Ok(())
147 }
148 fn session_before_compact(
150 &self,
151 _event: &crate::extensions::types::SessionBeforeCompactEvent,
152 ) -> Result<(), anyhow::Error> {
153 Ok(())
154 }
155 fn session_compact(
157 &self,
158 _event: &crate::extensions::types::SessionCompactEvent,
159 ) -> Result<(), anyhow::Error> {
160 Ok(())
161 }
162 fn session_shutdown(&self, _event: &crate::extensions::types::SessionShutdownEvent) {}
164 fn session_before_tree(
166 &self,
167 _event: &crate::extensions::types::SessionBeforeTreeEvent,
168 ) -> Result<(), anyhow::Error> {
169 Ok(())
170 }
171 fn session_tree(&self, _event: &crate::extensions::types::SessionTreeEvent) {}
173 fn context(
175 &self,
176 _event: &mut crate::extensions::types::ContextEvent,
177 ) -> Result<(), anyhow::Error> {
178 Ok(())
179 }
180 fn before_provider_request(
183 &self,
184 _event: &mut crate::extensions::types::BeforeProviderRequestEvent,
185 ) -> Result<(), anyhow::Error> {
186 Ok(())
187 }
188 fn after_provider_response(
191 &self,
192 _event: &crate::extensions::types::AfterProviderResponseEvent,
193 ) -> Result<(), anyhow::Error> {
194 Ok(())
195 }
196 fn model_select(&self, _event: &crate::extensions::types::ModelSelectEvent) {}
198 fn thinking_level_select(&self, _event: &crate::extensions::types::ThinkingLevelSelectEvent) {}
200 fn bash(&self, _event: &crate::extensions::types::BashEvent) {}
202 fn input(
206 &self,
207 _event: &crate::extensions::types::InputEvent,
208 ) -> crate::extensions::types::InputEventResult {
209 crate::extensions::types::InputEventResult::Continue
210 }
211 fn register_shortcuts(&self) -> Vec<ExtensionShortcut> {
214 vec![]
215 }
216}
217
218pub struct NoopExtension;
221impl Extension for NoopExtension {
222 fn name(&self) -> &str {
223 "noop"
224 }
225 fn description(&self) -> &str {
226 "Built-in no-op extension"
227 }
228}
229
230#[cfg(test)]
232pub struct RecordingExtension {
233 pub name: String,
234 pub calls: std::sync::Mutex<Vec<String>>,
235}
236#[cfg(test)]
237impl RecordingExtension {
238 pub fn new(name: impl Into<String>) -> Self {
239 Self {
240 name: name.into(),
241 calls: std::sync::Mutex::new(Vec::new()),
242 }
243 }
244 pub fn push(&self, call: &str) {
245 self.calls.lock().unwrap().push(call.to_string());
246 }
247 pub fn calls(&self) -> Vec<String> {
248 self.calls.lock().unwrap().clone()
249 }
250}
251#[cfg(test)]
252impl Extension for RecordingExtension {
253 fn name(&self) -> &str {
254 &self.name
255 }
256 fn description(&self) -> &str {
257 "recording test extension"
258 }
259 fn on_load(&self, _ctx: &ExtensionContext) {
260 self.push("on_load");
261 }
262 fn on_unload(&self) {
263 self.push("on_unload");
264 }
265 fn on_message_sent(&self, msg: &str) {
266 self.push(&format!("on_message_sent({})", msg));
267 }
268 fn on_message_received(&self, msg: &str) {
269 self.push(&format!("on_message_received({})", msg));
270 }
271 fn on_tool_call(&self, tool: &str, _params: &serde_json::Value) {
272 self.push(&format!("on_tool_call({})", tool));
273 }
274 fn on_tool_result(&self, tool: &str, _result: &oxi_agent::AgentToolResult) {
275 self.push(&format!("on_tool_result({})", tool));
276 }
277 fn on_session_start(&self, session_id: &str) {
278 self.push(&format!("on_session_start({})", session_id));
279 }
280 fn on_session_end(&self, session_id: &str) {
281 self.push(&format!("on_session_end({})", session_id));
282 }
283 fn on_settings_changed(&self, _settings: &oxi_store::settings::Settings) {
284 self.push("on_settings_changed");
285 }
286 fn on_event(&self, _event: &oxi_agent::AgentEvent) {
287 self.push("on_event");
288 }
289}
290
291#[cfg(test)]
293mod tests {
294 use super::*;
295 use oxi_store::settings::Settings;
296 use std::sync::Arc;
297
298 #[test]
299 fn test_manifest_builder() {
300 let manifest = ExtensionManifest::new("my-ext", "1.0.0")
301 .with_description("A test extension")
302 .with_author("test-author")
303 .with_permission(ExtensionPermission::FileRead)
304 .with_permission(ExtensionPermission::Bash)
305 .with_config_schema(serde_json::json!({"type": "object", "properties": {"api_key": {"type": "string"}}}));
306
307 assert_eq!(manifest.name, "my-ext");
308 assert_eq!(manifest.version, "1.0.0");
309 assert_eq!(manifest.description, "A test extension");
310 assert_eq!(manifest.author, "test-author");
311 assert!(manifest.has_permission(ExtensionPermission::FileRead));
312 assert!(manifest.has_permission(ExtensionPermission::Bash));
313 assert!(!manifest.has_permission(ExtensionPermission::Network));
314 }
315
316 #[test]
317 fn test_permission_display() {
318 assert_eq!(ExtensionPermission::FileRead.to_string(), "file_read");
319 assert_eq!(ExtensionPermission::Bash.to_string(), "bash");
320 }
321
322 #[test]
323 fn test_context_builder_minimal() {
324 let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
325 assert_eq!(ctx.cwd, std::path::PathBuf::from("/tmp"));
326 assert!(ctx.session_id.is_none());
327 assert!(ctx.is_idle());
328 }
329
330 #[test]
331 fn test_context_builder_full() {
332 use parking_lot::RwLock;
333 let settings = Arc::new(RwLock::new(Settings::default()));
334 let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/home"))
335 .settings(settings)
336 .config(serde_json::json!({"key": "value"}))
337 .session_id("sess-123")
338 .build();
339
340 assert_eq!(ctx.cwd, std::path::PathBuf::from("/home"));
341 assert_eq!(ctx.session_id, Some("sess-123".to_string()));
342 assert_eq!(ctx.config_get("key"), Some(serde_json::json!("value")));
343 }
344
345 #[test]
346 fn test_registry_register_and_collect() {
347 let mut reg = ExtensionRegistry::new();
348 reg.register(Arc::new(NoopExtension));
349 assert_eq!(reg.len(), 1);
350 assert!(!reg.is_empty());
351 }
352
353 #[test]
354 fn test_registry_enable_disable() {
355 let mut reg = ExtensionRegistry::new();
356 let ext = Arc::new(RecordingExtension::new("rec"));
357 reg.register(ext);
358 let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
359 assert!(reg.is_enabled("rec"));
360 reg.disable("rec").unwrap();
361 assert!(!reg.is_enabled("rec"));
362 reg.enable("rec", &ctx).unwrap();
363 assert!(reg.is_enabled("rec"));
364 }
365
366 #[test]
367 fn test_emit_load() {
368 let mut reg = ExtensionRegistry::new();
369 let ext = Arc::new(RecordingExtension::new("rec"));
370 reg.register(ext.clone());
371 let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
372 reg.emit_load(&ctx);
373 assert_eq!(ext.calls(), vec!["on_load"]);
374 }
375
376 #[test]
377 fn test_graceful_degradation_on_panic() {
378 struct PanickingExtension;
379 impl Extension for PanickingExtension {
380 fn name(&self) -> &str {
381 "panicker"
382 }
383 fn description(&self) -> &str {
384 "Panics"
385 }
386 fn on_load(&self, _ctx: &ExtensionContext) {
387 panic!("intentional panic in on_load");
388 }
389 fn on_message_sent(&self, _msg: &str) {
390 panic!("intentional panic in on_message_sent");
391 }
392 }
393
394 let mut reg = ExtensionRegistry::new();
395 reg.register(Arc::new(PanickingExtension));
396 let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
397 reg.emit_load(&ctx);
398 reg.emit_message_sent("hello");
399 let errors = reg.errors();
400 assert_eq!(errors.len(), 2);
401 }
402
403 #[test]
404 fn test_extension_state_display() {
405 assert_eq!(ExtensionState::Pending.to_string(), "pending");
406 assert_eq!(ExtensionState::Active.to_string(), "active");
407 }
408
409 #[test]
410 fn test_tool_call_emit_result_default() {
411 let result = ToolCallEmitResult::default();
412 assert!(!result.blocked);
413 assert!(result.errors.is_empty());
414 }
415
416 #[test]
417 fn test_runner_new() {
418 let runner = ExtensionRunner::new(std::path::PathBuf::from("/tmp"));
419 assert!(runner.is_empty());
420 assert_eq!(runner.len(), 0);
421 }
422}