Files
hermes-agent/tests/gateway/test_prompt_decline_no_fallback.py
teknium1 98b4efbf48 fix: clarify cards that cannot render are re-asked as plain text, never reported as user inactivity
A native clarify card on a messaging platform (Telegram inline keyboard, Slack blocks) could
fail without the user ever seeing a question, and the agent then waited out the full
clarify_timeout and reported "[user did not respond within Nm]" (#112684):

- the platform rejects the card at once -> the wait aborted with a delivery sentinel but the
  question was never re-asked;
- the send outruns the 15 s acknowledgement window and only then fails (pool/connect timeouts,
  a stale-thread retry) -> the runner never looked at the send future again and blocked for
  the whole timeout for a card that never posted;
- no status adapter at all -> _ask_clarify_question returned ('', False), so a batch reported
  timed_out=True with an empty notice.

gateway/run_turn_runner_clarify_delivery.py (new topical sibling; the send-disposition helpers
move out of the gateway/run.py facade) now retries every definitive card failure once through the
adapter's plain-text send_clarify (numbered list + text capture) - except a connector egress
DECLINE, where re-sending the text is the exfiltration the guard exists to stop - and watches a
possibly-delivered send so a late failure releases the waiter with
"[clarify prompt could not be delivered]". The no-surface case reports
"[clarify prompt could not be delivered: no chat surface]" (same prefix every consumer already
treats as a non-answer).

Live probe (real TurnRunner + real clarify_gateway, Telegram-shaped adapter, timeout 30 s):
card fails after 16 s -> before 45.2 s and "[user did not respond within 0m]", after 16.7 s and
the typed answer to the text prompt; card rejected at once -> before sentinel with no text prompt,
after the numbered prompt is sent and "2" resolves to the choice.
2026-09-17 09:06:17 -07:00

258 lines
8.9 KiB
Python

"""P5(b) at the CALLER: a connector egress decline must not fall back.
The adapter-level tests prove the decline REACHES the caller. These prove the
caller ACTS on it. Review round 1 blocker B-3: `_approval_send_outcome` had
only sent/failed/ambiguous, so a decline collapsed into `failed` — which is
precisely the cue to run the plain-text fallback into the chat the connector
had just refused. The adapter fix improved the error STRING while the
user-visible behaviour stayed identical to base.
These tests drive the real `gateway.run` classifier and the real
`tools.slash_confirm` registry; only the SendResult (the connector's answer)
is constructed.
"""
from __future__ import annotations
import concurrent.futures
from types import SimpleNamespace
import pytest
DECLINE_ERROR = (
"discord egress declined: target is not an approved destination for this connection"
)
LANE_ERROR = "relay prompt op unavailable"
def _future(result):
fut: concurrent.futures.Future = concurrent.futures.Future()
fut.set_result(result)
return fut
def _result(*, success: bool, error: str | None = None):
return SimpleNamespace(success=success, error=error, message_id=None)
# ── the classifier ──────────────────────────────────────────────────────────
def test_decline_is_not_classified_as_failed():
"""`failed` is the fallback cue; a decline must not wear it."""
from gateway.run import _approval_send_outcome
outcome = _approval_send_outcome(
_future(_result(success=False, error=DECLINE_ERROR)), timeout=5
)
assert outcome == "declined"
def test_genuine_lane_failure_still_falls_back():
"""The guard must not swallow real failures: those still re-ask."""
from gateway.run import _approval_send_outcome
outcome = _approval_send_outcome(
_future(_result(success=False, error=LANE_ERROR)), timeout=5
)
assert outcome == "failed"
def test_success_and_timeout_verdicts_unchanged():
"""No collateral change to the two settled verdicts."""
from gateway.run import _approval_send_outcome
assert (
_approval_send_outcome(_future(_result(success=True)), timeout=5) == "sent"
)
pending: concurrent.futures.Future = concurrent.futures.Future()
assert _approval_send_outcome(pending, timeout=0.05) == "ambiguous"
@pytest.mark.parametrize(
"error",
[
DECLINE_ERROR,
"slack egress declined: destination not permitted",
# Case-insensitivity is part of the contract (`.lower()` in
# is_egress_decline), so a connector that capitalises still classifies.
"WhatsApp Egress Declined: target refused",
],
)
def test_decline_recognised_across_lanes(error):
"""The verdict follows the decline CONTRACT, not one lane's wording.
The contract is `EGRESS_DECLINE_MARKER` ("egress declined:") or a
structured `code`; an invented sentence like "EGRESS_DECLINED: ..." is NOT
a decline and must not be treated as one. My first version of this test
asserted that invented form and failed — the test was wrong, not the code.
"""
from gateway.run import _approval_send_outcome
assert (
_approval_send_outcome(_future(_result(success=False, error=error)), timeout=5)
== "declined"
)
def test_non_decline_error_text_is_not_laundered_into_declined():
"""Fail-closed the other way: only the real contract yields `declined`.
Without this, a permissive marker check would silence genuine failures —
turning a lane outage into a silent no-fallback.
"""
from gateway.run import _approval_send_outcome
for error in (
"connection reset by peer",
"declined", # bare word, not the marker sentence
"egress declined", # no colon: not the uniform marker
):
assert (
_approval_send_outcome(
_future(_result(success=False, error=error)), timeout=5
)
== "failed"
), error
# ── the STRUCTURED response, not the rendered string ────────────────────────
#
# Round-2 review: I fixed the text-marker path and tested only the text-marker
# path. The adapter preserves the connector's own dict in `raw_response`, and
# classifying the error STRING instead loses two contracts.
def _result_raw(raw, *, error=None):
from gateway.relay.egress import decline_error
return SimpleNamespace(
success=False, error=error if error is not None else decline_error(raw),
raw_response=raw,
)
def test_code_only_decline_is_declined_not_failed():
"""A decline carrying `code` and NO text must not fall back.
`decline_error()` renders it as "relay egress declined" — no marker colon —
so a string-based check returns `failed`, which is the fallback cue. This
is the shape the connector actually sends when it has no human text.
"""
from gateway.run import _approval_send_outcome
raw = {"success": False, "code": "egress_declined"}
assert _approval_send_outcome(_future(_result_raw(raw)), timeout=5) == "declined"
def test_ambiguous_result_is_never_a_definite_failure():
"""Lost ack ≠ refusal ≠ failure.
`is_egress_decline` deliberately excludes `ambiguous`, because the frame
may well have been applied. Flattening it into `failed` re-sends a card
that may already be on the user's screen — the duplicate-card bug the
ambiguous verdict was introduced to prevent.
"""
from gateway.run import _approval_send_outcome
raw = {"success": False, "ambiguous": True, "error": "lost ack"}
assert _approval_send_outcome(_future(_result_raw(raw)), timeout=5) == "ambiguous"
def test_ambiguous_wins_over_decline_text():
"""An ambiguous result carrying decline text is STILL ambiguous.
Order matters: a mid-write drop whose partial error happens to contain the
marker must not be reported as a definite refusal.
"""
from gateway.run import _approval_send_outcome
raw = {
"success": False,
"ambiguous": True,
"error": "discord egress declined: target is not approved",
}
assert _approval_send_outcome(_future(_result_raw(raw)), timeout=5) == "ambiguous"
def test_structured_lane_failure_still_falls_back():
"""A structured NON-decline failure keeps its fallback."""
from gateway.run import _approval_send_outcome
raw = {"success": False, "error": "connection reset"}
assert _approval_send_outcome(_future(_result_raw(raw)), timeout=5) == "failed"
def test_legacy_connector_without_raw_response_still_classified():
"""No `raw_response` (older connector) falls back to the wire sentence."""
from gateway.run import _approval_send_outcome
legacy = SimpleNamespace(success=False, error=DECLINE_ERROR, raw_response=None)
assert _approval_send_outcome(_future(legacy), timeout=5) == "declined"
def test_declined_clarify_aborts_instead_of_waiting_for_a_reply():
"""Review round 3, finding 4.
`_clarify_send_disposition` handled `failed` and `ambiguous`; `declined`
fell through to `wait_for_response`, so the agent blocked until
clarify_timeout for a card that was REFUSED and can never be answered.
A decline is more definitive than a failure, not less.
"""
from types import SimpleNamespace
from gateway.relay.egress import EGRESS_DECLINE_CODE
from gateway.run_turn_runner_clarify_delivery import _clarify_send_disposition
cleared = []
clarify_mod = SimpleNamespace(
clear_session=lambda sk: cleared.append(sk),
get_clarify_timeout=lambda: 600,
wait_for_response=lambda *a, **k: pytest.fail(
"waited for a reply to a prompt the connector refused"
),
)
class _Fut:
def result(self, timeout=None):
return SimpleNamespace(
success=False,
error=None,
raw_response={"success": False, "code": EGRESS_DECLINE_CODE},
)
abort = _clarify_send_disposition(
_Fut(), session_key="sk1", clarify_mod=clarify_mod
)
assert abort is not None
assert cleared == ["sk1"]
def test_ambiguous_clarify_still_waits():
"""Control: a possibly-delivered card must STAY armed for a late reply."""
from types import SimpleNamespace
from gateway.run_turn_runner_clarify_delivery import _clarify_send_disposition
cleared = []
clarify_mod = SimpleNamespace(
clear_session=lambda sk: cleared.append(sk),
get_clarify_timeout=lambda: 600,
wait_for_response=lambda *a, **k: "answer",
)
class _Fut:
def result(self, timeout=None):
return SimpleNamespace(
success=False,
error="lost ack",
raw_response={"success": False, "error": "x", "ambiguous": True},
)
assert (
_clarify_send_disposition(_Fut(), session_key="sk1", clarify_mod=clarify_mod)
is None
)
assert cleared == []