test_verbose_command.py
1 """Tests for gateway /verbose command (config-gated tool progress cycling).""" 2 3 import asyncio 4 from unittest.mock import AsyncMock, MagicMock 5 6 import pytest 7 import yaml 8 9 import gateway.run as gateway_run 10 from gateway.config import Platform 11 from gateway.platforms.base import MessageEvent 12 from gateway.session import SessionSource 13 14 15 def _make_event(text="/verbose", platform=Platform.TELEGRAM, user_id="12345", chat_id="67890"): 16 """Build a MessageEvent for testing.""" 17 source = SessionSource( 18 platform=platform, 19 user_id=user_id, 20 chat_id=chat_id, 21 user_name="testuser", 22 ) 23 return MessageEvent(text=text, source=source) 24 25 26 def _make_runner(): 27 """Create a bare GatewayRunner without calling __init__.""" 28 runner = object.__new__(gateway_run.GatewayRunner) 29 runner.adapters = {} 30 runner._ephemeral_system_prompt = "" 31 runner._prefill_messages = [] 32 runner._reasoning_config = None 33 runner._show_reasoning = False 34 runner._provider_routing = {} 35 runner._fallback_model = None 36 runner._running_agents = {} 37 runner.hooks = MagicMock() 38 runner.hooks.emit = AsyncMock() 39 runner.hooks.loaded_hooks = [] 40 runner._session_db = None 41 runner._get_or_create_gateway_honcho = lambda session_key: (None, None) 42 return runner 43 44 45 class TestVerboseCommand: 46 """Tests for _handle_verbose_command in the gateway.""" 47 48 @pytest.mark.asyncio 49 async def test_disabled_by_default(self, tmp_path, monkeypatch): 50 """When tool_progress_command is false, /verbose returns an info message.""" 51 hermes_home = tmp_path / "hermes" 52 hermes_home.mkdir() 53 config_path = hermes_home / "config.yaml" 54 config_path.write_text("display:\n tool_progress: all\n", encoding="utf-8") 55 56 monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) 57 58 runner = _make_runner() 59 result = await runner._handle_verbose_command(_make_event()) 60 61 assert "not enabled" in result.lower() 62 assert "tool_progress_command" in result 63 64 @pytest.mark.asyncio 65 async def test_enabled_cycles_mode(self, tmp_path, monkeypatch): 66 """When enabled, /verbose cycles tool_progress mode per-platform.""" 67 hermes_home = tmp_path / "hermes" 68 hermes_home.mkdir() 69 config_path = hermes_home / "config.yaml" 70 config_path.write_text( 71 "display:\n tool_progress_command: true\n tool_progress: all\n", 72 encoding="utf-8", 73 ) 74 75 monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) 76 77 runner = _make_runner() 78 result = await runner._handle_verbose_command(_make_event()) 79 80 # all -> verbose 81 assert "VERBOSE" in result 82 assert "telegram" in result.lower() # per-platform feedback 83 84 # Verify config was saved to display.platforms.telegram 85 saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) 86 assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "verbose" 87 88 @pytest.mark.asyncio 89 async def test_quoted_false_keeps_command_disabled(self, tmp_path, monkeypatch): 90 """Quoted false must not enable the /verbose gateway command.""" 91 hermes_home = tmp_path / "hermes" 92 hermes_home.mkdir() 93 config_path = hermes_home / "config.yaml" 94 config_path.write_text( 95 'display:\n tool_progress_command: "false"\n tool_progress: all\n', 96 encoding="utf-8", 97 ) 98 99 monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) 100 101 runner = _make_runner() 102 result = await runner._handle_verbose_command(_make_event()) 103 104 assert "not enabled" in result.lower() 105 assert "tool_progress_command" in result 106 107 @pytest.mark.asyncio 108 async def test_cycles_through_all_modes(self, tmp_path, monkeypatch): 109 """Calling /verbose repeatedly cycles through all four modes.""" 110 hermes_home = tmp_path / "hermes" 111 hermes_home.mkdir() 112 config_path = hermes_home / "config.yaml" 113 config_path.write_text( 114 "display:\n tool_progress_command: true\n tool_progress: 'off'\n", 115 encoding="utf-8", 116 ) 117 118 monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) 119 runner = _make_runner() 120 121 # off -> new -> all -> verbose -> off 122 expected = ["new", "all", "verbose", "off"] 123 for mode in expected: 124 result = await runner._handle_verbose_command(_make_event()) 125 saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) 126 actual = saved["display"]["platforms"]["telegram"]["tool_progress"] 127 assert actual == mode, \ 128 f"Expected {mode}, got {actual}" 129 130 @pytest.mark.asyncio 131 async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeypatch): 132 """When tool_progress is not in config, defaults to 'all' then cycles to verbose.""" 133 hermes_home = tmp_path / "hermes" 134 hermes_home.mkdir() 135 config_path = hermes_home / "config.yaml" 136 config_path.write_text( 137 "display:\n tool_progress_command: true\n", 138 encoding="utf-8", 139 ) 140 141 monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) 142 143 runner = _make_runner() 144 result = await runner._handle_verbose_command(_make_event()) 145 146 # Telegram default is "all" (high tier) → cycles to verbose 147 assert "VERBOSE" in result 148 saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) 149 assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "verbose" 150 151 @pytest.mark.asyncio 152 async def test_per_platform_isolation(self, tmp_path, monkeypatch): 153 """Cycling /verbose on Telegram doesn't change Slack's setting. 154 155 Without a global tool_progress, each platform uses its built-in 156 default: Telegram = 'all' (high tier), Slack = 'off' (quiet Slack default). 157 """ 158 hermes_home = tmp_path / "hermes" 159 hermes_home.mkdir() 160 config_path = hermes_home / "config.yaml" 161 # No global tool_progress → built-in platform defaults apply 162 config_path.write_text( 163 "display:\n tool_progress_command: true\n", 164 encoding="utf-8", 165 ) 166 167 monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) 168 runner = _make_runner() 169 170 # Cycle on Telegram 171 await runner._handle_verbose_command( 172 _make_event(platform=Platform.TELEGRAM) 173 ) 174 # Cycle on Slack 175 await runner._handle_verbose_command( 176 _make_event(platform=Platform.SLACK) 177 ) 178 179 saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) 180 platforms = saved["display"]["platforms"] 181 # Telegram: all -> verbose (high tier default = all) 182 assert platforms["telegram"]["tool_progress"] == "verbose" 183 # Slack: off -> new (first /verbose cycle from quiet default) 184 assert platforms["slack"]["tool_progress"] == "new" 185 186 @pytest.mark.asyncio 187 async def test_no_config_file_returns_disabled(self, tmp_path, monkeypatch): 188 """When config.yaml doesn't exist, command reports disabled.""" 189 hermes_home = tmp_path / "hermes" 190 hermes_home.mkdir() 191 # No config.yaml 192 193 monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) 194 195 runner = _make_runner() 196 result = await runner._handle_verbose_command(_make_event()) 197 assert "not enabled" in result.lower() 198 199 def test_verbose_is_in_gateway_known_commands(self): 200 """The /verbose command is recognized by the gateway dispatch.""" 201 from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS 202 assert "verbose" in GATEWAY_KNOWN_COMMANDS