Skip to main content

heartbit_core/browser/
verify.rs

1//! Post-action verification via state-diff (spec capability 5).
2//!
3//! "An Illusion of Progress" (COLM'25) found the #1 web-agent failure *class* is
4//! incomplete steps — the agent fills a form but never confirms the Submit
5//! landed, then reports success. Agent-E's "change observation" is the fix:
6//! after every mutating action, re-observe and confirm the page actually changed
7//! as intended; if nothing changed, the action was a no-op and the loop should
8//! retry or replan instead of marching on.
9//!
10//! This module reduces a snapshot to a cheap [`SnapshotSignature`] and
11//! [`diff`]s before/after signatures into an [`ActionEffect`]. Pure functions
12//! over snapshot text — no browser/MCP/LLM — so fully unit-testable.
13
14use std::collections::{BTreeMap, BTreeSet};
15
16use super::distill::interactive_uids;
17
18/// A cheap fingerprint of page state, captured before and after an action.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct SnapshotSignature {
21    /// Current page URL.
22    pub url: String,
23    /// The page title / RootWebArea accessible name, if present.
24    pub title: Option<String>,
25    /// Set of interactable element handles present in the snapshot.
26    pub interactable_uids: BTreeSet<String>,
27    /// Per-`uid` content fingerprint covering everything after the `uid=N_M`
28    /// token (role, accessible name, `value=`, and other attributes). This is
29    /// what catches in-place changes such as a field whose `value="..."` was
30    /// set, or an element relabeled while keeping its `uid` — changes that leave
31    /// the URL, the title, and the interactable-uid set all untouched. Omitting
32    /// it caused the top "Illusion of Progress / Incomplete Steps" false-`NoOp`:
33    /// a successful `fill` reported as a no-op.
34    pub content_digest: BTreeMap<String, String>,
35    /// Count of console error lines observed (a negative signal).
36    pub console_errors: usize,
37}
38
39/// The observed effect of an action, derived from a before/after [`diff`].
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ActionEffect {
42    /// The page changed; the string summarizes what (for the loop / logs).
43    Changed(String),
44    /// Nothing observable changed — the action was a no-op (likely a miss).
45    NoOp,
46}
47
48impl ActionEffect {
49    /// Whether this effect indicates real progress (a change occurred).
50    pub fn is_progress(&self) -> bool {
51        matches!(self, ActionEffect::Changed(_))
52    }
53
54    /// A structured hint to append to a no-op tool result so the agent loop
55    /// re-grounds / replans instead of trusting an unverified "done".
56    pub fn hint(&self) -> Option<&'static str> {
57        match self {
58            ActionEffect::NoOp => Some(
59                "[browser] observed change: NO — the page did not change after this \
60                 action. The element may be wrong or the action had no effect. \
61                 Re-snapshot, re-check the target, and try a different approach.",
62            ),
63            ActionEffect::Changed(_) => None,
64        }
65    }
66}
67
68/// The accessible name on the `RootWebArea` line, used as the page title.
69fn root_title(snapshot: &str) -> Option<String> {
70    snapshot.lines().find_map(|l| {
71        let t = l.trim_start();
72        if t.contains("RootWebArea") {
73            let start = t.find('"')?;
74            let rest = &t[start + 1..];
75            let end = rest.find('"')?;
76            Some(rest[..end].to_string())
77        } else {
78            None
79        }
80    })
81}
82
83/// Split a snapshot line into `(uid, content)` where `content` is everything
84/// after the `uid=N_M` token (role + quoted name + `value=`/`url=`/other attrs),
85/// trimmed. Lines without a `uid=` token are ignored (returns `None`).
86fn uid_and_content(line: &str) -> Option<(String, String)> {
87    let body = line.trim_start();
88    let rest = body.strip_prefix("uid=")?;
89    let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
90    let uid = &rest[..end];
91    let content = rest[end..].trim();
92    Some((uid.to_string(), content.to_string()))
93}
94
95/// Build a [`SnapshotSignature`] from a snapshot, the page URL, and console
96/// lines. `console` lines containing "error" (case-insensitive) are counted.
97pub fn signature(snapshot: &str, url: &str, console: &[String]) -> SnapshotSignature {
98    SnapshotSignature {
99        url: url.to_string(),
100        title: root_title(snapshot),
101        interactable_uids: interactive_uids(snapshot).into_iter().collect(),
102        content_digest: snapshot.lines().filter_map(uid_and_content).collect(),
103        console_errors: console
104            .iter()
105            .filter(|l| l.to_lowercase().contains("error"))
106            .count(),
107    }
108}
109
110/// Diff two signatures into an [`ActionEffect`]. Any of: URL change, title
111/// change, a changed set of interactable handles, or new console errors counts
112/// as a change. Identical signatures are a [`ActionEffect::NoOp`].
113pub fn diff(before: &SnapshotSignature, after: &SnapshotSignature) -> ActionEffect {
114    let mut reasons = Vec::new();
115
116    if before.url != after.url {
117        reasons.push(format!("url {} -> {}", before.url, after.url));
118    }
119    if before.title != after.title {
120        reasons.push(format!("title {:?} -> {:?}", before.title, after.title));
121    }
122    if before.interactable_uids != after.interactable_uids {
123        let added = after
124            .interactable_uids
125            .difference(&before.interactable_uids)
126            .count();
127        let removed = before
128            .interactable_uids
129            .difference(&after.interactable_uids)
130            .count();
131        reasons.push(format!("elements +{added}/-{removed}"));
132    }
133    // In-place content change: same `uid`, different role/name/value/attrs — a set
134    // `value="..."` or a same-uid relabel (Play->Pause). URL/title/uid-set all miss
135    // these; this is the fix for the #1 false-`NoOp` (Illusion-of-Progress) class.
136    let changed = after
137        .content_digest
138        .iter()
139        .filter(|(uid, content)| {
140            before
141                .content_digest
142                .get(*uid)
143                .is_some_and(|prev| prev != *content)
144        })
145        .count();
146    if changed > 0 {
147        reasons.push(format!("content changed on {changed} element(s)"));
148    }
149    // B2: added/removed content nodes — including non-interactive ones
150    // (StaticText, alert) that `interactable_uids` does not track. A failed
151    // login adding `StaticText "Invalid password"`, or a successful submit
152    // adding a non-interactive success banner, is a real change that URL,
153    // title, and the interactable-uid set all miss → false `NoOp` otherwise.
154    let content_added = after
155        .content_digest
156        .keys()
157        .filter(|uid| !before.content_digest.contains_key(uid.as_str()))
158        .count();
159    let content_removed = before
160        .content_digest
161        .keys()
162        .filter(|uid| !after.content_digest.contains_key(uid.as_str()))
163        .count();
164    if content_added > 0 || content_removed > 0 {
165        reasons.push(format!("content nodes +{content_added}/-{content_removed}"));
166    }
167    if after.console_errors > before.console_errors {
168        reasons.push(format!(
169            "console errors +{}",
170            after.console_errors - before.console_errors
171        ));
172    }
173
174    if reasons.is_empty() {
175        ActionEffect::NoOp
176    } else {
177        ActionEffect::Changed(reasons.join("; "))
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    const SNAP_A: &str = r#"uid=1_0 RootWebArea "Login" url="https://app.test/login"
186  uid=1_1 textbox "Email"
187  uid=1_2 textbox "Password"
188  uid=1_3 button "Sign in""#;
189
190    // Same page, but after a successful sign-in: new URL, new title, new elements.
191    const SNAP_B: &str = r#"uid=2_0 RootWebArea "Dashboard" url="https://app.test/home"
192  uid=2_1 link "Profile"
193  uid=2_2 button "Log out""#;
194
195    #[test]
196    fn signature_extracts_title_url_uids_and_errors() {
197        let sig = signature(SNAP_A, "https://app.test/login", &["console.log ok".into()]);
198        assert_eq!(sig.url, "https://app.test/login");
199        assert_eq!(sig.title.as_deref(), Some("Login"));
200        assert_eq!(
201            sig.interactable_uids,
202            ["1_1", "1_2", "1_3"]
203                .iter()
204                .map(|s| s.to_string())
205                .collect()
206        );
207        assert_eq!(sig.console_errors, 0);
208    }
209
210    #[test]
211    fn signature_counts_console_errors() {
212        let console = vec![
213            "Uncaught TypeError: x is not a function".into(),
214            "info: loaded".into(),
215            "ERROR: network failed".into(),
216        ];
217        let sig = signature(SNAP_A, "u", &console);
218        assert_eq!(sig.console_errors, 2);
219    }
220
221    #[test]
222    fn diff_identical_is_noop() {
223        let a = signature(SNAP_A, "https://app.test/login", &[]);
224        let b = signature(SNAP_A, "https://app.test/login", &[]);
225        assert_eq!(diff(&a, &b), ActionEffect::NoOp);
226        assert!(!diff(&a, &b).is_progress());
227        assert!(diff(&a, &b).hint().is_some());
228    }
229
230    #[test]
231    fn diff_navigation_is_change() {
232        let a = signature(SNAP_A, "https://app.test/login", &[]);
233        let b = signature(SNAP_B, "https://app.test/home", &[]);
234        let effect = diff(&a, &b);
235        assert!(effect.is_progress(), "navigation must register as a change");
236        if let ActionEffect::Changed(summary) = effect {
237            assert!(summary.contains("url"), "summary: {summary}");
238            assert!(summary.contains("title"), "summary: {summary}");
239            assert!(summary.contains("elements"), "summary: {summary}");
240        } else {
241            panic!("expected Changed");
242        }
243        assert!(diff(&a, &b).hint().is_none());
244    }
245
246    #[test]
247    fn diff_same_url_new_element_is_change() {
248        // An inline validation error appearing (same URL/title, +1 element).
249        let before = signature(SNAP_A, "https://app.test/login", &[]);
250        let with_error = r#"uid=1_0 RootWebArea "Login" url="https://app.test/login"
251  uid=1_1 textbox "Email"
252  uid=1_2 textbox "Password"
253  uid=1_3 button "Sign in"
254  uid=1_9 textbox "2FA code""#;
255        let after = signature(with_error, "https://app.test/login", &[]);
256        let effect = diff(&before, &after);
257        assert!(effect.is_progress());
258        if let ActionEffect::Changed(s) = effect {
259            assert!(s.contains("elements +1"), "summary: {s}");
260        }
261    }
262
263    #[test]
264    fn diff_added_noninteractive_text_is_change() {
265        // B2: a failed login adds a non-interactive StaticText error banner with
266        // no URL/title/interactive-element change. URL, title, and the
267        // interactable-uid set all miss it; it must still register as a change,
268        // not a false NoOp ("Illusion of Progress" inverse).
269        let before = signature(SNAP_A, "https://app.test/login", &[]);
270        let with_banner = r#"uid=1_0 RootWebArea "Login" url="https://app.test/login"
271  uid=1_1 textbox "Email"
272  uid=1_2 textbox "Password"
273  uid=1_3 button "Sign in"
274  uid=1_9 StaticText "Invalid password""#;
275        let after = signature(with_banner, "https://app.test/login", &[]);
276        let effect = diff(&before, &after);
277        assert!(
278            effect.is_progress(),
279            "an added non-interactive error banner must register as a change, got {effect:?}"
280        );
281        if let ActionEffect::Changed(s) = effect {
282            assert!(s.contains("content nodes +1"), "summary: {s}");
283        }
284    }
285
286    #[test]
287    fn diff_new_console_error_is_change() {
288        let a = signature(SNAP_A, "u", &[]);
289        let b = signature(SNAP_A, "u", &["ERROR: submit failed".into()]);
290        assert_eq!(
291            diff(&a, &b),
292            ActionEffect::Changed("console errors +1".to_string())
293        );
294    }
295
296    // --- in-place mutation: the #1 "Illusion of Progress / Incomplete Steps"
297    // failure class. Live chrome-devtools-mcp evidence (2026-05-31) confirmed the
298    // textbox value IS in the snapshot as `value="..."` and that an element keeps
299    // its uid across same-document mutation — so url/title/uid-set/console are all
300    // unchanged when you fill a field or a label flips. The signature must still
301    // register these as a Change, or the agent reports a no-op step as success.
302
303    #[test]
304    fn diff_textbox_value_set_is_change() {
305        // Same page, same uids, only the typed-in value differs (fill succeeded).
306        let empty = r#"uid=4_0 RootWebArea "Form" url="https://app.test/f"
307  uid=4_1 textbox "Email"
308  uid=4_2 button "Submit""#;
309        let filled = r#"uid=4_0 RootWebArea "Form" url="https://app.test/f"
310  uid=4_1 textbox "Email" value="me@x.com"
311  uid=4_2 button "Submit""#;
312        let before = signature(empty, "https://app.test/f", &[]);
313        let after = signature(filled, "https://app.test/f", &[]);
314        assert!(
315            diff(&before, &after).is_progress(),
316            "typing into a textbox (value set) must register as progress, not NoOp"
317        );
318    }
319
320    #[test]
321    fn diff_same_uid_relabel_is_change() {
322        // A toggle button whose label flips Play->Pause but keeps its uid.
323        let play = r#"uid=4_0 RootWebArea "Player" url="https://app.test/p"
324  uid=4_1 button "Play""#;
325        let pause = r#"uid=4_0 RootWebArea "Player" url="https://app.test/p"
326  uid=4_1 button "Pause""#;
327        let before = signature(play, "https://app.test/p", &[]);
328        let after = signature(pause, "https://app.test/p", &[]);
329        assert!(
330            diff(&before, &after).is_progress(),
331            "a same-uid label change (Play->Pause) must register as progress"
332        );
333    }
334
335    #[test]
336    fn diff_true_noop_still_noop_with_digest() {
337        // Guard against over-sensitivity: identical snapshots (incl. values) must
338        // remain NoOp even though we now hash element content.
339        let snap = r#"uid=4_0 RootWebArea "Form" url="https://app.test/f"
340  uid=4_1 textbox "Email" value="me@x.com"
341  uid=4_2 button "Submit""#;
342        let a = signature(snap, "https://app.test/f", &[]);
343        let b = signature(snap, "https://app.test/f", &[]);
344        assert_eq!(
345            diff(&a, &b),
346            ActionEffect::NoOp,
347            "an unchanged page (same values) must stay NoOp"
348        );
349    }
350
351    #[test]
352    fn noop_hint_guides_replan() {
353        assert!(
354            ActionEffect::NoOp
355                .hint()
356                .unwrap()
357                .contains("observed change: NO")
358        );
359        assert!(ActionEffect::Changed("x".into()).hint().is_none());
360    }
361}