NousResearch/hermes-agent

WeCom WebSocket reconnect silent group message loss

Cluster 11554. Data as of

Nothing open on GitHub. Fix merged in #11897 on 18 Apr 2026. Check it covers your case.

The short answer

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

A cluster of bug reports and fixes addressing WeCom AI Bot WebSocket reconnections that cause group messages to be silently dropped, including zombie session handling, watchdog blind spots where DM traffic masks group routing failures, and resetting message timestamps on reconnect.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

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

  • #11897 fix(wecom): resolve WebSocket zombie sessions and group chat 600039 errors (#11554)

    merged to main

    files: gateway/platforms/wecom.py, tests/gateway/test_wecom.py

    Show the diff
    • gateway/platforms/wecom.py
      @@ -180,6 +180,8 @@ def __init__(self, config: PlatformConfig):
               self._text_batch_split_delay_seconds = float(os.getenv("HERMES_WECOM_TEXT_BATCH_SPLIT_DELAY_SECONDS", "2.0"))
               self._pending_text_batches: Dict[str, MessageEvent] = {}
               self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
      +        self._device_id = uuid.uuid4().hex
      +        self._last_chat_req_ids: Dict[str, str] = {}
       
           # ------------------------------------------------------------------
           # Connection lifecycle
      @@ -277,7 +279,11 @@ async def _open_connection(self) -> None:
                   {
                       "cmd": APP_CMD_SUBSCRIBE,
                       "headers": {"req_id": req_id},
      -                "body": {"bot_id": self._bot_id, "secret": [redacted:secret-assignment]
      +                "body": {
      +                    "bot_id": self._bot_id,
      +                    "secret": [redacted:secret-assignment]
      +                    "device_id": self._device_id,
      +                },
                   }
               )
       
      @@ -496,6 +502,11 @@ async def _on_message(self, payload: Dict[str, Any]) -> None:
                   logger.debug("[%s] DM sender %s blocked by policy", self.name, sender_id)
                   return
       
      +        # Cache the inbound req_id after policy checks so proactive sends to
      +        # this chat can fall back to APP_CMD_RESPONSE (required for groups —
      +        # WeCom AI Bots cannot initiate APP_CMD_SEND in group chats).
      +        self._remember_chat_req_id(chat_id, self._payload_req_id(payload))
      +
               text, reply_text = self._extract_text(body)
               media_urls, media_types = await self._extract_media(body)
               message_type = self._derive_message_type(body, text, media_types)
      @@ -847,6 +858,23 @@ def _remember_reply_req_id(self, message_id: str, req_id: str) -> None:
               while len(self._reply_req_ids) > DEDUP_MAX_SIZE:
                   self._reply_req_ids.pop(next(iter(self._reply_req_ids)))
       
      +    def _remember_chat_req_id(self, chat_id: str, req_id: str) -> None:
      +        """Cache the most recent inbound req_id per chat.
      +
      +        Used as a fallback reply target when we need to send into a group
      +        without an explicit ``reply_to`` — WeCom AI Bots are blocked from
      +        APP_CMD_SEND in groups and must use APP_CMD_RESPONSE bound to some
      +        prior req_id. Bounded like _reply_req_ids so long-running gateways
      +        don't leak memory across many chats.
      +        """
      +        normalized_chat_id = str(chat_id or "").strip()
      +        normalized_req_id = str(req_id or "").strip()
      +        if not normalized_chat_id or not normalized_req_id:
      +            return
      +        self._last_chat_req_ids[normalized_chat_id] = normalized_req_id
      +        while len(self._last_chat_req_ids) > DEDUP_MAX_SIZE:
      +            self._last_chat_req_ids.pop(next(iter(self._last_chat_req_ids)))
      +
           def _reply_req_id_for_message(self, reply_to: Optional[str]) -> Optional[str]:
               normalized = str(reply_to or "").strip()
               if not normalized or normalized.startswith("quote:"):
      @@ -1163,19 +1191,15 @@ async def _send_media_message(self, chat_id: str, media_type: str, media_id: str
               self._raise_for_wecom_error(response, "send media message")
               return response
       
      -    async def _send_reply_stream(self, reply_req_id: str, content: str) -> Dict[str, Any]:
      +    async def _send_reply_markdown(self, reply_req_id: str, content: str) -> Dict[str, Any]:
               response = await self._send_reply_request(
                   reply_req_id,
                   {
      -                "msgtype": "stream",
      -                "stream": {
      -                    "id": self._new_req_id("stream"),
      -                    "finish": True,
      -                    "content": content[:self.MAX_MESSAGE_LENGTH],
      -                },
      +                "msgtype": "markdown",
      +                "markdown": {"content": content[:self.MAX_MESSAGE_LENGTH]},
                   },
               )
      -        self._raise_for_wecom_error(response, "send reply stream")
      +        self._raise_for_wecom_error(response, "send reply markdown")
               return response
       
           async def _send_reply_media_message(
      @@ -1235,6 +1259,9 @@ async def _send_media_source(
                   return SendResult(success=False, error=prepared["reject_reason"])
       
               reply_req_id = self._reply_req_id_for_message(reply_to)
      +        if not reply_req_id and chat_id in self._last_chat_req_ids:
      +            reply_req_id = self._last_chat_req_ids[chat_id]
      +
               try:
                   upload_result = await self._upload_media_bytes(
                       prepared["data"],
      @@ -1302,8 +1329,12 @@ async def send(
       
               try:
                   reply_req_id = self._reply_req_id_for_message(reply_to)
      +
      +            if not reply_req_id and chat_id in self._last_chat_req_ids:
      +                reply_req_id = self._last_chat_req_ids[chat_id]
      +
                   if reply_req_id:
      -                response = await self._send_reply_stream(reply_req_id, content)
      +                response = await self._send_reply_markdown(reply_req_id, content)
                   else:
                       response = await self._send_request(
                           APP_CMD_SEND,
    • tests/gateway/test_wecom.py
      @@ -119,7 +119,7 @@ async def aclose(self):
       
       class TestWeComReplyMode:
           @pytest.mark.asyncio
      -    async def test_send_uses_passive_reply_stream_when_reply_context_exists(self):
      +    async def test_send_uses_passive_reply_markdown_when_reply_context_exists(self):
               from gateway.platforms.wecom import WeComAdapter
       
               adapter = WeComAdapter(PlatformConfig(enabled=True))
      @@ -134,9 +134,10 @@ async def test_send_uses_passive_reply_stream_when_reply_context_exists(self):
               adapter._send_reply_request.assert_awaited_once()
               args = adapter._send_reply_request.await_args.args
               assert args[0] == "req-1"
      -        assert args[1]["msgtype"] == "stream"
      -        assert args[1]["stream"]["finish"] is True
      -        assert args[1]["stream"]["content"] == "hello from reply"
      +        # msgtype: stream triggers WeCom errcode 600039 on many mobile clients
      +        # (unsupported type). Markdown renders everywhere.
      +        assert args[1]["msgtype"] == "markdown"
      +        assert args[1]["markdown"]["content"] == "hello from reply"
       
           @pytest.mark.asyncio
           async def test_send_image_file_uses_passive_reply_media_when_reply_context_exists(self):
      @@ -593,3 +594,193 @@ async def test_on_message_respects_group_policy(self):
               await adapter._on_message(payload)
               adapter.handle_message.assert_not_awaited()
       
      +
      +class TestWeComZombieSessionFix:
      +    """Tests for PR #11572 — device_id, markdown reply, group req_id fallback."""
      +
      +    def test_adapter_generates_stable_device_id_per_instance(self):
      +        from gateway.platforms.wecom import WeComAdapter
      +
      +        adapter = WeComAdapter(PlatformConfig(enabled=True))
      +        assert isinstance(adapter._device_id, str)
      +        assert len(adapter._device_id) > 0
      +        # Second snapshot on the same adapter must be identical — only a fresh
      +        # adapter instance should get a new device_id (one-per-reconnect is the
      +        # zombie-session footgun we're fixing).
      +        assert adapter._device_id == adapter._device_id
      +
      +    def test_different_adapter_instances_get_distinct_device_ids(self):
      +        from gateway.platforms.wecom import WeComAdapter
      +
      +        a = WeComAdapter(PlatformConfig(enabled=True))
      +        b = WeComAdapter(PlatformConfig(enabled=True))
      +        assert a._device_id != b._device_id
      +
      +    @pytest.mark.asyncio
      +    async def test_open_connection_includes_device_id_in_subscribe(self):
      +        from gateway.platforms.wecom import APP_CMD_SUBSCRIBE, WeComAdapter
      +
      +        adapter = WeComAdapter(PlatformConfig(enabled=True))
      +        adapter._bot_id = "test-bot"
      +        adapter._secret = "[redacted:secret-assignment]"
      +
      +        sent_payloads = []
      +
      +        class _FakeWS:
      +            closed = False
      +
      +            async def send_json(self, payload):
      +                sent_payloads.append(payload)
      +
      +            async def close(self):
      +                return None
      +
      +        class _FakeSession:
      +            def __init__(self, *args, **kwargs):
      +                pass
      +
      +            async def ws_connect(self, *args, **kwargs):
      +                return _FakeWS()
      +
      +            async def close(self):
      +                return None
      +
      +        async def _fake_cleanup():
      +            return None
      +
      +        async def _fake_handshake(req_id):
      +            return {"errcode": 0, "headers": {"req_id": req_id}}
      +
      +        adapter._cleanup_ws = _fake_cleanup
      +        adapter._wait_for_handshake = _fake_handshake
      +
      +        with patch("gateway.platforms.wecom.aiohttp.ClientSession", _FakeSession):
      +            await adapter._open_connection()
      +
      +        assert len(sent_payloads) == 1
      +        subscribe = sent_payloads[0]
      +        assert subscribe["cmd"] == APP_CMD_SUBSCRIBE
      +        assert subscribe["body"]["bot_id"] == "test-bot"
      +        assert subscribe["body"]["secret"] == "test-secret"
      +        assert subscribe["body"]["device_id"] == adapter._device_id
      +
      +    @pytest.mark.asyncio
      +    async def test_on_message_caches_last_req_id_per_chat(self):
      +        from gateway.platforms.wecom import WeComAdapter
      +
      +        adapter = WeComAdapter(PlatformConfig(enabled=True))
      +        adapter._text_batch_delay_seconds = 0
      +        adapter.handle_message = AsyncMock()
      +        adapter._extract_media = AsyncMock(return_value=([], []))
      +
      +        payload = {
      +            "cmd": "aibot_msg_callback",
      +            "headers": {"req_id": "req-abc"},
      +            "body": {
      +                "msgid": "msg-1",
      +                "chatid": "group-1",
      +                "chattype": "group",
      +                "from": {"userid": "user-1"},
      +                "msgtype": "text",
      +                "text": {"content": "hi"},
      +            },
      +        }
      +
      +        await adapter._on_message(payload)
      +        assert adapter._last_chat_req_ids["group-1"] == "req-abc"
      +
      +    @pytest.mark.asyncio
      +    async def test_on_message_does_not_cache_blocked_sender_req_id(self):
      +        """Blocked chats shouldn't populate the proactive-send fallback cache."""
      +        from gateway.platforms.wecom import WeComAdapter
      +
      +        adapter = WeComAdapter(
      +            PlatformConfig(
      +                enabled=True,
      +                extra={"group_policy": "allowlist", "group_allow_from": ["group-ok"]},
      +            )
      +        )
      +        adapter.handle_message = AsyncMock()
      +        adapter._extract_media = AsyncMock(return_value=([], []))
      +
      +        payload = {
      +            "cmd": "aibot_msg_callback",
      +            "headers": {"req_id": "req-abc"},
      +            "body": {
      +                "msgid": "msg-1",
      +                "chatid": "group-blocked",
      +                "chattype": "group",
      +                "from": {"userid": "user-1"},
      +                "msgtype": "text",
      +                "text": {"content": "hi"},
      +            },
      +        }
      +
      +        await adapter._on_message(payload)
      +        adapter.handle_message.assert_not_awaited()
      +        assert "group-blocked" not in adapter._last_chat_req_ids
      +
      +    def test_remember_chat_req_id_is_bounded(self):
      +        from gateway.platforms.wecom import DEDUP_MAX_SIZE, WeComAdapter
      +
      +        adapter = WeComAdapter(PlatformConfig(enabled=True))
      +        for i in range(DEDUP_MAX_SIZE + 50):
      +            adapter._remember_chat_req_id(f"chat-{i}", f"req-{i}")
      +        assert len(adapter._last_chat_req_ids) <= DEDUP_MAX_SIZE
      +        # The most recently remembered chat must still be present.
      +        latest = f"chat-{DEDUP_MAX_SIZE + 49}"
      +        assert adapter._last_chat_req_ids[latest] == f"req-{DEDUP_MAX_SIZE + 49}"
      +
      +    def test_remember_chat_req_id_ignores_empty_values(self):
      +        from gateway.platforms.wecom import WeComAdapter
      +
      +        adapter = WeComAdapter(PlatformConfig(enabled=True))
      +        adapter._remember_chat_req_id("", "req-1")
      +        adapter._remember_chat_req_id("chat-1", "")
      +        adapter._remember_chat_req_id("   ", "   ")
      +        assert adapter._last_chat_req_ids == {}
      +
      +    @pytest.mark.asyncio
      +    async def test_proactive_group_send_falls_back_to_cached_req_id(self):
      +        """Sending into a group without reply_to should use the last cached
      +        req_id via APP_CMD_RESPONSE — WeCom AI Bots cannot initiate APP_CMD_SEND
      +        in group chats (errcode 600039)."""
      +        from gateway.platforms.wecom import WeComAdapter
      +
      +        adapter = WeComAdapter(PlatformConfig(enabled=True))
      +        adapter._last_chat_req_ids["group-1"] = "inbound-req-42"
      +        adapter._send_reply_request = AsyncMock(
      +            return_value={"headers": {"req_id": "inbound-req-42"}, "errcode": 0}
      +        )
      +        adapter._send_request = AsyncMock(
      +            return_value={"headers": {"req_id": "new"}, "errcode": 0}
      +        )
      +
      +        result = await adapter.send("group-1", "ping", reply_to=None)
      +
      +        assert result.success is True
      +        # Must route through reply (APP_CMD_RESPONSE), not proactive send.
      +        adapter._send_reply_request.assert_awaited_once()
      +        adapter._send_request.assert_not_awaited()
      +        args = adapter._send_reply_request.await_args.args
      +        assert args[0] == "inbound-req-42"
      +        assert args[1]["msgtype"] == "markdown"
      +        assert args[1]["markdown"]["content"] == "ping"
      +
      +    @pytest.mark.asyncio
      +    async def test_proactive_send_without_cached_req_id_uses_app_cmd_send(self):
      +        """When we have no prior req_id (fresh DM target), APP_CMD_SEND is used."""
      +        from gateway.platforms.wecom import APP_CMD_SEND, WeComAdapter
      +
      +        adapter = WeComAdapter(PlatformConfig(enabled=True))
      +        adapter._send_request = AsyncMock(
      +            return_value={"headers": {"req_id": "new"}, "errcode": 0}
      +        )
      +
      +        result = await adapter.send("fresh-dm-chat", "ping", reply_to=None)
      +
      +        assert result.success is True
      +        adapter._send_request.assert_awaited_once()
      +        cmd = adapter._send_request.await_args.args[0]
      +        assert cmd == APP_CMD_SEND
      +

2 PRs closed without merging

From the close comments: merged via another change 2

  • #11572 fix(wecom): resolve WebSocket zombie sessions and group chat 600039 e… · merged via another change
  • #11656 fix(wecom): watchdog + reset _last_msg_at on reconnect to fix silent group message loss · merged via another change → #11572
Closed issues · 1
  • Closed#11554[Bug]: WeCom AI Bot WebSocket reconnects cause group message delivery to stop silently
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.50: 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

Merged PRs
#11897
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
18 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.