Skip to main content

bamboo_notification/
policy.rs

1//! Pure classification of agent events into candidate notifications.
2//!
3//! [`classify`] is the single source of truth for *which* events are
4//! notification-worthy, *how* they are categorised and prioritised, and *what*
5//! their dedup key is. It is intentionally side-effect free: it reads an
6//! [`AgentEvent`] and the user's [`NotificationPreferences`] and returns an
7//! optional [`ClassifiedNotification`]. Dedup timing and id/timestamp minting
8//! happen one layer up in [`crate::service`].
9
10use std::collections::hash_map::DefaultHasher;
11use std::hash::{Hash, Hasher};
12
13use bamboo_agent_core::AgentEvent;
14
15use crate::preferences::NotificationPreferences;
16
17/// Maximum body length for a clarification notification, in characters.
18const CLARIFICATION_BODY_MAX: usize = 120;
19
20/// Maximum body length for a background-task-completed notification, in characters.
21const BACKGROUND_TASK_BODY_MAX: usize = 120;
22
23/// Maximum body length for a run-failed notification, in characters.
24const RUN_FAILED_BODY_MAX: usize = 200;
25
26/// The kind of user-facing notification a classified event maps to.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum NotificationCategory {
29    /// A tool is requesting explicit user approval before running.
30    NeedsApproval,
31    /// The agent is asking the user a free-form clarifying question.
32    NeedsClarification,
33    /// Context usage has reached a critical level.
34    ContextCritical,
35    /// A background sub-agent task has completed.
36    SubagentCompleted,
37    /// A background shell/command (Bash `run_in_background`) has finished.
38    BackgroundTaskCompleted,
39    /// A run finished successfully (`AgentEvent::Complete`).
40    RunCompleted,
41    /// A run failed (`AgentEvent::Error`).
42    RunFailed,
43    /// A per-run token/tool-call/subagent budget tripped and the run was
44    /// gracefully stopped (`AgentEvent::BudgetExceeded`, issue #221).
45    BudgetExceeded,
46    /// A caller-supplied notification minted directly by the `notify` tool
47    /// rather than derived from a specific `AgentEvent` variant.
48    Custom,
49}
50
51impl NotificationCategory {
52    /// Returns the stable wire string for this category.
53    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/// The urgency of a notification, surfaced to the client for sorting/styling.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum NotificationPriority {
71    /// Needs prompt user attention (e.g. the agent is blocked on input).
72    High,
73    /// Informational but worth surfacing.
74    Normal,
75    /// Low-signal; can be quietly delivered.
76    Low,
77}
78
79impl NotificationPriority {
80    /// Returns the stable wire string for this priority.
81    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/// A notification candidate produced by [`classify`].
91///
92/// This carries everything the policy decided; the service layer adds the
93/// volatile bits (unique id, creation timestamp) when materialising the
94/// outgoing [`AgentEvent::Notification`].
95#[derive(Debug, Clone, PartialEq)]
96pub struct ClassifiedNotification {
97    /// Category the event was classified into.
98    pub category: NotificationCategory,
99    /// Priority assigned to the notification.
100    pub priority: NotificationPriority,
101    /// Short title line.
102    pub title: String,
103    /// Body text.
104    pub body: String,
105    /// Stable key used to coalesce duplicate notifications within a window.
106    pub dedup_key: String,
107}
108
109/// Returns `true` when a clarification question is actually a permission prompt.
110///
111/// Permission prompts are produced by the permission gate / `request_permissions`
112/// tool and carry one of these markers in their question text; we route them to
113/// the `NeedsApproval` category rather than `NeedsClarification`.
114fn is_permission_prompt(question: &str) -> bool {
115    question.contains("Permission required") || question.contains("Permission Request")
116}
117
118/// Truncates `text` to at most `max` characters, appending an ellipsis when cut.
119///
120/// Operates on Unicode scalar values (chars), not bytes, so it never splits a
121/// multi-byte character.
122fn 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
131/// Classifies an agent event into an optional notification candidate.
132///
133/// Returns `None` immediately when notifications are disabled, when the event is
134/// not notification-worthy, or when the specific per-category preference is off.
135/// The match uses a catch-all arm, so adding new [`AgentEvent`] variants never
136/// breaks this function.
137pub 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            // A killed shell was terminated by the user (KillShell) — they already
221            // know; don't ping. Notify when a background command reaches a terminal
222            // state on its own ("completed" — any exit code — or an internal
223            // "error").
224            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                // Dedup by the shell id: the live `BashCompleted` fires once, but
239                // key on `bash_id` (not session) so distinct shells each notify.
240                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            // No dedicated preference for this category yet; gated on
284            // `on_run_failed` since a budget-exceeded stop is, like a failure,
285            // an abnormal (non-`Complete`) run termination the user should
286            // learn about promptly.
287            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
306/// Classifies a caller-supplied notification (from the `notify` tool) into a
307/// candidate notification.
308///
309/// Unlike [`classify`], this does not gate on a per-category preference —
310/// there is no dedicated preference for arbitrary agent-chosen content, only
311/// the master [`NotificationPreferences::enabled`] switch applies here. The
312/// dedup key hashes `title` + `body` so an identical repeated call within the
313/// dedup window is coalesced while distinct content from the same session
314/// still notifies. Kept in `policy` (not `service`) to stay pure and
315/// unit-testable without a service instance.
316pub 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
339/// Classifies a schedule-run completion/failure into a notification.
340///
341/// Unlike [`classify`], `title`/`body` are caller-supplied rather than
342/// derived from a raw `AgentEvent` — the schedule manager can name the
343/// schedule and quote the run's final assistant message, which the generic
344/// `AgentEvent::Complete`/`Error` path has no way to know. Everything else
345/// (category, priority, prefs gate, and — critically — the dedup key) is
346/// deliberately IDENTICAL to what [`classify`] derives for
347/// `AgentEvent::Complete`/`Error`: `"run:{session_id}:done"` /
348/// `"run:{session_id}:failed"`. A scheduled run's agent loop still emits that
349/// raw event, which the always-on notification relay independently
350/// classifies via [`classify`] — sharing the dedup key means whichever of the
351/// two sources reaches [`crate::service::NotificationService`] first wins the
352/// user-visible copy and the second is coalesced within
353/// [`crate::service::NotificationService::DEDUP_WINDOW`], so a scheduled run
354/// can never double-notify its owner. See
355/// `bamboo_server::schedule_app::manager`'s completion hook for the call
356/// site.
357pub 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        // No tool_call_id → empty trailing segment.
475        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        // Keyed on bash_id (not session), so distinct shells each notify.
573        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        // A user-killed shell must not ping (the user initiated the kill).
590        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        // classify_custom must not be gated by any per-category flag — only
711        // the master `enabled` switch applies.
712        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}