Files
hermes-agent/tests/gateway/test_clarify_delivery_fallback.py
Siddharth Balyan 5eea87882a Clarify: one question shape and one result shape on every surface (skip, cancel, timeout and undelivered are told apart) (#127760)
* refactor(desktop): split clarify-tool.tsx into a clarify/ folder

Pure moves, no behaviour change. Parsing, the question-card core
(shell, choice rows, question block), the delivery watchdog, and each
pending/settled card get their own file so the core can be reused.

* feat(clarify): one questions[] shape with per-question status and one outcome

The clarify tool now takes only questions=[{question, choices?, multi_select?}].
A wrong shape is a tool error that names the right one, so a model that
sends the old top-level question/choices corrects itself on the next call.

Every result has the same shape on every surface: each response carries
status (answered, skipped, unanswered) with user_response null unless
answered, and the result carries one outcome (submitted, cancelled,
timed_out, undelivered) plus an optional surface-written notice. The
timeout and cancel sentences that used to pose as the user's answer are
gone, and the compressor reads status instead of matching their prefixes.

The callback contract is callback(questions) -> {answers, outcome, notice?}
(None = skipped, missing qid = unanswered). tui_gateway settles a batch the
same way for the last lock, a cancel, an interrupt and the deadline, and
clarify.lock keeps a null answer as a skip. A multi-select answer that is
not a JSON array counts as one typed ("Other") answer. The tool-row preview
reads the first question.

* feat(clarify): every surface asks through the one question card

CLI: the batch panel is the only panel; Enter on an empty field skips a
question, Ctrl+C cancels and keeps the locked answers, the deadline returns
timed_out. -q and -z return undelivered with a no-user notice.

Ink TUI: the single-question prompt is gone; the card supports multi-select
(Space or a digit toggles, Enter locks, typed Other text joins the picks),
an empty submit skips, Esc cancels, and "Batch" names are dropped.

Desktop: the single-question card is deleted and its keyboard handling moved
into the one card (arrows, letters and digits, auto-advance, Enter picks then
confirms). Confirm enables at one answer; blank questions lock null; Skip and
a composer message cancel; the settled card shows No answer for unanswered
questions. The bots room card follows the same contract.

Messaging: one card per question with a "Reply skip to skip" line in every
locale; timeouts and delivery failures map to timed_out and undelivered.

* fix(clarify): cancelled for a released messaging card, labels win over the skip word

A prose reply to a messaging choice card, /new and session end released
the wait with "", which read as timed_out. They now resolve with a
CANCELLED marker and the tool gets outcome cancelled.

A typed reply that matches a choice label ("Skip") now resolves to that
choice; the skip word applies only when no choice matches.

The bots room card sends the picked labels as they are; the tool already
strips the recommendation label. Unused OUTCOMES and a new docstring and
comment are removed.

* fix(tui): re-editing a multi-select answer keeps its picks

The Ink card stored a multi-select answer as display text ("A, B"), so
revisiting the question put the whole string into Other and re-locked it
as one item. The answers map now keeps the raw JSON array, the card
restores the picks on revisit, and only the display lines format it.

Comments that earlier edits reworded are restored to their original
words, minus the phrases the change made wrong. The compute-host clarify
lock accepts None for a skipped question.

* test(clarify): align three checks with the one-answer confirm and the regenerated keys

The desktop Cmd+Enter test now expects a send with one answer (the blank
question locks null), the compressor test expects the single-answer summary
the code gives, and locales/_keys.desktop.json is regenerated for the
removed and added clarify strings.

* test(tui): the one-question card header is singular
2026-09-29 18:33:44 +05:30

227 lines
9.1 KiB
Python

"""A clarify card that cannot render is retried as plain text, and a card that fails late does
not pass for user inactivity (#112684).
Real ``TurnRunner._clarify_callback_sync`` + real ``tools.clarify_gateway`` + a real gateway loop
thread; the adapter is Telegram-shaped (``SendResult`` shapes from
``plugins/platforms/telegram/adapter.py::_send_prompt``) with a native ``send_clarify`` override.
"""
import asyncio
import json
import threading
import time
from types import SimpleNamespace
import pytest
from gateway.platforms.base import BasePlatformAdapter, SendResult
from tools import clarify_gateway as cm
UNDELIVERED = "[clarify prompt could not be delivered" # prefix shared by every delivery notice
PICK = [{"qid": "q0", "question": "Pick?", "choices": ["alpha", "beta"], "multi_select": False}]
class _CardAdapter(BasePlatformAdapter):
"""Native card override; ``card`` is the coroutine function the card send runs."""
def __init__(self, card):
self.card = card
self.sent_text: list[str] = []
self.cards = 0
self.on_text_sent = None
def pause_typing_for_chat(self, chat_id):
return None
def resume_typing_for_chat(self, chat_id):
return None
async def send_clarify(self, **kwargs):
self.cards += 1
return await self.card()
async def send(self, chat_id, content, reply_to=None, metadata=None):
self.sent_text.append(content)
if self.on_text_sent is not None:
callback, self.on_text_sent = self.on_text_sent, None
callback()
return SendResult(success=True, message_id="t1")
async def retire_clarify_card(self, clarify_id, notice):
return None
async def connect(self):
return True
async def disconnect(self):
return None
async def get_chat_info(self, chat_id):
return {}
@pytest.fixture
def loop():
loop = asyncio.new_event_loop()
thread = threading.Thread(target=loop.run_forever, daemon=True)
thread.start()
yield loop
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=5)
def _runner(adapter, loop, monkeypatch, timeout=5):
from gateway.run_turn_runner import TurnRunner
runner = object.__new__(TurnRunner)
runner._ctx = SimpleNamespace(
_status_adapter=adapter, _status_chat_id="42", _status_thread_metadata=None,
session_key="sk-fallback", stream_consumer_holder=[None], _loop_for_step=loop)
runner._close_native_stream_boundary = lambda *a, **k: None
monkeypatch.setattr(cm, "get_clarify_timeout", lambda: timeout)
return runner
def _answer_once_text_prompt_is_seen(adapter, text):
"""Answer causally from the adapter's plain-text send, never from a timing poll."""
adapter.on_text_sent = lambda: cm.resolve_text_response_for_session("sk-fallback", text)
# --- Atom: the platform rejects the native card -> plain-text question instead of a sentinel ---
def test_rejected_card_is_reasked_as_plain_text_and_the_typed_answer_counts(loop, monkeypatch):
async def rejected():
return SendResult(success=False, error="Bad Request: BUTTON_TYPE_INVALID")
adapter = _CardAdapter(rejected)
_answer_once_text_prompt_is_seen(adapter, "2")
reply = _runner(adapter, loop, monkeypatch)._clarify_callback_sync(PICK)
assert reply == {"answers": {"q0": "beta"}, "outcome": "submitted"} # the numbered text prompt maps "2" back to the choice
assert adapter.cards == 1
assert len(adapter.sent_text) == 1 and "1. alpha" in adapter.sent_text[0]
def test_declined_card_is_never_reasked_as_text(loop, monkeypatch):
"""A connector egress DECLINE refused the destination: re-sending the question as text into
that chat is the exfiltration the guard exists to stop."""
async def declined():
return SendResult(success=False, error="egress declined: destination not allowed")
adapter = _CardAdapter(declined)
reply = _runner(adapter, loop, monkeypatch)._clarify_callback_sync(PICK)
assert reply["outcome"] == "undelivered"
assert reply["notice"].startswith(UNDELIVERED)
assert adapter.sent_text == []
# --- Atom: the card send outruns the ack window, then fails -------------------------------
def test_card_failing_after_the_ack_window_falls_back_to_text_instead_of_waiting(loop, monkeypatch):
from gateway import run_turn_runner_clarify_delivery as delivery
monkeypatch.setattr(delivery, "SEND_ACK_WINDOW", 0.2)
async def late_failure():
await asyncio.sleep(0.6)
return SendResult(success=False, error="Timed out: pool timeout")
adapter = _CardAdapter(late_failure)
_answer_once_text_prompt_is_seen(adapter, "beta")
started = time.monotonic()
reply = _runner(adapter, loop, monkeypatch, timeout=30)._clarify_callback_sync(PICK)
elapsed = time.monotonic() - started
assert reply["answers"] == {"q0": "beta"}
assert len(adapter.sent_text) == 1 # the plain-text prompt was actually sent, once
assert elapsed < 0.2 + 2 # released right after the ack window + late failure, never clarify_timeout
def test_card_resolving_ambiguous_after_the_ack_window_stays_armed_for_a_button_tap(loop, monkeypatch):
"""A relay lost-ack (``raw_response.ambiguous``) after the window means the card MAY have posted:
the registration must stay armed so the user's later button tap still answers — the same
invariant ``_abort_for_outcome`` keeps for an immediate ambiguous outcome. Before: the late watch
treated it like a definitive failure, released the wait with the delivery notice and the tap was
lost (no pending entry)."""
from gateway import run_turn_runner_clarify_delivery as delivery
monkeypatch.setattr(delivery, "SEND_ACK_WINDOW", 0.2)
async def late_ambiguous():
await asyncio.sleep(0.6)
return SendResult(success=False, raw_response={"ambiguous": True})
adapter = _CardAdapter(late_ambiguous)
pending_at_tap = []
def _tap_button():
time.sleep(1.5)
entry = cm.get_pending_for_session("sk-fallback", include_choice_prompts=True)
pending_at_tap.append(entry)
if entry is not None:
cm.resolve_gateway_clarify(entry.clarify_id, "beta")
tap = threading.Thread(target=_tap_button, daemon=True)
tap.start()
response, answered = _runner(adapter, loop, monkeypatch, timeout=30)._ask_clarify_question(
"Pick?", ["alpha", "beta"], False)
tap.join(timeout=5) # never let a late tap leak into the next test's registration
assert pending_at_tap and pending_at_tap[0] is not None # still armed when the tap arrived
assert (response, answered) == ("beta", True)
assert adapter.sent_text == [] # possibly-delivered: never re-sent as text
def test_card_and_text_both_failing_late_release_the_wait_with_the_delivery_notice(loop, monkeypatch):
"""Before: a card whose send failed after the window left the agent blocked for the whole
clarify_timeout and then reported ``[user did not respond within Nm]`` for a question the user
never saw."""
from gateway import run_turn_runner_clarify_delivery as delivery
monkeypatch.setattr(delivery, "SEND_ACK_WINDOW", 0.2)
async def late_failure():
await asyncio.sleep(0.6)
return SendResult(success=False, error="Timed out: pool timeout")
class _NoTextEither(_CardAdapter):
async def send(self, chat_id, content, reply_to=None, metadata=None):
self.sent_text.append(content)
return SendResult(success=False, error="Timed out")
adapter = _NoTextEither(late_failure)
started = time.monotonic()
response, answered = _runner(adapter, loop, monkeypatch, timeout=30)._ask_clarify_question(
"Pick?", ["alpha", "beta"], False)
assert (response, answered) == (UNDELIVERED + "]", False)
assert time.monotonic() - started < 10
assert len(adapter.sent_text) == 1 # the text fallback was tried exactly once
def test_card_declined_after_the_ack_window_releases_with_the_declined_notice(loop, monkeypatch):
"""A late connector DECLINE is as definitive as an immediate one: no text retry, and the
notice names the refusal (``UNDELIVERED_DECLINED``) instead of the generic delivery failure."""
from gateway import run_turn_runner_clarify_delivery as delivery
monkeypatch.setattr(delivery, "SEND_ACK_WINDOW", 0.2)
async def late_decline():
await asyncio.sleep(0.6)
return SendResult(success=False, error="egress declined: destination not allowed")
adapter = _CardAdapter(late_decline)
response, answered = _runner(adapter, loop, monkeypatch, timeout=30)._ask_clarify_question(
"Pick?", ["alpha", "beta"], False)
assert (response, answered) == (delivery.UNDELIVERED_DECLINED, False)
assert adapter.sent_text == []
# --- Atom: no chat surface at all -----------------------------------------------------------
def test_no_status_adapter_reports_the_missing_surface_not_inactivity(loop, monkeypatch):
runner = _runner(None, loop, monkeypatch)
reply = runner._clarify_callback_sync(
[{"qid": "q0", "question": "One?", "choices": ["a"], "multi_select": False}])
assert reply["outcome"] == "undelivered"
assert reply["notice"].startswith(UNDELIVERED)