Skip to main content

agent_engine/extensions/
loader.rs

1//! Async extension loading orchestration.
2//!
3//! The chat UI owns the manager behind an async lock; this module keeps startup
4//! snappy by running discovery/loading in the background and streaming progress
5//! events back to the UI (which can render them as toasts).
6
7use std::sync::Arc;
8
9use tokio::sync::{mpsc, RwLock};
10
11use super::hooks::events::{HookEvent, HookResult};
12use super::manager::{ExtensionLoadFailure, ExtensionManager};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum ExtensionLoaderEvent {
16    Started,
17    Loaded {
18        plugin: String,
19        loaded: usize,
20        failed: usize,
21    },
22    Failed {
23        failure: ExtensionLoadFailure,
24        loaded: usize,
25        failed: usize,
26    },
27    Finished {
28        loaded: Vec<String>,
29        failed: Vec<ExtensionLoadFailure>,
30    },
31}
32
33impl ExtensionLoaderEvent {
34    pub fn progress_counts(&self) -> Option<(usize, usize)> {
35        match self {
36            ExtensionLoaderEvent::Started => Some((0, 0)),
37            ExtensionLoaderEvent::Loaded { loaded, failed, .. } => Some((*loaded, *failed)),
38            ExtensionLoaderEvent::Failed { loaded, failed, .. } => Some((*loaded, *failed)),
39            ExtensionLoaderEvent::Finished { loaded, failed } => Some((loaded.len(), failed.len())),
40        }
41    }
42}
43
44/// Discover and load extensions in the background, then fire `on_session_start`.
45///
46/// The hook is emitted HERE, not at engine boot, and that placement is the
47/// whole point. `engine::setup::boot()` used to emit it while constructing an
48/// empty `ExtensionManager` — before any host had called this function — so
49/// the event reached zero subscribers in every host (TUI, server, rpc). No
50/// extension had ever received `on_session_start`. A comment in
51/// `cmd/server.rs` recorded the symptom ("no subscribers until extensions
52/// loaded") without it being recognised as a defect.
53///
54/// `session_id` is the session the hook reports. `None` skips the emit, for
55/// callers that have no session (or extensions disabled).
56pub fn spawn_discover_and_load(
57    manager: Arc<RwLock<ExtensionManager>>,
58    tx: mpsc::UnboundedSender<ExtensionLoaderEvent>,
59    session_id: Option<String>,
60) -> tokio::task::JoinHandle<()> {
61    tokio::spawn(async move {
62        let _ = tx.send(ExtensionLoaderEvent::Started);
63        let (loaded, failed) = manager
64            .write()
65            .await
66            .discover_and_load_with_progress(|event| {
67                let _ = tx.send(event);
68            })
69            .await;
70
71        // Extensions are now subscribed; the event has somewhere to land.
72        if let Some(session_id) = session_id {
73            let hook_bus = manager.read().await.hook_bus().clone();
74            let event = HookEvent::on_session_start(&session_id);
75            if let HookResult::Inject { content } = hook_bus.emit(&event).await {
76                tracing::debug!(
77                    len = content.len(),
78                    "on_session_start injected session-scoped context"
79                );
80                hook_bus.set_session_injection(content).await;
81            }
82        }
83
84        let _ = tx.send(ExtensionLoaderEvent::Finished { loaded, failed });
85    })
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn progress_counts_report_finished_totals() {
94        let event = ExtensionLoaderEvent::Finished {
95            loaded: vec!["a".into(), "b".into()],
96            failed: vec![ExtensionLoadFailure {
97                plugin: "bad".into(),
98                manifest_path: None,
99                reason: "oops".into(),
100                hint: "fix it".into(),
101            }],
102        };
103        assert_eq!(event.progress_counts(), Some((2, 1)));
104    }
105}