Files
hermes-agent/tests/gateway/test_update_command.py
Teknium 0c54da8aaf feat(gateway): live-stream /update output + interactive prompt buttons (#5180)
* feat(gateway): live-stream /update output + forward interactive prompts

Adds real-time output streaming and interactive prompt forwarding for
the gateway /update command, so users on Telegram/Discord/etc see the
full update progress and can respond to prompts (stash restore, config
migration) without needing terminal access.

Changes:

hermes_cli/main.py:
- Add --gateway flag to 'hermes update' argparse
- Add _gateway_prompt() file-based IPC function that writes
  .update_prompt.json and polls for .update_response
- Modify _restore_stashed_changes() to accept optional input_fn
  parameter for gateway mode prompt forwarding
- cmd_update() uses _gateway_prompt when --gateway is set, enabling
  interactive stash restore and config migration prompts

gateway/run.py:
- _handle_update_command: spawn with --gateway flag and
  PYTHONUNBUFFERED=1 for real-time output flushing
- Store session_key in .update_pending.json for cross-restart
  session matching
- Add _update_prompt_pending dict to track sessions awaiting
  update prompt responses
- Replace _watch_for_update_completion with _watch_update_progress:
  streams output chunks every ~4s, detects .update_prompt.json and
  forwards prompts to the user, handles completion/failure/timeout
- Add update prompt interception in _handle_message: when a prompt
  is pending, the user's next message is written to .update_response
  instead of being processed normally
- Preserve _send_update_notification as legacy fallback for
  post-restart cases where adapter isn't available yet

File-based IPC protocol:
- .update_prompt.json: written by update process with prompt text,
  default value, and unique ID
- .update_response: written by gateway with user's answer
- .update_output.txt: existing, now streamed in real-time
- .update_exit_code: existing completion marker

Tests: 16 new tests covering _gateway_prompt IPC, output streaming,
prompt detection/forwarding, message interception, and cleanup.

* feat: interactive buttons for update prompts (Telegram + Discord)

Telegram: Inline keyboard with ✓ Yes / ✗ No buttons. Clicking a button
answers the callback query, edits the message to show the choice, and
writes .update_response directly. CallbackQueryHandler registered on
the update_prompt: prefix.

Discord: UpdatePromptView (discord.ui.View) with green Yes / red No
buttons. Follows the ExecApprovalView pattern — auth check, embed color
update, disabled-after-click. Writes .update_response on click.

All platforms: /approve and /deny (and /yes, /no) now work as shorthand
for yes/no when an update prompt is pending. The text fallback message
instructs users to use these commands. Raw message interception still
works as a fallback for non-command responses.

Gateway watcher checks adapter for send_update_prompt method (class-level
check to avoid MagicMock false positives) and falls back to text prompt
with /approve instructions when unavailable.

* fix: block /update on non-messaging platforms (API, webhooks, ACP)

Add _UPDATE_ALLOWED_PLATFORMS frozenset that explicitly lists messaging
platforms where /update is permitted. API server, webhook, and ACP
platforms get a clear error directing them to run hermes update from
the terminal instead.

ACP and API server already don't reach _handle_message (separate
codepaths), and webhooks have distinct session keys that can't collide
with messaging sessions. This guard is belt-and-suspenders.
2026-04-05 00:28:58 -07:00

652 lines
25 KiB
Python

