1use serde::Deserialize;
20use std::sync::{Arc, Mutex, OnceLock};
21
22use crate::backend::blockcontroller::health::{TriggerKind, TurnEnd, TurnTrigger};
23use crate::backend::mps::{Broker, MuxEvent};
24use crate::backend::obj::{Block, Tab, Window, Workspace};
25use crate::backend::storage::store::Store;
26use crate::backend::wconfig::ConfigState;
27
28use super::policy::{
29 Action, Family, FocusReport, Input, NotifyKind, PolicyState, Preview, Request, Settings, TrayState, When,
30};
31
32pub const EVENT_NOTIFICATION: &str = "notification";
33pub const EVENT_NOTIFICATION_RETRACT: &str = "notification:retract";
34pub const EVENT_NOTIFICATION_ACTIVATE: &str = "notification:activate";
35pub const EVENT_NOTIFICATION_STATE: &str = "notification:state";
38pub const EVENT_BLOCK_REVEAL: &str = "block:reveal";
41
42#[derive(Clone, Debug, serde::Serialize, ts_rs::TS)]
44#[ts(export, export_to = "../../../frontend/types/rpc/")]
45pub struct NotificationRetractEvent {
46 pub id: String,
47 pub tag: String,
48}
49
50#[derive(Clone, Debug, serde::Serialize, ts_rs::TS)]
52#[ts(export, export_to = "../../../frontend/types/rpc/")]
53pub struct NotificationActivateEvent {
54 pub id: String,
55 pub block_id: String,
56 pub tab_id: String,
57 pub window_id: String,
58 pub workspace_id: Option<String>,
59 #[ts(type = "number")]
60 pub at_ms: i64,
61}
62
63#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
65#[ts(export, export_to = "../../../frontend/types/rpc/")]
66pub struct BlockRevealEvent {
67 pub block_id: String,
68 pub tab_id: String,
69 pub window_id: String,
71}
72
73enum Internal {
76 Emit { kind: NotifyKind, block_id: String, body: Option<String>, own_blocks_only: bool },
80 InputWaiting { block_id: String, question: Option<String>, count: usize },
83 Resolve { block_id: String, family: Family },
86 TurnStatus { block_id: String, active: bool },
88 TurnStopped { block_id: String },
90 TurnEnded(LedgerEnd),
92}
93
94#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct LedgerEnd {
97 pub block_id: String,
98 pub turn_id: u64,
99 pub ended_at_ms: u64,
102 pub external: bool,
105 pub system: bool,
107}
108
109#[derive(serde::Deserialize)]
112struct PublishedLedger {
113 block_id: String,
114 turn_id: u64,
115 #[serde(default)]
116 ended_at_ms: Option<u64>,
117 #[serde(default)]
118 end: Option<TurnEnd>,
119 #[serde(default)]
120 trigger: Option<TurnTrigger>,
121}
122
123pub fn parse_ledger_end(d: &serde_json::Value) -> Option<LedgerEnd> {
126 let l = PublishedLedger::deserialize(d).ok()?;
127 if l.end != Some(TurnEnd::Completed) {
128 return None;
129 }
130 let kind = l.trigger.map(|t| t.kind);
131 Some(LedgerEnd {
132 block_id: l.block_id,
133 turn_id: l.turn_id,
134 ended_at_ms: l.ended_at_ms.unwrap_or(0),
135 external: kind.is_some_and(TriggerKind::is_external),
136 system: kind == Some(TriggerKind::System),
137 })
138}
139
140pub fn notify_turn_end(end: &LedgerEnd, external_turns: bool) -> bool {
143 !end.system && (!end.external || external_turns)
144}
145
146const STOP_GRACE_MS: i64 = 15_000;
149const TURN_MAP_MAX: usize = 1024;
151
152#[derive(Debug, PartialEq, Eq)]
154pub enum TurnTransition {
155 Started,
157 Finished,
159 Nothing,
161}
162
163pub fn parse_turn_status(d: &serde_json::Value) -> Option<(String, bool)> {
169 let block_id = d.get("blockid")?.as_str()?.to_string();
170 let active = d.get("turn_active").and_then(|v| v.as_bool()).unwrap_or(false);
171 Some((block_id, active))
172}
173
174pub fn turn_transition(prev: Option<bool>, active: bool, stopped_recently: bool) -> TurnTransition {
177 match (prev, active) {
178 (Some(true), true) | (Some(false), false) => TurnTransition::Nothing,
179 (_, true) => TurnTransition::Started,
180 (Some(true), false) if !stopped_recently => TurnTransition::Finished,
181 _ => TurnTransition::Nothing,
182 }
183}
184
185pub const PENDING_ACTIVATION_MAX_AGE_MS: i64 = 30_000;
189
190#[derive(Clone, Debug, Default, PartialEq, Eq)]
194pub struct ClickTarget {
195 pub block_id: String,
196 pub tab_id: String,
197 pub workspace_id: Option<String>,
199 pub window_ids: Vec<String>,
202}
203
204pub fn resolve_click_target(store: &Store, block_id: &str) -> Option<ClickTarget> {
207 let block = store.get::<Block>(block_id).ok().flatten()?;
208 let tab_id = block.parentoref.strip_prefix("tab:")?.to_string();
209 store.get::<Tab>(&tab_id).ok().flatten()?;
210 let workspace_id = store
211 .get_all::<Workspace>()
212 .unwrap_or_default()
213 .into_iter()
214 .find(|w| w.tabids.contains(&tab_id) || w.pinnedtabids.contains(&tab_id))
215 .map(|w| w.oid);
216 let mut window_ids: Vec<String> = match &workspace_id {
217 Some(ws) => store
218 .get_all::<Window>()
219 .unwrap_or_default()
220 .into_iter()
221 .filter(|w| &w.workspaceid == ws)
222 .map(|w| w.oid)
223 .collect(),
224 None => Vec::new(),
225 };
226 window_ids.sort();
227 Some(ClickTarget { block_id: block_id.to_string(), tab_id, workspace_id, window_ids })
228}
229
230#[derive(Clone, Debug, Default, PartialEq, Eq)]
232pub struct AckOutcome {
233 pub known: bool,
236 pub window_id: Option<String>,
239 pub workspace_id: Option<String>,
241 pub has_window: bool,
243}
244
245#[derive(Clone, Debug, PartialEq, Eq)]
247pub struct PendingActivation {
248 pub block_id: String,
249 pub tab_id: String,
250 pub workspace_id: Option<String>,
251 pub at_ms: i64,
252}
253
254pub fn may_take_pending(pending: &PendingActivation, workspace_id: Option<&str>, now_ms: i64) -> bool {
257 if now_ms - pending.at_ms > PENDING_ACTIVATION_MAX_AGE_MS {
258 return false;
259 }
260 match (pending.workspace_id.as_deref(), workspace_id) {
261 (Some(want), Some(have)) => want == have,
262 _ => true,
263 }
264}
265
266fn pick_open_window(p: &PolicyState, window_ids: &[String]) -> Option<String> {
270 let raise = p.raise_window();
271 let open: Vec<&String> = window_ids.iter().filter(|w| p.window_open(w)).collect();
272 open.iter().find(|w| Some(w.as_str()) == raise.as_deref()).or(open.first()).map(|w| (*w).clone())
273}
274
275pub const CLOUD_SIGNED_OUT_BODY: &str =
277 "Agents won't get messages from other computers or GitHub until you sign in.";
278
279const AGENT_NAME_MAX: usize = 32;
280const BODY_MAX: usize = 80;
281const SUMMARY_MAX: usize = 90;
283
284pub struct Router {
285 policy: Mutex<PolicyState>,
286 broker: Arc<Broker>,
287 config: Arc<ConfigState>,
288 store: Arc<Store>,
289 pending_activation: Mutex<Option<PendingActivation>>,
292 internal: tokio::sync::mpsc::UnboundedSender<Internal>,
293 last_tray: Mutex<Option<TrayState>>,
294 turn_active: Mutex<std::collections::HashMap<String, bool>>,
296 stopped_at: Mutex<std::collections::HashMap<String, i64>>,
298 notified_turns: Mutex<std::collections::HashSet<(String, u64, u64)>>,
300}
301
302type Registry = Mutex<std::collections::HashMap<usize, Arc<Router>>>;
303static ROUTERS: OnceLock<Registry> = OnceLock::new();
304
305fn registry() -> &'static Registry {
306 ROUTERS.get_or_init(Default::default)
307}
308
309fn key(broker: &Arc<Broker>) -> usize {
310 Arc::as_ptr(broker) as usize
311}
312
313pub fn get(broker: &Arc<Broker>) -> Option<Arc<Router>> {
315 registry().lock().unwrap_or_else(|e| e.into_inner()).get(&key(broker)).cloned()
316}
317
318pub fn init(
324 broker: Arc<Broker>,
325 config: Arc<ConfigState>,
326 store: Arc<Store>,
327 reactive: &'static crate::backend::reactive::ReactiveHandler,
328) -> Arc<Router> {
329 let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner());
330 reg.entry(key(&broker))
331 .or_insert_with(|| {
332 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
333 let r = Arc::new(Router {
334 policy: Mutex::new(PolicyState::new()),
335 broker: broker.clone(),
336 config,
337 store,
338 pending_activation: Mutex::new(None),
339 internal: tx,
340 last_tray: Mutex::new(None),
341 turn_active: Mutex::new(Default::default()),
342 stopped_at: Mutex::new(Default::default()),
343 notified_turns: Mutex::new(Default::default()),
344 });
345 spawn_ticker(Arc::downgrade(&r));
346 spawn_internal(Arc::downgrade(&r), rx);
347 attach_sources(&r, &broker, reactive);
348 r
349 })
350 .clone()
351}
352
353fn attach_sources(r: &Arc<Router>, broker: &Arc<Broker>, reactive: &'static crate::backend::reactive::ReactiveHandler) {
357 let tx = r.internal.clone();
358 broker.add_observer(Arc::new(move |ev: &MuxEvent| {
359 if ev.event == crate::backend::mps::EVENT_CONTROLLER_STATUS {
364 if let Some((block_id, active)) = ev.data.as_ref().and_then(parse_turn_status) {
370 let _ = tx.send(Internal::TurnStatus { block_id, active });
371 }
372 return;
373 }
374 if ev.event == crate::backend::mps::EVENT_AGENT_TURN {
377 if let Some(end) = ev.data.as_ref().and_then(parse_ledger_end) {
378 let _ = tx.send(Internal::TurnEnded(end));
379 }
380 return;
381 }
382 if ev.event != crate::backend::mps::EVENT_AGENT_FAILURE {
383 return;
384 }
385 let Some(block_id) = ev.scopes.iter().find_map(|s| s.strip_prefix("block:")) else { return };
386 let code = ev.data.as_ref().and_then(|d| d.get("code")).and_then(|c| c.as_str()).unwrap_or("");
387 if let Some(body) = failure_body(code) {
388 let _ = tx.send(Internal::Emit {
389 kind: NotifyKind::AgentCrashed,
390 block_id: block_id.to_string(),
391 body: Some(body.to_string()),
392 own_blocks_only: false,
394 });
395 }
396 }));
397 let tx = r.internal.clone();
398 reactive.add_needs_review_hook(Arc::new(move |block_id: &str| {
399 let _ = tx.send(Internal::Emit {
400 kind: NotifyKind::MessageNeedsReview,
401 block_id: block_id.to_string(),
402 body: Some("Open AgentMux to see the sender and trust level before it acts.".to_string()),
405 own_blocks_only: true,
406 });
407 }));
408}
409
410fn spawn_internal(r: std::sync::Weak<Router>, mut rx: tokio::sync::mpsc::UnboundedReceiver<Internal>) {
411 if tokio::runtime::Handle::try_current().is_err() {
412 return;
413 }
414 tokio::spawn(async move {
415 while let Some(msg) = rx.recv().await {
416 let Some(r) = r.upgrade() else { return };
417 match msg {
418 Internal::Emit { kind, block_id, body, own_blocks_only } => {
419 let _ = tokio::task::spawn_blocking(move || {
421 if own_blocks_only && !r.owns_block(&block_id) {
422 return;
423 }
424 r.emit_fixed(kind, &block_id, body)
425 })
426 .await;
427 }
428 Internal::InputWaiting { block_id, question, count } => {
429 let _ = tokio::task::spawn_blocking(move || {
430 r.emit(NotifyKind::InputWaiting, &block_id, question.as_deref(), count)
431 })
432 .await;
433 }
434 Internal::Resolve { block_id, family } => r.resolve(&block_id, family),
435 Internal::TurnEnded(end) => {
436 {
437 let mut notified = r.notified_turns.lock().unwrap_or_else(|e| e.into_inner());
438 if notified.len() > TURN_MAP_MAX {
442 notified.clear();
443 }
444 if !notified.insert((end.block_id.clone(), end.turn_id, end.ended_at_ms)) {
445 continue;
446 }
447 }
448 let stopped_recently = r
449 .stopped_at
450 .lock()
451 .unwrap_or_else(|e| e.into_inner())
452 .get(&end.block_id)
453 .is_some_and(|t| now_ms() - *t < STOP_GRACE_MS);
454 if stopped_recently || !notify_turn_end(&end, r.settings().external_turns) {
455 continue;
456 }
457 let block_id = end.block_id;
458 let _ = tokio::task::spawn_blocking(move || r.emit(NotifyKind::TurnCompleted, &block_id, None, 0)).await;
459 }
460 Internal::TurnStopped { block_id } => {
461 {
462 let mut stopped = r.stopped_at.lock().unwrap_or_else(|e| e.into_inner());
463 let now = now_ms();
465 stopped.retain(|_, t| now - *t < STOP_GRACE_MS);
466 stopped.insert(block_id.clone(), now);
467 }
468 r.resolve(&block_id, Family::Turn);
469 }
470 Internal::TurnStatus { block_id, active } => {
471 let prev = {
472 let mut turns = r.turn_active.lock().unwrap_or_else(|e| e.into_inner());
473 if turns.len() > TURN_MAP_MAX {
476 turns.retain(|_, active| *active);
477 }
478 turns.insert(block_id.clone(), active)
479 };
480 let stopped_recently = r
481 .stopped_at
482 .lock()
483 .unwrap_or_else(|e| e.into_inner())
484 .get(&block_id)
485 .is_some_and(|t| now_ms() - *t < STOP_GRACE_MS);
486 match turn_transition(prev, active, stopped_recently) {
487 TurnTransition::Started => {
488 r.stopped_at.lock().unwrap_or_else(|e| e.into_inner()).remove(&block_id);
491 r.resolve(&block_id, Family::Turn)
492 }
493 TurnTransition::Finished => {}
497 TurnTransition::Nothing => {}
498 }
499 }
500 }
501 }
502 });
503}
504
505pub fn failure_body(code: &str) -> Option<&'static str> {
512 Some(match code {
513 "rate_limited" => "Hit a rate limit.",
514 "overloaded" => "The model provider is overloaded.",
515 "usage_limit" => "Reached its usage limit.",
516 "auth" => "Needs you to sign in again.",
517 "context_exceeded" => "Ran out of context.",
518 "max_turns" => "Reached its turn limit.",
519 "network" => "Lost its network connection.",
520 "spawn_failure" => "Couldn't start.",
521 "no_output" | "unknown_non_zero" => "Exited unexpectedly.",
522 "killed" => "Was stopped by the system (possibly out of memory).",
523 _ => return None,
524 })
525}
526
527fn spawn_ticker(r: std::sync::Weak<Router>) {
528 if tokio::runtime::Handle::try_current().is_err() {
531 return;
532 }
533 tokio::spawn(async move {
534 let mut iv = tokio::time::interval(std::time::Duration::from_millis(500));
535 loop {
536 iv.tick().await;
537 match r.upgrade() {
538 Some(r) => {
539 r.step(Input::Tick);
540 }
541 None => return,
542 }
543 }
544 });
545}
546
547pub use agentmux_common::time::now_ms;
548
549pub fn sanitize_name(raw: &str) -> String {
552 let cleaned: String = raw
553 .chars()
554 .filter(|c| !c.is_control() && !is_bidi_control(*c))
555 .collect();
556 let trimmed = cleaned.trim();
557 let mut out: String = trimmed.chars().take(AGENT_NAME_MAX).collect();
558 if out.is_empty() {
559 out = "An agent".to_string();
560 }
561 out
562}
563
564fn is_bidi_control(c: char) -> bool {
565 matches!(c, '\u{200E}' | '\u{200F}' | '\u{061C}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
566}
567
568pub fn sanitize_summary(raw: &str) -> Option<String> {
573 if crate::backend::reactive::sanitize::is_sensitive_message(raw) {
574 return None;
575 }
576 let cleaned: String = raw
577 .chars()
578 .filter(|c| !is_bidi_control(*c))
579 .map(|c| if c.is_control() { ' ' } else { c })
580 .collect();
581 let line = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
582 if line.is_empty() {
583 return None;
584 }
585 let mut out: String = line.chars().take(SUMMARY_MAX).collect();
586 if line.chars().count() > SUMMARY_MAX {
587 out.push('…');
588 }
589 Some(out)
590}
591
592pub fn with_question_count(body: Option<String>, count: usize) -> Option<String> {
596 body.map(|b| if count > 1 { format!("{b} (+{} more)", count - 1) } else { b })
597}
598
599pub fn redact_body(raw: &str, preview: Preview) -> Option<String> {
603 if preview == Preview::None {
604 return None;
605 }
606 let max = if preview == Preview::Full { 200 } else { BODY_MAX };
607 let first = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
608 if crate::backend::reactive::sanitize::is_sensitive_message(raw) {
609 return Some("Contains sensitive content — open AgentMux to view.".to_string());
610 }
611 let cleaned: String = first.chars().filter(|c| !c.is_control() && !is_bidi_control(*c)).collect();
612 let mut out: String = cleaned.chars().take(max).collect();
613 if cleaned.chars().count() > max {
614 out.push('…');
615 }
616 Some(out)
617}
618
619pub fn quiet_hours_until(spec: &str, now: chrono::DateTime<chrono::Local>) -> Option<i64> {
624 use chrono::{NaiveTime, TimeZone};
625 let (a, b) = spec.trim().split_once('-')?;
626 let start = NaiveTime::parse_from_str(a.trim(), "%H:%M").ok()?;
627 let end = NaiveTime::parse_from_str(b.trim(), "%H:%M").ok()?;
628 if start == end {
629 return None;
630 }
631 let t = now.time();
632 let today = now.date_naive();
633 let end_date = if start < end {
634 if !(t >= start && t < end) {
635 return None;
636 }
637 today
638 } else if t >= start {
639 today + chrono::Days::new(1)
640 } else if t < end {
641 today
642 } else {
643 return None;
644 };
645 chrono::Local
646 .from_local_datetime(&end_date.and_time(end))
647 .earliest()
648 .map(|dt| dt.timestamp_millis())
649}
650
651fn setting_bool(extra: &std::collections::HashMap<String, serde_json::Value>, key: &str, default: bool) -> bool {
652 extra.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
653}
654
655fn setting_str<'a>(extra: &'a std::collections::HashMap<String, serde_json::Value>, key: &str) -> Option<&'a str> {
656 extra.get(key).and_then(|v| v.as_str())
657}
658
659pub fn settings_from_extra(extra: &std::collections::HashMap<String, serde_json::Value>) -> Settings {
661 let mut s = Settings {
662 enabled: setting_bool(extra, "notify:os:enabled", true),
663 when: match setting_str(extra, "notify:os:when") {
664 Some("always") => When::Always,
665 Some("never") => When::Never,
666 _ => When::Unfocused,
667 },
668 preview: match setting_str(extra, "notify:os:preview") {
669 Some("full") => Preview::Full,
670 Some("none") => Preview::None,
671 _ => Preview::Redacted,
672 },
673 kind_enabled: Default::default(),
674 pause_until_ms: extra.get("notify:pause:until").and_then(|v| v.as_i64()).unwrap_or(0),
675 pause_allow_attention: setting_bool(extra, "notify:pause:allowattention", false),
676 quiet_until_ms: setting_str(extra, "notify:quiethours")
677 .and_then(|spec| quiet_hours_until(spec, chrono::Local::now()))
678 .unwrap_or(0),
679 show_summary: setting_bool(extra, "notify:os:summary", true),
680 external_turns: setting_bool(extra, "notify:os:turncompleted:external", false),
681 };
682 for kind in [
683 NotifyKind::InputWaiting,
684 NotifyKind::TurnCompleted,
685 NotifyKind::TurnErrored,
686 NotifyKind::AgentCrashed,
687 NotifyKind::MessageNeedsReview,
688 NotifyKind::CloudSignedOut,
689 ] {
690 if let Some(sfx) = kind.setting_suffix() {
691 s.kind_enabled.insert(kind, setting_bool(extra, &format!("notify:os:{sfx}"), true));
692 }
693 }
694 s
695}
696
697impl Router {
698 fn settings(&self) -> Settings {
699 settings_from_extra(&self.config.get_settings().extra)
700 }
701
702 fn owns_block(&self, block_id: &str) -> bool {
704 matches!(self.store.get::<Block>(block_id), Ok(Some(_)))
705 }
706
707 fn block_labels(&self, block_id: &str) -> (String, Option<String>) {
715 let block = self.store.get::<Block>(block_id).ok().flatten();
716 let meta = |key: &str| {
717 block.as_ref().map(|b| crate::backend::obj::meta_get_string(&b.meta, key, "")).unwrap_or_default()
718 };
719 let mut raw_name = meta("agentName");
720 if raw_name.is_empty() {
721 raw_name = meta("agentId");
722 }
723 let stored_summary = meta(crate::ambient::title::META_TITLE);
726 let stored_summary = if crate::ambient::validate::is_usable_title(&stored_summary) {
727 stored_summary
728 } else {
729 String::new()
730 };
731 (sanitize_name(&raw_name), sanitize_summary(&stored_summary))
732 }
733
734 fn step(&self, input: Input) -> Vec<Action> {
737 let settings = self.settings();
738 let now = now_ms();
739 let (actions, tray) = {
740 let mut p = self.policy.lock().unwrap_or_else(|e| e.into_inner());
741 let actions = p.step(input, now, &settings);
742 (actions, p.tray_state(&settings, now))
743 };
744 for a in &actions {
745 self.publish(a);
746 }
747 self.publish_tray_if_changed(tray);
748 actions
749 }
750
751 fn publish_tray_if_changed(&self, tray: TrayState) {
752 {
753 let mut last = self.last_tray.lock().unwrap_or_else(|e| e.into_inner());
754 if last.as_ref() == Some(&tray) {
755 return;
756 }
757 *last = Some(tray.clone());
758 }
759 self.broker.publish_event::<crate::backend::mps_events::NotificationState>(&tray, vec![], 1);
760 }
761
762 fn publish(&self, action: &Action) {
763 match action {
764 Action::Show(n) => self.publish_event::<crate::backend::mps_events::Notification>(n),
765 Action::Retract { id, tag } => {
766 self.publish_event::<crate::backend::mps_events::NotificationRetract>(&NotificationRetractEvent { id: id.clone(), tag: tag.clone() })
767 }
768 Action::Activate { .. } => {}
770 }
771 }
772
773 fn publish_event<E: crate::backend::mps_events::Event>(&self, payload: &E::Payload) {
774 tracing::info!(event = E::NAME, "notify: publish");
775 self.broker.publish_event::<E>(payload, vec![], 0);
776 }
777
778 pub fn emit(&self, kind: NotifyKind, block_id: &str, question: Option<&str>, question_count: usize) {
787 let preview = self.settings().preview;
788 let body = match kind {
789 NotifyKind::InputWaiting => {
790 with_question_count(question.and_then(|q| redact_body(q, preview)), question_count)
791 }
792 _ => None,
793 };
794 let (agent_name, summary) = self.block_labels(block_id);
795 self.step(Input::Emit(Request { kind, block_id: block_id.to_string(), agent_name, body, summary }));
796 }
797
798 pub fn emit_fixed(&self, kind: NotifyKind, block_id: &str, body: Option<String>) {
802 let (agent_name, summary) = self.block_labels(block_id);
803 self.step(Input::Emit(Request { kind, block_id: block_id.to_string(), agent_name, body, summary }));
804 }
805
806 pub fn input_waiting_nonblocking(&self, block_id: &str, question: Option<String>, count: usize) {
810 let _ = self.internal.send(Internal::InputWaiting { block_id: block_id.to_string(), question, count });
811 }
812
813 pub fn turn_stopped_nonblocking(&self, block_id: &str) {
816 let _ = self.internal.send(Internal::TurnStopped { block_id: block_id.to_string() });
817 }
818
819 pub fn resolve_nonblocking(&self, block_id: &str, family: Family) {
822 let _ = self.internal.send(Internal::Resolve { block_id: block_id.to_string(), family });
823 }
824
825 pub fn resolve(&self, block_id: &str, family: Family) {
826 self.step(Input::Resolve { block_id: block_id.to_string(), family });
827 }
828
829 pub fn test(&self) {
830 self.step(Input::Emit(Request {
831 kind: NotifyKind::Test,
832 block_id: String::new(),
833 agent_name: String::new(),
834 body: Some("You'll see alerts like this when an agent needs you.".to_string()),
835 summary: None,
836 }));
837 }
838
839 pub fn cloud_signed_out(&self) {
843 self.step(Input::Emit(Request {
844 kind: NotifyKind::CloudSignedOut,
845 block_id: String::new(),
846 agent_name: String::new(),
847 body: Some(CLOUD_SIGNED_OUT_BODY.to_string()),
848 summary: None,
849 }));
850 }
851
852 pub fn focus(&self, conn_id: &str, report: FocusReport) {
853 self.step(Input::Focus { conn_id: conn_id.to_string(), report });
854 }
855
856 pub fn disconnect(&self, conn_id: &str) {
857 self.step(Input::Disconnect { conn_id: conn_id.to_string() });
858 }
859
860 pub fn ack(&self, id: &str, clicked: bool) -> AckOutcome {
869 let actions = self.step(Input::Ack { id: id.to_string(), clicked });
870 let known = !actions.is_empty();
872 let activated = actions.into_iter().find_map(|a| match a {
873 Action::Activate { id, block_id } => Some((id, block_id)),
874 _ => None,
875 });
876 let target = activated.as_ref().and_then(|(_, block_id)| resolve_click_target(&self.store, block_id));
877 let (open_window, raise, any_window) = {
878 let p = self.policy.lock().unwrap_or_else(|e| e.into_inner());
879 let pick = target.as_ref().and_then(|t| pick_open_window(&p, &t.window_ids));
880 (pick, p.raise_window(), p.has_window())
881 };
882 let mut outcome = AckOutcome { known, window_id: None, workspace_id: None, has_window: any_window };
883 match (activated, target) {
884 (Some((id, _)), Some(t)) => {
885 if let Some(window_id) = open_window {
886 let activate = NotificationActivateEvent {
887 id,
888 block_id: t.block_id,
889 tab_id: t.tab_id,
890 window_id: window_id.clone(),
891 workspace_id: t.workspace_id,
892 at_ms: now_ms(),
893 };
894 self.publish_event::<crate::backend::mps_events::NotificationActivate>(&activate);
895 outcome.window_id = Some(window_id);
896 outcome.has_window = true;
897 } else {
898 tracing::info!(workspace = ?t.workspace_id, "notify: click target's workspace is open in no window");
899 outcome.workspace_id = t.workspace_id.clone();
900 outcome.has_window = false;
901 *self.pending_activation.lock().unwrap_or_else(|e| e.into_inner()) = Some(PendingActivation {
902 block_id: t.block_id,
903 tab_id: t.tab_id,
904 workspace_id: t.workspace_id,
905 at_ms: now_ms(),
906 });
907 }
908 }
909 (Some((_, block_id)), None) => {
910 tracing::info!(block_id, "notify: click target is gone; raising the app");
911 outcome.window_id = raise;
912 }
913 (None, _) if clicked => outcome.window_id = raise,
915 (None, _) => {}
916 }
917 outcome
918 }
919
920 pub fn reveal_target(&self, block_id: &str) -> Option<BlockRevealEvent> {
927 let t = resolve_click_target(&self.store, block_id)?;
928 let window_id = {
929 let p = self.policy.lock().unwrap_or_else(|e| e.into_inner());
930 pick_open_window(&p, &t.window_ids)
931 }?;
932 Some(BlockRevealEvent { block_id: t.block_id, tab_id: t.tab_id, window_id })
933 }
934
935 pub fn publish_block_reveal(&self, target: &BlockRevealEvent) {
937 self.publish_event::<crate::backend::mps_events::BlockReveal>(target);
938 }
939
940 pub fn take_pending_activation(&self, workspace_id: Option<&str>) -> Option<PendingActivation> {
944 let mut slot = self.pending_activation.lock().unwrap_or_else(|e| e.into_inner());
945 let now = now_ms();
946 if slot.as_ref().is_some_and(|p| now - p.at_ms > PENDING_ACTIVATION_MAX_AGE_MS) {
947 *slot = None;
948 }
949 if slot.as_ref().is_some_and(|p| may_take_pending(p, workspace_id, now)) {
950 return slot.take();
951 }
952 None
953 }
954}
955
956#[cfg(test)]
957mod tests {
958
959 fn ledger(end: Option<&str>, kind: &str) -> serde_json::Value {
962 let mut v = serde_json::json!({ "block_id": "b1", "turn_id": 42, "ended_at_ms": 7, "active": false, "trigger": { "kind": kind } });
963 if let Some(e) = end {
964 v["end"] = serde_json::json!(e);
965 }
966 v
967 }
968
969 #[test]
970 fn only_a_completed_turn_is_an_end() {
971 assert_eq!(
972 parse_ledger_end(&ledger(Some("completed"), "user")),
973 Some(LedgerEnd { block_id: "b1".into(), turn_id: 42, ended_at_ms: 7, external: false, system: false })
974 );
975 assert_eq!(parse_ledger_end(&ledger(None, "user")), None, "running or settling");
976 assert_eq!(parse_ledger_end(&ledger(Some("exited"), "user")), None, "a crash reports itself");
977 }
978
979 #[test]
980 fn a_turn_something_else_started_is_notified_only_when_asked() {
981 for kind in ["agent", "service", "schedule", "task"] {
982 let end = parse_ledger_end(&ledger(Some("completed"), kind)).unwrap();
983 assert!(end.external, "{kind}");
984 assert!(!notify_turn_end(&end, false), "{kind}: quiet by default");
985 assert!(notify_turn_end(&end, true), "{kind}: notified when asked");
986 }
987 for kind in ["user", "broadcast"] {
988 assert!(notify_turn_end(&parse_ledger_end(&ledger(Some("completed"), kind)).unwrap(), false), "{kind}");
989 }
990 let system = parse_ledger_end(&ledger(Some("completed"), "system")).unwrap();
991 assert!(!notify_turn_end(&system, true), "AgentMux's own hidden turn, never");
992 }
993
994 #[test]
995 fn a_rejoined_turn_s_second_end_is_a_new_end() {
996 let first = parse_ledger_end(&ledger(Some("completed"), "user")).unwrap();
997 let mut again = ledger(Some("completed"), "user");
998 again["ended_at_ms"] = serde_json::json!(99);
999 let second = parse_ledger_end(&again).unwrap();
1000 assert_eq!(first.turn_id, second.turn_id);
1001 assert_ne!((first.turn_id, first.ended_at_ms), (second.turn_id, second.ended_at_ms), "a dedupe key that tells them apart");
1002 }
1003
1004 #[test]
1005 fn the_external_turns_setting_is_off_unless_set() {
1006 let mut extra = std::collections::HashMap::new();
1007 assert!(!settings_from_extra(&extra).external_turns);
1008 extra.insert("notify:os:turncompleted:external".to_string(), serde_json::json!(true));
1009 assert!(settings_from_extra(&extra).external_turns);
1010 }
1011
1012 use super::*;
1013
1014 #[test]
1015 fn name_sanitization_strips_bidi_and_controls_and_caps() {
1016 assert_eq!(sanitize_name("lark"), "lark");
1017 assert_eq!(sanitize_name("evil\u{202E}txt.exe"), "eviltxt.exe");
1018 assert_eq!(sanitize_name("a\nb\tc"), "abc");
1019 assert_eq!(sanitize_name(" "), "An agent");
1020 assert_eq!(sanitize_name(&"x".repeat(100)).chars().count(), AGENT_NAME_MAX);
1021 }
1022
1023 #[test]
1026 fn parses_real_serialized_controller_status_including_omitted_false() {
1027 use crate::backend::blockcontroller::BlockControllerRuntimeStatus;
1028 let mk = |turn_active: bool| BlockControllerRuntimeStatus {
1029 durable: false,
1030 blockid: "b1".into(),
1031 version: 1,
1032 shellprocstatus: "running".into(),
1033 shellprocconnname: "local".into(),
1034 shellprocexitcode: 0,
1035 shellprocpid: None,
1036 shellprocname: String::new(),
1037 spawn_ts_ms: None,
1038 is_agent_pane: false,
1039 turn_active,
1040 };
1041 let started = serde_json::to_value(mk(true)).unwrap();
1042 let ended = serde_json::to_value(mk(false)).unwrap();
1043 assert!(ended.get("turn_active").is_none(), "precondition: false is omitted on the wire");
1044 assert_eq!(parse_turn_status(&started), Some(("b1".to_string(), true)));
1045 assert_eq!(parse_turn_status(&ended), Some(("b1".to_string(), false)));
1046 assert_eq!(parse_turn_status(&serde_json::json!({"turn_active": true})), None);
1047 let (_, a) = parse_turn_status(&started).unwrap();
1049 let (_, b) = parse_turn_status(&ended).unwrap();
1050 assert_eq!(turn_transition(None, a, false), TurnTransition::Started);
1051 assert_eq!(turn_transition(Some(a), b, false), TurnTransition::Finished);
1052 }
1053
1054 #[test]
1055 fn turn_transitions_come_only_from_real_flips() {
1056 use TurnTransition::*;
1057 assert_eq!(turn_transition(None, false, false), Nothing);
1059 assert_eq!(turn_transition(None, true, false), Started);
1060 assert_eq!(turn_transition(Some(true), false, false), Finished);
1062 assert_eq!(turn_transition(Some(true), false, true), Nothing);
1064 assert_eq!(turn_transition(Some(true), true, false), Nothing);
1066 assert_eq!(turn_transition(Some(false), false, false), Nothing);
1067 assert_eq!(turn_transition(Some(false), true, false), Started);
1069 }
1070
1071 #[test]
1072 fn quiet_hours_windows() {
1073 use chrono::TimeZone;
1074 let at = |h, m| chrono::Local.with_ymd_and_hms(2026, 9, 24, h, m, 0).unwrap();
1075 let ms = |d: u32, h, m| chrono::Local.with_ymd_and_hms(2026, 9, d, h, m, 0).unwrap().timestamp_millis();
1076 assert_eq!(quiet_hours_until("22:00-08:00", at(23, 30)), Some(ms(25, 8, 0)));
1078 assert_eq!(quiet_hours_until("22:00-08:00", at(6, 0)), Some(ms(24, 8, 0)));
1079 assert_eq!(quiet_hours_until("22:00-08:00", at(12, 0)), None);
1080 assert_eq!(quiet_hours_until("22:00-08:00", at(8, 0)), None, "end is exclusive");
1081 assert_eq!(quiet_hours_until("12:00-13:30", at(12, 45)), Some(ms(24, 13, 30)));
1083 assert_eq!(quiet_hours_until("12:00-13:30", at(14, 0)), None);
1084 assert_eq!(quiet_hours_until("", at(12, 0)), None);
1086 assert_eq!(quiet_hours_until("09:00-09:00", at(9, 0)), None);
1087 assert_eq!(quiet_hours_until("nonsense", at(12, 0)), None);
1088 assert_eq!(quiet_hours_until("25:00-08:00", at(1, 0)), None);
1089 }
1090
1091 #[test]
1092 fn failure_classes_map_to_fixed_bodies_and_skip_user_deletes() {
1093 assert_eq!(failure_body("auth"), Some("Needs you to sign in again."));
1094 assert_eq!(failure_body("unknown_non_zero"), Some("Exited unexpectedly."));
1095 assert_eq!(failure_body("killed"), Some("Was stopped by the system (possibly out of memory)."));
1096 assert_eq!(failure_body("agent_deleted"), None);
1097 assert_eq!(failure_body("<script>"), None);
1098 }
1099
1100 #[test]
1101 fn pending_activation_is_scoped_to_its_workspace_and_expires() {
1102 let p = PendingActivation { block_id: "b".into(), tab_id: "t".into(), workspace_id: Some("ws1".into()), at_ms: 1_000 };
1103 assert!(may_take_pending(&p, Some("ws1"), 2_000));
1104 assert!(!may_take_pending(&p, Some("ws2"), 2_000), "another workspace's window must not steal it");
1105 assert!(may_take_pending(&p, None, 2_000), "an older frontend doesn't say");
1106 assert!(may_take_pending(&p, Some("ws1"), 1_000 + PENDING_ACTIVATION_MAX_AGE_MS));
1107 assert!(!may_take_pending(&p, Some("ws1"), 1_001 + PENDING_ACTIVATION_MAX_AGE_MS), "30 s, then gone");
1108 let orphan = PendingActivation { workspace_id: None, ..p };
1109 assert!(may_take_pending(&orphan, Some("ws2"), 2_000));
1110 }
1111
1112 #[test]
1113 fn click_target_walks_block_to_tab_workspace_and_windows() {
1114 let store = Store::open_in_memory().unwrap();
1115 crate::backend::wcore::ensure_initial_data(&store).unwrap();
1116 let block = store.get_all::<Block>().unwrap().into_iter().next().expect("seeded block");
1117 let t = resolve_click_target(&store, &block.oid).expect("resolves");
1118 let ws = store.get_all::<Workspace>().unwrap().into_iter().next().unwrap();
1119 let win = store.get_all::<Window>().unwrap().into_iter().next().unwrap();
1120 assert_eq!(t.workspace_id.as_deref(), Some(ws.oid.as_str()));
1121 assert!(ws.tabids.contains(&t.tab_id) || ws.pinnedtabids.contains(&t.tab_id));
1122 assert_eq!(t.window_ids, vec![win.oid]);
1123 assert_eq!(resolve_click_target(&store, "gone"), None);
1124 }
1125
1126 #[test]
1127 fn body_redaction() {
1128 assert_eq!(redact_body("\n Which branch?\nmore", Preview::Redacted).as_deref(), Some("Which branch?"));
1129 assert_eq!(redact_body("Paste your API token here", Preview::Redacted).as_deref(),
1130 Some("Contains sensitive content — open AgentMux to view."));
1131 assert_eq!(redact_body("ok", Preview::None), None);
1132 let long = "y".repeat(300);
1133 assert_eq!(redact_body(&long, Preview::Redacted).unwrap().chars().count(), BODY_MAX + 1);
1134 assert_eq!(redact_body(&long, Preview::Full).unwrap().chars().count(), 201);
1135 assert_eq!(redact_body(" \n ", Preview::Redacted), None);
1136 }
1137
1138 #[test]
1139 fn summary_sanitization() {
1140 assert_eq!(sanitize_summary("Fix resize repaint delay").as_deref(), Some("Fix resize repaint delay"));
1141 assert_eq!(sanitize_summary(" two\nlines\tand spaces ").as_deref(), Some("two lines and spaces"));
1142 assert_eq!(sanitize_summary("evil\u{202E}gnp.exe").as_deref(), Some("evilgnp.exe"));
1143 assert_eq!(sanitize_summary(" "), None);
1144 assert_eq!(sanitize_summary(""), None);
1145 assert_eq!(sanitize_summary("Rotate the api_key for staging"), None);
1147 let long = "word ".repeat(40);
1148 let s = sanitize_summary(&long).unwrap();
1149 assert_eq!(s.chars().count(), SUMMARY_MAX + 1);
1150 assert!(s.ends_with('…'));
1151 }
1152
1153 #[test]
1154 fn question_count_suffix_survives_truncation() {
1155 let long = "q".repeat(300);
1156 let body = with_question_count(redact_body(&long, Preview::Redacted), 3).unwrap();
1157 assert!(body.ends_with("… (+2 more)"), "{body}");
1158 assert_eq!(with_question_count(Some("Which branch?".into()), 1).as_deref(), Some("Which branch?"));
1159 assert_eq!(with_question_count(Some("Which branch?".into()), 0).as_deref(), Some("Which branch?"));
1160 assert_eq!(with_question_count(None, 4), None, "no text → the title stands alone");
1161 }
1162
1163 #[test]
1164 fn settings_defaults_and_overrides() {
1165 let mut extra = std::collections::HashMap::new();
1166 let s = settings_from_extra(&extra);
1167 assert!(s.enabled);
1168 assert!(s.show_summary);
1169 assert_eq!(s.when, When::Unfocused);
1170 assert_eq!(s.preview, Preview::Redacted);
1171 extra.insert("notify:os:when".into(), serde_json::json!("never"));
1172 extra.insert("notify:os:preview".into(), serde_json::json!("none"));
1173 extra.insert("notify:os:turncompleted".into(), serde_json::json!(false));
1174 extra.insert("notify:os:enabled".into(), serde_json::json!(false));
1175 extra.insert("notify:pause:until".into(), serde_json::json!(1234));
1176 extra.insert("notify:os:agentcrashed".into(), serde_json::json!(false));
1177 extra.insert("notify:os:summary".into(), serde_json::json!(false));
1178 let s = settings_from_extra(&extra);
1179 assert!(!s.show_summary);
1180 assert_eq!(s.pause_until_ms, 1234);
1181 assert!(!s.pause_allow_attention);
1182 assert_eq!(s.kind_enabled.get(&NotifyKind::AgentCrashed), Some(&false));
1183 assert_eq!(s.kind_enabled.get(&NotifyKind::MessageNeedsReview), Some(&true));
1184 assert!(!s.enabled);
1185 assert_eq!(s.when, When::Never);
1186 assert_eq!(s.preview, Preview::None);
1187 assert_eq!(s.kind_enabled.get(&NotifyKind::TurnCompleted), Some(&false));
1188 assert_eq!(s.kind_enabled.get(&NotifyKind::InputWaiting), Some(&true));
1189 }
1190}