# NousResearch/hermes-agent · Typing indicators linger or get stuck after replies

## Open now

- 5 issues, 8 PRs open on GitHub.
- #31534: BlueBubbles: typing indicator lingers ~10s after the agent turn completes (phantom typing — extend #3003 to BlueBubbles)
- #42087: Bug: Ghost typing indicator on Telegram after background process / synth_event delivery
- #49290: Discord: typing indicator lingers after response is sent
- #50766: Typing indicator persists after /new — _handle_reset_command skips interrupt_session_activity()
- #62394: Teams: typing indicator animates forever after responses complete — suspected leaked _keep_typing task
- PR #21688: fix(telegram): stop typing refresh before response
- PR #29172: fix(gateway): stop typing indicator before final response delivery
- PR #33631: fix(telegram): stop typing after final reply
- PR #41258: fix(telegram): pause notify typing during flood-control sends (#41168)
- PR #42129: fix(gateway): skip typing indicator for internal/synth events
- PR #49304: fix(gateway): stop typing indicator on first stream delta for Discord
- PR #52490: fix(gateway): stop typing before final delivery
- PR #123916: fix(bluebubbles): stop re-lighting the typing indicator after replies

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.

## TL;DR

- 56 member(s): 5 open issue(s), 15 closed issue(s), 8 open PR(s), 4 merged PR(s), 24 PR(s) closed without merging.
- A fix was merged to main in PR #3003. Verify it addresses your case.
- **Recommended:** read PR #3003 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-10239,pr-44836,pr-50378`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

- #31534: BlueBubbles: typing indicator lingers ~10s after the agent turn completes (phantom typing — extend #3003 to BlueBubbles)
- #42087: Bug: Ghost typing indicator on Telegram after background process / synth_event delivery
- #49290: Discord: typing indicator lingers after response is sent
- #50766: Typing indicator persists after /new — _handle_reset_command skips interrupt_session_activity()
- #62394: Teams: typing indicator animates forever after responses complete — suspected leaked _keep_typing task

## Closed / duplicate issues

- #2831: Discord typing indicator lingers after response due to race between base.py _keep_typing and _typing_tasks
- #6016: \[Bug\]: Matrix typing indicator lingers for up to 30 seconds after response is sent
- #8387: Slack: assistant.threads.setStatus persists after response when MCP tools execute post-reply
- #24117: Slack Assistant thread can stay stuck in 'is thinking...' after response is sent
- #24971: fix(gateway): _keep_typing task stuck indefinitely when post-delivery callback hangs
- #26728: \[Bug\]: Discord typing indicator can get stuck forever during/after long replies
- #26854: Discord typing indicator stuck indefinitely due to race condition in _keep_typing cleanup
- #28004: Telegram typing indicator stuck indefinitely after response (_keep_typing race condition)
- #29175: \[Bug\]: Typing indicator lingers ~5s after streamed reply finishes
- #41168: Telegram typing indicator persists after background process notification under flood control
- #44677: Bug: Typing indicator persists after response is sent (Discord)
- #47539: Telegram typing indicator stuck infinitely (orphaned _keep_typing task)
- #49712: \[Bug\]: Telegram group typing indicator persists and stream finalize delayed by ~10s
- #50763: Typing indicator persists after /new — _handle_reset_command skips interrupt_session_activity()
- #50991: Orphaned _keep_typing task persists Telegram typing indicator after agent cache idle-eviction

## Prior & competing PRs

- **#3003: fix(discord): stop phantom typing indicator after agent turn completes**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/base.py`
  - fix hunks:
  - `gateway/platforms/base.py`

    ```diff
    @@ -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:
             """
    ```

    ```diff
    @@ -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]
    ```
- **#8413: fix(slack): clear assistant thread status after reply**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `70a33708e`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#8598: fix(slack): respect reply_in_thread and reply_to_mode for channel replies**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#17184`, `#16188`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#10239: fix(gateway): fix matrix lingering typing indicator**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/matrix.py`, `tests/gateway/test_matrix.py`
  - fix hunks:
  - `gateway/platforms/matrix.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -1831,4 +1854,3 @@ async def test_set_presence_no_client(self):
             assert result is False
     
     
    -
    ```
