use std::collections::HashMap;
use std::sync::Arc;
use bamboo_agent_core::AgentEvent;
use tokio::sync::{broadcast, RwLock};
use super::watchers::SessionWatchers;
pub use bamboo_engine::execution::session_events::get_or_create_event_sender;
#[derive(Clone)]
pub struct NotificationRelayDeps {
pub notification_service: Arc<bamboo_notification::NotificationService>,
pub session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
pub session_watchers: Arc<SessionWatchers>,
pub config: Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
}
pub fn ensure_notification_relay(
deps: &NotificationRelayDeps,
session_id: &str,
sender: broadcast::Sender<AgentEvent>,
) {
if !deps.notification_service.try_begin_relay(session_id) {
return;
}
let service = deps.notification_service.clone();
let senders = deps.session_event_senders.clone();
let watchers = deps.session_watchers.clone();
let config = deps.config.clone();
let sid = session_id.to_string();
let mut rx = sender.subscribe();
drop(sender);
tokio::spawn(async move {
use tokio::sync::broadcast::error::RecvError;
loop {
match rx.recv().await {
Ok(event) => {
if let Some(notification) = service.notify(&sid, &event) {
let sink_notification =
crate::notify_sinks::SinkNotification::from_event(¬ification);
let tx = senders.read().await.get(&sid).cloned();
if let Some(tx) = tx {
let _ = tx.send(notification);
}
if let Some(sink_notification) = sink_notification {
let has_watcher = watchers.has_watcher(&sid);
let config_snapshot = config.read().await.clone();
super::AppState::dispatch_to_sinks(
&config_snapshot,
has_watcher,
&sink_notification,
);
}
}
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => break,
}
}
service.end_relay(&sid);
});
}
impl super::AppState {
pub async fn get_session_event_sender(
&self,
session_id: &str,
) -> tokio::sync::broadcast::Sender<bamboo_agent_core::AgentEvent> {
get_or_create_event_sender(&self.session_event_senders, session_id).await
}
pub fn notification_relay_deps(&self) -> NotificationRelayDeps {
NotificationRelayDeps {
notification_service: self.notification_service.clone(),
session_event_senders: self.session_event_senders.clone(),
session_watchers: self.session_watchers.clone(),
config: self.config.clone(),
}
}
pub fn ensure_notification_relay(
&self,
session_id: &str,
sender: tokio::sync::broadcast::Sender<bamboo_agent_core::AgentEvent>,
) {
ensure_notification_relay(&self.notification_relay_deps(), session_id, sender);
}
pub fn dispatch_to_sinks(
config: &bamboo_llm::Config,
has_watcher: bool,
notification: &crate::notify_sinks::SinkNotification,
) {
crate::notify_sinks::dispatch_to_sinks(config, has_watcher, notification);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn test_deps() -> (NotificationRelayDeps, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let notification_service = Arc::new(bamboo_notification::NotificationService::new(
dir.path().join("prefs.json"),
));
let deps = NotificationRelayDeps {
notification_service,
session_event_senders: Arc::new(RwLock::new(HashMap::new())),
session_watchers: SessionWatchers::new(),
config: Arc::new(tokio::sync::RwLock::new(bamboo_llm::Config::default())),
};
(deps, dir)
}
#[tokio::test]
async fn ensure_notification_relay_is_idempotent_and_classifies_events() {
let (deps, _dir) = test_deps();
let (tx, mut rx) = broadcast::channel(16);
deps.session_event_senders
.write()
.await
.insert("sess-1".to_string(), tx.clone());
ensure_notification_relay(&deps, "sess-1", tx.clone());
assert!(!deps.notification_service.try_begin_relay("sess-1"));
tx.send(AgentEvent::NeedClarification {
question: "Which file?".to_string(),
options: None,
tool_call_id: Some("tc-1".to_string()),
tool_name: None,
allow_custom: true,
})
.unwrap();
let category = tokio::time::timeout(Duration::from_secs(2), async {
loop {
if let AgentEvent::Notification { category, .. } = rx.recv().await.unwrap() {
return category;
}
}
})
.await
.expect("relay should classify and re-broadcast within the timeout");
assert_eq!(category, "needs_clarification");
}
#[tokio::test]
async fn ensure_notification_relay_exits_when_broadcast_channel_closes() {
let (deps, _dir) = test_deps();
let (tx, rx) = broadcast::channel::<AgentEvent>(16);
ensure_notification_relay(&deps, "sess-closed", tx.clone());
drop(tx);
drop(rx);
let relay_ended = tokio::time::timeout(Duration::from_secs(2), async {
loop {
if deps.notification_service.try_begin_relay("sess-closed") {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await;
assert!(
relay_ended.is_ok(),
"relay task did not exit after its broadcast channel closed"
);
}
}