Files
hermes-agent/tests/gateway/test_exec_approval_template.py
T
teknium1 ad305bead5 refactor(gateway): send_exec_approval is a base template method; 9 adapters only render buttons
Nine surfaces (feishu, teams, slack, telegram, whatsapp_cloud, qqbot, matrix, discord, relay)
each re-derived the approval choice set — [Allow Once]; session + always unless smart-denied;
[Deny] — and four of them (discord, slack, teams, whatsapp_cloud) never adopted
base._format_exec_approval, so header/reason/smart-deny wording and truncation budgets drifted
per adapter. Three separate commits had to touch 5–9 adapters for one semantic fix.

BasePlatformAdapter.send_exec_approval now builds an ExecApprovalPrompt (shared text via
_format_exec_approval, shared `(label, choice, style)` rows via _exec_approval_actions) and
hands it to the `_send_exec_approval_prompt` hook. Each adapter keeps only its widget mapping
(~10–20 LOC); platform wording stays via the existing `_EA_*` class attrs, and a new
`_exec_approval_cmd_budget` hook lets Slack/Discord budget the command against their hard
message caps (3000-char section / 2000-char message) instead of computing it inline.
`_EA_REASON_BUDGET` covers Slack's 500 / Discord's 300 reason caps.

The runner used to detect button support by `hasattr(type(adapter), "send_exec_approval")`;
that is now true for every adapter, so `_renders_exec_approval_buttons` asks
`supports_exec_approval_buttons()` (hook overridden?) and keeps the duck-typed check for
non-BasePlatformAdapter classes.

Visible text changes (button semantics unchanged everywhere):
- Discord: the smart-deny line now follows the reason (was inside the header before the
  fence); the truncation marker is "..." not "\n... [truncated]".
- Slack: smart-deny line follows the reason instead of the header.
- Teams: unchanged (same 2000-char preview, same smart-deny block).
- WhatsApp Cloud: identical text; body still capped at 1024.
- QQBot/relay: unchanged.
2026-09-13 05:21:39 -07:00

92 lines
3.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Invariants for the exec-approval template method on BasePlatformAdapter.
The choice set (once / session / always / deny, minus the persistent tiers under a smart deny)
used to be re-derived in every button adapter and needed three separate "same fix × N adapters"
sweeps. It now comes from one place; adapters only render it.
"""
from typing import Any, Dict
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, ExecApprovalPrompt, SendResult
from gateway.run_turn_runner import _renders_exec_approval_buttons
class _Plain(BasePlatformAdapter):
def __init__(self):
super().__init__(PlatformConfig(enabled=True), Platform.TELEGRAM)
async def connect(self, *, is_reconnect: bool = False) -> bool:
return True
async def disconnect(self) -> None:
pass
async def send(self, *a: Any, **k: Any) -> SendResult:
return SendResult(success=True)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {}
class _Buttons(_Plain):
def __init__(self):
super().__init__()
self.prompts: list = []
async def _send_exec_approval_prompt(self, prompt: ExecApprovalPrompt) -> SendResult:
self.prompts.append(prompt)
return SendResult(success=True, message_id="m1")
@pytest.mark.parametrize(
"allow_permanent, allow_session, smart_denied, expected",
[
(True, True, False, ["once", "session", "always", "deny"]),
(False, True, False, ["once", "session", "deny"]),
(True, False, False, ["once", "deny"]), # no session tier → no permanent tier either
(True, True, True, ["once", "deny"]), # smart deny: owner override is one-shot
(False, False, True, ["once", "deny"]),
],
)
@pytest.mark.asyncio
async def test_choice_set_follows_the_shared_rule(allow_permanent, allow_session, smart_denied, expected):
adapter = _Buttons()
result = await adapter.send_exec_approval(
"chat", "rm -rf /tmp/x", "sess", description="cleanup",
allow_permanent=allow_permanent, allow_session=allow_session, smart_denied=smart_denied)
assert result.success
(prompt,) = adapter.prompts
assert prompt.choices == expected
assert "rm -rf /tmp/x" in prompt.text and "cleanup" in prompt.text
assert ("one operation only" in prompt.text) is smart_denied
@pytest.mark.asyncio
async def test_command_is_truncated_to_the_platform_budget():
class _Small(_Buttons):
_EA_CMD_BUDGET = 20
adapter = _Small()
await adapter.send_exec_approval("chat", "x" * 100, "sess")
(prompt,) = adapter.prompts
assert "x" * 20 + "..." in prompt.text and "x" * 21 not in prompt.text
assert prompt.command == "x" * 100 # the raw command stays available for embeds/cards
def test_runner_only_offers_buttons_to_adapters_that_render_them():
"""Plain adapters get the text ``/approve`` prompt; the base send_exec_approval must not make
the runner believe every adapter has buttons (its default reports failure and the runner would
log a spurious fallback on every approval)."""
assert _renders_exec_approval_buttons(_Buttons) is True
assert _renders_exec_approval_buttons(_Plain) is False
class _DuckTyped:
async def send_exec_approval(self, *a, **k):
return SendResult(success=True)
assert _renders_exec_approval_buttons(_DuckTyped) is True
assert _renders_exec_approval_buttons(object) is False