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/// Ensure a notification relay task is running for `session_id`.
36///
37/// The relay subscribes to the session's event broadcast, runs the backend
38/// notification policy on each event, and:
39/// - re-broadcasts any resulting `AgentEvent::Notification` onto the same
40///   channel so connected SSE/WS clients receive it, and
41/// - fans it out to the configured delivery sinks (desktop/ntfy/bark — see
42///   [`crate::notify_sinks::dispatch_to_sinks`]), reading the CURRENT config
43///   on every notification so a hot-reloaded topic/token/toggle takes effect
44///   immediately.
45///
46/// Idempotent: at most one relay per session (tracked by the notification
47/// service via `try_begin_relay`). The relay does not hold a `Sender` clone,
48/// so the channel still closes naturally (`RecvError::Closed`) when the
49/// session is torn down, and this task exits cleanly with it — nothing leaks.
50pub fn ensure_notification_relay(
51    deps: &NotificationRelayDeps,
52    session_id: &str,
53    sender: broadcast::Sender<AgentEvent>,
54) {
55    if !deps.notification_service.try_begin_relay(session_id) {
56        return;
57    }
58    let service = deps.notification_service.clone();
59    let senders = deps.session_event_senders.clone();
60    let watchers = deps.session_watchers.clone();
61    let config = deps.config.clone();
62    let sid = session_id.to_string();
63    let mut rx = sender.subscribe();
64    drop(sender);
65    tokio::spawn(async move {
66        use tokio::sync::broadcast::error::RecvError;
67        loop {
68            match rx.recv().await {
69                Ok(event) => {
70                    if let Some(notification) = service.notify(&sid, &event) {
71                        // Build the sink payload before `notification` is moved
72                        // into `tx.send` below.
73                        let sink_notification =
74                            crate::notify_sinks::SinkNotification::from_event(&notification);
75
76                        let tx = senders.read().await.get(&sid).cloned();
77                        if let Some(tx) = tx {
78                            let _ = tx.send(notification);
79                        }
80
81                        if let Some(sink_notification) = sink_notification {
82                            let has_watcher = watchers.has_watcher(&sid);
83                            let config_snapshot = config.read().await.clone();
84                            super::AppState::dispatch_to_sinks(
85                                &config_snapshot,
86                                has_watcher,
87                                &sink_notification,
88                            );
89                        }
90                    }
91                }
92                Err(RecvError::Lagged(_)) => continue,
93                Err(RecvError::Closed) => break,
94            }
95        }
96        service.end_relay(&sid);
97    });
98}
99
100impl super::AppState {
101    /// Get (or create) a long-lived session event sender for a session id.
102    ///
103    /// This stream is intended for UI consumption and background activity; it should remain
104    /// available even when no agent execution is running.
105    pub async fn get_session_event_sender(
106        &self,
107        session_id: &str,
108    ) -> tokio::sync::broadcast::Sender<bamboo_agent_core::AgentEvent> {
109        get_or_create_event_sender(&self.session_event_senders, session_id).await
110    }
111
112    /// Bundles this `AppState`'s notification-relay dependencies (see
113    /// [`NotificationRelayDeps`]).
114    pub fn notification_relay_deps(&self) -> NotificationRelayDeps {
115        NotificationRelayDeps {
116            notification_service: self.notification_service.clone(),
117            session_event_senders: self.session_event_senders.clone(),
118            session_watchers: self.session_watchers.clone(),
119            config: self.config.clone(),
120        }
121    }
122
123    /// Ensure a notification relay task is running for `session_id`. See
124    /// [`ensure_notification_relay`].
125    pub fn ensure_notification_relay(
126        &self,
127        session_id: &str,
128        sender: tokio::sync::broadcast::Sender<bamboo_agent_core::AgentEvent>,
129    ) {
130        ensure_notification_relay(&self.notification_relay_deps(), session_id, sender);
131    }
132
133    /// Fans a classified notification out to configured delivery sinks
134    /// (desktop/ntfy/bark). Free of `&self` — the relay task started by
135    /// [`ensure_notification_relay`] only holds cloned `Arc`s (not a full
136    /// `AppState`), so this is a plain associated function over the values
137    /// it needs; see [`crate::notify_sinks::dispatch_to_sinks`].
138    pub fn dispatch_to_sinks(
139        config: &bamboo_llm::Config,
140        has_watcher: bool,
141        notification: &crate::notify_sinks::SinkNotification,
142    ) {
143        crate::notify_sinks::dispatch_to_sinks(config, has_watcher, notification);
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use std::time::Duration;
151
152    fn test_deps() -> (NotificationRelayDeps, tempfile::TempDir) {
153        let dir = tempfile::tempdir().unwrap();
154        let notification_service = Arc::new(bamboo_notification::NotificationService::new(
155            dir.path().join("prefs.json"),
156        ));
157        let deps = NotificationRelayDeps {
158            notification_service,
159            session_event_senders: Arc::new(RwLock::new(HashMap::new())),
160            session_watchers: SessionWatchers::new(),
161            config: Arc::new(tokio::sync::RwLock::new(bamboo_llm::Config::default())),
162        };
163        (deps, dir)
164    }
165
166    #[tokio::test]
167    async fn ensure_notification_relay_is_idempotent_and_classifies_events() {
168        let (deps, _dir) = test_deps();
169        let (tx, mut rx) = broadcast::channel(16);
170        deps.session_event_senders
171            .write()
172            .await
173            .insert("sess-1".to_string(), tx.clone());
174
175        ensure_notification_relay(&deps, "sess-1", tx.clone());
176        // A second call for the same session must be a no-op: `try_begin_relay`
177        // only returns `true` once per session while a relay is active.
178        assert!(!deps.notification_service.try_begin_relay("sess-1"));
179
180        tx.send(AgentEvent::NeedClarification {
181            question: "Which file?".to_string(),
182            options: None,
183            tool_call_id: Some("tc-1".to_string()),
184            tool_name: None,
185            allow_custom: true,
186        })
187        .unwrap();
188
189        // The relay re-broadcasts the classified `Notification` onto the same
190        // channel, after the raw event this subscriber also echoes back to
191        // itself — loop past that echo (bounded by the outer timeout).
192        let category = tokio::time::timeout(Duration::from_secs(2), async {
193            loop {
194                if let AgentEvent::Notification { category, .. } = rx.recv().await.unwrap() {
195                    return category;
196                }
197            }
198        })
199        .await
200        .expect("relay should classify and re-broadcast within the timeout");
201        assert_eq!(category, "needs_clarification");
202    }
203
204    #[tokio::test]
205    async fn ensure_notification_relay_exits_when_broadcast_channel_closes() {
206        let (deps, _dir) = test_deps();
207        let (tx, rx) = broadcast::channel::<AgentEvent>(16);
208
209        ensure_notification_relay(&deps, "sess-closed", tx.clone());
210        // Drop every `Sender` clone (the relay's own clone is dropped
211        // internally right after subscribing) so the channel actually closes.
212        drop(tx);
213        drop(rx);
214
215        // On `RecvError::Closed` the relay breaks its loop and calls
216        // `end_relay`, which frees the session for `try_begin_relay` again.
217        // Poll (bounded) instead of asserting immediately — the task's exit
218        // is asynchronous from this test's perspective.
219        let relay_ended = tokio::time::timeout(Duration::from_secs(2), async {
220            loop {
221                if deps.notification_service.try_begin_relay("sess-closed") {
222                    return;
223                }
224                tokio::time::sleep(Duration::from_millis(10)).await;
225            }
226        })
227        .await;
228        assert!(
229            relay_ended.is_ok(),
230            "relay task did not exit after its broadcast channel closed"
231        );
232    }
233}