Files
hermes-agent/tests/gateway/test_ephemeral_reply.py
Teknium 4caad285a6 feat(gateway): auto-delete slash-command system notices after TTL (#18266)
Adds opt-in auto-deletion for slash-command reply messages like
"New session started!", "Restarting gateway…", "Stopped.", and
YOLO toggles.  After the TTL elapses the gateway calls the adapter's
delete_message; on platforms without a delete API (everything except
Telegram today) the TTL is silently ignored and the message stays.

Requested on Twitter by @charlesmcdowell — tool-call bubbles are useful
real-time, but system notices clutter the thread once the agent finishes.

Implementation:

- EphemeralReply(str) sentinel in gateway/platforms/base.py.  Subclasses
  str so existing 'X' in response / response.startswith(...) checks in
  tests and call sites keep working unchanged; isinstance() still
  distinguishes it for the send path.
- _process_message_background and both busy-session bypass paths
  (in base.py) call _unwrap_ephemeral() on the handler return, send
  the unwrapped text, and schedule a detached delete task when the
  TTL > 0 AND the adapter class overrides delete_message.
- display.ephemeral_system_ttl (default 0 = disabled) in DEFAULT_CONFIG.
  Handler can pass ttl_seconds explicitly to override.
- Wrapped the highest-noise return sites: /new, /reset, /stop,
  /yolo on/off, /restart success + "already in progress".  Draining
  notices and /help output left as plain strings — those are
  informational and users want to read them.

Backward-compat: default TTL 0 → no scheduling, no behavior change
for existing users.  Platforms without delete_message silently no-op.
2026-04-30 23:05:48 -07:00

337 lines
11 KiB
Python

"""Tests for EphemeralReply — system-notice auto-delete in gateway adapters.
Slash-command handlers in ``gateway/run.py`` can return an
``EphemeralReply`` wrapper to request auto-deletion of the reply message
after a TTL. The base adapter unwraps the sentinel before sending and
schedules a detached delete task when the platform supports
``delete_message``.
Covered:
1. ``_unwrap_ephemeral`` returns text + ttl for EphemeralReply, and
passes plain strings through unchanged.
2. TTL is zeroed on platforms that don't override ``delete_message``
(silent degrade — message stays in place).
3. TTL is honored on platforms that DO override ``delete_message``.
4. ``_schedule_ephemeral_delete`` invokes ``delete_message`` after the
configured delay with the correct chat_id / message_id.
5. ``_process_message_background`` sends the unwrapped text (not the
sentinel object) and schedules deletion when appropriate.
6. The two busy-session bypass paths also unwrap + schedule.
"""
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
EphemeralReply,
MessageEvent,
MessageType,
SendResult,
)
from gateway.session import SessionSource
class _NoDeleteAdapter(BasePlatformAdapter):
"""Adapter that does NOT override delete_message (silent degrade)."""
async def connect(self):
pass
async def disconnect(self):
pass
async def send(self, chat_id, content="", **kwargs):
return SendResult(success=True, message_id="m-1")
async def get_chat_info(self, chat_id):
return {}
class _DeleteCapableAdapter(BasePlatformAdapter):
"""Adapter that overrides delete_message (TTL honored)."""
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
self.deleted: list[tuple[str, str]] = []
async def connect(self):
pass
async def disconnect(self):
pass
async def send(self, chat_id, content="", **kwargs):
return SendResult(success=True, message_id="m-2")
async def get_chat_info(self, chat_id):
return {}
async def delete_message(self, chat_id: str, message_id: str) -> bool:
self.deleted.append((chat_id, message_id))
return True
def _no_delete_adapter():
return _NoDeleteAdapter(
PlatformConfig(enabled=True, token="t"), Platform.TELEGRAM
)
def _delete_adapter():
return _DeleteCapableAdapter(
PlatformConfig(enabled=True, token="t"), Platform.TELEGRAM
)
def _make_event(text="/stop", chat_id="42"):
return MessageEvent(
text=text,
message_id="msg-1",
source=SessionSource(
platform=Platform.TELEGRAM,
chat_id=chat_id,
user_id="u-1",
),
message_type=MessageType.TEXT,
)
# ---------------------------------------------------------------------------
# _unwrap_ephemeral
# ---------------------------------------------------------------------------
def test_unwrap_plain_string_is_passthrough():
adapter = _delete_adapter()
text, ttl = adapter._unwrap_ephemeral("hello")
assert text == "hello"
assert ttl == 0
def test_unwrap_none_is_passthrough():
adapter = _delete_adapter()
text, ttl = adapter._unwrap_ephemeral(None)
assert text is None
assert ttl == 0
def test_unwrap_ephemeral_explicit_ttl_on_capable_adapter():
adapter = _delete_adapter()
text, ttl = adapter._unwrap_ephemeral(EphemeralReply("bye", ttl_seconds=60))
assert text == "bye"
assert ttl == 60
def test_unwrap_ephemeral_zeros_ttl_on_incapable_adapter():
"""Platforms without delete_message should silently degrade to normal send."""
adapter = _no_delete_adapter()
text, ttl = adapter._unwrap_ephemeral(EphemeralReply("bye", ttl_seconds=60))
assert text == "bye"
assert ttl == 0 # forced to 0 — message will stay in place
def test_unwrap_ephemeral_default_ttl_from_config():
adapter = _delete_adapter()
with patch.object(adapter, "_get_ephemeral_system_ttl_default", return_value=120):
text, ttl = adapter._unwrap_ephemeral(EphemeralReply("bye"))
assert text == "bye"
assert ttl == 120
def test_unwrap_ephemeral_default_ttl_zero_disables():
"""Config default of 0 (the shipped default) means the feature is off."""
adapter = _delete_adapter()
with patch.object(adapter, "_get_ephemeral_system_ttl_default", return_value=0):
text, ttl = adapter._unwrap_ephemeral(EphemeralReply("bye"))
assert text == "bye"
assert ttl == 0
def test_unwrap_ephemeral_handles_unreadable_config():
adapter = _delete_adapter()
with patch.object(
adapter,
"_get_ephemeral_system_ttl_default",
side_effect=RuntimeError("boom"),
):
text, ttl = adapter._unwrap_ephemeral(EphemeralReply("bye"))
# Fall back to 0 rather than crashing the handler pipeline.
assert text == "bye"
assert ttl == 0
# ---------------------------------------------------------------------------
# _schedule_ephemeral_delete
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_schedule_ephemeral_delete_calls_delete_after_ttl():
adapter = _delete_adapter()
# Use a very short TTL to keep the test fast — the implementation
# floors sleeps at 1s via ``max(1, int(ttl_seconds))``. Patch asyncio.sleep
# inside the module under test; the test body uses the real one for
# scheduler pumping.
import gateway.platforms.base as base_module
sleeps: list[float] = []
_real_sleep = base_module.asyncio.sleep
async def _fake_sleep(duration):
sleeps.append(duration)
# Yield control so the rest of the task body can run.
await _real_sleep(0)
with patch.object(base_module.asyncio, "sleep", _fake_sleep):
adapter._schedule_ephemeral_delete(
chat_id="42", message_id="m-2", ttl_seconds=5
)
# Let the spawned task run.
for _ in range(5):
await _real_sleep(0)
# Only the ttl sleep shows up — the test pump uses the real sleep.
assert 5 in sleeps
assert adapter.deleted == [("42", "m-2")]
@pytest.mark.asyncio
async def test_schedule_ephemeral_delete_swallows_errors():
adapter = _delete_adapter()
async def _boom(*a, **kw):
raise RuntimeError("permission denied")
adapter.delete_message = _boom # type: ignore[assignment]
with patch("gateway.platforms.base.asyncio.sleep", AsyncMock()):
adapter._schedule_ephemeral_delete(
chat_id="42", message_id="m-2", ttl_seconds=1
)
# No exception should propagate even though delete_message raised.
for _ in range(5):
await asyncio.sleep(0)
def test_schedule_ephemeral_delete_outside_event_loop_is_noop():
"""No running loop → no crash, silently drops the request."""
adapter = _delete_adapter()
# No pytest.mark.asyncio → no loop. Must not raise.
adapter._schedule_ephemeral_delete(
chat_id="42", message_id="m-2", ttl_seconds=1
)
assert adapter.deleted == []
# ---------------------------------------------------------------------------
# _process_message_background unwraps EphemeralReply before send
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_process_message_unwraps_ephemeral_before_send():
"""The adapter must send the wrapper's .text, never the wrapper object."""
adapter = _delete_adapter()
adapter._send_with_retry = AsyncMock(
return_value=SendResult(success=True, message_id="sent-1")
)
async def _handler(evt):
return EphemeralReply("⚡ Stopped.", ttl_seconds=5)
adapter.set_message_handler(_handler)
sleeps: list[float] = []
async def _fake_sleep(duration):
sleeps.append(duration)
event = _make_event()
session_key = "agent:main:telegram:private:42"
with patch("gateway.platforms.base.asyncio.sleep", _fake_sleep), patch.object(
adapter, "_keep_typing", new=AsyncMock()
):
await adapter._process_message_background(event, session_key)
# Pump until the detached delete task completes.
for _ in range(10):
await asyncio.sleep(0)
# Sent text is the unwrapped string, NOT repr(EphemeralReply(...))
adapter._send_with_retry.assert_called_once()
sent_text = adapter._send_with_retry.call_args.kwargs["content"]
assert sent_text == "⚡ Stopped."
# Auto-delete scheduled using the returned message_id
assert ("42", "sent-1") in adapter.deleted
@pytest.mark.asyncio
async def test_process_message_incapable_platform_does_not_schedule_delete():
adapter = _no_delete_adapter()
adapter._send_with_retry = AsyncMock(
return_value=SendResult(success=True, message_id="sent-1")
)
async def _handler(evt):
return EphemeralReply("⚡ Stopped.", ttl_seconds=5)
adapter.set_message_handler(_handler)
# Spy on delete_message to confirm it is NOT invoked.
delete_calls: list = []
async def _spy_delete(chat_id, message_id):
delete_calls.append((chat_id, message_id))
return False
adapter.delete_message = _spy_delete # type: ignore[assignment]
event = _make_event()
session_key = "agent:main:telegram:private:42"
with patch("gateway.platforms.base.asyncio.sleep", AsyncMock()), patch.object(
adapter, "_keep_typing", new=AsyncMock()
):
await adapter._process_message_background(event, session_key)
for _ in range(10):
await asyncio.sleep(0)
# Send happened with the unwrapped text...
adapter._send_with_retry.assert_called_once()
assert adapter._send_with_retry.call_args.kwargs["content"] == "⚡ Stopped."
# ...but delete was never scheduled because the capability check skipped
# the schedule call (TTL was zeroed in _unwrap_ephemeral).
# Note: the capability gate on _unwrap_ephemeral checks for
# ``type(adapter).delete_message is BasePlatformAdapter.delete_message``.
# Monkeypatching the instance does NOT change the class, so this test
# verifies the gate uses the class method to detect capability.
assert delete_calls == []
@pytest.mark.asyncio
async def test_process_message_plain_string_behaves_unchanged():
adapter = _delete_adapter()
adapter._send_with_retry = AsyncMock(
return_value=SendResult(success=True, message_id="sent-1")
)
async def _handler(evt):
return "plain reply"
adapter.set_message_handler(_handler)
event = _make_event()
session_key = "agent:main:telegram:private:42"
with patch("gateway.platforms.base.asyncio.sleep", AsyncMock()), patch.object(
adapter, "_keep_typing", new=AsyncMock()
):
await adapter._process_message_background(event, session_key)
for _ in range(5):
await asyncio.sleep(0)
adapter._send_with_retry.assert_called_once()
assert adapter._send_with_retry.call_args.kwargs["content"] == "plain reply"
assert adapter.deleted == [] # no auto-delete for plain replies