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

5open issues
8open PRs
56items grouped
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.

Bugs 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

Merged to main. A stamp counts the issues a fix is linked to.

  • #3003 fix(discord): stop phantom typing indicator after agent turn completes

    merged to main

    files: gateway/platforms/base.py

    Show 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

    merged to main

    files: gateway/platforms/matrix.py, tests/gateway/test_matrix.py

    Show 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

    merged to main

    files: gateway/platforms/base.py, scripts/release.py, tests/gateway/test_keep_typing_timeout.py, tests/gateway/test_run_progress_topics.py

    Show 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)

    merged to main

    files: gateway/run.py, gateway/stream_consumer.py, tests/gateway/test_stream_consumer.py

    Show 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.00 means 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

At a glance

Subsystem
gateway
Merged PRs
#3003, #10239, #44836, #50378
Closed issues
15
Attempts
24 PRs closed without merging
Fix link checked
21 Jun 2026

For your agent

The same facts, typed.