mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
feat: config-gated /verbose command for messaging gateway (#3262)
* feat: config-gated /verbose command for messaging gateway Add gateway_config_gate field to CommandDef, allowing cli_only commands to be conditionally available in the gateway based on a config value. - CommandDef gains gateway_config_gate: str | None — a config dotpath that, when truthy, overrides cli_only for gateway surfaces - /verbose uses gateway_config_gate='display.tool_progress_command' - Default is off (cli_only behavior preserved) - When enabled, /verbose cycles tool_progress mode (off/new/all/verbose) in the gateway, saving to config.yaml — same cycle as the CLI - Gateway helpers (help, telegram menus, slack mapping) dynamically check config to include/exclude config-gated commands - GATEWAY_KNOWN_COMMANDS always includes config-gated commands so the gateway recognizes them and can respond appropriately - Handles YAML 1.1 bool coercion (bare 'off' parses as False) - 8 new tests for the config gate mechanism + gateway handler * docs: document gateway_config_gate and /verbose messaging support - AGENTS.md: add gateway_config_gate to CommandDef fields - slash-commands.md: note /verbose can be enabled for messaging, update Notes - configuration.md: add tool_progress_command to display section + usage note - cli.md: cross-link to config docs for messaging enablement - messaging/index.md: show tool_progress_command in config snippet - plugins.md: add gateway_config_gate to register_command parameter table
This commit is contained in:
146
tests/gateway/test_verbose_command.py
Normal file
146
tests/gateway/test_verbose_command.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Tests for gateway /verbose command (config-gated tool progress cycling)."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import gateway.run as gateway_run
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_event(text="/verbose", platform=Platform.TELEGRAM, user_id="12345", chat_id="67890"):
|
||||
"""Build a MessageEvent for testing."""
|
||||
source = SessionSource(
|
||||
platform=platform,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
user_name="testuser",
|
||||
)
|
||||
return MessageEvent(text=text, source=source)
|
||||
|
||||
|
||||
def _make_runner():
|
||||
"""Create a bare GatewayRunner without calling __init__."""
|
||||
runner = object.__new__(gateway_run.GatewayRunner)
|
||||
runner.adapters = {}
|
||||
runner._ephemeral_system_prompt = ""
|
||||
runner._prefill_messages = []
|
||||
runner._reasoning_config = None
|
||||
runner._show_reasoning = False
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._running_agents = {}
|
||||
runner.hooks = MagicMock()
|
||||
runner.hooks.emit = AsyncMock()
|
||||
runner.hooks.loaded_hooks = []
|
||||
runner._session_db = None
|
||||
runner._get_or_create_gateway_honcho = lambda session_key: (None, None)
|
||||
return runner
|
||||
|
||||
|
||||
class TestVerboseCommand:
|
||||
"""Tests for _handle_verbose_command in the gateway."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_by_default(self, tmp_path, monkeypatch):
|
||||
"""When tool_progress_command is false, /verbose returns an info message."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text("display:\n tool_progress: all\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||
|
||||
runner = _make_runner()
|
||||
result = await runner._handle_verbose_command(_make_event())
|
||||
|
||||
assert "not enabled" in result.lower()
|
||||
assert "tool_progress_command" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enabled_cycles_mode(self, tmp_path, monkeypatch):
|
||||
"""When enabled, /verbose cycles tool_progress mode."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"display:\n tool_progress_command: true\n tool_progress: all\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||
|
||||
runner = _make_runner()
|
||||
result = await runner._handle_verbose_command(_make_event())
|
||||
|
||||
# all -> verbose
|
||||
assert "VERBOSE" in result
|
||||
|
||||
# Verify config was saved
|
||||
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["display"]["tool_progress"] == "verbose"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cycles_through_all_modes(self, tmp_path, monkeypatch):
|
||||
"""Calling /verbose repeatedly cycles through all four modes."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"display:\n tool_progress_command: true\n tool_progress: 'off'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||
runner = _make_runner()
|
||||
|
||||
# off -> new -> all -> verbose -> off
|
||||
expected = ["new", "all", "verbose", "off"]
|
||||
for mode in expected:
|
||||
result = await runner._handle_verbose_command(_make_event())
|
||||
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["display"]["tool_progress"] == mode, \
|
||||
f"Expected {mode}, got {saved['display']['tool_progress']}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeypatch):
|
||||
"""When tool_progress is not in config, defaults to 'all' then cycles to verbose."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"display:\n tool_progress_command: true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||
|
||||
runner = _make_runner()
|
||||
result = await runner._handle_verbose_command(_make_event())
|
||||
|
||||
# default "all" -> verbose
|
||||
assert "VERBOSE" in result
|
||||
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["display"]["tool_progress"] == "verbose"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_config_file_returns_disabled(self, tmp_path, monkeypatch):
|
||||
"""When config.yaml doesn't exist, command reports disabled."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
# No config.yaml
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||
|
||||
runner = _make_runner()
|
||||
result = await runner._handle_verbose_command(_make_event())
|
||||
assert "not enabled" in result.lower()
|
||||
|
||||
def test_verbose_is_in_gateway_known_commands(self):
|
||||
"""The /verbose command is recognized by the gateway dispatch."""
|
||||
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS
|
||||
assert "verbose" in GATEWAY_KNOWN_COMMANDS
|
||||
@@ -134,12 +134,19 @@ class TestDerivedDicts:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGatewayKnownCommands:
|
||||
def test_excludes_cli_only(self):
|
||||
def test_excludes_cli_only_without_config_gate(self):
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if cmd.cli_only:
|
||||
if cmd.cli_only and not cmd.gateway_config_gate:
|
||||
assert cmd.name not in GATEWAY_KNOWN_COMMANDS, \
|
||||
f"cli_only command '{cmd.name}' should not be in GATEWAY_KNOWN_COMMANDS"
|
||||
|
||||
def test_includes_config_gated_cli_only(self):
|
||||
"""Commands with gateway_config_gate are always in GATEWAY_KNOWN_COMMANDS."""
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if cmd.gateway_config_gate:
|
||||
assert cmd.name in GATEWAY_KNOWN_COMMANDS, \
|
||||
f"config-gated command '{cmd.name}' should be in GATEWAY_KNOWN_COMMANDS"
|
||||
|
||||
def test_includes_gateway_commands(self):
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if not cmd.cli_only:
|
||||
@@ -160,11 +167,11 @@ class TestGatewayHelpLines:
|
||||
lines = gateway_help_lines()
|
||||
assert len(lines) > 10
|
||||
|
||||
def test_excludes_cli_only_commands(self):
|
||||
def test_excludes_cli_only_commands_without_config_gate(self):
|
||||
lines = gateway_help_lines()
|
||||
joined = "\n".join(lines)
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if cmd.cli_only:
|
||||
if cmd.cli_only and not cmd.gateway_config_gate:
|
||||
assert f"`/{cmd.name}" not in joined, \
|
||||
f"cli_only command /{cmd.name} should not be in gateway help"
|
||||
|
||||
@@ -188,10 +195,10 @@ class TestTelegramBotCommands:
|
||||
for name, _ in telegram_bot_commands():
|
||||
assert "-" not in name, f"Telegram command '{name}' contains a hyphen"
|
||||
|
||||
def test_excludes_cli_only(self):
|
||||
def test_excludes_cli_only_without_config_gate(self):
|
||||
names = {name for name, _ in telegram_bot_commands()}
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if cmd.cli_only:
|
||||
if cmd.cli_only and not cmd.gateway_config_gate:
|
||||
tg_name = cmd.name.replace("-", "_")
|
||||
assert tg_name not in names
|
||||
|
||||
@@ -211,13 +218,84 @@ class TestSlackSubcommandMap:
|
||||
assert "bg" in mapping
|
||||
assert "reset" in mapping
|
||||
|
||||
def test_excludes_cli_only(self):
|
||||
def test_excludes_cli_only_without_config_gate(self):
|
||||
mapping = slack_subcommand_map()
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if cmd.cli_only:
|
||||
if cmd.cli_only and not cmd.gateway_config_gate:
|
||||
assert cmd.name not in mapping
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config-gated gateway commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGatewayConfigGate:
|
||||
"""Tests for the gateway_config_gate mechanism on CommandDef."""
|
||||
|
||||
def test_verbose_has_config_gate(self):
|
||||
cmd = resolve_command("verbose")
|
||||
assert cmd is not None
|
||||
assert cmd.cli_only is True
|
||||
assert cmd.gateway_config_gate == "display.tool_progress_command"
|
||||
|
||||
def test_verbose_in_gateway_known_commands(self):
|
||||
"""Config-gated commands are always recognized by the gateway."""
|
||||
assert "verbose" in GATEWAY_KNOWN_COMMANDS
|
||||
|
||||
def test_config_gate_excluded_from_help_when_off(self, tmp_path, monkeypatch):
|
||||
"""When the config gate is falsy, the command should not appear in help."""
|
||||
# Write a config with the gate off (default)
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("display:\n tool_progress_command: false\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
lines = gateway_help_lines()
|
||||
joined = "\n".join(lines)
|
||||
assert "`/verbose" not in joined
|
||||
|
||||
def test_config_gate_included_in_help_when_on(self, tmp_path, monkeypatch):
|
||||
"""When the config gate is truthy, the command should appear in help."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("display:\n tool_progress_command: true\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
lines = gateway_help_lines()
|
||||
joined = "\n".join(lines)
|
||||
assert "`/verbose" in joined
|
||||
|
||||
def test_config_gate_excluded_from_telegram_when_off(self, tmp_path, monkeypatch):
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("display:\n tool_progress_command: false\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
names = {name for name, _ in telegram_bot_commands()}
|
||||
assert "verbose" not in names
|
||||
|
||||
def test_config_gate_included_in_telegram_when_on(self, tmp_path, monkeypatch):
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("display:\n tool_progress_command: true\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
names = {name for name, _ in telegram_bot_commands()}
|
||||
assert "verbose" in names
|
||||
|
||||
def test_config_gate_excluded_from_slack_when_off(self, tmp_path, monkeypatch):
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("display:\n tool_progress_command: false\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
mapping = slack_subcommand_map()
|
||||
assert "verbose" not in mapping
|
||||
|
||||
def test_config_gate_included_in_slack_when_on(self, tmp_path, monkeypatch):
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("display:\n tool_progress_command: true\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
mapping = slack_subcommand_map()
|
||||
assert "verbose" in mapping
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Autocomplete (SlashCommandCompleter)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user