diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 486d179de9..e743df8d59 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -491,6 +491,13 @@ class GatewayStreamConsumer: # Media files are delivered as native attachments after the stream # finishes (via _deliver_media_from_response in gateway/run.py). text = self._clean_for_display(text) + # A bare streaming cursor is not meaningful user-visible content and + # can render as a stray tofu/white-box message on some clients. + visible_without_cursor = text + if self.cfg.cursor: + visible_without_cursor = visible_without_cursor.replace(self.cfg.cursor, "") + if not visible_without_cursor.strip(): + return True # cursor-only / whitespace-only update if not text.strip(): return True # nothing to send is "success" try: diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index 8f7fb6dd5d..d66306722f 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -139,6 +139,22 @@ class TestSendOrEditMediaStripping: adapter.send.assert_not_called() + @pytest.mark.asyncio + async def test_cursor_only_update_skips_send(self): + """A bare streaming cursor should not be sent as its own message.""" + adapter = MagicMock() + adapter.send = AsyncMock() + adapter.MAX_MESSAGE_LENGTH = 4096 + + consumer = GatewayStreamConsumer( + adapter, + "chat_123", + StreamConsumerConfig(cursor=" ▉"), + ) + await consumer._send_or_edit(" ▉") + + adapter.send.assert_not_called() + # ── Integration: full stream run ─────────────────────────────────────────