1use bamboo_agent_core::AgentEvent;
11
12use crate::preferences::NotificationPreferences;
13
14const CLARIFICATION_BODY_MAX: usize = 120;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum NotificationCategory {
20 NeedsApproval,
22 NeedsClarification,
24 ContextCritical,
26 SubagentCompleted,
28}
29
30impl NotificationCategory {
31 pub fn as_str(&self) -> &'static str {
33 match self {
34 NotificationCategory::NeedsApproval => "needs_approval",
35 NotificationCategory::NeedsClarification => "needs_clarification",
36 NotificationCategory::ContextCritical => "context_critical",
37 NotificationCategory::SubagentCompleted => "subagent_completed",
38 }
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum NotificationPriority {
45 High,
47 Normal,
49 Low,
51}
52
53impl NotificationPriority {
54 pub fn as_str(&self) -> &'static str {
56 match self {
57 NotificationPriority::High => "high",
58 NotificationPriority::Normal => "normal",
59 NotificationPriority::Low => "low",
60 }
61 }
62}
63
64#[derive(Debug, Clone, PartialEq)]
70pub struct ClassifiedNotification {
71 pub category: NotificationCategory,
73 pub priority: NotificationPriority,
75 pub title: String,
77 pub body: String,
79 pub dedup_key: String,
81}
82
83fn is_permission_prompt(question: &str) -> bool {
89 question.contains("Permission required") || question.contains("Permission Request")
90}
91
92fn truncate(text: &str, max: usize) -> String {
97 if text.chars().count() <= max {
98 return text.to_string();
99 }
100 let mut out: String = text.chars().take(max).collect();
101 out.push('…');
102 out
103}
104
105pub fn classify(
112 session_id: &str,
113 event: &AgentEvent,
114 prefs: &NotificationPreferences,
115) -> Option<ClassifiedNotification> {
116 if !prefs.enabled {
117 return None;
118 }
119
120 match event {
121 AgentEvent::NeedClarification {
122 question,
123 tool_call_id,
124 ..
125 } => {
126 let (category, title) = if is_permission_prompt(question) {
127 if !prefs.on_tool_approval {
128 return None;
129 }
130 (NotificationCategory::NeedsApproval, "Approval needed")
131 } else {
132 if !prefs.on_clarification {
133 return None;
134 }
135 (
136 NotificationCategory::NeedsClarification,
137 "Your input needed",
138 )
139 };
140 Some(ClassifiedNotification {
141 category,
142 priority: NotificationPriority::High,
143 title: title.to_string(),
144 body: truncate(question, CLARIFICATION_BODY_MAX),
145 dedup_key: format!(
146 "clarification:{session_id}:{}",
147 tool_call_id.as_deref().unwrap_or("")
148 ),
149 })
150 }
151
152 AgentEvent::ContextPressureNotification { level, message, .. } => {
153 if level != "critical" {
154 return None;
155 }
156 if !prefs.on_context_pressure {
157 return None;
158 }
159 Some(ClassifiedNotification {
160 category: NotificationCategory::ContextCritical,
161 priority: NotificationPriority::Normal,
162 title: "Context almost full".to_string(),
163 body: message.clone(),
164 dedup_key: format!("context:{session_id}"),
165 })
166 }
167
168 AgentEvent::SubAgentCompleted {
169 child_session_id,
170 status,
171 ..
172 } => {
173 if status != "completed" {
174 return None;
175 }
176 if !prefs.on_subagent_complete {
177 return None;
178 }
179 Some(ClassifiedNotification {
180 category: NotificationCategory::SubagentCompleted,
181 priority: NotificationPriority::Normal,
182 title: "Background task done".to_string(),
183 body: format!("A background task finished ({}).", child_session_id),
184 dedup_key: format!("subagent:{}", child_session_id),
185 })
186 }
187
188 _ => None,
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn clarification(question: &str, tool_call_id: Option<&str>) -> AgentEvent {
197 AgentEvent::NeedClarification {
198 question: question.to_string(),
199 options: None,
200 tool_call_id: tool_call_id.map(str::to_string),
201 tool_name: None,
202 allow_custom: true,
203 }
204 }
205
206 fn context(level: &str) -> AgentEvent {
207 AgentEvent::ContextPressureNotification {
208 percent: 95.0,
209 level: level.to_string(),
210 message: "Context is nearly full.".to_string(),
211 }
212 }
213
214 fn subagent(status: &str) -> AgentEvent {
215 AgentEvent::SubAgentCompleted {
216 parent_session_id: "parent".to_string(),
217 child_session_id: "child-1".to_string(),
218 status: status.to_string(),
219 error: None,
220 }
221 }
222
223 #[test]
224 fn plain_clarification_is_needs_clarification() {
225 let prefs = NotificationPreferences::default();
226 let event = clarification("Which file should I edit?", Some("tc-1"));
227 let result = classify("sess", &event, &prefs).unwrap();
228 assert_eq!(result.category, NotificationCategory::NeedsClarification);
229 assert_eq!(result.priority, NotificationPriority::High);
230 assert_eq!(result.title, "Your input needed");
231 assert_eq!(result.dedup_key, "clarification:sess:tc-1");
232 }
233
234 #[test]
235 fn permission_prompt_is_needs_approval() {
236 let prefs = NotificationPreferences::default();
237 let event = clarification("**Permission required** to run `rm -rf`", Some("tc-2"));
238 let result = classify("sess", &event, &prefs).unwrap();
239 assert_eq!(result.category, NotificationCategory::NeedsApproval);
240 assert_eq!(result.title, "Approval needed");
241 }
242
243 #[test]
244 fn permission_request_marker_also_matches() {
245 let prefs = NotificationPreferences::default();
246 let event = clarification("Permission Request: write to disk", None);
247 let result = classify("sess", &event, &prefs).unwrap();
248 assert_eq!(result.category, NotificationCategory::NeedsApproval);
249 assert_eq!(result.dedup_key, "clarification:sess:");
251 }
252
253 #[test]
254 fn clarification_body_truncates_with_ellipsis() {
255 let prefs = NotificationPreferences::default();
256 let long = "x".repeat(200);
257 let event = clarification(&long, None);
258 let result = classify("sess", &event, &prefs).unwrap();
259 assert_eq!(result.body.chars().count(), CLARIFICATION_BODY_MAX + 1);
260 assert!(result.body.ends_with('…'));
261 }
262
263 #[test]
264 fn clarification_gated_by_on_clarification() {
265 let prefs = NotificationPreferences {
266 on_clarification: false,
267 ..NotificationPreferences::default()
268 };
269 let event = clarification("plain question", None);
270 assert!(classify("sess", &event, &prefs).is_none());
271 }
272
273 #[test]
274 fn approval_gated_by_on_tool_approval() {
275 let prefs = NotificationPreferences {
276 on_tool_approval: false,
277 ..NotificationPreferences::default()
278 };
279 let event = clarification("Permission required: do thing", None);
280 assert!(classify("sess", &event, &prefs).is_none());
281 }
282
283 #[test]
284 fn context_critical_fires() {
285 let prefs = NotificationPreferences::default();
286 let result = classify("sess", &context("critical"), &prefs).unwrap();
287 assert_eq!(result.category, NotificationCategory::ContextCritical);
288 assert_eq!(result.priority, NotificationPriority::Normal);
289 assert_eq!(result.title, "Context almost full");
290 assert_eq!(result.body, "Context is nearly full.");
291 assert_eq!(result.dedup_key, "context:sess");
292 }
293
294 #[test]
295 fn context_warning_is_none() {
296 let prefs = NotificationPreferences::default();
297 assert!(classify("sess", &context("warning"), &prefs).is_none());
298 }
299
300 #[test]
301 fn context_gated_by_on_context_pressure() {
302 let prefs = NotificationPreferences {
303 on_context_pressure: false,
304 ..NotificationPreferences::default()
305 };
306 assert!(classify("sess", &context("critical"), &prefs).is_none());
307 }
308
309 #[test]
310 fn subagent_completed_fires() {
311 let prefs = NotificationPreferences::default();
312 let result = classify("sess", &subagent("completed"), &prefs).unwrap();
313 assert_eq!(result.category, NotificationCategory::SubagentCompleted);
314 assert_eq!(result.priority, NotificationPriority::Normal);
315 assert_eq!(result.title, "Background task done");
316 assert_eq!(result.body, "A background task finished (child-1).");
317 assert_eq!(result.dedup_key, "subagent:child-1");
318 }
319
320 #[test]
321 fn subagent_error_is_none() {
322 let prefs = NotificationPreferences::default();
323 assert!(classify("sess", &subagent("error"), &prefs).is_none());
324 }
325
326 #[test]
327 fn subagent_gated_by_on_subagent_complete() {
328 let prefs = NotificationPreferences {
329 on_subagent_complete: false,
330 ..NotificationPreferences::default()
331 };
332 assert!(classify("sess", &subagent("completed"), &prefs).is_none());
333 }
334
335 #[test]
336 fn master_switch_off_yields_none() {
337 let prefs = NotificationPreferences {
338 enabled: false,
339 ..NotificationPreferences::default()
340 };
341 assert!(classify("sess", &context("critical"), &prefs).is_none());
342 assert!(classify("sess", &subagent("completed"), &prefs).is_none());
343 assert!(classify("sess", &clarification("q", None), &prefs).is_none());
344 }
345
346 #[test]
347 fn unrelated_variant_is_none() {
348 let prefs = NotificationPreferences::default();
349 let event = AgentEvent::Token {
350 content: "hello".to_string(),
351 };
352 assert!(classify("sess", &event, &prefs).is_none());
353 }
354}