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(
208 &self,
209 _event: &crate::extensions::types::InputEvent,
210 ) -> crate::extensions::types::InputEventResult {
211 crate::extensions::types::InputEventResult::Continue
212 }
213 fn register_shortcuts(&self) -> Vec<ExtensionShortcut> {
216 vec![]
217 }
218}
219
220pub struct NoopExtension;
223impl Extension for NoopExtension {
224 fn name(&self) -> &str {
225 "noop"
226 }
227 fn description(&self) -> &str {
228 "Built-in no-op extension"
229 }
230}
231
232#[cfg(test)]
234pub struct RecordingExtension {
235 pub name: String,
236 pub calls: std::sync::Mutex<Vec<String>>,
237}
238#[cfg(test)]
239impl RecordingExtension {
240 pub fn new(name: impl Into<String>) -> Self {
241 Self {
242 name: name.into(),
243 calls: std::sync::Mutex::new(Vec::new()),
244 }
245 }
246 pub fn push(&self, call: &str) {
247 self.calls.lock().unwrap().push(call.to_string());
248 }
249 pub fn calls(&self) -> Vec<String> {
250 self.calls.lock().unwrap().clone()
251 }
252}
253#[cfg(test)]
254impl Extension for RecordingExtension {
255 fn name(&self) -> &str {
256 &self.name
257 }
258 fn description(&self) -> &str {
259 "recording test extension"
260 }
261 fn on_load(&self, _ctx: &ExtensionContext) {
262 self.push("on_load");
263 }
264 fn on_unload(&self) {
265 self.push("on_unload");
266 }
267 fn on_message_sent(&self, msg: &str) {
268 self.push(&format!("on_message_sent({})", msg));
269 }
270 fn on_message_received(&self, msg: &str) {
271 self.push(&format!("on_message_received({})", msg));
272 }
273 fn on_tool_call(&self, tool: &str, _params: &serde_json::Value) {
274 self.push(&format!("on_tool_call({})", tool));
275 }
276 fn on_tool_result(&self, tool: &str, _result: &oxi_agent::AgentToolResult) {
277 self.push(&format!("on_tool_result({})", tool));
278 }
279 fn on_session_start(&self, session_id: &str) {
280 self.push(&format!("on_session_start({})", session_id));
281 }
282 fn on_session_end(&self, session_id: &str) {
283 self.push(&format!("on_session_end({})", session_id));
284 }
285 fn on_settings_changed(&self, _settings: &oxi_store::settings::Settings) {
286 self.push("on_settings_changed");
287 }
288 fn on_event(&self, _event: &oxi_agent::AgentEvent) {
289 self.push("on_event");
290 }
291}
292
293#[cfg(test)]
295mod tests {
296 use super::*;
297 use oxi_store::settings::Settings;
298 use std::sync::Arc;
299
300 #[test]
301 fn test_manifest_builder() {
302 let manifest = ExtensionManifest::new("my-ext", "1.0.0")
303 .with_description("A test extension")
304 .with_author("test-author")
305 .with_permission(ExtensionPermission::FileRead)
306 .with_permission(ExtensionPermission::Bash)
307 .with_config_schema(serde_json::json!({"type": "object", "properties": {"api_key": {"type": "string"}}}));
308
309 assert_eq!(manifest.name, "my-ext");
310 assert_eq!(manifest.version, "1.0.0");
311 assert_eq!(manifest.description, "A test extension");
312 assert_eq!(manifest.author, "test-author");
313 assert!(manifest.has_permission(ExtensionPermission::FileRead));
314 assert!(manifest.has_permission(ExtensionPermission::Bash));
315 assert!(!manifest.has_permission(ExtensionPermission::Network));
316 }
317
318 #[test]
319 fn test_permission_display() {
320 assert_eq!(ExtensionPermission::FileRead.to_string(), "file_read");
321 assert_eq!(ExtensionPermission::Bash.to_string(), "bash");
322 }
323
324 #[test]
325 fn test_context_builder_minimal() {
326 let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
327 assert_eq!(ctx.cwd, std::path::PathBuf::from("/tmp"));
328 assert!(ctx.session_id.is_none());
329 assert!(ctx.is_idle());
330 }
331
332 #[test]
333 fn test_context_builder_full() {
334 use parking_lot::RwLock;
335 let settings = Arc::new(RwLock::new(Settings::default()));
336 let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/home"))
337 .settings(settings)
338 .config(serde_json::json!({"key": "value"}))
339 .session_id("sess-123")
340 .build();
341
342 assert_eq!(ctx.cwd, std::path::PathBuf::from("/home"));
343 assert_eq!(ctx.session_id, Some("sess-123".to_string()));
344 assert_eq!(ctx.config_get("key"), Some(serde_json::json!("value")));
345 }
346
347 #[test]
348 fn test_registry_register_and_collect() {
349 let mut reg = ExtensionRegistry::new();
350 reg.register(Arc::new(NoopExtension));
351 assert_eq!(reg.len(), 1);
352 assert!(!reg.is_empty());
353 }
354
355 #[test]
356 fn test_registry_enable_disable() {
357 let mut reg = ExtensionRegistry::new();
358 let ext = Arc::new(RecordingExtension::new("rec"));
359 reg.register(ext);
360 let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
361 assert!(reg.is_enabled("rec"));
362 reg.disable("rec").unwrap();
363 assert!(!reg.is_enabled("rec"));
364 reg.enable("rec", &ctx).unwrap();
365 assert!(reg.is_enabled("rec"));
366 }
367
368 #[test]
369 fn test_emit_load() {
370 let mut reg = ExtensionRegistry::new();
371 let ext = Arc::new(RecordingExtension::new("rec"));
372 reg.register(ext.clone());
373 let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
374 reg.emit_load(&ctx);
375 assert_eq!(ext.calls(), vec!["on_load"]);
376 }
377
378 #[test]
379 fn test_graceful_degradation_on_panic() {
380 struct PanickingExtension;
381 impl Extension for PanickingExtension {
382 fn name(&self) -> &str {
383 "panicker"
384 }
385 fn description(&self) -> &str {
386 "Panics"
387 }
388 fn on_load(&self, _ctx: &ExtensionContext) {
389 panic!("intentional panic in on_load");
390 }
391 fn on_message_sent(&self, _msg: &str) {
392 panic!("intentional panic in on_message_sent");
393 }
394 }
395
396 let mut reg = ExtensionRegistry::new();
397 reg.register(Arc::new(PanickingExtension));
398 let ctx = ExtensionContextBuilder::new(std::path::PathBuf::from("/tmp")).build();
399 reg.emit_load(&ctx);
400 reg.emit_message_sent("hello");
401 let errors = reg.errors();
402 assert_eq!(errors.len(), 2);
403 }
404
405 #[test]
406 fn test_extension_state_display() {
407 assert_eq!(ExtensionState::Pending.to_string(), "pending");
408 assert_eq!(ExtensionState::Active.to_string(), "active");
409 }
410
411 #[test]
412 fn test_tool_call_emit_result_default() {
413 let result = ToolCallEmitResult::default();
414 assert!(!result.blocked);
415 assert!(result.errors.is_empty());
416 }
417
418 #[test]
419 fn test_runner_new() {
420 let runner = ExtensionRunner::new(std::path::PathBuf::from("/tmp"));
421 assert!(runner.is_empty());
422 assert_eq!(runner.len(), 0);
423 }
424}