Files
hermes-agent/tests/gateway/test_exec_approval_timeout_notice.py
Victor Kyriazakos cd3de040ab feat(notifications): opt-in suppression of user-channel warning notifications
Squash of the 54 commits on victor-kyriazakos:feat/user-channel-warning-suppression
(PR #112302, head f45c640e55) so the contributor's authorship survives a rebase-merge;
the commits interleave with a cron delivery-ledger rework that the salvage removes in
follow-up commits, so per-commit cherry-picks were not practical.

Adds display.suppress_warning_notifications (global + per-platform, default false):
one resolver (gateway/warning_notifications.py), BasePlatformAdapter.emit_warning /
emit_media_warning / warning_text, a notification_category classification carried
through wakes, queues and persistence, and render/present boundaries for CLI/TUI.
2026-09-18 01:43:35 +05:30

116 lines
4.4 KiB
Python

"""When a messaging-platform approval prompt times out, the chat must say so.
``_await_gateway_decision`` fires ``entry.settle("timeout")`` when nobody answers within
``approvals.timeout``; only the TUI server registered a settle hook, so on Telegram / Slack /
WhatsApp the card kept live buttons and the user never learned the command did NOT run.
These tests drive the real ``TurnRunner._approval_notify_sync`` with a fake adapter.
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from typing import Any, List
import pytest
from gateway.platforms.base import SendResult
from tools import approval as _approval
from tools.approval_gateway_wait import _ApprovalEntry
SESSION = "agent:main:telegram:dm:1"
APPROVAL = {"command": "rm -rf /tmp/x", "description": "recursive delete", "pattern_key": "k"}
class _ButtonAdapter:
"""Renders native buttons and remembers the posted card id; records edits and sends."""
typed_command_prefix = "/"
def __init__(self, *, editable: bool = True) -> None:
self.sends: List[str] = []
self.edits: List[tuple] = []
self._editable = editable
def pause_typing_for_chat(self, chat_id: str) -> None:
return None
async def send_exec_approval(self, *a: Any, **k: Any) -> SendResult:
return SendResult(success=True, message_id="card-1")
async def send(self, chat_id: str, message: str, **k: Any) -> SendResult:
self.sends.append(message)
return SendResult(success=True, message_id="m2")
async def edit_message(self, chat_id: str, message_id: str, content: str, **k: Any) -> SendResult:
self.edits.append((message_id, content))
return SendResult(success=self._editable, error=None if self._editable else "cannot edit")
def _runner(adapter):
from gateway.run_turn_runner import TurnRunner
runner = object.__new__(TurnRunner)
runner._ctx = SimpleNamespace(
_status_adapter=adapter, _status_chat_id="C1", _status_thread_metadata={"thread_id": "t1"},
session_key=SESSION, source=SimpleNamespace(chat_id="C1", platform="telegram", session_key=SESSION),
)
class _Fut:
def __init__(self, result): self._r = result
def result(self, timeout=None): return self._r
runner._schedule = lambda coro, _label: _Fut(asyncio.run(coro))
runner._close_native_stream_boundary = lambda _why: None
return runner
@pytest.fixture
def pending_entry(monkeypatch):
"""A queued approval entry exactly as ``_await_gateway_decision`` registers it."""
monkeypatch.setattr("gateway.platforms.base_exec_approval.approval_timeout_seconds", lambda: 300)
entry = _ApprovalEntry(dict(APPROVAL))
with _approval._lock:
_approval._gateway_queues[SESSION] = [entry]
yield entry
with _approval._lock:
_approval._gateway_queues.pop(SESSION, None)
@pytest.mark.parametrize("warning_notifications", [True, False])
def test_timeout_edits_the_card_to_say_the_command_did_not_run(pending_entry, tmp_path, monkeypatch, warning_notifications):
import gateway.run as gateway_run
(tmp_path / "config.yaml").write_text(f"display: {{suppress_warning_notifications: {str(not warning_notifications).lower()}}}")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
adapter = _ButtonAdapter()
_runner(adapter)._approval_notify_sync(dict(pending_entry.data)) # what notify_cb receives
assert pending_entry.settle is not None, "settle hook must be registered for the request_id"
pending_entry.settle("timeout")
assert len(adapter.edits) == 1
message_id, content = adapter.edits[0]
assert message_id == "card-1"
assert "NOT run" in content and "5 minutes" in content
assert adapter.sends == [], "an editable card needs no extra message"
def test_timeout_falls_back_to_a_new_message_when_the_card_cannot_be_edited(pending_entry):
adapter = _ButtonAdapter(editable=False)
_runner(adapter)._approval_notify_sync(dict(pending_entry.data)) # what notify_cb receives
pending_entry.settle("timeout")
assert len(adapter.sends) == 1
assert "NOT run" in adapter.sends[0]
@pytest.mark.parametrize("reason", ["answered", "interrupted", "notify_failed"])
def test_other_settle_reasons_post_nothing(pending_entry, reason):
adapter = _ButtonAdapter()
_runner(adapter)._approval_notify_sync(dict(pending_entry.data)) # what notify_cb receives
pending_entry.settle(reason)
assert adapter.edits == [] and adapter.sends == []