1use std::collections::HashMap;
17
18#[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 AgentCrashed,
29 MessageNeedsReview,
32 ShutdownPending,
35 Summary,
37 Test,
38 CloudSignedOut,
42}
43
44impl NotifyKind {
45 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 NotifyKind::ShutdownPending => None,
57 NotifyKind::Summary | NotifyKind::Test => None,
58 }
59 }
60
61 pub fn delay_ms(self) -> i64 {
65 match self {
66 NotifyKind::InputWaiting => 6_000,
67 NotifyKind::TurnCompleted | NotifyKind::TurnErrored | NotifyKind::AgentCrashed => 10_000,
69 NotifyKind::MessageNeedsReview => 2_000,
70 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 pub fn group_key(self, block_id: &str) -> String {
91 match self {
92 NotifyKind::InputWaiting => format!("input:{block_id}"),
93 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 fn has_summary(self) -> bool {
108 !matches!(self, NotifyKind::Summary | NotifyKind::Test | NotifyKind::CloudSignedOut)
109 }
110
111 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub enum When {
133 Unfocused,
134 Always,
135 Never,
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum Preview {
141 Full,
142 Redacted,
143 None,
144}
145
146#[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 pub pause_until_ms: i64,
156 pub pause_allow_attention: bool,
158 pub quiet_until_ms: i64,
162 pub show_summary: bool,
164 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
187pub 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 fn summary_on(&self) -> bool {
202 self.show_summary && self.preview != Preview::None
203 }
204}
205
206#[derive(Clone, Debug, Default, PartialEq, Eq)]
208pub struct FocusReport {
209 pub window_focused: bool,
210 pub block_id: Option<String>,
211 pub window_id: Option<String>,
215}
216
217#[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 pub summary: Option<String>,
228}
229
230#[derive(Clone, Debug)]
231pub enum Input {
232 Emit(Request),
234 Resolve { block_id: String, family: Family },
237 Focus { conn_id: String, report: FocusReport },
239 Disconnect { conn_id: String },
241 Ack { id: String, clicked: bool },
243 Tick,
245}
246
247#[derive(Clone, Copy, Debug, PartialEq, Eq)]
248pub enum Family {
249 Input,
250 Turn,
251 Shutdown,
253 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#[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 pub kind: NotifyKind,
281}
282
283#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
308 #[ts(optional)]
309 pub summary: Option<String>,
310 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_shown: bool,
332}
333
334#[derive(Default)]
335pub struct PolicyState {
336 live: HashMap<String, Live>,
338 focus: HashMap<String, FocusReport>,
339 last_focused_conn: Option<String>,
342 next_seq: u64,
343 last_shown: HashMap<String, i64>,
345 recent: std::collections::VecDeque<i64>,
347 summarized: u32,
349}
350
351pub 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 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
376pub 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 fn app_focused(&self) -> bool {
394 self.focus.values().any(|f| f.window_focused)
395 }
396
397 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 pub fn has_window(&self) -> bool {
406 !self.focus.is_empty()
407 }
408
409 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 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 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 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 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 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 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 if app_focused {
527 self.summarized = 0;
528 self.drop_where(&mut out, |n| n.kind == NotifyKind::Summary);
529 }
530 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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}