1use std::collections::HashMap;
17use std::path::PathBuf;
18use std::sync::{Mutex, RwLock};
19use std::time::{Duration, Instant};
20
21use bamboo_agent_core::AgentEvent;
22use bamboo_domain::poison::PoisonRecover;
23use dashmap::DashSet;
24
25use crate::policy;
26use crate::policy::{ClassifiedNotification, NotificationPriority};
27use crate::preferences::NotificationPreferences;
28
29pub struct NotificationService {
33 preferences: RwLock<NotificationPreferences>,
35 dedup: Mutex<HashMap<String, Instant>>,
37 active_relays: DashSet<String>,
39 prefs_path: PathBuf,
41}
42
43impl NotificationService {
44 pub const DEDUP_WINDOW: Duration = Duration::from_secs(30);
46
47 pub fn new(prefs_path: PathBuf) -> Self {
53 let preferences = NotificationPreferences::load(&prefs_path);
54 Self {
55 preferences: RwLock::new(preferences),
56 dedup: Mutex::new(HashMap::new()),
57 active_relays: DashSet::new(),
58 prefs_path,
59 }
60 }
61
62 pub fn notify(&self, session_id: &str, event: &AgentEvent) -> Option<AgentEvent> {
70 let classified = {
71 let prefs = self.preferences.read().recover_poison();
72 policy::classify(session_id, event, &prefs)?
73 };
74 self.dedup_and_mint(session_id, classified)
75 }
76
77 pub fn mint_custom(
85 &self,
86 session_id: &str,
87 title: impl Into<String>,
88 body: impl Into<String>,
89 priority: NotificationPriority,
90 ) -> Option<AgentEvent> {
91 let title = title.into();
92 let body = body.into();
93 let classified = {
94 let prefs = self.preferences.read().recover_poison();
95 policy::classify_custom(session_id, &title, &body, priority, &prefs)?
96 };
97 self.dedup_and_mint(session_id, classified)
98 }
99
100 pub fn notify_schedule_run(
108 &self,
109 session_id: &str,
110 success: bool,
111 title: impl Into<String>,
112 body: impl Into<String>,
113 ) -> Option<AgentEvent> {
114 let classified = {
115 let prefs = self.preferences.read().recover_poison();
116 policy::classify_schedule_run(session_id, success, title.into(), body.into(), &prefs)?
117 };
118 self.dedup_and_mint(session_id, classified)
119 }
120
121 fn dedup_and_mint(
125 &self,
126 session_id: &str,
127 classified: ClassifiedNotification,
128 ) -> Option<AgentEvent> {
129 {
130 let mut dedup = self.dedup.lock().recover_poison();
131 if let Some(last) = dedup.get(&classified.dedup_key) {
132 if last.elapsed() < Self::DEDUP_WINDOW {
133 return None;
134 }
135 }
136 let now = Instant::now();
137 dedup.insert(classified.dedup_key.clone(), now);
138 dedup.retain(|_, &mut last| last.elapsed() < Self::DEDUP_WINDOW);
140 }
141
142 Some(AgentEvent::Notification {
143 id: uuid::Uuid::new_v4().to_string(),
144 session_id: session_id.to_string(),
145 category: classified.category.as_str().to_string(),
146 priority: classified.priority.as_str().to_string(),
147 title: classified.title,
148 body: classified.body,
149 dedup_key: Some(classified.dedup_key),
150 created_at: chrono::Utc::now().to_rfc3339(),
151 })
152 }
153
154 pub fn preferences(&self) -> NotificationPreferences {
156 self.preferences.read().recover_poison().clone()
157 }
158
159 pub fn set_preferences(&self, prefs: NotificationPreferences) -> std::io::Result<()> {
164 {
165 let mut guard = self.preferences.write().recover_poison();
166 *guard = prefs.clone();
167 }
168 prefs.save(&self.prefs_path)
169 }
170
171 pub fn try_begin_relay(&self, session_id: &str) -> bool {
177 self.active_relays.insert(session_id.to_string())
178 }
179
180 pub fn end_relay(&self, session_id: &str) {
182 self.active_relays.remove(session_id);
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 fn clarification(question: &str, tool_call_id: &str) -> AgentEvent {
191 AgentEvent::NeedClarification {
192 question: question.to_string(),
193 options: None,
194 tool_call_id: Some(tool_call_id.to_string()),
195 tool_name: None,
196 allow_custom: true,
197 }
198 }
199
200 fn service() -> (NotificationService, tempfile::TempDir) {
201 let dir = tempfile::tempdir().unwrap();
202 let path = dir.path().join("prefs.json");
203 (NotificationService::new(path), dir)
204 }
205
206 #[test]
207 fn notify_dedups_within_window_and_refires_other_keys() {
208 let (svc, _dir) = service();
209 let first = svc.notify("sess", &clarification("question", "tc-1"));
210 assert!(first.is_some());
211
212 let second = svc.notify("sess", &clarification("question", "tc-1"));
214 assert!(second.is_none());
215
216 let third = svc.notify("sess", &clarification("question", "tc-2"));
218 assert!(third.is_some());
219 }
220
221 #[test]
222 fn notify_builds_notification_variant_with_expected_fields() {
223 let (svc, _dir) = service();
224 let event = svc
225 .notify("sess-42", &clarification("Need input", "tc-9"))
226 .unwrap();
227 match event {
228 AgentEvent::Notification {
229 session_id,
230 category,
231 priority,
232 title,
233 dedup_key,
234 id,
235 created_at,
236 ..
237 } => {
238 assert_eq!(session_id, "sess-42");
239 assert_eq!(category, "needs_clarification");
240 assert_eq!(priority, "high");
241 assert_eq!(title, "Your input needed");
242 assert_eq!(dedup_key.as_deref(), Some("clarification:sess-42:tc-9"));
243 assert!(!id.is_empty());
244 assert!(!created_at.is_empty());
245 }
246 other => panic!("expected Notification, got {other:?}"),
247 }
248 }
249
250 #[test]
251 fn set_preferences_persists_and_reloads() {
252 let dir = tempfile::tempdir().unwrap();
253 let path = dir.path().join("prefs.json");
254 let svc = NotificationService::new(path.clone());
255
256 let updated = NotificationPreferences {
257 enabled: true,
258 on_clarification: false,
259 on_tool_approval: false,
260 on_context_pressure: true,
261 on_subagent_complete: false,
262 on_background_task_complete: true,
263 on_run_complete: false,
264 on_run_failed: true,
265 };
266 svc.set_preferences(updated.clone()).unwrap();
267 assert_eq!(svc.preferences(), updated);
268
269 let reloaded = NotificationService::new(path);
271 assert_eq!(reloaded.preferences(), updated);
272 }
273
274 #[test]
275 fn try_begin_relay_is_idempotent_per_session() {
276 let (svc, _dir) = service();
277 assert!(svc.try_begin_relay("sess"));
278 assert!(!svc.try_begin_relay("sess"));
279
280 svc.end_relay("sess");
281 assert!(svc.try_begin_relay("sess"));
283 }
284
285 #[test]
286 fn notify_returns_none_when_disabled() {
287 let dir = tempfile::tempdir().unwrap();
288 let path = dir.path().join("prefs.json");
289 let svc = NotificationService::new(path);
290 svc.set_preferences(NotificationPreferences {
291 enabled: false,
292 ..NotificationPreferences::default()
293 })
294 .unwrap();
295 assert!(svc
296 .notify("sess", &clarification("question", "tc-1"))
297 .is_none());
298 }
299
300 #[test]
301 fn mint_custom_dedups_identical_content_and_refires_on_change() {
302 let (svc, _dir) = service();
303 let first = svc.mint_custom("sess", "Title", "Body", NotificationPriority::Low);
304 assert!(first.is_some());
305
306 let second = svc.mint_custom("sess", "Title", "Body", NotificationPriority::Low);
308 assert!(second.is_none());
309
310 let third = svc.mint_custom("sess", "Title", "Different body", NotificationPriority::Low);
312 assert!(third.is_some());
313 }
314
315 #[test]
316 fn mint_custom_builds_notification_variant_with_custom_category() {
317 let (svc, _dir) = service();
318 let event = svc
319 .mint_custom(
320 "sess-7",
321 "Heads up",
322 "Something happened",
323 NotificationPriority::Normal,
324 )
325 .unwrap();
326 match event {
327 AgentEvent::Notification {
328 session_id,
329 category,
330 priority,
331 title,
332 body,
333 ..
334 } => {
335 assert_eq!(session_id, "sess-7");
336 assert_eq!(category, "custom");
337 assert_eq!(priority, "normal");
338 assert_eq!(title, "Heads up");
339 assert_eq!(body, "Something happened");
340 }
341 other => panic!("expected Notification, got {other:?}"),
342 }
343 }
344
345 #[test]
346 fn mint_custom_returns_none_when_disabled() {
347 let dir = tempfile::tempdir().unwrap();
348 let path = dir.path().join("prefs.json");
349 let svc = NotificationService::new(path);
350 svc.set_preferences(NotificationPreferences {
351 enabled: false,
352 ..NotificationPreferences::default()
353 })
354 .unwrap();
355 assert!(svc
356 .mint_custom("sess", "Title", "Body", NotificationPriority::Low)
357 .is_none());
358 }
359
360 #[test]
361 fn notify_run_completed_and_run_failed() {
362 let (svc, _dir) = service();
363 let complete = svc
364 .notify(
365 "sess-1",
366 &AgentEvent::Complete {
367 usage: bamboo_agent_core::TokenUsage {
368 prompt_tokens: 10,
369 completion_tokens: 5,
370 total_tokens: 15,
371 },
372 },
373 )
374 .unwrap();
375 match complete {
376 AgentEvent::Notification {
377 category,
378 priority,
379 dedup_key,
380 ..
381 } => {
382 assert_eq!(category, "run_completed");
383 assert_eq!(priority, "normal");
384 assert_eq!(dedup_key.as_deref(), Some("run:sess-1:done"));
385 }
386 other => panic!("expected Notification, got {other:?}"),
387 }
388
389 let failed = svc
390 .notify(
391 "sess-1",
392 &AgentEvent::Error {
393 message: "boom".to_string(),
394 },
395 )
396 .unwrap();
397 match failed {
398 AgentEvent::Notification {
399 category,
400 priority,
401 dedup_key,
402 ..
403 } => {
404 assert_eq!(category, "run_failed");
405 assert_eq!(priority, "high");
406 assert_eq!(dedup_key.as_deref(), Some("run:sess-1:failed"));
407 }
408 other => panic!("expected Notification, got {other:?}"),
409 }
410 }
411
412 #[test]
413 fn notify_schedule_run_builds_expected_notification() {
414 let (svc, _dir) = service();
415 let completed = svc
416 .notify_schedule_run("sess-9", true, "Schedule 'nightly' completed", "All done.")
417 .unwrap();
418 match completed {
419 AgentEvent::Notification {
420 category,
421 priority,
422 title,
423 body,
424 dedup_key,
425 ..
426 } => {
427 assert_eq!(category, "run_completed");
428 assert_eq!(priority, "normal");
429 assert_eq!(title, "Schedule 'nightly' completed");
430 assert_eq!(body, "All done.");
431 assert_eq!(dedup_key.as_deref(), Some("run:sess-9:done"));
432 }
433 other => panic!("expected Notification, got {other:?}"),
434 }
435 }
436
437 #[test]
445 fn notify_schedule_run_shares_dedup_window_with_generic_complete_event() {
446 let (svc, _dir) = service();
447 let first = svc.notify(
449 "sess-1",
450 &AgentEvent::Complete {
451 usage: bamboo_agent_core::TokenUsage {
452 prompt_tokens: 1,
453 completion_tokens: 1,
454 total_tokens: 2,
455 },
456 },
457 );
458 assert!(first.is_some());
459 let second =
461 svc.notify_schedule_run("sess-1", true, "Schedule 'nightly' completed", "All done.");
462 assert!(second.is_none());
463 }
464
465 #[test]
469 fn notify_schedule_run_first_dedups_the_later_generic_complete_event() {
470 let (svc, _dir) = service();
471 let first =
472 svc.notify_schedule_run("sess-2", true, "Schedule 'nightly' completed", "All done.");
473 assert!(first.is_some());
474 let second = svc.notify(
475 "sess-2",
476 &AgentEvent::Complete {
477 usage: bamboo_agent_core::TokenUsage {
478 prompt_tokens: 1,
479 completion_tokens: 1,
480 total_tokens: 2,
481 },
482 },
483 );
484 assert!(second.is_none());
485 }
486
487 #[test]
489 fn notify_schedule_run_failure_shares_dedup_window_with_generic_error_event() {
490 let (svc, _dir) = service();
491 let first = svc.notify(
492 "sess-3",
493 &AgentEvent::Error {
494 message: "boom".to_string(),
495 },
496 );
497 assert!(first.is_some());
498 let second = svc.notify_schedule_run("sess-3", false, "Schedule 'nightly' failed", "boom");
499 assert!(second.is_none());
500 }
501
502 #[test]
503 fn notify_schedule_run_gated_by_prefs_and_disabled_switch() {
504 let dir = tempfile::tempdir().unwrap();
505 let path = dir.path().join("prefs.json");
506 let svc = NotificationService::new(path);
507 svc.set_preferences(NotificationPreferences {
508 on_run_complete: false,
509 ..NotificationPreferences::default()
510 })
511 .unwrap();
512 assert!(svc.notify_schedule_run("sess", true, "t", "b").is_none());
513 assert!(svc.notify_schedule_run("sess", false, "t", "b").is_some());
515
516 svc.set_preferences(NotificationPreferences {
517 enabled: false,
518 ..NotificationPreferences::default()
519 })
520 .unwrap();
521 assert!(svc.notify_schedule_run("sess", false, "t", "b").is_none());
522 }
523}