Files
hermes-agent/tests/tui_gateway/test_gui_surface_toolsets.py
T
Brooklyn Nicholson c57581cd0d feat(tools): drive_preview and annotate_preview — the agent can use the page it opened
The in-app browser was a one-way mirror. open_preview put a page in the pane
and read_preview read its text back, but nothing could touch it. A click meant
falling back to the browser_* tools, which drive a separate Chromium the user
cannot see — so "log into this and pull my invoices" happened in a different
browser from the one on screen, with none of the sessions the user is already
signed into.

Four pieces, and they only make sense together:

  · an in-page engine that inventories what is interactable and performs the
    verb, injected as source because it has to run inside the guest page;
  · the preview.act.request bridge from the gateway into the pane;
  · drive_preview, for acting: elements, click, type, scroll, press, and the
    pane's own back/forward/reload;
  · annotate_preview, for marking without acting.

Those last two started as one tool doing two unrelated jobs. Leaving a mark is
not an action — it outlives the turn that drew it — so it gets its own verb,
and the interaction verb gets a name that says what it does.

Gating is the existing surface rule: desktop_ui folds in on session
source: 'desktop', and the bridge refuses to act for a background session, so a
turn running behind the user's back cannot reach into the page they are working
in.

Two details worth a reviewer's attention. Typing assigns through the
prototype's value setter, because React shadows value with its own accessor and
ignores an input event whose value it believes it already wrote — a plain
el.value = … types into a field that snaps back on the next render. And
clicking replays the pointer/mouse pair before activation, because frameworks
bind to mousedown as often as to click.
2026-08-20 05:26:37 -05:00

120 lines
4.5 KiB
Python

"""GUI capability follows the SESSION's client, not the backend's process env.
The desktop app is a client. It can drive a backend that Electron spawned
locally, one reached over SSH, one behind a plain URL+token, or Hermes Cloud —
and only the first two run with ``HERMES_DESKTOP=1`` in their environment.
Gating the pane/browser/reaction tools on that env var therefore stripped every
one of them from URL and cloud gateways, while the same backend still told the
model "You are chatting inside the Hermes desktop app".
These tests pin the contract that replaced it: eligibility is resolved from the
session's own ``source`` (``session.create``'s ``source: 'desktop'``), so the
answer is identical on every connection topology.
"""
import pytest
import tui_gateway.server as server
from toolsets import TOOLSETS, resolve_toolset
GUI_TOOLS = {
"annotate_preview",
"close_preview",
"drive_preview",
"close_terminal",
"focus_pane",
"open_preview",
"read_preview",
"read_terminal",
"read_window_below",
"react_to_message",
"setup_mcp",
"tour",
}
@pytest.fixture
def no_desktop_env(monkeypatch):
"""A backend nobody told about the desktop — i.e. every remote gateway."""
monkeypatch.delenv("HERMES_DESKTOP", raising=False)
monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)
monkeypatch.delenv("HERMES_TUI_TOOLSETS", raising=False)
return monkeypatch
class TestDesktopUiToolset:
def test_holds_exactly_the_gui_affordances(self):
assert set(resolve_toolset("desktop_ui")) == GUI_TOOLS
def test_stays_off_the_core_tool_list(self):
"""Core ships on every API call — a GUI-only tool must not be there."""
from toolsets import _HERMES_CORE_TOOLS
assert GUI_TOOLS.isdisjoint(_HERMES_CORE_TOOLS)
def test_no_platform_bundle_carries_it(self):
"""Messaging/CLI bundles must not pick these up by listing them."""
for name, spec in TOOLSETS.items():
if name == "desktop_ui":
continue
assert GUI_TOOLS.isdisjoint(set(spec.get("tools") or ())), name
class TestSurfaceResolution:
def test_desktop_session_gets_them_with_no_desktop_env(self, no_desktop_env):
"""THE regression: a desktop client on a remote/cloud backend."""
assert "desktop_ui" in server._gui_surface_toolsets("desktop")
def test_tui_session_does_not(self, no_desktop_env):
assert "desktop_ui" not in server._gui_surface_toolsets("tui")
def test_desktop_env_alone_does_not_grant_them(self, no_desktop_env):
"""A desktop-spawned backend serving a TUI session stays clean.
The embedded terminal pane runs `hermes --tui` against this same
backend; env-keyed gating handed it GUI tools it cannot answer.
"""
no_desktop_env.setenv("HERMES_DESKTOP", "1")
assert "desktop_ui" not in server._gui_surface_toolsets("tui")
def test_project_tools_ride_on_every_gui_surface(self, no_desktop_env):
for platform in ("desktop", "tui"):
assert "project" in server._gui_surface_toolsets(platform)
class TestResolverPlumbing:
def test_posture_path_folds_in_the_session_surface(self, no_desktop_env):
"""Focus-mode returns early — the surface toolsets must survive it."""
import agent.coding_context as cc
no_desktop_env.setattr(cc, "coding_selection", lambda **_: ["coding"])
assert server._load_enabled_toolsets("desktop") == [
"coding",
"desktop_ui",
"project",
]
assert server._load_enabled_toolsets("tui") == ["coding", "project"]
def test_config_path_folds_in_the_session_surface(self, no_desktop_env):
import agent.coding_context as cc
import hermes_cli.config as config_mod
no_desktop_env.setattr(cc, "coding_selection", lambda **_: None)
no_desktop_env.setattr(
config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}
)
desktop = server._load_enabled_toolsets("desktop")
tui = server._load_enabled_toolsets("tui")
assert desktop is not None and tui is not None
assert "desktop_ui" in desktop
assert "desktop_ui" not in tui
def test_explicit_env_pin_still_wins(self, no_desktop_env):
"""HERMES_TUI_TOOLSETS is an operator override; surface can't re-add."""
no_desktop_env.setenv("HERMES_TUI_TOOLSETS", "web,memory")
assert server._load_enabled_toolsets("desktop") == ["web", "memory"]