diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 50ca91df2633..582d2cb1c640 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -4383,6 +4383,16 @@ async def _stop_typing_task() -> None: self.name, len(_response_pre_extract), event.source.chat_id, ) + # The user-visible reply has been delivered. Stop typing before + # post-delivery hooks/debounce cleanup so Discord does not keep + # refreshing "typing" after the response is already visible. + await _stop_typing_task() + try: + if hasattr(self, "stop_typing"): + await self.stop_typing(event.source.chat_id) + except Exception: + pass + # Determine overall success for the processing hook processing_ok = delivery_succeeded if delivery_attempted else not bool(response) await self._run_processing_hook( diff --git a/tests/gateway/test_base_topic_sessions.py b/tests/gateway/test_base_topic_sessions.py index dd2ef3a12622..a944826232ba 100644 --- a/tests/gateway/test_base_topic_sessions.py +++ b/tests/gateway/test_base_topic_sessions.py @@ -174,6 +174,52 @@ async def hold_typing(_chat_id, interval=2.0, metadata=None): ("complete", "1", ProcessingOutcome.FAILURE), ] + @pytest.mark.asyncio + async def test_process_message_background_stops_typing_before_complete_hook(self): + adapter = DummyTelegramAdapter() + keep_typing_cancelled = asyncio.Event() + complete_hook_seen = asyncio.Event() + stop_calls = [] + order = [] + + async def handler(_event): + await asyncio.sleep(0) + return "ack" + + async def hold_typing(_chat_id, interval=2.0, metadata=None): + try: + await asyncio.Event().wait() + finally: + order.append("typing-cancelled") + keep_typing_cancelled.set() + + async def stop_typing(chat_id: str): + stop_calls.append(chat_id) + order.append("stop-typing") + + async def on_processing_complete(event: MessageEvent, outcome: ProcessingOutcome) -> None: + order.append("complete") + assert keep_typing_cancelled.is_set() + adapter.processing_hooks.append(("complete", event.message_id, outcome)) + complete_hook_seen.set() + + adapter.set_message_handler(handler) + adapter._keep_typing = hold_typing + adapter.stop_typing = stop_typing + adapter.on_processing_complete = on_processing_complete + + event = _make_event("-1001", "17585") + await adapter._process_message_background(event, build_session_key(event.source)) + + assert complete_hook_seen.is_set() + assert stop_calls + assert order.index("typing-cancelled") < order.index("complete") + assert order.index("stop-typing") < order.index("complete") + assert adapter.processing_hooks == [ + ("start", "1"), + ("complete", "1", ProcessingOutcome.SUCCESS), + ] + @pytest.mark.asyncio async def test_process_message_background_marks_exception_unsuccessful(self): adapter = DummyTelegramAdapter()