Files
hermes-agent/tests/tools/test_tour_tool.py
T
Brooklyn Nicholson 84d81d258f fix(tour): an unanswered tour bridge no longer costs 45s per action
The renderer's `tour.request` handler ships in the desktop bundle, but the
tool is offered by the backend, and the two update on different clocks. A
desktop build older than the tour tool receives the event in a renderer with
no branch for it, so `tour.respond` never comes and the agent blocks for the
full 45s deadline — once per action the model tries. A single "give me a
tour" turn (targets, then narrate, then stop) stacked those waits into
minutes of dead air, which is what got reported against #89620.

Hold a session's first action to a deadline a working renderer cannot miss,
and let an unanswered probe mark the bridge unavailable for that session:
later calls return immediately with an error naming the actual fix instead
of stalling again. Once a client has answered, real actions get the full
deadline back, so a preview tour injecting into a live page still works and
one slow action no longer condemns a live client. The verdict lives on the
session record, so it dies with the session and a new one re-probes.

The same five-action sequence goes from ~225s of dead air to a single 10s
probe. Toolset gating is unchanged: removing the tool outright needs a
client capability declared at session.create, which prompt caching means
can only take effect for a new session.
2026-08-19 11:20:28 -05:00

81 lines
2.7 KiB
Python

"""Tests for the GUI-surface ``tour`` tool."""
import json
from tools import tour_tool as tt
from tools.registry import registry
def _run(**kwargs):
kwargs.setdefault("callback", lambda _payload: json.dumps({"success": True}))
return json.loads(tt.tour_tool(**kwargs))
def test_lives_in_the_gui_surface_toolset(monkeypatch):
"""Scoped by toolset, not by the backend's env — see AGENTS.md."""
monkeypatch.delenv("HERMES_DESKTOP", raising=False)
entry = registry.get_entry("tour")
assert entry is not None
assert entry.toolset == "desktop_ui"
assert entry.check_fn is None
def test_requires_callback():
"""Outside the desktop GUI there is no bridge — a clear error, no crash."""
assert "desktop" in json.loads(tt.tour_tool(action="targets", callback=None))["error"]
def test_rejects_unknown_action_and_surface():
assert "action must be one of" in _run(action="dance")["error"]
assert "surface must be one of" in _run(action="targets", surface="hologram")["error"]
assert "side must be one of" in _run(action="show", selector="#a", side="diagonal")["error"]
def test_show_needs_something_to_point_at_or_say():
assert "show needs" in _run(action="show")["error"]
assert "error" not in _run(action="show", text="just narration")
def test_start_validates_its_steps():
assert "non-empty steps" in _run(action="start")["error"]
assert "non-empty steps" in _run(action="start", steps=[])["error"]
assert "steps[1] must be an object" in _run(action="start", steps=[{"selector": "#a"}, "nope"])["error"]
assert "steps[1] needs" in _run(action="start", steps=[{"selector": "#a"}, {}])["error"]
def test_payload_omits_unset_fields_and_defaults_the_surface():
seen = {}
def cb(payload):
seen.update(payload)
return json.dumps({"success": True})
tt.tour_tool(action="show", selector="#composer", title="Composer", callback=cb)
assert seen == {
"action": "show",
"surface": "app",
"selector": "#composer",
"title": "Composer",
}
def test_unanswered_bridge_is_reported_rather_than_faked_as_success():
assert "error" in _run(action="targets", callback=lambda _p: "")
def test_passes_renderer_json_through():
payload = {"success": True, "matched": True, "step": 2}
assert _run(action="next", callback=lambda _p: json.dumps(payload)) == payload
def test_wraps_non_json_text():
assert _run(action="stop", callback=lambda _p: "stopped") == {"text": "stopped"}
def test_callback_failure_is_reported():
def _boom(_payload):
raise RuntimeError("renderer went away")
assert "renderer went away" in _run(action="stop", callback=_boom)["error"]