Skip to main content

gemini_adk_rs/live/
temporal.rs

1//! Temporal pattern detection for live sessions.
2//!
3//! A [`TemporalRegistry`] holds named [`TemporalPattern`]s that combine a
4//! [`PatternDetector`] (the condition) with an async action (the response).
5//! Detectors track time-based and count-based conditions such as sustained
6//! state, event rates, consecutive turns, and consecutive tool failures.
7//!
8//! The registry is evaluated by the control-lane processor on each event and
9//! optionally on a periodic timer (when [`TemporalRegistry::needs_timer`]
10//! returns `true`).
11
12use std::collections::VecDeque;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicU32, Ordering};
15use std::time::{Duration, Instant};
16
17use gemini_genai_rs::session::{SessionEvent, SessionWriter};
18
19use super::BoxFuture;
20use crate::state::State;
21
22// ── PatternDetector trait ─────────────────────────────────────────────────────
23
24/// A detector that evaluates whether a temporal pattern has been triggered.
25///
26/// Implementations track internal state (timestamps, counters) to decide
27/// when a pattern fires. All interior mutability is handled via atomic
28/// operations or `parking_lot` locks so that `&self` suffices.
29pub trait PatternDetector: Send + Sync {
30    /// Evaluate whether the pattern is currently triggered.
31    ///
32    /// - `state`: the current agent state snapshot.
33    /// - `event`: the session event that prompted this check (if any).
34    /// - `now`: the current instant (passed in for testability).
35    fn check(&self, state: &State, event: Option<&SessionEvent>, now: Instant) -> bool;
36
37    /// Reset the detector's internal state (counters, timestamps, etc.).
38    fn reset(&self);
39
40    /// Whether this detector requires periodic timer checks.
41    ///
42    /// Detectors that depend on elapsed time (e.g. [`SustainedDetector`])
43    /// should return `true` so the runtime can schedule a timer.
44    fn needs_timer(&self) -> bool {
45        false
46    }
47}
48
49// ── TemporalPattern ───────────────────────────────────────────────────────────
50
51/// A named temporal pattern: detector + action + cooldown.
52pub struct TemporalPattern {
53    /// Human-readable name for logging/debugging.
54    pub name: String,
55    /// The detector that decides when to fire.
56    pub detector: Box<dyn PatternDetector>,
57    /// The async action to execute when the pattern triggers.
58    /// Receives a cloned `State` and the session writer.
59    pub action: super::SessionHook,
60    /// Optional minimum interval between successive firings.
61    pub cooldown: Option<Duration>,
62    /// Tracks when this pattern last fired (for cooldown enforcement).
63    last_triggered: parking_lot::Mutex<Option<Instant>>,
64}
65
66impl TemporalPattern {
67    /// Create a new temporal pattern.
68    pub fn new(
69        name: impl Into<String>,
70        detector: Box<dyn PatternDetector>,
71        action: super::SessionHook,
72        cooldown: Option<Duration>,
73    ) -> Self {
74        Self {
75            name: name.into(),
76            detector,
77            action,
78            cooldown,
79            last_triggered: parking_lot::Mutex::new(None),
80        }
81    }
82
83    /// Check whether the pattern fires and cooldown allows it.
84    fn try_fire(
85        &self,
86        state: &State,
87        event: Option<&SessionEvent>,
88        writer: &Arc<dyn SessionWriter>,
89        now: Instant,
90    ) -> Option<BoxFuture<()>> {
91        if !pattern_fires(
92            self.detector.as_ref(),
93            state,
94            event,
95            now,
96            self.cooldown,
97            &mut self.last_triggered.lock(),
98        ) {
99            return None;
100        }
101
102        let s = state.clone();
103        let w = writer.clone();
104        Some((self.action)(s, w))
105    }
106}
107
108pub(crate) fn pattern_fires(
109    detector: &dyn PatternDetector,
110    state: &State,
111    event: Option<&SessionEvent>,
112    now: Instant,
113    cooldown: Option<Duration>,
114    last: &mut Option<Instant>,
115) -> bool {
116    if !detector.check(state, event, now) {
117        return false;
118    }
119    if let (Some(cooldown), Some(previous)) = (cooldown, *last)
120        && now.saturating_duration_since(previous) < cooldown
121    {
122        return false;
123    }
124    *last = Some(now);
125    true
126}
127
128// ── TemporalRegistry ─────────────────────────────────────────────────────────
129
130/// Registry of temporal patterns evaluated on events and/or timer ticks.
131pub struct TemporalRegistry {
132    patterns: Vec<TemporalPattern>,
133}
134
135impl Default for TemporalRegistry {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141impl TemporalRegistry {
142    /// Create an empty registry.
143    pub fn new() -> Self {
144        Self {
145            patterns: Vec::new(),
146        }
147    }
148
149    /// Whether the registry contains no temporal patterns.
150    pub fn is_empty(&self) -> bool {
151        self.patterns.is_empty()
152    }
153
154    /// Register a pattern.
155    pub fn register(&mut self, pattern: TemporalPattern) {
156        self.patterns.push(pattern);
157    }
158
159    /// Check all patterns and return the futures for those that fired.
160    ///
161    /// Called by the control-lane processor on each event and optionally on
162    /// a periodic timer tick.
163    pub fn check_all(
164        &self,
165        state: &State,
166        event: Option<&SessionEvent>,
167        writer: &Arc<dyn SessionWriter>,
168    ) -> Vec<BoxFuture<()>> {
169        let now = state.clock().now();
170        self.patterns
171            .iter()
172            .filter_map(|p| p.try_fire(state, event, writer, now))
173            .collect()
174    }
175
176    /// Returns `true` if any registered pattern's detector needs periodic
177    /// timer checks (i.e. its [`PatternDetector::needs_timer`] returns `true`).
178    pub fn needs_timer(&self) -> bool {
179        self.patterns.iter().any(|p| p.detector.needs_timer())
180    }
181}
182
183// ── SustainedDetector ─────────────────────────────────────────────────────────
184
185/// Fires when a state-based condition remains true for at least `duration`.
186///
187/// On each `check()`:
188/// - If the condition is true and `became_true_at` is `None`, record `now`.
189/// - If the condition is true and `became_true_at` is `Some(t)`, return
190///   `true` when `now - t >= duration`.
191/// - If the condition is false, reset `became_true_at` to `None`.
192///
193/// This detector **needs periodic timer checks** because it depends on
194/// elapsed wall-clock time.
195pub struct SustainedDetector {
196    condition: Arc<dyn Fn(&State) -> bool + Send + Sync>,
197    duration: Duration,
198    became_true_at: parking_lot::Mutex<Option<Instant>>,
199}
200
201impl SustainedDetector {
202    /// Create a new sustained detector.
203    ///
204    /// - `condition`: evaluated against the current state.
205    /// - `duration`: how long the condition must remain true before firing.
206    pub fn new(condition: Arc<dyn Fn(&State) -> bool + Send + Sync>, duration: Duration) -> Self {
207        Self {
208            condition,
209            duration,
210            became_true_at: parking_lot::Mutex::new(None),
211        }
212    }
213}
214
215impl PatternDetector for SustainedDetector {
216    fn check(&self, state: &State, _event: Option<&SessionEvent>, now: Instant) -> bool {
217        if (self.condition)(state) {
218            let mut guard = self.became_true_at.lock();
219            match *guard {
220                None => {
221                    *guard = Some(now);
222                    false
223                }
224                Some(t) => now.duration_since(t) >= self.duration,
225            }
226        } else {
227            *self.became_true_at.lock() = None;
228            false
229        }
230    }
231
232    fn reset(&self) {
233        *self.became_true_at.lock() = None;
234    }
235
236    fn needs_timer(&self) -> bool {
237        true
238    }
239}
240
241// ── RateDetector ──────────────────────────────────────────────────────────────
242
243/// Fires when at least `count` matching events occur within `window`.
244///
245/// On each `check()`:
246/// - If `event` is `Some` and the filter accepts it, push the current
247///   timestamp.
248/// - Expire timestamps older than `window`.
249/// - Return `true` if the remaining count >= threshold.
250pub struct RateDetector {
251    filter: Arc<dyn Fn(&SessionEvent) -> bool + Send + Sync>,
252    count: u32,
253    window: Duration,
254    timestamps: parking_lot::Mutex<VecDeque<Instant>>,
255}
256
257impl RateDetector {
258    /// Create a new rate detector.
259    ///
260    /// - `filter`: predicate to select which events count.
261    /// - `count`: number of matching events required.
262    /// - `window`: sliding time window.
263    pub fn new(
264        filter: Arc<dyn Fn(&SessionEvent) -> bool + Send + Sync>,
265        count: u32,
266        window: Duration,
267    ) -> Self {
268        Self {
269            filter,
270            count,
271            window,
272            timestamps: parking_lot::Mutex::new(VecDeque::new()),
273        }
274    }
275}
276
277impl PatternDetector for RateDetector {
278    fn check(&self, _state: &State, event: Option<&SessionEvent>, now: Instant) -> bool {
279        let mut ts = self.timestamps.lock();
280
281        // Record matching event.
282        if let Some(evt) = event
283            && (self.filter)(evt)
284        {
285            ts.push_back(now);
286        }
287
288        // Expire old timestamps.
289        while let Some(&front) = ts.front() {
290            if now.duration_since(front) > self.window {
291                ts.pop_front();
292            } else {
293                break;
294            }
295        }
296
297        ts.len() as u32 >= self.count
298    }
299
300    fn reset(&self) {
301        self.timestamps.lock().clear();
302    }
303
304    // RateDetector does not need timer — it is event-driven.
305}
306
307// ── TurnCountDetector ─────────────────────────────────────────────────────────
308
309/// Fires when a state-based condition is true for `required` consecutive
310/// evaluations (typically one evaluation per turn).
311///
312/// The caller decides when to invoke `check()` — usually on `TurnComplete`
313/// events.
314pub struct TurnCountDetector {
315    condition: Arc<dyn Fn(&State) -> bool + Send + Sync>,
316    required: u32,
317    consecutive: AtomicU32,
318}
319
320impl TurnCountDetector {
321    /// Create a new turn-count detector.
322    ///
323    /// - `condition`: evaluated against the current state each turn.
324    /// - `required`: number of consecutive true results before firing.
325    pub fn new(condition: Arc<dyn Fn(&State) -> bool + Send + Sync>, required: u32) -> Self {
326        Self {
327            condition,
328            required,
329            consecutive: AtomicU32::new(0),
330        }
331    }
332}
333
334impl PatternDetector for TurnCountDetector {
335    fn check(&self, state: &State, _event: Option<&SessionEvent>, _now: Instant) -> bool {
336        if (self.condition)(state) {
337            let prev = self.consecutive.fetch_add(1, Ordering::SeqCst);
338            prev + 1 >= self.required
339        } else {
340            self.consecutive.store(0, Ordering::SeqCst);
341            false
342        }
343    }
344
345    fn reset(&self) {
346        self.consecutive.store(0, Ordering::SeqCst);
347    }
348}
349
350// ── ConsecutiveFailureDetector ────────────────────────────────────────────────
351
352/// Fires when a named tool has failed `threshold` consecutive times.
353///
354/// Uses a state-key convention: if `bg:{tool_name}_failed` is `true` the
355/// tool is considered to have failed; if `false` (or absent) the streak
356/// resets.
357pub struct ConsecutiveFailureDetector {
358    tool_name: String,
359    threshold: u32,
360    consecutive: AtomicU32,
361}
362
363impl ConsecutiveFailureDetector {
364    /// Create a new consecutive-failure detector.
365    ///
366    /// - `tool_name`: the tool whose failures are tracked.
367    /// - `threshold`: how many consecutive failures before firing.
368    pub fn new(tool_name: impl Into<String>, threshold: u32) -> Self {
369        Self {
370            tool_name: tool_name.into(),
371            threshold,
372            consecutive: AtomicU32::new(0),
373        }
374    }
375}
376
377impl PatternDetector for ConsecutiveFailureDetector {
378    fn check(&self, state: &State, _event: Option<&SessionEvent>, _now: Instant) -> bool {
379        let key = format!("bg:{}_failed", self.tool_name);
380        let failed: bool = state.get(&key).unwrap_or(false);
381
382        if failed {
383            let prev = self.consecutive.fetch_add(1, Ordering::SeqCst);
384            prev + 1 >= self.threshold
385        } else {
386            self.consecutive.store(0, Ordering::SeqCst);
387            false
388        }
389    }
390
391    fn reset(&self) {
392        self.consecutive.store(0, Ordering::SeqCst);
393    }
394}
395
396// ── Tests ─────────────────────────────────────────────────────────────────────
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use std::sync::atomic::{AtomicU32, Ordering};
402
403    /// Dummy SessionWriter for tests — all methods return Ok.
404    struct MockWriter;
405
406    #[async_trait::async_trait]
407    impl SessionWriter for MockWriter {
408        async fn send_audio(
409            &self,
410            _: bytes::Bytes,
411        ) -> Result<(), gemini_genai_rs::session::SessionError> {
412            Ok(())
413        }
414        async fn send_text(&self, _: String) -> Result<(), gemini_genai_rs::session::SessionError> {
415            Ok(())
416        }
417        async fn send_tool_response(
418            &self,
419            _: Vec<gemini_genai_rs::protocol::FunctionResponse>,
420        ) -> Result<(), gemini_genai_rs::session::SessionError> {
421            Ok(())
422        }
423        async fn send_client_content(
424            &self,
425            _: Vec<gemini_genai_rs::protocol::Content>,
426            _: bool,
427        ) -> Result<(), gemini_genai_rs::session::SessionError> {
428            Ok(())
429        }
430        async fn send_video(
431            &self,
432            _: bytes::Bytes,
433        ) -> Result<(), gemini_genai_rs::session::SessionError> {
434            Ok(())
435        }
436        async fn update_instruction(
437            &self,
438            _: String,
439        ) -> Result<(), gemini_genai_rs::session::SessionError> {
440            Ok(())
441        }
442        async fn signal_activity_start(
443            &self,
444        ) -> Result<(), gemini_genai_rs::session::SessionError> {
445            Ok(())
446        }
447        async fn signal_activity_end(&self) -> Result<(), gemini_genai_rs::session::SessionError> {
448            Ok(())
449        }
450        async fn disconnect(&self) -> Result<(), gemini_genai_rs::session::SessionError> {
451            Ok(())
452        }
453    }
454
455    fn mock_writer() -> Arc<dyn SessionWriter> {
456        Arc::new(MockWriter)
457    }
458
459    /// Helper: action that increments a shared counter.
460    fn counting_action(counter: Arc<AtomicU32>) -> crate::live::SessionHook {
461        Arc::new(move |_state, _writer| {
462            let c = counter.clone();
463            Box::pin(async move {
464                c.fetch_add(1, Ordering::SeqCst);
465            })
466        })
467    }
468
469    // ── 1. SustainedDetector fires after duration elapses ─────────────────
470
471    #[test]
472    fn sustained_fires_after_duration() {
473        let state = State::new();
474        let _ = state.set("hot", true);
475
476        let detector = SustainedDetector::new(
477            Arc::new(|s: &State| s.get::<bool>("hot").unwrap_or(false)),
478            Duration::from_secs(5),
479        );
480
481        let t0 = Instant::now();
482
483        // First check: records the start time, does not fire yet.
484        assert!(!detector.check(&state, None, t0));
485
486        // 3 seconds later: not yet.
487        assert!(!detector.check(&state, None, t0 + Duration::from_secs(3)));
488
489        // 5 seconds later: fires.
490        assert!(detector.check(&state, None, t0 + Duration::from_secs(5)));
491
492        // Still fires on subsequent checks while condition holds.
493        assert!(detector.check(&state, None, t0 + Duration::from_secs(6)));
494    }
495
496    // ── 2. SustainedDetector resets when condition becomes false ───────────
497
498    #[test]
499    fn sustained_resets_on_false() {
500        let state = State::new();
501        let _ = state.set("hot", true);
502
503        let detector = SustainedDetector::new(
504            Arc::new(|s: &State| s.get::<bool>("hot").unwrap_or(false)),
505            Duration::from_secs(5),
506        );
507
508        let t0 = Instant::now();
509
510        // Start tracking.
511        assert!(!detector.check(&state, None, t0));
512
513        // Condition becomes false at t0+2s — resets internal timer.
514        let _ = state.set("hot", false);
515        assert!(!detector.check(&state, None, t0 + Duration::from_secs(2)));
516
517        // Condition becomes true again at t0+3s — starts fresh.
518        let _ = state.set("hot", true);
519        assert!(!detector.check(&state, None, t0 + Duration::from_secs(3)));
520
521        // t0+7s: only 4s since re-start at t0+3s — not enough.
522        assert!(!detector.check(&state, None, t0 + Duration::from_secs(7)));
523
524        // t0+8s: 5s since t0+3s — fires.
525        assert!(detector.check(&state, None, t0 + Duration::from_secs(8)));
526    }
527
528    // ── 3. SustainedDetector reset() clears state ─────────────────────────
529
530    #[test]
531    fn sustained_reset_clears_state() {
532        let state = State::new();
533        let _ = state.set("hot", true);
534
535        let detector = SustainedDetector::new(
536            Arc::new(|s: &State| s.get::<bool>("hot").unwrap_or(false)),
537            Duration::from_secs(5),
538        );
539
540        let t0 = Instant::now();
541
542        // Start tracking.
543        assert!(!detector.check(&state, None, t0));
544
545        // Explicit reset.
546        detector.reset();
547
548        // Must start tracking from scratch — 5s from the new check.
549        assert!(!detector.check(&state, None, t0 + Duration::from_secs(4)));
550        assert!(detector.check(&state, None, t0 + Duration::from_secs(9)));
551    }
552
553    // ── 4. RateDetector fires when count reached in window ────────────────
554
555    #[test]
556    fn rate_fires_when_count_reached() {
557        let state = State::new();
558        let detector = RateDetector::new(
559            Arc::new(|evt: &SessionEvent| matches!(evt, SessionEvent::TurnComplete)),
560            3,
561            Duration::from_secs(10),
562        );
563
564        let t0 = Instant::now();
565        let event = SessionEvent::TurnComplete;
566
567        assert!(!detector.check(&state, Some(&event), t0));
568        assert!(!detector.check(&state, Some(&event), t0 + Duration::from_secs(1)));
569        // Third event: fires.
570        assert!(detector.check(&state, Some(&event), t0 + Duration::from_secs(2)));
571    }
572
573    // ── 5. RateDetector does not fire when events outside window ──────────
574
575    #[test]
576    fn rate_does_not_fire_when_events_outside_window() {
577        let state = State::new();
578        let detector = RateDetector::new(
579            Arc::new(|evt: &SessionEvent| matches!(evt, SessionEvent::TurnComplete)),
580            3,
581            Duration::from_secs(5),
582        );
583
584        let t0 = Instant::now();
585        let event = SessionEvent::TurnComplete;
586
587        // Two events at t0.
588        assert!(!detector.check(&state, Some(&event), t0));
589        assert!(!detector.check(&state, Some(&event), t0 + Duration::from_secs(1)));
590
591        // Third event at t0+10s: first two have expired.
592        assert!(!detector.check(&state, Some(&event), t0 + Duration::from_secs(10)));
593    }
594
595    // ── 6. RateDetector with filter that rejects events ───────────────────
596
597    #[test]
598    fn rate_filter_rejects_events() {
599        let state = State::new();
600        let detector = RateDetector::new(
601            Arc::new(|evt: &SessionEvent| matches!(evt, SessionEvent::TurnComplete)),
602            2,
603            Duration::from_secs(10),
604        );
605
606        let t0 = Instant::now();
607
608        // These events don't match the filter.
609        let text_event = SessionEvent::TextDelta("hello".to_string());
610        assert!(!detector.check(&state, Some(&text_event), t0));
611        assert!(!detector.check(&state, Some(&text_event), t0 + Duration::from_secs(1)));
612        assert!(!detector.check(&state, Some(&text_event), t0 + Duration::from_secs(2)));
613
614        // Still at 0 matching events — no fire.
615        assert!(!detector.check(&state, None, t0 + Duration::from_secs(3)));
616    }
617
618    // ── 7. TurnCountDetector fires after N consecutive true ───────────────
619
620    #[test]
621    fn turn_count_fires_after_n_consecutive() {
622        let state = State::new();
623        let _ = state.set("confused", true);
624
625        let detector = TurnCountDetector::new(
626            Arc::new(|s: &State| s.get::<bool>("confused").unwrap_or(false)),
627            3,
628        );
629
630        let t0 = Instant::now();
631
632        assert!(!detector.check(&state, None, t0));
633        assert!(!detector.check(&state, None, t0));
634        // Third consecutive true: fires.
635        assert!(detector.check(&state, None, t0));
636    }
637
638    // ── 8. TurnCountDetector resets on false ──────────────────────────────
639
640    #[test]
641    fn turn_count_resets_on_false() {
642        let state = State::new();
643        let _ = state.set("confused", true);
644
645        let detector = TurnCountDetector::new(
646            Arc::new(|s: &State| s.get::<bool>("confused").unwrap_or(false)),
647            3,
648        );
649
650        let t0 = Instant::now();
651
652        assert!(!detector.check(&state, None, t0));
653        assert!(!detector.check(&state, None, t0));
654
655        // Condition becomes false — resets counter.
656        let _ = state.set("confused", false);
657        assert!(!detector.check(&state, None, t0));
658
659        // Start again — need 3 more consecutive trues.
660        let _ = state.set("confused", true);
661        assert!(!detector.check(&state, None, t0));
662        assert!(!detector.check(&state, None, t0));
663        assert!(detector.check(&state, None, t0));
664    }
665
666    // ── 9. ConsecutiveFailureDetector fires after threshold ───────────────
667
668    #[test]
669    fn consecutive_failure_fires_after_threshold() {
670        let state = State::new();
671        let _ = state.set("bg:search_failed", true);
672
673        let detector = ConsecutiveFailureDetector::new("search", 3);
674
675        let t0 = Instant::now();
676
677        assert!(!detector.check(&state, None, t0));
678        assert!(!detector.check(&state, None, t0));
679        // Third consecutive failure: fires.
680        assert!(detector.check(&state, None, t0));
681    }
682
683    // ── 10. ConsecutiveFailureDetector resets on success ──────────────────
684
685    #[test]
686    fn consecutive_failure_resets_on_success() {
687        let state = State::new();
688        let _ = state.set("bg:search_failed", true);
689
690        let detector = ConsecutiveFailureDetector::new("search", 3);
691
692        let t0 = Instant::now();
693
694        assert!(!detector.check(&state, None, t0));
695        assert!(!detector.check(&state, None, t0));
696
697        // Tool succeeds — reset.
698        let _ = state.set("bg:search_failed", false);
699        assert!(!detector.check(&state, None, t0));
700
701        // Must accumulate again from 0.
702        let _ = state.set("bg:search_failed", true);
703        assert!(!detector.check(&state, None, t0));
704        assert!(!detector.check(&state, None, t0));
705        assert!(detector.check(&state, None, t0));
706    }
707
708    // ── 11. TemporalPattern cooldown prevents rapid re-firing ─────────────
709
710    #[tokio::test]
711    async fn pattern_cooldown_prevents_rapid_refiring() {
712        let counter = Arc::new(AtomicU32::new(0));
713        let state = State::new();
714        let _ = state.set("active", true);
715        let writer = mock_writer();
716
717        let pattern = TemporalPattern::new(
718            "test-cooldown",
719            Box::new(SustainedDetector::new(
720                Arc::new(|s: &State| s.get::<bool>("active").unwrap_or(false)),
721                Duration::from_secs(0), // fires immediately once became_true_at is set
722            )),
723            counting_action(counter.clone()),
724            Some(Duration::from_secs(10)), // 10s cooldown
725        );
726
727        let t0 = Instant::now();
728
729        // First check: sets became_true_at but doesn't fire (duration=0, but
730        // the first check just records the start time).
731        assert!(pattern.try_fire(&state, None, &writer, t0).is_none());
732
733        // Second check: fires (condition true + duration=0 elapsed).
734        let fut = pattern.try_fire(&state, None, &writer, t0 + Duration::from_millis(1));
735        assert!(fut.is_some());
736        fut.unwrap().await;
737        assert_eq!(counter.load(Ordering::SeqCst), 1);
738
739        // Immediate re-check: cooldown blocks.
740        assert!(
741            pattern
742                .try_fire(&state, None, &writer, t0 + Duration::from_millis(2))
743                .is_none()
744        );
745
746        // After cooldown: fires again.
747        let fut = pattern.try_fire(&state, None, &writer, t0 + Duration::from_secs(11));
748        assert!(fut.is_some());
749        fut.unwrap().await;
750        assert_eq!(counter.load(Ordering::SeqCst), 2);
751    }
752
753    // ── 12. TemporalRegistry check_all returns actions ────────────────────
754
755    #[tokio::test]
756    async fn registry_check_all_returns_actions() {
757        let counter = Arc::new(AtomicU32::new(0));
758        let state = State::new();
759        let _ = state.set("confused", true);
760        let writer = mock_writer();
761
762        let mut registry = TemporalRegistry::new();
763
764        // TurnCountDetector with required=1 — fires on first true check.
765        registry.register(TemporalPattern::new(
766            "confusion",
767            Box::new(TurnCountDetector::new(
768                Arc::new(|s: &State| s.get::<bool>("confused").unwrap_or(false)),
769                1,
770            )),
771            counting_action(counter.clone()),
772            None,
773        ));
774
775        let actions = registry.check_all(&state, None, &writer);
776        assert_eq!(actions.len(), 1);
777
778        for fut in actions {
779            fut.await;
780        }
781        assert_eq!(counter.load(Ordering::SeqCst), 1);
782    }
783
784    /// A sustained pattern measures its duration on the state's clock, so a
785    /// manual clock decides when it fires, not the wall clock.
786    #[tokio::test]
787    async fn registry_reads_time_from_the_state_clock() {
788        let counter = Arc::new(AtomicU32::new(0));
789        let clock = Arc::new(crate::clock::ManualClock::new());
790        let state = State::new().with_clock(clock.clone());
791        let writer = mock_writer();
792
793        let mut registry = TemporalRegistry::new();
794        registry.register(TemporalPattern::new(
795            "sustained",
796            Box::new(SustainedDetector::new(
797                Arc::new(|_: &State| true),
798                Duration::from_secs(5),
799            )),
800            counting_action(counter.clone()),
801            None,
802        ));
803
804        assert!(registry.check_all(&state, None, &writer).is_empty());
805        clock.advance(Duration::from_secs(4));
806        assert!(registry.check_all(&state, None, &writer).is_empty());
807        clock.advance(Duration::from_secs(1));
808        let fired = registry.check_all(&state, None, &writer);
809        assert_eq!(fired.len(), 1, "fires once five clock seconds have passed");
810        for fut in fired {
811            fut.await;
812        }
813        assert_eq!(counter.load(Ordering::SeqCst), 1);
814    }
815
816    // ── 13. needs_timer returns true when SustainedDetector is registered ─
817
818    #[test]
819    fn needs_timer_true_with_sustained_detector() {
820        let counter = Arc::new(AtomicU32::new(0));
821        let mut registry = TemporalRegistry::new();
822
823        registry.register(TemporalPattern::new(
824            "sustained",
825            Box::new(SustainedDetector::new(
826                Arc::new(|_: &State| true),
827                Duration::from_secs(5),
828            )),
829            counting_action(counter),
830            None,
831        ));
832
833        assert!(registry.needs_timer());
834    }
835
836    // ── 14. needs_timer returns false when no SustainedDetector ───────────
837
838    #[test]
839    fn needs_timer_false_without_sustained_detector() {
840        let counter = Arc::new(AtomicU32::new(0));
841        let mut registry = TemporalRegistry::new();
842
843        registry.register(TemporalPattern::new(
844            "turn-count",
845            Box::new(TurnCountDetector::new(Arc::new(|_: &State| true), 3)),
846            counting_action(counter.clone()),
847            None,
848        ));
849
850        registry.register(TemporalPattern::new(
851            "rate",
852            Box::new(RateDetector::new(
853                Arc::new(|_: &SessionEvent| true),
854                5,
855                Duration::from_secs(10),
856            )),
857            counting_action(counter),
858            None,
859        ));
860
861        assert!(!registry.needs_timer());
862    }
863
864    // ── Additional: Default creates empty registry ────────────────────────
865
866    #[test]
867    fn default_creates_empty_registry() {
868        let registry = TemporalRegistry::default();
869        assert!(!registry.needs_timer());
870    }
871
872    // ── Additional: RateDetector reset clears timestamps ──────────────────
873
874    #[test]
875    fn rate_reset_clears_timestamps() {
876        let state = State::new();
877        let detector = RateDetector::new(
878            Arc::new(|evt: &SessionEvent| matches!(evt, SessionEvent::TurnComplete)),
879            2,
880            Duration::from_secs(10),
881        );
882
883        let t0 = Instant::now();
884        let event = SessionEvent::TurnComplete;
885
886        assert!(!detector.check(&state, Some(&event), t0));
887        detector.reset();
888        // After reset, first event should not be enough.
889        assert!(!detector.check(&state, Some(&event), t0 + Duration::from_secs(1)));
890        // Second event after reset fires.
891        assert!(detector.check(&state, Some(&event), t0 + Duration::from_secs(2)));
892    }
893
894    // ── Additional: TurnCountDetector reset clears counter ────────────────
895
896    #[test]
897    fn turn_count_reset_clears_counter() {
898        let state = State::new();
899        let _ = state.set("confused", true);
900
901        let detector = TurnCountDetector::new(
902            Arc::new(|s: &State| s.get::<bool>("confused").unwrap_or(false)),
903            3,
904        );
905
906        let t0 = Instant::now();
907
908        assert!(!detector.check(&state, None, t0));
909        assert!(!detector.check(&state, None, t0));
910        // Two consecutive trues accumulated.
911
912        detector.reset();
913
914        // After reset, need 3 more.
915        assert!(!detector.check(&state, None, t0));
916        assert!(!detector.check(&state, None, t0));
917        assert!(detector.check(&state, None, t0));
918    }
919
920    // ── Additional: ConsecutiveFailureDetector reset clears counter ───────
921
922    #[test]
923    fn consecutive_failure_reset_clears_counter() {
924        let state = State::new();
925        let _ = state.set("bg:search_failed", true);
926
927        let detector = ConsecutiveFailureDetector::new("search", 3);
928        let t0 = Instant::now();
929
930        assert!(!detector.check(&state, None, t0));
931        assert!(!detector.check(&state, None, t0));
932
933        detector.reset();
934
935        assert!(!detector.check(&state, None, t0));
936        assert!(!detector.check(&state, None, t0));
937        assert!(detector.check(&state, None, t0));
938    }
939
940    // ── Additional: SustainedDetector needs_timer is true ─────────────────
941
942    #[test]
943    fn sustained_detector_needs_timer() {
944        let detector = SustainedDetector::new(Arc::new(|_: &State| true), Duration::from_secs(5));
945        assert!(detector.needs_timer());
946    }
947
948    // ── Additional: RateDetector needs_timer is false ─────────────────────
949
950    #[test]
951    fn rate_detector_does_not_need_timer() {
952        let detector = RateDetector::new(
953            Arc::new(|_: &SessionEvent| true),
954            5,
955            Duration::from_secs(10),
956        );
957        assert!(!detector.needs_timer());
958    }
959
960    // ── Additional: TurnCountDetector needs_timer is false ────────────────
961
962    #[test]
963    fn turn_count_detector_does_not_need_timer() {
964        let detector = TurnCountDetector::new(Arc::new(|_: &State| true), 3);
965        assert!(!detector.needs_timer());
966    }
967
968    // ── Additional: Pattern without cooldown fires every time ─────────────
969
970    #[tokio::test]
971    async fn pattern_without_cooldown_fires_every_time() {
972        let counter = Arc::new(AtomicU32::new(0));
973        let state = State::new();
974        let _ = state.set("active", true);
975        let writer = mock_writer();
976
977        let pattern = TemporalPattern::new(
978            "no-cooldown",
979            Box::new(TurnCountDetector::new(
980                Arc::new(|s: &State| s.get::<bool>("active").unwrap_or(false)),
981                1,
982            )),
983            counting_action(counter.clone()),
984            None, // no cooldown
985        );
986
987        let t0 = Instant::now();
988
989        for i in 0..5u32 {
990            let fut = pattern.try_fire(&state, None, &writer, t0 + Duration::from_millis(i as u64));
991            assert!(fut.is_some(), "should fire on iteration {i}");
992            fut.unwrap().await;
993        }
994
995        assert_eq!(counter.load(Ordering::SeqCst), 5);
996    }
997}