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 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
58pub fn ensure_notification_relay(
74 deps: &NotificationRelayDeps,
75 session_id: &str,
76 sender: broadcast::Sender<AgentEvent>,
77) {
78 if !deps.notification_service.try_begin_relay(session_id) {
79 return;
80 }
81 let service = deps.notification_service.clone();
82 let senders = deps.session_event_senders.clone();
83 let watchers = deps.session_watchers.clone();
84 let config = deps.config.clone();
85 let sid = session_id.to_string();
86 let mut rx = sender.subscribe();
87 drop(sender);
88 tokio::spawn(async move {
89 use tokio::sync::broadcast::error::RecvError;
90 loop {
91 match rx.recv().await {
92 Ok(event) => {
93 if let Some(notification) = service.notify(&sid, &event) {
94 let sink_notification =
97 crate::notify_sinks::SinkNotification::from_event(¬ification);
98
99 let tx = senders.read().await.get(&sid).cloned();
100 if let Some(tx) = tx {
101 let _ = tx.send(notification);
102 }
103
104 if let Some(sink_notification) = sink_notification {
105 let has_watcher = watchers.has_watcher(&sid);
106 let config_snapshot = config.read().await.clone();
107 super::AppState::dispatch_to_sinks(
108 &config_snapshot,
109 has_watcher,
110 &sink_notification,
111 );
112 }
113 }
114 }
115 Err(RecvError::Lagged(_)) => continue,
116 Err(RecvError::Closed) => break,
117 }
118 }
119 service.end_relay(&sid);
120 });
121}
122
123impl super::AppState {
124 pub async fn get_session_event_sender(
129 &self,
130 session_id: &str,
131 ) -> tokio::sync::broadcast::Sender<bamboo_agent_core::AgentEvent> {
132 get_or_create_event_sender(&self.session_event_senders, session_id).await
133 }
134
135 pub fn notification_relay_deps(&self) -> NotificationRelayDeps {
138 NotificationRelayDeps {
139 notification_service: self.notification_service.clone(),
140 session_event_senders: self.session_event_senders.clone(),
141 session_watchers: self.session_watchers.clone(),
142 config: self.config.clone(),
143 }
144 }
145
146 pub fn ensure_notification_relay(
149 &self,
150 session_id: &str,
151 sender: tokio::sync::broadcast::Sender<bamboo_agent_core::AgentEvent>,
152 ) {
153 ensure_notification_relay(&self.notification_relay_deps(), session_id, sender);
154 }
155
156 pub fn dispatch_to_sinks(
162 config: &bamboo_llm::Config,
163 has_watcher: bool,
164 notification: &crate::notify_sinks::SinkNotification,
165 ) {
166 crate::notify_sinks::dispatch_to_sinks(config, has_watcher, notification);
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use std::time::Duration;
174
175 fn test_deps() -> (NotificationRelayDeps, tempfile::TempDir) {
176 let dir = tempfile::tempdir().unwrap();
177 let notification_service = Arc::new(bamboo_notification::NotificationService::new(
178 dir.path().join("prefs.json"),
179 ));
180 let deps = NotificationRelayDeps {
181 notification_service,
182 session_event_senders: Arc::new(RwLock::new(HashMap::new())),
183 session_watchers: SessionWatchers::new(),
184 config: Arc::new(tokio::sync::RwLock::new(bamboo_llm::Config::default())),
185 };
186 (deps, dir)
187 }
188
189 #[tokio::test]
190 async fn ensure_notification_relay_is_idempotent_and_classifies_events() {
191 let (deps, _dir) = test_deps();
192 let (tx, mut rx) = broadcast::channel(16);
193 deps.session_event_senders
194 .write()
195 .await
196 .insert("sess-1".to_string(), tx.clone());
197
198 ensure_notification_relay(&deps, "sess-1", tx.clone());
199 assert!(!deps.notification_service.try_begin_relay("sess-1"));
202
203 tx.send(AgentEvent::NeedClarification {
204 question: "Which file?".to_string(),
205 options: None,
206 tool_call_id: Some("tc-1".to_string()),
207 tool_name: None,
208 allow_custom: true,
209 })
210 .unwrap();
211
212 let category = tokio::time::timeout(Duration::from_secs(2), async {
216 loop {
217 if let AgentEvent::Notification { category, .. } = rx.recv().await.unwrap() {
218 return category;
219 }
220 }
221 })
222 .await
223 .expect("relay should classify and re-broadcast within the timeout");
224 assert_eq!(category, "needs_clarification");
225 }
226
227 #[tokio::test]
228 async fn ensure_notification_relay_exits_when_broadcast_channel_closes() {
229 let (deps, _dir) = test_deps();
230 let (tx, rx) = broadcast::channel::<AgentEvent>(16);
231
232 ensure_notification_relay(&deps, "sess-closed", tx.clone());
233 drop(tx);
236 drop(rx);
237
238 let relay_ended = tokio::time::timeout(Duration::from_secs(2), async {
243 loop {
244 if deps.notification_service.try_begin_relay("sess-closed") {
245 return;
246 }
247 tokio::time::sleep(Duration::from_millis(10)).await;
248 }
249 })
250 .await;
251 assert!(
252 relay_ended.is_ok(),
253 "relay task did not exit after its broadcast channel closed"
254 );
255 }
256}