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 dashmap::DashSet;
23
24use crate::policy;
25use crate::preferences::NotificationPreferences;
26
27/// Notification policy service: classification + dedup + preference persistence.
28///
29/// Construct with [`NotificationService::new`] and share via `Arc`.
30pub struct NotificationService {
31    /// Current user preferences (hot-swappable via [`Self::set_preferences`]).
32    preferences: RwLock<NotificationPreferences>,
33    /// Last-emitted instant per `dedup_key`, used to suppress rapid repeats.
34    dedup: Mutex<HashMap<String, Instant>>,
35    /// Session ids that currently have a running relay (server idempotency).
36    active_relays: DashSet<String>,
37    /// Path the preferences are persisted to.
38    prefs_path: PathBuf,
39}
40
41impl NotificationService {
42    /// Window within which a repeated `dedup_key` is coalesced (suppressed).
43    pub const DEDUP_WINDOW: Duration = Duration::from_secs(30);
44
45    /// Creates a service, loading preferences from `prefs_path`.
46    ///
47    /// A missing or unparsable file falls back to
48    /// [`NotificationPreferences::default`] (see [`NotificationPreferences::load`]).
49    /// The dedup map and relay set start empty.
50    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    /// Classifies `event` and, if notification-worthy and not a recent
61    /// duplicate, materialises an [`AgentEvent::Notification`].
62    ///
63    /// Returns `None` when the policy declines the event (disabled, gated, or
64    /// not notification-worthy) or when an identical `dedup_key` fired within
65    /// [`Self::DEDUP_WINDOW`]. On a successful (non-deduped) emission the dedup
66    /// map is opportunistically pruned of entries older than the window.
67    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            // Opportunistically drop entries that can no longer suppress anything.
86            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    /// Returns a snapshot clone of the current preferences.
102    pub fn preferences(&self) -> NotificationPreferences {
103        self.preferences
104            .read()
105            .expect("notification preferences lock poisoned")
106            .clone()
107    }
108
109    /// Replaces the current preferences and persists them to disk.
110    ///
111    /// The in-memory value is updated first, then written to the configured
112    /// path; a write error is returned but the in-memory update still stands.
113    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    /// Marks `session_id` as having a running relay.
125    ///
126    /// Returns `true` when the session was newly inserted — i.e. the caller is
127    /// the one that should spawn the relay. Returns `false` if a relay is
128    /// already active for this session.
129    pub fn try_begin_relay(&self, session_id: &str) -> bool {
130        self.active_relays.insert(session_id.to_string())
131    }
132
133    /// Clears the running-relay marker for `session_id`.
134    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        // Same dedup_key again within the window → suppressed.
166        let second = svc.notify("sess", &clarification("question", "tc-1"));
167        assert!(second.is_none());
168
169        // A different dedup_key (different tool_call_id) still fires.
170        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        // A fresh service over the same path reads the persisted value.
220        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        // After ending, the session can begin a relay again.
232        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}