{"repo":"NousResearch/hermes-agent","public_id":"12845","display_label":"Signal document attachment classification fixes","summary":"Multiple fixes and bug reports addressing the misclassification of document attachments (PDFs, etc.) from Signal and other gateways. Work spans MIME type detection, shared attachment directories, and per-attachment type handling across Signal, Email, and SimpleX.","subsystem":"gateway","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":44695,"merged_at":"2026-06-12T08:07:51+00:00"},"resolution":{"state":"resolved","by":"pr-44695"},"freshness":{"state":"verified","last_verified_at":"2026-06-12T08:07:51+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.2222222222222222},"fix_edges":[{"issue_number":12845,"pr_number":44695,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-12T08:07:51+00:00"}],"members":[{"kind":"issue","number":12845,"title":"[Bug]: Document attachments from Signal not detected","url":null,"state":"closed"},{"kind":"pr","number":7445,"title":"[codex] Fix Signal document attachments","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":12851,"title":"fix(gateway): add Signal message type classification for documents","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8207ae888","number":0},{"kind":"commit","value":"ffef9da9b","number":0},{"kind":"commit","value":"8e821cd2f","number":0},{"kind":"pr","value":"#44695","number":44695}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":12859,"title":"fix(signal): classify document attachments as MessageType.DOCUMENT","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":13243,"title":"fix: detect Signal document attachments (PDF, etc.) as MessageType.DOCUMENT","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":13512,"title":"fix(signal): detect document attachments via application/* and text/* MIME types","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":15671,"title":"fix(signal): support shared attachment directories","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":20347,"title":"fix(signal): classify inbound document attachments","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"8207ae888","number":0},{"kind":"commit","value":"1e29ab38c","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":44695,"title":"fix(gateway): classify document attachments as DOCUMENT on Signal, Email, and SimpleX","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/email.py","gateway/platforms/signal.py","plugins/platforms/simplex/adapter.py","scripts/release.py","tests/gateway/test_email.py","tests/gateway/test_signal.py","tests/gateway/test_simplex_plugin.py"],"fix_hunks":[{"file":"gateway/platforms/email.py","hunks":["@@ -470,8 +470,15 @@ async def _dispatch_message(self, msg_data: Dict[str, Any]) -> None:\n         for att in attachments:\n             media_urls.append(att[\"path\"])\n             media_types.append(att[\"media_type\"])\n-            if att[\"type\"] == \"image\":\n+            if att[\"type\"] == \"image\" and msg_type == MessageType.TEXT:\n                 msg_type = MessageType.PHOTO\n+            elif att[\"type\"] == \"document\":\n+                # Document wins over PHOTO for mixed attachments: run.py's\n+                # image handling keys off the per-path image/* mime type\n+                # regardless of message_type, but document-context injection\n+                # gates strictly on MessageType.DOCUMENT — so DOCUMENT is the\n+                # only classification that surfaces both.\n+                msg_type = MessageType.DOCUMENT\n \n         # Store thread context for reply threading\n         self._thread_context[sender_addr] = {"]},{"file":"gateway/platforms/signal.py","hunks":["@@ -602,6 +602,14 @@ async def _handle_envelope(self, envelope: dict) -> None:\n                 msg_type = MessageType.VOICE\n             elif any(mt.startswith(\"image/\") for mt in media_types):\n                 msg_type = MessageType.PHOTO\n+            elif any(mt.startswith(\"video/\") for mt in media_types):\n+                msg_type = MessageType.VIDEO\n+            else:\n+                # Catch-all: application/*, text/*, and unknown MIME types are\n+                # treated as documents so run.py's document-context injection\n+                # surfaces the cached file path to the agent (same pattern as\n+                # WhatsApp/Slack/BlueBubbles/Mattermost).\n+                msg_type = MessageType.DOCUMENT\n \n         # Parse timestamp from envelope data (milliseconds since epoch)\n         ts_ms = envelope_data.get(\"timestamp\", 0)"]},{"file":"plugins/platforms/simplex/adapter.py","hunks":["@@ -625,6 +625,11 @@ async def _handle_chat_item(self, chat_item: dict) -> None:\n                 msg_type = MessageType.VOICE\n             elif any(mt.startswith(\"image/\") for mt in media_types):\n                 msg_type = MessageType.PHOTO\n+            else:\n+                # Catch-all: non-image/non-audio files (tagged\n+                # application/octet-stream above) are documents so run.py's\n+                # document-context injection surfaces the file to the agent.\n+                msg_type = MessageType.DOCUMENT\n \n         # Timestamp\n         ts_str = meta.get(\"itemTs\") or meta.get(\"createdAt\", \"\")"]},{"file":"scripts/release.py","hunks":["@@ -76,6 +76,7 @@\n     \"129007007+HeLLGURD@users.noreply.github.com\": \"HeLLGURD\",\n     \"290859878+synapsesx@users.noreply.github.com\": \"synapsesx\",\n     \"dirtyren@users.noreply.github.com\": \"dirtyren\",\n+    \"[redacted:email]\": \"kdunn926\",\n     \"[redacted:email]\": \"mvanhorn\",\n     \"[redacted:email]\": \"youjunxiaji\",\n     \"[redacted:email]\": \"NormallyGaussian\","]},{"file":"tests/gateway/test_email.py","hunks":["@@ -393,6 +393,68 @@ async def capture_handle(event):\n         self.assertEqual(captured_events[0].message_type, MessageType.PHOTO)\n         self.assertEqual(captured_events[0].media_urls, [\"/tmp/img.jpg\"])\n \n+    def test_document_attachment_sets_document_type(self):\n+        \"\"\"Email with a document attachment must set DOCUMENT so run.py injects file context.\"\"\"\n+        import asyncio\n+        from gateway.platforms.base import MessageType\n+        adapter = self._make_adapter()\n+        captured_events = []\n+\n+        async def capture_handle(event):\n+            captured_events.append(event)\n+\n+        adapter.handle_message = capture_handle\n+\n+        msg_data = {\n+            \"uid\": b\"6\",\n+            \"sender_addr\": \"[redacted:email]\",\n+            \"sender_name\": \"User\",\n+            \"subject\": \"Re: report\",\n+            \"message_id\": \"<[redacted:email]>\",\n+            \"in_reply_to\": \"\",\n+            \"body\": \"See attached\",\n+            \"attachments\": [{\"path\": \"/tmp/report.pdf\", \"filename\": \"report.pdf\", \"type\": \"document\", \"media_type\": \"application/pdf\"}],\n+            \"date\": \"\",\n+        }\n+\n+        asyncio.run(adapter._dispatch_message(msg_data))\n+        self.assertEqual(len(captured_events), 1)\n+        self.assertEqual(captured_events[0].message_type, MessageType.DOCUMENT)\n+        self.assertEqual(captured_events[0].media_urls, [\"/tmp/report.pdf\"])\n+\n+    def test_mixed_image_and_document_prefers_document(self):\n+        \"\"\"DOCUMENT wins for mixed attachments — image handling keys off per-path\n+        mime types, but document injection gates strictly on MessageType.DOCUMENT.\"\"\"\n+        import asyncio\n+        from gateway.platforms.base import MessageType\n+        adapter = self._make_adapter()\n+        captured_events = []\n+\n+        async def capture_handle(event):\n+            captured_events.append(event)\n+\n+        adapter.handle_message = capture_handle\n+\n+        msg_data = {\n+            \"uid\": b\"7\",\n+            \"sender_addr\": \"[redacted:email]\",\n+            \"sender_name\": \"User\",\n+            \"subject\": \"Re: both\",\n+            \"message_id\": \"<[redacted:email]>\",\n+            \"in_reply_to\": \"\",\n+            \"body\": \"Photo and PDF\",\n+            \"attachments\": [\n+                {\"path\": \"/tmp/img.jpg\", \"filename\": \"img.jpg\", \"type\": \"image\", \"media_type\": \"image/jpeg\"},\n+                {\"path\": \"/tmp/report.pdf\", \"filename\": \"report.pdf\", \"type\": \"document\", \"media_type\": \"application/pdf\"},\n+            ],\n+            \"date\": \"\",\n+        }\n+\n+        asyncio.run(adapter._dispatch_message(msg_data))\n+        self.assertEqual(len(captured_events), 1)\n+        self.assertEqual(captured_events[0].message_type, MessageType.DOCUMENT)\n+        self.assertEqual(len(captured_events[0].media_urls), 2)\n+\n     def test_source_built_correctly(self):\n         \"\"\"Session source should have correct chat_id and user info.\"\"\"\n         import asyncio"]},{"file":"tests/gateway/test_signal.py","hunks":["@@ -770,6 +770,153 @@ def test_signal_has_all_media_methods(self, monkeypatch):\n         assert type(adapter).send_image is not BasePlatformAdapter.send_image\n \n \n+# ---------------------------------------------------------------------------\n+# Inbound attachment message type classification\n+# ---------------------------------------------------------------------------\n+\n+def _make_dm_envelope(sender: str, attachments: list, text: str = \"\") -> dict:\n+    \"\"\"Build a minimal signal-cli DM envelope with the given attachments.\"\"\"\n+    return {\n+        \"envelope\": {\n+            \"sourceNumber\": sender,\n+            \"sourceName\": \"Test User\",\n+            \"sourceUuid\": \"aaaaaaaa-0000-0000-0000-000000000001\",\n+            \"timestamp\": 1700000000000,\n+            \"dataMessage\": {\n+                \"timestamp\": 1700000000000,\n+                \"message\": text,\n+                \"expiresInSeconds\": 0,\n+                \"viewOnce\": False,\n+                \"attachments\": attachments,\n+            },\n+        }\n+    }\n+\n+\n+class TestSignalInboundMessageTypeClassification:\n+    \"\"\"_handle_envelope must set MessageType.DOCUMENT for application/* and text/* attachments.\n+\n+    Before the fix, PDFs and other documents left msg_type as MessageType.TEXT,\n+    so run.py's document-context injection (which gates on MessageType.DOCUMENT)\n+    silently dropped the file and the agent never saw it.\n+    \"\"\"\n+\n+    async def _dispatch_single_attachment(self, monkeypatch, content_type: str,\n+                                          att_id: str, fetch_path: str, fetch_ext: str):\n+        \"\"\"Helper: run _handle_envelope with one attachment and return the dispatched event.\"\"\"\n+        envelope = _make_dm_envelope(\n+            sender=\"+15559876543\",\n+            attachments=[{\n+                \"contentType\": content_type,\n+                \"id\": att_id,\n+                \"size\": 1024,\n+                \"filename\": None,\n+                \"width\": None,\n+                \"height\": None,\n+                \"caption\": None,\n+                \"uploadTimestamp\": 1700000000000,\n+            }],\n+        )\n+        adapter = _make_signal_adapter(monkeypatch)\n+        adapter._rpc, _ = _stub_rpc(None)\n+        dispatched = []\n+\n+        async def _fake_handle_message(event):\n+            dispatched.append(event)\n+\n+        adapter.handle_message = _fake_handle_message\n+        adapter._fetch_attachment = AsyncMock(return_value=(fetch_path, fetch_ext))\n+        await adapter._handle_envelope(envelope)\n+        assert dispatched, \"_handle_envelope did not dispatch any event\"\n+        return dispatched[0]\n+\n+    @pytest.mark.asyncio\n+    async def test_pdf_attachment_sets_document_type(self, monkeypatch):\n+        \"\"\"A PDF attachment (application/pdf) must produce MessageType.DOCUMENT, not TEXT.\"\"\"\n+        from gateway.platforms.base import MessageType\n+\n+        event = await self._dispatch_single_attachment(\n+            monkeypatch,\n+            content_type=\"application/pdf\",\n+            att_id=\"6zLO3b-6Yf3zVWeLDctA.pdf\",\n+            fetch_path=\"/tmp/report.pdf\",\n+            fetch_ext=\".pdf\",\n+        )\n+\n+        assert event.message_type == MessageType.DOCUMENT, (\n+            f\"Expected DOCUMENT, got {event.message_type}. \"\n+            \"PDFs must be classified as DOCUMENT so run.py injects file context.\"\n+        )\n+        assert \"/tmp/report.pdf\" in event.media_urls\n+\n+    @pytest.mark.asyncio\n+    async def test_text_plain_attachment_sets_document_type(self, monkeypatch):\n+        \"\"\"A text/plain attachment must produce MessageType.DOCUMENT, not TEXT.\"\"\"\n+        from gateway.platforms.base import MessageType\n+\n+        event = await self._dispatch_single_attachment(\n+            monkeypatch,\n+            content_type=\"text/plain\",\n+            att_id=\"notes.txt\",\n+            fetch_path=\"/tmp/notes.txt\",\n+            fetch_ext=\".txt\",\n+        )\n+\n+        assert event.message_type == MessageType.DOCUMENT, (\n+            f\"Expected DOCUMENT, got {event.message_type}. \"\n+            \"text/plain must be classified as DOCUMENT so run.py injects file context.\"\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_text_html_attachment_sets_document_type(self, monkeypatch):\n+        \"\"\"A text/html attachment must produce MessageType.DOCUMENT (covers the text/* wildcard).\"\"\"\n+        from gateway.platforms.base import MessageType\n+\n+        event = await self._dispatch_single_attachment(\n+            monkeypatch,\n+            content_type=\"text/html\",\n+            att_id=\"page.html\",\n+            fetch_path=\"/tmp/page.html\",\n+            fetch_ext=\".html\",\n+        )\n+\n+        assert event.message_type == MessageType.DOCUMENT, (\n+            f\"Expected DOCUMENT, got {event.message_type}. \"\n+            \"text/html must be classified as DOCUMENT so run.py injects file context.\"\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_video_attachment_sets_video_type(self, monkeypatch):\n+        \"\"\"A video/mp4 attachment must produce MessageType.VIDEO.\"\"\"\n+        from gateway.platforms.base import MessageType\n+\n+        event = await self._dispatch_single_attachment(\n+            monkeypatch,\n+            content_type=\"video/mp4\",\n+            att_id=\"clip.mp4\",\n+            fetch_path=\"/tmp/clip.mp4\",\n+            fetch_ext=\".mp4\",\n+        )\n+\n+        assert event.message_type == MessageType.VIDEO\n+\n+    @pytest.mark.asyncio\n+    async def test_unknown_mime_attachment_falls_back_to_document(self, monkeypatch):\n+        \"\"\"Unknown/exotic MIME types fall through to DOCUMENT (catch-all),\n+        matching the WhatsApp/Slack/BlueBubbles classification pattern.\"\"\"\n+        from gateway.platforms.base import MessageType\n+\n+        event = await self._dispatch_single_attachment(\n+            monkeypatch,\n+            content_type=\"chemical/x-pdb\",\n+            att_id=\"molecule.pdb\",\n+            fetch_path=\"/tmp/molecule.pdb\",\n+            fetch_ext=\".pdb\",\n+        )\n+\n+        assert event.message_type == MessageType.DOCUMENT\n+\n+\n # ---------------------------------------------------------------------------\n # send_document now routes through _send_attachment (#5105 bonus)\n # ---------------------------------------------------------------------------"]},{"file":"tests/gateway/test_simplex_plugin.py","hunks":["@@ -396,3 +396,74 @@ def test_register_calls_register_platform():\n     assert callable(kwargs[\"setup_fn\"])\n     # SimpleX uses opaque IDs only — no PII to redact.\n     assert kwargs[\"pii_safe\"] is True\n+\n+\n+# ---------------------------------------------------------------------------\n+# Inbound attachment message type classification\n+# ---------------------------------------------------------------------------\n+\n+def _make_file_chat_item(file_path: str, file_name: str) -> dict:\n+    \"\"\"Minimal direct-chat rcvMsgContent item carrying a completed file.\"\"\"\n+    return {\n+        \"chatInfo\": {\n+            \"type\": \"direct\",\n+            \"contact\": {\"contactId\": 42, \"localDisplayName\": \"tester\"},\n+        },\n+        \"chatItem\": {\n+            \"chatDir\": {\"type\": \"directRcv\"},\n+            \"meta\": {\"itemTs\": \"2026-01-01T00:00:00Z\"},\n+            \"content\": {\n+                \"type\": \"rcvMsgContent\",\n+                \"msgContent\": {\"type\": \"file\", \"text\": \"here you go\"},\n+            },\n+            \"file\": {\n+                \"fileId\": 7,\n+                \"fileName\": file_name,\n+                \"fileSource\": {\"filePath\": file_path},\n+            },\n+        },\n+    }\n+\n+\n[redacted:email]\n+async def test_document_file_sets_document_type():\n+    \"\"\"A non-image/non-audio file must classify as DOCUMENT, not TEXT,\n+    so run.py's document-context injection surfaces the path to the agent.\"\"\"\n+    from gateway.config import PlatformConfig\n+    from gateway.platforms.base import MessageType\n+\n+    cfg = PlatformConfig(enabled=True, extra={\"ws_url\": \"ws://localhost:5225\"})\n+    adapter = SimplexAdapter(cfg)\n+    dispatched = []\n+\n+    async def _capture(event):\n+        dispatched.append(event)\n+\n+    adapter.handle_message = _capture\n+    await adapter._handle_chat_item(_make_file_chat_item(\"/tmp/report.pdf\", \"report.pdf\"))\n+\n+    assert dispatched, \"_handle_chat_item did not dispatch any event\"\n+    assert dispatched[0].message_type == MessageType.DOCUMENT\n+    assert dispatched[0].media_urls == [\"/tmp/report.pdf\"]\n+    assert dispatched[0].media_types == [\"application/octet-stream\"]\n+\n+\n[redacted:email]\n+async def test_image_file_still_sets_photo_type():\n+    \"\"\"Regression guard: image files keep classifying as PHOTO after the\n+    document catch-all was added.\"\"\"\n+    from gateway.config import PlatformConfig\n+    from gateway.platforms.base import MessageType\n+\n+    cfg = PlatformConfig(enabled=True, extra={\"ws_url\": \"ws://localhost:5225\"})\n+    adapter = SimplexAdapter(cfg)\n+    dispatched = []\n+\n+    async def _capture(event):\n+        dispatched.append(event)\n+\n+    adapter.handle_message = _capture\n+    await adapter._handle_chat_item(_make_file_chat_item(\"/tmp/pic.jpg\", \"pic.jpg\"))\n+\n+    assert dispatched, \"_handle_chat_item did not dispatch any event\"\n+    assert dispatched[0].message_type == MessageType.PHOTO\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}