Improve browser tool ergonomics for agent UI control
Teach the Browser content helper to ignore global/delegated framework event bindings so snapshots surface the actual actionable controls instead of broad wrapper elements. Add an accessible name to the Browser address bar for more reliable capture output. Allow agents to use selector-based reference actions, coordinate click fallbacks, focused-field typing, and string key chords such as CTRL+A across the browser tool, container runtime, and host connector runtime. Cover the behavior with browser regression and host connector tests.
Alessandro committed
May 12, 2026 at 09:41 UTC
7b1c84aeca74d0fa60cdfe0999aade593adb9270
8 files changed
+363
-22
plugins/_browser/assets/browser-page-content.js
+20
-1
@@ -1,7 +1,7 @@
1
(() => {
2
const GLOBAL_KEY = "__spaceBrowserPageContent__";
3
const DOM_HELPER_KEY = "__spaceBrowserDomHelper__";
4
- const VERSION = "11";
4
+ const VERSION = "12";
5
const REQUIRED_API_NAMES = Object.freeze([
6
"annotate",
7
"boundingBoxFor",
@@ -646,6 +646,19 @@
646
.split(/[.:]/u, 1)[0];
647
}
648
649
+ function isGlobalOrDelegatedEventBinding(value) {
650
+ const parts = String(value || "")
651
+ .trim()
652
+ .toLowerCase()
653
+ .split(/[.:]/u)
654
+ .map((part) => part.trim())
655
+ .filter(Boolean);
656
+ return parts.includes("window")
657
+ || parts.includes("document")
658
+ || parts.includes("outside")
659
+ || parts.includes("away");
660
+ }
661
+
662
function isInteractiveEventName(value) {
663
return INTERACTIVE_EVENT_NAMES.has(normalizeInteractiveEventName(value));
664
}
@@ -657,10 +670,16 @@
670
}
671
672
if (normalizedName.startsWith("@")) {
673
+ if (isGlobalOrDelegatedEventBinding(normalizedName.slice(1))) {
674
+ return false;
675
+ }
676
return isInteractiveEventName(normalizedName.slice(1));
677
}
678
679
if (normalizedName.startsWith("x-on:") || normalizedName.startsWith("v-on:")) {
680
+ if (isGlobalOrDelegatedEventBinding(normalizedName.slice(5))) {
681
+ return false;
682
+ }
683
return isInteractiveEventName(normalizedName.slice(5));
684
}
685
plugins/_browser/helpers/connector_runtime.py
+47
-1
@@ -63,6 +63,18 @@ get_browser_config = browser_config.get_browser_config
63
_LOCAL_PROVIDERS = {"ollama", "lm_studio"}
64
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "host.docker.internal"}
65
_SENSITIVE_ACTIONS = {"content", "detail", "evaluate", "screenshot", "screenshot_file"}
66
+_KEY_ALIASES = {
67
+ "cmd": "Meta",
68
+ "command": "Meta",
69
+ "control": "Control",
70
+ "ctrl": "Control",
71
+ "escape": "Escape",
72
+ "esc": "Escape",
73
+ "meta": "Meta",
74
+ "option": "Alt",
75
+ "return": "Enter",
76
+ "space": "Space",
77
+}
78
_REQUIRED_API_NAMES_RE = re.compile(
79
r"const\s+REQUIRED_API_NAMES\s*=\s*Object\.freeze\(\[(?P<body>.*?)\]\);",
80
re.S,
@@ -151,7 +163,7 @@ class ConnectorBrowserRuntime:
163
payload["text"] = kwargs.get("text", "")
164
elif action == "key_chord":
165
payload["browser_id"] = args[0] if args else None
154
- payload["keys"] = args[1] if len(args) > 1 else []
166
+ payload["keys"] = self._normalize_keys(args[1] if len(args) > 1 else [])
167
elif action == "clipboard":
168
payload["browser_id"] = args[0] if args else None
169
payload["clipboard_action"] = kwargs.get("action", "")
@@ -196,11 +208,45 @@ class ConnectorBrowserRuntime:
208
normalized["url"] = cls._normalize_open_url(normalized.get("url"))
209
elif action == "navigate":
210
normalized["url"] = normalize_url(normalized.get("url", ""))
211
+ elif action == "click" and not normalized.get("ref") and (
212
+ normalized.get("x") or normalized.get("y")
213
+ ):
214
+ normalized["action"] = "mouse"
215
+ normalized.setdefault("event_type", "click")
216
+ normalized.setdefault("button", "left")
217
+ elif action == "type" and not normalized.get("ref"):
218
+ normalized["action"] = "keyboard"
219
+ normalized.setdefault("key", "")
220
+ elif action in {"key_chord", "keychord"}:
221
+ normalized["keys"] = cls._normalize_keys(normalized.get("keys"))
222
elif action == "multi" or isinstance(normalized.get("calls"), list):
223
normalized["calls"] = cls._normalize_multi_calls(normalized.get("calls", []))
224
normalized_calls.append(normalized)
225
return normalized_calls
226
227
+ @staticmethod
228
+ def _normalize_keys(keys: Any) -> list[str]:
229
+ if keys is None:
230
+ return []
231
+ if isinstance(keys, str):
232
+ raw = re.split(r"\s*\+\s*|\s*,\s*", keys.strip())
233
+ elif isinstance(keys, list):
234
+ raw = keys
235
+ else:
236
+ raw = [str(keys)]
237
+ normalized: list[str] = []
238
+ for key in raw:
239
+ value = str(key or "").strip()
240
+ if not value:
241
+ continue
242
+ normalized.append(
243
+ _KEY_ALIASES.get(
244
+ value.lower(),
245
+ value.upper() if len(value) == 1 and value.isalpha() else value,
246
+ )
247
+ )
248
+ return normalized
249
+
250
async def _dispatch(self, payload: dict[str, Any]) -> Any:
251
payload.setdefault("profile_mode", self._host_browser_profile_mode())
252
self._enforce_privacy(payload)
plugins/_browser/helpers/runtime.py
+51
-3
@@ -535,6 +535,18 @@ class BrowserRuntime:
535
536
class _BrowserRuntimeCore:
537
_VALID_MODIFIERS = {"Control", "Shift", "Alt", "Meta"}
538
+ _KEY_ALIASES = {
539
+ "cmd": "Meta",
540
+ "command": "Meta",
541
+ "control": "Control",
542
+ "ctrl": "Control",
543
+ "escape": "Escape",
544
+ "esc": "Escape",
545
+ "meta": "Meta",
546
+ "option": "Alt",
547
+ "return": "Enter",
548
+ "space": "Space",
549
+ }
550
_POPUP_WAIT_SECONDS = 2.0
551
552
def __init__(self, context_id: str):
@@ -596,6 +608,29 @@ class _BrowserRuntimeCore:
608
)
609
return normalized
610
611
+ @classmethod
612
+ def _normalize_keys(cls, keys: list[str] | str | None) -> list[str]:
613
+ if keys is None:
614
+ return []
615
+ if isinstance(keys, str):
616
+ raw = re.split(r"\s*\+\s*|\s*,\s*", keys.strip())
617
+ elif isinstance(keys, list):
618
+ raw = keys
619
+ else:
620
+ raw = [str(keys)]
621
+ normalized: list[str] = []
622
+ for key in raw:
623
+ value = str(key or "").strip()
624
+ if not value:
625
+ continue
626
+ normalized.append(
627
+ cls._KEY_ALIASES.get(
628
+ value.lower(),
629
+ value.upper() if len(value) == 1 and value.isalpha() else value,
630
+ )
631
+ )
632
+ return normalized
633
+
634
@staticmethod
635
def _has_reference(reference_id: int | str | None) -> bool:
636
return reference_id is not None and str(reference_id).strip() != ""
@@ -980,6 +1015,15 @@ class _BrowserRuntimeCore:
1015
return await self.detail(bid, ref)
1016
if action == "click":
1017
ref = call.get("ref")
1018
+ if ref is None and (call.get("x") or call.get("y")):
1019
+ return await self.mouse(
1020
+ bid,
1021
+ "click",
1022
+ float(call.get("x") or 0),
1023
+ float(call.get("y") or 0),
1024
+ button=call.get("button") or "left",
1025
+ modifiers=self._normalize_modifiers(call.get("modifiers")),
1026
+ )
1027
if ref is None:
1028
raise ValueError("click requires ref")
1029
return await self.click(
@@ -990,7 +1034,11 @@ class _BrowserRuntimeCore:
1034
if action == "type":
1035
ref = call.get("ref")
1036
if ref is None:
993
- raise ValueError("type requires ref")
1037
+ return await self.keyboard(
1038
+ bid,
1039
+ key="",
1040
+ text=str(call.get("text") or ""),
1041
+ )
1042
return await self.type(bid, ref, call.get("text") or "")
1043
if action == "submit":
1044
ref = call.get("ref")
@@ -1010,10 +1058,10 @@ class _BrowserRuntimeCore:
1058
if action == "evaluate":
1059
return await self.evaluate(bid, call.get("script") or "")
1060
if action in {"key_chord", "keychord"}:
1013
- keys = call.get("keys") or []
1061
+ keys = self._normalize_keys(call.get("keys"))
1062
if not keys:
1063
raise ValueError("key_chord requires non-empty keys")
1016
- return await self.key_chord(bid, list(keys))
1064
+ return await self.key_chord(bid, keys)
1065
if action == "mouse":
1066
return await self.mouse(
1067
bid, call.get("event_type") or "click",
plugins/_browser/prompts/agent.system.tool.browser.md
+2
@@ -15,6 +15,8 @@ Workflow:
15
- `open` creates a tab and returns id/state.
16
- `content` returns markdown with refs like `[link 3]`, `[button 6]`, `[input text 8]`.
17
- Interactions use refs from the latest `content` capture.
18
+- For same-page controls that are easier to identify structurally, `click`, `type`, `submit`, `type_submit`, `scroll`, `select_option`, `set_checked`, and `upload_file` may use `selector` instead of `ref`; the tool resolves the selector through `content` first.
19
+- `click` with `x`/`y` and no `ref` is treated as a coordinate mouse click. `type` with text and no `ref` types into the currently focused element. `key_chord` accepts either `["Control", "A"]` or `"CTRL+A"`.
20
- `navigate` reuses an existing `browser_id` and is preferred for serial browsing.
21
- Screenshots are explicit only; the browser does not automatically load screenshots. Call `vision_load` with the returned path before reasoning visually.
22
- Keep the tab set small; close pages after extracting what you need.
plugins/_browser/tools/browser.py
+129
-12
@@ -1,6 +1,7 @@
1
from __future__ import annotations
2
3
import json
4
+import re
5
import time
6
import uuid
7
from pathlib import Path
@@ -82,6 +83,7 @@ class Browser(Tool):
83
modifiers = [modifiers] if modifiers else None
84
elif isinstance(modifiers, list) and not modifiers:
85
modifiers = None
86
+ keys = self._normalize_keys(keys)
87
88
try:
89
if action == "open":
@@ -114,34 +116,70 @@ class Browser(Tool):
116
payload = self._selector_payload(selector, selectors)
117
result = await runtime.call("content", browser_id, payload)
118
elif action == "detail":
117
- result = await runtime.call("detail", browser_id, self._require_ref(ref))
119
+ result = await runtime.call(
120
+ "detail",
121
+ browser_id,
122
+ await self._resolve_ref(runtime, browser_id, ref, selector, action),
123
+ )
124
elif action == "click":
119
- if modifiers:
125
+ resolved_ref = await self._resolve_ref(
126
+ runtime,
127
+ browser_id,
128
+ ref,
129
+ selector,
130
+ action,
131
+ required=not self._has_coordinates(x, y),
132
+ )
133
+ if resolved_ref is None and self._has_coordinates(x, y):
134
result = await runtime.call(
121
- "click", browser_id, self._require_ref(ref),
135
+ "mouse", browser_id, "click", x, y,
136
+ button=button or "left", modifiers=modifiers,
137
+ )
138
+ elif modifiers:
139
+ result = await runtime.call(
140
+ "click", browser_id, resolved_ref,
141
modifiers=modifiers, focus_popup=focus_popup,
142
)
143
else:
125
- result = await runtime.call("click", browser_id, self._require_ref(ref))
144
+ result = await runtime.call("click", browser_id, resolved_ref)
145
elif action == "type":
127
- result = await runtime.call("type", browser_id, self._require_ref(ref), text)
146
+ resolved_ref = await self._resolve_ref(
147
+ runtime,
148
+ browser_id,
149
+ ref,
150
+ selector,
151
+ action,
152
+ required=False,
153
+ )
154
+ if resolved_ref is None:
155
+ result = await runtime.call("keyboard", browser_id, key="", text=text)
156
+ else:
157
+ result = await runtime.call("type", browser_id, resolved_ref, text)
158
elif action == "submit":
129
- result = await runtime.call("submit", browser_id, self._require_ref(ref))
159
+ result = await runtime.call(
160
+ "submit",
161
+ browser_id,
162
+ await self._resolve_ref(runtime, browser_id, ref, selector, action),
163
+ )
164
elif action in {"type_submit", "typesubmit"}:
165
result = await runtime.call(
166
"type_submit",
167
browser_id,
134
- self._require_ref(ref),
168
+ await self._resolve_ref(runtime, browser_id, ref, selector, action),
169
text,
170
)
171
elif action == "scroll":
138
- result = await runtime.call("scroll", browser_id, self._require_ref(ref))
172
+ result = await runtime.call(
173
+ "scroll",
174
+ browser_id,
175
+ await self._resolve_ref(runtime, browser_id, ref, selector, action),
176
+ )
177
elif action == "evaluate":
178
result = await runtime.call("evaluate", browser_id, script)
179
elif action in {"key_chord", "keychord"}:
180
if not keys:
181
raise ValueError("key_chord requires non-empty 'keys' list")
144
- result = await runtime.call("key_chord", browser_id, list(keys))
182
+ result = await runtime.call("key_chord", browser_id, keys)
183
elif action == "hover":
184
result = await runtime.call(
185
"hover",
@@ -232,7 +270,7 @@ class Browser(Tool):
270
result = await runtime.call(
271
"select_option",
272
browser_id,
235
- self._require_ref(ref),
273
+ await self._resolve_ref(runtime, browser_id, ref, selector, action),
274
value=value,
275
values=values,
276
)
@@ -240,14 +278,14 @@ class Browser(Tool):
278
result = await runtime.call(
279
"set_checked",
280
browser_id,
243
- self._require_ref(ref),
281
+ await self._resolve_ref(runtime, browser_id, ref, selector, action),
282
checked=True if checked is None else bool(checked),
283
)
284
elif action == "upload_file":
285
result = await runtime.call(
286
"upload_file",
287
browser_id,
250
- self._require_ref(ref),
288
+ await self._resolve_ref(runtime, browser_id, ref, selector, action),
289
path=path,
290
paths=paths,
291
)
@@ -290,6 +328,85 @@ class Browser(Tool):
328
raise ValueError("ref is required for this browser action")
329
return ref
330
331
+ @staticmethod
332
+ def _has_ref(ref: int | str | None) -> bool:
333
+ return ref is not None and str(ref).strip() != ""
334
+
335
+ @staticmethod
336
+ def _has_coordinates(x: float, y: float) -> bool:
337
+ return bool(float(x or 0) or float(y or 0))
338
+
339
+ @classmethod
340
+ async def _resolve_ref(
341
+ cls,
342
+ runtime: Any,
343
+ browser_id: int | str | None,
344
+ ref: int | str | None,
345
+ selector: str = "",
346
+ action: str = "action",
347
+ *,
348
+ required: bool = True,
349
+ ) -> int | str | None:
350
+ if cls._has_ref(ref):
351
+ return ref
352
+
353
+ selector = str(selector or "").strip()
354
+ if selector:
355
+ content = await runtime.call("content", browser_id, {"selector": selector})
356
+ resolved = cls._first_ref_from_content(content, selector)
357
+ if resolved is not None:
358
+ return resolved
359
+ raise ValueError(
360
+ f"{action} could not resolve selector {selector!r} to a browser ref"
361
+ )
362
+
363
+ if required:
364
+ return cls._require_ref(ref)
365
+ return None
366
+
367
+ @staticmethod
368
+ def _first_ref_from_content(content: Any, selector: str = "") -> str | None:
369
+ if isinstance(content, dict):
370
+ values: list[Any] = []
371
+ if selector and selector in content:
372
+ values.append(content.get(selector))
373
+ values.extend(value for key, value in content.items() if key != selector)
374
+ text = "\n".join(str(value or "") for value in values)
375
+ else:
376
+ text = str(content or "")
377
+ match = re.search(r"\[[^\]\n]*?\b(\d+)\]", text)
378
+ return match.group(1) if match else None
379
+
380
+ @staticmethod
381
+ def _normalize_keys(keys: list[str] | str | None) -> list[str]:
382
+ if keys is None:
383
+ return []
384
+ if isinstance(keys, str):
385
+ raw = re.split(r"\s*\+\s*|\s*,\s*", keys.strip())
386
+ elif isinstance(keys, list):
387
+ raw = keys
388
+ else:
389
+ raw = [str(keys)]
390
+ aliases = {
391
+ "cmd": "Meta",
392
+ "command": "Meta",
393
+ "control": "Control",
394
+ "ctrl": "Control",
395
+ "escape": "Escape",
396
+ "esc": "Escape",
397
+ "meta": "Meta",
398
+ "option": "Alt",
399
+ "return": "Enter",
400
+ "space": "Space",
401
+ }
402
+ normalized: list[str] = []
403
+ for key in raw:
404
+ value = str(key or "").strip()
405
+ if not value:
406
+ continue
407
+ normalized.append(aliases.get(value.lower(), value.upper() if len(value) == 1 and value.isalpha() else value))
408
+ return normalized
409
+
410
@staticmethod
411
def _selector_payload(selector: str = "", selectors: list[str] | None = None) -> dict | None:
412
if selectors:
plugins/_browser/webui/browser-panel.html
+2
-2
@@ -173,9 +173,9 @@
173
</button>
174
</div>
175
176
- <form class="browser-address-form" @submit.prevent="$store.browserPage.go()">
176
+ <form class="browser-address-form" aria-label="Browser navigation" @submit.prevent="$store.browserPage.go()">
177
<span class="material-symbols-outlined browser-address-icon">language</span>
178
- <input class="browser-address" x-model="$store.browserPage.address"
178
+ <input class="browser-address" aria-label="Browser address" name="browser_address" x-model="$store.browserPage.address"
179
@focus="$store.browserPage.onAddressFocus()" @blur="$store.browserPage.onAddressBlur()"
180
:disabled="$store.browserPage.isBusy()"
181
placeholder="https://example.com" autocomplete="off" />
tests/test_browser_agent_regressions.py
+91
-1
@@ -1237,7 +1237,7 @@ def test_browser_content_helper_keeps_label_wrapped_controls_referenceable():
1237
PROJECT_ROOT / "plugins" / "_browser" / "assets" / "browser-page-content.js"
1238
).read_text(encoding="utf-8")
1239
1240
- assert 'const VERSION = "11"' in helper
1240
+ assert 'const VERSION = "12"' in helper
1241
assert "function patchOpenShadowDom" in helper
1242
assert "Element.prototype.attachShadow = patched" in helper
1243
assert "const REQUIRED_API_NAMES = Object.freeze([" in helper
@@ -1249,6 +1249,18 @@ def test_browser_content_helper_keeps_label_wrapped_controls_referenceable():
1249
assert "getLabelElementText(labelElement, element)" in helper
1250
assert "return renderControlLabelReferences(node, context);" in helper
1251
assert "return renderControlLabelReferences(element, context);" in helper
1252
+ assert "function isGlobalOrDelegatedEventBinding" in helper
1253
+ assert 'parts.includes("window")' in helper
1254
+ assert 'parts.includes("outside")' in helper
1255
+
1256
+
1257
+def test_browser_panel_exposes_agent_friendly_address_input():
1258
+ panel = (
1259
+ PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html"
1260
+ ).read_text(encoding="utf-8")
1261
+
1262
+ assert 'class="browser-address-form" aria-label="Browser navigation"' in panel
1263
+ assert 'class="browser-address" aria-label="Browser address" name="browser_address"' in panel
1264
1265
1266
def test_browser_runtime_requires_current_content_helper_for_modifier_clicks():
@@ -1637,6 +1649,9 @@ async def test_browser_tool_dispatches_v1_agent_actions(monkeypatch):
1649
await execute(action="select_option", browser_id=1, ref=6, value="CA")
1650
await execute(action="set_checked", browser_id=1, ref=7, checked=False)
1651
await execute(action="upload_file", browser_id=1, ref=8, paths=["/tmp/a.txt"])
1652
+ await execute(action="key_chord", browser_id=1, keys="CTRL+A")
1653
+ await execute(action="click", browser_id=1, x=10, y=20)
1654
+ await execute(action="type", browser_id=1, text="agent-zero.ai")
1655
1656
assert calls == [
1657
("screenshot_file", (1,), {"quality": 91, "full_page": True, "path": "/tmp/a.jpg"}),
@@ -1690,6 +1705,81 @@ async def test_browser_tool_dispatches_v1_agent_actions(monkeypatch):
1705
("select_option", (1, 6), {"value": "CA", "values": None}),
1706
("set_checked", (1, 7), {"checked": False}),
1707
("upload_file", (1, 8), {"path": "", "paths": ["/tmp/a.txt"]}),
1708
+ ("key_chord", (1, ["Control", "A"]), {}),
1709
+ ("mouse", (1, "click", 10, 20), {"button": "left", "modifiers": None}),
1710
+ ("keyboard", (1,), {"key": "", "text": "agent-zero.ai"}),
1711
+ ]
1712
+
1713
+
1714
+@pytest.mark.anyio
1715
+async def test_browser_tool_resolves_selector_for_reference_actions(monkeypatch):
1716
+ calls = []
1717
+
1718
+ class FakeRuntime:
1719
+ async def call(self, method, *args, **kwargs):
1720
+ calls.append((method, args, kwargs))
1721
+ if method == "content":
1722
+ return {"input.browser-address": "[input text 31] Browser address"}
1723
+ return {"ok": True, "method": method, "args": args, "kwargs": kwargs}
1724
+
1725
+ async def fake_get_runtime(context_id, create=True, agent=None):
1726
+ del create, agent
1727
+ assert context_id == "ctx"
1728
+ return FakeRuntime()
1729
+
1730
+ monkeypatch.setattr(browser_tool_module, "get_runtime", fake_get_runtime)
1731
+ tool = browser_tool_module.Browser(
1732
+ agent=SimpleNamespace(context=SimpleNamespace(id="ctx")),
1733
+ name="browser",
1734
+ method=None,
1735
+ args={},
1736
+ message="",
1737
+ loop_data=None,
1738
+ )
1739
+
1740
+ response = await tool.execute(
1741
+ action="type_submit",
1742
+ browser_id=1,
1743
+ selector="input.browser-address",
1744
+ text="agent-zero.ai",
1745
+ )
1746
+
1747
+ assert response.break_loop is False
1748
+ assert calls == [
1749
+ ("content", (1, {"selector": "input.browser-address"}), {}),
1750
+ ("type_submit", (1, "31", "agent-zero.ai"), {}),
1751
+ ]
1752
+
1753
+
1754
+@pytest.mark.anyio
1755
+async def test_browser_runtime_multi_accepts_human_shaped_input_calls():
1756
+ calls = []
1757
+ core = _BrowserRuntimeCore("ctx")
1758
+
1759
+ async def fake_mouse(browser_id, event_type, x, y, *, button="left", modifiers=None):
1760
+ calls.append(("mouse", browser_id, event_type, x, y, button, modifiers))
1761
+ return {"ok": True}
1762
+
1763
+ async def fake_keyboard(browser_id, *, key="", text=""):
1764
+ calls.append(("keyboard", browser_id, key, text))
1765
+ return {"ok": True}
1766
+
1767
+ async def fake_key_chord(browser_id, keys):
1768
+ calls.append(("key_chord", browser_id, keys))
1769
+ return {"ok": True}
1770
+
1771
+ core.mouse = fake_mouse
1772
+ core.keyboard = fake_keyboard
1773
+ core.key_chord = fake_key_chord
1774
+
1775
+ assert await core._dispatch_call({"action": "click", "browser_id": 1, "x": 10, "y": 20}) == {"ok": True}
1776
+ assert await core._dispatch_call({"action": "type", "browser_id": 1, "text": "agent-zero.ai"}) == {"ok": True}
1777
+ assert await core._dispatch_call({"action": "key_chord", "browser_id": 1, "keys": "CTRL+A"}) == {"ok": True}
1778
+
1779
+ assert calls == [
1780
+ ("mouse", 1, "click", 10.0, 20.0, "left", None),
1781
+ ("keyboard", 1, "", "agent-zero.ai"),
1782
+ ("key_chord", 1, ["Control", "A"]),
1783
]
1784
1785
tests/test_host_browser_connector.py
+21
-2
@@ -245,6 +245,9 @@ def test_connector_runtime_normalizes_host_navigation_payloads(monkeypatch):
245
[
246
{"action": "open", "url": "example.com"},
247
{"action": "navigate", "browser_id": 1, "url": "127.0.0.1:8000/path"},
248
+ {"action": "click", "browser_id": 1, "x": 12, "y": 34},
249
+ {"action": "type", "browser_id": 1, "text": "agent-zero.ai"},
250
+ {"action": "key_chord", "browser_id": 1, "keys": "CTRL+A"},
251
{
252
"action": "multi",
253
"calls": [{"action": "open", "url": "nested.example"}],
@@ -258,9 +261,25 @@ def test_connector_runtime_normalizes_host_navigation_payloads(monkeypatch):
261
assert navigate_payload["url"] == "https://novinky.cz/"
262
assert multi_payload["calls"][0]["url"] == "https://example.com/"
263
assert multi_payload["calls"][1]["url"] == "http://127.0.0.1:8000/path"
261
- assert multi_payload["calls"][2]["calls"][0]["url"] == "https://nested.example/"
262
- assert multi_payload["calls"][3] == {"action": "content", "browser_id": 1}
264
+ assert multi_payload["calls"][2] == {
265
+ "action": "mouse",
266
+ "browser_id": 1,
267
+ "x": 12,
268
+ "y": 34,
269
+ "event_type": "click",
270
+ "button": "left",
271
+ }
272
+ assert multi_payload["calls"][3] == {
273
+ "action": "keyboard",
274
+ "browser_id": 1,
275
+ "text": "agent-zero.ai",
276
+ "key": "",
277
+ }
278
+ assert multi_payload["calls"][4]["keys"] == ["Control", "A"]
279
+ assert multi_payload["calls"][5]["calls"][0]["url"] == "https://nested.example/"
280
+ assert multi_payload["calls"][6] == {"action": "content", "browser_id": 1}
281
assert open_payload["profile_mode"] == "existing"
282
+ assert runtime._payload_for_call("key_chord", 1, "CTRL+A")["keys"] == ["Control", "A"]
283
284
285
def test_connector_runtime_forwards_host_profile_mode(monkeypatch):