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(
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 let sink_notification =
99 crate::notify_sinks::SinkNotification::from_event(¬ification);
100
101 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 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 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 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 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 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 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(tx);
240 drop(rx);
241
242 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}