"""Tests for /update gateway slash command.
Tests both the _handle_update_command handler (spawns update process) and
the _send_update_notification startup hook (sends results after restart).
"""
import json
import os
from pathlib import Path
from unittest.mock import patch, MagicMock, AsyncMock
import pytest
from gateway.config import Platform
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource
def _make_event(text="/update", 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__."""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.adapters = {}
runner._voice_mode = {}
return runner
# ---------------------------------------------------------------------------
# _handle_update_command
# ---------------------------------------------------------------------------
class TestHandleUpdateCommand:
"""Tests for GatewayRunner._handle_update_command."""
@pytest.mark.asyncio
async def test_managed_install_returns_package_manager_guidance(self, monkeypatch):
runner = _make_runner()
event = _make_event()
monkeypatch.setenv("HERMES_MANAGED", "homebrew")
result = await runner._handle_update_command(event)
assert "managed by Homebrew" in result
assert "brew upgrade hermes-agent" in result
@pytest.mark.asyncio
async def test_no_git_directory(self, tmp_path):
"""Returns an error when .git does not exist."""
runner = _make_runner()
event = _make_event()
# Point _hermes_home to tmp_path and project_root to a dir without .git
fake_root = tmp_path / "project"
fake_root.mkdir()
with patch("gateway.run._hermes_home", tmp_path), \
patch("gateway.run.Path") as MockPath:
# Path(__file__).parent.parent.resolve() -> fake_root
MockPath.return_value = MagicMock()
MockPath.__truediv__ = Path.__truediv__
# Easier: just patch the __file__ resolution in the method
pass
# Simpler approach — mock at method level using a wrapper
from gateway.run import GatewayRunner
runner = _make_runner()
with patch("gateway.run._hermes_home", tmp_path):
# The handler does Path(__file__).parent.parent.resolve()
# We need to make project_root / '.git' not exist.
# Since Path(__file__) resolves to the real gateway/run.py,
# project_root will be the real hermes-agent dir (which HAS .git).
# Patch Path to control this.
original_path = Path
class FakePath(type(Path())):
pass
# Actually, simplest: just patch the specific file attr
fake_file = str(fake_root / "gateway" / "run.py")
(fake_root / "gateway").mkdir(parents=True)
(fake_root / "gateway" / "run.py").touch()
with patch("gateway.run.__file__", fake_file):
result = await runner._handle_update_command(event)
assert "Not a git repository" in result
@pytest.mark.asyncio
async def test_no_hermes_binary(self, tmp_path):
"""Returns error when hermes is not on PATH and hermes_cli is not importable."""
runner = _make_runner()
event = _make_event()
# Create project dir WITH .git
fake_root = tmp_path / "project"
fake_root.mkdir()
(fake_root / ".git").mkdir()
(fake_root / "gateway").mkdir()
(fake_root / "gateway" / "run.py").touch()
fake_file = str(fake_root / "gateway" / "run.py")
with patch("gateway.run._hermes_home", tmp_path), \
patch("gateway.run.__file__", fake_file), \
patch("shutil.which", return_value=None), \
patch("importlib.util.find_spec", return_value=None):
result = await runner._handle_update_command(event)
assert "Could not locate" in result
assert "hermes update" in result
@pytest.mark.asyncio
async def test_fallback_to_sys_executable(self, tmp_path):
"""Falls back to sys.executable -m hermes_cli.main when hermes not on PATH."""
runner = _make_runner()
event = _make_event()
fake_root = tmp_path / "project"
fake_root.mkdir()
(fake_root / ".git").mkdir()
(fake_root / "gateway").mkdir()
(fake_root / "gateway" / "run.py").touch()
fake_file = str(fake_root / "gateway" / "run.py")
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
mock_popen = MagicMock()
fake_spec = MagicMock()
with patch("gateway.run._hermes_home", hermes_home), \
patch("gateway.run.__file__", fake_file), \
patch("shutil.which", return_value=None), \
patch("importlib.util.find_spec", return_value=fake_spec), \
patch("subprocess.Popen", mock_popen):
result = await runner._handle_update_command(event)
assert "Starting Hermes update" in result
call_args = mock_popen.call_args[0][0]
# The update_cmd uses sys.executable -m hermes_cli.main
joined = " ".join(call_args) if isinstance(call_args, list) else call_args
assert "hermes_cli.main" in joined or "bash" in call_args[0]
@pytest.mark.asyncio
async def test_resolve_hermes_bin_prefers_which(self, tmp_path):
"""_resolve_hermes_bin returns argv parts from shutil.which when available."""
from gateway.run import _resolve_hermes_bin
with patch("shutil.which", return_value="/custom/path/hermes"):
result = _resolve_hermes_bin()
assert result == ["/custom/path/hermes"]
@pytest.mark.asyncio
async def test_resolve_hermes_bin_fallback(self):
"""_resolve_hermes_bin falls back to sys.executable argv when which fails."""
import sys
from gateway.run import _resolve_hermes_bin
fake_spec = MagicMock()
with patch("shutil.which", return_value=None), \
patch("importlib.util.find_spec", return_value=fake_spec):
result = _resolve_hermes_bin()
assert result == [sys.executable, "-m", "hermes_cli.main"]
@pytest.mark.asyncio
async def test_resolve_hermes_bin_returns_none_when_both_fail(self):
"""_resolve_hermes_bin returns None when both strategies fail."""
from gateway.run import _resolve_hermes_bin
with patch("shutil.which", return_value=None), \
patch("importlib.util.find_spec", return_value=None):
result = _resolve_hermes_bin()
assert result is None
@pytest.mark.asyncio
async def test_writes_pending_marker(self, tmp_path):
"""Writes .update_pending.json with correct platform and chat info."""
runner = _make_runner()
event = _make_event(platform=Platform.TELEGRAM, chat_id="99999")
fake_root = tmp_path / "project"
fake_root.mkdir()
(fake_root / ".git").mkdir()
(fake_root / "gateway").mkdir()
(fake_root / "gateway" / "run.py").touch()
fake_file = str(fake_root / "gateway" / "run.py")
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
with patch("gateway.run._hermes_home", hermes_home), \
patch("gateway.run.__file__", fake_file), \
patch("shutil.which", side_effect=lambda x: "/usr/bin/hermes" if x == "hermes" else "/usr/bin/setsid"), \
patch("subprocess.Popen"):
result = await runner._handle_update_command(event)
pending_path = hermes_home / ".update_pending.json"
assert pending_path.exists()
data = json.loads(pending_path.read_text())
assert data["platform"] == "telegram"
assert data["chat_id"] == "99999"
assert "timestamp" in data
assert not (hermes_home / ".update_exit_code").exists()
@pytest.mark.asyncio
async def test_spawns_setsid(self, tmp_path):
"""Uses setsid when available."""
runner = _make_runner()
event = _make_event()
fake_root = tmp_path / "project"
fake_root.mkdir()
(fake_root / ".git").mkdir()
(fake_root / "gateway").mkdir()
(fake_root / "gateway" / "run.py").touch()
fake_file = str(fake_root / "gateway" / "run.py")
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
mock_popen = MagicMock()
with patch("gateway.run._hermes_home", hermes_home), \
patch("gateway.run.__file__", fake_file), \
patch("shutil.which", side_effect=lambda x: f"/usr/bin/{x}"), \
patch("subprocess.Popen", mock_popen):
result = await runner._handle_update_command(event)
# Verify setsid was used
call_args = mock_popen.call_args[0][0]
assert call_args[0] == "/usr/bin/setsid"
assert call_args[1] == "bash"
assert ".update_exit_code" in call_args[-1]
assert "Starting Hermes update" in result
@pytest.mark.asyncio
async def test_fallback_when_no_setsid(self, tmp_path):
"""Falls back to start_new_session=True when setsid is not available."""
runner = _make_runner()
event = _make_event()
fake_root = tmp_path / "project"
fake_root.mkdir()
(fake_root / ".git").mkdir()
(fake_root / "gateway").mkdir()
(fake_root / "gateway" / "run.py").touch()
fake_file = str(fake_root / "gateway" / "run.py")
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
mock_popen = MagicMock()
def which_no_setsid(x):
if x == "hermes":
return "/usr/bin/hermes"
if x == "setsid":
return None
return None
with patch("gateway.run._hermes_home", hermes_home), \
patch("gateway.run.__file__", fake_file), \
patch("shutil.which", side_effect=which_no_setsid), \
patch("subprocess.Popen", mock_popen):
result = await runner._handle_update_command(event)
# Verify plain bash -c fallback (no nohup, no setsid)
call_args = mock_popen.call_args[0][0]
assert call_args[0] == "bash"
assert "nohup" not in call_args[2]
assert ".update_exit_code" in call_args[2]
# start_new_session=True should be in kwargs
call_kwargs = mock_popen.call_args[1]
assert call_kwargs.get("start_new_session") is True
assert "Starting Hermes update" in result
@pytest.mark.asyncio
async def test_popen_failure_cleans_up(self, tmp_path):
"""Cleans up pending file and returns error on Popen failure."""
runner = _make_runner()
event = _make_event()
fake_root = tmp_path / "project"
fake_root.mkdir()
(fake_root / ".git").mkdir()
(fake_root / "gateway").mkdir()
(fake_root / "gateway" / "run.py").touch()
fake_file = str(fake_root / "gateway" / "run.py")
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
with patch("gateway.run._hermes_home", hermes_home), \
patch("gateway.run.__file__", fake_file), \
patch("shutil.which", side_effect=lambda x: f"/usr/bin/{x}"), \
patch("subprocess.Popen", side_effect=OSError("spawn failed")):
result = await runner._handle_update_command(event)
assert "Failed to start update" in result
# Pending file should be cleaned up
assert not (hermes_home / ".update_pending.json").exists()
assert not (hermes_home / ".update_exit_code").exists()
@pytest.mark.asyncio
async def test_returns_user_friendly_message(self, tmp_path):
"""The success response is user-friendly."""
runner = _make_runner()
event = _make_event()
fake_root = tmp_path / "project"
fake_root.mkdir()
(fake_root / ".git").mkdir()
(fake_root / "gateway").mkdir()
(fake_root / "gateway" / "run.py").touch()
fake_file = str(fake_root / "gateway" / "run.py")
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
with patch("gateway.run._hermes_home", hermes_home), \
patch("gateway.run.__file__", fake_file), \
patch("shutil.which", side_effect=lambda x: f"/usr/bin/{x}"), \
patch("subprocess.Popen"):
result = await runner._handle_update_command(event)
assert "stream progress" in result
# ---------------------------------------------------------------------------
# _send_update_notification
# ---------------------------------------------------------------------------
class TestSendUpdateNotification:
"""Tests for GatewayRunner._send_update_notification."""
@pytest.mark.asyncio
async def test_no_pending_file_is_noop(self, tmp_path):
"""Does nothing when no pending file exists."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
with patch("gateway.run._hermes_home", hermes_home):
# Should not raise
await runner._send_update_notification()
@pytest.mark.asyncio
async def test_defers_notification_while_update_still_running(self, tmp_path):
"""Returns False and keeps marker files when the update has not exited yet."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending_path = hermes_home / ".update_pending.json"
pending_path.write_text(json.dumps({
"platform": "telegram", "chat_id": "67890", "user_id": "12345",
}))
(hermes_home / ".update_output.txt").write_text("still running")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
result = await runner._send_update_notification()
assert result is False
mock_adapter.send.assert_not_called()
assert pending_path.exists()
@pytest.mark.asyncio
async def test_recovers_from_claimed_pending_file(self, tmp_path):
"""A claimed pending file from a crashed notifier is still deliverable."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
claimed_path = hermes_home / ".update_pending.claimed.json"
claimed_path.write_text(json.dumps({
"platform": "telegram", "chat_id": "67890", "user_id": "12345",
}))
(hermes_home / ".update_output.txt").write_text("done")
(hermes_home / ".update_exit_code").write_text("0")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
result = await runner._send_update_notification()
assert result is True
mock_adapter.send.assert_called_once()
assert not claimed_path.exists()
@pytest.mark.asyncio
async def test_sends_notification_with_output(self, tmp_path):
"""Sends update output to the correct platform and chat."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
# Write pending marker
pending = {
"platform": "telegram",
"chat_id": "67890",
"user_id": "12345",
"timestamp": "2026-03-04T21:00:00",
}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
(hermes_home / ".update_output.txt").write_text(
"→ Found 3 new commit(s)\n✓ Code updated!\n✓ Update complete!"
)
(hermes_home / ".update_exit_code").write_text("0")
# Mock the adapter
mock_adapter = AsyncMock()
mock_adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
await runner._send_update_notification()
mock_adapter.send.assert_called_once()
call_args = mock_adapter.send.call_args
assert call_args[0][0] == "67890" # chat_id
assert "Update complete" in call_args[0][1] or "update finished" in call_args[0][1].lower()
@pytest.mark.asyncio
async def test_strips_ansi_codes(self, tmp_path):
"""ANSI escape codes are removed from output."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
(hermes_home / ".update_output.txt").write_text(
"\x1b[32m✓ Code updated!\x1b[0m\n\x1b[1mDone\x1b[0m"
)
(hermes_home / ".update_exit_code").write_text("0")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
await runner._send_update_notification()
sent_text = mock_adapter.send.call_args[0][1]
assert "\x1b[" not in sent_text
assert "Code updated" in sent_text
@pytest.mark.asyncio
async def test_truncates_long_output(self, tmp_path):
"""Output longer than 3500 chars is truncated."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
(hermes_home / ".update_output.txt").write_text("x" * 5000)
(hermes_home / ".update_exit_code").write_text("0")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
await runner._send_update_notification()
sent_text = mock_adapter.send.call_args[0][1]
# Should start with truncation marker
assert "" in sent_text
# Total message should not be absurdly long
assert len(sent_text) < 4500
@pytest.mark.asyncio
async def test_sends_failure_message_when_update_fails(self, tmp_path):
"""Non-zero exit codes produce a failure notification with captured output."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
(hermes_home / ".update_output.txt").write_text("Traceback: boom")
(hermes_home / ".update_exit_code").write_text("1")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
result = await runner._send_update_notification()
assert result is True
sent_text = mock_adapter.send.call_args[0][1]
assert "update failed" in sent_text.lower()
assert "Traceback: boom" in sent_text
@pytest.mark.asyncio
async def test_sends_generic_message_when_no_output(self, tmp_path):
"""Sends a success message even if the output file is missing."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
# No .update_output.txt created
(hermes_home / ".update_exit_code").write_text("0")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
await runner._send_update_notification()
sent_text = mock_adapter.send.call_args[0][1]
assert "finished successfully" in sent_text
@pytest.mark.asyncio
async def test_cleans_up_files_after_notification(self, tmp_path):
"""Both marker and output files are deleted after notification."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending_path = hermes_home / ".update_pending.json"
output_path = hermes_home / ".update_output.txt"
exit_code_path = hermes_home / ".update_exit_code"
pending_path.write_text(json.dumps({
"platform": "telegram", "chat_id": "111", "user_id": "222",
}))
output_path.write_text("✓ Done")
exit_code_path.write_text("0")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
await runner._send_update_notification()
assert not pending_path.exists()
assert not output_path.exists()
assert not exit_code_path.exists()
@pytest.mark.asyncio
async def test_cleans_up_on_error(self, tmp_path):
"""Files are cleaned up even if notification fails."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending_path = hermes_home / ".update_pending.json"
output_path = hermes_home / ".update_output.txt"
exit_code_path = hermes_home / ".update_exit_code"
pending_path.write_text(json.dumps({
"platform": "telegram", "chat_id": "111", "user_id": "222",
}))
output_path.write_text("✓ Done")
exit_code_path.write_text("0")
# Adapter send raises
mock_adapter = AsyncMock()
mock_adapter.send.side_effect = RuntimeError("network error")
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
await runner._send_update_notification()
# Files should still be cleaned up (finally block)
assert not pending_path.exists()
assert not output_path.exists()
assert not exit_code_path.exists()
@pytest.mark.asyncio
async def test_handles_corrupt_pending_file(self, tmp_path):
"""Gracefully handles a malformed pending JSON file."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending_path = hermes_home / ".update_pending.json"
pending_path.write_text("{corrupt json!!")
with patch("gateway.run._hermes_home", hermes_home):
# Should not raise
await runner._send_update_notification()
# File should be cleaned up
assert not pending_path.exists()
@pytest.mark.asyncio
async def test_no_adapter_for_platform(self, tmp_path):
"""Does not crash if the platform adapter is not connected."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {"platform": "discord", "chat_id": "111", "user_id": "222"}
pending_path = hermes_home / ".update_pending.json"
output_path = hermes_home / ".update_output.txt"
exit_code_path = hermes_home / ".update_exit_code"
pending_path.write_text(json.dumps(pending))
output_path.write_text("Done")
exit_code_path.write_text("0")
# Only telegram adapter available, but pending says discord
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
await runner._send_update_notification()
# send should not have been called (wrong platform)
mock_adapter.send.assert_not_called()
# Files should still be cleaned up
assert not pending_path.exists()
assert not exit_code_path.exists()
# ---------------------------------------------------------------------------
# /update in help and known_commands
# ---------------------------------------------------------------------------
class TestUpdateInHelp:
"""Verify /update appears in help text and known commands set."""
@pytest.mark.asyncio
async def test_update_in_help_output(self):
"""The /help output includes /update."""
runner = _make_runner()
event = _make_event(text="/help")
result = await runner._handle_help_command(event)
assert "/update" in result
def test_update_is_known_command(self):
"""The /update command is in the help text (proxy for _known_commands)."""
# _known_commands is local to _handle_message, so we verify by
# checking the help output includes it.
from gateway.run import GatewayRunner
import inspect
source = inspect.getsource(GatewayRunner._handle_message)
assert '"update"' in source