bamboo_notification/
service.rs1use std::collections::HashMap;
17use std::path::PathBuf;
18use std::sync::{Mutex, RwLock};
19use std::time::{Duration, Instant};
20
21use bamboo_agent_core::AgentEvent;
22use dashmap::DashSet;
23
24use crate::policy;
25use crate::preferences::NotificationPreferences;
26
27pub struct NotificationService {
31 preferences: RwLock<NotificationPreferences>,
33 dedup: Mutex<HashMap<String, Instant>>,
35 active_relays: DashSet<String>,
37 prefs_path: PathBuf,
39}
40
41impl NotificationService {
42 pub const DEDUP_WINDOW: Duration = Duration::from_secs(30);
44
45 pub fn new(prefs_path: PathBuf) -> Self {
51 let preferences = NotificationPreferences::load(&prefs_path);
52 Self {
53 preferences: RwLock::new(preferences),
54 dedup: Mutex::new(HashMap::new()),
55 active_relays: DashSet::new(),
56 prefs_path,
57 }
58 }
59
60 pub fn notify(&self, session_id: &str, event: &AgentEvent) -> Option<AgentEvent> {
68 let classified = {
69 let prefs = self
70 .preferences
71 .read()
72 .expect("notification preferences lock poisoned");
73 policy::classify(session_id, event, &prefs)?
74 };
75
76 {
77 let mut dedup = self.dedup.lock().expect("notification dedup lock poisoned");
78 if let Some(last) = dedup.get(&classified.dedup_key) {
79 if last.elapsed() < Self::DEDUP_WINDOW {
80 return None;
81 }
82 }
83 let now = Instant::now();
84 dedup.insert(classified.dedup_key.clone(), now);
85 dedup.retain(|_, &mut last| last.elapsed() < Self::DEDUP_WINDOW);
87 }
88
89 Some(AgentEvent::Notification {
90 id: uuid::Uuid::new_v4().to_string(),
91 session_id: session_id.to_string(),
92 category: classified.category.as_str().to_string(),
93 priority: classified.priority.as_str().to_string(),
94 title: classified.title,
95 body: classified.body,
96 dedup_key: Some(classified.dedup_key),
97 created_at: chrono::Utc::now().to_rfc3339(),
98 })
99 }
100
101 pub fn preferences(&self) -> NotificationPreferences {
103 self.preferences
104 .read()
105 .expect("notification preferences lock poisoned")
106 .clone()
107 }
108
109 pub fn set_preferences(&self, prefs: NotificationPreferences) -> std::io::Result<()> {
114 {
115 let mut guard = self
116 .preferences
117 .write()
118 .expect("notification preferences lock poisoned");
119 *guard = prefs.clone();
120 }
121 prefs.save(&self.prefs_path)
122 }
123
124 pub fn try_begin_relay(&self, session_id: &str) -> bool {
130 self.active_relays.insert(session_id.to_string())
131 }
132
133 pub fn end_relay(&self, session_id: &str) {
135 self.active_relays.remove(session_id);
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 fn clarification(question: &str, tool_call_id: &str) -> AgentEvent {
144 AgentEvent::NeedClarification {
145 question: question.to_string(),
146 options: None,
147 tool_call_id: Some(tool_call_id.to_string()),
148 tool_name: None,
149 allow_custom: true,
150 }
151 }
152
153 fn service() -> (NotificationService, tempfile::TempDir) {
154 let dir = tempfile::tempdir().unwrap();
155 let path = dir.path().join("prefs.json");
156 (NotificationService::new(path), dir)
157 }
158
159 #[test]
160 fn notify_dedups_within_window_and_refires_other_keys() {
161 let (svc, _dir) = service();
162 let first = svc.notify("sess", &clarification("question", "tc-1"));
163 assert!(first.is_some());
164
165 let second = svc.notify("sess", &clarification("question", "tc-1"));
167 assert!(second.is_none());
168
169 let third = svc.notify("sess", &clarification("question", "tc-2"));
171 assert!(third.is_some());
172 }
173
174 #[test]
175 fn notify_builds_notification_variant_with_expected_fields() {
176 let (svc, _dir) = service();
177 let event = svc
178 .notify("sess-42", &clarification("Need input", "tc-9"))
179 .unwrap();
180 match event {
181 AgentEvent::Notification {
182 session_id,
183 category,
184 priority,
185 title,
186 dedup_key,
187 id,
188 created_at,
189 ..
190 } => {
191 assert_eq!(session_id, "sess-42");
192 assert_eq!(category, "needs_clarification");
193 assert_eq!(priority, "high");
194 assert_eq!(title, "Your input needed");
195 assert_eq!(dedup_key.as_deref(), Some("clarification:sess-42:tc-9"));
196 assert!(!id.is_empty());
197 assert!(!created_at.is_empty());
198 }
199 other => panic!("expected Notification, got {other:?}"),
200 }
201 }
202
203 #[test]
204 fn set_preferences_persists_and_reloads() {
205 let dir = tempfile::tempdir().unwrap();
206 let path = dir.path().join("prefs.json");
207 let svc = NotificationService::new(path.clone());
208
209 let updated = NotificationPreferences {
210 enabled: true,
211 on_clarification: false,
212 on_tool_approval: false,
213 on_context_pressure: true,
214 on_subagent_complete: false,
215 };
216 svc.set_preferences(updated.clone()).unwrap();
217 assert_eq!(svc.preferences(), updated);
218
219 let reloaded = NotificationService::new(path);
221 assert_eq!(reloaded.preferences(), updated);
222 }
223
224 #[test]
225 fn try_begin_relay_is_idempotent_per_session() {
226 let (svc, _dir) = service();
227 assert!(svc.try_begin_relay("sess"));
228 assert!(!svc.try_begin_relay("sess"));
229
230 svc.end_relay("sess");
231 assert!(svc.try_begin_relay("sess"));
233 }
234
235 #[test]
236 fn notify_returns_none_when_disabled() {
237 let dir = tempfile::tempdir().unwrap();
238 let path = dir.path().join("prefs.json");
239 let svc = NotificationService::new(path);
240 svc.set_preferences(NotificationPreferences {
241 enabled: false,
242 ..NotificationPreferences::default()
243 })
244 .unwrap();
245 assert!(svc
246 .notify("sess", &clarification("question", "tc-1"))
247 .is_none());
248 }
249}