{"repo":"NousResearch/hermes-agent","public_id":"4701","display_label":"Signal and gateway media/attachment delivery features","summary":"These issues and pull requests concern implementing and fixing MEDIA: tag handling, file attachment delivery, and multi-image sending across Signal and other gateway integrations. Work spans both the Signal-specific attachment pipeline and broader gateway-level media dispatch.","subsystem":"gateway","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":5602},"resolution":{"state":"partially_resolved","by":"pr-5602"},"freshness":{"state":"stale","last_verified_at":"2026-04-06T18:41:34+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"fix_edges":[{"issue_number":5105,"pr_number":5602,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":4701,"title":"[Feature]: Signal: expose send_image/send_attachment tool for in-session image delivery","url":null,"state":"closed"},{"kind":"issue","number":5105,"title":"Signal gateway: MEDIA: tag attachments not extracted/delivered","url":null,"state":"closed"},{"kind":"pr","number":5107,"title":"fix(signal): implement send_image_file for MEDIA: tag delivery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5602","number":5602}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":5550,"title":"fix(signal): implement send_image_file, send_voice, and send_video for MEDIA: tag delivery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5602","number":5602}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":5602,"title":"fix(signal): implement send_image_file, send_voice, and send_video for MEDIA: tag delivery","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/signal.py","tests/gateway/test_signal.py"],"fix_hunks":[{"file":"gateway/platforms/signal.py","hunks":["@@ -717,19 +717,27 @@ async def send_image(\n             return SendResult(success=True)\n         return SendResult(success=False, error=\"RPC send with attachment failed\")\n \n-    async def send_document(\n+    async def _send_attachment(\n         self,\n         chat_id: str,\n         file_path: str,\n+        media_label: str,\n         caption: Optional[str] = None,\n-        filename: Optional[str] = None,\n-        **kwargs,\n     ) -> SendResult:\n-        \"\"\"Send a document/file attachment.\"\"\"\n+        \"\"\"Send any file as a Signal attachment via RPC.\n+\n+        Shared implementation for send_document, send_image_file, send_voice,\n+        and send_video — avoids duplicating the validation/routing/RPC logic.\n+        \"\"\"\n         await self._stop_typing_indicator(chat_id)\n \n-        if not Path(file_path).exists():\n-            return SendResult(success=False, error=\"File not found\")\n+        try:\n+            file_size = Path(file_path).stat().st_size\n+        except FileNotFoundError:\n+            return SendResult(success=False, error=f\"{media_label} file not found: {file_path}\")\n+\n+        if file_size > SIGNAL_MAX_ATTACHMENT_SIZE:\n+            return SendResult(success=False, error=f\"{media_label} too large ({file_size} bytes)\")\n \n         params: Dict[str, Any] = {\n             \"account\": self.account,","@@ -746,7 +754,59 @@ async def send_document(\n         if result is not None:\n             self._track_sent_timestamp(result)\n             return SendResult(success=True)\n-        return SendResult(success=False, error=\"RPC send document failed\")\n+        return SendResult(success=False, error=f\"RPC send {media_label.lower()} failed\")\n+\n+    async def send_document(\n+        self,\n+        chat_id: str,\n+        file_path: str,\n+        caption: Optional[str] = None,\n+        filename: Optional[str] = None,\n+        **kwargs,\n+    ) -> SendResult:\n+        \"\"\"Send a document/file attachment.\"\"\"\n+        return await self._send_attachment(chat_id, file_path, \"File\", caption)\n+\n+    async def send_image_file(\n+        self,\n+        chat_id: str,\n+        image_path: str,\n+        caption: Optional[str] = None,\n+        reply_to: Optional[str] = None,\n+        **kwargs,\n+    ) -> SendResult:\n+        \"\"\"Send a local image file as a native Signal attachment.\n+\n+        Called by the gateway media delivery flow when MEDIA: tags containing\n+        image paths are extracted from agent responses.\n+        \"\"\"\n+        return await self._send_attachment(chat_id, image_path, \"Image\", caption)\n+\n+    async def send_voice(\n+        self,\n+        chat_id: str,\n+        audio_path: str,\n+        caption: Optional[str] = None,\n+        reply_to: Optional[str] = None,\n+        **kwargs,\n+    ) -> SendResult:\n+        \"\"\"Send an audio file as a Signal attachment.\n+\n+        Signal does not distinguish voice messages from file attachments at\n+        the API level, so this routes through the same RPC send path.\n+        \"\"\"\n+        return await self._send_attachment(chat_id, audio_path, \"Audio\", caption)\n+\n+    async def send_video(\n+        self,\n+        chat_id: str,\n+        video_path: str,\n+        caption: Optional[str] = None,\n+        reply_to: Optional[str] = None,\n+        **kwargs,\n+    ) -> SendResult:\n+        \"\"\"Send a video file as a Signal attachment.\"\"\"\n+        return await self._send_attachment(chat_id, video_path, \"Video\", caption)\n \n     # ------------------------------------------------------------------\n     # Typing Indicators"]},{"file":"tests/gateway/test_signal.py","hunks":["@@ -2,6 +2,7 @@\n import base64\n import json\n import pytest\n+from pathlib import Path\n from unittest.mock import MagicMock, patch, AsyncMock\n from urllib.parse import quote\n ","@@ -368,3 +369,341 @@ def test_signal_in_platform_map(self):\n         # Just verify the import works and Signal is a valid platform\n         from gateway.config import Platform\n         assert Platform.SIGNAL.value == \"signal\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# send_image_file method (#5105)\n+# ---------------------------------------------------------------------------\n+\n+class TestSignalSendImageFile:\n+    @pytest.mark.asyncio\n+    async def test_send_image_file_sends_via_rpc(self, monkeypatch, tmp_path):\n+        \"\"\"send_image_file should send image as attachment via signal-cli RPC.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        mock_rpc, captured = _stub_rpc({\"timestamp\": 1234567890})\n+        adapter._rpc = mock_rpc\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        img_path = tmp_path / \"chart.png\"\n+        img_path.write_bytes(b\"\\x89PNG\" + b\"\\x00\" * 100)\n+\n+        result = await adapter.send_image_file(chat_id=\"+155****4567\", image_path=str(img_path))\n+\n+        assert result.success is True\n+        assert len(captured) == 1\n+        assert captured[0][\"method\"] == \"send\"\n+        assert captured[0][\"params\"][\"account\"] == adapter.account\n+        assert captured[0][\"params\"][\"recipient\"] == [\"+155****4567\"]\n+        assert captured[0][\"params\"][\"attachments\"] == [str(img_path)]\n+        assert captured[0][\"params\"][\"message\"] == \"\"  # caption=None → \"\"\n+        # Typing indicator must be stopped before sending\n+        adapter._stop_typing_indicator.assert_awaited_once_with(\"+155****4567\")\n+        # Timestamp must be tracked for echo-back prevention\n+        assert 1234567890 in adapter._recent_sent_timestamps\n+\n+    @pytest.mark.asyncio\n+    async def test_send_image_file_to_group(self, monkeypatch, tmp_path):\n+        \"\"\"send_image_file should route group chats via groupId.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        mock_rpc, captured = _stub_rpc({\"timestamp\": 1234567890})\n+        adapter._rpc = mock_rpc\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        img_path = tmp_path / \"photo.jpg\"\n+        img_path.write_bytes(b\"\\xff\\xd8\" + b\"\\x00\" * 100)\n+\n+        result = await adapter.send_image_file(\n+            chat_id=\"group:abc123==\", image_path=str(img_path), caption=\"Here's the chart\"\n+        )\n+\n+        assert result.success is True\n+        assert captured[0][\"params\"][\"groupId\"] == \"abc123==\"\n+        assert captured[0][\"params\"][\"message\"] == \"Here's the chart\"\n+\n+    @pytest.mark.asyncio\n+    async def test_send_image_file_missing(self, monkeypatch):\n+        \"\"\"send_image_file should fail gracefully for nonexistent files.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        result = await adapter.send_image_file(chat_id=\"+155****4567\", image_path=\"/nonexistent.png\")\n+\n+        assert result.success is False\n+        assert \"not found\" in result.error.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_send_image_file_too_large(self, monkeypatch, tmp_path):\n+        \"\"\"send_image_file should reject files over 100MB.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        img_path = tmp_path / \"huge.png\"\n+        img_path.write_bytes(b\"x\")\n+\n+        def mock_stat(self, **kwargs):\n+            class FakeStat:\n+                st_size = 200 * 1024 * 1024  # 200 MB\n+            return FakeStat()\n+\n+        with patch.object(Path, \"stat\", mock_stat):\n+            result = await adapter.send_image_file(chat_id=\"+155****4567\", image_path=str(img_path))\n+\n+        assert result.success is False\n+        assert \"too large\" in result.error.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_send_image_file_rpc_failure(self, monkeypatch, tmp_path):\n+        \"\"\"send_image_file should return error when RPC returns None.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        mock_rpc, _ = _stub_rpc(None)\n+        adapter._rpc = mock_rpc\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        img_path = tmp_path / \"test.png\"\n+        img_path.write_bytes(b\"\\x89PNG\" + b\"\\x00\" * 100)\n+\n+        result = await adapter.send_image_file(chat_id=\"+155****4567\", image_path=str(img_path))\n+\n+        assert result.success is False\n+        assert \"failed\" in result.error.lower()\n+\n+\n+# ---------------------------------------------------------------------------\n+# send_voice method (#5105)\n+# ---------------------------------------------------------------------------\n+\n+class TestSignalSendVoice:\n+    @pytest.mark.asyncio\n+    async def test_send_voice_sends_via_rpc(self, monkeypatch, tmp_path):\n+        \"\"\"send_voice should send audio as attachment via signal-cli RPC.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        mock_rpc, captured = _stub_rpc({\"timestamp\": 1234567890})\n+        adapter._rpc = mock_rpc\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        audio_path = tmp_path / \"reply.ogg\"\n+        audio_path.write_bytes(b\"OggS\" + b\"\\x00\" * 100)\n+\n+        result = await adapter.send_voice(chat_id=\"+155****4567\", audio_path=str(audio_path))\n+\n+        assert result.success is True\n+        assert captured[0][\"method\"] == \"send\"\n+        assert captured[0][\"params\"][\"attachments\"] == [str(audio_path)]\n+        assert captured[0][\"params\"][\"message\"] == \"\"  # caption=None → \"\"\n+        adapter._stop_typing_indicator.assert_awaited_once_with(\"+155****4567\")\n+        assert 1234567890 in adapter._recent_sent_timestamps\n+\n+    @pytest.mark.asyncio\n+    async def test_send_voice_missing_file(self, monkeypatch):\n+        \"\"\"send_voice should fail for nonexistent audio.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        result = await adapter.send_voice(chat_id=\"+155****4567\", audio_path=\"/missing.ogg\")\n+\n+        assert result.success is False\n+        assert \"not found\" in result.error.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_send_voice_to_group(self, monkeypatch, tmp_path):\n+        \"\"\"send_voice should route group chats correctly.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        mock_rpc, captured = _stub_rpc({\"timestamp\": 9999})\n+        adapter._rpc = mock_rpc\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        audio_path = tmp_path / \"note.mp3\"\n+        audio_path.write_bytes(b\"\\xff\\xe0\" + b\"\\x00\" * 100)\n+\n+        result = await adapter.send_voice(chat_id=\"group:grp1==\", audio_path=str(audio_path))\n+\n+        assert result.success is True\n+        assert captured[0][\"params\"][\"groupId\"] == \"grp1==\"\n+\n+    @pytest.mark.asyncio\n+    async def test_send_voice_too_large(self, monkeypatch, tmp_path):\n+        \"\"\"send_voice should reject files over 100MB.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        audio_path = tmp_path / \"huge.ogg\"\n+        audio_path.write_bytes(b\"x\")\n+\n+        def mock_stat(self, **kwargs):\n+            class FakeStat:\n+                st_size = 200 * 1024 * 1024\n+            return FakeStat()\n+\n+        with patch.object(Path, \"stat\", mock_stat):\n+            result = await adapter.send_voice(chat_id=\"+155****4567\", audio_path=str(audio_path))\n+\n+        assert result.success is False\n+        assert \"too large\" in result.error.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_send_voice_rpc_failure(self, monkeypatch, tmp_path):\n+        \"\"\"send_voice should return error when RPC returns None.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        mock_rpc, _ = _stub_rpc(None)\n+        adapter._rpc = mock_rpc\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        audio_path = tmp_path / \"reply.ogg\"\n+        audio_path.write_bytes(b\"OggS\" + b\"\\x00\" * 100)\n+\n+        result = await adapter.send_voice(chat_id=\"+155****4567\", audio_path=str(audio_path))\n+\n+        assert result.success is False\n+        assert \"failed\" in result.error.lower()\n+\n+\n+# ---------------------------------------------------------------------------\n+# send_video method (#5105)\n+# ---------------------------------------------------------------------------\n+\n+class TestSignalSendVideo:\n+    @pytest.mark.asyncio\n+    async def test_send_video_sends_via_rpc(self, monkeypatch, tmp_path):\n+        \"\"\"send_video should send video as attachment via signal-cli RPC.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        mock_rpc, captured = _stub_rpc({\"timestamp\": 1234567890})\n+        adapter._rpc = mock_rpc\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        vid_path = tmp_path / \"demo.mp4\"\n+        vid_path.write_bytes(b\"\\x00\\x00\\x00\\x18ftyp\" + b\"\\x00\" * 100)\n+\n+        result = await adapter.send_video(chat_id=\"+155****4567\", video_path=str(vid_path))\n+\n+        assert result.success is True\n+        assert captured[0][\"method\"] == \"send\"\n+        assert captured[0][\"params\"][\"attachments\"] == [str(vid_path)]\n+        assert captured[0][\"params\"][\"message\"] == \"\"  # caption=None → \"\"\n+        adapter._stop_typing_indicator.assert_awaited_once_with(\"+155****4567\")\n+        assert 1234567890 in adapter._recent_sent_timestamps\n+\n+    @pytest.mark.asyncio\n+    async def test_send_video_missing_file(self, monkeypatch):\n+        \"\"\"send_video should fail for nonexistent video.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        result = await adapter.send_video(chat_id=\"+155****4567\", video_path=\"/missing.mp4\")\n+\n+        assert result.success is False\n+        assert \"not found\" in result.error.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_send_video_too_large(self, monkeypatch, tmp_path):\n+        \"\"\"send_video should reject files over 100MB.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        vid_path = tmp_path / \"huge.mp4\"\n+        vid_path.write_bytes(b\"x\")\n+\n+        def mock_stat(self, **kwargs):\n+            class FakeStat:\n+                st_size = 200 * 1024 * 1024\n+            return FakeStat()\n+\n+        with patch.object(Path, \"stat\", mock_stat):\n+            result = await adapter.send_video(chat_id=\"+155****4567\", video_path=str(vid_path))\n+\n+        assert result.success is False\n+        assert \"too large\" in result.error.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_send_video_rpc_failure(self, monkeypatch, tmp_path):\n+        \"\"\"send_video should return error when RPC returns None.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        mock_rpc, _ = _stub_rpc(None)\n+        adapter._rpc = mock_rpc\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        vid_path = tmp_path / \"demo.mp4\"\n+        vid_path.write_bytes(b\"\\x00\\x00\\x00\\x18ftyp\" + b\"\\x00\" * 100)\n+\n+        result = await adapter.send_video(chat_id=\"+155****4567\", video_path=str(vid_path))\n+\n+        assert result.success is False\n+        assert \"failed\" in result.error.lower()\n+\n+\n+# ---------------------------------------------------------------------------\n+# MEDIA: tag extraction integration\n+# ---------------------------------------------------------------------------\n+\n+class TestSignalMediaExtraction:\n+    \"\"\"Verify the full pipeline: MEDIA: tag → extract → send_image_file/send_voice.\"\"\"\n+\n+    def test_extract_media_finds_image_tag(self):\n+        \"\"\"BasePlatformAdapter.extract_media should find MEDIA: image paths.\"\"\"\n+        from gateway.platforms.base import BasePlatformAdapter\n+        media, cleaned = BasePlatformAdapter.extract_media(\n+            \"Here's the chart.\\nMEDIA:/tmp/price_graph.png\"\n+        )\n+        assert len(media) == 1\n+        assert media[0][0] == \"/tmp/price_graph.png\"\n+        assert \"MEDIA:\" not in cleaned\n+\n+    def test_extract_media_finds_audio_tag(self):\n+        \"\"\"BasePlatformAdapter.extract_media should find MEDIA: audio paths.\"\"\"\n+        from gateway.platforms.base import BasePlatformAdapter\n+        media, cleaned = BasePlatformAdapter.extract_media(\n+            \"[[audio_as_voice]]\\nMEDIA:/tmp/reply.ogg\"\n+        )\n+        assert len(media) == 1\n+        assert media[0][0] == \"/tmp/reply.ogg\"\n+        assert media[0][1] is True  # is_voice flag\n+\n+    def test_signal_has_all_media_methods(self, monkeypatch):\n+        \"\"\"SignalAdapter must override all media send methods used by gateway.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        from gateway.platforms.base import BasePlatformAdapter\n+\n+        # These methods must NOT be the base class defaults (which just send text)\n+        assert type(adapter).send_image_file is not BasePlatformAdapter.send_image_file\n+        assert type(adapter).send_voice is not BasePlatformAdapter.send_voice\n+        assert type(adapter).send_video is not BasePlatformAdapter.send_video\n+        assert type(adapter).send_document is not BasePlatformAdapter.send_document\n+        assert type(adapter).send_image is not BasePlatformAdapter.send_image\n+\n+\n+# ---------------------------------------------------------------------------\n+# send_document now routes through _send_attachment (#5105 bonus)\n+# ---------------------------------------------------------------------------\n+\n+class TestSignalSendDocumentViaHelper:\n+    \"\"\"Verify send_document gained size check and path-in-error via _send_attachment.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_send_document_too_large(self, monkeypatch, tmp_path):\n+        \"\"\"send_document should now reject files over 100MB (was previously missing).\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        doc_path = tmp_path / \"huge.pdf\"\n+        doc_path.write_bytes(b\"x\")\n+\n+        def mock_stat(self, **kwargs):\n+            class FakeStat:\n+                st_size = 200 * 1024 * 1024\n+            return FakeStat()\n+\n+        with patch.object(Path, \"stat\", mock_stat):\n+            result = await adapter.send_document(chat_id=\"+155****4567\", file_path=str(doc_path))\n+\n+        assert result.success is False\n+        assert \"too large\" in result.error.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_send_document_error_includes_path(self, monkeypatch):\n+        \"\"\"send_document error message should include the file path.\"\"\"\n+        adapter = _make_signal_adapter(monkeypatch)\n+        adapter._stop_typing_indicator = AsyncMock()\n+\n+        result = await adapter.send_document(chat_id=\"+155****4567\", file_path=\"/nonexistent.pdf\")\n+\n+        assert result.success is False\n+        assert \"/nonexistent.pdf\" in result.error\n"]}]},{"kind":"pr","number":15671,"title":"fix(signal): support shared attachment directories","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}