Skip to main content

bamboo_notification/
service.rs

1//! Stateful notification service wrapping the pure [`crate::policy`].
2//!
3//! [`NotificationService`] is the object the server holds (behind an `Arc`).
4//! It adds three things to bare classification:
5//!
6//! - a **dedup window** so a repeated event (same `dedup_key`) does not spam the
7//!   user within [`NotificationService::DEDUP_WINDOW`];
8//! - **preference persistence** so updates survive a restart; and
9//! - **per-session relay idempotency** so the server spawns at most one
10//!   notification relay per session.
11//!
12//! All methods take `&self`; interior mutability is provided by `RwLock`,
13//! `Mutex`, and a `DashSet`. The type is intentionally **not** `Clone` — share
14//! it via `Arc`.
15
16use 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
29/// Notification policy service: classification + dedup + preference persistence.
30///
31/// Construct with [`NotificationService::new`] and share via `Arc`.
32pub struct NotificationService {
33    /// Current user preferences (hot-swappable via [`Self::set_preferences`]).
34    preferences: RwLock<NotificationPreferences>,
35    /// Last-emitted instant per `dedup_key`, used to suppress rapid repeats.
36    dedup: Mutex<HashMap<String, Instant>>,
37    /// Session ids that currently have a running relay (server idempotency).
38    active_relays: DashSet<String>,
39    /// Path the preferences are persisted to.
40    prefs_path: PathBuf,
41}
42
43impl NotificationService {
44    /// Window within which a repeated `dedup_key` is coalesced (suppressed).
45    pub const DEDUP_WINDOW: Duration = Duration::from_secs(30);
46
47    /// Creates a service, loading preferences from `prefs_path`.
48    ///
49    /// A missing or unparsable file falls back to
50    /// [`NotificationPreferences::default`] (see [`NotificationPreferences::load`]).
51    /// The dedup map and relay set start empty.
52    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    /// Classifies `event` and, if notification-worthy and not a recent
63    /// duplicate, materialises an [`AgentEvent::Notification`].
64    ///
65    /// Returns `None` when the policy declines the event (disabled, gated, or
66    /// not notification-worthy) or when an identical `dedup_key` fired within
67    /// [`Self::DEDUP_WINDOW`]. On a successful (non-deduped) emission the dedup
68    /// map is opportunistically pruned of entries older than the window.
69    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    /// Mints a custom notification for the `notify` tool.
78    ///
79    /// This is a passthrough classification (see
80    /// [`policy::classify_custom`]): it does not consult a per-category
81    /// preference — there isn't one for arbitrary agent-chosen content — only
82    /// the master `enabled` switch and the shared dedup window apply. Callers
83    /// choose `priority`; `title`/`body` are used verbatim (no truncation).
84    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    /// Mints a schedule-run completion/failure notification (see
101    /// [`policy::classify_schedule_run`] for the full no-double-fire
102    /// rationale): it shares the SAME category/priority/dedup key that
103    /// [`Self::notify`] would derive from the raw `AgentEvent::Complete`/
104    /// `Error` a scheduled run's agent loop also emits, so this is safe to
105    /// call unconditionally alongside the always-on relay — at most one of
106    /// the two ever survives this service's dedup window.
107    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    /// Shared dedup-check + [`AgentEvent::Notification`] minting used by both
122    /// [`Self::notify`] and [`Self::mint_custom`], once a
123    /// [`ClassifiedNotification`] has already cleared policy gating.
124    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            // Opportunistically drop entries that can no longer suppress anything.
139            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    /// Returns a snapshot clone of the current preferences.
155    pub fn preferences(&self) -> NotificationPreferences {
156        self.preferences.read().recover_poison().clone()
157    }
158
159    /// Replaces the current preferences and persists them to disk.
160    ///
161    /// The in-memory value is updated first, then written to the configured
162    /// path; a write error is returned but the in-memory update still stands.
163    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    /// Marks `session_id` as having a running relay.
172    ///
173    /// Returns `true` when the session was newly inserted — i.e. the caller is
174    /// the one that should spawn the relay. Returns `false` if a relay is
175    /// already active for this session.
176    pub fn try_begin_relay(&self, session_id: &str) -> bool {
177        self.active_relays.insert(session_id.to_string())
178    }
179
180    /// Clears the running-relay marker for `session_id`.
181    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        // Same dedup_key again within the window → suppressed.
213        let second = svc.notify("sess", &clarification("question", "tc-1"));
214        assert!(second.is_none());
215
216        // A different dedup_key (different tool_call_id) still fires.
217        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        // A fresh service over the same path reads the persisted value.
270        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        // After ending, the session can begin a relay again.
282        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        // Same title+body within the window → suppressed.
307        let second = svc.mint_custom("sess", "Title", "Body", NotificationPriority::Low);
308        assert!(second.is_none());
309
310        // Different body → distinct dedup key → fires.
311        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    /// The core no-double-fire guarantee (WP5): a scheduled run's raw
438    /// `AgentEvent::Complete` is independently classified by the always-on
439    /// relay via `notify`, AND the schedule manager's completion hook calls
440    /// `notify_schedule_run` to enrich it — both share dedup key
441    /// `"run:{session_id}:done"`, so within the dedup window only the FIRST
442    /// of the two ever produces a delivered notification; the second is
443    /// coalesced. This holds regardless of call order.
444    #[test]
445    fn notify_schedule_run_shares_dedup_window_with_generic_complete_event() {
446        let (svc, _dir) = service();
447        // Generic relay path fires first (raw AgentEvent::Complete)...
448        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        // ...the schedule-level enrichment attempt is deduped away.
460        let second =
461            svc.notify_schedule_run("sess-1", true, "Schedule 'nightly' completed", "All done.");
462        assert!(second.is_none());
463    }
464
465    /// Same guarantee, opposite call order: the schedule-level enrichment
466    /// wins the race and the later generic relay classification is the one
467    /// deduped away. Either order is safe — never both.
468    #[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    /// Same guarantee for the failure side (`run:{session_id}:failed`).
488    #[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        // Failure category is unaffected by the on_run_complete flag.
514        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}