- **#12901: fix(slack): explicitly clear assistant thread status**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `70a33708e`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#25003: fix(gateway): stop typing indicator before post-delivery callback to prevent indefinite typing**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#24983`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#26857: fix(discord): single-shot send_typing to kill stuck typing indicator race (closes #26854)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `331cb38e21affee3527dbe5a646ac6d5e25f2996`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#30732: fix(telegram): stop typing indicator from lingering after response delivery**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#29595`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#33606: fix(gateway): stop typing before final delivery**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `09a96ba0f6ee68d701bd7c4fc2b2518a83b37c62`, `565b7c8d9d879c6423c55e9be84596936bc489ba`, `6a30cfca82409cbf20b915c832a9acfb46551fe5`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#41240: fix(gateway): cap typing indicator lifetime (#41168)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `565b7c8d9d879c6423c55e9be84596936bc489ba`, `#41168`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#41306: fix(gateway): set stop_event before cancelling typing task (#41168)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#41308: fix(telegram): don't re-trigger typing indicator after last chunk**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `565b7c8d9d879c6423c55e9be84596936bc489ba`, `#41491`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#42131: fix(gateway): suppress ghost typing indicator after final response on Telegram**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `565b7c8d9d879c6423c55e9be84596936bc489ba`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#44836: fix: stop Discord typing after replies**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/base.py`, `scripts/release.py`, `tests/gateway/test_keep_typing_timeout.py`, `tests/gateway/test_run_progress_topics.py`
  - fix hunks:
  - `gateway/platforms/base.py`

    ```diff
    @@ -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).
     
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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).
                 #
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/run.py`, `gateway/stream_consumer.py`, `tests/gateway/test_stream_consumer.py`
  - fix hunks:
  - `gateway/run.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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 = ""
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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,
             *,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 ──────────────────────────────────
     
     
    ```
- **#50774: fix(gateway): stop orphaned typing indicator on /new reset**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `331cb38e21affee3527dbe5a646ac6d5e25f2996`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#54544: fix(telegram): override stop_typing to clear stuck typing indicator (Fixes #28004)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `565b7c8d9d879c6423c55e9be84596936bc489ba`, `09a96ba0f6ee68d701bd7c4fc2b2518a83b37c62`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#2832: fix(discord): cancel platform-level typing tasks in base.py finally block**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#3003`, `#2945`
- **#6020: fix(matrix): add stop_typing to clear typing indicator after response**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#10239`, `#6016`
- **#8414: fix(slack): clear assistant typing indicator after response delivery**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#21688: fix(telegram): stop typing refresh before response**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `565b7c8d9`
  - files: `gateway/platforms/base.py`, `gateway/platforms/telegram.py`, `tests/gateway/test_typing_lifecycle.py`
- **#24139: fix(slack): track assistant typing per-thread to unstick concurrent chats**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#26838: fix(discord): cap typing indicator loop lifetime**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `331cb38e2`
- **#29172: fix(gateway): stop typing indicator before final response delivery**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `6a30cfca8`, `#37556`, `#24971`, `#37556`, `#37556`
  - files: `gateway/platforms/base.py`, `tests/gateway/test_ephemeral_reply.py`, `tests/gateway/test_session_split_brain_11016.py`, `tests/gateway/test_typing_delivery_order.py`
- **#31544: fix(bluebubbles): stop phantom typing indicator after agent turn completes**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#33631: fix(telegram): stop typing after final reply**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#37556`, `#37556`
  - files: `gateway/platforms/base.py`, `gateway/run.py`, `plugins/platforms/telegram/adapter.py`, `tests/gateway/test_base_topic_sessions.py`, `tests/gateway/test_telegram_footer_post_send_typing.py`, `tests/gateway/test_telegram_post_send_typing.py`
- **#36156: fix(gateway): stop typing cleanup from hanging post-delivery callbacks**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#24983`
- **#38413: fix: stop BlueBubbles typing before final delivery**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#31544`
- **#41258: fix(telegram): pause notify typing during flood-control sends (#41168)**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `5600105478ffde29d7566b45421b100eaa29c4ef`, `565b7c8d9d879c6423c55e9be84596936bc489ba`
  - files: `plugins/platforms/telegram/adapter.py`, `tests/gateway/test_telegram_typing_flood_control.py`
- **#42129: fix(gateway): skip typing indicator for internal/synth events**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `gateway/platforms/base.py`, `tests/gateway/test_ghost_typing_internal_event.py`
- **#43691: fix(gateway): prevent orphaned Discord typing loops**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#44836`
- **#44740: fix: stop Discord typing after replies**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `331cb38e2`, `#44836`
- **#49304: fix(gateway): stop typing indicator on first stream delta for Discord**. open
  - maintainer direction: category `silent_close`; implication `active`
  - files: `gateway/run.py`, `tests/gateway/test_stream_typing_stop.py`
- **#49872: fix(gateway): pause Telegram typing before stream finalize**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#50378`, `#49712`, `#49712`
- **#52490: fix(gateway): stop typing before final delivery**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#29172`, `#25210`, `#43390`
  - files: `gateway/platforms/base.py`, `tests/gateway/test_typing_stop_before_delivery.py`
- **#123916: fix(bluebubbles): stop re-lighting the typing indicator after replies**. open
  - maintainer direction: category `silent_close`; implication `active`
  - files: `gateway/platforms/bluebubbles.py`, `tests/gateway/test_bluebubbles.py`

## Fix status

- Merged to main in PR #3003 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-21`.
- Subsystem: `gateway`
