Skip to main content

agentmux_srv/backend/notify/
router.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Notification Router — the side-effecting shell around `policy.rs`
5//! (`SPEC_OS_NOTIFICATIONS_SYSTEM_2026_09_24.md` §3, §5).
6//!
7//! Owns: settings resolution, agent-name lookup + sanitization, body
8//! redaction (§9), publishing on the mps broker, a 500 ms tick task for
9//! debounce release, and the single pending "activate on next window" slot
10//! for a click that arrived while no window was open.
11//!
12//! One Router per broker (i.e. per `AppState`), kept in a small registry
13//! keyed by the broker's identity rather than as an `AppState` field: it is
14//! created lazily by the first WS connection and outlives any one connection.
15//! Keying by broker (not a single global) means a second `AppState` in the
16//! same process — tests, or any future multi-state setup — never has its
17//! events published onto someone else's bus.
18
19use 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";
35/// Tray snapshot (`TrayState`), published on change with persist=1 so a
36/// presenter that (re)subscribes gets the current state immediately.
37pub const EVENT_NOTIFICATION_STATE: &str = "notification:state";
38/// `block.reveal`: the named window's renderer reveals the block itself
39/// (`SPEC_REVEAL_BLOCK_ONE_PATH_2026_09_27.md` §4.3). Payload: `BlockRevealEvent`.
40pub const EVENT_BLOCK_REVEAL: &str = "block:reveal";
41
42/// `notification:retract`: take a shown notification down.
43#[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/// `notification:activate`: the user clicked a notification; this window shows its block.
51#[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/// Where `block.reveal` reveals a block — also the `block:reveal` payload.
64#[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    /// The open window showing the block's workspace; only it acts.
70    pub window_id: String,
71}
72
73/// srv-internal sources, fed from places that must not block (the broker's
74/// publish path, the jekt delivery path) and drained by the router's task.
75enum Internal {
76    /// `own_blocks_only`: the source is process-global (the reactive handler
77    /// is shared by every AppState), so drop blocks this Router's store
78    /// doesn't know.
79    Emit { kind: NotifyKind, block_id: String, body: Option<String>, own_blocks_only: bool },
80    /// Raw question text — redacted by `emit` like a renderer report.
81    /// `count`: how many questions the call asks (0 = unknown).
82    InputWaiting { block_id: String, question: Option<String>, count: usize },
83    /// Resolve through the SAME ordered queue as the srv-side emits, so a
84    /// resolve can never overtake the emit it cancels (Codex P2 on #3662).
85    Resolve { block_id: String, family: Family },
86    /// A controller's authoritative `turn_active` (from `controllerstatus`).
87    TurnStatus { block_id: String, active: bool },
88    /// The user stopped/interrupted this block's turn in its pane.
89    TurnStopped { block_id: String },
90    /// A block's turn ledger (`agentturn`) reports the turn over.
91    TurnEnded(LedgerEnd),
92}
93
94/// What the router needs from an ended turn's ledger (`agentturn`).
95#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct LedgerEnd {
97    pub block_id: String,
98    pub turn_id: u64,
99    /// When it ended: a turn rejoined by a held message ends again later, and
100    /// that end is a new one (#4511).
101    pub ended_at_ms: u64,
102    /// Started by something other than the user: another agent, a service, a
103    /// schedule, a background task (spec §5.2).
104    pub external: bool,
105    /// AgentMux's own hidden turn (memory reinjection): never notified.
106    pub system: bool,
107}
108
109/// The fields of a published ledger the router reads, typed by the same
110/// enums srv serializes it with.
111#[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
123/// The turn is over by its ledger: `end: "completed"` (an exit is a crash,
124/// which `agentfailure` reports). `None` for a turn still running or settling.
125pub 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
140/// Whether a finished turn is notified: never AgentMux's own hidden turn; a
141/// turn something else started only when the user asked for those too.
142pub fn notify_turn_end(end: &LedgerEnd, external_turns: bool) -> bool {
143    !end.system && (!end.external || external_turns)
144}
145
146/// How long after a user Stop the turn-ended transition it causes is treated
147/// as "the user's own action", not "finished".
148const STOP_GRACE_MS: i64 = 15_000;
149/// Cap on tracked blocks' turn state (see `Internal::TurnStatus` handling).
150const TURN_MAP_MAX: usize = 1024;
151
152/// What the Router does with a `controllerstatus` turn transition.
153#[derive(Debug, PartialEq, Eq)]
154pub enum TurnTransition {
155    /// Turn (re)started: anything "finished" for this block is moot.
156    Started,
157    /// Turn really ended: notify "finished" (subject to policy).
158    Finished,
159    /// No transition, first sighting, or ended by the user's own Stop.
160    Nothing,
161}
162
163/// `(blockid, turn_active)` from a serialized `BlockControllerRuntimeStatus`.
164/// `turn_active` is `skip_serializing_if = "is_false"`, so a turn that ENDED
165/// arrives with the field absent — absent means false (Codex P1 on #3705;
166/// treating it as "unknown" dropped every end-of-turn and "finished" never
167/// fired).
168pub 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
174/// Pure: `prev` = last known `turn_active` for the block (None = never seen,
175/// e.g. right after srv start — a replayed/first status is not a transition).
176pub 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
185/// A click's target is picked up by a newly opened window only this long
186/// after the click (rich-content spec §3.3 D) — never "jumps to an old block
187/// much later".
188pub const PENDING_ACTIVATION_MAX_AGE_MS: i64 = 30_000;
189
190/// Where a toast click lands (rich-content spec §3.3 A). Resolved by srv from
191/// its own store for the notification the click acked — never taken from the
192/// toast's launch argument (§3.4).
193#[derive(Clone, Debug, Default, PartialEq, Eq)]
194pub struct ClickTarget {
195    pub block_id: String,
196    pub tab_id: String,
197    /// `None` when the tab belongs to no workspace any more.
198    pub workspace_id: Option<String>,
199    /// Every `Window` object showing that workspace. Whether one is actually
200    /// open is the policy's call (a connected frontend reports it).
201    pub window_ids: Vec<String>,
202}
203
204/// Walk block → tab → workspace → windows. `None` when the block or its tab
205/// is gone. BLOCKING (store reads).
206pub 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/// What a click asks of the launcher (the `notify.ack` response).
231#[derive(Clone, Debug, Default, PartialEq, Eq)]
232pub struct AckOutcome {
233    /// The ack named a live notification. An unknown id (a toast from before
234    /// an srv restart) still raises the app (§3.3 F).
235    pub known: bool,
236    /// The window to raise, when one is open. For a pane's toast it is the
237    /// window showing the pane; otherwise the most recently focused window.
238    pub window_id: Option<String>,
239    /// For a pane whose workspace is open in no window: open a window on it.
240    pub workspace_id: Option<String>,
241    /// Kept for launchers older than `window_id`: false means "open a window".
242    pub has_window: bool,
243}
244
245/// A click that arrived while its pane's workspace was open in no window.
246#[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
254/// Pure: may a window showing `workspace_id` (None = an older frontend that
255/// doesn't say) take `pending` at `now_ms`?
256pub 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
266/// Of `window_ids` (a workspace's windows), the one a connected frontend
267/// reports open. Two open windows on one workspace: prefer the one the user
268/// was last in.
269fn 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
275/// The body of the `CloudSignedOut` notification.
276pub 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;
281/// Rich-content spec §1.2: the summary line's cap, before the `…`.
282const 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    /// A click whose pane's workspace was open in no window: taken by the
290    /// window the launcher opens on that workspace.
291    pending_activation: Mutex<Option<PendingActivation>>,
292    internal: tokio::sync::mpsc::UnboundedSender<Internal>,
293    last_tray: Mutex<Option<TrayState>>,
294    /// block_id → last `turn_active` seen in `controllerstatus`.
295    turn_active: Mutex<std::collections::HashMap<String, bool>>,
296    /// block_id → when the user last stopped its turn (epoch ms).
297    stopped_at: Mutex<std::collections::HashMap<String, i64>>,
298    /// (block_id, turn_id, ended_at_ms) of turn ends already notified.
299    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
313/// The router for this broker, if a connection has created it yet.
314pub fn get(broker: &Arc<Broker>) -> Option<Arc<Router>> {
315    registry().lock().unwrap_or_else(|e| e.into_inner()).get(&key(broker)).cloned()
316}
317
318/// Get-or-create the router for this broker. Idempotent.
319///
320/// On creation it also attaches the srv-side sources: an observer on the
321/// broker (`agentfailure` → `AgentCrashed`) and the reactive handler's
322/// needs-review hook (`ESCALATE=required` jekt → `MessageNeedsReview`).
323pub 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
353/// Both hooks run on hot, lock-holding paths (the broker's publish; jekt
354/// delivery). They only parse and enqueue — never call back into the broker
355/// or touch the store inline.
356fn 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        // "Finished" comes from the controller's own turn state, not from the
360        // renderer's reducer — which can report turn-ended mid-turn (e.g. a
361        // queued message released straight into the next turn), producing
362        // "finished" toasts for an agent that is still working.
363        if ev.event == crate::backend::mps::EVENT_CONTROLLER_STATUS {
364            // No `is_agent_pane` filter: the subprocess controller (Codex,
365            // Gemini, …) publishes `is_agent_pane: false` for its agent panes
366            // (Codex P1 on #3705), and only real agent turns ever report
367            // turn_active=true (shell controllers hardcode false), so a
368            // true→false edge is agent-specific on its own.
369            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        // "Finished" is the TURN's end, as its ledger reports it: one per
375        // turn the user sees, not one per CLI pass (spec §4.3).
376        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                // Per-broker observer: already scoped to this AppState.
393                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            // Fixed text only — never the message, sender or trust label
403            // (spec §9.1: the toast can only say "go look").
404            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                    // Store lookups — off the async workers.
420                    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                        // One end can be published twice (its lapse, then the
439                        // lazy close): notify once. A turn reopened by a held
440                        // message and ended again is a new end. Bounded.
441                        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                        // Bounded: entries past the grace window can't matter.
464                        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                        // Bounded: forgetting an IDLE block is
474                        // harmless — its next `true` still reads as Started.
475                        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                            // A new turn: the earlier stop no longer applies
489                            // to it (Codex P2 on #3705).
490                            r.stopped_at.lock().unwrap_or_else(|e| e.into_inner()).remove(&block_id);
491                            r.resolve(&block_id, Family::Turn)
492                        }
493                        // Notified from the turn's ledger instead
494                        // (`Internal::TurnEnded`): a pass ending is not the
495                        // turn ending.
496                        TurnTransition::Finished => {}
497                        TurnTransition::Nothing => {}
498                    }
499                }
500            }
501        }
502    });
503}
504
505/// `AgentFailure.code` → the fixed body for its toast; `None` = don't notify.
506/// Only `agent_deleted` (a user action) is skipped. `killed` is NOT a user
507/// Stop — those go through the controllers' kill arm and are never
508/// classified (host_spawn.rs) — it means a signal/137, most often the OOM
509/// killer: exactly the "stopped while you were away" case (ReAgent P1 on
510/// #3654). Detail/stderr never reach a toast.
511pub 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    // Only when a Tokio runtime exists (always true in srv; unit tests of the
529    // pure policy never reach here).
530    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
549/// Strip control + bidi-override characters and cap length — the only
550/// user-influenced text that reaches a title (§9.1).
551pub 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
568/// Rich-content spec §1.2: the agent's session summary as a single line —
569/// controls and bidi stripped, whitespace collapsed, capped at 90 characters.
570/// A summary that trips the jekt keyword list is dropped, not replaced: it is
571/// decoration, not something the user needs to be told is hidden.
572pub 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
592/// Rich-content spec §2.1: " (+N more)" after the (already truncated) first
593/// question, so the count survives truncation. No body, no suffix — the
594/// title "has a question" stands on its own.
595pub 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
599/// §9.2 body policy: first line, sanitized, truncated, and replaced wholesale
600/// when it trips the same credential/destructive keyword list the jekt tier
601/// gate uses.
602pub 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
619/// `notify:quiethours` = `"HH:MM-HH:MM"` in local time. If `now` is inside
620/// the window, return when it ends (epoch ms); `None` outside it or on a
621/// malformed/empty value. A window whose end is before its start spans
622/// midnight ("22:00-08:00"). Equal start and end means "off".
623pub 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
659/// Resolve the policy's settings view from `settings.json` (§8).
660pub 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    /// Does this Router's store know the block? BLOCKING (SQLite).
703    fn owns_block(&self, block_id: &str) -> bool {
704        matches!(self.store.get::<Block>(block_id), Ok(Some(_)))
705    }
706
707    /// The block's sanitized agent name and session summary, from one read.
708    /// BLOCKING (SQLite behind `Store`'s process-wide mutex) — only ever
709    /// reached through `emit` / `emit_fixed`, which callers must run off the
710    /// async workers (`spawn_blocking`; the #1782 failure class, see
711    /// websocket.rs's controllerinput note). Not cached: the summary changes
712    /// every turn and emits are rare, so the read the summary needs anyway
713    /// also serves the name.
714    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        // A stored value that is not a real title (a placeholder an older build
724        // accepted, such as `(none yet)`) must not reach an OS notification either.
725        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    /// Run the policy and publish what it decided. `Activate` is returned,
735    /// not published: only `ack` produces it, and it needs a store lookup.
736    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            // Resolved and published by `ack`.
769            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    // ── Source entry points ───────────────────────────────────────────────
779
780    /// A frontend reports a pane event. `question` is only used for
781    /// `InputWaiting` and goes through `redact_body`; `question_count` adds
782    /// " (+N more)" after it.
783    ///
784    /// BLOCKING (the block lookup hits the store) — call from
785    /// `spawn_blocking`, never inline on an async worker.
786    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    /// srv-internal sources: the body is app-controlled fixed text, so it is
799    /// not redacted — only the preview=none setting strips it (in policy).
800    /// BLOCKING, like `emit`.
801    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    /// srv-observed "agent is waiting on you" (Phase 5). Non-blocking: safe
807    /// from the controller's stdout reader thread. Coalesces with the
808    /// renderer's own report for the same block (same `input:` group).
809    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    /// The user stopped this block's turn: cancel "finished" for it, and don't
814    /// turn the resulting turn-end into one (queued, ordered).
815    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    /// Ordered counterpart of `resolve`: queued behind any emit already sent
820    /// for the same block.
821    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    /// This channel's MuxBus sign-in stopped working
840    /// (`muxbus::delivery_status`). Fixed text; about no agent. Taken down
841    /// with `resolve("", Family::Cloud)`.
842    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    /// Presenter feedback (rich-content spec §3.3 A–D, F). For a click on a
861    /// pane's toast: find the open window showing the pane and tell only it
862    /// to select the pane (`notification:activate` names the window); if the
863    /// pane's workspace is open nowhere, park the click for the window the
864    /// launcher will open on it. The outcome tells the launcher what to raise
865    /// or open — every click ends with something in front.
866    ///
867    /// BLOCKING (store reads) — call from `spawn_blocking`.
868    pub fn ack(&self, id: &str, clicked: bool) -> AckOutcome {
869        let actions = self.step(Input::Ack { id: id.to_string(), clicked });
870        // A live notification always retracts on ack; an unknown id yields nothing.
871        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            // A summary toast, or an id this srv never issued: raise the app.
914            (None, _) if clicked => outcome.window_id = raise,
915            (None, _) => {}
916        }
917        outcome
918    }
919
920    /// `block.reveal`: the block's tab and the open window showing it,
921    /// resolved exactly as a toast click is (`resolve_click_target` + the
922    /// same open-window pick). `None` when the block is gone or its
923    /// workspace is open in no window.
924    ///
925    /// BLOCKING (store reads) — call from `spawn_blocking`.
926    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    /// Tell `target.window_id`'s renderer to reveal the block.
936    pub fn publish_block_reveal(&self, target: &BlockRevealEvent) {
937        self.publish_event::<crate::backend::mps_events::BlockReveal>(target);
938    }
939
940    /// One-shot: the pane a click asked for while its workspace was open in
941    /// no window. Only a window on that workspace takes it (`workspace_id`
942    /// None = an older frontend, which takes any), and only for 30 s.
943    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    // ---- the turn's end, from its ledger (turn-model spec §4.3, §5) ----
960
961    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    /// Against the REAL serialized status type, not a hand-written JSON — the
1024    /// omitted-when-false field is exactly what a hand-written fixture hid.
1025    #[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        // End to end with the transition rule: start then end = Finished.
1048        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        // First sighting (srv start / replay) is never a transition to "finished".
1058        assert_eq!(turn_transition(None, false, false), Nothing);
1059        assert_eq!(turn_transition(None, true, false), Started);
1060        // Real end of a turn.
1061        assert_eq!(turn_transition(Some(true), false, false), Finished);
1062        // Ended by the user's own Stop.
1063        assert_eq!(turn_transition(Some(true), false, true), Nothing);
1064        // Repeated statuses are not transitions.
1065        assert_eq!(turn_transition(Some(true), true, false), Nothing);
1066        assert_eq!(turn_transition(Some(false), false, false), Nothing);
1067        // Next turn starts.
1068        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        // Spans midnight.
1077        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        // Same-day window.
1082        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        // Off / malformed.
1085        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        // Sensitive → dropped entirely, never "sensitive content".
1146        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}