feat: add /update slash command for gateway platforms
Adds a /update command to Telegram, Discord, and other gateway platforms that runs `hermes update` to pull the latest code, update dependencies, sync skills, and restart the gateway. Implementation: - Spawns `hermes update` in a separate systemd scope (systemd-run --user --scope) so the process survives the gateway restart that hermes update triggers at the end. Falls back to nohup if systemd-run is unavailable. - Writes a marker file (.update_pending.json) with the originating platform and chat_id before spawning the update. - On gateway startup, _send_update_notification() checks for the marker, reads the captured update output, sends the results back to the user, and cleans up. Also: - Registers /update as a Discord slash command - Updates README.md, docs/messaging.md, docs/slash-commands.md - Adds 18 tests covering handler, notification, and edge cases
This commit is contained in:
parent
2af2f148ab
commit
d400fb8b23
6 changed files with 623 additions and 1 deletions
|
|
@ -336,6 +336,8 @@ See [docs/messaging.md](docs/messaging.md) for advanced WhatsApp configuration.
|
||||||
| `/sethome` | Set this chat as the home channel |
|
| `/sethome` | Set this chat as the home channel |
|
||||||
| `/compress` | Manually compress conversation context |
|
| `/compress` | Manually compress conversation context |
|
||||||
| `/usage` | Show token usage for this session |
|
| `/usage` | Show token usage for this session |
|
||||||
|
| `/reload-mcp` | Reload MCP servers from config |
|
||||||
|
| `/update` | Update Hermes Agent to the latest version |
|
||||||
| `/help` | Show available commands |
|
| `/help` | Show available commands |
|
||||||
| `/<skill-name>` | Invoke any installed skill (e.g., `/axolotl`, `/gif-search`) |
|
| `/<skill-name>` | Invoke any installed skill (e.g., `/axolotl`, `/gif-search`) |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ Send `/new` or `/reset` as a message to start fresh.
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `/compress` | Manually compress conversation context (saves memories, then summarizes) |
|
| `/compress` | Manually compress conversation context (saves memories, then summarizes) |
|
||||||
| `/usage` | Show token usage and context window status for the current session |
|
| `/usage` | Show token usage and context window status for the current session |
|
||||||
|
| `/update` | Update Hermes Agent to the latest version (pulls code, updates deps, restarts gateway) |
|
||||||
|
| `/reload-mcp` | Disconnect and reconnect all MCP servers from config |
|
||||||
|
|
||||||
### Per-Platform Overrides
|
### Per-Platform Overrides
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,20 @@ Quick reference for all CLI slash commands in Hermes Agent.
|
||||||
| `/skills` | Search, install, or manage skills |
|
| `/skills` | Search, install, or manage skills |
|
||||||
| `/platforms` | Show gateway/messaging platform status |
|
| `/platforms` | Show gateway/messaging platform status |
|
||||||
|
|
||||||
|
## Gateway Only
|
||||||
|
|
||||||
|
These commands are available in messaging platforms (Telegram, Discord, etc.) but not the interactive CLI:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/stop` | Stop the running agent |
|
||||||
|
| `/sethome` | Set this chat as the home channel |
|
||||||
|
| `/compress` | Manually compress conversation context |
|
||||||
|
| `/usage` | Show token usage for the current session |
|
||||||
|
| `/reload-mcp` | Reload MCP servers from config |
|
||||||
|
| `/update` | Update Hermes Agent to the latest version |
|
||||||
|
| `/status` | Show session info |
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
### Changing Models
|
### Changing Models
|
||||||
|
|
|
||||||
|
|
@ -533,6 +533,16 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Discord followup failed: %s", e)
|
logger.debug("Discord followup failed: %s", e)
|
||||||
|
|
||||||
|
@tree.command(name="update", description="Update Hermes Agent to the latest version")
|
||||||
|
async def slash_update(interaction: discord.Interaction):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
event = self._build_slash_event(interaction, "/update")
|
||||||
|
await self.handle_message(event)
|
||||||
|
try:
|
||||||
|
await interaction.followup.send("Update initiated~", ephemeral=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Discord followup failed: %s", e)
|
||||||
|
|
||||||
def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:
|
def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:
|
||||||
"""Build a MessageEvent from a Discord slash command interaction."""
|
"""Build a MessageEvent from a Discord slash command interaction."""
|
||||||
is_dm = isinstance(interaction.channel, discord.DMChannel)
|
is_dm = isinstance(interaction.channel, discord.DMChannel)
|
||||||
|
|
|
||||||
114
gateway/run.py
114
gateway/run.py
|
|
@ -455,6 +455,9 @@ class GatewayRunner:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Channel directory build failed: %s", e)
|
logger.warning("Channel directory build failed: %s", e)
|
||||||
|
|
||||||
|
# Check if we're restarting after a /update command
|
||||||
|
await self._send_update_notification()
|
||||||
|
|
||||||
logger.info("Press Ctrl+C to stop")
|
logger.info("Press Ctrl+C to stop")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
@ -655,7 +658,7 @@ class GatewayRunner:
|
||||||
# Emit command:* hook for any recognized slash command
|
# Emit command:* hook for any recognized slash command
|
||||||
_known_commands = {"new", "reset", "help", "status", "stop", "model",
|
_known_commands = {"new", "reset", "help", "status", "stop", "model",
|
||||||
"personality", "retry", "undo", "sethome", "set-home",
|
"personality", "retry", "undo", "sethome", "set-home",
|
||||||
"compress", "usage", "reload-mcp"}
|
"compress", "usage", "reload-mcp", "update"}
|
||||||
if command and command in _known_commands:
|
if command and command in _known_commands:
|
||||||
await self.hooks.emit(f"command:{command}", {
|
await self.hooks.emit(f"command:{command}", {
|
||||||
"platform": source.platform.value if source.platform else "",
|
"platform": source.platform.value if source.platform else "",
|
||||||
|
|
@ -699,6 +702,9 @@ class GatewayRunner:
|
||||||
|
|
||||||
if command == "reload-mcp":
|
if command == "reload-mcp":
|
||||||
return await self._handle_reload_mcp_command(event)
|
return await self._handle_reload_mcp_command(event)
|
||||||
|
|
||||||
|
if command == "update":
|
||||||
|
return await self._handle_update_command(event)
|
||||||
|
|
||||||
# Skill slash commands: /skill-name loads the skill and sends to agent
|
# Skill slash commands: /skill-name loads the skill and sends to agent
|
||||||
if command:
|
if command:
|
||||||
|
|
@ -1098,6 +1104,7 @@ class GatewayRunner:
|
||||||
"`/compress` — Compress conversation context",
|
"`/compress` — Compress conversation context",
|
||||||
"`/usage` — Show token usage for this session",
|
"`/usage` — Show token usage for this session",
|
||||||
"`/reload-mcp` — Reload MCP servers from config",
|
"`/reload-mcp` — Reload MCP servers from config",
|
||||||
|
"`/update` — Update Hermes Agent to the latest version",
|
||||||
"`/help` — Show this message",
|
"`/help` — Show this message",
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
|
|
@ -1460,6 +1467,111 @@ class GatewayRunner:
|
||||||
logger.warning("MCP reload failed: %s", e)
|
logger.warning("MCP reload failed: %s", e)
|
||||||
return f"❌ MCP reload failed: {e}"
|
return f"❌ MCP reload failed: {e}"
|
||||||
|
|
||||||
|
async def _handle_update_command(self, event: MessageEvent) -> str:
|
||||||
|
"""Handle /update command — update Hermes Agent to the latest version.
|
||||||
|
|
||||||
|
Spawns ``hermes update`` in a separate systemd scope so it survives the
|
||||||
|
gateway restart that ``hermes update`` triggers at the end. A marker
|
||||||
|
file is written so the *new* gateway process can notify the user of the
|
||||||
|
result on startup.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
project_root = Path(__file__).parent.parent.resolve()
|
||||||
|
git_dir = project_root / '.git'
|
||||||
|
|
||||||
|
if not git_dir.exists():
|
||||||
|
return "✗ Not a git repository — cannot update."
|
||||||
|
|
||||||
|
hermes_bin = shutil.which("hermes")
|
||||||
|
if not hermes_bin:
|
||||||
|
return "✗ `hermes` command not found on PATH."
|
||||||
|
|
||||||
|
# Write marker so the restarted gateway can notify this chat
|
||||||
|
pending_path = _hermes_home / ".update_pending.json"
|
||||||
|
output_path = _hermes_home / ".update_output.txt"
|
||||||
|
pending = {
|
||||||
|
"platform": event.source.platform.value,
|
||||||
|
"chat_id": event.source.chat_id,
|
||||||
|
"user_id": event.source.user_id,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
pending_path.write_text(json.dumps(pending))
|
||||||
|
|
||||||
|
# Spawn `hermes update` in a separate cgroup so it survives gateway
|
||||||
|
# restart. systemd-run --user --scope creates a transient scope unit.
|
||||||
|
update_cmd = f"{hermes_bin} update > {output_path} 2>&1"
|
||||||
|
try:
|
||||||
|
systemd_run = shutil.which("systemd-run")
|
||||||
|
if systemd_run:
|
||||||
|
subprocess.Popen(
|
||||||
|
[systemd_run, "--user", "--scope",
|
||||||
|
"--unit=hermes-update", "--",
|
||||||
|
"bash", "-c", update_cmd],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback: best-effort detach with start_new_session
|
||||||
|
subprocess.Popen(
|
||||||
|
["bash", "-c", f"nohup {update_cmd} &"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
pending_path.unlink(missing_ok=True)
|
||||||
|
return f"✗ Failed to start update: {e}"
|
||||||
|
|
||||||
|
return "⚕ Starting Hermes update… I'll notify you when it's done."
|
||||||
|
|
||||||
|
async def _send_update_notification(self) -> None:
|
||||||
|
"""If the gateway is starting after a ``/update``, notify the user."""
|
||||||
|
import json
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
pending_path = _hermes_home / ".update_pending.json"
|
||||||
|
output_path = _hermes_home / ".update_output.txt"
|
||||||
|
|
||||||
|
if not pending_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
pending = json.loads(pending_path.read_text())
|
||||||
|
platform_str = pending.get("platform")
|
||||||
|
chat_id = pending.get("chat_id")
|
||||||
|
|
||||||
|
# Read the captured update output
|
||||||
|
output = ""
|
||||||
|
if output_path.exists():
|
||||||
|
output = output_path.read_text()
|
||||||
|
|
||||||
|
# Resolve adapter
|
||||||
|
platform = Platform(platform_str)
|
||||||
|
adapter = self.adapters.get(platform)
|
||||||
|
|
||||||
|
if adapter and chat_id:
|
||||||
|
# Strip ANSI escape codes for clean display
|
||||||
|
output = _re.sub(r'\x1b\[[0-9;]*m', '', output).strip()
|
||||||
|
if output:
|
||||||
|
# Truncate if too long for a single message
|
||||||
|
if len(output) > 3500:
|
||||||
|
output = "…" + output[-3500:]
|
||||||
|
msg = f"✅ Hermes update finished — gateway restarted.\n\n```\n{output}\n```"
|
||||||
|
else:
|
||||||
|
msg = "✅ Hermes update finished — gateway restarted successfully."
|
||||||
|
await adapter.send(chat_id, msg)
|
||||||
|
logger.info("Sent post-update notification to %s:%s", platform_str, chat_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Post-update notification failed: %s", e)
|
||||||
|
finally:
|
||||||
|
pending_path.unlink(missing_ok=True)
|
||||||
|
output_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
def _set_session_env(self, context: SessionContext) -> None:
|
def _set_session_env(self, context: SessionContext) -> None:
|
||||||
"""Set environment variables for the current session."""
|
"""Set environment variables for the current session."""
|
||||||
os.environ["HERMES_SESSION_PLATFORM"] = context.source.platform.value
|
os.environ["HERMES_SESSION_PLATFORM"] = context.source.platform.value
|
||||||
|
|
|
||||||
482
tests/gateway/test_update_command.py
Normal file
482
tests/gateway/test_update_command.py
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
"""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 = {}
|
||||||
|
return runner
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _handle_update_command
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleUpdateCommand:
|
||||||
|
"""Tests for GatewayRunner._handle_update_command."""
|
||||||
|
|
||||||
|
@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."""
|
||||||
|
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):
|
||||||
|
result = await runner._handle_update_command(event)
|
||||||
|
|
||||||
|
assert "not found on PATH" in result
|
||||||
|
|
||||||
|
@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/systemd-run"), \
|
||||||
|
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
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_spawns_systemd_run(self, tmp_path):
|
||||||
|
"""Uses systemd-run 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 systemd-run was used
|
||||||
|
call_args = mock_popen.call_args[0][0]
|
||||||
|
assert call_args[0] == "/usr/bin/systemd-run"
|
||||||
|
assert "--scope" in call_args
|
||||||
|
assert "Starting Hermes update" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fallback_nohup_when_no_systemd_run(self, tmp_path):
|
||||||
|
"""Falls back to nohup when systemd-run 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_systemd(x):
|
||||||
|
if x == "hermes":
|
||||||
|
return "/usr/bin/hermes"
|
||||||
|
if x == "systemd-run":
|
||||||
|
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_systemd), \
|
||||||
|
patch("subprocess.Popen", mock_popen):
|
||||||
|
result = await runner._handle_update_command(event)
|
||||||
|
|
||||||
|
# Verify bash -c nohup fallback was used
|
||||||
|
call_args = mock_popen.call_args[0][0]
|
||||||
|
assert call_args[0] == "bash"
|
||||||
|
assert "nohup" in call_args[2]
|
||||||
|
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()
|
||||||
|
|
||||||
|
@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 "notify you when it's done" 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_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!"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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_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
|
||||||
|
|
||||||
|
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 "restarted 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"
|
||||||
|
pending_path.write_text(json.dumps({
|
||||||
|
"platform": "telegram", "chat_id": "111", "user_id": "222",
|
||||||
|
}))
|
||||||
|
output_path.write_text("✓ Done")
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
@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"
|
||||||
|
pending_path.write_text(json.dumps({
|
||||||
|
"platform": "telegram", "chat_id": "111", "user_id": "222",
|
||||||
|
}))
|
||||||
|
output_path.write_text("✓ Done")
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
@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"
|
||||||
|
pending_path.write_text(json.dumps(pending))
|
||||||
|
output_path.write_text("Done")
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue