"""Tests for the embedded-browser (``browser_*``) tool surface. Covers the runner-side half of the feature: - ``_execute_browser_tool``: the blocking ``server_client.post`` to the server ``/browser/action_request`` route — correct URL / ``action`` / ``args`` payload, verbatim JSON passthrough, and the clean timeout error on ``httpx.ReadTimeout``. - Registration in ``omnigent.tools.builtins``: the five ``browser_*`` names are always registered. - Native-relay exposure: ``build_native_relay_tool_schemas`` surfaces the five ``browser_*`` schemas when the spec declares them — native harnesses see the relay as their only tool surface, so a miss here means the feature is dead on the desktop app. """ from __future__ import annotations import json import httpx import pytest import omnigent.tools.builtins as builtins_mod from omnigent.runner.tool_dispatch import ( _BROWSER_TOOLS, _NATIVE_RELAY_BUILTIN_TOOLS, _execute_browser_tool, build_native_relay_tool_schemas, ) from omnigent.spec.types import AgentSpec # ── Helpers ────────────────────────────────────────────────────── class _RecordingResponse: """Minimal httpx response stub with a scripted body.""" def __init__(self, *, status_code: int = 200, body: dict[str, object] | None = None) -> None: self.status_code = status_code self._body = body if body is not None else {} @property def text(self) -> str: """Return the JSON body as text (what the tool returns verbatim).""" return json.dumps(self._body) class _RecordingClient: """httpx.AsyncClient stub that records the POST and returns a script.""" def __init__(self, response: _RecordingResponse | None = None) -> None: self.calls: list[tuple[str, dict[str, object], object]] = [] self._response = response or _RecordingResponse(body={"final_url": "https://x"}) async def post( self, url: str, *, json: dict[str, object] | None = None, timeout: object = None, ) -> _RecordingResponse: """Record the call and return the scripted response.""" self.calls.append((url, json or {}, timeout)) return self._response class _TimeoutClient: """httpx.AsyncClient stub whose POST raises ReadTimeout.""" async def post(self, url: str, **_: object) -> _RecordingResponse: """Raise the read timeout the tool must translate to clean JSON.""" raise httpx.ReadTimeout("read timed out") class _ErrorClient: """httpx.AsyncClient stub whose POST raises a generic HTTPError.""" async def post(self, url: str, **_: object) -> _RecordingResponse: """Raise a connect error the tool must surface as an error string.""" raise httpx.ConnectError("connection refused") # ── _execute_browser_tool ──────────────────────────────────────── @pytest.mark.asyncio async def test_browser_tool_posts_action_request_with_stripped_prefix() -> None: """ The tool POSTs to the action_request route with ``action`` = tool name minus ``browser_`` and forwards ``args`` verbatim. """ client = _RecordingClient(_RecordingResponse(body={"final_url": "https://example.com"})) out = await _execute_browser_tool( "browser_navigate", {"url": "https://example.com"}, server_client=client, conversation_id="conv_abc", ) assert len(client.calls) == 1 url, body, timeout = client.calls[0] assert url == "/v1/sessions/conv_abc/browser/action_request" assert body == {"action": "navigate", "args": {"url": "https://example.com"}} # read budget MUST exceed the server await (30s) so the runner never # severs the still-open POST first. assert isinstance(timeout, httpx.Timeout) assert timeout.read == 60.0 # Result is the server JSON verbatim. assert json.loads(out) == {"final_url": "https://example.com"} @pytest.mark.asyncio async def test_browser_tool_strips_prefix_for_every_action() -> None: """Each of the five tools maps to the correct wire ``action``.""" expected = { "browser_navigate": "navigate", "browser_snapshot": "snapshot", "browser_click": "click", "browser_type": "type", "browser_screenshot": "screenshot", } for tool_name, action in expected.items(): client = _RecordingClient() await _execute_browser_tool(tool_name, {}, server_client=client, conversation_id="conv_x") assert client.calls[0][1]["action"] == action @pytest.mark.asyncio async def test_browser_tool_read_timeout_returns_clean_json() -> None: """ A runner-side ``httpx.ReadTimeout`` becomes the clean timeout-error JSON, not an exception — so the LLM sees an actionable tool error. """ out = await _execute_browser_tool( "browser_snapshot", {}, server_client=_TimeoutClient(), conversation_id="conv_abc", ) parsed = json.loads(out) assert "timed out" in parsed["error"] assert "Omnigent desktop app" in parsed["error"] @pytest.mark.asyncio async def test_browser_tool_http_error_returns_error_json() -> None: """A generic HTTP error is surfaced as an error JSON, not raised.""" out = await _execute_browser_tool( "browser_click", {"ref": 3}, server_client=_ErrorClient(), conversation_id="conv_abc", ) parsed = json.loads(out) assert "browser_click failed" in parsed["error"] @pytest.mark.asyncio async def test_browser_tool_4xx_returns_error_json() -> None: """A >=400 response body is reported as an error string, not raised.""" client = _RecordingClient(_RecordingResponse(status_code=403, body={"detail": "nope"})) out = await _execute_browser_tool( "browser_type", {"text": "hi"}, server_client=client, conversation_id="conv_abc", ) parsed = json.loads(out) assert "browser_type returned 403" in parsed["error"] @pytest.mark.asyncio async def test_browser_tool_requires_server_and_session() -> None: """Missing server_client or conversation_id fails loud with JSON.""" out_no_client = await _execute_browser_tool( "browser_navigate", {"url": "u"}, server_client=None, conversation_id="conv" ) assert "requires server access" in json.loads(out_no_client)["error"] out_no_conv = await _execute_browser_tool( "browser_navigate", {"url": "u"}, server_client=_RecordingClient(), conversation_id=None, ) assert "requires a session id" in json.loads(out_no_conv)["error"] # ── Framework-owned registration (always on, no spec opt-in) ───── _EXPECTED_BROWSER_NAMES = { "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_screenshot", } def test_browser_names_reserved_framework_owned() -> None: """ The five ``browser_*`` names are reserved in the builtin registry so user specs can't shadow them, but they are FRAMEWORK-OWNED — like ``list_comments`` / ``update_comment``, they are NOT instantiable via ``get_builtin_tool`` (registration is ToolManager's job). This pins the single source of truth: ToolManager, not the registry factory. """ browser = {n for n in builtins_mod.BUILTIN_NAMES if n.startswith("browser_")} assert browser == _EXPECTED_BROWSER_NAMES # Framework-owned → reserved but not user-instantiable. assert not (_EXPECTED_BROWSER_NAMES & set(builtins_mod.INSTANTIABLE_BUILTINS)) for name in sorted(browser): assert builtins_mod.get_builtin_tool(name) is None def test_toolmanager_always_registers_browser_tools() -> None: """ EVERY session — even a spec with NO ``tools.builtins`` declared — has all five ``browser_*`` tools registered on its ToolManager. This is the invariant the earlier per-spec registration missed (agents fell back to WebFetch because no shipped spec declared browser_*). """ from omnigent.tools.manager import ToolManager mgr = ToolManager(AgentSpec(spec_version=1)) # empty tools.builtins for name in sorted(_EXPECTED_BROWSER_NAMES): tool = mgr.get_tool(name) assert tool is not None, f"{name} not registered on a bare spec" schema = tool.get_schema() assert schema["function"]["name"] == name assert schema["function"]["description"] # ── Native-relay exposure ──────────────────────────────────────── def test_browser_tools_in_native_relay_union() -> None: """The relay builtin union must include every browser tool name.""" assert _BROWSER_TOOLS <= _NATIVE_RELAY_BUILTIN_TOOLS def test_native_relay_includes_browser_for_bare_spec() -> None: """ A spec with NO ``tools.builtins`` still surfaces all five ``browser_*`` schemas on the native relay — because ToolManager always registers them, the relay (which filters ToolManager's schemas by the union) always emits them. The desktop app runs native sessions that see only the relay, so this is the load-bearing path. """ schemas = build_native_relay_tool_schemas(AgentSpec(spec_version=1)) names = {s["name"] for s in schemas if s["name"].startswith("browser_")} assert names == _EXPECTED_BROWSER_NAMES # Each relay entry is the flat {name, description, parameters} shape. for schema in schemas: if schema["name"].startswith("browser_"): assert schema["description"] assert schema["parameters"]["type"] == "object"