bamboo_server/app_state/
session_events.rs1use 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#[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
35pub 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 let sink_notification =
74 crate::notify_sinks::SinkNotification::from_event(¬ification);
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 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 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 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 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 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 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(tx);
213 drop(rx);
214
215 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}