Skip to main content

agentmux_srv/backend/notify/
policy.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pure notification policy — `SPEC_OS_NOTIFICATIONS_SYSTEM_2026_09_24.md` §5.
5//!
6//! `PolicyState::step(input, now_ms, &settings) -> Vec<Action>` is the whole
7//! decision surface: no clocks, no I/O, no broker. `router.rs` owns the side
8//! effects (publishing, the tick task). Keeping it pure is what makes the
9//! debounce / dedupe / focus-gate / retract rules table-testable.
10//!
11//! Phase 1: kinds `InputWaiting` / `TurnCompleted` / `TurnErrored` / `Test`,
12//! per-kind enable, debounce, dedupe-by-group, focus gate, retract.
13//! Phase 2: `AgentCrashed` / `MessageNeedsReview` / `Summary`, pause, rate
14//! limits folding into a summary, and a tray state snapshot (§4.2, §5.1).
15
16use std::collections::HashMap;
17
18/// Closed set of notification kinds. A source cannot invent a new visual
19/// treatment — every kind has an app-controlled title template (§9.1).
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, ts_rs::TS)]
21#[serde(rename_all = "snake_case")]
22#[ts(export, export_to = "../../../frontend/types/rpc/")]
23pub enum NotifyKind {
24    InputWaiting,
25    TurnCompleted,
26    TurnErrored,
27    /// srv-observed `agentfailure` (not a user stop).
28    AgentCrashed,
29    /// A jekt that requires a human's review before the agent acts
30    /// (`ESCALATE=required`). Body is fixed text — never message content.
31    MessageNeedsReview,
32    /// Something other than the user asked to shut the agent down; the user
33    /// has 15 s to keep it (SPEC_AGENT_SELF_QUIT_2026_09_24.md §6.5).
34    ShutdownPending,
35    /// Rate-limit overflow digest (§5.1 step 6).
36    Summary,
37    Test,
38    /// This channel's MuxBus sign-in stopped working, so agents stopped
39    /// getting cloud messages (`muxbus::delivery_status`). About no agent;
40    /// once per episode.
41    CloudSignedOut,
42}
43
44impl NotifyKind {
45    /// `notify:os:<suffix>` per-kind enable key (§8).
46    pub fn setting_suffix(self) -> Option<&'static str> {
47        match self {
48            NotifyKind::InputWaiting => Some("inputwaiting"),
49            NotifyKind::TurnCompleted => Some("turncompleted"),
50            NotifyKind::TurnErrored => Some("turnerrored"),
51            NotifyKind::AgentCrashed => Some("agentcrashed"),
52            NotifyKind::MessageNeedsReview => Some("messageneedsreview"),
53            NotifyKind::CloudSignedOut => Some("cloudsignedout"),
54            // Follows the master switch only: a shutdown the user can still
55            // stop must not be silenceable per kind.
56            NotifyKind::ShutdownPending => None,
57            NotifyKind::Summary | NotifyKind::Test => None,
58        }
59    }
60
61    /// Debounce before a notification may be shown (§5.1 step 5). Lets the
62    /// user's own imminent action (answering, starting the next turn) cancel
63    /// it before it ever interrupts them.
64    pub fn delay_ms(self) -> i64 {
65        match self {
66            NotifyKind::InputWaiting => 6_000,
67            // Same window as TurnErrored so the two coalesce into one toast.
68            NotifyKind::TurnCompleted | NotifyKind::TurnErrored | NotifyKind::AgentCrashed => 10_000,
69            NotifyKind::MessageNeedsReview => 2_000,
70            // The window is only 15 s: tell the user at once.
71            NotifyKind::ShutdownPending | NotifyKind::CloudSignedOut => 0,
72            NotifyKind::Summary | NotifyKind::Test => 0,
73        }
74    }
75
76    pub fn priority(self) -> Priority {
77        match self {
78            NotifyKind::InputWaiting
79            | NotifyKind::AgentCrashed
80            | NotifyKind::MessageNeedsReview
81            | NotifyKind::ShutdownPending
82            | NotifyKind::CloudSignedOut => {
83                Priority::Attention
84            }
85            _ => Priority::Normal,
86        }
87    }
88
89    /// Coalescing key: at most one live notification per (block, family).
90    pub fn group_key(self, block_id: &str) -> String {
91        match self {
92            NotifyKind::InputWaiting => format!("input:{block_id}"),
93            // AgentCrashed shares the turn group: one failure, one toast.
94            NotifyKind::TurnCompleted | NotifyKind::TurnErrored | NotifyKind::AgentCrashed => {
95                format!("turn:{block_id}")
96            }
97            NotifyKind::MessageNeedsReview => format!("review:{block_id}"),
98            NotifyKind::ShutdownPending => format!("shutdown:{block_id}"),
99            NotifyKind::Summary => "summary".to_string(),
100            NotifyKind::Test => "test".to_string(),
101            NotifyKind::CloudSignedOut => "cloud".to_string(),
102        }
103    }
104
105    /// Kinds about one agent carry its summary line; the digest and the test
106    /// toast are about no agent.
107    fn has_summary(self) -> bool {
108        !matches!(self, NotifyKind::Summary | NotifyKind::Test | NotifyKind::CloudSignedOut)
109    }
110
111    /// Within one group, a later event must not replace a more informative
112    /// earlier one: srv's `AgentCrashed` (with a reason) outranks the
113    /// renderer's generic `TurnErrored`, which often arrives just after it.
114    fn rank(self) -> u8 {
115        match self {
116            NotifyKind::AgentCrashed => 2,
117            _ => 1,
118        }
119    }
120}
121
122#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
123#[serde(rename_all = "snake_case")]
124#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "NotifyPriority")]
125pub enum Priority {
126    Attention,
127    Normal,
128}
129
130/// `notify:os:when` (§6.0).
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub enum When {
133    Unfocused,
134    Always,
135    Never,
136}
137
138/// `notify:os:preview` (§9.2).
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum Preview {
141    Full,
142    Redacted,
143    None,
144}
145
146/// Resolved settings snapshot the policy reads. Built by the router from
147/// `settings.json` on every step (cheap; the config watcher holds it in memory).
148#[derive(Clone, Debug)]
149pub struct Settings {
150    pub enabled: bool,
151    pub when: When,
152    pub preview: Preview,
153    pub kind_enabled: HashMap<NotifyKind, bool>,
154    /// `notify:pause:until` — epoch ms; toasts are held back until then.
155    pub pause_until_ms: i64,
156    /// `notify:pause:allowattention` — let Attention through while paused.
157    pub pause_allow_attention: bool,
158    /// End of the quiet-hours window we're currently inside (epoch ms), or 0.
159    /// Behaves like a pause, but is NOT shown as one in the tray (it isn't
160    /// something "Resume" can undo — it's a schedule).
161    pub quiet_until_ms: i64,
162    /// `notify:os:summary` — show the agent's session summary line.
163    pub show_summary: bool,
164    /// `notify:os:turncompleted:external` — also notify when a turn something
165    /// other than the user started finishes (a jekt, a service notice, a
166    /// schedule, a background task). Off: such a turn ends quietly unless it
167    /// needs the user (a question, a failure), which notifies as always.
168    pub external_turns: bool,
169}
170
171impl Default for Settings {
172    fn default() -> Self {
173        Settings {
174            enabled: true,
175            when: When::Unfocused,
176            preview: Preview::Redacted,
177            kind_enabled: HashMap::new(),
178            pause_until_ms: 0,
179            pause_allow_attention: false,
180            quiet_until_ms: 0,
181            show_summary: true,
182            external_turns: false,
183        }
184    }
185}
186
187/// Rate limits (§5.1 step 6). Attention is exempt from the per-block limit
188/// (an agent that finished and then immediately asks something must still
189/// reach you) but not from the global one.
190pub const PER_BLOCK_WINDOW_MS: i64 = 30_000;
191pub const GLOBAL_WINDOW_MS: i64 = 60_000;
192pub const GLOBAL_MAX: usize = 4;
193
194impl Settings {
195    fn kind_on(&self, kind: NotifyKind) -> bool {
196        *self.kind_enabled.get(&kind).unwrap_or(&true)
197    }
198
199    /// The summary line is decoration: off with its own setting, and with
200    /// preview=none like the body (rich-content spec §1.2).
201    fn summary_on(&self) -> bool {
202        self.show_summary && self.preview != Preview::None
203    }
204}
205
206/// What one frontend window last reported about focus.
207#[derive(Clone, Debug, Default, PartialEq, Eq)]
208pub struct FocusReport {
209    pub window_focused: bool,
210    pub block_id: Option<String>,
211    /// The reporting window's own `Window` oid (rich-content spec §3.3 A):
212    /// what makes "is that window open" answerable. `None` from an older
213    /// frontend.
214    pub window_id: Option<String>,
215}
216
217/// A request from a source. `agent_name` / `body` are already resolved and
218/// sanitized by the router — the policy never sees untrusted free text it
219/// would render as a title.
220#[derive(Clone, Debug, PartialEq, Eq)]
221pub struct Request {
222    pub kind: NotifyKind,
223    pub block_id: String,
224    pub agent_name: String,
225    pub body: Option<String>,
226    /// The agent's session summary (`term:ambient_summary`), sanitized.
227    pub summary: Option<String>,
228}
229
230#[derive(Clone, Debug)]
231pub enum Input {
232    /// A source says something notification-worthy happened.
233    Emit(Request),
234    /// The condition behind a family cleared for a block (question answered,
235    /// new turn started) — cancel pending, retract shown.
236    Resolve { block_id: String, family: Family },
237    /// A frontend window's focus changed.
238    Focus { conn_id: String, report: FocusReport },
239    /// A frontend window went away.
240    Disconnect { conn_id: String },
241    /// The user acted on a notification (clicked it / dismissed it).
242    Ack { id: String, clicked: bool },
243    /// Time passed; release any debounced notification that is now due.
244    Tick,
245}
246
247#[derive(Clone, Copy, Debug, PartialEq, Eq)]
248pub enum Family {
249    Input,
250    Turn,
251    /// A pending external shutdown was kept or went ahead (§6.5).
252    Shutdown,
253    /// This channel's MuxBus sign-in works again, or the user signed out.
254    Cloud,
255}
256
257impl Family {
258    fn matches(self, kind: NotifyKind) -> bool {
259        match self {
260            Family::Input => kind == NotifyKind::InputWaiting,
261            Family::Turn => {
262                matches!(kind, NotifyKind::TurnCompleted | NotifyKind::TurnErrored | NotifyKind::AgentCrashed)
263            }
264            Family::Shutdown => kind == NotifyKind::ShutdownPending,
265            Family::Cloud => kind == NotifyKind::CloudSignedOut,
266        }
267    }
268}
269
270/// One shown Attention item, for the tray's "needs you" submenu. Titles are
271/// app-controlled templates, so they are safe to show in a native menu.
272#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
273#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "NotifyAttentionItem")]
274pub struct AttentionItem {
275    pub id: String,
276    pub block_id: String,
277    pub title: String,
278    /// Lets surfaces treat kinds differently — e.g. only `InputWaiting`
279    /// flashes the taskbar (spec Phase 4).
280    pub kind: NotifyKind,
281}
282
283/// Snapshot the tray renders (§4.2).
284#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
285#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "NotifyTrayState")]
286pub struct TrayState {
287    pub attention: Vec<AttentionItem>,
288    #[ts(type = "number")]
289    pub paused_until_ms: i64,
290}
291
292/// The payload Presenters render. Every string here is app-controlled or
293/// router-sanitized. Exported to TS as `OsNotification` so it can't shadow the
294/// DOM's global `Notification`.
295#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
296#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "OsNotification")]
297pub struct Notification {
298    pub id: String,
299    pub kind: NotifyKind,
300    pub priority: Priority,
301    pub block_id: String,
302    pub agent_name: String,
303    pub title: String,
304    pub body: Option<String>,
305    /// What the agent is working on — a small third line under the body
306    /// (rich-content spec §1). Absent when the agent has none.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    #[ts(optional)]
309    pub summary: Option<String>,
310    /// Stable per group: OS toast replace/remove key (≤16 chars, Win10-safe).
311    pub tag: String,
312    #[ts(type = "number")]
313    pub created_at_ms: i64,
314}
315
316#[derive(Clone, Debug, PartialEq, Eq)]
317pub enum Action {
318    Show(Notification),
319    Retract { id: String, tag: String },
320    Activate { id: String, block_id: String },
321}
322
323#[derive(Clone, Debug)]
324struct Live {
325    notification: Notification,
326    due_at_ms: i64,
327    shown: bool,
328    /// Replaces a toast of the same group that was already on screen — it
329    /// swaps one toast for another rather than adding one, so the per-block
330    /// limit doesn't apply.
331    replaces_shown: bool,
332}
333
334#[derive(Default)]
335pub struct PolicyState {
336    /// group_key → the one live (pending or shown) notification for it.
337    live: HashMap<String, Live>,
338    focus: HashMap<String, FocusReport>,
339    /// The connection whose window most recently reported being focused —
340    /// where a click with no pane of its own raises (§3.3 F).
341    last_focused_conn: Option<String>,
342    next_seq: u64,
343    /// block_id → last time a toast for it was shown (per-block limit).
344    last_shown: HashMap<String, i64>,
345    /// Show times inside the global window.
346    recent: std::collections::VecDeque<i64>,
347    /// Notifications folded into the current summary.
348    summarized: u32,
349}
350
351/// Title from a closed template set (§9.1). Only the agent name is
352/// interpolated, and it arrives pre-sanitized.
353pub fn title_for(kind: NotifyKind, agent_name: &str) -> String {
354    match kind {
355        NotifyKind::InputWaiting => format!("{agent_name} has a question"),
356        NotifyKind::TurnCompleted => format!("{agent_name} finished"),
357        NotifyKind::TurnErrored => format!("{agent_name} stopped with an error"),
358        NotifyKind::AgentCrashed => format!("{agent_name} stopped unexpectedly"),
359        NotifyKind::MessageNeedsReview => format!("A message for {agent_name} needs your review"),
360        NotifyKind::ShutdownPending => format!("{agent_name} will shut down in 15 s"),
361        // Count-bearing; see `summary_title`.
362        NotifyKind::Summary => "More agent updates".to_string(),
363        NotifyKind::Test => "AgentMux notifications are working".to_string(),
364        NotifyKind::CloudSignedOut => "AgentMux is signed out of MuxBus".to_string(),
365    }
366}
367
368pub fn summary_title(count: u32) -> String {
369    if count == 1 {
370        "1 more agent update".to_string()
371    } else {
372        format!("{count} more agent updates")
373    }
374}
375
376/// 16 hex chars of FNV-1a over the group key — stable across restarts,
377/// short enough for every Windows toast Tag limit.
378pub fn tag_for(group_key: &str) -> String {
379    let mut h: u64 = 0xcbf29ce484222325;
380    for b in group_key.as_bytes() {
381        h ^= *b as u64;
382        h = h.wrapping_mul(0x100000001b3);
383    }
384    format!("{h:016x}")
385}
386
387impl PolicyState {
388    pub fn new() -> Self {
389        Self::default()
390    }
391
392    /// Any window focused at all?
393    fn app_focused(&self) -> bool {
394        self.focus.values().any(|f| f.window_focused)
395    }
396
397    /// Is the user looking at exactly this block right now?
398    fn looking_at(&self, block_id: &str) -> bool {
399        self.focus
400            .values()
401            .any(|f| f.window_focused && f.block_id.as_deref() == Some(block_id))
402    }
403
404    /// Whether any frontend window is connected (for click-with-no-window).
405    pub fn has_window(&self) -> bool {
406        !self.focus.is_empty()
407    }
408
409    /// Whether a connected frontend reports being window `window_id`.
410    pub fn window_open(&self, window_id: &str) -> bool {
411        self.focus.values().any(|f| f.window_id.as_deref() == Some(window_id))
412    }
413
414    /// The window a click should raise when it names none of its own: the
415    /// most recently focused one, else any connected window with an id.
416    pub fn raise_window(&self) -> Option<String> {
417        self.last_focused_conn
418            .as_ref()
419            .and_then(|c| self.focus.get(c))
420            .and_then(|f| f.window_id.clone())
421            .or_else(|| {
422                let mut ids: Vec<&String> = self.focus.values().filter_map(|f| f.window_id.as_ref()).collect();
423                ids.sort();
424                ids.first().map(|s| (*s).clone())
425            })
426    }
427
428    /// §6.0 gate, evaluated at the moment a notification becomes due.
429    fn should_show(&self, n: &Notification, s: &Settings) -> bool {
430        if n.kind == NotifyKind::Test {
431            return true;
432        }
433        if self.looking_at(&n.block_id) {
434            return false;
435        }
436        match s.when {
437            When::Never => false,
438            When::Always => true,
439            // Unfocused: OS toast only when the app isn't foreground — except
440            // Attention, which still reaches a user looking at another pane.
441            When::Unfocused => !self.app_focused() || n.priority == Priority::Attention,
442        }
443    }
444
445    pub fn step(&mut self, input: Input, now_ms: i64, s: &Settings) -> Vec<Action> {
446        let mut out = Vec::new();
447        match input {
448            Input::Emit(req) => {
449                let is_test = req.kind == NotifyKind::Test;
450                if !is_test && (!s.enabled || !s.kind_on(req.kind)) {
451                    return out;
452                }
453                let group = req.kind.group_key(&req.block_id);
454                if let Some(existing) = self.live.get_mut(&group) {
455                    if existing.notification.kind.rank() > req.kind.rank() {
456                        return out;
457                    }
458                    // The same question reported again — srv detected it, then
459                    // the renderer mounts the pane and reports it too (the one
460                    // kind with two reporters). Idempotent: no retract, no
461                    // re-debounce; only fill a body or summary we didn't have.
462                    // Other kinds are distinct events (a second crash is news)
463                    // and replace as before (Codex P2s on #3662).
464                    if existing.notification.kind == req.kind && req.kind == NotifyKind::InputWaiting {
465                        if existing.notification.body.is_none() && s.preview != Preview::None {
466                            existing.notification.body = req.body;
467                        }
468                        if existing.notification.summary.is_none() && s.summary_on() {
469                            existing.notification.summary = req.summary;
470                        }
471                        return out;
472                    }
473                }
474                let tag = tag_for(&group);
475                // Replace in place: a newer event in the same family supersedes
476                // the older one (e.g. completed → errored), same OS tag.
477                let replaced_shown = self.live.get(&group).map(|l| l.shown).unwrap_or(false);
478                self.next_seq += 1;
479                let id = format!("n{}-{}", now_ms, self.next_seq);
480                let notification = Notification {
481                    id,
482                    kind: req.kind,
483                    priority: req.kind.priority(),
484                    block_id: req.block_id,
485                    title: title_for(req.kind, &req.agent_name),
486                    agent_name: req.agent_name,
487                    body: match s.preview {
488                        Preview::None => None,
489                        _ => req.body,
490                    },
491                    summary: if s.summary_on() && req.kind.has_summary() { req.summary } else { None },
492                    tag,
493                    created_at_ms: now_ms,
494                };
495                // If the old one was on screen and the new one is still
496                // debouncing, pull the stale one now rather than leaving it up.
497                if replaced_shown && req.kind.delay_ms() > 0 {
498                    if let Some(old) = self.live.get(&group) {
499                        out.push(Action::Retract { id: old.notification.id.clone(), tag: old.notification.tag.clone() });
500                    }
501                }
502                self.live.insert(
503                    group,
504                    Live {
505                        notification,
506                        due_at_ms: now_ms + req.kind.delay_ms(),
507                        shown: false,
508                        replaces_shown: replaced_shown,
509                    },
510                );
511                if is_test {
512                    out.extend(self.release_due(now_ms, s));
513                }
514            }
515            Input::Resolve { block_id, family } => {
516                self.drop_where(&mut out, |n| n.block_id == block_id && family.matches(n.kind));
517            }
518            Input::Focus { conn_id, report } => {
519                let looking = report.window_focused.then(|| report.block_id.clone()).flatten();
520                let app_focused = report.window_focused;
521                if app_focused {
522                    self.last_focused_conn = Some(conn_id.clone());
523                }
524                self.focus.insert(conn_id, report);
525                // Back in the app: the digest of what you missed is moot.
526                if app_focused {
527                    self.summarized = 0;
528                    self.drop_where(&mut out, |n| n.kind == NotifyKind::Summary);
529                }
530                // The user just looked at the block: its notifications are moot.
531                if let Some(b) = looking {
532                    self.drop_where(&mut out, |n| n.block_id == b && n.kind != NotifyKind::Test);
533                }
534            }
535            Input::Disconnect { conn_id } => {
536                self.focus.remove(&conn_id);
537                if self.last_focused_conn.as_deref() == Some(conn_id.as_str()) {
538                    self.last_focused_conn = None;
539                }
540            }
541            Input::Ack { id, clicked } => {
542                let group = self
543                    .live
544                    .iter()
545                    .find(|(_, l)| l.notification.id == id)
546                    .map(|(g, _)| g.clone());
547                if let Some(g) = group {
548                    let l = self.live.remove(&g).expect("present");
549                    if l.notification.kind == NotifyKind::Summary {
550                        self.summarized = 0;
551                    }
552                    out.push(Action::Retract { id: l.notification.id.clone(), tag: l.notification.tag.clone() });
553                    if clicked && !l.notification.block_id.is_empty() {
554                        out.push(Action::Activate { id: l.notification.id, block_id: l.notification.block_id });
555                    }
556                }
557            }
558            Input::Tick => out.extend(self.release_due(now_ms, s)),
559        }
560        out
561    }
562
563    fn release_due(&mut self, now_ms: i64, s: &Settings) -> Vec<Action> {
564        let mut out = Vec::new();
565        while self.recent.front().is_some_and(|t| now_ms - *t >= GLOBAL_WINDOW_MS) {
566            self.recent.pop_front();
567        }
568        let mut due: Vec<String> = self
569            .live
570            .iter()
571            .filter(|(_, l)| !l.shown && l.due_at_ms <= now_ms)
572            .map(|(g, _)| g.clone())
573            .collect();
574        // Deterministic order: oldest first, so the limits keep the earliest.
575        due.sort_by_key(|g| (self.live[g].notification.created_at_ms, g.clone()));
576        let mut folded = 0u32;
577        for g in due {
578            let n = self.live[&g].notification.clone();
579            if !self.should_show(&n, s) || self.paused(&n, now_ms, s) {
580                // Gated out at due time → gone for good. Not re-offered later:
581                // a "finished" toast an hour after the user already saw the
582                // pane would be noise.
583                self.live.remove(&g);
584                continue;
585            }
586            if self.rate_limited(&n, now_ms, self.live[&g].replaces_shown) {
587                self.live.remove(&g);
588                folded += 1;
589                continue;
590            }
591            let l = self.live.get_mut(&g).expect("present");
592            l.shown = true;
593            if n.kind != NotifyKind::Test && n.kind != NotifyKind::Summary {
594                self.last_shown.insert(n.block_id.clone(), now_ms);
595                self.recent.push_back(now_ms);
596            }
597            out.push(Action::Show(n));
598        }
599        if folded > 0 {
600            self.summarized += folded;
601            out.extend(self.show_summary(now_ms));
602        }
603        out
604    }
605
606    fn paused(&self, n: &Notification, now_ms: i64, s: &Settings) -> bool {
607        n.kind != NotifyKind::Test
608            && now_ms < s.pause_until_ms.max(s.quiet_until_ms)
609            && !(s.pause_allow_attention && n.priority == Priority::Attention)
610    }
611
612    fn rate_limited(&self, n: &Notification, now_ms: i64, replaces_shown: bool) -> bool {
613        if matches!(n.kind, NotifyKind::Test | NotifyKind::Summary) {
614            return false;
615        }
616        if self.recent.len() >= GLOBAL_MAX {
617            return true;
618        }
619        n.priority != Priority::Attention
620            && !replaces_shown
621            && self.last_shown.get(&n.block_id).is_some_and(|t| now_ms - *t < PER_BLOCK_WINDOW_MS)
622    }
623
624    /// Show (or replace in place) the one summary toast.
625    fn show_summary(&mut self, now_ms: i64) -> Vec<Action> {
626        let group = NotifyKind::Summary.group_key("");
627        self.next_seq += 1;
628        let n = Notification {
629            id: format!("n{}-{}", now_ms, self.next_seq),
630            kind: NotifyKind::Summary,
631            priority: Priority::Normal,
632            block_id: String::new(),
633            agent_name: String::new(),
634            title: summary_title(self.summarized),
635            body: Some("Open AgentMux to catch up.".to_string()),
636            summary: None,
637            tag: tag_for(&group),
638            created_at_ms: now_ms,
639        };
640        self.live.insert(group, Live { notification: n.clone(), due_at_ms: now_ms, shown: true, replaces_shown: false });
641        vec![Action::Show(n)]
642    }
643
644    /// What the tray shows: every on-screen Attention notification, oldest
645    /// first, plus the pause deadline.
646    pub fn tray_state(&self, s: &Settings, now_ms: i64) -> TrayState {
647        let mut attention: Vec<&Notification> = self
648            .live
649            .values()
650            .filter(|l| l.shown && l.notification.priority == Priority::Attention)
651            .map(|l| &l.notification)
652            .collect();
653        attention.sort_by_key(|n| (n.created_at_ms, n.id.clone()));
654        TrayState {
655            attention: attention
656                .into_iter()
657                .map(|n| AttentionItem { id: n.id.clone(), block_id: n.block_id.clone(), title: n.title.clone(), kind: n.kind })
658                .collect(),
659            paused_until_ms: if s.pause_until_ms > now_ms { s.pause_until_ms } else { 0 },
660        }
661    }
662
663    fn drop_where(&mut self, out: &mut Vec<Action>, pred: impl Fn(&Notification) -> bool) {
664        let groups: Vec<String> =
665            self.live.iter().filter(|(_, l)| pred(&l.notification)).map(|(g, _)| g.clone()).collect();
666        for g in groups {
667            let l = self.live.remove(&g).expect("present");
668            if l.shown {
669                out.push(Action::Retract { id: l.notification.id, tag: l.notification.tag });
670            }
671        }
672    }
673
674    /// Number of live (pending or shown) notifications — for tests/diagnostics.
675    #[cfg(test)]
676    pub fn live_len(&self) -> usize {
677        self.live.len()
678    }
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    #[test]
686    fn shutdown_pending_is_immediate_attention_and_not_silenceable_per_kind() {
687        let k = NotifyKind::ShutdownPending;
688        assert_eq!(k.setting_suffix(), None, "only the master switch can silence it");
689        assert_eq!(k.delay_ms(), 0, "the window is only 15 s");
690        assert!(matches!(k.priority(), Priority::Attention));
691        assert_eq!(k.group_key("b1"), "shutdown:b1");
692        assert_eq!(title_for(k, "Camper"), "Camper will shut down in 15 s");
693    }
694
695    fn req(kind: NotifyKind, block: &str) -> Request {
696        Request { kind, block_id: block.into(), agent_name: "lark".into(), body: Some("Which branch?".into()), summary: None }
697    }
698    fn focus(window: bool, block: Option<&str>) -> Input {
699        Input::Focus { conn_id: "c1".into(), report: FocusReport { window_focused: window, block_id: block.map(Into::into), window_id: None } }
700    }
701    fn shows(a: &[Action]) -> Vec<&Notification> {
702        a.iter().filter_map(|x| if let Action::Show(n) = x { Some(n) } else { None }).collect()
703    }
704    fn retracts(a: &[Action]) -> usize {
705        a.iter().filter(|x| matches!(x, Action::Retract { .. })).count()
706    }
707
708    #[test]
709    fn cloud_signed_out_shows_at_once_even_while_focused_and_retracts_when_resolved() {
710        let k = NotifyKind::CloudSignedOut;
711        assert_eq!(k.setting_suffix(), Some("cloudsignedout"));
712        assert_eq!(title_for(k, ""), "AgentMux is signed out of MuxBus");
713        let s = Settings::default();
714        let mut p = PolicyState::new();
715        p.step(focus(true, Some("b1")), 0, &s);
716        let blockless = Request { kind: k, block_id: String::new(), agent_name: String::new(), body: Some("x".into()), summary: Some("s".into()) };
717        p.step(Input::Emit(blockless), 0, &s);
718        let a = p.step(Input::Tick, 0, &s);
719        let n = shows(&a);
720        assert_eq!(n.len(), 1, "attention reaches a user looking at a pane");
721        assert_eq!(n[0].summary, None, "about no agent");
722        assert_eq!(retracts(&p.step(Input::Resolve { block_id: String::new(), family: Family::Turn }, 1, &s)), 0);
723        assert_eq!(retracts(&p.step(Input::Resolve { block_id: String::new(), family: Family::Cloud }, 1, &s)), 1);
724    }
725
726    #[test]
727    fn debounces_then_shows_when_app_unfocused() {
728        let s = Settings::default();
729        let mut p = PolicyState::new();
730        p.step(focus(false, None), 0, &s);
731        assert!(p.step(Input::Emit(req(NotifyKind::TurnCompleted, "b1")), 0, &s).is_empty());
732        assert!(shows(&p.step(Input::Tick, 9_999, &s)).is_empty());
733        let a = p.step(Input::Tick, 10_000, &s);
734        let n = shows(&a);
735        assert_eq!(n.len(), 1);
736        assert_eq!(n[0].title, "lark finished");
737        // Already shown → not shown again on later ticks.
738        assert!(p.step(Input::Tick, 20_000, &s).is_empty());
739    }
740
741    #[test]
742    fn resolve_inside_debounce_drops_silently() {
743        let s = Settings::default();
744        let mut p = PolicyState::new();
745        p.step(Input::Emit(req(NotifyKind::InputWaiting, "b1")), 0, &s);
746        let a = p.step(Input::Resolve { block_id: "b1".into(), family: Family::Input }, 3_000, &s);
747        assert!(a.is_empty(), "never shown → nothing to retract");
748        assert!(p.step(Input::Tick, 10_000, &s).is_empty());
749        assert_eq!(p.live_len(), 0);
750    }
751
752    #[test]
753    fn resolve_after_show_retracts() {
754        let s = Settings::default();
755        let mut p = PolicyState::new();
756        p.step(Input::Emit(req(NotifyKind::InputWaiting, "b1")), 0, &s);
757        assert_eq!(shows(&p.step(Input::Tick, 6_000, &s)).len(), 1);
758        let a = p.step(Input::Resolve { block_id: "b1".into(), family: Family::Input }, 7_000, &s);
759        assert_eq!(retracts(&a), 1);
760    }
761
762    #[test]
763    fn looking_at_the_block_suppresses_even_attention() {
764        let s = Settings { when: When::Always, ..Settings::default() };
765        let mut p = PolicyState::new();
766        p.step(focus(true, Some("b1")), 0, &s);
767        p.step(Input::Emit(req(NotifyKind::InputWaiting, "b1")), 0, &s);
768        assert!(shows(&p.step(Input::Tick, 6_000, &s)).is_empty());
769    }
770
771    #[test]
772    fn unfocused_mode_suppresses_normal_but_not_attention_when_app_focused_elsewhere() {
773        let s = Settings::default();
774        let mut p = PolicyState::new();
775        p.step(focus(true, Some("other")), 0, &s);
776        p.step(Input::Emit(req(NotifyKind::TurnCompleted, "b1")), 0, &s);
777        p.step(Input::Emit(req(NotifyKind::InputWaiting, "b2")), 0, &s);
778        let a = p.step(Input::Tick, 10_000, &s);
779        let n = shows(&a);
780        assert_eq!(n.len(), 1);
781        assert_eq!(n[0].kind, NotifyKind::InputWaiting);
782    }
783
784    #[test]
785    fn focusing_the_block_retracts_its_shown_toasts() {
786        let s = Settings::default();
787        let mut p = PolicyState::new();
788        p.step(Input::Emit(req(NotifyKind::TurnCompleted, "b1")), 0, &s);
789        assert_eq!(shows(&p.step(Input::Tick, 10_000, &s)).len(), 1);
790        let a = p.step(focus(true, Some("b1")), 11_000, &s);
791        assert_eq!(retracts(&a), 1);
792        assert_eq!(p.live_len(), 0);
793    }
794
795    #[test]
796    fn same_family_replaces_in_place_with_same_tag() {
797        let s = Settings::default();
798        let mut p = PolicyState::new();
799        p.step(Input::Emit(req(NotifyKind::TurnCompleted, "b1")), 0, &s);
800        let first = shows(&p.step(Input::Tick, 10_000, &s))[0].clone();
801        // New turn errors: old shown toast is pulled, new one debounces.
802        let a = p.step(Input::Emit(req(NotifyKind::TurnErrored, "b1")), 12_000, &s);
803        assert_eq!(retracts(&a), 1);
804        let second = shows(&p.step(Input::Tick, 22_000, &s))[0].clone();
805        assert_eq!(first.tag, second.tag);
806        assert_ne!(first.id, second.id);
807        assert_eq!(second.title, "lark stopped with an error");
808        assert_eq!(p.live_len(), 1);
809    }
810
811    #[test]
812    fn disabled_master_or_kind_or_never_mode_shows_nothing() {
813        let mut p = PolicyState::new();
814        let off = Settings { enabled: false, ..Settings::default() };
815        p.step(Input::Emit(req(NotifyKind::TurnCompleted, "b1")), 0, &off);
816        assert_eq!(p.live_len(), 0);
817
818        let mut kinds = HashMap::new();
819        kinds.insert(NotifyKind::TurnCompleted, false);
820        let kind_off = Settings { kind_enabled: kinds, ..Settings::default() };
821        p.step(Input::Emit(req(NotifyKind::TurnCompleted, "b1")), 0, &kind_off);
822        assert_eq!(p.live_len(), 0);
823
824        let never = Settings { when: When::Never, ..Settings::default() };
825        p.step(Input::Emit(req(NotifyKind::InputWaiting, "b1")), 0, &never);
826        assert!(shows(&p.step(Input::Tick, 6_000, &never)).is_empty());
827    }
828
829    #[test]
830    fn test_kind_is_immediate_and_bypasses_gates() {
831        let s = Settings { enabled: false, when: When::Never, ..Settings::default() };
832        let mut p = PolicyState::new();
833        p.step(focus(true, Some("")), 0, &s);
834        let a = p.step(Input::Emit(Request { kind: NotifyKind::Test, block_id: String::new(), agent_name: String::new(), body: None, summary: None }), 0, &s);
835        assert_eq!(shows(&a).len(), 1);
836        assert_eq!(shows(&a)[0].title, "AgentMux notifications are working");
837    }
838
839    #[test]
840    fn click_ack_retracts_and_activates_dismiss_only_retracts() {
841        let s = Settings::default();
842        let mut p = PolicyState::new();
843        p.step(Input::Emit(req(NotifyKind::InputWaiting, "b1")), 0, &s);
844        let id = shows(&p.step(Input::Tick, 6_000, &s))[0].id.clone();
845        let a = p.step(Input::Ack { id: id.clone(), clicked: true }, 7_000, &s);
846        assert_eq!(retracts(&a), 1);
847        assert!(a.contains(&Action::Activate { id, block_id: "b1".into() }));
848
849        p.step(Input::Emit(req(NotifyKind::InputWaiting, "b2")), 8_000, &s);
850        let id2 = shows(&p.step(Input::Tick, 14_000, &s))[0].id.clone();
851        let a = p.step(Input::Ack { id: id2, clicked: false }, 15_000, &s);
852        assert_eq!(retracts(&a), 1);
853        assert!(!a.iter().any(|x| matches!(x, Action::Activate { .. })));
854    }
855
856    #[test]
857    fn unknown_ack_is_a_noop() {
858        let s = Settings::default();
859        let mut p = PolicyState::new();
860        assert!(p.step(Input::Ack { id: "forged".into(), clicked: true }, 0, &s).is_empty());
861    }
862
863    #[test]
864    fn preview_none_strips_body() {
865        let s = Settings { preview: Preview::None, ..Settings::default() };
866        let mut p = PolicyState::new();
867        p.step(Input::Emit(req(NotifyKind::InputWaiting, "b1")), 0, &s);
868        assert_eq!(shows(&p.step(Input::Tick, 6_000, &s))[0].body, None);
869    }
870
871    #[test]
872    fn tag_is_stable_and_short() {
873        assert_eq!(tag_for("input:b1"), tag_for("input:b1"));
874        assert_ne!(tag_for("input:b1"), tag_for("turn:b1"));
875        assert_eq!(tag_for("x").len(), 16);
876    }
877    fn req_b(kind: NotifyKind, block: &str) -> Request {
878        Request { kind, block_id: block.into(), agent_name: "lark".into(), body: None, summary: None }
879    }
880
881    #[test]
882    fn crash_outranks_later_generic_error_in_same_group() {
883        let s = Settings::default();
884        let mut p = PolicyState::new();
885        p.step(Input::Emit(Request { kind: NotifyKind::AgentCrashed, block_id: "b1".into(), agent_name: "lark".into(), body: Some("Needs you to sign in again".into()), summary: None }), 0, &s);
886        p.step(Input::Emit(req_b(NotifyKind::TurnErrored, "b1")), 500, &s);
887        let a = p.step(Input::Tick, 10_000, &s);
888        let n = shows(&a);
889        assert_eq!(n.len(), 1);
890        assert_eq!(n[0].kind, NotifyKind::AgentCrashed);
891        assert_eq!(n[0].title, "lark stopped unexpectedly");
892        assert_eq!(n[0].body.as_deref(), Some("Needs you to sign in again"));
893    }
894
895    #[test]
896    fn generic_error_is_upgraded_by_crash() {
897        let s = Settings::default();
898        let mut p = PolicyState::new();
899        p.step(Input::Emit(req_b(NotifyKind::TurnErrored, "b1")), 0, &s);
900        p.step(Input::Emit(req_b(NotifyKind::AgentCrashed, "b1")), 500, &s);
901        let n = shows(&p.step(Input::Tick, 10_500, &s)).into_iter().cloned().collect::<Vec<_>>();
902        assert_eq!(n.len(), 1);
903        assert_eq!(n[0].kind, NotifyKind::AgentCrashed);
904    }
905
906    #[test]
907    fn pause_holds_back_everything_but_optionally_attention() {
908        let mut s = Settings { pause_until_ms: 100_000, ..Settings::default() };
909        let mut p = PolicyState::new();
910        p.step(Input::Emit(req_b(NotifyKind::TurnCompleted, "b1")), 0, &s);
911        p.step(Input::Emit(req_b(NotifyKind::InputWaiting, "b2")), 0, &s);
912        assert!(shows(&p.step(Input::Tick, 20_000, &s)).is_empty());
913
914        s.pause_allow_attention = true;
915        p.step(Input::Emit(req_b(NotifyKind::TurnCompleted, "b3")), 30_000, &s);
916        p.step(Input::Emit(req_b(NotifyKind::InputWaiting, "b4")), 30_000, &s);
917        let a = p.step(Input::Tick, 45_000, &s);
918        let n = shows(&a);
919        assert_eq!(n.len(), 1);
920        assert_eq!(n[0].block_id, "b4");
921
922        // Pause expired → normal again.
923        p.step(Input::Emit(req_b(NotifyKind::TurnCompleted, "b5")), 100_000, &s);
924        assert_eq!(shows(&p.step(Input::Tick, 110_000, &s)).len(), 1);
925    }
926
927    #[test]
928    fn burst_folds_into_one_summary_and_focus_clears_it() {
929        let s = Settings::default();
930        let mut p = PolicyState::new();
931        for i in 0..20 {
932            p.step(Input::Emit(req_b(NotifyKind::TurnCompleted, &format!("b{i:02}"))), i, &s);
933        }
934        let a = p.step(Input::Tick, 10_100, &s);
935        let n = shows(&a);
936        let normal: Vec<_> = n.iter().filter(|x| x.kind == NotifyKind::TurnCompleted).collect();
937        let summaries: Vec<_> = n.iter().filter(|x| x.kind == NotifyKind::Summary).collect();
938        assert_eq!(normal.len(), GLOBAL_MAX, "global limit");
939        assert_eq!(summaries.len(), 1);
940        assert_eq!(summaries[0].title, "16 more agent updates");
941
942        // A second burst replaces the same summary (same tag), counting up.
943        p.step(Input::Emit(req_b(NotifyKind::TurnCompleted, "c1")), 11_000, &s);
944        let n2 = shows(&p.step(Input::Tick, 21_000, &s)).into_iter().cloned().collect::<Vec<_>>();
945        assert_eq!(n2.len(), 1);
946        assert_eq!(n2[0].title, "17 more agent updates");
947        assert_eq!(n2[0].tag, summaries[0].tag);
948
949        // Focusing the app retracts the summary and resets the count.
950        let a = p.step(focus(true, None), 22_000, &s);
951        assert_eq!(retracts(&a), 1);
952        p.step(focus(false, None), 23_000, &s);
953        for i in 0..6 {
954            p.step(Input::Emit(req_b(NotifyKind::TurnCompleted, &format!("d{i}"))), 100_000 + i, &s);
955        }
956        let n3 = shows(&p.step(Input::Tick, 110_100, &s)).into_iter().cloned().collect::<Vec<_>>();
957        assert!(n3.iter().any(|x| x.title == "2 more agent updates"), "{n3:?}");
958    }
959
960    #[test]
961    fn per_block_limit_spares_attention() {
962        let s = Settings::default();
963        let mut p = PolicyState::new();
964        p.step(Input::Emit(req_b(NotifyKind::TurnCompleted, "b1")), 0, &s);
965        assert_eq!(shows(&p.step(Input::Tick, 10_000, &s)).len(), 1);
966        // Same block, 5s later: a Normal would be limited, Attention is not.
967        p.step(Input::Emit(req_b(NotifyKind::InputWaiting, "b1")), 9_000, &s);
968        let a = p.step(Input::Tick, 15_000, &s);
969        assert_eq!(shows(&a).len(), 1);
970        assert_eq!(shows(&a)[0].kind, NotifyKind::InputWaiting);
971    }
972
973    #[test]
974    fn quiet_hours_hold_back_like_a_pause_but_are_not_shown_as_one() {
975        let s = Settings { quiet_until_ms: 100_000, ..Settings::default() };
976        let mut p = PolicyState::new();
977        p.step(Input::Emit(req_b(NotifyKind::TurnCompleted, "b1")), 0, &s);
978        assert!(shows(&p.step(Input::Tick, 20_000, &s)).is_empty());
979        assert_eq!(p.tray_state(&s, 20_000).paused_until_ms, 0);
980    }
981
982    #[test]
983    fn tray_state_lists_shown_attention_only() {
984        let s = Settings { pause_until_ms: 1_000_000, pause_allow_attention: true, ..Settings::default() };
985        let mut p = PolicyState::new();
986        p.step(Input::Emit(req_b(NotifyKind::InputWaiting, "b1")), 0, &s);
987        p.step(Input::Emit(req_b(NotifyKind::TurnCompleted, "b2")), 0, &s);
988        assert!(p.tray_state(&s, 1_000).attention.is_empty(), "pending isn't shown yet");
989        p.step(Input::Tick, 10_000, &s);
990        let t = p.tray_state(&s, 10_000);
991        assert_eq!(t.attention.len(), 1);
992        assert_eq!(t.attention[0].block_id, "b1");
993        assert_eq!(t.attention[0].title, "lark has a question");
994        assert_eq!(t.attention[0].kind, NotifyKind::InputWaiting);
995        assert_eq!(t.paused_until_ms, 1_000_000);
996        assert_eq!(p.tray_state(&s, 2_000_000).paused_until_ms, 0);
997    }
998
999    #[test]
1000    fn repeated_same_kind_report_is_idempotent() {
1001        let s = Settings::default();
1002        let mut p = PolicyState::new();
1003        // srv detects the question (no text), then the renderer reports it too.
1004        p.step(Input::Emit(req_b(NotifyKind::InputWaiting, "b1")), 0, &s);
1005        let a = p.step(Input::Tick, 6_000, &s);
1006        let first = shows(&a)[0].clone();
1007        let a = p.step(Input::Emit(req(NotifyKind::InputWaiting, "b1")), 7_000, &s);
1008        assert!(a.is_empty(), "no retract, no new toast: {a:?}");
1009        assert!(p.step(Input::Tick, 20_000, &s).is_empty(), "not re-debounced/re-shown");
1010        let t = p.tray_state(&s, 20_000);
1011        assert_eq!(t.attention.len(), 1);
1012        assert_eq!(t.attention[0].id, first.id, "same notification stays live");
1013
1014        // Pending (not yet shown) duplicate keeps its original due time and
1015        // picks up the body it was missing.
1016        p.step(Input::Emit(req_b(NotifyKind::InputWaiting, "b2")), 30_000, &s);
1017        p.step(Input::Emit(req(NotifyKind::InputWaiting, "b2")), 35_000, &s);
1018        let n = shows(&p.step(Input::Tick, 36_000, &s)).into_iter().cloned().collect::<Vec<_>>();
1019        assert_eq!(n.len(), 1, "due at 36s from the FIRST report");
1020        assert_eq!(n[0].body.as_deref(), Some("Which branch?"));
1021    }
1022
1023    #[test]
1024    fn a_second_crash_is_news_not_a_duplicate() {
1025        let s = Settings::default();
1026        let mut p = PolicyState::new();
1027        p.step(Input::Emit(req_b(NotifyKind::AgentCrashed, "b1")), 0, &s);
1028        let first = shows(&p.step(Input::Tick, 10_000, &s))[0].clone();
1029        let a = p.step(Input::Emit(req_b(NotifyKind::AgentCrashed, "b1")), 60_000, &s);
1030        assert_eq!(retracts(&a), 1, "old toast pulled for the new crash");
1031        let second = shows(&p.step(Input::Tick, 70_000, &s))[0].clone();
1032        assert_ne!(first.id, second.id);
1033        assert_eq!(first.tag, second.tag);
1034    }
1035
1036    fn req_s(kind: NotifyKind, block: &str, summary: Option<&str>) -> Request {
1037        Request { summary: summary.map(Into::into), ..req(kind, block) }
1038    }
1039
1040    #[test]
1041    fn summary_rides_along_and_is_dropped_by_its_setting_or_preview_none() {
1042        let mut p = PolicyState::new();
1043        let s = Settings::default();
1044        p.step(Input::Emit(req_s(NotifyKind::InputWaiting, "b1", Some("Fix resize repaint"))), 0, &s);
1045        let n = shows(&p.step(Input::Tick, 6_000, &s))[0].clone();
1046        assert_eq!(n.title, "lark has a question");
1047        assert_eq!(n.body.as_deref(), Some("Which branch?"));
1048        assert_eq!(n.summary.as_deref(), Some("Fix resize repaint"));
1049
1050        for off in [
1051            Settings { show_summary: false, ..Settings::default() },
1052            Settings { preview: Preview::None, ..Settings::default() },
1053        ] {
1054            let mut p = PolicyState::new();
1055            p.step(Input::Emit(req_s(NotifyKind::TurnCompleted, "b1", Some("Fix resize repaint"))), 0, &off);
1056            assert_eq!(shows(&p.step(Input::Tick, 10_000, &off))[0].summary, None, "{off:?}");
1057        }
1058    }
1059
1060    #[test]
1061    fn duplicate_question_report_fills_a_missing_summary() {
1062        let s = Settings::default();
1063        let mut p = PolicyState::new();
1064        // srv sees the question first (no pane → no summary read yet), then
1065        // the renderer reports it with the block's summary set.
1066        p.step(Input::Emit(req_s(NotifyKind::InputWaiting, "b1", None)), 0, &s);
1067        p.step(Input::Emit(req_s(NotifyKind::InputWaiting, "b1", Some("Fix resize repaint"))), 1_000, &s);
1068        let n = shows(&p.step(Input::Tick, 6_000, &s))[0].clone();
1069        assert_eq!(n.summary.as_deref(), Some("Fix resize repaint"));
1070
1071        let off = Settings { show_summary: false, ..Settings::default() };
1072        let mut p = PolicyState::new();
1073        p.step(Input::Emit(req_s(NotifyKind::InputWaiting, "b1", None)), 0, &off);
1074        p.step(Input::Emit(req_s(NotifyKind::InputWaiting, "b1", Some("x"))), 1_000, &off);
1075        assert_eq!(shows(&p.step(Input::Tick, 6_000, &off))[0].summary, None);
1076    }
1077
1078    #[test]
1079    fn summary_line_is_left_out_on_the_digest_and_the_test_toast() {
1080        let s = Settings::default();
1081        let mut p = PolicyState::new();
1082        let a = p.step(Input::Emit(req_s(NotifyKind::Test, "", Some("x"))), 0, &s);
1083        assert_eq!(shows(&a)[0].summary, None);
1084    }
1085
1086    #[test]
1087    fn serialized_payload_omits_an_absent_summary() {
1088        let s = Settings::default();
1089        let mut p = PolicyState::new();
1090        p.step(Input::Emit(req_s(NotifyKind::InputWaiting, "b1", None)), 0, &s);
1091        let n = shows(&p.step(Input::Tick, 6_000, &s))[0].clone();
1092        let v = serde_json::to_value(&n).unwrap();
1093        assert!(v.get("summary").is_none(), "an older launcher sees the same shape as before: {v}");
1094        // And a payload without the field still deserializes.
1095        let back: Notification = serde_json::from_value(v).unwrap();
1096        assert_eq!(back.summary, None);
1097    }
1098
1099    fn focus_as(conn: &str, window_id: &str, focused: bool) -> Input {
1100        Input::Focus {
1101            conn_id: conn.into(),
1102            report: FocusReport { window_focused: focused, block_id: None, window_id: Some(window_id.into()) },
1103        }
1104    }
1105
1106    #[test]
1107    fn open_windows_and_the_raise_target_follow_focus_reports() {
1108        let s = Settings::default();
1109        let mut p = PolicyState::new();
1110        assert!(!p.window_open("w1"));
1111        assert_eq!(p.raise_window(), None);
1112        p.step(focus_as("c1", "w1", false), 0, &s);
1113        p.step(focus_as("c2", "w2", false), 0, &s);
1114        assert!(p.window_open("w1") && p.window_open("w2"));
1115        assert!(!p.window_open("w3"));
1116        // Nobody focused yet: some open window, deterministically.
1117        assert_eq!(p.raise_window().as_deref(), Some("w1"));
1118        p.step(focus_as("c2", "w2", true), 1, &s);
1119        p.step(focus_as("c2", "w2", false), 2, &s);
1120        assert_eq!(p.raise_window().as_deref(), Some("w2"), "last focused, even after it blurred");
1121        p.step(Input::Disconnect { conn_id: "c2".into() }, 3, &s);
1122        assert!(!p.window_open("w2"));
1123        assert_eq!(p.raise_window().as_deref(), Some("w1"));
1124    }
1125
1126    #[test]
1127    fn needs_review_is_attention_with_its_own_group() {
1128        let s = Settings::default();
1129        let mut p = PolicyState::new();
1130        p.step(Input::Emit(req_b(NotifyKind::MessageNeedsReview, "b1")), 0, &s);
1131        p.step(Input::Emit(req_b(NotifyKind::InputWaiting, "b1")), 0, &s);
1132        let a = p.step(Input::Tick, 6_000, &s);
1133        assert_eq!(shows(&a).len(), 2);
1134        assert!(shows(&a).iter().any(|n| n.title == "A message for lark needs your review"));
1135    }
1136}