Skip to main content

ao_core/
notifier.rs

1//! Notifier plugin contract + registry — Slice 3 Phase A (data only).
2//!
3//! Slice 3 turns `ReactionAction::Notify` from "emit a `ReactionTriggered`
4//! event and hope a subscriber is listening" into real fan-out to
5//! configurable channels (stdout, ntfy, desktop, slack, …).
6//!
7//! ## Phase split
8//!
9//! - **Phase A (this module)** — `Notifier` trait, `NotificationPayload`,
10//!   `NotifierError`, `NotificationRouting` config type, `NotifierRegistry`.
11//!   No engine integration, no plugin crates. The types land first so
12//!   they can be reviewed before anything calls them.
13//! - **Phase B** — `ReactionEngine::dispatch_notify` resolves a priority
14//!   through the registry and calls `Notifier::send` on each target,
15//!   aggregating results into `ReactionOutcome`. Uses the test-only
16//!   `TestNotifier` below for coverage — still no plugin crates.
17//! - **Phase C** — first real plugin crate `ao-plugin-notifier-stdout`,
18//!   wired in `ao-cli` with a default-to-stdout policy when the routing
19//!   table is empty.
20//! - **Phase D+** — additional plugin crates (ntfy, desktop, slack, …).
21//!
22//! See `docs/ai/design/feature-notifier-routing.md` for the full Slice 3
23//! arc and the rationale for each design choice.
24//!
25//! ## Why data-only for Phase A
26//!
27//! Landing the trait, payload, error, routing config, and registry as
28//! one focused commit gives reviewers a stable contract to evaluate
29//! before any call sites depend on it. Mirrors the Phase A commit for
30//! Slice 2 (reaction config types only) that preceded the engine wiring
31//! in Phase D.
32//!
33//! Mirrors the `Notifier` / `NotificationPayload` / `notificationRouting`
34//! types in `packages/core/src/types.ts` (TS reference).
35
36use crate::{
37    reactions::{EventPriority, ReactionAction},
38    types::SessionId,
39};
40use async_trait::async_trait;
41use serde::{Deserialize, Serialize};
42use std::{
43    collections::{HashMap, HashSet},
44    sync::{Arc, Mutex},
45};
46
47// ---------------------------------------------------------------------------
48// NotificationPayload
49// ---------------------------------------------------------------------------
50
51/// Data handed to every `Notifier::send` call.
52///
53/// Constructed by `ReactionEngine::dispatch_notify` at Phase B and
54/// later. Phase A only defines the shape so plugins can be written
55/// against a stable target.
56///
57/// Not `Serialize` — the payload lives entirely in-process, never hits
58/// disk, and never rides the event bus (the bus carries narrow
59/// `OrchestratorEvent` variants for fan-out, not rich payloads).
60/// Keeping it off serde means plugins are free to embed non-serde
61/// types (handles, closures, Instants) later without breaking the
62/// type boundary.
63#[derive(Debug, Clone)]
64pub struct NotificationPayload {
65    /// Session the notification is about.
66    pub session_id: SessionId,
67    /// Reaction key that fired (e.g. `"ci-failed"`).
68    pub reaction_key: String,
69    /// Action the engine actually took — always `Notify` at the call
70    /// site, but carried for plugins that want to log/format it.
71    pub action: ReactionAction,
72    /// Priority chosen by the engine for this fire. Decides routing.
73    pub priority: EventPriority,
74    /// One-line title. Synthesized by the engine from `reaction_key +
75    /// session` in Phase B.
76    pub title: String,
77    /// Body text. Pulled from `ReactionConfig.message` when set,
78    /// otherwise engine-supplied boilerplate.
79    pub body: String,
80    /// `true` if this notify is the escalation fallback after retries
81    /// were exhausted (engine swapped `SendToAgent` → `Notify`).
82    /// Plugins that want to badge "escalated" branch on this.
83    pub escalated: bool,
84}
85
86// ---------------------------------------------------------------------------
87// NotifierError
88// ---------------------------------------------------------------------------
89
90/// Plugin-returned error type.
91///
92/// Every variant is treated identically by the engine: logged via
93/// `tracing::warn!`, recorded in `ReactionOutcome { success: false, .. }`,
94/// and never propagated up to the polling loop. A flaky notifier must
95/// not wedge the tick — matches the "never poison the engine" principle
96/// used for malformed durations in Slice 2 Phase H.
97///
98/// The variant split exists so plugin authors have a reasonable place
99/// to put their own errors without inventing a new enum per plugin.
100/// HTTP plugins lean on `Service` + `Timeout`; desktop plugins lean on
101/// `Unavailable`; anything that failed before the wire lean on `Config`
102/// or `Io`.
103#[derive(Debug, thiserror::Error)]
104pub enum NotifierError {
105    /// Local I/O failed — filesystem, stdout, named pipe, etc.
106    #[error("notifier I/O failure: {0}")]
107    Io(String),
108    /// Plugin configuration is invalid or incomplete (missing token,
109    /// unparseable URL, …).
110    #[error("notifier configuration error: {0}")]
111    Config(String),
112    /// External service returned a non-success status.
113    #[error("notifier external service error: {status}: {message}")]
114    Service { status: u16, message: String },
115    /// Plugin exceeded its own timeout budget before the service
116    /// responded.
117    #[error("notifier timed out after {elapsed_ms}ms")]
118    Timeout { elapsed_ms: u64 },
119    /// External service or local dependency is unreachable right now
120    /// (connection refused, DNS failure, desktop daemon missing).
121    #[error("notifier unavailable: {0}")]
122    Unavailable(String),
123}
124
125// ---------------------------------------------------------------------------
126// Notifier trait
127// ---------------------------------------------------------------------------
128
129/// Plugin contract for delivering notifications.
130///
131/// One method + one associated function. Plugins live in their own
132/// crates under `ao-plugin-notifier-*` starting in Phase C; the first
133/// real plugin is stdout.
134///
135/// ## Implementor responsibilities
136///
137/// - **Never panic.** Return a `NotifierError` variant instead. The
138///   engine traps errors but panics would tear down the polling task.
139/// - **Respect a bounded timeout.** HTTP plugins should default to 5s
140///   and map overruns to `NotifierError::Timeout`. The trait signature
141///   does not enforce this; it's a hard convention.
142/// - **Don't hold locks across `.await`.** The engine calls `send`
143///   inline during a poll tick and a deadlocked plugin would wedge the
144///   whole loop.
145/// - **Keep `send` side-effect-only.** Payload mutation is out of
146///   scope — plugins receive `&NotificationPayload` precisely so they
147///   can't rewrite history for downstream plugins in the same fan-out.
148///
149/// ## Concurrency
150///
151/// Implementors must be `Send + Sync` because the registry stores
152/// `Arc<dyn Notifier>` and the engine runs inside a `tokio::spawn`
153/// task. Matches the rest of the `ao-core` plugin traits.
154#[async_trait]
155pub trait Notifier: Send + Sync {
156    /// Canonical name used in the `notification-routing` table.
157    /// Conventionally kebab-case (`"stdout"`, `"ntfy"`, `"slack"`).
158    /// Must be stable across the plugin's lifetime.
159    fn name(&self) -> &str;
160
161    /// Deliver one notification.
162    ///
163    /// Returning `Err` does not crash the engine — the engine logs via
164    /// `tracing::warn!`, marks the `ReactionOutcome` as `success =
165    /// false`, and proceeds to the next plugin in the fan-out.
166    async fn send(&self, payload: &NotificationPayload) -> Result<(), NotifierError>;
167}
168
169// ---------------------------------------------------------------------------
170// NotificationRouting
171// ---------------------------------------------------------------------------
172
173/// Priority-based routing table read from the `notification-routing:`
174/// section of `~/.ao-rs/config.yaml`.
175///
176/// On-disk YAML:
177///
178/// ```yaml
179/// notification-routing:
180///   urgent: [stdout, ntfy]
181///   action: [stdout, ntfy]
182///   warning: [stdout]
183///   info:    [stdout]
184/// ```
185///
186/// Stored as a newtype around `HashMap<EventPriority, Vec<String>>`
187/// with `#[serde(transparent)]` so the on-disk form is just the map —
188/// no wrapper key. Hiding the inner `HashMap` behind `names_for` keeps
189/// the public API stable if we later want to change the container or
190/// bolt on a per-reaction-key override layer.
191///
192/// Default: empty map. An empty table means "nothing configured for
193/// any priority" — `NotifierRegistry::resolve` warn-onces per priority
194/// on the first miss and drops the notification. The fallback policy
195/// (default-to-stdout when the table is empty) belongs one layer up
196/// at the `ao-cli` wiring site in Phase C, not inside the config
197/// type itself, so this module stays pure data.
198#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(transparent)]
200pub struct NotificationRouting(HashMap<EventPriority, Vec<String>>);
201
202impl NotificationRouting {
203    /// Return the list of notifier names registered for this priority,
204    /// or `None` if the priority has no entry.
205    ///
206    /// An empty list (priority present but points at `[]`) is returned
207    /// as `Some(&[])` — distinct from a missing entry. The registry's
208    /// `resolve` method folds both cases together (warn-once + empty
209    /// result) so callers don't need to branch on the difference, but
210    /// they CAN if they ever want to.
211    pub fn names_for(&self, priority: EventPriority) -> Option<&[String]> {
212        self.0.get(&priority).map(Vec::as_slice)
213    }
214
215    /// True if the routing table has no priorities configured at all.
216    /// The `ao-cli` wiring uses this in Phase C to decide whether to
217    /// apply the "default to stdout for everything" fallback.
218    pub fn is_empty(&self) -> bool {
219        self.0.is_empty()
220    }
221
222    /// Number of priorities that have at least one entry.
223    pub fn len(&self) -> usize {
224        self.0.len()
225    }
226
227    /// Construct a routing table from a pre-built map. Used by
228    /// `ao-cli` to build the default-to-stdout routing when the user's
229    /// config has no `notification-routing:` section, and by unit tests
230    /// that want an inline table without going through serde.
231    pub fn from_map(map: HashMap<EventPriority, Vec<String>>) -> Self {
232        Self(map)
233    }
234}
235
236// ---------------------------------------------------------------------------
237// NotifierRegistry
238// ---------------------------------------------------------------------------
239
240/// Runtime-side registry of notifier plugins keyed by name, plus the
241/// routing table that decides which plugins receive each priority.
242///
243/// Constructed in `ao-cli` (Phase C) after plugin instantiation,
244/// attached to `ReactionEngine` via `with_notifier_registry` (Phase B).
245/// Existing call sites that don't attach one keep working — identical
246/// opt-in pattern to `ReactionEngine::with_scm`.
247///
248/// ## Warn-once policy
249///
250/// `resolve` logs exactly one `tracing::warn!` per distinct
251/// `(priority, notifier_name)` pair across the process lifetime, so a
252/// typo in the routing table can't spam the log on every poll tick.
253/// Matches the dedup pattern used by
254/// `reaction_engine::warn_once_parse_failure` for malformed durations.
255pub struct NotifierRegistry {
256    plugins: HashMap<String, Arc<dyn Notifier>>,
257    routing: NotificationRouting,
258    /// Dedup set for `resolve`'s warn-once emits. Keys are one of:
259    /// - `"priority.{priority}"` for missing or empty priority entries
260    /// - `"{priority}.{notifier_name}"` for names with no registered
261    ///   matching plugin
262    ///
263    /// `Mutex` (not `RwLock`) because the set is write-mostly: every
264    /// miss either inserts a new key or short-circuits on an existing
265    /// one. Lock is held narrowly — acquire, check-and-insert, drop,
266    /// *then* call `tracing::warn!`.
267    warned: Mutex<HashSet<String>>,
268}
269
270impl NotifierRegistry {
271    /// Construct an empty registry with the given routing table. Plugins
272    /// are added via `register`.
273    pub fn new(routing: NotificationRouting) -> Self {
274        Self {
275            plugins: HashMap::new(),
276            routing,
277            warned: Mutex::new(HashSet::new()),
278        }
279    }
280
281    /// Register a plugin under a name. Overwrites any existing entry
282    /// for the same name — tests rely on this to stub plugins with
283    /// replacements. Production wiring in `ao-cli` registers each
284    /// plugin exactly once at startup.
285    pub fn register(&mut self, name: impl Into<String>, plugin: Arc<dyn Notifier>) {
286        self.plugins.insert(name.into(), plugin);
287    }
288
289    /// Look up a plugin by name without going through routing.
290    /// Primarily useful for `ao-cli` smoke tests and for future phases
291    /// that may want direct-addressed notifications.
292    pub fn get(&self, name: &str) -> Option<Arc<dyn Notifier>> {
293        self.plugins.get(name).cloned()
294    }
295
296    /// Number of registered plugins.
297    pub fn len(&self) -> usize {
298        self.plugins.len()
299    }
300
301    /// `true` if no plugins have been registered.
302    pub fn is_empty(&self) -> bool {
303        self.plugins.is_empty()
304    }
305
306    /// Resolve a priority against the routing table, returning the
307    /// `(name, plugin)` pairs the engine should dispatch to.
308    ///
309    /// Empty return vec means "do nothing for this priority". That
310    /// happens in three cases, all of which trigger a warn-once:
311    ///
312    /// 1. Priority missing from the routing table entirely.
313    /// 2. Priority present but points at an empty list.
314    /// 3. The routing table names one or more plugins that are not
315    ///    registered — the registered subset (if any) is returned and
316    ///    the missing names are each warned once.
317    ///
318    /// Case 3 can return a non-empty vec (the registered subset) even
319    /// though some of the configured names were missing. That is
320    /// deliberate: a partially-wired routing table should still deliver
321    /// to the plugins that DO exist, not fail closed.
322    pub fn resolve(&self, priority: EventPriority) -> Vec<(String, Arc<dyn Notifier>)> {
323        let Some(names) = self.routing.names_for(priority) else {
324            self.warn_once(format!("priority.{}", priority.as_str()), || {
325                tracing::warn!(
326                    priority = priority.as_str(),
327                    "notification-routing has no entry for priority; notification dropped"
328                );
329            });
330            return Vec::new();
331        };
332
333        if names.is_empty() {
334            self.warn_once(format!("priority.{}", priority.as_str()), || {
335                tracing::warn!(
336                    priority = priority.as_str(),
337                    "notification-routing has an empty list for priority; notification dropped"
338                );
339            });
340            return Vec::new();
341        }
342
343        let mut out = Vec::with_capacity(names.len());
344        for name in names {
345            if let Some(plugin) = self.plugins.get(name) {
346                out.push((name.clone(), plugin.clone()));
347            } else {
348                let key = format!("{}.{}", priority.as_str(), name);
349                let missing_name = name.clone();
350                self.warn_once(key, || {
351                    tracing::warn!(
352                        priority = priority.as_str(),
353                        notifier = missing_name.as_str(),
354                        "notification-routing references unregistered notifier; skipping"
355                    );
356                });
357            }
358        }
359        out
360    }
361
362    /// Dedup helper. Acquires `warned` narrowly — insert, drop lock,
363    /// then invoke `emit`. Matches the lock discipline used by
364    /// `reaction_engine::warn_once_parse_failure` (Phase H) so a
365    /// future `tracing::warn!` macro expansion that panics inside the
366    /// formatter can never poison the mutex while it's held.
367    fn warn_once<F: FnOnce()>(&self, key: String, emit: F) {
368        let fire = {
369            let mut set = self.warned.lock().unwrap_or_else(|e| {
370                tracing::error!(
371                    "notifier registry warned mutex poisoned; recovering inner state: {e}"
372                );
373                e.into_inner()
374            });
375            set.insert(key)
376        };
377        if fire {
378            emit();
379        }
380    }
381
382    /// Test-only accessor for the dedup set size. Production code
383    /// must treat `warned` as opaque.
384    #[cfg(test)]
385    pub(crate) fn warned_count(&self) -> usize {
386        self.warned
387            .lock()
388            .unwrap_or_else(|e| {
389                tracing::error!(
390                    "notifier registry warned mutex poisoned; recovering inner state: {e}"
391                );
392                e.into_inner()
393            })
394            .len()
395    }
396}
397
398// ---------------------------------------------------------------------------
399// Tests
400// ---------------------------------------------------------------------------
401
402#[cfg(test)]
403pub(crate) mod tests {
404    use super::*;
405    use std::sync::Mutex as StdMutex;
406
407    /// Records every `send` call for inspection by tests. Lives in the
408    /// `tests` module but is `pub(crate)` so Phase B's `reaction_engine`
409    /// tests can import it: `use crate::notifier::tests::TestNotifier`.
410    ///
411    /// The inner mutex wraps a `Vec` of owned payloads. `send` is
412    /// async but we never hold the lock across `.await` (we don't have
413    /// an await point inside this impl at all), so `std::sync::Mutex`
414    /// is fine — a `tokio::sync::Mutex` would be overkill.
415    pub(crate) struct TestNotifier {
416        name: String,
417        received: Arc<StdMutex<Vec<NotificationPayload>>>,
418    }
419
420    impl TestNotifier {
421        pub(crate) fn new(
422            name: impl Into<String>,
423        ) -> (Self, Arc<StdMutex<Vec<NotificationPayload>>>) {
424            let received = Arc::new(StdMutex::new(Vec::new()));
425            (
426                Self {
427                    name: name.into(),
428                    received: Arc::clone(&received),
429                },
430                received,
431            )
432        }
433    }
434
435    #[async_trait]
436    impl Notifier for TestNotifier {
437        fn name(&self) -> &str {
438            &self.name
439        }
440
441        async fn send(&self, payload: &NotificationPayload) -> Result<(), NotifierError> {
442            self.received
443                .lock()
444                .unwrap_or_else(|e| {
445                    tracing::error!("test notifier mutex poisoned; recovering inner state: {e}");
446                    e.into_inner()
447                })
448                .push(payload.clone());
449            Ok(())
450        }
451    }
452
453    fn fake_payload(priority: EventPriority) -> NotificationPayload {
454        NotificationPayload {
455            session_id: SessionId("sess-test".into()),
456            reaction_key: "ci-failed".into(),
457            action: ReactionAction::Notify,
458            priority,
459            title: "CI broke on sess-test".into(),
460            body: "tests failed on main".into(),
461            escalated: false,
462        }
463    }
464
465    // ---- NotificationRouting ----
466
467    #[test]
468    fn routing_default_is_empty() {
469        let r = NotificationRouting::default();
470        assert!(r.is_empty());
471        assert_eq!(r.len(), 0);
472        assert!(r.names_for(EventPriority::Urgent).is_none());
473    }
474
475    #[test]
476    fn routing_yaml_round_trip() {
477        let yaml = r#"
478urgent: [stdout, ntfy]
479action: [stdout, ntfy]
480warning: [stdout]
481info: [stdout]
482"#;
483        let parsed: NotificationRouting = serde_yaml::from_str(yaml).unwrap();
484        assert_eq!(parsed.len(), 4);
485        assert_eq!(
486            parsed.names_for(EventPriority::Urgent).unwrap(),
487            &["stdout".to_string(), "ntfy".to_string()]
488        );
489        assert_eq!(
490            parsed.names_for(EventPriority::Info).unwrap(),
491            &["stdout".to_string()]
492        );
493
494        // Round-trip through YAML: serialize back, re-parse, equals original.
495        let back = serde_yaml::to_string(&parsed).unwrap();
496        let again: NotificationRouting = serde_yaml::from_str(&back).unwrap();
497        assert_eq!(parsed, again);
498    }
499
500    #[test]
501    fn routing_rejects_unknown_priority_key() {
502        // Strict priority matching: a typo ("critical") must fail the
503        // parse, not be silently dropped. Locks in behaviour so a
504        // future serde change (e.g. `#[serde(other)]`) can't flip it
505        // without this test failing first.
506        let yaml = "critical: [stdout]\n";
507        let result: std::result::Result<NotificationRouting, _> = serde_yaml::from_str(yaml);
508        assert!(
509            result.is_err(),
510            "expected parse error for unknown priority, got {result:?}"
511        );
512    }
513
514    #[test]
515    fn routing_preserves_empty_list_distinct_from_missing() {
516        // `warning: []` is preserved as Some(&[]), NOT folded into
517        // None. `resolve` folds them together for the engine, but the
518        // distinction is visible at the config layer so tooling can
519        // tell them apart if it ever wants to.
520        let yaml = "warning: []\n";
521        let parsed: NotificationRouting = serde_yaml::from_str(yaml).unwrap();
522        assert_eq!(parsed.names_for(EventPriority::Warning), Some(&[][..]));
523        assert!(parsed.names_for(EventPriority::Urgent).is_none());
524    }
525
526    // ---- NotifierRegistry ----
527
528    #[test]
529    fn registry_new_is_empty() {
530        let r = NotifierRegistry::new(NotificationRouting::default());
531        assert!(r.is_empty());
532        assert_eq!(r.len(), 0);
533        assert!(r.get("stdout").is_none());
534    }
535
536    #[test]
537    fn registry_register_and_get_round_trip() {
538        let (tn, _received) = TestNotifier::new("stdout");
539        let mut reg = NotifierRegistry::new(NotificationRouting::default());
540        reg.register("stdout", Arc::new(tn));
541        assert_eq!(reg.len(), 1);
542        let got = reg.get("stdout").expect("plugin should be registered");
543        assert_eq!(got.name(), "stdout");
544    }
545
546    #[test]
547    fn registry_register_overwrites_existing() {
548        // Two plugins registered under the same name — the second
549        // replaces the first. Documented behaviour so tests can
550        // reliably stub plugins with replacements.
551        let (first, _) = TestNotifier::new("first");
552        let (second, _) = TestNotifier::new("second");
553        let mut reg = NotifierRegistry::new(NotificationRouting::default());
554        reg.register("slot", Arc::new(first));
555        reg.register("slot", Arc::new(second));
556        assert_eq!(reg.len(), 1);
557        assert_eq!(reg.get("slot").unwrap().name(), "second");
558    }
559
560    #[test]
561    fn resolve_empty_routing_returns_empty_and_warns_once() {
562        // Priority missing from the table → empty vec, one warn.
563        // Resolving the same priority a second time → still empty
564        // vec, warn is deduped.
565        let reg = NotifierRegistry::new(NotificationRouting::default());
566        assert!(reg.resolve(EventPriority::Urgent).is_empty());
567        assert_eq!(reg.warned_count(), 1);
568        assert!(reg.resolve(EventPriority::Urgent).is_empty());
569        assert_eq!(reg.warned_count(), 1, "same-priority miss must dedup");
570
571        // Different priority → second warn key.
572        assert!(reg.resolve(EventPriority::Warning).is_empty());
573        assert_eq!(reg.warned_count(), 2);
574    }
575
576    #[test]
577    fn resolve_returns_only_registered_names() {
578        // Routing table names two plugins; only one is registered.
579        // Registered subset is returned; missing name fires a warn.
580        let mut routing = HashMap::new();
581        routing.insert(
582            EventPriority::Urgent,
583            vec!["stdout".to_string(), "ntfy".to_string()],
584        );
585        let (tn, _received) = TestNotifier::new("stdout");
586        let mut reg = NotifierRegistry::new(NotificationRouting::from_map(routing));
587        reg.register("stdout", Arc::new(tn));
588
589        let resolved = reg.resolve(EventPriority::Urgent);
590        assert_eq!(resolved.len(), 1, "should return only the registered one");
591        assert_eq!(resolved[0].0, "stdout");
592        assert_eq!(reg.warned_count(), 1, "one warn for missing 'ntfy'");
593
594        // Second resolve of the same priority: same subset, same warn
595        // set size (the missing-name dedup kicks in).
596        let again = reg.resolve(EventPriority::Urgent);
597        assert_eq!(again.len(), 1);
598        assert_eq!(reg.warned_count(), 1);
599    }
600
601    #[test]
602    fn resolve_distinct_missing_names_are_warned_separately() {
603        // Two priorities each referencing a different missing plugin
604        // → two distinct warn keys.
605        let mut routing = HashMap::new();
606        routing.insert(EventPriority::Urgent, vec!["missing-a".to_string()]);
607        routing.insert(EventPriority::Warning, vec!["missing-b".to_string()]);
608        let reg = NotifierRegistry::new(NotificationRouting::from_map(routing));
609
610        assert!(reg.resolve(EventPriority::Urgent).is_empty());
611        assert!(reg.resolve(EventPriority::Warning).is_empty());
612        assert_eq!(reg.warned_count(), 2);
613    }
614
615    #[test]
616    fn resolve_empty_list_warns_once() {
617        // A priority configured with an empty list is the same as
618        // "missing" from the engine's perspective — warn once, drop.
619        let mut routing = HashMap::new();
620        routing.insert(EventPriority::Warning, Vec::<String>::new());
621        let reg = NotifierRegistry::new(NotificationRouting::from_map(routing));
622
623        assert!(reg.resolve(EventPriority::Warning).is_empty());
624        assert_eq!(reg.warned_count(), 1);
625        assert!(reg.resolve(EventPriority::Warning).is_empty());
626        assert_eq!(reg.warned_count(), 1);
627    }
628
629    #[test]
630    fn resolve_returns_plugins_in_routing_order() {
631        // The per-priority name list is a Vec — dispatch happens in
632        // declared order. Locking this in so Phase B's failure
633        // aggregation can rely on stable ordering.
634        let mut routing = HashMap::new();
635        routing.insert(
636            EventPriority::Info,
637            vec!["a".to_string(), "b".to_string(), "c".to_string()],
638        );
639        let (a, _) = TestNotifier::new("a");
640        let (b, _) = TestNotifier::new("b");
641        let (c, _) = TestNotifier::new("c");
642        let mut reg = NotifierRegistry::new(NotificationRouting::from_map(routing));
643        reg.register("a", Arc::new(a));
644        reg.register("b", Arc::new(b));
645        reg.register("c", Arc::new(c));
646
647        let resolved = reg.resolve(EventPriority::Info);
648        let names: Vec<&str> = resolved.iter().map(|(n, _)| n.as_str()).collect();
649        assert_eq!(names, vec!["a", "b", "c"]);
650    }
651
652    // ---- TestNotifier (directly) ----
653
654    #[tokio::test]
655    async fn test_notifier_records_payload() {
656        // Sanity-check the mock: send one payload, assert it landed
657        // in the shared vec. Phase B's engine tests will depend on
658        // this mechanism.
659        let (tn, received) = TestNotifier::new("test");
660        let payload = fake_payload(EventPriority::Urgent);
661        tn.send(&payload).await.unwrap();
662
663        let got = received.lock().unwrap();
664        assert_eq!(got.len(), 1);
665        assert_eq!(got[0].reaction_key, "ci-failed");
666        assert_eq!(got[0].priority, EventPriority::Urgent);
667    }
668}