Typing indicators linger or get stuck after replies
NousResearch/hermes-agent · cluster 2831 · gateway
Data as of
13 open items. Fix merged in #3003. Check it covers your case.
The short answer
- Known?
- Yes. 56 related items grouped here, 15 issues already closed.
- Fix event
- A fix was merged to main in PR #3003. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #10239, #44836, #50378. That is separate from the fix event. - Still open
- 5 issues and 8 PRs.
Next step: read #3003, then check whether your report is one of the 5 still open.
Read #3003Pick up a Lab taskBugs and fixes across multiple platforms (Discord, Slack, Matrix, Telegram, BlueBubbles) where typing indicators persist or get stuck after a response is sent or streamed. Work focuses on explicitly stopping or capping typing indicators before or after final message delivery.
Open issues5
- Issue#31534BlueBubbles: typing indicator lingers ~10s after the agent turn completes (phantom typing — extend #3003 to BlueBubbles)
- Issue#42087Bug: Ghost typing indicator on Telegram after background process / synth_event delivery
- Issue#49290Discord: typing indicator lingers after response is sent
- Issue#50766Typing indicator persists after /new — _handle_reset_command skips interrupt_session_activity()
- Issue#62394Teams: typing indicator animates forever after responses complete — suspected leaked _keep_typing task
Open PRs8
- PR#21688fix(telegram): stop typing refresh before response
- PR#29172fix(gateway): stop typing indicator before final response delivery
- PR#33631fix(telegram): stop typing after final reply
- PR#41258fix(telegram): pause notify typing during flood-control sends (#41168)
- PR#42129fix(gateway): skip typing indicator for internal/synth events
- PR#49304fix(gateway): stop typing indicator on first stream delta for Discord
- PR#52490fix(gateway): stop typing before final delivery
- PR#123916fix(bluebubbles): stop re-lighting the typing indicator after replies
Fixes on record
-
#3003 fix(discord): stop phantom typing indicator after agent turn completes
files:
gateway/platforms/base.pyShow the diff
-
gateway/platforms/base.py@@ -819,6 +819,16 @@ async def _keep_typing(self, chat_id: str, interval: float = 2.0, metadata=None) await asyncio.sleep(interval) except asyncio.CancelledError: pass # Normal cancellation when handler completes + finally: + # Ensure the underlying platform typing loop is stopped. + # _keep_typing may have called send_typing() after an outer + # stop_typing() cleared the task dict, recreating the loop. + # Cancelling _keep_typing alone won't clean that up. + if hasattr(self, "stop_typing"): + try: + await self.stop_typing(chat_id) + except Exception: + pass async def handle_message(self, event: MessageEvent) -> None: """ @@ -1130,6 +1140,13 @@ async def _process_message_background(self, event: MessageEvent, session_key: st await typing_task except asyncio.CancelledError: pass + # Also cancel any platform-level persistent typing tasks (e.g. Discord) + # that may have been recreated by _keep_typing after the last stop_typing() + try: + if hasattr(self, "stop_typing"): + await self.stop_typing(event.source.chat_id) + except Exception: + pass # Clean up session tracking if session_key in self._active_sessions: del self._active_sessions[session_key]
-
-
#10239 fix(gateway): fix matrix lingering typing indicator
files:
gateway/platforms/matrix.py,tests/gateway/test_matrix.pyShow the diff
-
gateway/platforms/matrix.py@@ -729,6 +729,14 @@ async def send_typing( except Exception: pass + async def stop_typing(self, chat_id: str) -> None: + """Stop the Matrix typing indicator.""" + if self._client: + try: + await self._client.set_typing(RoomID(chat_id), timeout=0) + except Exception: + pass + async def edit_message( self, chat_id: str, message_id: str, content: str ) -> SendResult: -
tests/gateway/test_matrix.py@@ -335,6 +335,29 @@ def _make_adapter(): return adapter +# --------------------------------------------------------------------------- +# Typing indicator +# --------------------------------------------------------------------------- + +class TestMatrixTypingIndicator: + def setup_method(self): + self.adapter = _make_adapter() + self.adapter._client = MagicMock() + self.adapter._client.set_typing = AsyncMock() + + @pytest.mark.asyncio + async def test_stop_typing_clears_matrix_typing_state(self): + """stop_typing() should send typing=false instead of waiting for timeout expiry.""" + from gateway.platforms.matrix import RoomID + + await self.adapter.stop_typing("!room:example.org") + + self.adapter._client.set_typing.assert_awaited_once_with( + RoomID("!room:example.org"), + timeout=0, + ) + + # --------------------------------------------------------------------------- # mxc:// URL conversion # --------------------------------------------------------------------------- @@ -1831,4 +1854,3 @@ async def test_set_presence_no_client(self): assert result is False -
-
-
#44836 fix: stop Discord typing after replies
files:
gateway/platforms/base.py,scripts/release.py,tests/gateway/test_keep_typing_timeout.py,tests/gateway/test_run_progress_topics.pyShow the diff
-
gateway/platforms/base.py@@ -3163,6 +3163,38 @@ async def _keep_typing( pass self._typing_paused.discard(chat_id) + async def _stop_typing_refresh( + self, + chat_id: str, + typing_task: asyncio.Task | None = None, + *, + timeout: float = 0.5, + stop_attempts: int = 2, + ) -> None: + """Stop the refresh task and platform typing state as one operation.""" + self._typing_paused.add(chat_id) + try: + if typing_task is not None and not typing_task.done(): + typing_task.cancel() + try: + await asyncio.wait_for(asyncio.shield(typing_task), timeout=timeout) + except (asyncio.CancelledError, asyncio.TimeoutError): + # The task is cancelled; don't let a slow adapter-specific + # cleanup block response delivery or shutdown. + pass + if not hasattr(self, "stop_typing"): + return + attempts = max(1, stop_attempts) + for attempt in range(attempts): + try: + await self.stop_typing(chat_id) + except Exception: + pass + if attempt < attempts - 1: + await asyncio.sleep(0) + finally: + self._typing_paused.discard(chat_id) + def pause_typing_for_chat(self, chat_id: str) -> None: """Pause typing indicator for a chat (e.g. during approval waits). @@ -4092,14 +4124,10 @@ def _record_delivery(result): ) async def _stop_typing_task() -> None: - typing_task.cancel() - try: - await asyncio.wait_for(asyncio.shield(typing_task), timeout=0.5) - except (asyncio.CancelledError, asyncio.TimeoutError): - # Cancellation cleanup must not block adapter shutdown. The - # typing task is already cancelled; if the parent task is also - # cancelling, let this message-processing task unwind now. - pass + await self._stop_typing_refresh( + event.source.chat_id, + typing_task, + ) try: await self._run_processing_hook("on_processing_start", event) @@ -4486,11 +4514,6 @@ async def _stop_typing_task() -> None: # callbacks may perform platform I/O; a stuck callback must not # leave the typing refresh task running indefinitely. await _stop_typing_task() - try: - if hasattr(self, "stop_typing"): - await self.stop_typing(event.source.chat_id) - except Exception: - pass # Fire any one-shot post-delivery callback registered for this # session (e.g. deferred background-review notifications). # @@ -4524,13 +4547,14 @@ async def _stop_typing_task() -> None: ) except (asyncio.TimeoutError, Exception): pass - # Also cancel any platform-level persistent typing tasks (e.g. Discord) - # that may have been recreated by _keep_typing after the last stop_typing() - try: - if hasattr(self, "stop_typing"): - await self.stop_typing(event.source.chat_id) - except Exception: - pass + # Some adapters keep platform-level typing tasks. If callback + # work or a late refresh recreated one, make one final bounded stop + # before releasing the session guard. + await self._stop_typing_refresh( + event.source.chat_id, + None, + stop_attempts=1, + ) # Final drain/release boundary: force-flush any timer that missed # the in-band drain before deciding whether the guard can clear. await self._flush_text_debounce_now(session_key) -
scripts/release.py@@ -75,6 +75,7 @@ "[redacted:email]": "harjothkhara", "[redacted:email]": "HeLLGURD", "[redacted:email]": "synapsesx", + "[redacted:email]": "itsflownium", "[redacted:email]": "dirtyren", "[redacted:email]": "kdunn926", "[redacted:email]": "mvanhorn", -
tests/gateway/test_keep_typing_timeout.py@@ -198,3 +198,39 @@ async def recording_send_typing(chat_id, metadata=None): assert calls == [], ( f"send_typing was called on a paused chat: {calls}" ) + + @pytest.mark.asyncio + async def test_stop_typing_refresh_blocks_late_cancel_tick(self, monkeypatch): + """Final cleanup must not let a cancelled refresh loop send typing again.""" + adapter = _StubAdapter() + late_sends = [] + stop_calls = [] + + async def send_typing(chat_id, metadata=None): + late_sends.append(chat_id) + + async def stop_typing(chat_id): + stop_calls.append((chat_id, chat_id in adapter._typing_paused)) + + monkeypatch.setattr(adapter, "send_typing", send_typing) + monkeypatch.setattr(adapter, "stop_typing", stop_typing) + + async def late_refresh_after_cancel(): + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + if "discord-chat" not in adapter._typing_paused: + await adapter.send_typing("discord-chat") + raise + + task = asyncio.create_task(late_refresh_after_cancel()) + await asyncio.sleep(0) + + await adapter._stop_typing_refresh("discord-chat", task, timeout=1.0) + + assert late_sends == [] + assert stop_calls == [ + ("discord-chat", True), + ("discord-chat", True), + ] + assert "discord-chat" not in adapter._typing_paused -
tests/gateway/test_run_progress_topics.py@@ -1121,7 +1121,15 @@ async def _stop_typing(chat_id): ) assert [call["content"] for call in adapter.sent] == ["done"] - assert events[:2] == ["typing-stopped", "callback-start"] + # Invariant: typing must stop before the (hung) post-delivery callback + # starts. Don't pin the exact stop_typing call count — the shared + # cleanup path may make more than one bounded stop attempt. + assert "typing-stopped" in events + assert "callback-start" in events + assert events.index("typing-stopped") < events.index("callback-start") + assert events[: events.index("callback-start")] == ( + ["typing-stopped"] * events.index("callback-start") + ) assert any(call["metadata"] == {"stopped": True} for call in adapter.typing)
-
-
#50378 fix(gateway): pause Telegram typing before stream finalize (#49712)
files:
gateway/run.py,gateway/stream_consumer.py,tests/gateway/test_stream_consumer.pyShow the diff
-
gateway/run.py@@ -14133,6 +14133,13 @@ def _run_still_current() -> bool: from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig _adapter = self.adapters.get(source.platform) if _adapter: + _pause_typing_before_finalize = None + if source.platform == Platform.TELEGRAM and hasattr(_adapter, "pause_typing_for_chat"): + def _pause_typing_before_finalize( + _adapter=_adapter, + _chat_id=source.chat_id, + ) -> None: + _adapter.pause_typing_for_chat(_chat_id) _adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True) _effective_cursor = _scfg.cursor if _adapter_supports_edit else "" _buffer_only = False @@ -14162,6 +14169,7 @@ def _run_still_current() -> bool: chat_id=source.chat_id, config=_consumer_cfg, metadata=_thread_metadata, + on_before_finalize=_pause_typing_before_finalize, initial_reply_to_id=event_message_id, ) except Exception as _sc_err: @@ -15290,6 +15298,13 @@ def run_sync(): from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig _adapter = self.adapters.get(source.platform) if _adapter: + _pause_typing_before_finalize = None + if source.platform == Platform.TELEGRAM and hasattr(_adapter, "pause_typing_for_chat"): + def _pause_typing_before_finalize( + _adapter=_adapter, + _chat_id=source.chat_id, + ) -> None: + _adapter.pause_typing_for_chat(_chat_id) # Platforms that don't support editing sent messages # (e.g. QQ, WeChat) should skip streaming entirely — # without edit support, the consumer sends a partial @@ -15334,6 +15349,7 @@ def run_sync(): if progress_queue is not None else None ), + on_before_finalize=_pause_typing_before_finalize, initial_reply_to_id=event_message_id, ) if _want_stream_deltas: -
gateway/stream_consumer.py@@ -119,6 +119,7 @@ def __init__( config: Optional[StreamConsumerConfig] = None, metadata: Optional[dict] = None, on_new_message: Optional[callable] = None, + on_before_finalize: Optional[Callable[[], Any]] = None, initial_reply_to_id: Optional[str] = None, ): self.adapter = adapter @@ -133,6 +134,10 @@ def __init__( # the content, not edit the old bubble above it. # Called with no arguments. Exceptions are swallowed. self._on_new_message = on_new_message + # Fired once when the stream transitions into its finalization path. + # Gateway callers use this to pause typing refreshes before a slow + # final rich-text edit (Telegram MarkdownV2 finalize, etc.). + self._on_before_finalize = on_before_finalize self._initial_reply_to_id = initial_reply_to_id self._queue: queue.Queue = queue.Queue() self._accumulated = "" @@ -196,6 +201,7 @@ def __init__( # first failure we permanently disable drafts for the remainder of # this response and route through edit-based for graceful degradation. self._draft_failures = 0 + self._before_finalize_notified = False def _metadata_for_send( self, @@ -242,6 +248,20 @@ def final_content_delivered(self) -> bool: the subsequent cosmetic edit (cursor removal) failed.""" return self._final_content_delivered + async def _notify_before_finalize(self) -> None: + """Run the pre-finalize hook exactly once, swallowing hook errors.""" + if self._before_finalize_notified: + return + self._before_finalize_notified = True + if self._on_before_finalize is None: + return + try: + result = self._on_before_finalize() + if inspect.isawaitable(result): + await result + except Exception: + pass + async def _edit_message( self, *, @@ -620,6 +640,8 @@ async def run(self) -> None: self._last_edit_time = time.monotonic() if got_done: + if self._accumulated or self._message_id is not None or self._already_sent: + await self._notify_before_finalize() # Final edit without cursor. If progressive editing failed # mid-stream, send a single continuation/fallback message # here instead of letting the base gateway path send the -
tests/gateway/test_stream_consumer.py@@ -361,6 +361,67 @@ async def test_stream_with_media_tag(self): assert consumer.already_sent +class TestBeforeFinalizeHook: + """Verify the optional pre-finalize hook fires at the right time.""" + + @pytest.mark.asyncio + async def test_hook_runs_before_finalize_edit(self): + """Adapters that require finalize should pause typing before the edit.""" + events = [] + adapter = MagicMock() + adapter.REQUIRES_EDIT_FINALIZE = True + adapter.send = AsyncMock( + side_effect=lambda **_kw: ( + events.append("send"), + SimpleNamespace(success=True, message_id="msg_1"), + )[1] + ) + adapter.edit_message = AsyncMock( + side_effect=lambda **_kw: ( + events.append("edit"), + SimpleNamespace(success=True, message_id="msg_1"), + )[1] + ) + adapter.MAX_MESSAGE_LENGTH = 4096 + + consumer = GatewayStreamConsumer( + adapter, + "chat_123", + StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5), + on_before_finalize=lambda: events.append("pause"), + ) + consumer.on_delta("Hello") + consumer.finish() + + await consumer.run() + + assert events == ["send", "pause", "edit"] + + @pytest.mark.asyncio + async def test_hook_runs_once_when_final_text_already_visible(self): + """The hook still fires once even when no final edit is required.""" + events = [] + adapter = MagicMock() + adapter.REQUIRES_EDIT_FINALIZE = False + adapter.send = AsyncMock(return_value=SimpleNamespace(success=True, message_id="msg_1")) + adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True, message_id="msg_1")) + adapter.MAX_MESSAGE_LENGTH = 4096 + + consumer = GatewayStreamConsumer( + adapter, + "chat_123", + StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5), + on_before_finalize=lambda: events.append("pause"), + ) + consumer.on_delta("Hello") + consumer.finish() + + await consumer.run() + + assert events == ["pause"] + adapter.edit_message.assert_not_called() + + # ── Segment break (tool boundary) tests ──────────────────────────────────
-
24 PRs closed without merging
From the close comments: done elsewhere 10 · superseded by another PR 4 · merged via another change 3 · stale or abandoned 2 · closed without comment 1 · other 4
-
#2832 fix(discord): cancel platform-level typing tasks in base.py finally block · merged via another change →
#3003,#2945 -
#6020 fix(matrix): add stop_typing to clear typing indicator after response · done elsewhere →
#10239,#6016 -
#8413 fix(slack): clear assistant thread status after reply · done elsewhere →
70a3370 - #8414 fix(slack): clear assistant typing indicator after response delivery · closed without comment
-
#8598 fix(slack): respect reply_in_thread and reply_to_mode for channel replies · other →
#17184,#16188
Show 19 more
-
#12901 fix(slack): explicitly clear assistant thread status · done elsewhere →
70a3370 - #24139 fix(slack): track assistant typing per-thread to unstick concurrent chats · stale or abandoned
-
#25003 fix(gateway): stop typing indicator before post-delivery callback to prevent indefinite typing · superseded by another PR →
#24983 -
#26838 fix(discord): cap typing indicator loop lifetime · done elsewhere →
331cb38 -
#26857 fix(discord): single-shot send_typing to kill stuck typing indicator race (closes #26854) · done elsewhere →
331cb38 -
#30732 fix(telegram): stop typing indicator from lingering after response delivery · superseded by another PR →
#29595 - #31544 fix(bluebubbles): stop phantom typing indicator after agent turn completes · stale or abandoned
-
#33606 fix(gateway): stop typing before final delivery · done elsewhere →
09a96ba,565b7c8,6a30cfc -
#36156 fix(gateway): stop typing cleanup from hanging post-delivery callbacks · superseded by another PR →
#24983 -
#38413 fix: stop BlueBubbles typing before final delivery · other →
#31544 -
#41240 fix(gateway): cap typing indicator lifetime (#41168) · done elsewhere →
565b7c8,#41168 - #41306 fix(gateway): set stop_event before cancelling typing task (#41168) · other
-
#41308 fix(telegram): don't re-trigger typing indicator after last chunk · done elsewhere →
565b7c8,#41491 -
#42131 fix(gateway): suppress ghost typing indicator after final response on Telegram · done elsewhere →
565b7c8 -
#43691 fix(gateway): prevent orphaned Discord typing loops · superseded by another PR →
#44836 -
#44740 fix: stop Discord typing after replies · merged via another change →
331cb38,#44836 -
#49872 fix(gateway): pause Telegram typing before stream finalize · merged via another change →
#50378,#49712 -
#50774 fix(gateway): stop orphaned typing indicator on /new reset · done elsewhere →
331cb38 -
#54544 fix(telegram): override stop_typing to clear stuck typing indicator (Fixes #28004) · other →
565b7c8,09a96ba
Closed issues · 15
- Closed#2831Discord typing indicator lingers after response due to race between base.py _keep_typing and _typing_tasks
- Closed#6016[Bug]: Matrix typing indicator lingers for up to 30 seconds after response is sent
- Closed#8387Slack: assistant.threads.setStatus persists after response when MCP tools execute post-reply
- Closed#24117Slack Assistant thread can stay stuck in 'is thinking...' after response is sent
- Closed#24971fix(gateway): _keep_typing task stuck indefinitely when post-delivery callback hangs
- Closed#26728[Bug]: Discord typing indicator can get stuck forever during/after long replies
- Closed#26854Discord typing indicator stuck indefinitely due to race condition in _keep_typing cleanup
- Closed#28004Telegram typing indicator stuck indefinitely after response (_keep_typing race condition)
- Closed#29175[Bug]: Typing indicator lingers ~5s after streamed reply finishes
- Closed#41168Telegram typing indicator persists after background process notification under flood control
- Closed#44677Bug: Typing indicator persists after response is sent (Discord)
- Closed#47539Telegram typing indicator stuck infinitely (orphaned _keep_typing task)
- Closed#49712[Bug]: Telegram group typing indicator persists and stream finalize delayed by ~10s
- Closed#50763Typing indicator persists after /new — _handle_reset_command skips interrupt_session_activity()
- Closed#50991Orphaned _keep_typing task persists Telegram typing indicator after agent cache idle-eviction
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.03: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab