use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Mutex, RwLock};
use std::time::{Duration, Instant};
use bamboo_agent_core::AgentEvent;
use dashmap::DashSet;
use crate::policy;
use crate::preferences::NotificationPreferences;
pub struct NotificationService {
preferences: RwLock<NotificationPreferences>,
dedup: Mutex<HashMap<String, Instant>>,
active_relays: DashSet<String>,
prefs_path: PathBuf,
}
impl NotificationService {
pub const DEDUP_WINDOW: Duration = Duration::from_secs(30);
pub fn new(prefs_path: PathBuf) -> Self {
let preferences = NotificationPreferences::load(&prefs_path);
Self {
preferences: RwLock::new(preferences),
dedup: Mutex::new(HashMap::new()),
active_relays: DashSet::new(),
prefs_path,
}
}
pub fn notify(&self, session_id: &str, event: &AgentEvent) -> Option<AgentEvent> {
let classified = {
let prefs = self
.preferences
.read()
.expect("notification preferences lock poisoned");
policy::classify(session_id, event, &prefs)?
};
{
let mut dedup = self.dedup.lock().expect("notification dedup lock poisoned");
if let Some(last) = dedup.get(&classified.dedup_key) {
if last.elapsed() < Self::DEDUP_WINDOW {
return None;
}
}
let now = Instant::now();
dedup.insert(classified.dedup_key.clone(), now);
dedup.retain(|_, &mut last| last.elapsed() < Self::DEDUP_WINDOW);
}
Some(AgentEvent::Notification {
id: uuid::Uuid::new_v4().to_string(),
session_id: session_id.to_string(),
category: classified.category.as_str().to_string(),
priority: classified.priority.as_str().to_string(),
title: classified.title,
body: classified.body,
dedup_key: Some(classified.dedup_key),
created_at: chrono::Utc::now().to_rfc3339(),
})
}
pub fn preferences(&self) -> NotificationPreferences {
self.preferences
.read()
.expect("notification preferences lock poisoned")
.clone()
}
pub fn set_preferences(&self, prefs: NotificationPreferences) -> std::io::Result<()> {
{
let mut guard = self
.preferences
.write()
.expect("notification preferences lock poisoned");
*guard = prefs.clone();
}
prefs.save(&self.prefs_path)
}
pub fn try_begin_relay(&self, session_id: &str) -> bool {
self.active_relays.insert(session_id.to_string())
}
pub fn end_relay(&self, session_id: &str) {
self.active_relays.remove(session_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn clarification(question: &str, tool_call_id: &str) -> AgentEvent {
AgentEvent::NeedClarification {
question: question.to_string(),
options: None,
tool_call_id: Some(tool_call_id.to_string()),
tool_name: None,
allow_custom: true,
}
}
fn service() -> (NotificationService, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("prefs.json");
(NotificationService::new(path), dir)
}
#[test]
fn notify_dedups_within_window_and_refires_other_keys() {
let (svc, _dir) = service();
let first = svc.notify("sess", &clarification("question", "tc-1"));
assert!(first.is_some());
let second = svc.notify("sess", &clarification("question", "tc-1"));
assert!(second.is_none());
let third = svc.notify("sess", &clarification("question", "tc-2"));
assert!(third.is_some());
}
#[test]
fn notify_builds_notification_variant_with_expected_fields() {
let (svc, _dir) = service();
let event = svc
.notify("sess-42", &clarification("Need input", "tc-9"))
.unwrap();
match event {
AgentEvent::Notification {
session_id,
category,
priority,
title,
dedup_key,
id,
created_at,
..
} => {
assert_eq!(session_id, "sess-42");
assert_eq!(category, "needs_clarification");
assert_eq!(priority, "high");
assert_eq!(title, "Your input needed");
assert_eq!(dedup_key.as_deref(), Some("clarification:sess-42:tc-9"));
assert!(!id.is_empty());
assert!(!created_at.is_empty());
}
other => panic!("expected Notification, got {other:?}"),
}
}
#[test]
fn set_preferences_persists_and_reloads() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("prefs.json");
let svc = NotificationService::new(path.clone());
let updated = NotificationPreferences {
enabled: true,
on_clarification: false,
on_tool_approval: false,
on_context_pressure: true,
on_subagent_complete: false,
};
svc.set_preferences(updated.clone()).unwrap();
assert_eq!(svc.preferences(), updated);
let reloaded = NotificationService::new(path);
assert_eq!(reloaded.preferences(), updated);
}
#[test]
fn try_begin_relay_is_idempotent_per_session() {
let (svc, _dir) = service();
assert!(svc.try_begin_relay("sess"));
assert!(!svc.try_begin_relay("sess"));
svc.end_relay("sess");
assert!(svc.try_begin_relay("sess"));
}
#[test]
fn notify_returns_none_when_disabled() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("prefs.json");
let svc = NotificationService::new(path);
svc.set_preferences(NotificationPreferences {
enabled: false,
..NotificationPreferences::default()
})
.unwrap();
assert!(svc
.notify("sess", &clarification("question", "tc-1"))
.is_none());
}
}