{"repo":"NousResearch/hermes-agent","public_id":"26207","display_label":"Telegram gateway tool progress and audio routing fixes","summary":"These issues and pull requests address rolling over Telegram tool progress bubbles before message limits are reached and routing Telegram audio file attachments away from the speech-to-text pipeline. The members share a focused theme around Telegram gateway message handling behavior.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":28478},"resolution":{"state":"resolved","by":"pr-28482"},"freshness":{"state":"verified","last_verified_at":"2026-05-19T04:57:20+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":24870,"pr_number":28478,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":26207,"pr_number":28478,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":26207,"pr_number":28482,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":26207,"title":"Telegram tool progress should roll over before message limit","url":null,"state":"closed"},{"kind":"pr","number":26208,"title":"fix(gateway): roll over Telegram tool progress bubbles","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28482","number":28482},{"kind":"pr","value":"#28478","number":28478}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":26242,"title":"fix(gateway): roll over telegram tool progress bubbles","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#26208","number":26208},{"kind":"pr","value":"#26207","number":26207},{"kind":"pr","value":"#26208","number":26208}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":28478,"title":"fix(gateway): route Telegram audio file attachments away from STT pipeline (#24870)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","scripts/release.py","tests/gateway/test_telegram_audio_vs_voice.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -7257,11 +7257,19 @@ async def _prepare_inbound_message_text(\n         if event.media_urls:\n             image_paths = []\n             audio_paths = []\n+            audio_file_paths: list[str] = []  # audio file attachments — not for STT\n             for i, path in enumerate(event.media_urls):\n                 mtype = event.media_types[i] if i < len(event.media_types) else \"\"\n                 if mtype.startswith(\"image/\") or event.message_type == MessageType.PHOTO:\n                     image_paths.append(path)\n-                if mtype.startswith(\"audio/\") or event.message_type in {MessageType.VOICE, MessageType.AUDIO}:\n+                # MessageType.AUDIO = audio file attachment (e.g. .mp3, .m4a) — never STT\n+                # MessageType.VOICE = voice message (Opus/OGG) — always STT\n+                if event.message_type == MessageType.AUDIO:\n+                    audio_file_paths.append(path)\n+                elif event.message_type == MessageType.VOICE or (\n+                    mtype.startswith(\"audio/\")\n+                    and event.message_type not in {MessageType.AUDIO, MessageType.DOCUMENT}\n+                ):\n                     audio_paths.append(path)\n \n             if image_paths:","@@ -7323,6 +7331,21 @@ async def _prepare_inbound_message_text(\n                         except Exception:\n                             pass\n \n+        if audio_file_paths:\n+            from tools.credential_files import to_agent_visible_cache_path as _to_agent_path\n+            for _apath in audio_file_paths:\n+                _basename = os.path.basename(_apath)\n+                _parts = _basename.split(\"_\", 2)\n+                _display = _parts[2] if len(_parts) >= 3 else _basename\n+                _display = re.sub(r'[^\\w.\\- ]', '_', _display)\n+                _agent_path = _to_agent_path(_apath)\n+                _note = (\n+                    f\"[The user sent an audio file attachment: '{_display}'. \"\n+                    f\"It is saved at: {_agent_path}. \"\n+                    f\"Ask the user what they'd like you to do with it, or pass the path to a transcription or media tool.]\"\n+                )\n+                message_text = f\"{_note}\\n\\n{message_text}\"\n+\n         if event.media_urls and event.message_type == MessageType.DOCUMENT:\n             import mimetypes as _mimetypes\n             from tools.credential_files import to_agent_visible_cache_path"]},{"file":"scripts/release.py","hunks":["@@ -165,6 +165,7 @@\n     \"[redacted:email]\": \"dengtaoyuan450-a11y\",\n     \"[redacted:email]\": \"Junass1\",\n     \"[redacted:email]\": \"Bartok9\",\n+    \"[redacted:email]\": \"Bartok9\",\n     \"[redacted:email]\": \"hllqkb\",\n     \"[redacted:email]\": \"Bongulielmi\",\n     \"[redacted:email]\": \"JTroyerOvermatch\","]},{"file":"tests/gateway/test_telegram_audio_vs_voice.py","hunks":["@@ -0,0 +1,184 @@\n+\"\"\"\n+Tests for #24870 — Telegram: audio file attachments must NOT be routed to STT.\n+\n+Telegram distinguishes three kinds of audio payloads:\n+  - message.voice  → Opus/OGG voice message  → STT pipeline\n+  - message.audio  → audio file attachment   → file path note, NOT STT\n+  - message.document (audio mime) → generic file route\n+\n+These tests confirm that:\n+  1. MessageType.VOICE events still flow through the STT pipeline.\n+  2. MessageType.AUDIO events bypass STT and get a file-path context note instead.\n+  3. Mixed media lists (voice + audio) split correctly.\n+\"\"\"\n+\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+from gateway.config import GatewayConfig, Platform\n+from gateway.platforms.base import MessageEvent, MessageType\n+from gateway.session import SessionSource\n+\n+\n+def _make_runner(stt_enabled: bool = True) -> \"GatewayRunner\":  # type: ignore[name-defined]\n+    from gateway.run import GatewayRunner\n+\n+    runner = GatewayRunner.__new__(GatewayRunner)\n+    runner.config = GatewayConfig(stt_enabled=stt_enabled)\n+    runner.adapters = {}\n+    runner._model = \"test-model\"\n+    runner._base_url = \"\"\n+    runner._has_setup_skill = lambda: False\n+    return runner\n+\n+\n+def _voice_event(path: str = \"/tmp/voice.ogg\") -> MessageEvent:\n+    return MessageEvent(\n+        text=\"\",\n+        message_type=MessageType.VOICE,\n+        source=SessionSource(platform=Platform.TELEGRAM, chat_id=\"1\", chat_type=\"dm\"),\n+        media_urls=[path],\n+        media_types=[\"audio/ogg\"],\n+    )\n+\n+\n+def _audio_event(path: str = \"/tmp/song.mp3\") -> MessageEvent:\n+    return MessageEvent(\n+        text=\"\",\n+        message_type=MessageType.AUDIO,\n+        source=SessionSource(platform=Platform.TELEGRAM, chat_id=\"1\", chat_type=\"dm\"),\n+        media_urls=[path],\n+        media_types=[\"audio/mpeg\"],\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# 1. VOICE still goes through STT\n+# ---------------------------------------------------------------------------\n+\n[redacted:email]\n+async def test_voice_message_still_transcribed():\n+    \"\"\"MessageType.VOICE must still be sent through _enrich_message_with_transcription.\"\"\"\n+    runner = _make_runner(stt_enabled=True)\n+    source = SessionSource(platform=Platform.TELEGRAM, chat_id=\"1\", chat_type=\"dm\")\n+    event = _voice_event(\"/tmp/voice.ogg\")\n+\n+    with patch(\n+        \"tools.transcription_tools.transcribe_audio\",\n+        return_value={\"success\": True, \"transcript\": \"hello world\", \"provider\": \"whisper\"},\n+    ) as mock_transcribe:\n+        result = await runner._prepare_inbound_message_text(\n+            event=event,\n+            source=source,\n+            history=[],\n+        )\n+\n+    mock_transcribe.assert_called_once_with(\"/tmp/voice.ogg\")\n+    assert \"hello world\" in result\n+    assert \"voice message\" in result.lower()\n+\n+\n+# ---------------------------------------------------------------------------\n+# 2. AUDIO file attachment bypasses STT\n+# ---------------------------------------------------------------------------\n+\n[redacted:email]\n+async def test_audio_attachment_skips_stt():\n+    \"\"\"MessageType.AUDIO must NOT be routed to STT — transcribe_audio must not be called.\"\"\"\n+    runner = _make_runner(stt_enabled=True)\n+    source = SessionSource(platform=Platform.TELEGRAM, chat_id=\"1\", chat_type=\"dm\")\n+    event = _audio_event(\"/tmp/song.mp3\")\n+\n+    with patch(\n+        \"tools.transcription_tools.transcribe_audio\",\n+        side_effect=AssertionError(\"transcribe_audio must NOT be called for audio file attachments\"),\n+    ):\n+        with patch(\n+            \"tools.credential_files.to_agent_visible_cache_path\",\n+            side_effect=lambda p: p,\n+        ):\n+            result = await runner._prepare_inbound_message_text(\n+                event=event,\n+                source=source,\n+                history=[],\n+            )\n+\n+    assert result is not None\n+    assert \"/tmp/song.mp3\" in result\n+    assert \"audio file attachment\" in result.lower()\n+\n+\n[redacted:email]\n+async def test_audio_attachment_context_note_format():\n+    \"\"\"Context note for audio file attachments should include the file path and guidance.\"\"\"\n+    runner = _make_runner(stt_enabled=True)\n+    source = SessionSource(platform=Platform.TELEGRAM, chat_id=\"1\", chat_type=\"dm\")\n+    event = _audio_event(\"/tmp/cache_12345_my_song.mp3\")\n+\n+    with patch(\n+        \"tools.transcription_tools.transcribe_audio\",\n+        side_effect=AssertionError(\"must not be called\"),\n+    ):\n+        with patch(\n+            \"tools.credential_files.to_agent_visible_cache_path\",\n+            side_effect=lambda p: p,\n+        ):\n+            result = await runner._prepare_inbound_message_text(\n+                event=event,\n+                source=source,\n+                history=[],\n+            )\n+\n+    assert \"my_song.mp3\" in result\n+    assert \"audio file attachment\" in result.lower()\n+    # Should NOT contain the voice-message transcription wrapper text\n+    assert \"voice message\" not in result.lower()\n+\n+\n+# ---------------------------------------------------------------------------\n+# 3. STT disabled still results in no transcription for audio file attachments\n+# ---------------------------------------------------------------------------\n+\n[redacted:email]\n+async def test_audio_attachment_skips_stt_when_stt_disabled():\n+    \"\"\"Even with STT disabled, AUDIO must NOT produce STT disabled notice — just a file note.\"\"\"\n+    runner = _make_runner(stt_enabled=False)\n+    source = SessionSource(platform=Platform.TELEGRAM, chat_id=\"1\", chat_type=\"dm\")\n+    event = _audio_event(\"/tmp/podcast.m4a\")\n+\n+    with patch(\n+        \"tools.transcription_tools.transcribe_audio\",\n+        side_effect=AssertionError(\"must not be called\"),\n+    ):\n+        with patch(\n+            \"tools.credential_files.to_agent_visible_cache_path\",\n+            side_effect=lambda p: p,\n+        ):\n+            result = await runner._prepare_inbound_message_text(\n+                event=event,\n+                source=source,\n+                history=[],\n+            )\n+\n+    # Should NOT see the \"transcription is disabled\" note — that's only for VOICE\n+    assert \"transcription is disabled\" not in result.lower()\n+    assert \"audio file attachment\" in result.lower()\n+    assert \"/tmp/podcast.m4a\" in result\n+\n+\n+# ---------------------------------------------------------------------------\n+# 4. Telegram gateway: msg.audio → MessageType.AUDIO (not VOICE)\n+# ---------------------------------------------------------------------------\n+\n+def test_telegram_media_type_detection_audio_vs_voice():\n+    \"\"\"The Telegram platform must set MessageType.AUDIO for msg.audio, VOICE for msg.voice.\"\"\"\n+    from gateway.platforms.base import MessageType\n+\n+    # The Telegram adapter's _build_media_type already returns correct values\n+    # via MessageType.AUDIO for .audio and MessageType.VOICE for .voice.\n+    # Check the constants match expected semantic roles.\n+    assert MessageType.AUDIO.value == \"audio\"\n+    assert MessageType.VOICE.value == \"voice\"\n+    # Sanity: they are distinct\n+    assert MessageType.AUDIO != MessageType.VOICE\n"]}]},{"kind":"pr","number":28482,"title":"fix(gateway): roll over Telegram tool progress bubbles + scope audio_file_paths","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_run_progress_topics.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -7254,10 +7254,13 @@ async def _prepare_inbound_message_text(\n         if getattr(event, \"channel_context\", None):\n             message_text = f\"{event.channel_context}\\n\\n[New message]\\n{message_text}\"\n \n+        # Declare at outer scope so the audio-file-paths handling block below\n+        # remains safe when ``event.media_urls`` is empty (no inner block runs).\n+        audio_file_paths: list[str] = []\n+\n         if event.media_urls:\n             image_paths = []\n             audio_paths = []\n-            audio_file_paths: list[str] = []  # audio file attachments — not for STT\n             for i, path in enumerate(event.media_urls):\n                 mtype = event.media_types[i] if i < len(event.media_types) else \"\"\n                 if mtype.startswith(\"image/\") or event.message_type == MessageType.PHOTO:","@@ -15276,12 +15279,104 @@ async def send_progress_messages():\n                         break\n                 return\n \n-            progress_lines = []      # Accumulated tool lines\n-            progress_msg_id = None   # ID of the progress message to edit\n+            progress_lines = []      # Accumulated tool lines for the CURRENT editable bubble\n+            progress_msg_id = None   # ID of the current progress message to edit\n             can_edit = True          # False once an edit fails (platform doesn't support it)\n             _last_edit_ts = 0.0      # Throttle edits to avoid Telegram flood control\n             _PROGRESS_EDIT_INTERVAL = 1.5  # Minimum seconds between edits\n \n+            _progress_len_fn = (\n+                adapter.message_len_fn\n+                if isinstance(adapter, BasePlatformAdapter)\n+                else len\n+            )\n+            try:\n+                _raw_progress_limit = int(getattr(adapter, \"MAX_MESSAGE_LENGTH\", 4000) or 4000)\n+            except Exception:\n+                _raw_progress_limit = 4000\n+            # Leave a little room for platform quirks / formatting.  For tiny\n+            # test adapters keep the limit usable instead of clamping to 500+.\n+            _PROGRESS_TEXT_LIMIT = max(\n+                1,\n+                _raw_progress_limit - (64 if _raw_progress_limit > 128 else 0),\n+            )\n+\n+            def _progress_text(lines: list) -> str:\n+                return \"\\n\".join(str(line) for line in lines)\n+\n+            def _split_progress_groups(lines: list) -> list[list]:\n+                \"\"\"Partition progress lines into platform-sized editable bubbles.\"\"\"\n+                groups: list[list] = []\n+                current: list = []\n+                for line in lines:\n+                    candidate = current + [line]\n+                    if current and _progress_len_fn(_progress_text(candidate)) > _PROGRESS_TEXT_LIMIT:\n+                        groups.append(current)\n+                        current = [line]\n+                    else:\n+                        current = candidate\n+                if current:\n+                    groups.append(current)\n+                return groups\n+\n+            def _track_progress_result(result) -> None:\n+                if (\n+                    _cleanup_progress\n+                    and getattr(result, \"success\", False)\n+                    and getattr(result, \"message_id\", None)\n+                ):\n+                    _cleanup_msg_ids.append(str(result.message_id))\n+\n+            async def _send_progress_text(text: str):\n+                result = await adapter.send(\n+                    chat_id=source.chat_id,\n+                    content=text,\n+                    reply_to=_progress_reply_to,\n+                    metadata=_progress_metadata,\n+                )\n+                _track_progress_result(result)\n+                return result\n+\n+            async def _roll_progress_overflow_if_needed() -> bool:\n+                \"\"\"Start fresh editable progress bubbles before a bubble exceeds limit.\n+\n+                Returns True when it delivered/split the current buffer and the\n+                caller should skip the normal send/edit path for this tick.\n+                \"\"\"\n+                nonlocal progress_msg_id, progress_lines, can_edit\n+                if not progress_lines or not can_edit:\n+                    return False\n+                groups = _split_progress_groups(progress_lines)\n+                if len(groups) <= 1:\n+                    return False\n+\n+                first_text = _progress_text(groups[0])\n+                if progress_msg_id is not None:\n+                    result = await adapter.edit_message(\n+                        chat_id=source.chat_id,\n+                        message_id=progress_msg_id,\n+                        content=first_text,\n+                    )\n+                    if not result.success:\n+                        can_edit = False\n+                        # Fall back to the existing non-edit behavior below.\n+                        return False\n+                else:\n+                    result = await _send_progress_text(first_text)\n+                    if result.success and result.message_id:\n+                        progress_msg_id = result.message_id\n+\n+                for group in groups[1:]:\n+                    result = await _send_progress_text(_progress_text(group))\n+                    if result.success and result.message_id:\n+                        progress_msg_id = result.message_id\n+\n+                # The newest continuation is now the only mutable bubble.  Keep\n+                # just its lines so subsequent edits update it instead of\n+                # replaying the full historical transcript into new messages.\n+                progress_lines = groups[-1]\n+                return True\n+\n             while True:\n                 try:\n                     if not _run_still_current():","@@ -15334,6 +15429,13 @@ async def send_progress_messages():\n                         msg = raw\n                         progress_lines.append(msg)\n \n+                    if await _roll_progress_overflow_if_needed():\n+                        _last_edit_ts = time.monotonic()\n+                        await asyncio.sleep(0.3)\n+                        if _run_still_current():\n+                            await adapter.send_typing(source.chat_id, metadata=_progress_metadata)\n+                        continue\n+\n                     # Throttle edits: batch rapid tool updates into fewer\n                     # API calls to avoid hitting Telegram flood control.\n                     # (grammY auto-retry pattern: proactively rate-limit","@@ -15422,12 +15524,14 @@ async def send_progress_messages():\n                                 _, base_msg, count = raw\n                                 if progress_lines:\n                                     progress_lines[-1] = f\"{base_msg} (×{count + 1})\"\n+                                    await _roll_progress_overflow_if_needed()\n                             elif isinstance(raw, tuple) and len(raw) >= 1 and raw[0] == \"__reset__\":\n                                 # Content-bubble marker during drain: close off\n                                 # the current progress bubble and start a fresh\n                                 # one for any tool lines that arrived after.\n+                                await _roll_progress_overflow_if_needed()\n                                 if can_edit and progress_lines and progress_msg_id:\n-                                    _pending_text = \"\\n\".join(progress_lines)\n+                                    _pending_text = _progress_text(progress_lines)\n                                     try:\n                                         await adapter.edit_message(\n                                             chat_id=source.chat_id,","@@ -15442,11 +15546,14 @@ async def send_progress_messages():\n                                 repeat_count[0] = 0\n                             else:\n                                 progress_lines.append(raw)\n+                                await _roll_progress_overflow_if_needed()\n                         except Exception:\n                             break\n                     # Final edit with all remaining tools (only if editing works)\n                     if can_edit and progress_lines and progress_msg_id:\n-                        full_text = \"\\n\".join(progress_lines)\n+                        await _roll_progress_overflow_if_needed()\n+                    if can_edit and progress_lines and progress_msg_id:\n+                        full_text = _progress_text(progress_lines)\n                         try:\n                             await adapter.edit_message(\n                                 chat_id=source.chat_id,"]},{"file":"tests/gateway/test_run_progress_topics.py","hunks":["@@ -58,6 +58,47 @@ async def get_chat_info(self, chat_id: str):\n         return {\"id\": chat_id}\n \n \n+class SmallLimitProgressAdapter(ProgressCaptureAdapter):\n+    \"\"\"Adapter with a tiny platform limit to exercise progress rollover.\"\"\"\n+\n+    MAX_MESSAGE_LENGTH = 180\n+\n+    def __init__(self, platform=Platform.TELEGRAM):\n+        super().__init__(platform=platform)\n+        self._next_id = 0\n+        self.oversized_edits = []\n+        self.oversized_sends = []\n+\n+    def _mint_id(self):\n+        self._next_id += 1\n+        return f\"progress-{self._next_id}\"\n+\n+    async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:\n+        if len(content) > self.MAX_MESSAGE_LENGTH:\n+            self.oversized_sends.append(content)\n+        self.sent.append(\n+            {\n+                \"chat_id\": chat_id,\n+                \"content\": content,\n+                \"reply_to\": reply_to,\n+                \"metadata\": metadata,\n+            }\n+        )\n+        return SendResult(success=True, message_id=self._mint_id())\n+\n+    async def edit_message(self, chat_id, message_id, content) -> SendResult:\n+        if len(content) > self.MAX_MESSAGE_LENGTH:\n+            self.oversized_edits.append(content)\n+        self.edits.append(\n+            {\n+                \"chat_id\": chat_id,\n+                \"message_id\": message_id,\n+                \"content\": content,\n+            }\n+        )\n+        return SendResult(success=True, message_id=message_id)\n+\n+\n class NonEditingProgressCaptureAdapter(ProgressCaptureAdapter):\n     SUPPORTS_MESSAGE_EDITING = False\n ","@@ -123,6 +164,31 @@ def run_conversation(self, message, conversation_history=None, task_id=None):\n         }\n \n \n+class ManyProgressLinesAgent:\n+    \"\"\"Emits enough tool-progress lines to exceed a single platform bubble.\"\"\"\n+\n+    def __init__(self, **kwargs):\n+        self.tool_progress_callback = kwargs.get(\"tool_progress_callback\")\n+        self.tools = []\n+\n+    def run_conversation(self, message, conversation_history=None, task_id=None):\n+        cb = self.tool_progress_callback\n+        assert cb is not None\n+        cb(\"tool.started\", \"terminal\", \"first-short\", {})\n+        # Let the progress task create the first editable bubble, then enqueue\n+        # the rest quickly.  The cancellation drain must roll them into fresh\n+        # editable bubbles instead of trying to edit the first one past limit.\n+        time.sleep(0.35)\n+        for idx in range(1, 8):\n+            cb(\"tool.started\", \"terminal\", f\"overflow-line-{idx}-\" + \"x\" * 45, {})\n+        time.sleep(0.1)\n+        return {\n+            \"final_response\": \"done\",\n+            \"messages\": [],\n+            \"api_calls\": 1,\n+        }\n+\n+\n class DelayedInterimAgent:\n     def __init__(self, **kwargs):\n         self.interim_assistant_callback = kwargs.get(\"interim_assistant_callback\")","@@ -617,6 +683,39 @@ async def _run_with_agent(\n     return adapter, result\n \n \n[redacted:email]\n+async def test_run_agent_rolls_progress_bubble_before_platform_limit(monkeypatch, tmp_path):\n+    \"\"\"Tool progress should start a second editable bubble before Telegram's limit.\n+\n+    Regression: once the first progress bubble grew past the platform limit,\n+    the gateway kept trying to edit that same oversized full transcript.  The\n+    Telegram adapter then split-and-sent a fresh continuation on every update,\n+    causing a noisy trail of one-line messages instead of a new editable bubble.\n+    \"\"\"\n+    adapter, result = await _run_with_agent(\n+        monkeypatch,\n+        tmp_path,\n+        ManyProgressLinesAgent,\n+        session_id=\"sess-progress-overflow-rollover\",\n+        config_data={\n+            \"display\": {\n+                \"tool_progress\": \"all\",\n+                \"interim_assistant_messages\": False,\n+                \"tool_preview_length\": 60,\n+            }\n+        },\n+        adapter_cls=SmallLimitProgressAdapter,\n+    )\n+\n+    assert result[\"final_response\"] == \"done\"\n+    assert isinstance(adapter, SmallLimitProgressAdapter)\n+    assert len(adapter.sent) >= 2, \"expected a fresh progress bubble after the first filled\"\n+    assert adapter.oversized_sends == []\n+    assert adapter.oversized_edits == []\n+    all_bubbles = [call[\"content\"] for call in adapter.sent + adapter.edits]\n+    assert all(len(text) <= adapter.MAX_MESSAGE_LENGTH for text in all_bubbles)\n+\n+\n @pytest.mark.asyncio\n async def test_run_agent_surfaces_real_interim_commentary(monkeypatch, tmp_path):\n     adapter, result = await _run_with_agent(\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}