# NousResearch/hermes-agent · Signal document attachment classification fixes

## Open now

- 0 issues, 1 PR open on GitHub.
- PR #20347: fix(signal): classify inbound document attachments

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.

## TL;DR

- 9 member(s): 0 open issue(s), 1 closed issue(s), 1 open PR(s), 1 merged PR(s), 6 PR(s) closed without merging.
- A fix was merged to main in PR #44695. Verify it addresses your case.
- **Recommended:** read PR #44695 (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: `resolved` · deciding PR(s): `pr-44695`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.22`: 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.

## Closed / duplicate issues

- #12845: \[Bug\]: Document attachments from Signal not detected

## Prior & competing PRs

- **#12859: fix(signal): classify document attachments as MessageType.DOCUMENT**. 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._
- **#44695: fix(gateway): classify document attachments as DOCUMENT on Signal, Email, and SimpleX**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `gateway/platforms/email.py`

    ```diff
    @@ -470,8 +470,15 @@ async def _dispatch_message(self, msg_data: Dict[str, Any]) -> None:
             for att in attachments:
                 media_urls.append(att["path"])
                 media_types.append(att["media_type"])
    -            if att["type"] == "image":
    +            if att["type"] == "image" and msg_type == MessageType.TEXT:
                     msg_type = MessageType.PHOTO
    +            elif att["type"] == "document":
    +                # Document wins over PHOTO for mixed attachments: run.py's
    +                # image handling keys off the per-path image/* mime type
    +                # regardless of message_type, but document-context injection
    +                # gates strictly on MessageType.DOCUMENT — so DOCUMENT is the
    +                # only classification that surfaces both.
    +                msg_type = MessageType.DOCUMENT
     
             # Store thread context for reply threading
             self._thread_context[sender_addr] = {
    ```
  - `gateway/platforms/signal.py`

    ```diff
    @@ -602,6 +602,14 @@ async def _handle_envelope(self, envelope: dict) -> None:
                     msg_type = MessageType.VOICE
                 elif any(mt.startswith("image/") for mt in media_types):
                     msg_type = MessageType.PHOTO
    +            elif any(mt.startswith("video/") for mt in media_types):
    +                msg_type = MessageType.VIDEO
    +            else:
    +                # Catch-all: application/*, text/*, and unknown MIME types are
    +                # treated as documents so run.py's document-context injection
    +                # surfaces the cached file path to the agent (same pattern as
    +                # WhatsApp/Slack/BlueBubbles/Mattermost).
    +                msg_type = MessageType.DOCUMENT
     
             # Parse timestamp from envelope data (milliseconds since epoch)
             ts_ms = envelope_data.get("timestamp", 0)
    ```
  - `plugins/platforms/simplex/adapter.py`

    ```diff
    @@ -625,6 +625,11 @@ async def _handle_chat_item(self, chat_item: dict) -> None:
                     msg_type = MessageType.VOICE
                 elif any(mt.startswith("image/") for mt in media_types):
                     msg_type = MessageType.PHOTO
    +            else:
    +                # Catch-all: non-image/non-audio files (tagged
    +                # application/octet-stream above) are documents so run.py's
    +                # document-context injection surfaces the file to the agent.
    +                msg_type = MessageType.DOCUMENT
     
             # Timestamp
             ts_str = meta.get("itemTs") or meta.get("createdAt", "")
    ```
  - `scripts/release.py`

    ```diff
    @@ -76,6 +76,7 @@
         "129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
         "290859878+synapsesx@users.noreply.github.com": "synapsesx",
         "dirtyren@users.noreply.github.com": "dirtyren",
    +    "[redacted:email]": "kdunn926",
         "[redacted:email]": "mvanhorn",
         "[redacted:email]": "youjunxiaji",
         "[redacted:email]": "NormallyGaussian",
    ```
  - `tests/gateway/test_email.py`

    ```diff
    @@ -393,6 +393,68 @@ async def capture_handle(event):
             self.assertEqual(captured_events[0].message_type, MessageType.PHOTO)
             self.assertEqual(captured_events[0].media_urls, ["/tmp/img.jpg"])
     
    +    def test_document_attachment_sets_document_type(self):
    +        """Email with a document attachment must set DOCUMENT so run.py injects file context."""
    +        import asyncio
    +        from gateway.platforms.base import MessageType
    +        adapter = self._make_adapter()
    +        captured_events = []
    +
    +        async def capture_handle(event):
    +            captured_events.append(event)
    +
    +        adapter.handle_message = capture_handle
    +
    +        msg_data = {
    +            "uid": b"6",
    +            "sender_addr": "[redacted:email]",
    +            "sender_name": "User",
    +            "subject": "Re: report",
    +            "message_id": "<[redacted:email]>",
    +            "in_reply_to": "",
    +            "body": "See attached",
    +            "attachments": [{"path": "/tmp/report.pdf", "filename": "report.pdf", "type": "document", "media_type": "application/pdf"}],
    +            "date": "",
    +        }
    +
    +        asyncio.run(adapter._dispatch_message(msg_data))
    +        self.assertEqual(len(captured_events), 1)
    +        self.assertEqual(captured_events[0].message_type, MessageType.DOCUMENT)
    +        self.assertEqual(captured_events[0].media_urls, ["/tmp/report.pdf"])
    +
    +    def test_mixed_image_and_document_prefers_document(self):
    +        """DOCUMENT wins for mixed attachments — image handling keys off per-path
    +        mime types, but document injection gates strictly on MessageType.DOCUMENT."""
    +        import asyncio
    +        from gateway.platforms.base import MessageType
    +        adapter = self._make_adapter()
    +        captured_events = []
    +
    +        async def capture_handle(event):
    +            captured_events.append(event)
    +
    +        adapter.handle_message = capture_handle
    +
    +        msg_data = {
    +            "uid": b"7",
    +            "sender_addr": "[redacted:email]",
    +            "sender_name": "User",
    +            "subject": "Re: both",
    +            "message_id": "<[redacted:email]>",
    +            "in_reply_to": "",
    +            "body": "Photo and PDF",
    +            "attachments": [
    +                {"path": "/tmp/img.jpg", "filename": "img.jpg", "type": "image", "media_type": "image/jpeg"},
    +                {"path": "/tmp/report.pdf", "filename": "report.pdf", "type": "document", "media_type": "application/pdf"},
    +            ],
    +            "date": "",
    +        }
    +
    +        asyncio.run(adapter._dispatch_message(msg_data))
    +        self.assertEqual(len(captured_events), 1)
    +        self.assertEqual(captured_events[0].message_type, MessageType.DOCUMENT)
    +        self.assertEqual(len(captured_events[0].media_urls), 2)
    +
         def test_source_built_correctly(self):
             """Session source should have correct chat_id and user info."""
             import asyncio
    ```
  - `tests/gateway/test_signal.py`

    ```diff
    @@ -770,6 +770,153 @@ def test_signal_has_all_media_methods(self, monkeypatch):
             assert type(adapter).send_image is not BasePlatformAdapter.send_image
     
     
    +# ---------------------------------------------------------------------------
    +# Inbound attachment message type classification
    +# ---------------------------------------------------------------------------
    +
    +def _make_dm_envelope(sender: str, attachments: list, text: str = "") -> dict:
    +    """Build a minimal signal-cli DM envelope with the given attachments."""
    +    return {
    +        "envelope": {
    +            "sourceNumber": sender,
    +            "sourceName": "Test User",
    +            "sourceUuid": "aaaaaaaa-0000-0000-0000-000000000001",
    +            "timestamp": 1700000000000,
    +            "dataMessage": {
    +                "timestamp": 1700000000000,
    +                "message": text,
    +                "expiresInSeconds": 0,
    +                "viewOnce": False,
    +                "attachments": attachments,
    +            },
    +        }
    +    }
    +
    +
    +class TestSignalInboundMessageTypeClassification:
    +    """_handle_envelope must set MessageType.DOCUMENT for application/* and text/* attachments.
    +
    +    Before the fix, PDFs and other documents left msg_type as MessageType.TEXT,
    +    so run.py's document-context injection (which gates on MessageType.DOCUMENT)
    +    silently dropped the file and the agent never saw it.
    +    """
    +
    +    async def _dispatch_single_attachment(self, monkeypatch, content_type: str,
    +                                          att_id: str, fetch_path: str, fetch_ext: str):
    +        """Helper: run _handle_envelope with one attachment and return the dispatched event."""
    +        envelope = _make_dm_envelope(
    +            sender="+15559876543",
    +            attachments=[{
    +                "contentType": content_type,
    +                "id": att_id,
    +                "size": 1024,
    +                "filename": None,
    +                "width": None,
    +                "height": None,
    +                "caption": None,
    +                "uploadTimestamp": 1700000000000,
    +            }],
    +        )
    +        adapter = _make_signal_adapter(monkeypatch)
    +        adapter._rpc, _ = _stub_rpc(None)
    +        dispatched = []
    +
    +        async def _fake_handle_message(event):
    +            dispatched.append(event)
    +
    +        adapter.handle_message = _fake_handle_message
    +        adapter._fetch_attachment = AsyncMock(return_value=(fetch_path, fetch_ext))
    +        await adapter._handle_envelope(envelope)
    +        assert dispatched, "_handle_envelope did not dispatch any event"
    +        return dispatched[0]
    +
    +    @pytest.mark.asyncio
    +    async def test_pdf_attachment_sets_document_type(self, monkeypatch):
    +        """A PDF attachment (application/pdf) must produce MessageType.DOCUMENT, not TEXT."""
    +        from gateway.platforms.base import MessageType
    +
    +        event = await self._dispatch_single_attachment(
    +            monkeypatch,
    +            content_type="application/pdf",
    +            att_id="6zLO3b-6Yf3zVWeLDctA.pdf",
    +            fetch_path="/tmp/report.pdf",
    +            fetch_ext=".pdf",
    +        )
    +
    +        assert event.message_type == MessageType.DOCUMENT, (
    +            f"Expected DOCUMENT, got {event.message_type}. "
    +            "PDFs must be classified as DOCUMENT so run.py injects file context."
    +        )
    +        assert "/tmp/report.pdf" in event.media_urls
    +
    +    @pytest.mark.asyncio
    +    async def test_text_plain_attachment_sets_document_type(self, monkeypatch):
    +        """A text/plain attachment must produce MessageType.DOCUMENT, not TEXT."""
    +        from gateway.platforms.base import MessageType
    +
    +        event = await self._dispatch_single_attachment(
    +            monkeypatch,
    +            content_type="text/plain",
    +            att_id="notes.txt",
    +            fetch_path="/tmp/notes.txt",
    +            fetch_ext=".txt",
    +        )
    +
    +        assert event.message_type == MessageType.DOCUMENT, (
    +            f"Expected DOCUMENT, got {event.message_type}. "
    +            "text/plain must be classified as DOCUMENT so run.py injects file context."
    +        )
    +
    +    @pytest.mark.asyncio
    +    async def test_text_html_attachment_sets_document_type(self, monkeypatch):
    +        """A text/html attachment must produce MessageType.DOCUMENT (covers the text/* wildcard)."""
    +        from gateway.platforms.base import MessageType
    +
    +        event = await self._dispatch_single_attachment(
    +            monkeypatch,
    +            content_type="text/html",
    +            att_id="page.html",
    +            fetch_path="/tmp/page.html",
    +            fetch_ext=".html",
    +        )
    +
    +        assert event.message_type == MessageType.DOCUMENT, (
    +            f"Expected DOCUMENT, got {event.message_type}. "
    +            "text/html must be classified as DOCUMENT so run.py injects file context."
    +        )
    +
    +    @pytest.mark.asyncio
    +    async def test_video_attachment_sets_video_type(self, monkeypatch):
    +        """A video/mp4 attachment must produce MessageType.VIDEO."""
    +        from gateway.platforms.base import MessageType
    +
    +        event = await self._dispatch_single_attachment(
    +            monkeypatch,
    +            content_type="video/mp4",
    +            att_id="clip.mp4",
    +            fetch_path="/tmp/clip.mp4",
    +            fetch_ext=".mp4",
    +        )
    +
    +        assert event.message_type == MessageType.VIDEO
    +
    +    @pytest.mark.asyncio
    +    async def test_unknown_mime_attachment_falls_back_to_document(self, monkeypatch):
    +        """Unknown/exotic MIME types fall through to DOCUMENT (catch-all),
    +        matching the WhatsApp/Slack/BlueBubbles classification pattern."""
    +        from gateway.platforms.base import MessageType
    +
    +        event = await self._dispatch_single_attachment(
    +            monkeypatch,
    +            content_type="chemical/x-pdb",
    +            att_id="molecule.pdb",
    +            fetch_path="/tmp/molecule.pdb",
    +            fetch_ext=".pdb",
    +        )
    +
    +        assert event.message_type == MessageType.DOCUMENT
    +
    +
     # ---------------------------------------------------------------------------
     # send_document now routes through _send_attachment (#5105 bonus)
     # ---------------------------------------------------------------------------
    ```
  - `tests/gateway/test_simplex_plugin.py`

    ```diff
    @@ -396,3 +396,74 @@ def test_register_calls_register_platform():
         assert callable(kwargs["setup_fn"])
         # SimpleX uses opaque IDs only — no PII to redact.
         assert kwargs["pii_safe"] is True
    +
    +
    +# ---------------------------------------------------------------------------
    +# Inbound attachment message type classification
    +# ---------------------------------------------------------------------------
    +
    +def _make_file_chat_item(file_path: str, file_name: str) -> dict:
    +    """Minimal direct-chat rcvMsgContent item carrying a completed file."""
    +    return {
    +        "chatInfo": {
    +            "type": "direct",
    +            "contact": {"contactId": 42, "localDisplayName": "tester"},
    +        },
    +        "chatItem": {
    +            "chatDir": {"type": "directRcv"},
    +            "meta": {"itemTs": "2026-01-01T00:00:00Z"},
    +            "content": {
    +                "type": "rcvMsgContent",
    +                "msgContent": {"type": "file", "text": "here you go"},
    +            },
    +            "file": {
    +                "fileId": 7,
    +                "fileName": file_name,
    +                "fileSource": {"filePath": file_path},
    +            },
    +        },
    +    }
    +
    +
    [redacted:email]
    +async def test_document_file_sets_document_type():
    +    """A non-image/non-audio file must classify as DOCUMENT, not TEXT,
    +    so run.py's document-context injection surfaces the path to the agent."""
    +    from gateway.config import PlatformConfig
    +    from gateway.platforms.base import MessageType
    +
    +    cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
    +    adapter = SimplexAdapter(cfg)
    +    dispatched = []
    +
    +    async def _capture(event):
    +        dispatched.append(event)
    +
    +    adapter.handle_message = _capture
    +    await adapter._handle_chat_item(_make_file_chat_item("/tmp/report.pdf", "report.pdf"))
    +
    +    assert dispatched, "_handle_chat_item did not dispatch any event"
    +    assert dispatched[0].message_type == MessageType.DOCUMENT
    +    assert dispatched[0].media_urls == ["/tmp/report.pdf"]
    +    assert dispatched[0].media_types == ["application/octet-stream"]
    +
    +
    [redacted:email]
    +async def test_image_file_still_sets_photo_type():
    +    """Regression guard: image files keep classifying as PHOTO after the
    +    document catch-all was added."""
    +    from gateway.config import PlatformConfig
    +    from gateway.platforms.base import MessageType
    +
    +    cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
    +    adapter = SimplexAdapter(cfg)
    +    dispatched = []
    +
    +    async def _capture(event):
    +        dispatched.append(event)
    +
    +    adapter.handle_message = _capture
    +    await adapter._handle_chat_item(_make_file_chat_item("/tmp/pic.jpg", "pic.jpg"))
    +
    +    assert dispatched, "_handle_chat_item did not dispatch any event"
    +    assert dispatched[0].message_type == MessageType.PHOTO
    ```
- **#7445: \[codex\] Fix Signal document attachments**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
- **#12851: fix(gateway): add Signal message type classification for documents**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `8207ae888`, `ffef9da9b`, `8e821cd2f`, `#44695`
- **#13243: fix: detect Signal document attachments (PDF, etc.) as MessageType.DOCUMENT**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`
- **#13512: fix(signal): detect document attachments via application/* and text/* MIME types**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`
- **#15671: fix(signal): support shared attachment directories**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#20347: fix(signal): classify inbound document attachments**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `8207ae888`, `1e29ab38c`
  - files: `gateway/platforms/signal.py`, `tests/gateway/test_signal.py`

## Fix status

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