Files
hermes-agent/tests/gateway/test_update_streaming.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

497 lines
18 KiB
Python

"""Tests for /update live streaming, prompt forwarding, and gateway IPC.
Tests the new --gateway mode for hermes update, including:
- _gateway_prompt() file-based IPC
- _watch_update_progress() output streaming and prompt detection
- Message interception for update prompt responses
- _restore_stashed_changes() with input_fn parameter
"""
import json
import os
import time
import asyncio
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(hermes_home=None):
"""Create a bare GatewayRunner without calling __init__."""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.adapters = {}
runner._voice_mode = {}
runner._update_prompt_pending = {}
runner._running_agents = {}
runner._running_agents_ts = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._failed_platforms = {}
return runner
# ---------------------------------------------------------------------------
# _gateway_prompt (file-based IPC in main.py)
# ---------------------------------------------------------------------------
class TestGatewayPrompt:
"""Tests for _gateway_prompt() function."""
def test_writes_prompt_file_and_reads_response(self, tmp_path):
"""Writes .update_prompt.json, reads .update_response, returns answer."""
import threading
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
# Simulate the response arriving after a short delay
def write_response():
time.sleep(0.3)
(hermes_home / ".update_response").write_text("y")
thread = threading.Thread(target=write_response)
thread.start()
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
from hermes_cli.main import _gateway_prompt
result = _gateway_prompt("Restore? [Y/n]", "y", timeout=5.0)
thread.join()
assert result == "y"
# Both files should be cleaned up
assert not (hermes_home / ".update_prompt.json").exists()
assert not (hermes_home / ".update_response").exists()
def test_prompt_file_content(self, tmp_path):
"""Verifies the prompt JSON structure."""
import threading
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
prompt_data = None
def capture_and_respond():
nonlocal prompt_data
prompt_path = hermes_home / ".update_prompt.json"
for _ in range(20):
if prompt_path.exists():
prompt_data = json.loads(prompt_path.read_text())
(hermes_home / ".update_response").write_text("n")
return
time.sleep(0.1)
thread = threading.Thread(target=capture_and_respond)
thread.start()
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
from hermes_cli.main import _gateway_prompt
_gateway_prompt("Configure now? [Y/n]", "n", timeout=5.0)
thread.join()
assert prompt_data is not None
assert prompt_data["prompt"] == "Configure now? [Y/n]"
assert prompt_data["default"] == "n"
assert "id" in prompt_data
def test_timeout_returns_default(self, tmp_path):
"""Returns default when no response within timeout."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
from hermes_cli.main import _gateway_prompt
result = _gateway_prompt("test?", "default_val", timeout=0.5)
assert result == "default_val"
def test_empty_response_returns_default(self, tmp_path):
"""Empty response file returns default."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / ".update_response").write_text("")
# Write prompt file so the function starts polling
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
from hermes_cli.main import _gateway_prompt
# Pre-create the response
result = _gateway_prompt("test?", "default_val", timeout=2.0)
assert result == "default_val"
# ---------------------------------------------------------------------------
# _restore_stashed_changes with input_fn
# ---------------------------------------------------------------------------
class TestRestoreStashWithInputFn:
"""Tests for _restore_stashed_changes with the input_fn parameter."""
def test_uses_input_fn_when_provided(self, tmp_path):
"""When input_fn is provided, it's called instead of input()."""
from hermes_cli.main import _restore_stashed_changes
captured_args = []
def fake_input_fn(prompt, default=""):
captured_args.append((prompt, default))
return "n"
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0, stdout="", stderr=""
)
result = _restore_stashed_changes(
["git"], tmp_path, "abc123",
prompt_user=True,
input_fn=fake_input_fn,
)
assert len(captured_args) == 1
assert "Restore" in captured_args[0][0]
assert result is False # user declined
def test_input_fn_yes_proceeds_with_restore(self, tmp_path):
"""When input_fn returns 'y', stash apply is attempted."""
from hermes_cli.main import _restore_stashed_changes
call_count = [0]
def fake_run(*args, **kwargs):
call_count[0] += 1
mock = MagicMock()
mock.returncode = 0
mock.stdout = ""
mock.stderr = ""
return mock
with patch("subprocess.run", side_effect=fake_run):
_restore_stashed_changes(
["git"], tmp_path, "abc123",
prompt_user=True,
input_fn=lambda p, d="": "y",
)
# Should have called git stash apply + git diff --name-only
assert call_count[0] >= 2
# ---------------------------------------------------------------------------
# Update command spawns --gateway flag
# ---------------------------------------------------------------------------
class TestUpdateCommandGatewayFlag:
"""Verify the gateway spawns hermes update --gateway."""
@pytest.mark.asyncio
async def test_spawns_with_gateway_flag(self, tmp_path):
"""The spawned update command includes --gateway and PYTHONUNBUFFERED."""
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)
# Check the bash command string contains --gateway and PYTHONUNBUFFERED
call_args = mock_popen.call_args[0][0]
cmd_string = call_args[-1] if isinstance(call_args, list) else str(call_args)
assert "--gateway" in cmd_string
assert "PYTHONUNBUFFERED" in cmd_string
assert "stream progress" in result
# ---------------------------------------------------------------------------
# _watch_update_progress — output streaming
# ---------------------------------------------------------------------------
class TestWatchUpdateProgress:
"""Tests for _watch_update_progress() streaming output."""
@pytest.mark.asyncio
async def test_streams_output_to_adapter(self, tmp_path):
"""New output is sent to the adapter periodically."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222",
"session_key": "agent:main:telegram:dm:111"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
# Write output
(hermes_home / ".update_output.txt").write_text("→ Fetching updates...\n")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
# Write exit code after a brief delay
async def write_exit_code():
await asyncio.sleep(0.3)
(hermes_home / ".update_output.txt").write_text(
"→ Fetching updates...\n✓ Code updated!\n"
)
(hermes_home / ".update_exit_code").write_text("0")
with patch("gateway.run._hermes_home", hermes_home):
task = asyncio.create_task(write_exit_code())
await runner._watch_update_progress(
poll_interval=0.1,
stream_interval=0.2,
timeout=5.0,
)
await task
# Should have sent at least the output and a success message
assert mock_adapter.send.call_count >= 1
all_sent = " ".join(str(c) for c in mock_adapter.send.call_args_list)
assert "update finished" in all_sent.lower()
@pytest.mark.asyncio
async def test_detects_and_forwards_prompt(self, tmp_path):
"""Detects .update_prompt.json and sends it to the user."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222",
"session_key": "agent:main:telegram:dm:111"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
(hermes_home / ".update_output.txt").write_text("output\n")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
# Write a prompt, then respond and finish
async def simulate_prompt_cycle():
await asyncio.sleep(0.3)
prompt = {"prompt": "Restore local changes? [Y/n]", "default": "y", "id": "test1"}
(hermes_home / ".update_prompt.json").write_text(json.dumps(prompt))
# Simulate user responding
await asyncio.sleep(0.5)
(hermes_home / ".update_response").write_text("y")
(hermes_home / ".update_prompt.json").unlink(missing_ok=True)
await asyncio.sleep(0.3)
(hermes_home / ".update_exit_code").write_text("0")
with patch("gateway.run._hermes_home", hermes_home):
task = asyncio.create_task(simulate_prompt_cycle())
await runner._watch_update_progress(
poll_interval=0.1,
stream_interval=0.2,
timeout=10.0,
)
await task
# Check that the prompt was forwarded
all_sent = [str(c) for c in mock_adapter.send.call_args_list]
prompt_found = any("Restore local changes" in s for s in all_sent)
assert prompt_found, f"Prompt not forwarded. Sent: {all_sent}"
# Check session was marked as having pending prompt
# (may be cleared by the time we check since update finished)
@pytest.mark.asyncio
async def test_cleans_up_on_completion(self, tmp_path):
"""All marker files are cleaned up when update finishes."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222",
"session_key": "agent:main:telegram:dm:111"}
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\n")
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._watch_update_progress(
poll_interval=0.1,
stream_interval=0.2,
timeout=5.0,
)
assert not pending_path.exists()
assert not output_path.exists()
assert not exit_code_path.exists()
@pytest.mark.asyncio
async def test_failure_exit_code(self, tmp_path):
"""Non-zero exit code sends failure message."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222",
"session_key": "agent:main:telegram:dm:111"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
(hermes_home / ".update_output.txt").write_text("error occurred\n")
(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):
await runner._watch_update_progress(
poll_interval=0.1,
stream_interval=0.2,
timeout=5.0,
)
all_sent = " ".join(str(c) for c in mock_adapter.send.call_args_list)
assert "failed" in all_sent.lower()
@pytest.mark.asyncio
async def test_falls_back_when_adapter_unavailable(self, tmp_path):
"""Falls back to legacy notification when adapter can't be resolved."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
# Platform doesn't match any adapter
pending = {"platform": "discord", "chat_id": "111", "user_id": "222"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
(hermes_home / ".update_output.txt").write_text("done\n")
(hermes_home / ".update_exit_code").write_text("0")
# Only telegram adapter available
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
await runner._watch_update_progress(
poll_interval=0.1,
stream_interval=0.2,
timeout=5.0,
)
# Should not crash; legacy notification handles this case
# ---------------------------------------------------------------------------
# Message interception for update prompts
# ---------------------------------------------------------------------------
class TestUpdatePromptInterception:
"""Tests for update prompt response interception in _handle_message."""
@pytest.mark.asyncio
async def test_intercepts_response_when_prompt_pending(self, tmp_path):
"""When _update_prompt_pending is set, the next message writes .update_response."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
event = _make_event(text="y", chat_id="67890")
# The session key uses the full format from build_session_key
session_key = "agent:main:telegram:dm:67890"
runner._update_prompt_pending[session_key] = True
# Mock authorization and _session_key_for_source
runner._is_user_authorized = MagicMock(return_value=True)
runner._session_key_for_source = MagicMock(return_value=session_key)
with patch("gateway.run._hermes_home", hermes_home):
result = await runner._handle_message(event)
assert result is not None
assert "Sent" in result
response_path = hermes_home / ".update_response"
assert response_path.exists()
assert response_path.read_text() == "y"
# Should clear the pending flag
assert session_key not in runner._update_prompt_pending
@pytest.mark.asyncio
async def test_normal_message_when_no_prompt_pending(self, tmp_path):
"""Messages pass through normally when no prompt is pending."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
event = _make_event(text="hello", chat_id="67890")
# No pending prompt
runner._is_user_authorized = MagicMock(return_value=True)
# The message should flow through to normal processing;
# we just verify it doesn't get intercepted
session_key = "agent:main:telegram:dm:67890"
assert session_key not in runner._update_prompt_pending
# ---------------------------------------------------------------------------
# cmd_update --gateway flag
# ---------------------------------------------------------------------------
class TestCmdUpdateGatewayMode:
"""Tests for cmd_update with --gateway flag."""
def test_gateway_flag_enables_gateway_prompt_for_stash(self, tmp_path):
"""With --gateway, stash restore uses _gateway_prompt instead of input()."""
from hermes_cli.main import _restore_stashed_changes
# Use input_fn to verify the gateway path is taken
calls = []
def fake_input(prompt, default=""):
calls.append(prompt)
return "n"
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
_restore_stashed_changes(
["git"], tmp_path, "abc123",
prompt_user=True,
input_fn=fake_input,
)
assert len(calls) == 1
assert "Restore" in calls[0]
def test_gateway_flag_parsed(self):
"""The --gateway flag is accepted by the update subparser."""
# Verify the argparse parser accepts --gateway by checking cmd_update
# receives gateway=True when the flag is set
from types import SimpleNamespace
args = SimpleNamespace(gateway=True)
assert args.gateway is True