Skip to main content

bamboo_server/app_state/
session_events.rs

1//! Session event sender management + the always-on notification relay.
2//!
3//! Re-exports the shared `get_or_create_event_sender` function from the
4//! runtime crate. The `impl AppState` methods delegate to it and to the
5//! free [`ensure_notification_relay`] below.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use bamboo_agent_core::AgentEvent;
11use tokio::sync::{broadcast, RwLock};
12
13use super::watchers::SessionWatchers;
14
15pub use bamboo_engine::execution::session_events::get_or_create_event_sender;
16
17/// Dependencies the always-on notification relay needs, independent of a
18/// full `AppState`.
19///
20/// Two call sites start this relay: [`super::AppState::ensure_notification_relay`]
21/// (interactive/resume execution, and SSE/WS client subscribe) and the
22/// schedule manager (`schedule_app::manager`, headless/scheduled runs — which
23/// hold their own trimmed `ScheduleContext` rather than a full `AppState`,
24/// since scheduled runs use a minimal tool/session surface). Bundling the
25/// four `Arc`/cheap-clone fields the relay task actually touches lets both
26/// callers share one implementation instead of two copies drifting apart.
27#[derive(Clone)]
28pub struct NotificationRelayDeps {
29    pub notification_service: Arc<bamboo_notification::NotificationService>,
30    pub session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
31    pub session_watchers: Arc<SessionWatchers>,
32    pub config: Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
33}
34
35/// Server-owned observer setup injected into the engine's canonical child
36/// scheduler. Both queued tool launches and reserved SessionInbox activations
37/// therefore start the same always-on relay before child execution.
38pub struct NotificationRelayLaunchHook {
39    deps: NotificationRelayDeps,
40}
41
42impl NotificationRelayLaunchHook {
43    pub fn new(deps: NotificationRelayDeps) -> Self {
44        Self { deps }
45    }
46}
47
48impl bamboo_engine::execution::ChildRunLaunchHook for NotificationRelayLaunchHook {
49    fn before_child_launch(
50        &self,
51        job: &bamboo_engine::execution::SpawnJob,
52        child_events: broadcast::Sender<AgentEvent>,
53    ) {
54        ensure_notification_relay(&self.deps, &job.child_session_id, child_events);
55    }
56}
57
58/// Ensure a notification relay task is running for `session_id`.
59///
60/// The relay subscribes to the session's event broadcast, runs the backend
61/// notification policy on each event, and:
62/// - re-broadcasts any resulting `AgentEvent::Notification` onto the same
63///   channel so connected SSE/WS clients receive it, and
64/// - fans it out to the configured delivery sinks (command/desktop/ntfy/bark — see
65///   [`crate::notify_sinks::dispatch_to_sinks`]), reading the CURRENT config
66///   on every notification so a hot-reloaded topic/token/toggle takes effect
67///   immediately.
68///
69/// Idempotent for the exact session channel. Its RAII subscription releases the
70/// registration on normal close, cancellation, or panic, and permits a recreated
71/// channel to replace a still-draining old relay without losing notifications.
72pub fn ensure_notification_relay(
73    deps: &NotificationRelayDeps,
74    session_id: &str,
75    sender: broadcast::Sender<AgentEvent>,
76) {
77    let Some(mut subscription) = deps.session_watchers.begin_notification_relay(
78        deps.notification_service.clone(),
79        session_id,
80        &sender,
81    ) else {
82        return;
83    };
84    let service = deps.notification_service.clone();
85    let channel = sender.downgrade();
86    let watchers = deps.session_watchers.clone();
87    let config = deps.config.clone();
88    let sid = session_id.to_string();
89    drop(sender);
90    tokio::spawn(async move {
91        use tokio::sync::broadcast::error::RecvError;
92        loop {
93            match subscription.recv().await {
94                Ok(event) => {
95                    if let Some(notification) = service.notify(&sid, &event) {
96                        // Build the sink payload before `notification` is moved
97                        // into `tx.send` below.
98                        let sink_notification =
99                            crate::notify_sinks::SinkNotification::from_event(&notification);
100
101                        // Publish back onto this generation only. Re-reading
102                        // the map could inject a delayed old notification into
103                        // a replacement channel after idle eviction/resume.
104                        if let Some(tx) = channel.upgrade() {
105                            let _ = tx.send(notification);
106                        }
107
108                        if let Some(sink_notification) = sink_notification {
109                            let has_watcher = watchers.has_watcher(&sid);
110                            let config_snapshot = config.read().await.clone();
111                            super::AppState::dispatch_to_sinks(
112                                &config_snapshot,
113                                has_watcher,
114                                &sink_notification,
115                            );
116                        }
117                    }
118                }
119                Err(RecvError::Lagged(_)) => continue,
120                Err(RecvError::Closed) => break,
121            }
122        }
123    });
124}
125
126impl super::AppState {
127    /// Get (or create) a long-lived session event sender for a session id.
128    ///
129    /// This stream is intended for UI consumption and background activity; it should remain
130    /// available even when no agent execution is running.
131    pub async fn get_session_event_sender(
132        &self,
133        session_id: &str,
134    ) -> tokio::sync::broadcast::Sender<bamboo_agent_core::AgentEvent> {
135        get_or_create_event_sender(&self.session_event_senders, session_id).await
136    }
137
138    /// Bundles this `AppState`'s notification-relay dependencies (see
139    /// [`NotificationRelayDeps`]).
140    pub fn notification_relay_deps(&self) -> NotificationRelayDeps {
141        NotificationRelayDeps {
142            notification_service: self.notification_service.clone(),
143            session_event_senders: self.session_event_senders.clone(),
144            session_watchers: self.session_watchers.clone(),
145            config: self.config.clone(),
146        }
147    }
148
149    /// Ensure a notification relay task is running for `session_id`. See
150    /// [`ensure_notification_relay`].
151    pub fn ensure_notification_relay(
152        &self,
153        session_id: &str,
154        sender: tokio::sync::broadcast::Sender<bamboo_agent_core::AgentEvent>,
155    ) {
156        ensure_notification_relay(&self.notification_relay_deps(), session_id, sender);
157    }
158
159    /// Fans a classified notification out to configured delivery sinks
160    /// (command/desktop/ntfy/bark). Free of `&self` — the relay task started by
161    /// [`ensure_notification_relay`] only holds cloned `Arc`s (not a full
162    /// `AppState`), so this is a plain associated function over the values
163    /// it needs; see [`crate::notify_sinks::dispatch_to_sinks`].
164    pub fn dispatch_to_sinks(
165        config: &bamboo_llm::Config,
166        has_watcher: bool,
167        notification: &crate::notify_sinks::SinkNotification,
168    ) {
169        crate::notify_sinks::dispatch_to_sinks(config, has_watcher, notification);
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use std::time::Duration;
177
178    fn test_deps() -> (NotificationRelayDeps, tempfile::TempDir) {
179        let dir = tempfile::tempdir().unwrap();
180        let notification_service = Arc::new(bamboo_notification::NotificationService::new(
181            dir.path().join("prefs.json"),
182        ));
183        let deps = NotificationRelayDeps {
184            notification_service,
185            session_event_senders: Arc::new(RwLock::new(HashMap::new())),
186            session_watchers: SessionWatchers::new(),
187            config: Arc::new(tokio::sync::RwLock::new(bamboo_llm::Config::default())),
188        };
189        (deps, dir)
190    }
191
192    #[tokio::test]
193    async fn ensure_notification_relay_is_idempotent_and_classifies_events() {
194        let (deps, _dir) = test_deps();
195        let (tx, mut rx) = broadcast::channel(16);
196        deps.session_event_senders
197            .write()
198            .await
199            .insert("sess-1".to_string(), tx.clone());
200
201        ensure_notification_relay(&deps, "sess-1", tx.clone());
202        // A second call for the same session must be a no-op: `try_begin_relay`
203        // only returns `true` once per session while a relay is active.
204        assert!(!deps.notification_service.try_begin_relay("sess-1"));
205
206        tx.send(AgentEvent::NeedClarification {
207            question: "Which file?".to_string(),
208            options: None,
209            tool_call_id: Some("tc-1".to_string()),
210            tool_name: None,
211            allow_custom: true,
212            source: None,
213        })
214        .unwrap();
215
216        // The relay re-broadcasts the classified `Notification` onto the same
217        // channel, after the raw event this subscriber also echoes back to
218        // itself — loop past that echo (bounded by the outer timeout).
219        let category = tokio::time::timeout(Duration::from_secs(2), async {
220            loop {
221                if let AgentEvent::Notification { category, .. } = rx.recv().await.unwrap() {
222                    return category;
223                }
224            }
225        })
226        .await
227        .expect("relay should classify and re-broadcast within the timeout");
228        assert_eq!(category, "needs_clarification");
229    }
230
231    #[tokio::test]
232    async fn ensure_notification_relay_exits_when_broadcast_channel_closes() {
233        let (deps, _dir) = test_deps();
234        let (tx, rx) = broadcast::channel::<AgentEvent>(16);
235
236        ensure_notification_relay(&deps, "sess-closed", tx.clone());
237        // Drop every `Sender` clone (the relay's own clone is dropped
238        // internally right after subscribing) so the channel actually closes.
239        drop(tx);
240        drop(rx);
241
242        // On `RecvError::Closed` the relay breaks its loop and calls
243        // `end_relay`, which frees the session for `try_begin_relay` again.
244        // Poll (bounded) instead of asserting immediately — the task's exit
245        // is asynchronous from this test's perspective.
246        let relay_ended = tokio::time::timeout(Duration::from_secs(2), async {
247            loop {
248                if deps.notification_service.try_begin_relay("sess-closed") {
249                    return;
250                }
251                tokio::time::sleep(Duration::from_millis(10)).await;
252            }
253        })
254        .await;
255        assert!(
256            relay_ended.is_ok(),
257            "relay task did not exit after its broadcast channel closed"
258        );
259    }
260}