NousResearch/hermes-agent

Signal document attachment classification fixes

Cluster 12845 · gateway. Data as of

1 open item. Fix merged in #44695 on 12 Jun 2026. Check it covers your case.

The short answer

0open issues
1open PR
9items grouped
Known?
Yes. 9 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #44695. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #44695. That is separate from the fix event.
Still open
0 issues and 1 PR.

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.

Open issues0

No open issues.

Open PRs1

  • PR#20347fix(signal): classify inbound document attachments

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #44695 fix(gateway): classify document attachments as DOCUMENT on Signal, Email, and SimpleX

    merged to main

    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

    Show the diff
    • gateway/platforms/email.py
      @@ -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
      @@ -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
      @@ -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
      @@ -76,6 +76,7 @@
           "[email protected]": "HeLLGURD",
           "[email protected]": "synapsesx",
           "[email protected]": "dirtyren",
      +    "[redacted:email]": "kdunn926",
           "[redacted:email]": "mvanhorn",
           "[redacted:email]": "youjunxiaji",
           "[redacted:email]": "NormallyGaussian",
    • tests/gateway/test_email.py
      @@ -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
      @@ -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
      @@ -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

6 PRs closed without merging

From the close comments: duplicate 2 · closed without comment 1 · done elsewhere 1 · merged via another change 1 · other 1

  • #7445 [codex] Fix Signal document attachments · done elsewhere
  • #12851 fix(gateway): add Signal message type classification for documents · merged via another change → 8207ae8, ffef9da, 8e821cd, #44695
  • #12859 fix(signal): classify document attachments as MessageType.DOCUMENT · other
  • #13243 fix: detect Signal document attachments (PDF, etc.) as MessageType.DOCUMENT · duplicate
  • #13512 fix(signal): detect document attachments via application/* and text/* MIME types · duplicate
Show 1 more
  • #15671 fix(signal): support shared attachment directories · closed without comment
Closed issues · 1
  • Closed#12845[Bug]: Document attachments from Signal not detected
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.22: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#44695
Closed issues
1
Attempts
6 PRs closed without merging
Fix link checked
12 Jun 2026

For your agent

The same facts, typed.