fix: align Discord approval buttons with request timeout

This commit is contained in:
Gille authored and kshitij committed 2026-10-28 15:28:40 +05:30
1 parent ee7e0f5172
commit 4f86e97791
13 files changed
+284 -24

No files matched your search

+5 -1
View File
@@ -725,10 +725,14 @@ class TurnRunner(GatewayTurnProgressMixin, GatewaySessionAgentMixin):
# Check the *class*, not the instance — MagicMock auto-creates attributes in tests.
if _renders_exec_approval_buttons(type(adapter)):
try:
approval_metadata = ctx._status_thread_metadata
if "timeout_s" in approval_data:
approval_metadata = dict(approval_metadata or {})
approval_metadata["_approval_request_id"] = approval_data.get("request_id")
fut = self._schedule(
adapter.send_exec_approval(
chat_id=ctx._status_chat_id, command=cmd, session_key=ctx.session_key or "",
description=desc, metadata=ctx._status_thread_metadata, **flags,
description=desc, metadata=approval_metadata, **flags,
),
"send_exec_approval scheduling error",
)
+7 -2
View File
@@ -37,7 +37,9 @@ def register_timeout_notice(
session_key = runner._ctx.session_key or ""
if not request_id or not session_key:
return
timeout_s = approval_timeout_seconds()
timeout_s = approval_data.get("timeout_s")
if timeout_s is None:
timeout_s = approval_timeout_seconds()
def settle(reason: str) -> None:
if reason != "timeout":
@@ -73,7 +75,10 @@ async def _post_timeout_notice(ctx, command: str, card_message_id: Optional[str]
async def _edit_card(adapter, chat_id: str, message_id: str, content: str) -> bool:
"""Edit the card in place (drops the buttons on platforms whose edit replaces the markup)."""
edit: Optional[Callable[..., Any]] = getattr(adapter, "edit_message", None)
expire = getattr(type(adapter), "expire_exec_approval", None)
edit: Optional[Callable[..., Any]] = (
adapter.expire_exec_approval if callable(expire) else getattr(adapter, "edit_message", None)
)
if edit is None:
return False
try:
+49 -3
View File
@@ -5694,8 +5694,23 @@ class DiscordAdapter(DiscordAuthorizationMixin, DiscordMediaMixin, DiscordThread
+ len(self._approval_mention_content() or "") + 1)
return max(0, self.MAX_MESSAGE_LENGTH - fixed)
async def expire_exec_approval(self, chat_id: str, message_id: str, content: str) -> SendResult:
"""Remove Discord's components when the backend approval expires."""
if not self._client:
return SendResult(success=False, error="Not connected")
try:
channel = await self._resolve_channel(chat_id)
msg = channel.get_partial_message(int(message_id))
content = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH)[0]
await msg.edit(content=content, view=None)
return SendResult(success=True, message_id=message_id)
except Exception as exc:
logger.warning("[Discord] Failed to expire approval card: %s", exc, exc_info=True)
return SendResult(success=False, error=str(exc))
async def _send_exec_approval_prompt(self, prompt: ExecApprovalPrompt) -> SendResult:
"""Send an approval with content as its canonical payload and an embed for state."""
view_holder = []
def _build(_channel):
content = prompt.text
mention_content = self._approval_mention_content()
@@ -5712,7 +5727,9 @@ class DiscordAdapter(DiscordAuthorizationMixin, DiscordMediaMixin, DiscordThread
allowed_role_ids=self._allowed_role_ids, require_admin=require_admin,
admin_user_ids=admin_user_ids, allow_permanent="always" in choices,
allow_session="session" in choices, smart_denied=prompt.smart_denied,
request_id=(prompt.metadata or {}).get("_approval_request_id"),
)
view_holder.append(view)
send_kwargs: dict[str, Any] = {"content": content, "embed": embed, "view": view}
if mention_content:
allowed_mentions_cls = getattr(discord, "AllowedMentions", None)
@@ -5721,7 +5738,18 @@ class DiscordAdapter(DiscordAuthorizationMixin, DiscordMediaMixin, DiscordThread
users=True, roles=False, everyone=False, replied_user=False,
)
return send_kwargs, view
return await self._send_prompt(prompt.chat_id, prompt.metadata, _build)
result = await self._send_prompt(prompt.chat_id, prompt.metadata, _build)
if result.success and view_holder and view_holder[0].request_id:
from tools.approval import observe_gateway_settle
view = view_holder[0]
loop = asyncio.get_running_loop()
def retire(_reason):
loop.call_soon_threadsafe(lambda: loop.create_task(view.retire()))
if not observe_gateway_settle(prompt.session_key, view.request_id, retire):
await view.retire()
return result
async def send_slash_confirm(
self, chat_id: str, title: str, message: str, session_key: str,
@@ -6437,9 +6465,14 @@ def _define_discord_view_classes() -> None:
self, session_key: str, allowed_user_ids: set, allowed_role_ids: Optional[set] = None,
require_admin: bool = False, admin_user_ids: Optional[set] = None,
allow_permanent: bool = True, allow_session: bool = True, smart_denied: bool = False,
request_id: Optional[str] = None,
):
super().__init__(allowed_user_ids, allowed_role_ids, timeout=_read_discord_prompt_timeout())
# The backend starts waiting only after delivery. Its settlement retires
# this exact card; a separate view timer could expire it prematurely.
timeout = None if request_id else _read_discord_prompt_timeout()
super().__init__(allowed_user_ids, allowed_role_ids, timeout=timeout)
self.session_key = session_key
self.request_id = request_id
self.require_admin = require_admin
self.admin_user_ids = {str(a).strip() for a in (admin_user_ids or set()) if str(a).strip()}
self._localize_buttons(
@@ -6475,6 +6508,19 @@ def _define_discord_view_classes() -> None:
)
return False
async def retire(self):
"""Disable a card when its particular backend request is no longer pending."""
if self.resolved:
return
self.resolved = True
self._disable_all()
if self._message:
try:
await self._message.edit(view=self)
except Exception:
logger.debug("[Discord] Could not retire approval card", exc_info=True)
self.stop()
async def _resolve(self, interaction: discord.Interaction, choice: str, color: discord.Color, label_key: str):
"""Resolve the approval via the gateway approval queue and update the embed."""
if not await self._gate(
@@ -6488,7 +6534,7 @@ def _define_discord_view_classes() -> None:
# wait timed out (count == 0) must not claim "Approved".
try:
from tools.approval import resolve_gateway_approval
count = resolve_gateway_approval(self.session_key, choice)
count = resolve_gateway_approval(self.session_key, choice, request_id=self.request_id)
logger.info(
"Discord button resolved %d approval(s) for session %s (choice=%s, user=%s)",
count, self.session_key, choice, interaction.user.display_name,
@@ -24,7 +24,7 @@ def _read_dm_role_auth_guild() -> Optional[int]:
return guild_id if guild_id > 0 else None
# Default timeout for Discord button views when ``approvals.discord_prompt_timeout`` is unset;
# Default timeout for non-exec Discord button views when ``approvals.discord_prompt_timeout`` is unset;
# Discord interaction tokens expire at ~15 minutes, so 900s is the practical ceiling.
_DISCORD_PROMPT_TIMEOUT_DEFAULT = 300
_DISCORD_PROMPT_TIMEOUT_MIN = 30
@@ -32,7 +32,7 @@ _DISCORD_PROMPT_TIMEOUT_MAX = 900
def _read_discord_prompt_timeout() -> int:
"""Timeout (seconds) for Discord button views from ``approvals.discord_prompt_timeout``
"""Timeout (seconds) for non-exec Discord button views from ``approvals.discord_prompt_timeout``
(default 300), clamped to [MIN, MAX] so a typo can't make prompts vanish or outlive tokens."""
raw: Any = None
try:
+2
View File
@@ -271,6 +271,8 @@ def _ensure_discord_mock() -> None:
def __init__(self, timeout=None):
self.timeout = timeout
self.children = []
def stop(self):
pass
def add_item(self, item):
self.children.append(item)
def clear_items(self):
@@ -60,3 +60,18 @@ async def test_text_prompt_is_never_edited_in_place():
assert [kind for kind, _ in calls] == ["send"]
await settle_mod._post_timeout_notice(ctx, "ls", "card-9", 300)
assert ("edit", "card-9") in calls
@pytest.mark.asyncio
async def test_timeout_notice_uses_platform_approval_expiry():
calls = []
class _Adapter:
async def expire_exec_approval(self, chat_id, message_id, content):
calls.append((chat_id, message_id, content))
return SimpleNamespace(success=True)
ctx = SimpleNamespace(_status_adapter=_Adapter(), _status_chat_id="c", _status_thread_metadata=None)
await settle_mod._post_timeout_notice(ctx, "ls", "card-9", 3600)
assert len(calls) == 1
assert calls[0][:2] == ("c", "card-9")
@@ -1,3 +1,4 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -65,6 +66,97 @@ async def test_exec_approval_content_stays_within_discord_cap_for_long_reason():
assert "xxx" in sent["content"] # the command preview is not starved to zero
@pytest.mark.asyncio
async def test_exec_approval_view_uses_the_waiting_request_timeout():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
sent = _capture_channel(adapter)
result = await adapter.send_exec_approval(
chat_id="555", command="ls", session_key="agent:other:discord:555",
metadata={"_approval_request_id": "request-1"},
)
assert result.success
assert sent["view"].timeout is None
assert sent["view"].request_id == "request-1"
@pytest.mark.asyncio
async def test_expired_card_cannot_resolve_a_different_pending_request(monkeypatch):
from plugins.platforms.discord import adapter as discord_adapter
from tools import approval
from tools.approval_gateway_wait import _ApprovalEntry
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
sent = _capture_channel(adapter)
session = "agent:other:discord:555"
await adapter.send_exec_approval(
chat_id="555", command="ls", session_key=session,
metadata={"_approval_request_id": "expired"},
)
other = _ApprovalEntry({"request_id": "new", "command": "rm other"})
monkeypatch.setitem(approval._gateway_queues, session, [other])
view = sent["view"]
monkeypatch.setattr(view, "_gate", AsyncMock(return_value=True))
monkeypatch.setattr(view, "_finalize_embed", AsyncMock())
monkeypatch.setattr(discord_adapter.discord, "Color", SimpleNamespace(dark_grey=lambda: 0))
interaction = SimpleNamespace(user=SimpleNamespace(display_name="tester"))
await view._resolve(interaction, "once", 1, "gateway.exec_approval.action_once")
assert not other.event.is_set()
assert approval._gateway_queues[session] == [other]
@pytest.mark.asyncio
async def test_expired_approval_removes_discord_components():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
message = SimpleNamespace(edit=AsyncMock())
channel = SimpleNamespace(get_partial_message=lambda _id: message)
adapter._client = SimpleNamespace(get_channel=lambda _id: channel)
result = await adapter.expire_exec_approval("555", "1234", "Approval timed out")
assert result.success
assert message.edit.await_args.kwargs["view"] is None
@pytest.mark.asyncio
@pytest.mark.parametrize("expire_during_send", [False, True])
async def test_card_retires_when_backend_request_ends(monkeypatch, expire_during_send):
from tools import approval
from tools.approval_gateway_wait import _ApprovalEntry
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
session = "agent:other:discord:555"
entry = _ApprovalEntry({"request_id": "request-1", "command": "ls"})
monkeypatch.setitem(approval._gateway_queues, session, [entry])
message = SimpleNamespace(id=1234, edit=AsyncMock())
sent = {}
async def send(**kwargs):
sent.update(kwargs)
if expire_during_send:
approval._gateway_queues.pop(session)
return message
channel = SimpleNamespace(send=send)
adapter._client = SimpleNamespace(get_channel=lambda _id: channel)
await adapter.send_exec_approval(
chat_id="555", command="ls", session_key=session,
metadata={"_approval_request_id": "request-1"},
)
view = sent["view"]
if not expire_during_send:
assert not view.resolved
assert len(entry.observers) == 1
entry.observers[0]("timeout")
await asyncio.sleep(0)
await asyncio.sleep(0)
assert view.resolved
message.edit.assert_awaited_once()
def _embed_text(embed):
# Fields are attribute objects on discord.py and dicts on the test stub.
@@ -1,7 +1,6 @@
"""Tests for the configurable Discord interactive-view timeout.
Previously hardcoded to 300s on ExecApprovalView, SlashConfirmView,
UpdatePromptView, and ClarifyChoiceView. Now reads
SlashConfirmView, UpdatePromptView, and ClarifyChoiceView read
``approvals.discord_prompt_timeout`` with the same 300s default, clamped to
``[_DISCORD_PROMPT_TIMEOUT_MIN, _DISCORD_PROMPT_TIMEOUT_MAX]`` so a typo
can't make prompts disappear (too short) or outlive Discord's 15-min
@@ -107,6 +107,16 @@ def test_timeout_edits_the_card_to_say_the_command_did_not_run(pending_entry, tm
assert adapter.card_metadata == [{"thread_id": "t1"}] # no notify: A2A's turn-final marker
def test_prompt_carries_request_identity(pending_entry):
adapter = _ButtonAdapter()
data = dict(pending_entry.data, timeout_s=3600)
_runner(adapter)._approval_notify_sync(data)
assert adapter.card_metadata == [{
"thread_id": "t1", "_approval_request_id": pending_entry.data["request_id"],
}]
@pytest.mark.parametrize("text_fallback", [False, True])
def test_timeout_falls_back_to_a_new_message_when_the_card_cannot_be_edited(pending_entry, text_fallback):
adapter = _PlainAdapter() if text_fallback else _ButtonAdapter(editable=False)
@@ -37,7 +37,7 @@ def _run_leader_then_follower(monkeypatch, notify_cb, *, leader_poll=_leader_nev
leader_enqueued = threading.Event()
results: dict = {}
def poll(event, session_key, *, interrupt_log):
def poll(event, session_key, *, interrupt_log, timeout_s=None):
if "Coalesced" in interrupt_log:
follower_waiting.set()
return real_poll(event, session_key, interrupt_log=interrupt_log)
@@ -23,8 +23,12 @@ def test_choice_landing_after_the_deadline_check_is_resolved(monkeypatch):
hooks: list[tuple[str, dict]] = []
monkeypatch.setattr(wait_mod._ctx, "_fire_approval_hook", lambda name, **kw: hooks.append((name, kw)))
def deadline_passed_then_user_answered(event, session_key, *, interrupt_log):
settles = []
def deadline_passed_then_user_answered(event, session_key, *, interrupt_log, timeout_s=None):
# The poll loop has already decided "timeout"; the user's /approve lands before the entry leaves the queue.
request_id = mod._gateway_queues[session_key][0].data["request_id"]
assert mod.register_gateway_settle(session_key, request_id, settles.append)
assert mod.resolve_gateway_approval(session_key, "once") == 1
return "timeout"
@@ -34,13 +38,14 @@ def test_choice_landing_after_the_deadline_check_is_resolved(monkeypatch):
assert decision == {"resolved": True, "choice": "once", "reason": None}
assert hooks[-1][0] == "post_approval_response" and hooks[-1][1]["choice"] == "once"
assert settles == ["resolved"]
assert SESSION_KEY not in mod._gateway_queues
def test_plain_timeout_still_reports_unresolved(monkeypatch):
_clear()
monkeypatch.setattr(wait_mod._ctx, "_fire_approval_hook", lambda name, **kw: None)
monkeypatch.setattr(wait_mod, "_poll_event", lambda event, session_key, *, interrupt_log: "timeout")
monkeypatch.setattr(wait_mod, "_poll_event", lambda event, session_key, *, interrupt_log, timeout_s=None: "timeout")
decision = wait_mod._await_gateway_decision(SESSION_KEY, lambda data: None, APPROVAL)
@@ -49,6 +54,70 @@ def test_plain_timeout_still_reports_unresolved(monkeypatch):
assert mod.resolve_gateway_approval(SESSION_KEY, "once") == 0
def test_multiple_observers_and_settle_hook_run_on_expiry(monkeypatch):
_clear()
monkeypatch.setattr(wait_mod._ctx, "_fire_approval_hook", lambda name, **kw: None)
monkeypatch.setattr(wait_mod, "_poll_event", lambda event, session_key, *, interrupt_log, timeout_s=None: "timeout")
outcomes = []
def notify(data):
rid = data["request_id"]
assert mod.register_gateway_settle(SESSION_KEY, rid, lambda reason: outcomes.append(("notice", reason)))
assert mod.observe_gateway_settle(SESSION_KEY, rid, lambda reason: outcomes.append(("card", reason)))
wait_mod._await_gateway_decision(SESSION_KEY, notify, APPROVAL)
assert outcomes == [("notice", "timeout"), ("card", "timeout")]
def test_prompt_and_wait_use_same_profile_timeout_when_config_changes(monkeypatch):
_clear()
current = {"timeout": 3600}
monkeypatch.setattr(wait_mod._ctx, "_get_approval_timeout", lambda: current["timeout"])
monkeypatch.setattr(wait_mod._ctx, "_fire_approval_hook", lambda name, **kw: None)
observed = {}
def notify(data):
observed["prompt_timeout"] = data["timeout_s"]
current["timeout"] = 300
def poll(event, session_key, *, interrupt_log, timeout_s=None):
observed["wait_timeout"] = timeout_s
return "timeout"
monkeypatch.setattr(wait_mod, "_poll_event", poll)
decision = wait_mod._await_gateway_decision(SESSION_KEY, notify, APPROVAL)
assert decision["resolved"] is False
assert observed == {"prompt_timeout": 3600, "wait_timeout": 3600}
def test_gateway_approval_captures_routed_profiles_timeout(tmp_path, monkeypatch):
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
_clear()
default = tmp_path / "default"
routed = tmp_path / "routed"
for home, timeout in ((default, 300), (routed, 3600)):
home.mkdir()
(home / "config.yaml").write_text(f"approvals:\n timeout: {timeout}\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(default))
monkeypatch.setattr(wait_mod._ctx, "_fire_approval_hook", lambda name, **kw: None)
observed = []
def poll(event, session_key, *, interrupt_log, timeout_s=None):
observed.append(timeout_s)
return "timeout"
monkeypatch.setattr(wait_mod, "_poll_event", poll)
for home in (default, routed):
token = set_hermes_home_override(str(home))
try:
wait_mod._await_gateway_decision(SESSION_KEY, lambda data: observed.append(data["timeout_s"]), APPROVAL)
finally:
reset_hermes_home_override(token)
assert observed == [300, 300, 3600, 3600]
def test_resolve_commits_the_choice_before_releasing_the_approval_lock(monkeypatch):
"""``_drop_entry`` reads ``entry.result`` and leaves the queue in one ``_lock`` section, which only
closes the race if ``resolve_gateway_approval`` commits ``result``/``event`` INSIDE the section that
@@ -81,7 +150,7 @@ def test_withdrawn_entry_settles_with_a_wire_reason(monkeypatch):
monkeypatch.setattr(wait_mod._ctx, "_fire_approval_hook", lambda name, **kw: None)
reasons: list[str] = []
def torn_down(event, session_key, *, interrupt_log):
def torn_down(event, session_key, *, interrupt_log, timeout_s=None):
mod.register_gateway_settle(session_key, mod._gateway_queues[session_key][0].data["request_id"], reasons.append)
mod.clear_session(session_key)
return "set"
+10
View File
@@ -210,6 +210,16 @@ def register_gateway_settle(session_key: str, request_id: str, settle) -> bool:
return False
def observe_gateway_settle(session_key: str, request_id: str, observer) -> bool:
"""Observe a pending approval without replacing its existing settle hook."""
with _lock:
for entry in _gateway_queues.get(session_key, []):
if entry.data.get("request_id") == request_id:
entry.observers.append(observer)
return True
return False
def ack_gateway_approval(session_key: str, request_id: str) -> bool:
"""Record that a client received a particular pending approval request."""
with _lock:
+18 -10
View File
@@ -24,7 +24,7 @@ logger = logging.getLogger("tools.approval")
class _ApprovalEntry:
"""One pending dangerous-command approval inside a gateway session."""
__slots__ = ("acknowledged", "cancelled", "data", "event", "expired", "reason", "result", "settle")
__slots__ = ("acknowledged", "cancelled", "data", "event", "expired", "observers", "reason", "result", "settle")
def __init__(self, data: dict):
self.event = threading.Event()
@@ -34,6 +34,7 @@ class _ApprovalEntry:
# Surface hook run once when the wait ends by ANY path (answer, timeout, interrupt, /approve from
# another client): the tui_gateway withdraws its open server→client request through it.
self.settle = None
self.observers = []
self.result: str | None = None # "once"|"session"|"always"|"deny"
# Free-text reason from ``/deny <reason>`` so the agent can adapt, not just hear "denied".
self.reason: str | None = None
@@ -44,7 +45,8 @@ class _ApprovalEntry:
self.expired = False
def _poll_event(event: threading.Event, session_key: str, *, interrupt_log: str) -> str:
def _poll_event(event: threading.Event, session_key: str, *, interrupt_log: str,
timeout_s: int | None = None) -> str:
"""Wait on *event* until it fires, the turn is interrupted, or approvals.timeout
elapses; returns ``"set"`` | ``"interrupted"`` | ``"timeout"``. Polls in ~1s
slices so activity heartbeats reach the agent's inactivity tracker every ~10s —
@@ -57,7 +59,7 @@ def _poll_event(event: threading.Event, session_key: str, *, interrupt_log: str)
per-thread interrupt-cause channel (``get_interrupt_reason()``, a trusted fixed
category), never inferred from message text, so the caller can report a
withdrawn prompt without inventing a user refusal."""
deadline = time.monotonic() + max(_ctx._get_approval_timeout(), 0)
deadline = time.monotonic() + max(_ctx._get_approval_timeout() if timeout_s is None else timeout_s, 0)
heartbeat = activity_heartbeat("waiting for user approval")
with human_wait_window(session_key):
while True:
@@ -199,6 +201,7 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, *,
if not queue:
_approval._gateway_queues.pop(session_key, None)
settle, entry.settle = entry.settle, None
observers, entry.observers = entry.observers, []
if choice is None and state in ("timeout", "notify_failed"):
# Coalesced followers block on this event: wake them with the leader's outcome rather than
# leaving them waiting their own full timeout on a prompt that no longer exists.
@@ -206,18 +209,21 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, *,
if state == "notify_failed":
entry.cancelled = "the approval prompt could not be delivered"
entry.event.set()
if settle is not None:
if settle is not None or observers:
# ``request.cancel`` carries a RequestCancelReason: a choice committed from another surface is
# ``resolved``; a withdrawn entry (woken with no choice — session torn down, turn ended, client
# cannot answer) is ``session_closed``; never the raw poll-state token "set".
if state == "set":
if state == "timeout" and choice is not None:
reason = "resolved"
elif state == "set":
reason = "resolved" if choice is not None else "session_closed"
else:
reason = state
try:
settle(reason)
except Exception:
logger.debug("approval settle hook failed", exc_info=True)
for callback in ([settle] if settle is not None else []) + observers:
try:
callback(reason)
except Exception:
logger.debug("approval settle hook failed", exc_info=True)
return choice
from tools.human_input_hooks import human_input_request
@@ -226,6 +232,8 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, *,
_ctx._fire_approval_hook("pre_approval_request", **payload)
# Bridges sync agent thread → async gateway.
try:
timeout_s = _ctx._get_approval_timeout()
entry.data["timeout_s"] = timeout_s
notify_cb(dict(entry.data))
approval_published()
except Exception as exc:
@@ -235,7 +243,7 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, *,
human.outcome = "notify_failed"
return {"resolved": False, "choice": None, "notify_failed": True}
state = _poll_event(entry.event, session_key,
state = _poll_event(entry.event, session_key, timeout_s=timeout_s,
interrupt_log="Approval wait interrupted — returning deny for session %s")
cancelled = _cancel_cause(state, entry)
if state == "interrupted":