Ctrl K
ComunidadGuías…

crates/notify/src/sound_policy.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! One audio decision for notification delivery. The caller first applies
//! attention, duration, method, quiet and category gates. This policy selects
//! one cue and preserves per-category repeat history across settings updates.

use super::payload::NotificationKind;
pub use codewhale_config::notifications::NotificationEvent as SoundEvent;
use codewhale_config::notifications::{CompletionSound, NotificationsConfig};
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};

pub fn event_for_kind(kind: NotificationKind) -> SoundEvent {
    match kind {
        NotificationKind::TurnComplete => SoundEvent::TurnComplete,
        NotificationKind::SubagentTerminal | NotificationKind::BackgroundTerminal => {
            SoundEvent::SubagentTerminal
        }
        NotificationKind::ApprovalNeeded => SoundEvent::ApprovalNeeded,
        NotificationKind::InputNeeded => SoundEvent::InputNeeded,
        NotificationKind::ElevationNeeded => SoundEvent::ElevationNeeded,
        NotificationKind::ModelNotify => SoundEvent::ModelNotify,
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SoundCue {
    Bell,
    DoubleBell,
    Beep,
    Whale,
    File(PathBuf),
}

pub fn cue_for(event: SoundEvent) -> SoundCue {
    match event {
        SoundEvent::TurnComplete | SoundEvent::SubagentTerminal => SoundCue::Bell,
        SoundEvent::ApprovalNeeded | SoundEvent::ElevationNeeded => SoundCue::DoubleBell,
        SoundEvent::InputNeeded | SoundEvent::ModelNotify => SoundCue::Beep,
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SuppressReason {
    Disabled,
    QuietMode,
    NotListed,
    RateLimited,
    MissingFile,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SoundDecision {
    Play(SoundCue),
    Suppress(SuppressReason),
}

#[derive(Debug, Clone, Default)]
pub struct EventSoundPolicy {
    config: NotificationsConfig,
    last_played_ms: [Option<u64>; 6],
}

impl EventSoundPolicy {
    pub fn from_config(config: &NotificationsConfig) -> Self {
        Self {
            config: config.clone(),
            last_played_ms: [None; 6],
        }
    }

    pub fn decide(
        &mut self,
        event: SoundEvent,
        now_ms: u64,
        bell_transport: bool,
    ) -> SoundDecision {
        Self::decide_configured(
            &self.config,
            &mut self.last_played_ms,
            event,
            now_ms,
            bell_transport,
        )
    }

    fn decide_configured(
        config: &NotificationsConfig,
        last_played_ms: &mut [Option<u64>; 6],
        event: SoundEvent,
        now_ms: u64,
        bell_transport: bool,
    ) -> SoundDecision {
        if config.quiet {
            return SoundDecision::Suppress(SuppressReason::QuietMode);
        }
        let selected = if let Some(sound) = config.sound {
            Some(sound)
        } else if event == SoundEvent::TurnComplete
            && config.completion_sound != CompletionSound::Off
        {
            Some(config.completion_sound)
        } else {
            if config.event_sound.quiet {
                return SoundDecision::Suppress(SuppressReason::QuietMode);
            }
            if config.event_sound.enabled {
                if !config
                    .event_sound
                    .events
                    .iter()
                    .any(|name| SoundEvent::parse(name) == Some(event))
                {
                    return SoundDecision::Suppress(SuppressReason::NotListed);
                }
                None
            } else if bell_transport {
                Some(CompletionSound::Bell)
            } else {
                return SoundDecision::Suppress(SuppressReason::Disabled);
            }
        };
        let cue = match selected {
            Some(CompletionSound::Off) => return SoundDecision::Suppress(SuppressReason::Disabled),
            Some(CompletionSound::Whale) => SoundCue::Whale,
            Some(CompletionSound::Bell) => SoundCue::Bell,
            Some(CompletionSound::Beep) => SoundCue::Beep,
            Some(CompletionSound::File) => match &config.sound_file {
                Some(path) => SoundCue::File(path.clone()),
                None => return SoundDecision::Suppress(SuppressReason::MissingFile),
            },
            None => cue_for(event),
        };
        let slot = &mut last_played_ms[event.index()];
        if slot.is_some_and(|last| now_ms.saturating_sub(last) < config.event_sound.min_interval_ms)
        {
            return SoundDecision::Suppress(SuppressReason::RateLimited);
        }
        *slot = Some(now_ms);
        SoundDecision::Play(cue)
    }
}

pub fn epoch_millis_now() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis().min(u64::MAX as u128) as u64)
        .unwrap_or(0)
}

static POLICY: OnceLock<RwLock<EventSoundPolicy>> = OnceLock::new();
fn policy_cell() -> &'static RwLock<EventSoundPolicy> {
    POLICY.get_or_init(|| RwLock::new(EventSoundPolicy::default()))
}

pub fn reconfigure(mut policy: EventSoundPolicy) {
    if let Ok(mut slot) = policy_cell().write() {
        policy.last_played_ms = slot.last_played_ms;
        *slot = policy;
    }
}

pub fn decide(kind: NotificationKind, now_ms: u64, bell_transport: bool) -> SoundDecision {
    policy_cell()
        .write()
        .map(|mut policy| policy.decide(event_for_kind(kind), now_ms, bell_transport))
        .unwrap_or(SoundDecision::Suppress(SuppressReason::Disabled))
}

/// Give back a repeat slot that `decide` reserved for a cue that did not play.
/// Only the exact reservation is released, so a later decision in the same
/// category keeps its own slot.
pub fn release(kind: NotificationKind, reserved_ms: u64) {
    if let Ok(mut policy) = policy_cell().write() {
        let slot = &mut policy.last_played_ms[event_for_kind(kind).index()];
        if *slot == Some(reserved_ms) {
            *slot = None;
        }
    }
}

/// Decide from this request's configuration while holding the shared history
/// lock. Request snapshots never replace the installed TUI/model policy.
pub fn decide_configured(
    config: &NotificationsConfig,
    kind: NotificationKind,
    now_ms: u64,
    bell_transport: bool,
) -> SoundDecision {
    policy_cell()
        .write()
        .map(|mut policy| {
            EventSoundPolicy::decide_configured(
                config,
                &mut policy.last_played_ms,
                event_for_kind(kind),
                now_ms,
                bell_transport,
            )
        })
        .unwrap_or(SoundDecision::Suppress(SuppressReason::Disabled))
}

#[cfg(any(test, feature = "test-support"))]
pub fn configure(policy: EventSoundPolicy) {
    *policy_cell().write().unwrap() = policy;
}

#[cfg(test)]
mod tests {
    use super::*;
    use codewhale_config::notifications::EventSoundConfig;

    #[test]
    fn canonical_whale_controls_all_six_categories_over_legacy_controls() {
        let config = NotificationsConfig {
            sound: Some(CompletionSound::Whale),
            completion_sound: CompletionSound::Bell,
            event_sound: EventSoundConfig {
                quiet: true,
                ..Default::default()
            },
            ..Default::default()
        };
        let mut policy = EventSoundPolicy::from_config(&config);
        for event in SoundEvent::ALL {
            assert_eq!(
                policy.decide(event, 0, false),
                SoundDecision::Play(SoundCue::Whale)
            );
        }
    }

    #[test]
    fn explicit_off_silences_legacy_sounds_and_bell_transport() {
        let config = NotificationsConfig {
            sound: Some(CompletionSound::Off),
            completion_sound: CompletionSound::Whale,
            event_sound: EventSoundConfig {
                enabled: true,
                ..Default::default()
            },
            ..Default::default()
        };
        let mut policy = EventSoundPolicy::from_config(&config);
        for event in SoundEvent::ALL {
            assert_eq!(
                policy.decide(event, 0, true),
                SoundDecision::Suppress(SuppressReason::Disabled)
            );
        }
    }

    #[test]
    fn legacy_completion_uses_same_decision_and_other_categories_keep_allowlist() {
        let mut policy = EventSoundPolicy::from_config(&NotificationsConfig {
            completion_sound: CompletionSound::Whale,
            event_sound: EventSoundConfig {
                enabled: true,
                ..Default::default()
            },
            ..Default::default()
        });
        assert_eq!(
            policy.decide(SoundEvent::TurnComplete, 0, false),
            SoundDecision::Play(SoundCue::Whale)
        );
        assert_eq!(
            policy.decide(SoundEvent::ApprovalNeeded, 0, false),
            SoundDecision::Play(SoundCue::DoubleBell)
        );
        assert_eq!(
            policy.decide(SoundEvent::InputNeeded, 0, false),
            SoundDecision::Suppress(SuppressReason::NotListed)
        );
    }

    #[test]
    fn default_sound_is_opt_in_but_explicit_bell_transport_selects_one_bell() {
        let mut policy = EventSoundPolicy::default();
        assert_eq!(
            policy.decide(SoundEvent::TurnComplete, 0, false),
            SoundDecision::Suppress(SuppressReason::Disabled)
        );
        assert_eq!(
            policy.decide(SoundEvent::TurnComplete, 0, true),
            SoundDecision::Play(SoundCue::Bell)
        );
    }

    #[test]
    fn rate_limit_is_per_category_handles_clock_reversal_and_exact_boundary() {
        let mut policy = EventSoundPolicy::from_config(&NotificationsConfig {
            sound: Some(CompletionSound::Whale),
            ..Default::default()
        });
        assert_eq!(
            policy.decide(SoundEvent::TurnComplete, 5000, false),
            SoundDecision::Play(SoundCue::Whale)
        );
        for now in [1000, 5001, 6999] {
            assert_eq!(
                policy.decide(SoundEvent::TurnComplete, now, false),
                SoundDecision::Suppress(SuppressReason::RateLimited)
            );
        }
        assert_eq!(
            policy.decide(SoundEvent::ApprovalNeeded, 5001, false),
            SoundDecision::Play(SoundCue::Whale)
        );
        assert_eq!(
            policy.decide(SoundEvent::TurnComplete, 7000, false),
            SoundDecision::Play(SoundCue::Whale)
        );
    }

    #[test]
    fn settings_refresh_preserves_repeat_history() {
        let _lock = codewhale_runtime::test_support::lock_test_env();
        let config = NotificationsConfig {
            sound: Some(CompletionSound::Whale),
            ..Default::default()
        };
        configure(EventSoundPolicy::from_config(&config));
        assert_eq!(
            decide(NotificationKind::ApprovalNeeded, 10, false),
            SoundDecision::Play(SoundCue::Whale)
        );
        reconfigure(EventSoundPolicy::from_config(&config));
        assert_eq!(
            decide(NotificationKind::ApprovalNeeded, 11, false),
            SoundDecision::Suppress(SuppressReason::RateLimited)
        );
        configure(EventSoundPolicy::default());
    }

    #[test]
    fn configured_off_decision_ignores_intervening_installed_whale_policy() {
        let _lock = codewhale_runtime::test_support::lock_test_env();
        let off = NotificationsConfig {
            sound: Some(CompletionSound::Off),
            ..Default::default()
        };
        configure(EventSoundPolicy::from_config(&off));
        let (ready_tx, ready_rx) = std::sync::mpsc::channel();
        let (resume_tx, resume_rx) = std::sync::mpsc::channel();
        let request = std::thread::spawn(move || {
            ready_tx.send(()).unwrap();
            resume_rx.recv().unwrap();
            decide_configured(&off, NotificationKind::ApprovalNeeded, 10, false)
        });
        ready_rx.recv().unwrap();
        reconfigure(EventSoundPolicy::from_config(&NotificationsConfig {
            sound: Some(CompletionSound::Whale),
            ..Default::default()
        }));
        resume_tx.send(()).unwrap();
        assert_eq!(
            request.join().unwrap(),
            SoundDecision::Suppress(SuppressReason::Disabled)
        );
        assert_eq!(
            decide(NotificationKind::ApprovalNeeded, 10, false),
            SoundDecision::Play(SoundCue::Whale),
            "the suppressed request neither replaces defaults nor consumes history"
        );
        configure(EventSoundPolicy::default());
    }

    #[test]
    fn configured_sound_keeps_installed_defaults_and_suppression_keeps_history() {
        let _lock = codewhale_runtime::test_support::lock_test_env();
        let off = NotificationsConfig {
            sound: Some(CompletionSound::Off),
            ..Default::default()
        };
        let whale = NotificationsConfig {
            sound: Some(CompletionSound::Whale),
            ..Default::default()
        };
        configure(EventSoundPolicy::from_config(&off));
        assert_eq!(
            decide_configured(&whale, NotificationKind::InputNeeded, 100, false),
            SoundDecision::Play(SoundCue::Whale)
        );
        for config in [
            off,
            NotificationsConfig {
                quiet: true,
                ..whale.clone()
            },
        ] {
            assert!(matches!(
                decide_configured(&config, NotificationKind::InputNeeded, 101, false),
                SoundDecision::Suppress(_)
            ));
        }
        assert_eq!(
            decide_configured(&whale, NotificationKind::InputNeeded, 102, false),
            SoundDecision::Suppress(SuppressReason::RateLimited)
        );
        assert_eq!(
            decide(NotificationKind::InputNeeded, 2_100, false),
            SoundDecision::Suppress(SuppressReason::Disabled),
            "a request must not replace installed defaults, even after cooldown"
        );
        assert_eq!(
            decide_configured(&whale, NotificationKind::InputNeeded, 2_100, false),
            SoundDecision::Play(SoundCue::Whale),
            "suppression must not move the original exact cooldown boundary"
        );
        configure(EventSoundPolicy::default());
    }

    #[test]
    fn concurrent_configured_decisions_share_history_with_settings_refresh() {
        let _lock = codewhale_runtime::test_support::lock_test_env();
        configure(EventSoundPolicy::default());
        let whale = NotificationsConfig {
            sound: Some(CompletionSound::Whale),
            ..Default::default()
        };
        let start = std::sync::Arc::new(std::sync::Barrier::new(3));
        let requests = (0..2)
            .map(|_| {
                let start = start.clone();
                let config = whale.clone();
                std::thread::spawn(move || {
                    start.wait();
                    decide_configured(&config, NotificationKind::ApprovalNeeded, 10, false)
                })
            })
            .collect::<Vec<_>>();
        start.wait();
        let results = requests
            .into_iter()
            .map(|request| request.join().unwrap())
            .collect::<Vec<_>>();
        assert_eq!(
            results
                .iter()
                .filter(|result| **result == SoundDecision::Play(SoundCue::Whale))
                .count(),
            1
        );
        assert_eq!(
            results
                .iter()
                .filter(|result| **result == SoundDecision::Suppress(SuppressReason::RateLimited))
                .count(),
            1
        );
        reconfigure(EventSoundPolicy::from_config(&whale));
        assert_eq!(
            decide(NotificationKind::ApprovalNeeded, 11, false),
            SoundDecision::Suppress(SuppressReason::RateLimited)
        );
        assert_eq!(
            decide_configured(&whale, NotificationKind::InputNeeded, 11, false),
            SoundDecision::Play(SoundCue::Whale)
        );
        assert_eq!(
            decide_configured(&whale, NotificationKind::ApprovalNeeded, 2_010, false),
            SoundDecision::Play(SoundCue::Whale)
        );
        configure(EventSoundPolicy::default());
    }

    #[test]
    fn custom_file_requires_path_and_never_substitutes_bell() {
        let mut config = NotificationsConfig {
            sound: Some(CompletionSound::File),
            ..Default::default()
        };
        assert_eq!(
            EventSoundPolicy::from_config(&config).decide(SoundEvent::TurnComplete, 0, true),
            SoundDecision::Suppress(SuppressReason::MissingFile)
        );
        config.sound_file = Some(PathBuf::from("file with spaces.wav"));
        assert_eq!(
            EventSoundPolicy::from_config(&config).decide(SoundEvent::TurnComplete, 0, true),
            SoundDecision::Play(SoundCue::File(PathBuf::from("file with spaces.wav")))
        );
    }

    #[test]
    fn master_quiet_suppresses_every_sound_and_does_not_consume_repeat_slot() {
        let mut policy = EventSoundPolicy::from_config(&NotificationsConfig {
            sound: Some(CompletionSound::Whale),
            quiet: true,
            ..Default::default()
        });
        for event in SoundEvent::ALL {
            assert_eq!(
                policy.decide(event, 0, true),
                SoundDecision::Suppress(SuppressReason::QuietMode)
            );
        }
        policy.config.quiet = false;
        assert_eq!(
            policy.decide(SoundEvent::TurnComplete, 1, false),
            SoundDecision::Play(SoundCue::Whale)
        );
    }

    #[test]
    fn shared_event_vocabulary_maps_every_payload_kind() {
        let kinds = [
            NotificationKind::TurnComplete,
            NotificationKind::SubagentTerminal,
            NotificationKind::ApprovalNeeded,
            NotificationKind::InputNeeded,
            NotificationKind::ElevationNeeded,
            NotificationKind::ModelNotify,
        ];
        assert_eq!(kinds.map(event_for_kind), SoundEvent::ALL);
        assert_eq!(
            event_for_kind(NotificationKind::BackgroundTerminal),
            SoundEvent::SubagentTerminal
        );
        for event in SoundEvent::ALL {
            assert_eq!(SoundEvent::parse(event.as_str()), Some(event));
        }
        assert_eq!(SoundEvent::parse("unknown"), None);
    }
}