# NousResearch/hermes-agent · MessageDeduplicator TTL expiry and slash-command exemption bugs

## Open now

- 2 issues, 1 PR open on GitHub.
- #29779: WeChat adapter content dedup silently drops repeated commands, breaking /approve workflow
- #36750: \[Bug\]: Weixin content dedup can drop legitimate repeated messages after the previous turn has completed
- PR #36758: fix(weixin): clear content dedup key after text-batch dispatch

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.

## TL;DR

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

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.13`: 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.

## Open related issues

- #29779: WeChat adapter content dedup silently drops repeated commands, breaking /approve workflow
- #36750: \[Bug\]: Weixin content dedup can drop legitimate repeated messages after the previous turn has completed

## Closed / duplicate issues

- #10306: \[Bug\]: MessageDeduplicator TTL never expires unless cache exceeds max_size
- #13829: MessageDeduplicator TTL never expires entries unless the cache overflows

## Prior & competing PRs

- **#10509: fix: enforce TTL in MessageDeduplicator + use yaml for gateway --config**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/helpers.py`, `gateway/run.py`, `tests/gateway/test_message_deduplicator.py`
  - fix hunks:
  - `gateway/platforms/helpers.py`

    ```diff
    @@ -49,7 +49,10 @@ def is_duplicate(self, msg_id: str) -> bool:
                 return False
             now = time.time()
             if msg_id in self._seen:
    -            return True
    +            if now - self._seen[msg_id] < self._ttl:
    +                return True
    +            # Entry has expired — remove it and treat as new
    +            del self._seen[msg_id]
             self._seen[msg_id] = now
             if len(self._seen) > self._max_size:
                 cutoff = now - self._ttl
    ```
  - `gateway/run.py`

    ```diff
    @@ -9725,9 +9725,9 @@ def main():
         
         config = None
         if args.config:
    -        import json
    +        import yaml
             with open(args.config, encoding="utf-8") as f:
    -            data = json.load(f)
    +            data = yaml.safe_load(f)
                 config = GatewayConfig.from_dict(data)
         
         # Run the gateway - exit with code 1 if no platforms connected,
    ```
  - `tests/gateway/test_message_deduplicator.py`

    ```diff
    @@ -0,0 +1,89 @@
    +"""Tests for MessageDeduplicator TTL enforcement (#10306).
    +
    +Previously, is_duplicate() returned True for any previously seen ID without
    +checking its age — expired entries were only purged when cache size exceeded
    +max_size.  Normal workloads never overflowed, so messages stayed "duplicate"
    +forever.
    +
    +The fix checks TTL at query time: if the entry's timestamp plus TTL is in
    +the past, the entry is treated as expired and the message is allowed through.
    +"""
    +
    +import time
    +from unittest.mock import patch
    +
    +from gateway.platforms.helpers import MessageDeduplicator
    +
    +
    +class TestMessageDeduplicatorTTL:
    +    """TTL-based expiration must work regardless of cache size."""
    +
    +    def test_duplicate_within_ttl(self):
    +        """Same message within TTL window is duplicate."""
    +        dedup = MessageDeduplicator(ttl_seconds=60)
    +        assert dedup.is_duplicate("msg-1") is False
    +        assert dedup.is_duplicate("msg-1") is True
    +
    +    def test_not_duplicate_after_ttl_expires(self):
    +        """Same message AFTER TTL expires should NOT be duplicate."""
    +        dedup = MessageDeduplicator(ttl_seconds=5)
    +        assert dedup.is_duplicate("msg-1") is False
    +
    +        # Fast-forward time past TTL
    +        dedup._seen["msg-1"] = time.time() - 10  # 10s ago, TTL is 5s
    +        assert dedup.is_duplicate("msg-1") is False, \
    +            "Expired entry should not be treated as duplicate"
    +
    +    def test_expired_entry_gets_refreshed(self):
    +        """After an expired entry is allowed through, it should be re-tracked."""
    +        dedup = MessageDeduplicator(ttl_seconds=5)
    +        assert dedup.is_duplicate("msg-1") is False
    +
    +        # Expire the entry
    +        dedup._seen["msg-1"] = time.time() - 10
    +
    +        # Should be allowed through (expired)
    +        assert dedup.is_duplicate("msg-1") is False
    +        # Now should be duplicate again (freshly tracked)
    +        assert dedup.is_duplicate("msg-1") is True
    +
    +    def test_different_messages_not_confused(self):
    +        """Different message IDs are independent."""
    +        dedup = MessageDeduplicator(ttl_seconds=60)
    +        assert dedup.is_duplicate("msg-1") is False
    +        assert dedup.is_duplicate("msg-2") is False
    +        assert dedup.is_duplicate("msg-1") is True
    +        assert dedup.is_duplicate("msg-2") is True
    +
    +    def test_empty_id_never_duplicate(self):
    +        """Empty/None message IDs are never treated as duplicate."""
    +        dedup = MessageDeduplicator(ttl_seconds=60)
    +        assert dedup.is_duplicate("") is False
    +        assert dedup.is_duplicate("") is False
    +
    +    def test_max_size_eviction_prunes_expired(self):
    +        """Cache pruning on overflow removes expired entries."""
    +        dedup = MessageDeduplicator(max_size=5, ttl_seconds=60)
    +        # Add 6 entries, with the first 3 expired
    +        now = time.time()
    +        for i in range(3):
    +            dedup._seen[f"old-{i}"] = now - 120  # expired (2 min ago, TTL 60s)
    +        for i in range(3):
    +            dedup.is_duplicate(f"new-{i}")
    +        # Now we have 6 entries. Next insert triggers pruning.
    +        dedup.is_duplicate("trigger")
    +        # The 3 expired entries should be gone, leaving 4 fresh ones
    +        assert len(dedup._seen) == 4
    +        assert "old-0" not in dedup._seen
    +        assert "new-0" in dedup._seen
    +
    +    def test_ttl_zero_means_no_dedup(self):
    +        """With TTL=0, all entries expire immediately."""
    +        dedup = MessageDeduplicator(ttl_seconds=0)
    +        assert dedup.is_duplicate("msg-1") is False
    +        # Entry was just added at time.time(), and TTL is 0,
    +        # so now - seen_time >= 0 = ttl, meaning it's expired
    +        # But time.time() might be the exact same float, so
    +        # the check is `now - ts < ttl` which is `0 < 0` = False
    +        # This means TTL=0 effectively disables dedup
    +        assert dedup.is_duplicate("msg-1") is False
    ```
- **#10312: fix(dedup): check TTL expiry before returning duplicate status**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#10509`
- **#10345: fix(gateway): purge MessageDeduplicator entries by TTL on every lookup**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#10509`, `#13829`
- **#29787: fix(gateway): exempt slash commands from weixin content-fingerprint dedup (#29779)**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#29855: fix(weixin): skip content dedup for slash commands like /approve**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#29787`, `#29779`
- **#33218: fix(weixin): skip content dedup for approval commands**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#29787`, `#29787`
- **#36707: fix(weixin): exempt slash commands from content-fingerprint dedup**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#35434`, `#29787`, `#29855`, `#29779`
- **#36758: fix(weixin): clear content dedup key after text-batch dispatch**. open
  - maintainer direction: category `volume_quota`; implication `active`; refs `09f10b2f700653729f327264ded1a3b071853277`, `#36750`
  - files: `gateway/platforms/weixin.py`, `tests/gateway/test_weixin.py`
- **#129536: fix(weixin): preserve intentional repeated slash commands**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`

## Fix status

- Merged to main in PR #10509 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-15`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `gateway`
