Skip to main content

cel_context/
watchdog.rs

1//! Context Watchdog — detects changes between context snapshots.
2//!
3//! The watchdog compares consecutive ScreenContext snapshots and emits
4//! CelEvents when something changes. This replaces polling in the MCP
5//! observe tool with a more efficient diff-based approach.
6
7use crate::element::ScreenContext;
8use crate::events::CelEvent;
9use std::collections::HashSet;
10
11/// Watches for context changes by diffing consecutive snapshots.
12pub struct ContextWatchdog {
13    /// Element IDs from the last snapshot.
14    last_element_ids: HashSet<String>,
15    /// ID of the focused element from the last snapshot.
16    last_focused_id: Option<String>,
17    /// Whether the last network state was idle.
18    last_network_idle: bool,
19    /// Whether we have a baseline (first tick initializes state, doesn't emit events).
20    initialized: bool,
21}
22
23impl Default for ContextWatchdog {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29impl ContextWatchdog {
30    pub fn new() -> Self {
31        Self {
32            last_element_ids: HashSet::new(),
33            last_focused_id: None,
34            last_network_idle: true,
35            initialized: false,
36        }
37    }
38
39    /// Compare the current context against the previous snapshot and emit events.
40    ///
41    /// `context`: The current screen context.
42    /// `network_idle`: Whether the network monitor reports idle state.
43    ///
44    /// Returns a list of events that occurred since the last tick.
45    pub fn tick(&mut self, context: &ScreenContext, network_idle: bool) -> Vec<CelEvent> {
46        let current_ids: HashSet<String> = context.elements.iter().map(|e| e.id.clone()).collect();
47
48        let current_focused = context
49            .elements
50            .iter()
51            .find(|e| e.state.focused)
52            .map(|e| e.id.clone());
53
54        // First tick: just initialize state, don't emit events
55        if !self.initialized {
56            self.last_element_ids = current_ids;
57            self.last_focused_id = current_focused;
58            self.last_network_idle = network_idle;
59            self.initialized = true;
60            return vec![];
61        }
62
63        let mut events = Vec::new();
64
65        // Tree diff: detect added/removed elements
66        let added: Vec<String> = current_ids
67            .difference(&self.last_element_ids)
68            .cloned()
69            .collect();
70        let removed: Vec<String> = self
71            .last_element_ids
72            .difference(&current_ids)
73            .cloned()
74            .collect();
75        if !added.is_empty() || !removed.is_empty() {
76            events.push(CelEvent::TreeChanged { added, removed });
77        }
78
79        // Focus change — polling fallback. Even when AXObserver is active, we keep
80        // this as a safety net. AXObserver events are merged separately via merge_ax_events(),
81        // and deduplication happens naturally (same focus = no event from either source).
82        if current_focused != self.last_focused_id {
83            events.push(CelEvent::FocusChanged {
84                old: self.last_focused_id.clone(),
85                new: current_focused.clone(),
86            });
87        }
88
89        // Network idle transition (was busy → now idle)
90        if network_idle && !self.last_network_idle {
91            events.push(CelEvent::NetworkIdle);
92        }
93
94        // Update state for next tick
95        self.last_element_ids = current_ids;
96        self.last_focused_id = current_focused;
97        self.last_network_idle = network_idle;
98
99        events
100    }
101
102    /// Merge source-specific push events that have already been normalized to CEL events.
103    pub fn merge_events(&self, events: Vec<CelEvent>) -> Vec<CelEvent> {
104        events
105    }
106
107    /// Compatibility shim for runtimes that still receive source-specific
108    /// accessibility events. Convert those events before calling `merge_events`
109    /// if push-event fidelity matters.
110    pub fn merge_ax_events<T>(&self, _events: Vec<T>) -> Vec<CelEvent> {
111        Vec::new()
112    }
113
114    /// Reset the watchdog state (e.g., when restarting monitoring).
115    pub fn reset(&mut self) {
116        self.last_element_ids.clear();
117        self.last_focused_id = None;
118        self.last_network_idle = true;
119        self.initialized = false;
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::element::{Bounds, ContentRole, ContextElement, ContextSource, ElementState};
127
128    fn make_element(id: &str, focused: bool) -> ContextElement {
129        ContextElement {
130            id: id.to_string(),
131            label: Some(format!("Element {}", id)),
132            description: None,
133            element_type: "button".to_string(),
134            value: None,
135            bounds: Some(Bounds {
136                x: 0,
137                y: 0,
138                width: 100,
139                height: 30,
140            }),
141            state: ElementState {
142                focused,
143                enabled: true,
144                visible: true,
145                selected: false,
146                expanded: None,
147                checked: None,
148            },
149            parent_id: None,
150            actions: vec![],
151            properties: std::collections::HashMap::new(),
152            confidence: 0.85,
153            source: ContextSource::AccessibilityTree,
154            content_role: ContentRole::default(),
155        }
156    }
157
158    fn make_context(elements: Vec<ContextElement>) -> ScreenContext {
159        ScreenContext {
160            app: "Test".into(),
161            window: "Test Window".into(),
162            elements,
163            network_events: vec![],
164            http_events: vec![],
165            timestamp_ms: 0,
166            screen_width: None,
167            screen_height: None,
168            clipboard: None,
169            window_list: vec![],
170            audio: None,
171            power: None,
172            running_apps: vec![],
173            recent_files: vec![],
174            transcripts: vec![],
175        }
176    }
177
178    #[test]
179    fn test_first_tick_no_events() {
180        let mut wd = ContextWatchdog::new();
181        let ctx = make_context(vec![make_element("a", true)]);
182        let events = wd.tick(&ctx, true);
183        assert!(events.is_empty(), "First tick should not emit events");
184    }
185
186    #[test]
187    fn test_no_change_no_events() {
188        let mut wd = ContextWatchdog::new();
189        let ctx = make_context(vec![make_element("a", true)]);
190        wd.tick(&ctx, true); // init
191        let events = wd.tick(&ctx, true);
192        assert!(events.is_empty());
193    }
194
195    #[test]
196    fn test_element_added() {
197        let mut wd = ContextWatchdog::new();
198        let ctx1 = make_context(vec![make_element("a", true)]);
199        wd.tick(&ctx1, true);
200
201        let ctx2 = make_context(vec![make_element("a", true), make_element("b", false)]);
202        let events = wd.tick(&ctx2, true);
203        assert_eq!(events.len(), 1);
204        match &events[0] {
205            CelEvent::TreeChanged { added, removed } => {
206                assert_eq!(added, &["b"]);
207                assert!(removed.is_empty());
208            }
209            _ => panic!("Expected TreeChanged"),
210        }
211    }
212
213    #[test]
214    fn test_element_removed() {
215        let mut wd = ContextWatchdog::new();
216        let ctx1 = make_context(vec![make_element("a", true), make_element("b", false)]);
217        wd.tick(&ctx1, true);
218
219        let ctx2 = make_context(vec![make_element("a", true)]);
220        let events = wd.tick(&ctx2, true);
221        assert_eq!(events.len(), 1);
222        match &events[0] {
223            CelEvent::TreeChanged { added, removed } => {
224                assert!(added.is_empty());
225                assert_eq!(removed, &["b"]);
226            }
227            _ => panic!("Expected TreeChanged"),
228        }
229    }
230
231    #[test]
232    fn test_focus_changed() {
233        let mut wd = ContextWatchdog::new();
234        let ctx1 = make_context(vec![make_element("a", true), make_element("b", false)]);
235        wd.tick(&ctx1, true);
236
237        let ctx2 = make_context(vec![make_element("a", false), make_element("b", true)]);
238        let events = wd.tick(&ctx2, true);
239        assert!(events
240            .iter()
241            .any(|e| matches!(e, CelEvent::FocusChanged { .. })));
242    }
243
244    #[test]
245    fn test_network_idle_transition() {
246        let mut wd = ContextWatchdog::new();
247        let ctx = make_context(vec![make_element("a", true)]);
248        wd.tick(&ctx, false); // init: not idle
249
250        let events = wd.tick(&ctx, true); // now idle
251        assert!(events.iter().any(|e| matches!(e, CelEvent::NetworkIdle)));
252    }
253
254    #[test]
255    fn test_network_stays_idle_no_event() {
256        let mut wd = ContextWatchdog::new();
257        let ctx = make_context(vec![make_element("a", true)]);
258        wd.tick(&ctx, true); // init: idle
259        let events = wd.tick(&ctx, true); // still idle
260        assert!(!events.iter().any(|e| matches!(e, CelEvent::NetworkIdle)));
261    }
262
263    #[test]
264    fn test_reset() {
265        let mut wd = ContextWatchdog::new();
266        let ctx = make_context(vec![make_element("a", true)]);
267        wd.tick(&ctx, true);
268        wd.reset();
269        // After reset, next tick is initialization again
270        let events = wd.tick(&ctx, true);
271        assert!(events.is_empty());
272    }
273}