{"repo":"NousResearch/hermes-agent","public_id":"10306","display_label":"MessageDeduplicator TTL expiry and slash-command exemption bugs","summary":"These issues and fixes address two related deduplication problems: TTL-based expiry in MessageDeduplicator only triggering on cache overflow rather than on lookup, and Weixin/WeChat content deduplication incorrectly suppressing legitimate repeated messages and slash commands like /approve.","subsystem":"gateway","counts":{"members":13,"open_issues":2,"open_issues_distinct":2,"open_prs":1,"closed_issues":2,"merged_prs":1,"attempt_prs":8},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":10509},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-15T20:35:40+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.13384615384615386},"fix_edges":[{"issue_number":10306,"pr_number":10509,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":10306,"title":"[Bug]: MessageDeduplicator TTL never expires unless cache exceeds max_size","url":null,"state":"closed"},{"kind":"issue","number":13829,"title":"MessageDeduplicator TTL never expires entries unless the cache overflows","url":null,"state":"closed"},{"kind":"issue","number":29779,"title":"WeChat adapter content dedup silently drops repeated commands, breaking /approve workflow","url":null,"state":"open"},{"kind":"issue","number":36750,"title":"[Bug]: Weixin content dedup can drop legitimate repeated messages after the previous turn has completed","url":null,"state":"open"},{"kind":"pr","number":10312,"title":"fix(dedup): check TTL expiry before returning duplicate status","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10509","number":10509}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":10345,"title":"fix(gateway): purge MessageDeduplicator entries by TTL on every lookup","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10509","number":10509},{"kind":"pr","value":"#13829","number":13829}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":10509,"title":"fix: enforce TTL in MessageDeduplicator + use yaml for gateway --config","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/helpers.py","gateway/run.py","tests/gateway/test_message_deduplicator.py"],"fix_hunks":[{"file":"gateway/platforms/helpers.py","hunks":["@@ -49,7 +49,10 @@ def is_duplicate(self, msg_id: str) -> bool:\n             return False\n         now = time.time()\n         if msg_id in self._seen:\n-            return True\n+            if now - self._seen[msg_id] < self._ttl:\n+                return True\n+            # Entry has expired — remove it and treat as new\n+            del self._seen[msg_id]\n         self._seen[msg_id] = now\n         if len(self._seen) > self._max_size:\n             cutoff = now - self._ttl"]},{"file":"gateway/run.py","hunks":["@@ -9725,9 +9725,9 @@ def main():\n     \n     config = None\n     if args.config:\n-        import json\n+        import yaml\n         with open(args.config, encoding=\"utf-8\") as f:\n-            data = json.load(f)\n+            data = yaml.safe_load(f)\n             config = GatewayConfig.from_dict(data)\n     \n     # Run the gateway - exit with code 1 if no platforms connected,"]},{"file":"tests/gateway/test_message_deduplicator.py","hunks":["@@ -0,0 +1,89 @@\n+\"\"\"Tests for MessageDeduplicator TTL enforcement (#10306).\n+\n+Previously, is_duplicate() returned True for any previously seen ID without\n+checking its age — expired entries were only purged when cache size exceeded\n+max_size.  Normal workloads never overflowed, so messages stayed \"duplicate\"\n+forever.\n+\n+The fix checks TTL at query time: if the entry's timestamp plus TTL is in\n+the past, the entry is treated as expired and the message is allowed through.\n+\"\"\"\n+\n+import time\n+from unittest.mock import patch\n+\n+from gateway.platforms.helpers import MessageDeduplicator\n+\n+\n+class TestMessageDeduplicatorTTL:\n+    \"\"\"TTL-based expiration must work regardless of cache size.\"\"\"\n+\n+    def test_duplicate_within_ttl(self):\n+        \"\"\"Same message within TTL window is duplicate.\"\"\"\n+        dedup = MessageDeduplicator(ttl_seconds=60)\n+        assert dedup.is_duplicate(\"msg-1\") is False\n+        assert dedup.is_duplicate(\"msg-1\") is True\n+\n+    def test_not_duplicate_after_ttl_expires(self):\n+        \"\"\"Same message AFTER TTL expires should NOT be duplicate.\"\"\"\n+        dedup = MessageDeduplicator(ttl_seconds=5)\n+        assert dedup.is_duplicate(\"msg-1\") is False\n+\n+        # Fast-forward time past TTL\n+        dedup._seen[\"msg-1\"] = time.time() - 10  # 10s ago, TTL is 5s\n+        assert dedup.is_duplicate(\"msg-1\") is False, \\\n+            \"Expired entry should not be treated as duplicate\"\n+\n+    def test_expired_entry_gets_refreshed(self):\n+        \"\"\"After an expired entry is allowed through, it should be re-tracked.\"\"\"\n+        dedup = MessageDeduplicator(ttl_seconds=5)\n+        assert dedup.is_duplicate(\"msg-1\") is False\n+\n+        # Expire the entry\n+        dedup._seen[\"msg-1\"] = time.time() - 10\n+\n+        # Should be allowed through (expired)\n+        assert dedup.is_duplicate(\"msg-1\") is False\n+        # Now should be duplicate again (freshly tracked)\n+        assert dedup.is_duplicate(\"msg-1\") is True\n+\n+    def test_different_messages_not_confused(self):\n+        \"\"\"Different message IDs are independent.\"\"\"\n+        dedup = MessageDeduplicator(ttl_seconds=60)\n+        assert dedup.is_duplicate(\"msg-1\") is False\n+        assert dedup.is_duplicate(\"msg-2\") is False\n+        assert dedup.is_duplicate(\"msg-1\") is True\n+        assert dedup.is_duplicate(\"msg-2\") is True\n+\n+    def test_empty_id_never_duplicate(self):\n+        \"\"\"Empty/None message IDs are never treated as duplicate.\"\"\"\n+        dedup = MessageDeduplicator(ttl_seconds=60)\n+        assert dedup.is_duplicate(\"\") is False\n+        assert dedup.is_duplicate(\"\") is False\n+\n+    def test_max_size_eviction_prunes_expired(self):\n+        \"\"\"Cache pruning on overflow removes expired entries.\"\"\"\n+        dedup = MessageDeduplicator(max_size=5, ttl_seconds=60)\n+        # Add 6 entries, with the first 3 expired\n+        now = time.time()\n+        for i in range(3):\n+            dedup._seen[f\"old-{i}\"] = now - 120  # expired (2 min ago, TTL 60s)\n+        for i in range(3):\n+            dedup.is_duplicate(f\"new-{i}\")\n+        # Now we have 6 entries. Next insert triggers pruning.\n+        dedup.is_duplicate(\"trigger\")\n+        # The 3 expired entries should be gone, leaving 4 fresh ones\n+        assert len(dedup._seen) == 4\n+        assert \"old-0\" not in dedup._seen\n+        assert \"new-0\" in dedup._seen\n+\n+    def test_ttl_zero_means_no_dedup(self):\n+        \"\"\"With TTL=0, all entries expire immediately.\"\"\"\n+        dedup = MessageDeduplicator(ttl_seconds=0)\n+        assert dedup.is_duplicate(\"msg-1\") is False\n+        # Entry was just added at time.time(), and TTL is 0,\n+        # so now - seen_time >= 0 = ttl, meaning it's expired\n+        # But time.time() might be the exact same float, so\n+        # the check is `now - ts < ttl` which is `0 < 0` = False\n+        # This means TTL=0 effectively disables dedup\n+        assert dedup.is_duplicate(\"msg-1\") is False\n"]}]},{"kind":"pr","number":29787,"title":"fix(gateway): exempt slash commands from weixin content-fingerprint dedup (#29779)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":29855,"title":"fix(weixin): skip content dedup for slash commands like /approve","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#29787","number":29787},{"kind":"pr","value":"#29779","number":29779}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":33218,"title":"fix(weixin): skip content dedup for approval commands","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#29787","number":29787},{"kind":"pr","value":"#29787","number":29787}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":36707,"title":"fix(weixin): exempt slash commands from content-fingerprint dedup","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#35434","number":35434},{"kind":"pr","value":"#29787","number":29787},{"kind":"pr","value":"#29855","number":29855},{"kind":"pr","value":"#29779","number":29779}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":36758,"title":"fix(weixin): clear content dedup key after text-batch dispatch","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"09f10b2f700653729f327264ded1a3b071853277","number":0},{"kind":"pr","value":"#36750","number":36750}],"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":129536,"title":"fix(weixin): preserve intentional repeated slash commands","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}