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