From cb0f244d415ced1a7f0d8bec36103ad899ae6d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Sun, 26 Jul 2026 15:53:17 +0200 Subject: [PATCH 1/2] =?UTF-8?q?web:=20browser=20session=20robustness=20?= =?UTF-8?q?=E2=80=94=20chords,=20fill/clear,=20semantic=20selectors,=20dia?= =?UTF-8?q?log=20discovery,=20text-light=20observe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - press_key/press_key_global accept modifier chords (Meta+A, Control+a, Shift+Tab) via raw CDP DispatchKeyEvent + modifier bitmask, fixing 'Key not found: Meta+a' (finding 1) - fill()/clear() replace or empty a field instead of appending (finding 2) - find() resolves text=/role=/aria= selectors, robust against re-rendered hashed ids (finding 6) - click/type/fill/clear/press scroll the target into view first (findings 8/9) - element discovery descends shadow roots, ranks dialog content first, and raises the cap 40->200 (findings 3/4/5) - observe_with(include_text) can suppress the innerText dump (finding 7) --- Cargo.lock | 1 + .../src/tool_renderers/sub_agent_renderer.rs | 3 +- crates/web/Cargo.toml | 1 + crates/web/src/browser_session.rs | 391 ++++++++++++++++-- crates/web/src/tests.rs | 165 ++++++++ 5 files changed, 532 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0e0e03f3..b5c55b89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11370,6 +11370,7 @@ dependencies = [ "anyhow", "async-trait", "axum", + "base64 0.22.1", "chromiumoxide", "futures", "htmd", diff --git a/crates/ui_terminal/src/tool_renderers/sub_agent_renderer.rs b/crates/ui_terminal/src/tool_renderers/sub_agent_renderer.rs index 2b95eaf3..fece9302 100644 --- a/crates/ui_terminal/src/tool_renderers/sub_agent_renderer.rs +++ b/crates/ui_terminal/src/tool_renderers/sub_agent_renderer.rs @@ -65,8 +65,7 @@ fn sub_agent_lines(tool_block: &ToolUseBlock) -> Vec> { ), ]; if let Some(instructions) = tool_block.parameters.get("instructions") { - let summary = - truncate_with_ellipsis(instructions.value.trim(), INSTRUCTIONS_SUMMARY_LEN); + let summary = truncate_with_ellipsis(instructions.value.trim(), INSTRUCTIONS_SUMMARY_LEN); if !summary.is_empty() { header.push(Span::styled( format!(": {summary}"), diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index d03623ec..920d5265 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -22,3 +22,4 @@ url = "2.5" [dev-dependencies] axum = "0.7" +base64 = "0.22" diff --git a/crates/web/src/browser_session.rs b/crates/web/src/browser_session.rs index 2573c185..22e00dee 100644 --- a/crates/web/src/browser_session.rs +++ b/crates/web/src/browser_session.rs @@ -13,9 +13,11 @@ use crate::browser::LaunchedBrowser; use anyhow::Result; +use chromiumoxide::cdp::browser_protocol::input::{DispatchKeyEventParams, DispatchKeyEventType}; use chromiumoxide::cdp::browser_protocol::network::{CookieParam, CookieSameSite, TimeSinceEpoch}; use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat; use chromiumoxide::element::Element; +use chromiumoxide::keys::get_key_definition; use chromiumoxide::layout::Point; use chromiumoxide::page::{Page, ScreenshotParams}; use std::collections::HashMap; @@ -27,10 +29,14 @@ use tokio::sync::Mutex as AsyncMutex; /// array of `{selector, role, label}`. Best-effort: it prefers `#id` selectors, /// falls back to an `:nth-of-type` path, skips hidden/disabled elements, and is /// bounded so a huge page can't blow up the observation. +/// +/// It also descends into open shadow roots and puts elements that live inside a +/// modal/dialog (or a fixed high-z-index overlay) first, so a dialog's buttons +/// are never dropped by the cap when the page behind it is long. const DISCOVER_ELEMENTS_JS: &str = r#" (() => { - const MAX = 40; - const SEL = 'a,button,input,textarea,select,summary,[role=button],[role=link],[role=checkbox],[role=tab],[role=menuitem],[onclick],[tabindex]'; + const MAX = 200; + const SEL = 'a,button,input,textarea,select,summary,[role=button],[role=link],[role=checkbox],[role=tab],[role=menuitem],[role=menuitemcheckbox],[role=menuitemradio],[role=switch],[role=option],[contenteditable=true],[onclick],[tabindex]'; const seen = new Set(); const out = []; @@ -53,14 +59,14 @@ const DISCOVER_ELEMENTS_JS: &str = r#" let sel = node.tagName.toLowerCase(); if (node.id) { parts.unshift('#' + CSS.escape(node.id)); break; } const parent = node.parentNode; - if (parent) { + if (parent && parent.children) { const sameTag = Array.from(parent.children).filter(c => c.tagName === node.tagName); if (sameTag.length > 1) { sel += ':nth-of-type(' + (sameTag.indexOf(node) + 1) + ')'; } } parts.unshift(sel); - node = node.parentNode; + node = node.parentNode && node.parentNode.host ? node.parentNode.host : node.parentNode; } return parts.join(' > '); }; @@ -85,7 +91,42 @@ const DISCOVER_ELEMENTS_JS: &str = r#" return l.slice(0, 80); }; - for (const el of document.querySelectorAll(SEL)) { + // Rank: elements inside a dialog / high-z fixed overlay first, so the + // topmost interactive surface is never truncated away. + const inDialog = (el) => { + let node = el; + while (node && node.nodeType === 1) { + const role = node.getAttribute && node.getAttribute('role'); + if (node.tagName === 'DIALOG' || role === 'dialog' || role === 'alertdialog' || node.getAttribute && node.getAttribute('aria-modal') === 'true') { + return true; + } + if (node.parentNode && node.parentNode.host) { node = node.parentNode.host; continue; } + node = node.parentNode; + } + return false; + }; + + // Gather across the main document and any open shadow roots. + const collect = (root, acc) => { + let nodes = []; + try { nodes = Array.from(root.querySelectorAll(SEL)); } catch (e) {} + for (const el of nodes) acc.push(el); + let all = []; + try { all = Array.from(root.querySelectorAll('*')); } catch (e) {} + for (const el of all) { + if (el.shadowRoot) collect(el.shadowRoot, acc); + } + }; + + const candidates = []; + collect(document, candidates); + + // Stable sort: dialog elements first, keeping document order otherwise. + const ranked = candidates + .map((el, i) => ({ el, i, dlg: inDialog(el) ? 0 : 1 })) + .sort((a, b) => (a.dlg - b.dlg) || (a.i - b.i)); + + for (const { el } of ranked) { if (out.length >= MAX) break; if (!visible(el)) continue; const selector = cssPath(el); @@ -227,14 +268,25 @@ impl BrowserSession { /// Read the current location, title, visible text, and the actionable /// elements on the page. pub async fn observe(&self) -> Result { + self.observe_with(true).await + } + + /// Like [`observe`](Self::observe), but `include_text` can suppress the + /// (often large and redundant) `innerText` dump — the model keeps the + /// screenshot plus the interactive-element list, and avoids re-reading a + /// long form's text on every step. + pub async fn observe_with(&self, include_text: bool) -> Result { let url = self.page.url().await?.unwrap_or_default(); let title = self.page.get_title().await?.unwrap_or_default(); - let text = self - .page - .evaluate("document.body ? document.body.innerText : ''") - .await? - .into_value::() - .unwrap_or_default(); + let text = if include_text { + self.page + .evaluate("document.body ? document.body.innerText : ''") + .await? + .into_value::() + .unwrap_or_default() + } else { + String::new() + }; // Element discovery is best-effort: a failure (e.g. mid-navigation) // just yields an empty list rather than failing the observation. let elements = match self.page.evaluate(DISCOVER_ELEMENTS_JS).await { @@ -267,18 +319,119 @@ impl BrowserSession { Ok(dims) } - /// Find an element, turning chromiumoxide's opaque CDP miss ("Could not - /// find node with given id") into a message that names the selector. + /// Find an element by a selector, turning chromiumoxide's opaque CDP miss + /// ("Could not find node with given id") into a message that names the + /// selector. + /// + /// Besides plain CSS, this understands robust prefixes that survive a site + /// re-rendering with fresh hashed ids (a real problem on portals like + /// ELSTER): + /// - `text=Foo` — the first visible element whose trimmed text / + /// aria-label / value contains `Foo` (case-insensitive). + /// - `role=button` — the first element with that ARIA role (or, for a bare + /// tag, that tag). `role=button[name=Save]` also matches on text. + /// - `aria=Save` — the first element whose aria-label matches. + /// + /// These are resolved to a concrete node in the page, so a fragile hashed + /// `#id` is never needed. async fn find(&self, selector: &str) -> Result { + if let Some(css) = self.resolve_semantic_selector(selector).await? { + return self + .page + .find_element(&css) + .await + .map_err(|_| anyhow::anyhow!("no element matches selector '{selector}'")); + } self.page .find_element(selector) .await .map_err(|_| anyhow::anyhow!("no element matches selector '{selector}'")) } - /// Click the first element matching a CSS selector. + /// If `selector` uses a semantic prefix (`text=`, `role=`, `aria=`), locate + /// the matching element in the page and stamp it with a unique data + /// attribute, returning a concrete CSS selector for it. Returns `Ok(None)` + /// for a plain CSS selector (handled directly by the caller). + async fn resolve_semantic_selector(&self, selector: &str) -> Result> { + let sel = selector.trim(); + let (kind, query) = if let Some(q) = sel.strip_prefix("text=") { + ("text", q) + } else if let Some(q) = sel.strip_prefix("role=") { + ("role", q) + } else if let Some(q) = sel.strip_prefix("aria=") { + ("aria", q) + } else { + return Ok(None); + }; + let kind_json = serde_json::to_string(kind)?; + let query_json = serde_json::to_string(query.trim())?; + // Tag the match with a unique attribute so we can hand back a stable CSS + // selector even on a page that mints fresh ids every render. + let js = format!( + r#"(() => {{ + const kind = {kind_json}; + const q = {query_json}; + const SEL = 'a,button,input,textarea,select,summary,[role],[onclick],[tabindex],label'; + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase(); + const visible = (el) => {{ + const r = el.getClientRects(); + if (!r.length) return false; + const st = getComputedStyle(el); + return st.visibility !== 'hidden' && st.display !== 'none'; + }}; + const labelText = (el) => norm(el.getAttribute('aria-label')) || norm(el.textContent) || norm(el.value) || norm(el.getAttribute('placeholder')) || norm(el.title); + const roleOf = (el) => (el.getAttribute('role') || el.tagName.toLowerCase()); + // role=button[name=Save] → role + optional name filter. + let wantRole = q, wantName = null; + const m = q.match(/^([^\[]+)\[name=(.+)\]$/); + if (kind === 'role' && m) {{ wantRole = m[1].trim(); wantName = norm(m[2]); }} + const needle = norm(q); + const collect = (root, acc) => {{ + let nodes = []; + try {{ nodes = Array.from(root.querySelectorAll(SEL)); }} catch (e) {{}} + for (const el of nodes) acc.push(el); + let all = []; + try {{ all = Array.from(root.querySelectorAll('*')); }} catch (e) {{}} + for (const el of all) if (el.shadowRoot) collect(el.shadowRoot, acc); + }}; + const cands = []; + collect(document, cands); + const match = cands.find((el) => {{ + if (!visible(el)) return false; + if (kind === 'text') return labelText(el).includes(needle); + if (kind === 'aria') return norm(el.getAttribute('aria-label')).includes(needle); + if (kind === 'role') {{ + if (norm(roleOf(el)) !== norm(wantRole)) return false; + return wantName ? labelText(el).includes(wantName) : true; + }} + return false; + }}); + if (!match) return null; + const token = 'ca-sel-' + Math.random().toString(36).slice(2); + match.setAttribute('data-ca-sel', token); + return '[data-ca-sel="' + token + '"]'; +}})()"# + ); + let resolved = self + .page + .evaluate(js) + .await? + .into_value::>() + .unwrap_or(None); + match resolved { + Some(css) => Ok(Some(css)), + None => Err(anyhow::anyhow!("no element matches selector '{selector}'")), + } + } + + /// Click the first element matching a selector. The element is scrolled + /// into view first (via `scrollIntoView`, which handles nested scroll + /// containers), so an off-screen or collapsed target no longer fails with + /// "Node is either not visible or not an HTMLElement". pub async fn click(&self, selector: &str) -> Result<()> { - self.find(selector).await?.click().await?; + let element = self.find(selector).await?; + let _ = element.scroll_into_view().await; + element.click().await?; Ok(()) } @@ -296,31 +449,141 @@ impl BrowserSession { Ok(()) } - /// Focus a field and type text into it. Never used for credentials — those - /// go through the human-in-the-loop login handoff. + /// Focus a field and type text into it, appending to any existing value. + /// The element is scrolled into view first. Never used for credentials — + /// those go through the human-in-the-loop login handoff. Prefer + /// [`fill`](Self::fill) to replace a prefilled field. pub async fn type_text(&self, selector: &str, text: &str) -> Result<()> { let element = self.find(selector).await?; + let _ = element.scroll_into_view().await; element.focus().await?; element.type_str(text).await?; Ok(()) } - /// Press a key (e.g. `"Enter"`) on the element matching a selector. The - /// element is focused first so the key event lands on it — CDP dispatches - /// key events to whatever currently has focus, not to a named node. - pub async fn press_key(&self, selector: &str, key: &str) -> Result<()> { + /// Clear a field, then type `text` — the replace semantics editing a + /// prefilled input needs (the old workflow required End + repeated + /// Backspace). Works for ``/`