1use std::collections::hash_map::DefaultHasher;
11use std::hash::{Hash, Hasher};
12
13use bamboo_agent_core::AgentEvent;
14
15use crate::preferences::NotificationPreferences;
16
17const CLARIFICATION_BODY_MAX: usize = 120;
19
20const BACKGROUND_TASK_BODY_MAX: usize = 120;
22
23const RUN_FAILED_BODY_MAX: usize = 200;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum NotificationCategory {
29 NeedsApproval,
31 NeedsClarification,
33 ContextCritical,
35 SubagentCompleted,
37 BackgroundTaskCompleted,
39 RunCompleted,
41 RunFailed,
43 Custom,
46}
47
48impl NotificationCategory {
49 pub fn as_str(&self) -> &'static str {
51 match self {
52 NotificationCategory::NeedsApproval => "needs_approval",
53 NotificationCategory::NeedsClarification => "needs_clarification",
54 NotificationCategory::ContextCritical => "context_critical",
55 NotificationCategory::SubagentCompleted => "subagent_completed",
56 NotificationCategory::BackgroundTaskCompleted => "background_task_completed",
57 NotificationCategory::RunCompleted => "run_completed",
58 NotificationCategory::RunFailed => "run_failed",
59 NotificationCategory::Custom => "custom",
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum NotificationPriority {
67 High,
69 Normal,
71 Low,
73}
74
75impl NotificationPriority {
76 pub fn as_str(&self) -> &'static str {
78 match self {
79 NotificationPriority::High => "high",
80 NotificationPriority::Normal => "normal",
81 NotificationPriority::Low => "low",
82 }
83 }
84}
85
86#[derive(Debug, Clone, PartialEq)]
92pub struct ClassifiedNotification {
93 pub category: NotificationCategory,
95 pub priority: NotificationPriority,
97 pub title: String,
99 pub body: String,
101 pub dedup_key: String,
103}
104
105fn is_permission_prompt(question: &str) -> bool {
111 question.contains("Permission required") || question.contains("Permission Request")
112}
113
114fn truncate(text: &str, max: usize) -> String {
119 if text.chars().count() <= max {
120 return text.to_string();
121 }
122 let mut out: String = text.chars().take(max).collect();
123 out.push('…');
124 out
125}
126
127pub fn classify(
134 session_id: &str,
135 event: &AgentEvent,
136 prefs: &NotificationPreferences,
137) -> Option<ClassifiedNotification> {
138 if !prefs.enabled {
139 return None;
140 }
141
142 match event {
143 AgentEvent::NeedClarification {
144 question,
145 tool_call_id,
146 ..
147 } => {
148 let (category, title) = if is_permission_prompt(question) {
149 if !prefs.on_tool_approval {
150 return None;
151 }
152 (NotificationCategory::NeedsApproval, "Approval needed")
153 } else {
154 if !prefs.on_clarification {
155 return None;
156 }
157 (
158 NotificationCategory::NeedsClarification,
159 "Your input needed",
160 )
161 };
162 Some(ClassifiedNotification {
163 category,
164 priority: NotificationPriority::High,
165 title: title.to_string(),
166 body: truncate(question, CLARIFICATION_BODY_MAX),
167 dedup_key: format!(
168 "clarification:{session_id}:{}",
169 tool_call_id.as_deref().unwrap_or("")
170 ),
171 })
172 }
173
174 AgentEvent::ContextPressureNotification { level, message, .. } => {
175 if level != "critical" {
176 return None;
177 }
178 if !prefs.on_context_pressure {
179 return None;
180 }
181 Some(ClassifiedNotification {
182 category: NotificationCategory::ContextCritical,
183 priority: NotificationPriority::Normal,
184 title: "Context almost full".to_string(),
185 body: message.clone(),
186 dedup_key: format!("context:{session_id}"),
187 })
188 }
189
190 AgentEvent::SubAgentCompleted {
191 child_session_id,
192 status,
193 ..
194 } => {
195 if status != "completed" {
196 return None;
197 }
198 if !prefs.on_subagent_complete {
199 return None;
200 }
201 Some(ClassifiedNotification {
202 category: NotificationCategory::SubagentCompleted,
203 priority: NotificationPriority::Normal,
204 title: "Background task done".to_string(),
205 body: format!("A background task finished ({}).", child_session_id),
206 dedup_key: format!("subagent:{}", child_session_id),
207 })
208 }
209
210 AgentEvent::BashCompleted {
211 bash_id,
212 command,
213 exit_code,
214 status,
215 } => {
216 if status == "killed" {
221 return None;
222 }
223 if !prefs.on_background_task_complete {
224 return None;
225 }
226 let exit = match exit_code {
227 Some(code) => format!("exit {code}"),
228 None => "no exit code".to_string(),
229 };
230 Some(ClassifiedNotification {
231 category: NotificationCategory::BackgroundTaskCompleted,
232 priority: NotificationPriority::Normal,
233 title: "Background command finished".to_string(),
234 body: truncate(
237 &format!("{command} — {status}, {exit}"),
238 BACKGROUND_TASK_BODY_MAX,
239 ),
240 dedup_key: format!("bash:{bash_id}"),
241 })
242 }
243
244 AgentEvent::Complete { usage } => {
245 if !prefs.on_run_complete {
246 return None;
247 }
248 Some(ClassifiedNotification {
249 category: NotificationCategory::RunCompleted,
250 priority: NotificationPriority::Normal,
251 title: "Run complete".to_string(),
252 body: format!(
253 "Session {session_id} finished ({} tokens).",
254 usage.total_tokens
255 ),
256 dedup_key: format!("run:{session_id}:done"),
257 })
258 }
259
260 AgentEvent::Error { message } => {
261 if !prefs.on_run_failed {
262 return None;
263 }
264 Some(ClassifiedNotification {
265 category: NotificationCategory::RunFailed,
266 priority: NotificationPriority::High,
267 title: "Run failed".to_string(),
268 body: truncate(message, RUN_FAILED_BODY_MAX),
269 dedup_key: format!("run:{session_id}:failed"),
270 })
271 }
272
273 _ => None,
274 }
275}
276
277pub fn classify_custom(
288 session_id: &str,
289 title: &str,
290 body: &str,
291 priority: NotificationPriority,
292 prefs: &NotificationPreferences,
293) -> Option<ClassifiedNotification> {
294 if !prefs.enabled {
295 return None;
296 }
297 let mut hasher = DefaultHasher::new();
298 title.hash(&mut hasher);
299 body.hash(&mut hasher);
300 let digest = hasher.finish();
301 Some(ClassifiedNotification {
302 category: NotificationCategory::Custom,
303 priority,
304 title: title.to_string(),
305 body: body.to_string(),
306 dedup_key: format!("custom:{session_id}:{digest:x}"),
307 })
308}
309
310pub fn classify_schedule_run(
329 session_id: &str,
330 success: bool,
331 title: String,
332 body: String,
333 prefs: &NotificationPreferences,
334) -> Option<ClassifiedNotification> {
335 if !prefs.enabled {
336 return None;
337 }
338 if success {
339 if !prefs.on_run_complete {
340 return None;
341 }
342 Some(ClassifiedNotification {
343 category: NotificationCategory::RunCompleted,
344 priority: NotificationPriority::Normal,
345 title,
346 body,
347 dedup_key: format!("run:{session_id}:done"),
348 })
349 } else {
350 if !prefs.on_run_failed {
351 return None;
352 }
353 Some(ClassifiedNotification {
354 category: NotificationCategory::RunFailed,
355 priority: NotificationPriority::High,
356 title,
357 body,
358 dedup_key: format!("run:{session_id}:failed"),
359 })
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 fn clarification(question: &str, tool_call_id: Option<&str>) -> AgentEvent {
368 AgentEvent::NeedClarification {
369 question: question.to_string(),
370 options: None,
371 tool_call_id: tool_call_id.map(str::to_string),
372 tool_name: None,
373 allow_custom: true,
374 }
375 }
376
377 fn context(level: &str) -> AgentEvent {
378 AgentEvent::ContextPressureNotification {
379 percent: 95.0,
380 level: level.to_string(),
381 message: "Context is nearly full.".to_string(),
382 }
383 }
384
385 fn subagent(status: &str) -> AgentEvent {
386 AgentEvent::SubAgentCompleted {
387 parent_session_id: "parent".to_string(),
388 child_session_id: "child-1".to_string(),
389 status: status.to_string(),
390 error: None,
391 }
392 }
393
394 fn bash(bash_id: &str, status: &str, exit_code: Option<i32>) -> AgentEvent {
395 AgentEvent::BashCompleted {
396 bash_id: bash_id.to_string(),
397 command: "npm run build".to_string(),
398 exit_code,
399 status: status.to_string(),
400 }
401 }
402
403 fn complete(total_tokens: u64) -> AgentEvent {
404 AgentEvent::Complete {
405 usage: bamboo_agent_core::TokenUsage {
406 prompt_tokens: total_tokens / 2,
407 completion_tokens: total_tokens - total_tokens / 2,
408 total_tokens,
409 },
410 }
411 }
412
413 fn error(message: &str) -> AgentEvent {
414 AgentEvent::Error {
415 message: message.to_string(),
416 }
417 }
418
419 #[test]
420 fn plain_clarification_is_needs_clarification() {
421 let prefs = NotificationPreferences::default();
422 let event = clarification("Which file should I edit?", Some("tc-1"));
423 let result = classify("sess", &event, &prefs).unwrap();
424 assert_eq!(result.category, NotificationCategory::NeedsClarification);
425 assert_eq!(result.priority, NotificationPriority::High);
426 assert_eq!(result.title, "Your input needed");
427 assert_eq!(result.dedup_key, "clarification:sess:tc-1");
428 }
429
430 #[test]
431 fn permission_prompt_is_needs_approval() {
432 let prefs = NotificationPreferences::default();
433 let event = clarification("**Permission required** to run `rm -rf`", Some("tc-2"));
434 let result = classify("sess", &event, &prefs).unwrap();
435 assert_eq!(result.category, NotificationCategory::NeedsApproval);
436 assert_eq!(result.title, "Approval needed");
437 }
438
439 #[test]
440 fn permission_request_marker_also_matches() {
441 let prefs = NotificationPreferences::default();
442 let event = clarification("Permission Request: write to disk", None);
443 let result = classify("sess", &event, &prefs).unwrap();
444 assert_eq!(result.category, NotificationCategory::NeedsApproval);
445 assert_eq!(result.dedup_key, "clarification:sess:");
447 }
448
449 #[test]
450 fn clarification_body_truncates_with_ellipsis() {
451 let prefs = NotificationPreferences::default();
452 let long = "x".repeat(200);
453 let event = clarification(&long, None);
454 let result = classify("sess", &event, &prefs).unwrap();
455 assert_eq!(result.body.chars().count(), CLARIFICATION_BODY_MAX + 1);
456 assert!(result.body.ends_with('…'));
457 }
458
459 #[test]
460 fn clarification_gated_by_on_clarification() {
461 let prefs = NotificationPreferences {
462 on_clarification: false,
463 ..NotificationPreferences::default()
464 };
465 let event = clarification("plain question", None);
466 assert!(classify("sess", &event, &prefs).is_none());
467 }
468
469 #[test]
470 fn approval_gated_by_on_tool_approval() {
471 let prefs = NotificationPreferences {
472 on_tool_approval: false,
473 ..NotificationPreferences::default()
474 };
475 let event = clarification("Permission required: do thing", None);
476 assert!(classify("sess", &event, &prefs).is_none());
477 }
478
479 #[test]
480 fn context_critical_fires() {
481 let prefs = NotificationPreferences::default();
482 let result = classify("sess", &context("critical"), &prefs).unwrap();
483 assert_eq!(result.category, NotificationCategory::ContextCritical);
484 assert_eq!(result.priority, NotificationPriority::Normal);
485 assert_eq!(result.title, "Context almost full");
486 assert_eq!(result.body, "Context is nearly full.");
487 assert_eq!(result.dedup_key, "context:sess");
488 }
489
490 #[test]
491 fn context_warning_is_none() {
492 let prefs = NotificationPreferences::default();
493 assert!(classify("sess", &context("warning"), &prefs).is_none());
494 }
495
496 #[test]
497 fn context_gated_by_on_context_pressure() {
498 let prefs = NotificationPreferences {
499 on_context_pressure: false,
500 ..NotificationPreferences::default()
501 };
502 assert!(classify("sess", &context("critical"), &prefs).is_none());
503 }
504
505 #[test]
506 fn subagent_completed_fires() {
507 let prefs = NotificationPreferences::default();
508 let result = classify("sess", &subagent("completed"), &prefs).unwrap();
509 assert_eq!(result.category, NotificationCategory::SubagentCompleted);
510 assert_eq!(result.priority, NotificationPriority::Normal);
511 assert_eq!(result.title, "Background task done");
512 assert_eq!(result.body, "A background task finished (child-1).");
513 assert_eq!(result.dedup_key, "subagent:child-1");
514 }
515
516 #[test]
517 fn subagent_error_is_none() {
518 let prefs = NotificationPreferences::default();
519 assert!(classify("sess", &subagent("error"), &prefs).is_none());
520 }
521
522 #[test]
523 fn subagent_gated_by_on_subagent_complete() {
524 let prefs = NotificationPreferences {
525 on_subagent_complete: false,
526 ..NotificationPreferences::default()
527 };
528 assert!(classify("sess", &subagent("completed"), &prefs).is_none());
529 }
530
531 #[test]
532 fn bash_completed_fires_and_dedups_by_bash_id() {
533 let prefs = NotificationPreferences::default();
534 let result = classify("sess", &bash("sh-9", "completed", Some(0)), &prefs).unwrap();
535 assert_eq!(
536 result.category,
537 NotificationCategory::BackgroundTaskCompleted
538 );
539 assert_eq!(result.priority, NotificationPriority::Normal);
540 assert_eq!(result.title, "Background command finished");
541 assert!(result.body.contains("npm run build"));
542 assert!(result.body.contains("exit 0"));
543 assert_eq!(result.dedup_key, "bash:sh-9");
545 }
546
547 #[test]
548 fn bash_error_status_still_fires() {
549 let prefs = NotificationPreferences::default();
550 let result = classify("sess", &bash("sh-e", "error", None), &prefs).unwrap();
551 assert_eq!(
552 result.category,
553 NotificationCategory::BackgroundTaskCompleted
554 );
555 assert!(result.body.contains("no exit code"));
556 }
557
558 #[test]
559 fn bash_killed_is_none() {
560 let prefs = NotificationPreferences::default();
562 assert!(classify("sess", &bash("sh-k", "killed", None), &prefs).is_none());
563 }
564
565 #[test]
566 fn bash_gated_by_on_background_task_complete() {
567 let prefs = NotificationPreferences {
568 on_background_task_complete: false,
569 ..NotificationPreferences::default()
570 };
571 assert!(classify("sess", &bash("sh-1", "completed", Some(0)), &prefs).is_none());
572 }
573
574 #[test]
575 fn master_switch_off_yields_none() {
576 let prefs = NotificationPreferences {
577 enabled: false,
578 ..NotificationPreferences::default()
579 };
580 assert!(classify("sess", &context("critical"), &prefs).is_none());
581 assert!(classify("sess", &subagent("completed"), &prefs).is_none());
582 assert!(classify("sess", &clarification("q", None), &prefs).is_none());
583 assert!(classify("sess", &bash("sh-1", "completed", Some(0)), &prefs).is_none());
584 assert!(classify("sess", &complete(100), &prefs).is_none());
585 assert!(classify("sess", &error("boom"), &prefs).is_none());
586 assert!(classify_custom("sess", "t", "b", NotificationPriority::Low, &prefs).is_none());
587 }
588
589 #[test]
590 fn unrelated_variant_is_none() {
591 let prefs = NotificationPreferences::default();
592 let event = AgentEvent::Token {
593 content: "hello".to_string(),
594 };
595 assert!(classify("sess", &event, &prefs).is_none());
596 }
597
598 #[test]
599 fn run_completed_fires_with_token_usage_in_body() {
600 let prefs = NotificationPreferences::default();
601 let result = classify("sess-1", &complete(1234), &prefs).unwrap();
602 assert_eq!(result.category, NotificationCategory::RunCompleted);
603 assert_eq!(result.priority, NotificationPriority::Normal);
604 assert_eq!(result.title, "Run complete");
605 assert!(result.body.contains("sess-1"));
606 assert!(result.body.contains("1234"));
607 assert_eq!(result.dedup_key, "run:sess-1:done");
608 }
609
610 #[test]
611 fn run_completed_gated_by_on_run_complete() {
612 let prefs = NotificationPreferences {
613 on_run_complete: false,
614 ..NotificationPreferences::default()
615 };
616 assert!(classify("sess", &complete(10), &prefs).is_none());
617 }
618
619 #[test]
620 fn run_failed_fires_with_truncated_message() {
621 let prefs = NotificationPreferences::default();
622 let long_message = "x".repeat(300);
623 let result = classify("sess-2", &error(&long_message), &prefs).unwrap();
624 assert_eq!(result.category, NotificationCategory::RunFailed);
625 assert_eq!(result.priority, NotificationPriority::High);
626 assert_eq!(result.title, "Run failed");
627 assert_eq!(result.body.chars().count(), RUN_FAILED_BODY_MAX + 1);
628 assert!(result.body.ends_with('…'));
629 assert_eq!(result.dedup_key, "run:sess-2:failed");
630 }
631
632 #[test]
633 fn run_failed_gated_by_on_run_failed() {
634 let prefs = NotificationPreferences {
635 on_run_failed: false,
636 ..NotificationPreferences::default()
637 };
638 assert!(classify("sess", &error("boom"), &prefs).is_none());
639 }
640
641 #[test]
642 fn classify_custom_mints_custom_category_with_hashed_dedup_key() {
643 let prefs = NotificationPreferences::default();
644 let result =
645 classify_custom("sess-3", "Title", "Body", NotificationPriority::Low, &prefs).unwrap();
646 assert_eq!(result.category, NotificationCategory::Custom);
647 assert_eq!(result.priority, NotificationPriority::Low);
648 assert_eq!(result.title, "Title");
649 assert_eq!(result.body, "Body");
650 assert!(result.dedup_key.starts_with("custom:sess-3:"));
651 }
652
653 #[test]
654 fn classify_custom_dedup_key_is_stable_for_identical_content() {
655 let prefs = NotificationPreferences::default();
656 let a =
657 classify_custom("sess", "Title", "Body", NotificationPriority::Low, &prefs).unwrap();
658 let b =
659 classify_custom("sess", "Title", "Body", NotificationPriority::Low, &prefs).unwrap();
660 assert_eq!(a.dedup_key, b.dedup_key);
661 }
662
663 #[test]
664 fn classify_custom_dedup_key_differs_for_different_content() {
665 let prefs = NotificationPreferences::default();
666 let a =
667 classify_custom("sess", "Title", "Body", NotificationPriority::Low, &prefs).unwrap();
668 let b = classify_custom(
669 "sess",
670 "Title",
671 "Different body",
672 NotificationPriority::Low,
673 &prefs,
674 )
675 .unwrap();
676 assert_ne!(a.dedup_key, b.dedup_key);
677 }
678
679 #[test]
680 fn classify_custom_ignores_per_category_prefs() {
681 let prefs = NotificationPreferences {
684 on_clarification: false,
685 on_tool_approval: false,
686 on_context_pressure: false,
687 on_subagent_complete: false,
688 on_background_task_complete: false,
689 on_run_complete: false,
690 on_run_failed: false,
691 ..NotificationPreferences::default()
692 };
693 assert!(classify_custom("sess", "t", "b", NotificationPriority::Low, &prefs).is_some());
694 }
695
696 #[test]
697 fn classify_schedule_run_success_shares_dedup_key_with_classify_complete() {
698 let prefs = NotificationPreferences::default();
699 let scheduled = classify_schedule_run(
700 "sess-1",
701 true,
702 "Schedule 'nightly' completed".to_string(),
703 "All done.".to_string(),
704 &prefs,
705 )
706 .unwrap();
707 let raw = classify("sess-1", &complete(10), &prefs).unwrap();
708 assert_eq!(scheduled.dedup_key, raw.dedup_key);
709 assert_eq!(scheduled.category, NotificationCategory::RunCompleted);
710 assert_eq!(scheduled.priority, NotificationPriority::Normal);
711 assert_eq!(scheduled.title, "Schedule 'nightly' completed");
712 assert_eq!(scheduled.body, "All done.");
713 }
714
715 #[test]
716 fn classify_schedule_run_failure_shares_dedup_key_with_classify_error() {
717 let prefs = NotificationPreferences::default();
718 let scheduled = classify_schedule_run(
719 "sess-2",
720 false,
721 "Schedule 'nightly' failed".to_string(),
722 "boom".to_string(),
723 &prefs,
724 )
725 .unwrap();
726 let raw = classify("sess-2", &error("boom"), &prefs).unwrap();
727 assert_eq!(scheduled.dedup_key, raw.dedup_key);
728 assert_eq!(scheduled.category, NotificationCategory::RunFailed);
729 assert_eq!(scheduled.priority, NotificationPriority::High);
730 }
731
732 #[test]
733 fn classify_schedule_run_gated_by_on_run_complete_and_on_run_failed() {
734 let complete_off = NotificationPreferences {
735 on_run_complete: false,
736 ..NotificationPreferences::default()
737 };
738 assert!(classify_schedule_run(
739 "sess",
740 true,
741 "t".to_string(),
742 "b".to_string(),
743 &complete_off
744 )
745 .is_none());
746
747 let failed_off = NotificationPreferences {
748 on_run_failed: false,
749 ..NotificationPreferences::default()
750 };
751 assert!(classify_schedule_run(
752 "sess",
753 false,
754 "t".to_string(),
755 "b".to_string(),
756 &failed_off
757 )
758 .is_none());
759 }
760
761 #[test]
762 fn classify_schedule_run_master_switch_off_yields_none() {
763 let prefs = NotificationPreferences {
764 enabled: false,
765 ..NotificationPreferences::default()
766 };
767 assert!(
768 classify_schedule_run("sess", true, "t".to_string(), "b".to_string(), &prefs).is_none()
769 );
770 assert!(
771 classify_schedule_run("sess", false, "t".to_string(), "b".to_string(), &prefs)
772 .is_none()
773 );
774 }
775}