diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py
index 09a5777a4ce..37f99b75adb 100644
--- a/plugins/platforms/telegram/adapter.py
+++ b/plugins/platforms/telegram/adapter.py
@@ -4292,6 +4292,31 @@ class TelegramAdapter(BasePlatformAdapter):
# Catch-all (e.g. page counter button "mx:noop")
await query.answer()
+ async def _notify_clarify_expired(self, query, user_display: str) -> None:
+ """Tell the user a clarify tap arrived too late to be delivered.
+
+ Fires when the clarify entry was evicted by ``clarify_timeout`` or the
+ gateway restarted between asking and the tap. In both cases the agent
+ thread is no longer waiting, so the tap would otherwise leave a
+ misleading ✓ (or an "awaiting typed response" prompt) on a button the
+ agent never receives.
+ """
+ try:
+ await query.answer(text="⚠️ This prompt expired — please /retry.")
+ except Exception:
+ pass
+ try:
+ await query.edit_message_text(
+ text=(
+ f"❓ {_html.escape(query.message.text or '')}\n\n"
+ "⚠️ This question expired or the session reset — please /retry."
+ ),
+ parse_mode=ParseMode.HTML,
+ reply_markup=None,
+ )
+ except Exception:
+ pass
+
async def _handle_callback_query(
self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"
) -> None:
@@ -4529,12 +4554,20 @@ class TelegramAdapter(BasePlatformAdapter):
# clarify. Do NOT pop _clarify_state yet — we still
# need it if the user is slow to respond and the entry
# is cleared by something else.
+ flipped = False
try:
from tools.clarify_gateway import mark_awaiting_text
- mark_awaiting_text(clarify_id)
+ flipped = mark_awaiting_text(clarify_id)
except Exception as exc:
logger.warning("[%s] mark_awaiting_text failed: %s", self.name, exc)
+ if not flipped:
+ # Entry evicted (clarify_timeout) or gateway restarted
+ # between ask and tap — a typed answer would go nowhere.
+ self._clarify_state.pop(clarify_id, None)
+ await self._notify_clarify_expired(query, user_display)
+ return
+
await query.answer(text="✏️ Type your answer in the chat.")
try:
await query.edit_message_text(
@@ -4580,22 +4613,25 @@ class TelegramAdapter(BasePlatformAdapter):
logger.error("[%s] resolve_gateway_clarify failed: %s", self.name, exc)
resolved = False
- await query.answer(text=f"✓ {resolved_text[:60]}")
- try:
- await query.edit_message_text(
- text=f"❓ {_html.escape(query.message.text or '')}\n\n{_html.escape(user_display)}: {_html.escape(resolved_text)}",
- parse_mode=ParseMode.HTML,
- reply_markup=None,
- )
- except Exception:
- pass
-
if resolved:
+ await query.answer(text=f"✓ {resolved_text[:60]}")
+ try:
+ await query.edit_message_text(
+ text=f"❓ {_html.escape(query.message.text or '')}\n\n{_html.escape(user_display)}: {_html.escape(resolved_text)}",
+ parse_mode=ParseMode.HTML,
+ reply_markup=None,
+ )
+ except Exception:
+ pass
logger.info(
"Telegram clarify button resolved (id=%s, choice=%r, user=%s)",
clarify_id, resolved_text, user_display,
)
else:
+ # Entry evicted (clarify_timeout) or gateway restarted
+ # between ask and tap — surface this instead of leaving a
+ # misleading ✓ on a button the agent will never receive.
+ await self._notify_clarify_expired(query, user_display)
logger.warning(
"Telegram clarify button: resolve_gateway_clarify returned False (id=%s)",
clarify_id,
diff --git a/tests/gateway/test_telegram_clarify_buttons.py b/tests/gateway/test_telegram_clarify_buttons.py
index 70a06087a44..420e7046859 100644
--- a/tests/gateway/test_telegram_clarify_buttons.py
+++ b/tests/gateway/test_telegram_clarify_buttons.py
@@ -351,6 +351,71 @@ class TestTelegramClarifyCallback:
# State preserved
assert adapter._clarify_state["cidC"] == "sk-auth"
+ @pytest.mark.asyncio
+ async def test_numeric_choice_expired_notifies_user(self):
+ """Late tap after the entry was evicted (timeout) or the gateway
+ restarted must surface an expiry notice, not a misleading ✓."""
+ adapter = _make_adapter()
+ # _clarify_state still maps the id (timeout eviction does not pop it),
+ # but the clarify primitive entry is gone → resolve returns False.
+ adapter._clarify_state["cidExpired"] = "sk-expired"
+
+ query = AsyncMock()
+ query.data = "cl:cidExpired:0"
+ query.message = MagicMock()
+ query.message.chat_id = 12345
+ query.message.text = "Pick"
+ query.from_user = MagicMock()
+ query.from_user.id = "777"
+ query.from_user.first_name = "Tester"
+ query.answer = AsyncMock()
+ query.edit_message_text = AsyncMock()
+
+ update = MagicMock()
+ update.callback_query = query
+ context = MagicMock()
+
+ with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
+ await adapter._handle_callback_query(update, context)
+
+ # User is told the prompt expired — not a misleading checkmark.
+ answer_text = query.answer.call_args[1]["text"].lower()
+ assert "expired" in answer_text
+ edit_text = query.edit_message_text.call_args[1]["text"].lower()
+ assert "expired" in edit_text or "session reset" in edit_text
+ assert "/retry" in edit_text
+
+ @pytest.mark.asyncio
+ async def test_other_button_expired_notifies_user(self):
+ """Tapping 'Other' after the entry was evicted must tell the user the
+ prompt expired instead of silently entering text-capture mode."""
+ adapter = _make_adapter()
+ # No clarify primitive entry → mark_awaiting_text returns False.
+ adapter._clarify_state["cidOtherExpired"] = "sk-other-expired"
+
+ query = AsyncMock()
+ query.data = "cl:cidOtherExpired:other"
+ query.message = MagicMock()
+ query.message.chat_id = 12345
+ query.message.text = "Pick"
+ query.from_user = MagicMock()
+ query.from_user.id = "777"
+ query.from_user.first_name = "Tester"
+ query.answer = AsyncMock()
+ query.edit_message_text = AsyncMock()
+
+ update = MagicMock()
+ update.callback_query = query
+ context = MagicMock()
+
+ with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
+ await adapter._handle_callback_query(update, context)
+
+ answer_text = query.answer.call_args[1]["text"].lower()
+ assert "expired" in answer_text
+ # State popped so a subsequent typed message is not mis-captured.
+ assert "cidOtherExpired" not in adapter._clarify_state
+
@pytest.mark.asyncio
async def test_invalid_choice_token(self):
from tools import clarify_gateway as cm