diff --git a/tests/tools/test_display_toggles.py b/tests/tools/test_display_toggles.py new file mode 100644 index 0000000000..a19159c5d8 --- /dev/null +++ b/tests/tools/test_display_toggles.py @@ -0,0 +1,63 @@ +"""The desktop's Appearance switches, as the AGENT experiences them. + +Each of these toggles decides whether a tool is in the model's schema at all, +so the contract under test is end to end: a real ``config.yaml`` in a temp +``HERMES_HOME``, read through the real config loader, answering a real +``check_fn``. Mocking the loader here would test nothing that matters — the +whole failure this guards against was a value that never reached the config. +""" + +import textwrap + +import pytest + +from hermes_constants import get_hermes_home +from tools import desktop_ui +from tools.react_to_message_tool import check_react_requirements +from tools.tip_tool import check_tips_enabled +from tools.tour_tool import check_tours_enabled + + +@pytest.fixture +def display_config(): + """Write a ``display:`` section into this test's HERMES_HOME.""" + + def _write(**flags: bool) -> None: + home = get_hermes_home() + home.mkdir(parents=True, exist_ok=True) + body = "".join(f" {name}: {str(on).lower()}\n" for name, on in flags.items()) + (home / "config.yaml").write_text(textwrap.dedent("display:\n") + body) + + return _write + + +def test_a_missing_setting_leaves_the_feature_at_its_default(): + """Absence is not an answer: an opt-out feature stays on, opt-in stays off.""" + assert desktop_ui.user_enabled("in_app_tips", default=True) is True + assert desktop_ui.user_enabled("message_reactions", default=False) is False + + +def test_the_users_answer_wins_in_both_directions(display_config): + display_config(in_app_tips=False, message_reactions=True) + + assert desktop_ui.user_enabled("in_app_tips", default=True) is False + assert desktop_ui.user_enabled("message_reactions", default=False) is True + + +@pytest.mark.parametrize( + ("setting", "check", "default_on"), + [ + ("in_app_tips", check_tips_enabled, True), + ("in_app_tours", check_tours_enabled, True), + ("message_reactions", check_react_requirements, False), + ], +) +def test_switching_a_feature_off_withdraws_its_tool(display_config, setting, check, default_on): + """The point of the mirror: off means the model is never told it exists.""" + assert check() is default_on + + display_config(**{setting: False}) + assert check() is False + + display_config(**{setting: True}) + assert check() is True diff --git a/tests/tools/test_tip_tool.py b/tests/tools/test_tip_tool.py index a510f0c0a4..7134b2799a 100644 --- a/tests/tools/test_tip_tool.py +++ b/tests/tools/test_tip_tool.py @@ -30,12 +30,13 @@ def test_lives_in_the_gui_surface_toolset(monkeypatch): assert entry.toolset == "desktop_ui" -def test_is_ungated_like_tour(): - """The Appearance switch governs the app's idle rotation, not this.""" +def test_answers_to_the_appearance_switch(): + """Tips off has to mean the model never sees the tool. See + tests/tools/test_display_toggles.py for the config end of it.""" entry = registry.get_entry("tip") assert entry is not None - assert entry.check_fn is None + assert entry.check_fn is tt.check_tips_enabled def test_requires_the_desktop_bridge(monkeypatch): diff --git a/tests/tools/test_tour_tool.py b/tests/tools/test_tour_tool.py index f6147f6274..1ab40e21ac 100644 --- a/tests/tools/test_tour_tool.py +++ b/tests/tools/test_tour_tool.py @@ -18,7 +18,15 @@ def test_lives_in_the_gui_surface_toolset(monkeypatch): assert entry is not None assert entry.toolset == "desktop_ui" - assert entry.check_fn is None + + +def test_answers_to_the_appearance_switch(): + """Tours off has to mean the model never sees the tool. See + tests/tools/test_display_toggles.py for the config end of it.""" + entry = registry.get_entry("tour") + + assert entry is not None + assert entry.check_fn is tt.check_tours_enabled def test_requires_callback(): diff --git a/tools/desktop_ui.py b/tools/desktop_ui.py index 94955a29bd..7179e3ea16 100644 --- a/tools/desktop_ui.py +++ b/tools/desktop_ui.py @@ -29,6 +29,30 @@ def available() -> bool: return _emit is not None +def user_enabled(setting: str, default: bool) -> bool: + """Read one of the desktop's Appearance switches from ``display.``. + + The renderer owns these toggles and mirrors them onto the CONNECTED + gateway's config (``config.set``), so this reads the user's real answer + whether that gateway is local, SSH, URL, or cloud — where an env var would + only ever describe the process. Tool ``check_fn``s call it to withdraw + themselves from the schema when the user has switched the feature off: + Hermes should not be told about a surface it isn't allowed to use. + + An unreadable config falls back to ``default``, which is how a feature that + ships on stays on rather than disappearing on a transient read error. + """ + try: + from hermes_cli.config import load_config_readonly + + display = load_config_readonly().get("display") + except Exception: + return default + if not isinstance(display, dict) or setting not in display: + return default + return bool(display.get(setting)) + + def emit(event: str, payload: dict) -> bool: """Route ``event`` to the window that owns the current turn. diff --git a/tools/react_to_message_tool.py b/tools/react_to_message_tool.py index 4fb759cdf0..8cf36198bd 100644 --- a/tools/react_to_message_tool.py +++ b/tools/react_to_message_tool.py @@ -120,17 +120,9 @@ def check_react_requirements() -> bool: """Opt-in feature flag — surface eligibility is the toolset's job. ``desktop_ui`` already restricts this to GUI sessions. What's left is the - user's own toggle (Settings → Appearance), which the desktop mirrors into - ``display.message_reactions`` on the CONNECTED gateway's config — so this - reads the right config whether that gateway is local, SSH, URL, or cloud. + user's own toggle (Settings → Appearance). """ - try: - from hermes_cli.config import load_config_readonly - - display = load_config_readonly().get("display") - except Exception: - return False - return isinstance(display, dict) and bool(display.get("message_reactions", False)) + return desktop_ui.user_enabled("message_reactions", default=False) REACT_TO_MESSAGE_SCHEMA = { diff --git a/tools/tip_tool.py b/tools/tip_tool.py index ab80092f17..57bd72e508 100644 --- a/tools/tip_tool.py +++ b/tools/tip_tool.py @@ -10,12 +10,11 @@ sentence. Fire-and-forget, unlike ``tour``: a tip is not a question, so blocking the turn on a round-trip would stall the reply it belongs to. -Ungated, also like ``tour``. The desktop's Settings → Appearance switch governs -the app's own idle rotation — the half that talks unprompted — not this, which -Hermes raises mid-conversation in answer to something the user said. - Lives in the ``desktop_ui`` toolset, which the GUI gateway enables only for -desktop-sourced sessions. +desktop-sourced sessions, and withdraws itself entirely when the user has +turned tips off (Settings → Appearance). Off means the model is never told the +tool exists — a switch that only made the call fail would leave Hermes +promising to point at things it cannot point at. """ import json @@ -95,6 +94,11 @@ TIP_SCHEMA = { } +def check_tips_enabled() -> bool: + """The user's Settings → Appearance switch. On unless they turned it off.""" + return desktop_ui.user_enabled("in_app_tips", default=True) + + registry.register( name="tip", toolset="desktop_ui", @@ -105,5 +109,6 @@ registry.register( title=args.get("title", ""), side=args.get("side", ""), ), + check_fn=check_tips_enabled, emoji="💡", ) diff --git a/tools/tour_tool.py b/tools/tour_tool.py index 2ccff61002..5f6b204a57 100644 --- a/tools/tour_tool.py +++ b/tools/tour_tool.py @@ -19,12 +19,16 @@ outcome, so the agent knows whether the selector matched. This module is just schema + a thin dispatcher over the platform-injected callback. Lives in the ``desktop_ui`` toolset, which the GUI gateway enables only for -desktop-sourced sessions. +desktop-sourced sessions, and withdraws itself when the user has switched tours +off (Settings → Appearance). A tour takes the whole screen, so "no thanks" has +to mean the model is never told the tool exists — a switch that only made the +call fail would leave Hermes offering walkthroughs it cannot give. """ import json from typing import Callable, Optional +from tools import desktop_ui from tools.registry import registry, tool_error ACTIONS = ("targets", "show", "start", "next", "prev", "stop") @@ -179,6 +183,11 @@ TOUR_SCHEMA = { } +def check_tours_enabled() -> bool: + """The user's Settings → Appearance switch. On unless they turned it off.""" + return desktop_ui.user_enabled("in_app_tours", default=True) + + registry.register( name="tour", toolset="desktop_ui", @@ -194,5 +203,6 @@ registry.register( step_index=args.get("step_index"), callback=kw.get("callback"), ), + check_fn=check_tours_enabled, emoji="🧭", ) diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index a0775b8f61..3a3c725c68 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -265,14 +265,19 @@ discovery is one call for both, and the durable `data-tour` handles above name targets for either. One tip is on screen at a time; a new one replaces the last. The app can also show its own, walking a built-in catalog of app features in -order. That half is on by default and switched off in Settings → Appearance, -and it is paced like a game's loading-screen tips rather than a notification: a -few minutes into a launch at the earliest, then at most one every six hours, and -only at a genuinely idle moment. Closing one of its tips with the ✕ retires that -tip for good, and the same settings row brings them back. The tool is not behind -that switch — like `tour`, it runs in answer to the conversation rather than at -idle. It does share the cooldown, so a tip from Hermes also buys the user six -hours of quiet from the rotation. +order, paced like a game's loading-screen tips rather than a notification: a few +minutes into a launch at the earliest, then at most one every six hours, and +only at a genuinely idle moment. A tip from Hermes shares that cooldown, so it +also buys the user six hours of quiet from the rotation. Closing a rotation tip +with the ✕ retires that tip for good, and the settings row brings them back. + +Both tips and tours are on by default and switched off in Settings → Appearance +(`display.in_app_tips`, `display.in_app_tours`). Off covers Hermes as well as +the app: the switch reaches the connected gateway's config and the tool leaves +the model's schema, so the agent is never told about a surface it isn't allowed +to use. Like every schema change, that lands on the next session — a running +conversation keeps the toolset it started with, and the app declines the call in +the meantime. ## `todo` toolset