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.
258 lines
8.9 KiB
Python
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 == []
|