Files
hermes-agent/tests/gateway/test_clarify_card_retire_on_timeout.py
teknium1 21c698b7df test: purge low-value tests, lane py06 (309 removed)
Change-detectors, tautologies, source-reading tests, redundant duplicates,
mock-echo tests and dead/unrunnable tests. Per-test rationale in the lane
ledger (category + reason for every removal).
2026-09-23 03:15:26 -07:00

168 lines
6.8 KiB
Python

"""A clarify that ends without a click retires the adapter's native card (#110821, #111019).
Drives the real ``TurnRunner._clarify_callback_sync`` against a duck-typed adapter with a
persistent card: on timeout the gateway schedules ``retire_clarify_card`` with the expired
notice; an adapter without that method (text-prompt platforms) gets nothing scheduled.
"""
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from gateway.platforms.base import SendResult
class _CardAdapter:
def __init__(self):
self.retired: list[tuple[str, str]] = []
self.asked: list[str] = []
self.resumed: int = 0
def pause_typing_for_chat(self, chat_id):
return None
def resume_typing_for_chat(self, chat_id):
self.resumed += 1
async def send_clarify(self, **kwargs):
self.asked.append(kwargs["question"])
return SendResult(success=True, message_id="1.2")
async def retire_clarify_card(self, clarify_id, notice):
self.retired.append((clarify_id, notice))
class _TextAdapter(_CardAdapter):
retire_clarify_card = None # type: ignore[assignment]
def _run_clarify(adapter, answer=None, questions=None, answers=(), via_tool=False):
"""Returns (clarify result, labels of every coroutine the runner scheduled).
``answer`` resolves a single question with that text instead of letting it time out.
``questions`` drives clarify_tool's batch form instead: question i resolves from
``answers[i]``, and ``None`` leaves it unanswered so it expires. ``via_tool`` routes the
batch through ``clarify_tool`` itself rather than calling the runner's callback directly."""
from gateway.run_turn_runner import TurnRunner
from tools import clarify_gateway as cm
runner = object.__new__(TurnRunner)
runner._ctx = SimpleNamespace(
_status_adapter=adapter, _status_chat_id="C1", _status_thread_metadata={},
session_key="sk1", stream_consumer_holder=[None])
labels: list[str] = []
class _Fut:
def __init__(self, r): self._r = r
def result(self, timeout=None): return self._r
def _schedule(coro, label):
labels.append(label)
return _Fut(asyncio.run(coro))
runner._schedule = _schedule
runner._close_native_stream_boundary = lambda *a, **k: None
real_register = cm.register
seen = {"n": 0}
def _register(**kwargs):
entry = real_register(**kwargs)
index = seen["n"]
seen["n"] += 1
target = answer if questions is None else (answers[index] if index < len(answers) else None)
if target is not None:
cm.resolve_gateway_clarify(kwargs["clarify_id"], target)
return entry
with patch.object(cm, "register", _register), \
patch("tools.clarify_gateway.get_clarify_timeout", return_value=0.05):
if questions is None:
return runner._clarify_callback_sync("Pick one", ["a", "b"]), labels
if via_tool:
from tools.clarify_tool import clarify_tool
return clarify_tool("", questions=questions,
callback=runner._clarify_callback_sync), labels
return runner._clarify_callback_sync("", None, questions=questions), labels
def test_timeout_retires_the_native_card_with_the_expired_notice():
adapter = _CardAdapter()
response, _labels = _run_clarify(adapter)
assert response.startswith("[user did not respond")
assert len(adapter.retired) == 1
assert "expired" in adapter.retired[0][1].lower()
def test_timeout_schedules_nothing_for_adapters_without_a_card():
_response, labels = _run_clarify(_TextAdapter())
assert labels == ["Clarify send failed to schedule"]
def test_real_answer_starting_with_a_bracket_is_not_mistaken_for_a_sentinel():
"""'[A] staging' is a user answer, not a timeout: no card retirement, typing re-armed."""
adapter = _CardAdapter()
response, labels = _run_clarify(adapter, answer="[A] staging")
assert response == "[A] staging"
assert adapter.retired == []
assert labels == ["Clarify send failed to schedule"]
assert adapter.resumed == 1 # a lone card re-arms typing the moment it is answered
# --- Batches: one card per question, stop at the first unanswered one -----
_THREE_QUESTIONS = [{"qid": f"q{i}", "question": q, "choices": ["a", "b"]}
for i, q in enumerate(("One?", "Two?", "Three?"))]
@pytest.mark.parametrize("answers,asked,payload,resumed", [
# Nobody answers question 1: the batch ends there instead of re-asking — every further
# question used to cost another full clarify_timeout — and reports the walk-away.
((), ["One?"], {"answers": {}, "timed_out": True, "notice": "[user did not respond within 0m]"}, 0),
# Answers already given survive; the unanswered question is not invented.
(("use postgres",), ["One?", "Two?"],
{"answers": {"q0": "use postgres"}, "timed_out": True, "notice": "[user did not respond within 0m]"}, 0),
# A fully answered batch re-arms typing once, at the end: between two cards the re-arm
# would only open a bubble the next question's boundary finalizes.
(("one", "two", "three"), ["One?", "Two?", "Three?"],
{"answers": {"q0": "one", "q1": "two", "q2": "three"}, "timed_out": False}, 1),
])
def test_batch_routing(answers, asked, payload, resumed):
adapter = _CardAdapter()
raw, _labels = _run_clarify(adapter, questions=_THREE_QUESTIONS, answers=answers)
assert adapter.asked == asked
assert json.loads(raw) == payload
assert adapter.resumed == resumed
if payload["timed_out"]:
assert len(adapter.retired) == 1 # the card that expired is retired
class _UndeliverableAdapter(_CardAdapter):
"""Telegram's ``_send_prompt`` shape when the Bot API rejects the card."""
async def send_clarify(self, **kwargs):
self.asked.append(kwargs["question"])
return SendResult(success=False, error="Bad Request: chat not found")
@pytest.mark.parametrize("adapter_cls,notice", [
(_CardAdapter, "[user did not respond within 0m]"),
# #112684: an undelivered card must not read as user inactivity — the delivery
# sentinel rides along instead of being stored as the question's "answer".
(_UndeliverableAdapter, "[clarify prompt could not be delivered]"),
])
def test_clarify_tool_batch_route_answers_and_stops_together(adapter_cls, notice):
"""End to end: the tool sees blank answers + ``timed_out`` + the surface's notice, where the
legacy loop stored the sentinel as each question's answer and never set the flag."""
adapter = adapter_cls()
raw, _labels = _run_clarify(
adapter, questions=_THREE_QUESTIONS[:2], via_tool=True)
assert adapter.asked == ["One?"]
result = json.loads(raw)
assert result["timed_out"] is True
assert result["notice"] == notice
assert [r["user_response"] for r in result["responses"]] == ["", ""]