Skip to main content

zeph_core/
notifications.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Best-effort per-turn completion notifier.
5//!
6//! Fires after each agent turn completes via two independent channels:
7//! - **macOS native** — `osascript` banner via stdin (no argument-injection risk)
8//! - **ntfy webhook** — JSON POST to an ntfy-compatible endpoint
9//!
10//! All notifications are fire-and-forget: failures are logged at `warn` level and
11//! never propagated to the caller. Secrets are redacted before any payload leaves
12//! the process.
13//!
14//! # Gating
15//!
16//! [`Notifier::should_fire`] applies all gate conditions in order:
17//! 1. Master `enabled` switch must be `true`
18//! 2. `llm_requests == 0` → skip (slash commands, cache-only, security-blocked turns)
19//! 3. `only_on_error && !is_error` → skip
20//! 4. Duration gate (`min_turn_duration_ms`) applies only to successful turns;
21//!    error turns always fire regardless of duration
22//!
23//! `tool_calls` does not participate in gating — a turn can legitimately dispatch
24//! zero tool calls (e.g. an LLM response with no tool use) and still fire. It only
25//! feeds the notification body and the `turn_complete` hook env (see
26//! [`TurnSummary::tool_calls`]).
27//!
28//! # Examples
29//!
30//! ```no_run
31//! use zeph_core::notifications::{Notifier, TurnSummary, TurnExitStatus};
32//! use zeph_config::NotificationsConfig;
33//!
34//! let cfg = NotificationsConfig {
35//!     enabled: true,
36//!     macos_native: true,
37//!     ..Default::default()
38//! };
39//! let notifier = Notifier::new(cfg);
40//! let summary = TurnSummary {
41//!     duration_ms: 5000,
42//!     preview: "Done. Files updated.".to_owned(),
43//!     tool_calls: 2,
44//!     llm_requests: 1,
45//!     exit_status: TurnExitStatus::Success,
46//! };
47//! // notifier.fire(&summary, supervisor) — called from the agent loop with
48//! // its BackgroundSupervisor; fire-and-forget, errors are logged only.
49//! let _ = notifier.should_fire(&summary); // gate check
50//! ```
51
52use std::time::Duration;
53
54use serde::Serialize;
55use tracing::warn;
56use zeph_config::NotificationsConfig;
57
58use crate::agent::agent_supervisor::{BackgroundSupervisor, TaskClass};
59use crate::redact::scrub_content;
60
61// ── Public types ─────────────────────────────────────────────────────────────
62
63#[non_exhaustive]
64/// Whether a turn completed successfully or with an error.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum TurnExitStatus {
67    /// Turn completed without error.
68    Success,
69    /// Turn completed with an error (tool failure, LLM error, etc.).
70    Error,
71}
72
73/// Lightweight summary of a completed agent turn used as notification input.
74///
75/// Built by the agent loop after `channel.flush_chunks()` and passed to
76/// `Notifier::fire`. Contains only what is needed for gate decisions and
77/// notification body assembly — no LLM payloads or raw tool outputs.
78#[derive(Debug, Clone)]
79pub struct TurnSummary {
80    /// Total wall-clock duration of the turn in milliseconds.
81    pub duration_ms: u64,
82    /// First ≤ 160 chars of the assistant response, already redacted by the caller.
83    pub preview: String,
84    /// Number of tool calls dispatched this turn.
85    ///
86    /// Included in the notification body when non-zero, and exported to
87    /// `turn_complete` hooks as `ZEPH_TURN_TOOL_CALLS`. Does not participate in
88    /// [`Notifier::should_fire`] gating.
89    pub tool_calls: u32,
90    /// Number of completed LLM round-trips this turn.
91    /// Zero for slash commands, cache-only turns, and security-blocked inputs.
92    pub llm_requests: u32,
93    /// Whether the turn ended with an error.
94    pub exit_status: TurnExitStatus,
95}
96
97/// Per-turn completion notifier.
98///
99/// Holds a shared [`reqwest::Client`] and the resolved config. Construct once at
100/// agent startup via [`Notifier::new`] and call `Notifier::fire` after each turn.
101///
102/// All I/O is routed through the agent's `BackgroundSupervisor` so it is
103/// visible in TUI status and bounded by the Telemetry class concurrency limit.
104/// `fire` returns immediately without blocking the agent loop.
105///
106/// Cloning is cheap — `reqwest::Client` is an `Arc`-backed handle.
107#[derive(Clone)]
108pub struct Notifier {
109    cfg: NotificationsConfig,
110    http: reqwest::Client,
111}
112
113impl Notifier {
114    /// Create a notifier from a [`NotificationsConfig`].
115    ///
116    /// Constructs a shared HTTP client with a 5-second connect timeout. The client
117    /// is reused across all webhook calls for the agent session.
118    ///
119    /// If `webhook_url` is set but fails URL validation (unparseable or non-HTTP(S)
120    /// scheme), it is cleared to `None` and a warning is logged. This prevents SSRF
121    /// via malformed URLs (e.g. `file://`, `ftp://`).
122    #[must_use]
123    pub fn new(cfg: NotificationsConfig) -> Self {
124        let http = reqwest::Client::builder()
125            .connect_timeout(Duration::from_secs(5))
126            .timeout(Duration::from_secs(5))
127            .build()
128            .unwrap_or_default();
129        let mut cfg = cfg;
130        if cfg
131            .webhook_url
132            .as_deref()
133            .is_some_and(|url| !validate_webhook_url(url, cfg.webhook_allow_insecure))
134        {
135            cfg.webhook_url = None;
136        }
137        Self { cfg, http }
138    }
139
140    /// Evaluate all gate conditions and return `true` when the notification should fire.
141    ///
142    /// Gates applied in order (all must pass):
143    /// 1. `enabled` is `true`
144    /// 2. `summary.llm_requests > 0` (zero-LLM turns are never notified)
145    /// 3. If `only_on_error`: turn must have errored
146    /// 4. For successful turns: `duration_ms >= min_turn_duration_ms`
147    ///    (error turns bypass the duration gate)
148    ///
149    /// `summary.tool_calls` is never gated on — a turn with `tool_calls == 0` but
150    /// `llm_requests > 0` (an LLM response with no tool use) still fires.
151    #[must_use]
152    pub fn should_fire(&self, summary: &TurnSummary) -> bool {
153        if !self.cfg.enabled {
154            return false;
155        }
156        // Gate S6: never notify for zero-LLM turns (slash commands, cache hits, etc.)
157        // Exception M8 from critic: allow zero-LLM errors through so setup failures surface.
158        if summary.llm_requests == 0 && summary.exit_status == TurnExitStatus::Success {
159            return false;
160        }
161        match summary.exit_status {
162            // Gate S4: errors always fire, bypassing the duration gate.
163            TurnExitStatus::Error => true,
164            TurnExitStatus::Success => {
165                if self.cfg.only_on_error {
166                    return false;
167                }
168                // Duration gate applies only to successful turns.
169                summary.duration_ms >= self.cfg.min_turn_duration_ms
170            }
171        }
172    }
173
174    /// Fire all enabled notification channels for this turn summary.
175    ///
176    /// Returns immediately — all I/O is routed through `supervisor` as a
177    /// [`TaskClass::Telemetry`] task, making it visible in TUI status and
178    /// bounded by the class concurrency limit. Failures are logged at `warn`
179    /// level and never propagated.
180    pub(crate) fn fire(&self, summary: &TurnSummary, supervisor: &mut BackgroundSupervisor) {
181        let cfg = self.cfg.clone();
182        let http = self.http.clone();
183        let summary = summary.clone();
184
185        supervisor.spawn(TaskClass::Telemetry, "notify_turn_complete", async move {
186            fire_all_channels(&cfg, &http, &summary).await;
187        });
188    }
189
190    /// Fire a test notification with a fixed message.
191    ///
192    /// Used by the `zeph notify test` CLI subcommand. Returns an error if all
193    /// channels are disabled or if every channel failed.
194    ///
195    /// # Errors
196    ///
197    /// - `NotifyTestError::AllDisabled` — no channel is enabled
198    /// - `NotifyTestError::MacOsFailed` — macOS notification failed (macOS only)
199    /// - `NotifyTestError::WebhookFailed` — webhook POST failed
200    pub async fn fire_test(&self) -> Result<(), NotifyTestError> {
201        if !self.cfg.enabled {
202            return Err(NotifyTestError::MasterSwitchDisabled);
203        }
204
205        let macos_enabled = self.cfg.macos_native;
206        let webhook_enabled = self.cfg.webhook_url.is_some() && self.cfg.webhook_topic.is_some();
207
208        if !macos_enabled && !webhook_enabled {
209            return Err(NotifyTestError::AllDisabled);
210        }
211
212        let summary = TurnSummary {
213            duration_ms: 0,
214            preview: "Zeph is working".to_owned(),
215            tool_calls: 0,
216            llm_requests: 1,
217            exit_status: TurnExitStatus::Success,
218        };
219
220        #[cfg(target_os = "macos")]
221        if macos_enabled {
222            fire_macos_native(&self.cfg.title, "Zeph is working")
223                .await
224                .map_err(|e| NotifyTestError::MacOsFailed(e.to_string()))?;
225        }
226
227        if let (Some(url), Some(topic)) = (&self.cfg.webhook_url, &self.cfg.webhook_topic) {
228            fire_webhook(&self.http, url, &self.cfg.title, topic, &summary)
229                .await
230                .map_err(|e| NotifyTestError::WebhookFailed(e.to_string()))?;
231        }
232
233        Ok(())
234    }
235}
236
237#[non_exhaustive]
238/// Error returned by [`Notifier::fire_test`].
239#[derive(Debug, thiserror::Error)]
240pub enum NotifyTestError {
241    /// The master `notifications.enabled` switch is `false`.
242    #[error("notifications are disabled (set notifications.enabled = true to enable)")]
243    MasterSwitchDisabled,
244    /// No channels are enabled in the current configuration.
245    #[error("all notification channels are disabled")]
246    AllDisabled,
247    /// macOS notification failed.
248    #[error("macOS notification failed: {0}")]
249    MacOsFailed(String),
250    /// Webhook POST failed.
251    #[error("webhook notification failed: {0}")]
252    WebhookFailed(String),
253}
254
255// ── Internal helpers ──────────────────────────────────────────────────────────
256
257/// Fire all enabled channels for `summary`. Called from a spawned task.
258async fn fire_all_channels(
259    cfg: &NotificationsConfig,
260    http: &reqwest::Client,
261    summary: &TurnSummary,
262) {
263    let title = &cfg.title;
264
265    #[cfg(target_os = "macos")]
266    {
267        let message = build_notification_message(summary);
268        if cfg.macos_native
269            && let Err(e) = fire_macos_native(title, &message).await
270        {
271            warn!(error = %e, "macOS notification failed");
272        }
273    }
274
275    if let (Some(url), Some(topic)) = (&cfg.webhook_url, &cfg.webhook_topic)
276        && let Err(e) = fire_webhook(http, url, title, topic, summary).await
277    {
278        warn!(error = %e, "webhook notification failed");
279    }
280}
281
282/// Build the notification body from a turn summary, applying secret redaction.
283fn build_notification_message(summary: &TurnSummary) -> String {
284    let status = if summary.exit_status == TurnExitStatus::Error {
285        "Error"
286    } else {
287        "Done"
288    };
289
290    let header = if summary.tool_calls > 0 {
291        let noun = if summary.tool_calls == 1 {
292            "tool call"
293        } else {
294            "tool calls"
295        };
296        format!(
297            "{status} — {dur}ms, {calls} {noun}",
298            dur = summary.duration_ms,
299            calls = summary.tool_calls,
300        )
301    } else {
302        format!("{status} — {dur}ms", dur = summary.duration_ms)
303    };
304
305    // Apply scrub_content to redact any secrets that may be in the preview.
306    let safe_preview = scrub_content(&summary.preview);
307
308    if safe_preview.is_empty() {
309        header
310    } else {
311        format!("{header}\n{safe_preview}")
312    }
313}
314
315/// Sanitize a string for safe inclusion inside an `AppleScript` `"..."` literal.
316///
317/// Steps applied in order (order is important):
318/// 1. Replace all ASCII control characters (< 0x20) and Unicode control chars with space
319///    (tab `\t` is also replaced — single-line banners only)
320/// 2. Replace newlines `\n` and carriage returns `\r` with a single space
321/// 3. Truncate to `max` chars, appending `…` when cut
322/// 4. Strip `\` and `"` — `AppleScript` does not support backslash escaping inside strings,
323///    so these characters must be removed to prevent injection
324///
325/// # Examples
326///
327/// ```
328/// # use zeph_core::notifications::sanitize_applescript_payload;
329/// let s = sanitize_applescript_payload("Hello\nWorld\"", 200);
330/// assert_eq!(s, "Hello World");
331/// ```
332#[must_use]
333pub fn sanitize_applescript_payload(s: &str, max: usize) -> String {
334    // Step 1 + 2: normalise control characters and Unicode line/paragraph separators.
335    // U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR) are not in the Unicode Cc
336    // category but still break AppleScript string literals, so they are explicitly replaced.
337    let cleaned: String = s
338        .chars()
339        .map(|c| {
340            if c.is_control() || c == '\u{2028}' || c == '\u{2029}' {
341                ' '
342            } else {
343                c
344            }
345        })
346        .collect();
347    // Step 3: truncate to `max` char count (not bytes).
348    let char_count = cleaned.chars().count();
349    let truncated: String = if char_count > max {
350        let end = cleaned
351            .char_indices()
352            .nth(max)
353            .map_or(cleaned.len(), |(i, _)| i);
354        let mut t = cleaned[..end].to_owned();
355        t.push('…');
356        t
357    } else {
358        cleaned
359    };
360    // Step 4: strip characters that cannot be safely embedded in an AppleScript string literal.
361    // AppleScript does not use backslash escaping inside strings — the only safe approach
362    // is to remove double-quote and backslash characters entirely.
363    truncated.replace(['\\', '"'], "")
364}
365
366/// Validate a webhook URL for safe use as a notification endpoint.
367///
368/// Returns `true` when the URL is acceptable. Logs a warning and returns `false`
369/// when the URL is unparseable or uses a non-HTTP(S) scheme. Accepts `http://`
370/// only when `allow_insecure` is `true` (opt-in for local testing).
371fn validate_webhook_url(url: &str, allow_insecure: bool) -> bool {
372    match url.parse::<reqwest::Url>() {
373        Ok(parsed) => {
374            if parsed.scheme() == "https" {
375                return true;
376            }
377            if allow_insecure && parsed.scheme() == "http" {
378                warn!(
379                    "webhook_url uses insecure HTTP scheme; set webhook_allow_insecure=false for production"
380                );
381                return true;
382            }
383            warn!(
384                scheme = parsed.scheme(),
385                "webhook_url has non-HTTP(S) scheme — channel disabled"
386            );
387            false
388        }
389        Err(e) => {
390            warn!(error = %e, "webhook_url is not a valid URL — channel disabled");
391            false
392        }
393    }
394}
395
396/// Fire a macOS Notification Center banner via osascript (stdin-fed to avoid arg injection).
397#[cfg(target_os = "macos")]
398async fn fire_macos_native(
399    title: &str,
400    message: &str,
401) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
402    use tokio::io::AsyncWriteExt as _;
403    use tokio::process::Command;
404
405    let safe_title = sanitize_applescript_payload(title, 120);
406    let safe_message = sanitize_applescript_payload(message, 240);
407
408    let script = format!(r#"display notification "{safe_message}" with title "{safe_title}""#);
409
410    let mut child = Command::new("osascript")
411        .stdin(std::process::Stdio::piped())
412        .stdout(std::process::Stdio::null())
413        .stderr(std::process::Stdio::null())
414        .spawn()?;
415
416    if let Some(mut stdin) = child.stdin.take() {
417        stdin.write_all(script.as_bytes()).await?;
418        stdin.shutdown().await?;
419    }
420
421    // Wait up to 5s for osascript to complete; ignore exit status (best-effort).
422    let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
423
424    Ok(())
425}
426
427/// ntfy-compatible JSON webhook body.
428///
429/// Matches the [ntfy publish-as-JSON](https://docs.ntfy.sh/publish/#publish-as-json) schema.
430#[derive(Serialize)]
431struct NtfyWebhookBody<'a> {
432    topic: &'a str,
433    title: &'a str,
434    message: &'a str,
435    tags: Vec<&'a str>,
436    /// Priority 1–5. Default 3; error turns use 4.
437    priority: u8,
438}
439
440/// POST a notification to an ntfy-compatible JSON endpoint.
441async fn fire_webhook(
442    client: &reqwest::Client,
443    url: &str,
444    title: &str,
445    topic: &str,
446    summary: &TurnSummary,
447) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
448    let message = build_notification_message(summary);
449    let (tags, priority) = if summary.exit_status == TurnExitStatus::Error {
450        (vec!["zeph", "error"], 4u8)
451    } else {
452        (vec!["zeph", "turn-complete"], 3u8)
453    };
454
455    let body = NtfyWebhookBody {
456        topic,
457        title,
458        message: &message,
459        tags,
460        priority,
461    };
462
463    // Timeout is already set on the client (5s), but wrap for clarity.
464    tokio::time::timeout(Duration::from_secs(5), client.post(url).json(&body).send())
465        .await??
466        .error_for_status()?;
467
468    Ok(())
469}
470
471// ── Tests ─────────────────────────────────────────────────────────────────────
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use zeph_config::NotificationsConfig;
477
478    fn make_notifier(cfg: NotificationsConfig) -> Notifier {
479        Notifier::new(cfg)
480    }
481
482    fn success_summary(duration_ms: u64, llm_requests: u32) -> TurnSummary {
483        TurnSummary {
484            duration_ms,
485            preview: "All done.".to_owned(),
486            tool_calls: 0,
487            llm_requests,
488            exit_status: TurnExitStatus::Success,
489        }
490    }
491
492    fn error_summary(duration_ms: u64, llm_requests: u32) -> TurnSummary {
493        TurnSummary {
494            duration_ms,
495            preview: "Error occurred.".to_owned(),
496            tool_calls: 0,
497            llm_requests,
498            exit_status: TurnExitStatus::Error,
499        }
500    }
501
502    // ── should_fire gate tests ────────────────────────────────────────────────
503
504    #[test]
505    fn should_fire_disabled_master_switch() {
506        let n = make_notifier(NotificationsConfig {
507            enabled: false,
508            ..Default::default()
509        });
510        assert!(!n.should_fire(&success_summary(5000, 1)));
511    }
512
513    #[test]
514    fn should_fire_zero_llm_success_skipped() {
515        let n = make_notifier(NotificationsConfig {
516            enabled: true,
517            ..Default::default()
518        });
519        // Zero-LLM successful turns (slash commands, cache hits) are never notified.
520        assert!(!n.should_fire(&success_summary(0, 0)));
521    }
522
523    #[test]
524    fn should_fire_zero_llm_error_fires() {
525        // Critic M8: zero-LLM errors (setup failures) should still fire.
526        let n = make_notifier(NotificationsConfig {
527            enabled: true,
528            ..Default::default()
529        });
530        assert!(n.should_fire(&error_summary(0, 0)));
531    }
532
533    #[test]
534    fn should_fire_only_on_error_skips_success() {
535        let n = make_notifier(NotificationsConfig {
536            enabled: true,
537            only_on_error: true,
538            ..Default::default()
539        });
540        assert!(!n.should_fire(&success_summary(5000, 1)));
541    }
542
543    #[test]
544    fn should_fire_only_on_error_fires_on_error() {
545        let n = make_notifier(NotificationsConfig {
546            enabled: true,
547            only_on_error: true,
548            ..Default::default()
549        });
550        assert!(n.should_fire(&error_summary(100, 1)));
551    }
552
553    #[test]
554    fn should_fire_duration_gate_success_below_threshold() {
555        let n = make_notifier(NotificationsConfig {
556            enabled: true,
557            min_turn_duration_ms: 3000,
558            ..Default::default()
559        });
560        assert!(!n.should_fire(&success_summary(2999, 1)));
561    }
562
563    #[test]
564    fn should_fire_duration_gate_success_at_threshold() {
565        let n = make_notifier(NotificationsConfig {
566            enabled: true,
567            min_turn_duration_ms: 3000,
568            ..Default::default()
569        });
570        assert!(n.should_fire(&success_summary(3000, 1)));
571    }
572
573    #[test]
574    fn should_fire_error_bypasses_duration_gate() {
575        // Gate S4: errors always fire even when below min_turn_duration_ms.
576        let n = make_notifier(NotificationsConfig {
577            enabled: true,
578            min_turn_duration_ms: 3000,
579            ..Default::default()
580        });
581        assert!(n.should_fire(&error_summary(100, 1)));
582    }
583
584    // ── sanitize_applescript_payload tests ────────────────────────────────────
585
586    #[test]
587    fn sanitize_control_chars_replaced_with_space() {
588        let result = sanitize_applescript_payload("Hello\nWorld", 200);
589        // Newline becomes space, no quotes broken
590        assert!(!result.contains('\n'));
591        assert!(result.contains("Hello World"));
592    }
593
594    #[test]
595    fn sanitize_quotes_stripped() {
596        // AppleScript has no backslash escape — double quotes are stripped entirely.
597        let result = sanitize_applescript_payload(r#"say "hi""#, 200);
598        assert!(!result.contains('"'));
599        assert_eq!(result, "say hi");
600    }
601
602    #[test]
603    fn sanitize_backslash_stripped() {
604        // AppleScript has no backslash escape — backslashes are stripped entirely.
605        let result = sanitize_applescript_payload(r"C:\Users\foo", 200);
606        assert_eq!(result, "C:Usersfoo");
607    }
608
609    #[test]
610    fn sanitize_truncation_appends_ellipsis() {
611        let long = "a".repeat(300);
612        let result = sanitize_applescript_payload(&long, 200);
613        assert!(result.ends_with('…'));
614        // Char count should be max + 1 for the ellipsis.
615        assert_eq!(result.chars().count(), 201);
616    }
617
618    #[test]
619    fn sanitize_no_truncation_when_short() {
620        let result = sanitize_applescript_payload("short", 200);
621        assert_eq!(result, "short");
622    }
623
624    #[test]
625    fn sanitize_injection_attempt() {
626        // Classic AppleScript injection via closing the string and calling display dialog.
627        let payload = r#""; display dialog "gotcha"; ""#;
628        let result = sanitize_applescript_payload(payload, 200);
629        // All `"` must be escaped; the script cannot terminate the outer string.
630        assert!(!result.contains('"'));
631    }
632
633    #[test]
634    fn sanitize_applescript_payload_empty() {
635        assert_eq!(sanitize_applescript_payload("", 200), "");
636    }
637
638    #[test]
639    fn sanitize_tab_replaced() {
640        let result = sanitize_applescript_payload("a\tb", 200);
641        assert_eq!(result, "a b");
642    }
643
644    #[test]
645    fn sanitize_line_separators() {
646        let s = "hello\u{2028}world\u{2029}end";
647        let result = sanitize_applescript_payload(s, 200);
648        assert!(!result.contains('\u{2028}'));
649        assert!(!result.contains('\u{2029}'));
650        assert_eq!(result, "hello world end");
651    }
652
653    // ── build_notification_message tests ──────────────────────────────────────
654
655    #[test]
656    fn notification_message_success() {
657        let summary = success_summary(1234, 1);
658        let msg = build_notification_message(&summary);
659        assert!(msg.starts_with("Done"));
660        assert!(msg.contains("1234ms"));
661    }
662
663    #[test]
664    fn notification_message_error() {
665        let summary = error_summary(500, 1);
666        let msg = build_notification_message(&summary);
667        assert!(msg.starts_with("Error"));
668    }
669
670    #[test]
671    fn notification_message_includes_tool_calls_when_nonzero() {
672        let summary = TurnSummary {
673            duration_ms: 1234,
674            preview: "All done.".to_owned(),
675            tool_calls: 3,
676            llm_requests: 1,
677            exit_status: TurnExitStatus::Success,
678        };
679        let msg = build_notification_message(&summary);
680        assert!(
681            msg.contains("3 tool calls"),
682            "message should mention the tool-call count: {msg}"
683        );
684    }
685
686    #[test]
687    fn notification_message_singular_tool_call() {
688        let summary = TurnSummary {
689            duration_ms: 1234,
690            preview: "All done.".to_owned(),
691            tool_calls: 1,
692            llm_requests: 1,
693            exit_status: TurnExitStatus::Success,
694        };
695        let msg = build_notification_message(&summary);
696        assert!(
697            msg.contains("1 tool call") && !msg.contains("1 tool calls"),
698            "singular count should use singular noun: {msg}"
699        );
700    }
701
702    #[test]
703    fn notification_message_omits_tool_calls_when_zero() {
704        // tool_calls == 0 (e.g. slash commands, plain LLM replies) must not clutter the banner.
705        let summary = success_summary(1234, 1);
706        let msg = build_notification_message(&summary);
707        assert!(!msg.contains("tool call"), "message should be: {msg}");
708    }
709
710    #[test]
711    fn notification_message_redacts_secrets() {
712        let summary = TurnSummary {
713            duration_ms: 100,
714            preview: "Done. Key: sk-abc123xyz".to_owned(),
715            tool_calls: 0,
716            llm_requests: 1,
717            exit_status: TurnExitStatus::Success,
718        };
719        let msg = build_notification_message(&summary);
720        assert!(!msg.contains("sk-abc123xyz"), "secret must be redacted");
721        assert!(
722            msg.contains("[REDACTED]"),
723            "should contain redaction marker"
724        );
725    }
726}