Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor

This commit is contained in:
Brooklyn Nicholson
2026-04-17 08:59:33 -05:00
126 changed files with 12584 additions and 2666 deletions

View File

@@ -258,3 +258,785 @@ class TestAgentCacheLifecycle:
cb3 = lambda *a: None
agent.tool_progress_callback = cb3
assert agent.tool_progress_callback is cb3
class TestAgentCacheBoundedGrowth:
"""LRU cap and idle-TTL eviction prevent unbounded cache growth."""
def _bounded_runner(self):
"""Runner with an OrderedDict cache (matches real gateway init)."""
from collections import OrderedDict
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner._agent_cache = OrderedDict()
runner._agent_cache_lock = threading.Lock()
return runner
def _fake_agent(self, last_activity: float | None = None):
"""Lightweight stand-in; real AIAgent is heavy to construct."""
m = MagicMock()
if last_activity is not None:
m._last_activity_ts = last_activity
else:
import time as _t
m._last_activity_ts = _t.time()
return m
def test_cap_evicts_lru_when_exceeded(self, monkeypatch):
"""Inserting past _AGENT_CACHE_MAX_SIZE pops the oldest entry."""
from gateway import run as gw_run
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 3)
runner = self._bounded_runner()
runner._cleanup_agent_resources = MagicMock()
for i in range(3):
runner._agent_cache[f"s{i}"] = (self._fake_agent(), f"sig{i}")
# Insert a 4th — oldest (s0) must be evicted.
with runner._agent_cache_lock:
runner._agent_cache["s3"] = (self._fake_agent(), "sig3")
runner._enforce_agent_cache_cap()
assert "s0" not in runner._agent_cache
assert "s3" in runner._agent_cache
assert len(runner._agent_cache) == 3
def test_cap_respects_move_to_end(self, monkeypatch):
"""Entries refreshed via move_to_end are NOT evicted as 'oldest'."""
from gateway import run as gw_run
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 3)
runner = self._bounded_runner()
runner._cleanup_agent_resources = MagicMock()
for i in range(3):
runner._agent_cache[f"s{i}"] = (self._fake_agent(), f"sig{i}")
# Touch s0 — it is now MRU, so s1 becomes LRU.
runner._agent_cache.move_to_end("s0")
with runner._agent_cache_lock:
runner._agent_cache["s3"] = (self._fake_agent(), "sig3")
runner._enforce_agent_cache_cap()
assert "s0" in runner._agent_cache # rescued by move_to_end
assert "s1" not in runner._agent_cache # now oldest → evicted
assert "s3" in runner._agent_cache
def test_cap_triggers_cleanup_thread(self, monkeypatch):
"""Evicted agent has release_clients() called for it (soft cleanup).
Uses the soft path (_release_evicted_agent_soft), NOT the hard
_cleanup_agent_resources — cache eviction must not tear down
per-task state (terminal/browser/bg procs).
"""
from gateway import run as gw_run
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 1)
runner = self._bounded_runner()
release_calls: list = []
cleanup_calls: list = []
# Intercept both paths; only release_clients path should fire.
def _soft(agent):
release_calls.append(agent)
runner._release_evicted_agent_soft = _soft
runner._cleanup_agent_resources = lambda a: cleanup_calls.append(a)
old_agent = self._fake_agent()
new_agent = self._fake_agent()
with runner._agent_cache_lock:
runner._agent_cache["old"] = (old_agent, "sig_old")
runner._agent_cache["new"] = (new_agent, "sig_new")
runner._enforce_agent_cache_cap()
# Cleanup is dispatched to a daemon thread; join briefly to observe.
import time as _t
deadline = _t.time() + 2.0
while _t.time() < deadline and not release_calls:
_t.sleep(0.02)
assert old_agent in release_calls
assert new_agent not in release_calls
# Hard-cleanup path must NOT have fired — that's for session expiry only.
assert cleanup_calls == []
def test_idle_ttl_sweep_evicts_stale_agents(self, monkeypatch):
"""_sweep_idle_cached_agents removes agents idle past the TTL."""
from gateway import run as gw_run
monkeypatch.setattr(gw_run, "_AGENT_CACHE_IDLE_TTL_SECS", 0.05)
runner = self._bounded_runner()
runner._cleanup_agent_resources = MagicMock()
import time as _t
fresh = self._fake_agent(last_activity=_t.time())
stale = self._fake_agent(last_activity=_t.time() - 10.0)
runner._agent_cache["fresh"] = (fresh, "s1")
runner._agent_cache["stale"] = (stale, "s2")
evicted = runner._sweep_idle_cached_agents()
assert evicted == 1
assert "stale" not in runner._agent_cache
assert "fresh" in runner._agent_cache
def test_idle_sweep_skips_agents_without_activity_ts(self, monkeypatch):
"""Agents missing _last_activity_ts are left alone (defensive)."""
from gateway import run as gw_run
monkeypatch.setattr(gw_run, "_AGENT_CACHE_IDLE_TTL_SECS", 0.01)
runner = self._bounded_runner()
runner._cleanup_agent_resources = MagicMock()
no_ts = MagicMock(spec=[]) # no _last_activity_ts attribute
runner._agent_cache["s"] = (no_ts, "sig")
assert runner._sweep_idle_cached_agents() == 0
assert "s" in runner._agent_cache
def test_plain_dict_cache_is_tolerated(self):
"""Test fixtures using plain {} don't crash _enforce_agent_cache_cap."""
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner._agent_cache = {} # plain dict, not OrderedDict
runner._agent_cache_lock = threading.Lock()
runner._cleanup_agent_resources = MagicMock()
# Should be a no-op rather than raising.
with runner._agent_cache_lock:
for i in range(200):
runner._agent_cache[f"s{i}"] = (MagicMock(), f"sig{i}")
runner._enforce_agent_cache_cap() # no crash, no eviction
assert len(runner._agent_cache) == 200
def test_main_lookup_updates_lru_order(self, monkeypatch):
"""Cache hit via the main-lookup path refreshes LRU position."""
runner = self._bounded_runner()
a0 = self._fake_agent()
a1 = self._fake_agent()
a2 = self._fake_agent()
runner._agent_cache["s0"] = (a0, "sig0")
runner._agent_cache["s1"] = (a1, "sig1")
runner._agent_cache["s2"] = (a2, "sig2")
# Simulate what _process_message_background does on a cache hit
# (minus the agent-state reset which isn't relevant here).
with runner._agent_cache_lock:
cached = runner._agent_cache.get("s0")
if cached and hasattr(runner._agent_cache, "move_to_end"):
runner._agent_cache.move_to_end("s0")
# After the hit, insertion order should be s1, s2, s0.
assert list(runner._agent_cache.keys()) == ["s1", "s2", "s0"]
class TestAgentCacheActiveSafety:
"""Safety: eviction must not tear down agents currently mid-turn.
AIAgent.close() kills process_registry entries for the task, cleans
the terminal sandbox, closes the OpenAI client, and cascades
.close() into active child subagents. Calling it while the agent
is still processing would crash the in-flight request. These tests
pin that eviction skips any agent present in _running_agents.
"""
def _runner(self):
from collections import OrderedDict
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner._agent_cache = OrderedDict()
runner._agent_cache_lock = threading.Lock()
runner._running_agents = {}
return runner
def _fake_agent(self, idle_seconds: float = 0.0):
import time as _t
m = MagicMock()
m._last_activity_ts = _t.time() - idle_seconds
return m
def test_cap_skips_active_lru_entry(self, monkeypatch):
"""Active LRU entry is skipped; cache stays over cap rather than
compensating by evicting a newer entry.
Rationale: evicting a more-recent entry just because the oldest
slot is temporarily locked would punish the most recently-
inserted session (which has no cache to preserve) to protect
one that happens to be mid-turn. Better to let the cache stay
transiently over cap and re-check on the next insert.
"""
from gateway import run as gw_run
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 2)
runner = self._runner()
runner._cleanup_agent_resources = MagicMock()
active = self._fake_agent()
idle_a = self._fake_agent()
idle_b = self._fake_agent()
# Insertion order: active (oldest), idle_a, idle_b.
runner._agent_cache["session-active"] = (active, "sig")
runner._agent_cache["session-idle-a"] = (idle_a, "sig")
runner._agent_cache["session-idle-b"] = (idle_b, "sig")
# Mark `active` as mid-turn — it's LRU, but protected.
runner._running_agents["session-active"] = active
with runner._agent_cache_lock:
runner._enforce_agent_cache_cap()
# All three remain; no eviction ran, no cleanup dispatched.
assert "session-active" in runner._agent_cache
assert "session-idle-a" in runner._agent_cache
assert "session-idle-b" in runner._agent_cache
assert runner._cleanup_agent_resources.call_count == 0
def test_cap_evicts_when_multiple_excess_and_some_inactive(self, monkeypatch):
"""Mixed active/idle in the LRU excess window: only the idle ones go.
With CAP=2 and 4 entries, excess=2 (the two oldest). If the
oldest is active and the next is idle, we evict exactly one.
Cache ends at CAP+1, which is still better than unbounded.
"""
from gateway import run as gw_run
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 2)
runner = self._runner()
runner._cleanup_agent_resources = MagicMock()
oldest_active = self._fake_agent()
idle_second = self._fake_agent()
idle_third = self._fake_agent()
idle_fourth = self._fake_agent()
runner._agent_cache["s1"] = (oldest_active, "sig")
runner._agent_cache["s2"] = (idle_second, "sig") # in excess window, idle
runner._agent_cache["s3"] = (idle_third, "sig")
runner._agent_cache["s4"] = (idle_fourth, "sig")
runner._running_agents["s1"] = oldest_active # oldest is mid-turn
with runner._agent_cache_lock:
runner._enforce_agent_cache_cap()
# s1 protected (active), s2 evicted (idle + in excess window),
# s3 and s4 untouched (outside excess window).
assert "s1" in runner._agent_cache
assert "s2" not in runner._agent_cache
assert "s3" in runner._agent_cache
assert "s4" in runner._agent_cache
def test_cap_leaves_cache_over_limit_if_all_active(self, monkeypatch, caplog):
"""If every over-cap entry is mid-turn, the cache stays over cap.
Better to temporarily exceed the cap than to crash an in-flight
turn by tearing down its clients.
"""
from gateway import run as gw_run
import logging as _logging
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 1)
runner = self._runner()
runner._cleanup_agent_resources = MagicMock()
a1 = self._fake_agent()
a2 = self._fake_agent()
a3 = self._fake_agent()
runner._agent_cache["s1"] = (a1, "sig")
runner._agent_cache["s2"] = (a2, "sig")
runner._agent_cache["s3"] = (a3, "sig")
# All three are mid-turn.
runner._running_agents["s1"] = a1
runner._running_agents["s2"] = a2
runner._running_agents["s3"] = a3
with caplog.at_level(_logging.WARNING, logger="gateway.run"):
with runner._agent_cache_lock:
runner._enforce_agent_cache_cap()
# Cache unchanged because eviction had to skip every candidate.
assert len(runner._agent_cache) == 3
# _cleanup_agent_resources must NOT have been scheduled.
assert runner._cleanup_agent_resources.call_count == 0
# And we logged a warning so operators can see the condition.
assert any("mid-turn" in r.message for r in caplog.records)
def test_cap_pending_sentinel_does_not_block_eviction(self, monkeypatch):
"""_AGENT_PENDING_SENTINEL in _running_agents is treated as 'not active'.
The sentinel is set while an agent is being CONSTRUCTED, before the
real AIAgent instance exists. Cached agents from other sessions
can still be evicted safely.
"""
from gateway import run as gw_run
from gateway.run import _AGENT_PENDING_SENTINEL
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 1)
runner = self._runner()
runner._cleanup_agent_resources = MagicMock()
a1 = self._fake_agent()
a2 = self._fake_agent()
runner._agent_cache["s1"] = (a1, "sig")
runner._agent_cache["s2"] = (a2, "sig")
# Another session is mid-creation — sentinel, no real agent yet.
runner._running_agents["s3-being-created"] = _AGENT_PENDING_SENTINEL
with runner._agent_cache_lock:
runner._enforce_agent_cache_cap()
assert "s1" not in runner._agent_cache # evicted normally
assert "s2" in runner._agent_cache
def test_idle_sweep_skips_active_agent(self, monkeypatch):
"""Idle-TTL sweep must not tear down an active agent even if 'stale'."""
from gateway import run as gw_run
monkeypatch.setattr(gw_run, "_AGENT_CACHE_IDLE_TTL_SECS", 0.01)
runner = self._runner()
runner._cleanup_agent_resources = MagicMock()
old_but_active = self._fake_agent(idle_seconds=10.0)
runner._agent_cache["s1"] = (old_but_active, "sig")
runner._running_agents["s1"] = old_but_active
evicted = runner._sweep_idle_cached_agents()
assert evicted == 0
assert "s1" in runner._agent_cache
assert runner._cleanup_agent_resources.call_count == 0
def test_eviction_does_not_close_active_agent_client(self, monkeypatch):
"""Live test: evicting an active agent does NOT null its .client.
This reproduces the original concern — if eviction fired while an
agent was mid-turn, `agent.close()` would set `self.client = None`
and the next API call inside the loop would crash. With the
active-agent skip, the client stays intact.
"""
from gateway import run as gw_run
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 1)
runner = self._runner()
# Build a proper fake agent whose close() matches AIAgent's contract.
active = MagicMock()
active._last_activity_ts = __import__("time").time()
active.client = MagicMock() # simulate an OpenAI client
def _real_close():
active.client = None # mirrors run_agent.py:3299
active.close = _real_close
active.shutdown_memory_provider = MagicMock()
idle = self._fake_agent()
runner._agent_cache["active-session"] = (active, "sig")
runner._agent_cache["idle-session"] = (idle, "sig")
runner._running_agents["active-session"] = active
# Real cleanup function, not mocked — we want to see whether close()
# runs on the active agent. (It shouldn't.)
with runner._agent_cache_lock:
runner._enforce_agent_cache_cap()
# Let any eviction cleanup threads drain.
import time as _t
_t.sleep(0.2)
# The ACTIVE agent's client must still be usable.
assert active.client is not None, (
"Active agent's client was closed by eviction — "
"running turn would crash on its next API call."
)
class TestAgentCacheSpilloverLive:
"""Live E2E: fill cache with real AIAgent instances and stress it."""
def _runner(self):
from collections import OrderedDict
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner._agent_cache = OrderedDict()
runner._agent_cache_lock = threading.Lock()
runner._running_agents = {}
return runner
def _real_agent(self):
"""A genuine AIAgent; no API calls are made during these tests."""
from run_agent import AIAgent
return AIAgent(
model="anthropic/claude-sonnet-4", api_key="test",
base_url="https://openrouter.ai/api/v1", provider="openrouter",
max_iterations=5, quiet_mode=True,
skip_context_files=True, skip_memory=True,
platform="telegram",
)
def test_fill_to_cap_then_spillover(self, monkeypatch):
"""Fill to cap with real agents, insert one more, oldest evicted."""
from gateway import run as gw_run
CAP = 8
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", CAP)
runner = self._runner()
agents = [self._real_agent() for _ in range(CAP)]
for i, a in enumerate(agents):
with runner._agent_cache_lock:
runner._agent_cache[f"s{i}"] = (a, "sig")
runner._enforce_agent_cache_cap()
assert len(runner._agent_cache) == CAP
# Spillover insertion.
newcomer = self._real_agent()
with runner._agent_cache_lock:
runner._agent_cache["new"] = (newcomer, "sig")
runner._enforce_agent_cache_cap()
# Oldest (s0) evicted, cap still CAP.
assert "s0" not in runner._agent_cache
assert "new" in runner._agent_cache
assert len(runner._agent_cache) == CAP
# Clean up so pytest doesn't leak resources.
for a in agents + [newcomer]:
try:
a.close()
except Exception:
pass
def test_spillover_all_active_keeps_cache_over_cap(self, monkeypatch, caplog):
"""Every slot active: cache goes over cap, no one gets torn down."""
from gateway import run as gw_run
import logging as _logging
CAP = 4
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", CAP)
runner = self._runner()
agents = [self._real_agent() for _ in range(CAP)]
for i, a in enumerate(agents):
runner._agent_cache[f"s{i}"] = (a, "sig")
runner._running_agents[f"s{i}"] = a # every session mid-turn
newcomer = self._real_agent()
with caplog.at_level(_logging.WARNING, logger="gateway.run"):
with runner._agent_cache_lock:
runner._agent_cache["new"] = (newcomer, "sig")
runner._enforce_agent_cache_cap()
assert len(runner._agent_cache) == CAP + 1 # temporarily over cap
# All existing agents still usable.
for i, a in enumerate(agents):
assert a.client is not None, f"s{i} got closed while active!"
# And we warned operators.
assert any("mid-turn" in r.message for r in caplog.records)
for a in agents + [newcomer]:
try:
a.close()
except Exception:
pass
def test_concurrent_inserts_settle_at_cap(self, monkeypatch):
"""Many threads inserting in parallel end with len(cache) == CAP."""
from gateway import run as gw_run
CAP = 16
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", CAP)
runner = self._runner()
N_THREADS = 8
PER_THREAD = 20 # 8 * 20 = 160 inserts into a 16-slot cache
def worker(tid: int):
for j in range(PER_THREAD):
a = self._real_agent()
key = f"t{tid}-s{j}"
with runner._agent_cache_lock:
runner._agent_cache[key] = (a, "sig")
runner._enforce_agent_cache_cap()
threads = [
threading.Thread(target=worker, args=(t,), daemon=True)
for t in range(N_THREADS)
]
for t in threads:
t.start()
for t in threads:
t.join(timeout=30)
assert not t.is_alive(), "Worker thread hung — possible deadlock?"
# Let daemon cleanup threads settle.
import time as _t
_t.sleep(0.5)
assert len(runner._agent_cache) == CAP, (
f"Expected exactly {CAP} entries after concurrent inserts, "
f"got {len(runner._agent_cache)}."
)
def test_evicted_session_next_turn_gets_fresh_agent(self, monkeypatch):
"""After eviction, the same session_key can insert a fresh agent.
Simulates the real spillover flow: evicted session sends another
message, which builds a new AIAgent and re-enters the cache.
"""
from gateway import run as gw_run
CAP = 2
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", CAP)
runner = self._runner()
a0 = self._real_agent()
a1 = self._real_agent()
runner._agent_cache["sA"] = (a0, "sig")
runner._agent_cache["sB"] = (a1, "sig")
# 3rd session forces sA (oldest) out.
a2 = self._real_agent()
with runner._agent_cache_lock:
runner._agent_cache["sC"] = (a2, "sig")
runner._enforce_agent_cache_cap()
assert "sA" not in runner._agent_cache
# Let the eviction cleanup thread run.
import time as _t
_t.sleep(0.3)
# Now sA's user sends another message → a fresh agent goes in.
a0_new = self._real_agent()
with runner._agent_cache_lock:
runner._agent_cache["sA"] = (a0_new, "sig")
runner._enforce_agent_cache_cap()
assert "sA" in runner._agent_cache
assert runner._agent_cache["sA"][0] is a0_new # the new one, not stale
# Fresh agent is usable.
assert a0_new.client is not None
for a in (a0, a1, a2, a0_new):
try:
a.close()
except Exception:
pass
class TestAgentCacheIdleResume:
"""End-to-end: idle-TTL-evicted session resumes cleanly with task state.
Real-world scenario: user leaves a Telegram session open for 2+ hours.
Idle-TTL evicts their cached agent. They come back and send a message.
The new agent built for the same session_id must inherit:
- Conversation history (from SessionStore — outside cache concern)
- Terminal sandbox (same task_id → same _active_environments entry)
- Browser daemon (same task_id → same browser session)
- Background processes (same task_id → same process_registry entries)
The ONLY thing that should reset is the LLM client pool (rebuilt fresh).
"""
def _runner(self):
from collections import OrderedDict
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner._agent_cache = OrderedDict()
runner._agent_cache_lock = threading.Lock()
runner._running_agents = {}
return runner
def test_release_clients_does_not_touch_process_registry(self, monkeypatch):
"""release_clients must not call process_registry.kill_all for task_id."""
from run_agent import AIAgent
agent = AIAgent(
model="anthropic/claude-sonnet-4", api_key="test",
base_url="https://openrouter.ai/api/v1", provider="openrouter",
max_iterations=5, quiet_mode=True,
skip_context_files=True, skip_memory=True,
session_id="idle-resume-test-session",
)
# Spy on process_registry.kill_all — it MUST NOT be called.
from tools import process_registry as _pr
kill_all_calls: list = []
original_kill_all = _pr.process_registry.kill_all
_pr.process_registry.kill_all = lambda **kw: kill_all_calls.append(kw)
try:
agent.release_clients()
finally:
_pr.process_registry.kill_all = original_kill_all
try:
agent.close()
except Exception:
pass
assert kill_all_calls == [], (
f"release_clients() called process_registry.kill_all — would "
f"kill user's bg processes on cache eviction. Calls: {kill_all_calls}"
)
def test_release_clients_does_not_touch_terminal_or_browser(self, monkeypatch):
"""release_clients must not call cleanup_vm or cleanup_browser."""
from run_agent import AIAgent
from tools import terminal_tool as _tt
from tools import browser_tool as _bt
agent = AIAgent(
model="anthropic/claude-sonnet-4", api_key="test",
base_url="https://openrouter.ai/api/v1", provider="openrouter",
max_iterations=5, quiet_mode=True,
skip_context_files=True, skip_memory=True,
session_id="idle-resume-test-2",
)
vm_calls: list = []
browser_calls: list = []
original_vm = _tt.cleanup_vm
original_browser = _bt.cleanup_browser
_tt.cleanup_vm = lambda tid: vm_calls.append(tid)
_bt.cleanup_browser = lambda tid: browser_calls.append(tid)
try:
agent.release_clients()
finally:
_tt.cleanup_vm = original_vm
_bt.cleanup_browser = original_browser
try:
agent.close()
except Exception:
pass
assert vm_calls == [], (
f"release_clients() tore down terminal sandbox — user's cwd, "
f"env, and bg shells would be gone on resume. Calls: {vm_calls}"
)
assert browser_calls == [], (
f"release_clients() tore down browser session — user's open "
f"tabs and cookies gone on resume. Calls: {browser_calls}"
)
def test_release_clients_closes_llm_client(self):
"""release_clients IS expected to close the OpenAI/httpx client."""
from run_agent import AIAgent
agent = AIAgent(
model="anthropic/claude-sonnet-4", api_key="test",
base_url="https://openrouter.ai/api/v1", provider="openrouter",
max_iterations=5, quiet_mode=True,
skip_context_files=True, skip_memory=True,
)
# Clients are lazy-built; force one to exist so we can verify close.
assert agent.client is not None # __init__ builds it
agent.release_clients()
# Post-release: client reference is dropped (memory freed).
assert agent.client is None
def test_close_vs_release_full_teardown_difference(self, monkeypatch):
"""close() tears down task state; release_clients() does not.
This pins the semantic contract: session-expiry path uses close()
(full teardown — session is done), cache-eviction path uses
release_clients() (soft — session may resume).
"""
from run_agent import AIAgent
from tools import terminal_tool as _tt
# Agent A: evicted from cache (soft) — terminal survives.
# Agent B: session expired (hard) — terminal torn down.
agent_a = AIAgent(
model="anthropic/claude-sonnet-4", api_key="test",
base_url="https://openrouter.ai/api/v1", provider="openrouter",
max_iterations=5, quiet_mode=True,
skip_context_files=True, skip_memory=True,
session_id="soft-session",
)
agent_b = AIAgent(
model="anthropic/claude-sonnet-4", api_key="test",
base_url="https://openrouter.ai/api/v1", provider="openrouter",
max_iterations=5, quiet_mode=True,
skip_context_files=True, skip_memory=True,
session_id="hard-session",
)
vm_calls: list = []
original_vm = _tt.cleanup_vm
_tt.cleanup_vm = lambda tid: vm_calls.append(tid)
try:
agent_a.release_clients() # cache eviction
agent_b.close() # session expiry
finally:
_tt.cleanup_vm = original_vm
try:
agent_a.close()
except Exception:
pass
# Only agent_b's task_id should appear in cleanup calls.
assert "hard-session" in vm_calls
assert "soft-session" not in vm_calls
def test_idle_evicted_session_rebuild_inherits_task_id(self, monkeypatch):
"""After idle-TTL eviction, a fresh agent with the same session_id
gets the same task_id — so tool state (terminal/browser/bg procs)
that persisted across eviction is reachable via the new agent.
"""
from gateway import run as gw_run
from run_agent import AIAgent
monkeypatch.setattr(gw_run, "_AGENT_CACHE_IDLE_TTL_SECS", 0.01)
runner = self._runner()
# Build an agent representing a stale (idle) session.
SESSION_ID = "long-lived-user-session"
old = AIAgent(
model="anthropic/claude-sonnet-4", api_key="test",
base_url="https://openrouter.ai/api/v1", provider="openrouter",
max_iterations=5, quiet_mode=True,
skip_context_files=True, skip_memory=True,
session_id=SESSION_ID,
)
old._last_activity_ts = 0.0 # force idle
runner._agent_cache["sKey"] = (old, "sig")
# Simulate the idle-TTL sweep firing.
runner._sweep_idle_cached_agents()
assert "sKey" not in runner._agent_cache
# Wait for the daemon thread doing release_clients() to finish.
import time as _t
_t.sleep(0.3)
# Old agent's client is gone (soft cleanup fired).
assert old.client is None
# User comes back — new agent built for the SAME session_id.
new_agent = AIAgent(
model="anthropic/claude-sonnet-4", api_key="test",
base_url="https://openrouter.ai/api/v1", provider="openrouter",
max_iterations=5, quiet_mode=True,
skip_context_files=True, skip_memory=True,
session_id=SESSION_ID,
)
# Same session_id means same task_id routed to tools. The new
# agent inherits any per-task state (terminal sandbox etc.) that
# was preserved across eviction.
assert new_agent.session_id == old.session_id == SESSION_ID
# And it has a fresh working client.
assert new_agent.client is not None
try:
new_agent.close()
except Exception:
pass

View File

@@ -20,11 +20,6 @@ def _make_adapter(monkeypatch, **extra):
return BlueBubblesAdapter(cfg)
class TestBlueBubblesPlatformEnum:
def test_bluebubbles_enum_exists(self):
assert Platform.BLUEBUBBLES.value == "bluebubbles"
class TestBlueBubblesConfigLoading:
def test_apply_env_overrides_bluebubbles(self, monkeypatch):
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
@@ -41,15 +36,6 @@ class TestBlueBubblesConfigLoading:
assert bc.extra["password"] == "secret"
assert bc.extra["webhook_port"] == 9999
def test_connected_platforms_includes_bluebubbles(self, monkeypatch):
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
from gateway.config import GatewayConfig, _apply_env_overrides
config = GatewayConfig()
_apply_env_overrides(config)
assert Platform.BLUEBUBBLES in config.get_connected_platforms()
def test_home_channel_set_from_env(self, monkeypatch):
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
@@ -273,29 +259,6 @@ class TestBlueBubblesGuidResolution:
assert result is None
class TestBlueBubblesToolsetIntegration:
def test_toolset_exists(self):
from toolsets import TOOLSETS
assert "hermes-bluebubbles" in TOOLSETS
def test_toolset_in_gateway_composite(self):
from toolsets import TOOLSETS
gateway = TOOLSETS["hermes-gateway"]
assert "hermes-bluebubbles" in gateway["includes"]
class TestBlueBubblesPromptHint:
def test_platform_hint_exists(self):
from agent.prompt_builder import PLATFORM_HINTS
assert "bluebubbles" in PLATFORM_HINTS
hint = PLATFORM_HINTS["bluebubbles"]
assert "iMessage" in hint
assert "plain text" in hint
class TestBlueBubblesAttachmentDownload:
"""Verify _download_attachment routes to the correct cache helper."""

View File

@@ -71,6 +71,51 @@ class TestGetConnectedPlatforms:
config = GatewayConfig()
assert config.get_connected_platforms() == []
def test_dingtalk_recognised_via_extras(self):
config = GatewayConfig(
platforms={
Platform.DINGTALK: PlatformConfig(
enabled=True,
extra={"client_id": "cid", "client_secret": "sec"},
),
},
)
assert Platform.DINGTALK in config.get_connected_platforms()
def test_dingtalk_recognised_via_env_vars(self, monkeypatch):
"""DingTalk configured via env vars (no extras) should still be
recognised as connected — covers the case where _apply_env_overrides
hasn't populated extras yet."""
monkeypatch.setenv("DINGTALK_CLIENT_ID", "env_cid")
monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "env_sec")
config = GatewayConfig(
platforms={
Platform.DINGTALK: PlatformConfig(enabled=True, extra={}),
},
)
assert Platform.DINGTALK in config.get_connected_platforms()
def test_dingtalk_missing_creds_not_connected(self, monkeypatch):
monkeypatch.delenv("DINGTALK_CLIENT_ID", raising=False)
monkeypatch.delenv("DINGTALK_CLIENT_SECRET", raising=False)
config = GatewayConfig(
platforms={
Platform.DINGTALK: PlatformConfig(enabled=True, extra={}),
},
)
assert Platform.DINGTALK not in config.get_connected_platforms()
def test_dingtalk_disabled_not_connected(self):
config = GatewayConfig(
platforms={
Platform.DINGTALK: PlatformConfig(
enabled=False,
extra={"client_id": "cid", "client_secret": "sec"},
),
},
)
assert Platform.DINGTALK not in config.get_connected_platforms()
class TestSessionResetPolicy:
def test_roundtrip(self):

View File

@@ -2,6 +2,7 @@
import asyncio
import json
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
import pytest
@@ -230,6 +231,29 @@ class TestSend:
class TestConnect:
@pytest.mark.asyncio
async def test_disconnect_closes_session_websocket(self):
from gateway.platforms.dingtalk import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
websocket = AsyncMock()
blocker = asyncio.Event()
async def _run_forever():
try:
await blocker.wait()
except asyncio.CancelledError:
return
adapter._stream_client = SimpleNamespace(websocket=websocket)
adapter._stream_task = asyncio.create_task(_run_forever())
adapter._running = True
await adapter.disconnect()
websocket.close.assert_awaited_once()
assert adapter._stream_task is None
@pytest.mark.asyncio
async def test_connect_fails_without_sdk(self, monkeypatch):
monkeypatch.setattr(
@@ -269,7 +293,391 @@ class TestConnect:
# ---------------------------------------------------------------------------
class TestPlatformEnum:
# ---------------------------------------------------------------------------
# SDK compatibility regression tests (dingtalk-stream >= 0.20 / 0.24)
# ---------------------------------------------------------------------------
class TestWebhookDomainAllowlist:
"""Guard the webhook origin allowlist against regression.
The SDK started returning reply webhooks on ``oapi.dingtalk.com`` in
addition to ``api.dingtalk.com``. Both must be accepted, and hostile
lookalikes must still be rejected (SSRF defence-in-depth).
"""
def test_api_domain_accepted(self):
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
assert _DINGTALK_WEBHOOK_RE.match(
"https://api.dingtalk.com/robot/send?access_token=x"
)
def test_oapi_domain_accepted(self):
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
assert _DINGTALK_WEBHOOK_RE.match(
"https://oapi.dingtalk.com/robot/send?access_token=x"
)
def test_http_rejected(self):
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
assert not _DINGTALK_WEBHOOK_RE.match("http://api.dingtalk.com/robot/send")
def test_suffix_attack_rejected(self):
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
assert not _DINGTALK_WEBHOOK_RE.match(
"https://api.dingtalk.com.evil.example/"
)
def test_unsanctioned_subdomain_rejected(self):
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
# Only api.* and oapi.* are allowed — e.g. eapi.dingtalk.com must not slip through
assert not _DINGTALK_WEBHOOK_RE.match("https://eapi.dingtalk.com/robot/send")
class TestHandlerProcessIsAsync:
"""dingtalk-stream >= 0.20 requires ``process`` to be a coroutine."""
def test_process_is_coroutine_function(self):
from gateway.platforms.dingtalk import _IncomingHandler
assert asyncio.iscoroutinefunction(_IncomingHandler.process)
class TestExtractText:
"""_extract_text must handle both legacy and current SDK payload shapes.
Before SDK 0.20 ``message.text`` was a ``dict`` with a ``content`` key.
From 0.20 onward it is a ``TextContent`` dataclass whose ``__str__``
returns ``"TextContent(content=...)"`` — falling back to ``str(text)``
leaks that repr into the agent's input.
"""
def test_text_as_dict_legacy(self):
from gateway.platforms.dingtalk import DingTalkAdapter
msg = MagicMock()
msg.text = {"content": "hello world"}
msg.rich_text_content = None
msg.rich_text = None
assert DingTalkAdapter._extract_text(msg) == "hello world"
def test_text_as_textcontent_object(self):
"""SDK >= 0.20 shape: object with ``.content`` attribute."""
from gateway.platforms.dingtalk import DingTalkAdapter
class FakeTextContent:
content = "hello from new sdk"
def __str__(self): # mimic real SDK repr
return f"TextContent(content={self.content})"
msg = MagicMock()
msg.text = FakeTextContent()
msg.rich_text_content = None
msg.rich_text = None
result = DingTalkAdapter._extract_text(msg)
assert result == "hello from new sdk"
assert "TextContent(" not in result
def test_text_content_attr_with_empty_string(self):
from gateway.platforms.dingtalk import DingTalkAdapter
class FakeTextContent:
content = ""
msg = MagicMock()
msg.text = FakeTextContent()
msg.rich_text_content = None
msg.rich_text = None
assert DingTalkAdapter._extract_text(msg) == ""
def test_rich_text_content_new_shape(self):
"""SDK >= 0.20 exposes rich text as ``message.rich_text_content.rich_text_list``."""
from gateway.platforms.dingtalk import DingTalkAdapter
class FakeRichText:
rich_text_list = [{"text": "hello "}, {"text": "world"}]
msg = MagicMock()
msg.text = None
msg.rich_text_content = FakeRichText()
msg.rich_text = None
result = DingTalkAdapter._extract_text(msg)
assert "hello" in result and "world" in result
def test_rich_text_legacy_shape(self):
"""Legacy ``message.rich_text`` list remains supported."""
from gateway.platforms.dingtalk import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text_content = None
msg.rich_text = [{"text": "legacy "}, {"text": "rich"}]
result = DingTalkAdapter._extract_text(msg)
assert "legacy" in result and "rich" in result
def test_empty_message(self):
from gateway.platforms.dingtalk import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text_content = None
msg.rich_text = None
assert DingTalkAdapter._extract_text(msg) == ""
# ---------------------------------------------------------------------------
# Group gating — require_mention + allowed_users (parity with other platforms)
# ---------------------------------------------------------------------------
def _make_gating_adapter(monkeypatch, *, extra=None, env=None):
"""Build a DingTalkAdapter with only the gating fields populated.
Clears every DINGTALK_* gating env var before applying the caller's
overrides so individual tests stay isolated.
"""
for key in (
"DINGTALK_REQUIRE_MENTION",
"DINGTALK_MENTION_PATTERNS",
"DINGTALK_FREE_RESPONSE_CHATS",
"DINGTALK_ALLOWED_USERS",
):
monkeypatch.delenv(key, raising=False)
for key, value in (env or {}).items():
monkeypatch.setenv(key, value)
from gateway.platforms.dingtalk import DingTalkAdapter
return DingTalkAdapter(PlatformConfig(enabled=True, extra=extra or {}))
class TestAllowedUsersGate:
def test_empty_allowlist_allows_everyone(self, monkeypatch):
adapter = _make_gating_adapter(monkeypatch)
assert adapter._is_user_allowed("anyone", "any-staff") is True
def test_wildcard_allowlist_allows_everyone(self, monkeypatch):
adapter = _make_gating_adapter(monkeypatch, extra={"allowed_users": ["*"]})
assert adapter._is_user_allowed("anyone", "any-staff") is True
def test_matches_sender_id_case_insensitive(self, monkeypatch):
adapter = _make_gating_adapter(
monkeypatch, extra={"allowed_users": ["SenderABC"]}
)
assert adapter._is_user_allowed("senderabc", "") is True
def test_matches_staff_id(self, monkeypatch):
adapter = _make_gating_adapter(
monkeypatch, extra={"allowed_users": ["staff_1234"]}
)
assert adapter._is_user_allowed("", "staff_1234") is True
def test_rejects_unknown_user(self, monkeypatch):
adapter = _make_gating_adapter(
monkeypatch, extra={"allowed_users": ["staff_1234"]}
)
assert adapter._is_user_allowed("other-sender", "other-staff") is False
def test_env_var_csv_populates_allowlist(self, monkeypatch):
adapter = _make_gating_adapter(
monkeypatch, env={"DINGTALK_ALLOWED_USERS": "alice,bob,carol"}
)
assert adapter._is_user_allowed("alice", "") is True
assert adapter._is_user_allowed("dave", "") is False
class TestMentionPatterns:
def test_empty_patterns_list(self, monkeypatch):
adapter = _make_gating_adapter(monkeypatch)
assert adapter._mention_patterns == []
assert adapter._message_matches_mention_patterns("anything") is False
def test_pattern_matches_text(self, monkeypatch):
adapter = _make_gating_adapter(
monkeypatch, extra={"mention_patterns": ["^hermes"]}
)
assert adapter._message_matches_mention_patterns("hermes please help") is True
assert adapter._message_matches_mention_patterns("please hermes help") is False
def test_pattern_is_case_insensitive(self, monkeypatch):
adapter = _make_gating_adapter(
monkeypatch, extra={"mention_patterns": ["^hermes"]}
)
assert adapter._message_matches_mention_patterns("HERMES help") is True
def test_invalid_regex_is_skipped_not_raised(self, monkeypatch):
adapter = _make_gating_adapter(
monkeypatch,
extra={"mention_patterns": ["[unclosed", "^valid"]},
)
# Invalid pattern dropped, valid one kept
assert len(adapter._mention_patterns) == 1
assert adapter._message_matches_mention_patterns("valid trigger") is True
def test_env_var_json_populates_patterns(self, monkeypatch):
adapter = _make_gating_adapter(
monkeypatch,
env={"DINGTALK_MENTION_PATTERNS": '["^bot", "^assistant"]'},
)
assert len(adapter._mention_patterns) == 2
assert adapter._message_matches_mention_patterns("bot ping") is True
def test_env_var_newline_fallback_when_not_json(self, monkeypatch):
adapter = _make_gating_adapter(
monkeypatch,
env={"DINGTALK_MENTION_PATTERNS": "^bot\n^assistant"},
)
assert len(adapter._mention_patterns) == 2
class TestShouldProcessMessage:
def test_dm_always_accepted(self, monkeypatch):
adapter = _make_gating_adapter(
monkeypatch, extra={"require_mention": True}
)
msg = MagicMock(is_in_at_list=False)
assert adapter._should_process_message(msg, "hi", is_group=False, chat_id="dm1") is True
def test_group_rejected_when_require_mention_and_no_trigger(self, monkeypatch):
adapter = _make_gating_adapter(
monkeypatch, extra={"require_mention": True}
)
msg = MagicMock(is_in_at_list=False)
assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp1") is False
def test_group_accepted_when_require_mention_disabled(self, monkeypatch):
adapter = _make_gating_adapter(
monkeypatch, extra={"require_mention": False}
)
msg = MagicMock(is_in_at_list=False)
assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp1") is True
def test_group_accepted_when_bot_is_mentioned(self, monkeypatch):
adapter = _make_gating_adapter(
monkeypatch, extra={"require_mention": True}
)
msg = MagicMock(is_in_at_list=True)
assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp1") is True
def test_group_accepted_when_text_matches_wake_word(self, monkeypatch):
adapter = _make_gating_adapter(
monkeypatch,
extra={"require_mention": True, "mention_patterns": ["^hermes"]},
)
msg = MagicMock(is_in_at_list=False)
assert adapter._should_process_message(msg, "hermes help", is_group=True, chat_id="grp1") is True
def test_group_accepted_when_chat_in_free_response_list(self, monkeypatch):
adapter = _make_gating_adapter(
monkeypatch,
extra={"require_mention": True, "free_response_chats": ["grp1"]},
)
msg = MagicMock(is_in_at_list=False)
assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp1") is True
# Different group still blocked
assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp2") is False
# ---------------------------------------------------------------------------
# _IncomingHandler.process — session_webhook extraction & fire-and-forget
# ---------------------------------------------------------------------------
class TestIncomingHandlerProcess:
"""Verify that _IncomingHandler.process correctly converts callback data
and dispatches message processing as a background task (fire-and-forget)
so the SDK ACK is returned immediately."""
@pytest.mark.asyncio
async def test_process_extracts_session_webhook(self):
"""session_webhook must be populated from callback data."""
from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
adapter._on_message = AsyncMock()
handler = _IncomingHandler(adapter, asyncio.get_running_loop())
callback = MagicMock()
callback.data = {
"msgtype": "text",
"text": {"content": "hello"},
"senderId": "user1",
"conversationId": "conv1",
"sessionWebhook": "https://oapi.dingtalk.com/robot/sendBySession?session=abc",
"msgId": "msg-001",
}
result = await handler.process(callback)
# Should return ACK immediately (STATUS_OK = 200)
assert result[0] == 200
# Let the background task run
await asyncio.sleep(0.05)
# _on_message should have been called with a ChatbotMessage
adapter._on_message.assert_called_once()
chatbot_msg = adapter._on_message.call_args[0][0]
assert chatbot_msg.session_webhook == "https://oapi.dingtalk.com/robot/sendBySession?session=abc"
@pytest.mark.asyncio
async def test_process_fallback_session_webhook_when_from_dict_misses_it(self):
"""If ChatbotMessage.from_dict does not map sessionWebhook (e.g. SDK
version mismatch), the handler should fall back to extracting it
directly from the raw data dict."""
from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
adapter._on_message = AsyncMock()
handler = _IncomingHandler(adapter, asyncio.get_running_loop())
callback = MagicMock()
# Use a key that from_dict might not recognise in some SDK versions
callback.data = {
"msgtype": "text",
"text": {"content": "hi"},
"senderId": "user2",
"conversationId": "conv2",
"session_webhook": "https://oapi.dingtalk.com/robot/sendBySession?session=def",
"msgId": "msg-002",
}
await handler.process(callback)
await asyncio.sleep(0.05)
adapter._on_message.assert_called_once()
chatbot_msg = adapter._on_message.call_args[0][0]
assert chatbot_msg.session_webhook == "https://oapi.dingtalk.com/robot/sendBySession?session=def"
@pytest.mark.asyncio
async def test_process_returns_ack_immediately(self):
"""process() must not block on _on_message — it should return
the ACK tuple before the message is fully processed."""
from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter
processing_started = asyncio.Event()
processing_gate = asyncio.Event()
async def slow_on_message(msg):
processing_started.set()
await processing_gate.wait() # Block until we release
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
adapter._on_message = slow_on_message
handler = _IncomingHandler(adapter, asyncio.get_running_loop())
callback = MagicMock()
callback.data = {
"msgtype": "text",
"text": {"content": "test"},
"senderId": "u",
"conversationId": "c",
"sessionWebhook": "https://oapi.dingtalk.com/x",
"msgId": "m",
}
# process() should return immediately even though _on_message blocks
result = await handler.process(callback)
assert result[0] == 200
# Clean up: release the gate so the background task finishes
processing_gate.set()
await asyncio.sleep(0.05)
def test_dingtalk_in_platform_enum(self):
assert Platform.DINGTALK.value == "dingtalk"

View File

@@ -0,0 +1,155 @@
"""Tests for the Discord ``allowed_mentions`` safe-default helper.
Ensures the bot defaults to blocking ``@everyone`` / ``@here`` / role pings
so an LLM response (or echoed user content) can't spam a whole server —
and that the four ``DISCORD_ALLOW_MENTION_*`` env vars correctly opt back
in when an operator explicitly wants a different policy.
"""
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
class _FakeAllowedMentions:
"""Stand-in for ``discord.AllowedMentions`` that exposes the same four
boolean flags as real attributes so the test can assert on them.
"""
def __init__(self, *, everyone=True, roles=True, users=True, replied_user=True):
self.everyone = everyone
self.roles = roles
self.users = users
self.replied_user = replied_user
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
f"AllowedMentions(everyone={self.everyone}, roles={self.roles}, "
f"users={self.users}, replied_user={self.replied_user})"
)
def _ensure_discord_mock():
"""Install (or augment) a mock ``discord`` module.
Other test modules in this directory stub ``discord`` via
``sys.modules.setdefault`` — whichever test file imports first wins and
our full module is then silently dropped. We therefore ALWAYS force
``AllowedMentions`` onto whatever is currently in ``sys.modules["discord"]``;
that's the only attribute this test file actually needs real behavior from.
"""
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
sys.modules["discord"].AllowedMentions = _FakeAllowedMentions
return
if sys.modules.get("discord") is None:
discord_mod = MagicMock()
discord_mod.Intents.default.return_value = MagicMock()
discord_mod.Client = MagicMock
discord_mod.File = MagicMock
discord_mod.DMChannel = type("DMChannel", (), {})
discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3, grey=4, secondary=5)
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
discord_mod.Interaction = object
discord_mod.Embed = MagicMock
discord_mod.app_commands = SimpleNamespace(
describe=lambda **kwargs: (lambda fn: fn),
choices=lambda **kwargs: (lambda fn: fn),
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
)
discord_mod.opus = SimpleNamespace(is_loaded=lambda: True)
ext_mod = MagicMock()
commands_mod = MagicMock()
commands_mod.Bot = MagicMock
ext_mod.commands = commands_mod
sys.modules["discord"] = discord_mod
sys.modules.setdefault("discord.ext", ext_mod)
sys.modules.setdefault("discord.ext.commands", commands_mod)
# Whether we just installed the mock OR the mock was already installed
# by another test's _ensure_discord_mock, force the AllowedMentions
# stand-in onto it — _build_allowed_mentions() reads this attribute.
sys.modules["discord"].AllowedMentions = _FakeAllowedMentions
_ensure_discord_mock()
from gateway.platforms.discord import _build_allowed_mentions # noqa: E402
# The four DISCORD_ALLOW_MENTION_* env vars that _build_allowed_mentions reads.
# Cleared before each test so env leakage from other tests never masks a regression.
_ENV_VARS = (
"DISCORD_ALLOW_MENTION_EVERYONE",
"DISCORD_ALLOW_MENTION_ROLES",
"DISCORD_ALLOW_MENTION_USERS",
"DISCORD_ALLOW_MENTION_REPLIED_USER",
)
@pytest.fixture(autouse=True)
def _clear_allowed_mention_env(monkeypatch):
for name in _ENV_VARS:
monkeypatch.delenv(name, raising=False)
def test_safe_defaults_block_everyone_and_roles():
am = _build_allowed_mentions()
assert am.everyone is False, "default must NOT allow @everyone/@here pings"
assert am.roles is False, "default must NOT allow role pings"
assert am.users is True, "default must allow user pings so replies work"
assert am.replied_user is True, "default must allow reply-reference pings"
def test_env_var_opts_back_into_everyone(monkeypatch):
monkeypatch.setenv("DISCORD_ALLOW_MENTION_EVERYONE", "true")
am = _build_allowed_mentions()
assert am.everyone is True
# other defaults unaffected
assert am.roles is False
assert am.users is True
assert am.replied_user is True
def test_env_var_can_disable_users(monkeypatch):
monkeypatch.setenv("DISCORD_ALLOW_MENTION_USERS", "false")
am = _build_allowed_mentions()
assert am.users is False
# safe defaults elsewhere remain
assert am.everyone is False
assert am.roles is False
assert am.replied_user is True
@pytest.mark.parametrize("raw, expected", [
("true", True), ("True", True), ("TRUE", True),
("1", True), ("yes", True), ("YES", True), ("on", True),
("false", False), ("False", False), ("0", False),
("no", False), ("off", False),
("", False), # empty falls back to default (False for everyone)
("garbage", False), # unknown falls back to default
(" true ", True), # whitespace tolerated
])
def test_everyone_boolean_parsing(monkeypatch, raw, expected):
monkeypatch.setenv("DISCORD_ALLOW_MENTION_EVERYONE", raw)
am = _build_allowed_mentions()
assert am.everyone is expected
def test_all_four_knobs_together(monkeypatch):
monkeypatch.setenv("DISCORD_ALLOW_MENTION_EVERYONE", "true")
monkeypatch.setenv("DISCORD_ALLOW_MENTION_ROLES", "true")
monkeypatch.setenv("DISCORD_ALLOW_MENTION_USERS", "false")
monkeypatch.setenv("DISCORD_ALLOW_MENTION_REPLIED_USER", "false")
am = _build_allowed_mentions()
assert am.everyone is True
assert am.roles is True
assert am.users is False
assert am.replied_user is False

View File

@@ -0,0 +1,360 @@
"""Tests for Discord attachment downloads via the authenticated bot session.
Covers the three download paths (image / audio / document) in
``DiscordAdapter._handle_message()`` and the shared ``_cache_discord_*``
helpers. Verifies that:
- ``att.read()`` is preferred over the legacy URL-based downloaders so
that Discord's CDN auth (and user-environment DNS quirks) can't block
media caching. (issues #8242 image 403s, #6587 CDN SSRF false-positives)
- Falls back cleanly to the SSRF-gated ``cache_*_from_url`` helpers
(image/audio) or SSRF-gated aiohttp (documents) when ``att.read()``
isn't available or fails.
- The document fallback path now runs through the SSRF gate for
defense-in-depth. (issue #11345)
"""
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import PlatformConfig
def _ensure_discord_mock():
"""Install a mock discord module when discord.py isn't available."""
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
return
discord_mod = MagicMock()
discord_mod.Intents.default.return_value = MagicMock()
discord_mod.Client = MagicMock
discord_mod.File = MagicMock
discord_mod.DMChannel = type("DMChannel", (), {})
discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
discord_mod.Interaction = object
discord_mod.Embed = MagicMock
discord_mod.app_commands = SimpleNamespace(
describe=lambda **kwargs: (lambda fn: fn),
choices=lambda **kwargs: (lambda fn: fn),
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
)
ext_mod = MagicMock()
commands_mod = MagicMock()
commands_mod.Bot = MagicMock
ext_mod.commands = commands_mod
sys.modules.setdefault("discord", discord_mod)
sys.modules.setdefault("discord.ext", ext_mod)
sys.modules.setdefault("discord.ext.commands", commands_mod)
_ensure_discord_mock()
from gateway.platforms.discord import DiscordAdapter # noqa: E402
# Minimal valid image / audio / PDF bytes so the cache_*_from_bytes
# validators accept them. cache_image_from_bytes runs _looks_like_image()
# which checks for magic bytes; PNG's magic is sufficient.
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
_OGG_BYTES = b"OggS" + b"\x00" * 60
_PDF_BYTES = b"%PDF-1.4\n" + b"fake pdf body" + b"\n%%EOF"
def _make_adapter() -> DiscordAdapter:
return DiscordAdapter(PlatformConfig(enabled=True, token="***"))
def _make_attachment_with_read(payload: bytes) -> SimpleNamespace:
"""Attachment stub that exposes .read() — the happy-path primary."""
return SimpleNamespace(
url="https://cdn.discordapp.com/attachments/fake/file.png",
filename="file.png",
size=len(payload),
read=AsyncMock(return_value=payload),
)
def _make_attachment_without_read() -> SimpleNamespace:
"""Attachment stub that has no .read() — exercises the URL fallback."""
return SimpleNamespace(
url="https://cdn.discordapp.com/attachments/fake/file.png",
filename="file.png",
size=1024,
)
# ---------------------------------------------------------------------------
# _read_attachment_bytes
# ---------------------------------------------------------------------------
class TestReadAttachmentBytes:
"""Unit tests for the low-level att.read() wrapper."""
@pytest.mark.asyncio
async def test_returns_bytes_on_successful_read(self):
adapter = _make_adapter()
att = _make_attachment_with_read(b"hello world")
result = await adapter._read_attachment_bytes(att)
assert result == b"hello world"
att.read.assert_awaited_once()
@pytest.mark.asyncio
async def test_returns_none_when_read_missing(self):
adapter = _make_adapter()
att = _make_attachment_without_read()
result = await adapter._read_attachment_bytes(att)
assert result is None
@pytest.mark.asyncio
async def test_returns_none_when_read_raises(self):
"""Bot-session fetch failures are swallowed so callers fall back."""
adapter = _make_adapter()
att = SimpleNamespace(
url="https://cdn.discordapp.com/attachments/fake/file.png",
filename="file.png",
read=AsyncMock(side_effect=RuntimeError("403 Forbidden")),
)
result = await adapter._read_attachment_bytes(att)
assert result is None
# ---------------------------------------------------------------------------
# _cache_discord_image
# ---------------------------------------------------------------------------
class TestCacheDiscordImage:
@pytest.mark.asyncio
async def test_prefers_att_read_over_url(self):
"""Primary path: att.read() bytes → cache_image_from_bytes, no URL fetch."""
adapter = _make_adapter()
att = _make_attachment_with_read(_PNG_BYTES)
with patch(
"gateway.platforms.discord.cache_image_from_bytes",
return_value="/tmp/cached.png",
) as mock_bytes, patch(
"gateway.platforms.discord.cache_image_from_url",
new_callable=AsyncMock,
) as mock_url:
result = await adapter._cache_discord_image(att, ".png")
assert result == "/tmp/cached.png"
mock_bytes.assert_called_once_with(_PNG_BYTES, ext=".png")
mock_url.assert_not_called()
@pytest.mark.asyncio
async def test_falls_back_to_url_when_no_read(self):
"""No .read() → URL path is used (existing SSRF-gated behavior)."""
adapter = _make_adapter()
att = _make_attachment_without_read()
with patch(
"gateway.platforms.discord.cache_image_from_bytes",
) as mock_bytes, patch(
"gateway.platforms.discord.cache_image_from_url",
new_callable=AsyncMock,
return_value="/tmp/from_url.png",
) as mock_url:
result = await adapter._cache_discord_image(att, ".png")
assert result == "/tmp/from_url.png"
mock_bytes.assert_not_called()
mock_url.assert_awaited_once_with(att.url, ext=".png")
@pytest.mark.asyncio
async def test_falls_back_to_url_when_bytes_validator_rejects(self):
"""If att.read() returns garbage that cache_image_from_bytes rejects
(e.g. an HTML error page), fall back to the URL downloader instead
of surfacing the validation error to the caller."""
adapter = _make_adapter()
att = _make_attachment_with_read(b"<html>forbidden</html>")
with patch(
"gateway.platforms.discord.cache_image_from_bytes",
side_effect=ValueError("not a valid image"),
), patch(
"gateway.platforms.discord.cache_image_from_url",
new_callable=AsyncMock,
return_value="/tmp/fallback.png",
) as mock_url:
result = await adapter._cache_discord_image(att, ".png")
assert result == "/tmp/fallback.png"
mock_url.assert_awaited_once()
# ---------------------------------------------------------------------------
# _cache_discord_audio
# ---------------------------------------------------------------------------
class TestCacheDiscordAudio:
@pytest.mark.asyncio
async def test_prefers_att_read_over_url(self):
adapter = _make_adapter()
att = _make_attachment_with_read(_OGG_BYTES)
with patch(
"gateway.platforms.discord.cache_audio_from_bytes",
return_value="/tmp/voice.ogg",
) as mock_bytes, patch(
"gateway.platforms.discord.cache_audio_from_url",
new_callable=AsyncMock,
) as mock_url:
result = await adapter._cache_discord_audio(att, ".ogg")
assert result == "/tmp/voice.ogg"
mock_bytes.assert_called_once_with(_OGG_BYTES, ext=".ogg")
mock_url.assert_not_called()
@pytest.mark.asyncio
async def test_falls_back_to_url_when_no_read(self):
adapter = _make_adapter()
att = _make_attachment_without_read()
with patch(
"gateway.platforms.discord.cache_audio_from_url",
new_callable=AsyncMock,
return_value="/tmp/from_url.ogg",
) as mock_url:
result = await adapter._cache_discord_audio(att, ".ogg")
assert result == "/tmp/from_url.ogg"
mock_url.assert_awaited_once_with(att.url, ext=".ogg")
# ---------------------------------------------------------------------------
# _cache_discord_document
# ---------------------------------------------------------------------------
class TestCacheDiscordDocument:
@pytest.mark.asyncio
async def test_prefers_att_read_returns_bytes_directly(self):
"""Primary path: att.read() → raw bytes, no aiohttp involvement."""
adapter = _make_adapter()
att = _make_attachment_with_read(_PDF_BYTES)
with patch("aiohttp.ClientSession") as mock_session:
result = await adapter._cache_discord_document(att, ".pdf")
assert result == _PDF_BYTES
mock_session.assert_not_called()
@pytest.mark.asyncio
async def test_fallback_blocked_by_ssrf_guard(self):
"""Document fallback path now honors is_safe_url — was missing before.
Regression guard for #11345: the old aiohttp block skipped the
SSRF check entirely; a non-CDN ``att.url`` could have reached
internal-looking hosts. The fallback must now refuse unsafe URLs.
"""
adapter = _make_adapter()
att = _make_attachment_without_read() # no .read → forces fallback
with patch(
"gateway.platforms.discord.is_safe_url", return_value=False
) as mock_safe, patch("aiohttp.ClientSession") as mock_session:
with pytest.raises(ValueError, match="SSRF"):
await adapter._cache_discord_document(att, ".pdf")
mock_safe.assert_called_once_with(att.url)
# aiohttp must NOT be contacted when the URL is blocked.
mock_session.assert_not_called()
@pytest.mark.asyncio
async def test_fallback_aiohttp_when_safe_url(self):
"""Safe URL + no att.read() → aiohttp fallback executes."""
adapter = _make_adapter()
att = _make_attachment_without_read()
# Build an aiohttp session mock that returns 200 + payload.
resp = AsyncMock()
resp.status = 200
resp.read = AsyncMock(return_value=_PDF_BYTES)
resp.__aenter__ = AsyncMock(return_value=resp)
resp.__aexit__ = AsyncMock(return_value=False)
session = AsyncMock()
session.get = MagicMock(return_value=resp)
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=False)
with patch(
"gateway.platforms.discord.is_safe_url", return_value=True
), patch("aiohttp.ClientSession", return_value=session):
result = await adapter._cache_discord_document(att, ".pdf")
assert result == _PDF_BYTES
# ---------------------------------------------------------------------------
# Integration: end-to-end via _handle_message
# ---------------------------------------------------------------------------
class TestHandleMessageUsesAuthenticatedRead:
"""E2E: verify _handle_message routes image/audio downloads through
att.read() so cdn.discordapp.com 403s (#8242) and SSRF false-positives
on mangled DNS (#6587) no longer block media caching.
"""
@pytest.mark.asyncio
async def test_image_downloads_via_att_read_not_url(self, monkeypatch):
"""Image attachments with .read() never call cache_image_from_url."""
adapter = _make_adapter()
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
adapter.handle_message = AsyncMock()
with patch(
"gateway.platforms.discord.cache_image_from_bytes",
return_value="/tmp/img_from_read.png",
), patch(
"gateway.platforms.discord.cache_image_from_url",
new_callable=AsyncMock,
) as mock_url_download:
att = SimpleNamespace(
url="https://cdn.discordapp.com/attachments/fake/file.png",
filename="file.png",
content_type="image/png",
size=len(_PNG_BYTES),
read=AsyncMock(return_value=_PNG_BYTES),
)
# Minimal Discord message stub for _handle_message.
from datetime import datetime, timezone
class _FakeDMChannel:
id = 100
name = "dm"
# Patch the DMChannel isinstance check so our fake counts as DM.
monkeypatch.setattr(
"gateway.platforms.discord.discord.DMChannel",
_FakeDMChannel,
)
chan = _FakeDMChannel()
msg = SimpleNamespace(
id=1, content="", attachments=[att], mentions=[],
reference=None,
created_at=datetime.now(timezone.utc),
channel=chan,
author=SimpleNamespace(id=42, display_name="U", name="U"),
)
await adapter._handle_message(msg)
mock_url_download.assert_not_called()
event = adapter.handle_message.call_args[0][0]
assert event.media_urls == ["/tmp/img_from_read.png"]
assert event.media_types == ["image/png"]

View File

@@ -0,0 +1,226 @@
"""Regression guard for #4466: DISCORD_ALLOW_BOTS works without DISCORD_ALLOWED_USERS.
The bug had two sequential gates both rejecting bot messages:
Gate 1 — `on_message` in gateway/platforms/discord.py ran the user-allowlist
check BEFORE the bot filter, so bot senders were dropped with a warning
before the DISCORD_ALLOW_BOTS policy was ever evaluated.
Gate 2 — `_is_user_authorized` in gateway/run.py rejected bots at the
gateway level even if they somehow reached that layer.
These tests assert both gates now pass a bot message through when
DISCORD_ALLOW_BOTS permits it AND no user allowlist entry exists.
"""
import os
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from gateway.session import Platform, SessionSource
@pytest.fixture(autouse=True)
def _isolate_discord_env(monkeypatch):
"""Make every test start with a clean Discord env so prior tests in the
session (or CI setups) can't leak DISCORD_ALLOWED_ROLES / DISCORD_ALLOWED_USERS
/ DISCORD_ALLOW_BOTS and silently flip the auth result.
"""
for var in (
"DISCORD_ALLOW_BOTS",
"DISCORD_ALLOWED_USERS",
"DISCORD_ALLOWED_ROLES",
"DISCORD_ALLOW_ALL_USERS",
"GATEWAY_ALLOW_ALL_USERS",
"GATEWAY_ALLOWED_USERS",
):
monkeypatch.delenv(var, raising=False)
# -----------------------------------------------------------------------------
# Gate 2: _is_user_authorized bypasses allowlist for permitted bots
# -----------------------------------------------------------------------------
def _make_bare_runner():
"""Build a GatewayRunner skeleton with just enough wiring for the auth test.
Uses ``object.__new__`` to skip the heavy __init__ — many gateway tests
use this pattern (see AGENTS.md pitfall #17).
"""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
# _is_user_authorized reads self.pairing_store.is_approved(...) before
# any allowlist check succeeds; stub it to never approve so we exercise
# the real allowlist path.
runner.pairing_store = SimpleNamespace(is_approved=lambda *_a, **_kw: False)
return runner
def _make_discord_bot_source(bot_id: str = "999888777"):
return SessionSource(
platform=Platform.DISCORD,
chat_id="123",
chat_type="channel",
user_id=bot_id,
user_name="SomeBot",
is_bot=True,
)
def _make_discord_human_source(user_id: str = "100200300"):
return SessionSource(
platform=Platform.DISCORD,
chat_id="123",
chat_type="channel",
user_id=user_id,
user_name="SomeHuman",
is_bot=False,
)
def test_discord_bot_authorized_when_allow_bots_mentions(monkeypatch):
"""DISCORD_ALLOW_BOTS=mentions must authorize a bot sender even when
DISCORD_ALLOWED_USERS is set and the bot's ID is NOT in it.
This is the exact scenario from #4466 — a Cloudflare Worker webhook
posts Notion events to Discord, the Hermes bot gets @mentioned, and
the webhook's bot ID is not (and shouldn't be) on the human
allowlist.
"""
runner = _make_bare_runner()
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "mentions")
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300") # human-only allowlist
source = _make_discord_bot_source(bot_id="999888777")
assert runner._is_user_authorized(source) is True
def test_discord_bot_authorized_when_allow_bots_all(monkeypatch):
"""DISCORD_ALLOW_BOTS=all is a superset of =mentions — should also bypass."""
runner = _make_bare_runner()
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
source = _make_discord_bot_source()
assert runner._is_user_authorized(source) is True
def test_discord_bot_NOT_authorized_when_allow_bots_none(monkeypatch):
"""DISCORD_ALLOW_BOTS=none (default) must still reject bots that aren't
in DISCORD_ALLOWED_USERS — preserves the original security behavior.
"""
runner = _make_bare_runner()
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "none")
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
source = _make_discord_bot_source(bot_id="999888777")
assert runner._is_user_authorized(source) is False
def test_discord_bot_NOT_authorized_when_allow_bots_unset(monkeypatch):
"""Unset DISCORD_ALLOW_BOTS must behave like 'none'."""
runner = _make_bare_runner()
monkeypatch.delenv("DISCORD_ALLOW_BOTS", raising=False)
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
source = _make_discord_bot_source(bot_id="999888777")
assert runner._is_user_authorized(source) is False
def test_discord_human_still_checked_against_allowlist_when_bot_policy_set(monkeypatch):
"""DISCORD_ALLOW_BOTS=all must NOT open the gate for humans — they
still need to be in DISCORD_ALLOWED_USERS (or a pairing approval).
"""
runner = _make_bare_runner()
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
# Human NOT on the allowlist → must be rejected.
source = _make_discord_human_source(user_id="999999999")
assert runner._is_user_authorized(source) is False
# Human ON the allowlist → accepted.
source_allowed = _make_discord_human_source(user_id="100200300")
assert runner._is_user_authorized(source_allowed) is True
def test_bot_bypass_does_not_leak_to_other_platforms(monkeypatch):
"""The is_bot bypass is Discord-specific — a Telegram bot source with
is_bot=True must NOT be authorized just because DISCORD_ALLOW_BOTS=all.
"""
runner = _make_bare_runner()
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "100200300")
telegram_bot = SessionSource(
platform=Platform.TELEGRAM,
chat_id="123",
chat_type="channel",
user_id="999888777",
is_bot=True,
)
assert runner._is_user_authorized(telegram_bot) is False
# -----------------------------------------------------------------------------
# DISCORD_ALLOWED_ROLES gateway-layer bypass (#7871)
# -----------------------------------------------------------------------------
def test_discord_role_config_bypasses_gateway_allowlist(monkeypatch):
"""When DISCORD_ALLOWED_ROLES is set, _is_user_authorized must trust
the adapter's pre-filter and authorize. Without this, role-only setups
(DISCORD_ALLOWED_ROLES populated, DISCORD_ALLOWED_USERS empty) would
hit the 'no allowlists configured' branch and get rejected.
"""
runner = _make_bare_runner()
monkeypatch.setenv("DISCORD_ALLOWED_ROLES", "1493705176387948674")
# Note: DISCORD_ALLOWED_USERS is NOT set — the entire point.
source = _make_discord_human_source(user_id="999888777")
assert runner._is_user_authorized(source) is True
def test_discord_role_config_still_authorizes_alongside_users(monkeypatch):
"""Sanity: setting both DISCORD_ALLOWED_ROLES and DISCORD_ALLOWED_USERS
doesn't break the user-id path. Users in the allowlist should still be
authorized even if they don't have a role. (OR semantics.)
"""
runner = _make_bare_runner()
monkeypatch.setenv("DISCORD_ALLOWED_ROLES", "1493705176387948674")
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
# User on the user allowlist, no role → still authorized at gateway
# level via the role bypass (adapter already approved them).
source = _make_discord_human_source(user_id="100200300")
assert runner._is_user_authorized(source) is True
def test_discord_role_bypass_does_not_leak_to_other_platforms(monkeypatch):
"""DISCORD_ALLOWED_ROLES must only affect Discord. Setting it should
not suddenly start authorizing Telegram users whose platform has its
own empty allowlist.
"""
runner = _make_bare_runner()
monkeypatch.setenv("DISCORD_ALLOWED_ROLES", "1493705176387948674")
# Telegram has its own empty allowlist and no allow-all flag.
telegram_user = SessionSource(
platform=Platform.TELEGRAM,
chat_id="123",
chat_type="channel",
user_id="999888777",
)
assert runner._is_user_authorized(telegram_user) is False

View File

@@ -8,37 +8,60 @@ import pytest
from gateway.config import PlatformConfig
class _FakeAllowedMentions:
"""Stand-in for ``discord.AllowedMentions`` — exposes the same four
boolean flags as real attributes so tests can assert on safe defaults.
"""
def __init__(self, *, everyone=True, roles=True, users=True, replied_user=True):
self.everyone = everyone
self.roles = roles
self.users = users
self.replied_user = replied_user
def _ensure_discord_mock():
"""Install (or augment) a mock ``discord`` module.
Always force ``AllowedMentions`` onto whatever is in ``sys.modules`` —
other test files also stub the module via ``setdefault``, and we need
``_build_allowed_mentions()``'s return value to have real attribute
access regardless of which file loaded first.
"""
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
sys.modules["discord"].AllowedMentions = _FakeAllowedMentions
return
discord_mod = MagicMock()
discord_mod.Intents.default.return_value = MagicMock()
discord_mod.Client = MagicMock
discord_mod.File = MagicMock
discord_mod.DMChannel = type("DMChannel", (), {})
discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3, grey=4, secondary=5)
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
discord_mod.Interaction = object
discord_mod.Embed = MagicMock
discord_mod.app_commands = SimpleNamespace(
describe=lambda **kwargs: (lambda fn: fn),
choices=lambda **kwargs: (lambda fn: fn),
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
)
discord_mod.opus = SimpleNamespace(is_loaded=lambda: True)
if sys.modules.get("discord") is None:
discord_mod = MagicMock()
discord_mod.Intents.default.return_value = MagicMock()
discord_mod.Client = MagicMock
discord_mod.File = MagicMock
discord_mod.DMChannel = type("DMChannel", (), {})
discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3, grey=4, secondary=5)
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
discord_mod.Interaction = object
discord_mod.Embed = MagicMock
discord_mod.app_commands = SimpleNamespace(
describe=lambda **kwargs: (lambda fn: fn),
choices=lambda **kwargs: (lambda fn: fn),
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
)
discord_mod.opus = SimpleNamespace(is_loaded=lambda: True)
ext_mod = MagicMock()
commands_mod = MagicMock()
commands_mod.Bot = MagicMock
ext_mod.commands = commands_mod
ext_mod = MagicMock()
commands_mod = MagicMock()
commands_mod.Bot = MagicMock
ext_mod.commands = commands_mod
sys.modules.setdefault("discord", discord_mod)
sys.modules.setdefault("discord.ext", ext_mod)
sys.modules.setdefault("discord.ext.commands", commands_mod)
sys.modules["discord"] = discord_mod
sys.modules.setdefault("discord.ext", ext_mod)
sys.modules.setdefault("discord.ext.commands", commands_mod)
sys.modules["discord"].AllowedMentions = _FakeAllowedMentions
_ensure_discord_mock()
@@ -56,8 +79,9 @@ class FakeTree:
class FakeBot:
def __init__(self, *, intents, proxy=None):
def __init__(self, *, intents, proxy=None, allowed_mentions=None, **_):
self.intents = intents
self.allowed_mentions = allowed_mentions
self.user = SimpleNamespace(id=999, name="Hermes")
self._events = {}
self.tree = FakeTree()
@@ -115,8 +139,8 @@ async def test_connect_only_requests_members_intent_when_needed(monkeypatch, all
created = {}
def fake_bot_factory(*, command_prefix, intents, proxy=None):
created["bot"] = FakeBot(intents=intents)
def fake_bot_factory(*, command_prefix, intents, proxy=None, allowed_mentions=None, **_):
created["bot"] = FakeBot(intents=intents, allowed_mentions=allowed_mentions)
return created["bot"]
monkeypatch.setattr(discord_platform.commands, "Bot", fake_bot_factory)
@@ -126,6 +150,13 @@ async def test_connect_only_requests_members_intent_when_needed(monkeypatch, all
assert ok is True
assert created["bot"].intents.members is expected_members_intent
# Safe-default AllowedMentions must be applied on every connect so the
# bot cannot @everyone from LLM output. Granular overrides live in the
# dedicated test_discord_allowed_mentions.py module.
am = created["bot"].allowed_mentions
assert am is not None, "connect() must pass an AllowedMentions to commands.Bot"
assert am.everyone is False
assert am.roles is False
await adapter.disconnect()
@@ -144,7 +175,11 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
monkeypatch.setattr(
discord_platform.commands,
"Bot",
lambda **kwargs: FakeBot(intents=kwargs["intents"], proxy=kwargs.get("proxy")),
lambda **kwargs: FakeBot(
intents=kwargs["intents"],
proxy=kwargs.get("proxy"),
allowed_mentions=kwargs.get("allowed_mentions"),
),
)
async def fake_wait_for(awaitable, timeout):
@@ -172,7 +207,7 @@ async def test_connect_does_not_wait_for_slash_sync(monkeypatch):
created = {}
def fake_bot_factory(*, command_prefix, intents, proxy=None):
def fake_bot_factory(*, command_prefix, intents, proxy=None, allowed_mentions=None, **_):
bot = SlowSyncBot(intents=intents, proxy=proxy)
created["bot"] = bot
return bot

View File

@@ -96,7 +96,7 @@ def adapter(monkeypatch):
return adapter
def make_message(*, channel, content: str, mentions=None):
def make_message(*, channel, content: str, mentions=None, msg_type=None):
author = SimpleNamespace(id=42, display_name="Jezza", name="Jezza")
return SimpleNamespace(
id=123,
@@ -107,6 +107,7 @@ def make_message(*, channel, content: str, mentions=None):
created_at=datetime.now(timezone.utc),
channel=channel,
author=author,
type=msg_type if msg_type is not None else discord_platform.discord.MessageType.default,
)
@@ -204,6 +205,21 @@ async def test_discord_free_response_channel_overrides_mention_requirement(adapt
assert event.text == "allowed without mention"
@pytest.mark.asyncio
async def test_discord_free_response_channel_can_come_from_config_extra(adapter, monkeypatch):
monkeypatch.delenv("DISCORD_REQUIRE_MENTION", raising=False)
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
adapter.config.extra["free_response_channels"] = ["789", "999"]
message = make_message(channel=FakeTextChannel(channel_id=789), content="allowed from config")
await adapter._handle_message(message)
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.text == "allowed from config"
@pytest.mark.asyncio
async def test_discord_forum_parent_in_free_response_list_allows_forum_thread(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
@@ -276,6 +292,31 @@ async def test_discord_auto_thread_enabled_by_default(adapter, monkeypatch):
assert event.source.thread_id == "999"
@pytest.mark.asyncio
async def test_discord_reply_message_skips_auto_thread(adapter, monkeypatch):
"""Quote-replies should stay in-channel instead of trying to create a thread."""
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "123")
adapter._auto_create_thread = AsyncMock()
message = make_message(
channel=FakeTextChannel(channel_id=123),
content="reply without mention",
msg_type=discord_platform.discord.MessageType.reply,
)
await adapter._handle_message(message)
adapter._auto_create_thread.assert_not_awaited()
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.text == "reply without mention"
assert event.source.chat_id == "123"
assert event.source.chat_type == "group"
@pytest.mark.asyncio
async def test_discord_auto_thread_can_be_disabled(adapter, monkeypatch):
"""Setting auto_thread to false skips thread creation."""
@@ -385,6 +426,33 @@ async def test_discord_voice_linked_channel_skips_mention_requirement_and_auto_t
assert event.source.chat_type == "group"
@pytest.mark.asyncio
async def test_discord_free_channel_skips_auto_thread(adapter, monkeypatch):
"""Free-response channels must NOT auto-create threads — bot replies inline.
Without this, every message in a free-response channel would spin off a
thread (since the channel bypasses the @mention gate), defeating the
lightweight-chat purpose of free-response mode.
"""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "789")
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False) # default true
adapter._auto_create_thread = AsyncMock()
message = make_message(
channel=FakeTextChannel(channel_id=789),
content="free chat message",
)
await adapter._handle_message(message)
adapter._auto_create_thread.assert_not_awaited()
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.source.chat_type == "group"
@pytest.mark.asyncio
async def test_discord_voice_linked_parent_thread_still_requires_mention(adapter, monkeypatch):
"""Threads under a voice-linked channel should still require @mention."""

View File

@@ -105,9 +105,14 @@ def _make_discord_adapter(reply_to_mode: str = "first"):
config = PlatformConfig(enabled=True, token="test-token", reply_to_mode=reply_to_mode)
adapter = DiscordAdapter(config)
# Mock the Discord client and channel
# Mock the Discord client and channel.
# ref_message.to_reference() → a distinct sentinel: the adapter now wraps
# the fetched Message via to_reference(fail_if_not_exists=False) so a
# deleted target degrades to "send without reply chip" instead of a 400.
mock_channel = AsyncMock()
ref_message = MagicMock()
ref_reference = MagicMock(name="MessageReference")
ref_message.to_reference = MagicMock(return_value=ref_reference)
mock_channel.fetch_message = AsyncMock(return_value=ref_message)
sent_msg = MagicMock()
@@ -118,7 +123,9 @@ def _make_discord_adapter(reply_to_mode: str = "first"):
mock_client.get_channel = MagicMock(return_value=mock_channel)
adapter._client = mock_client
return adapter, mock_channel, ref_message
# Return the reference sentinel alongside so tests can assert identity.
adapter._test_expected_reference = ref_reference
return adapter, mock_channel, ref_reference
class TestSendWithReplyToMode:

View File

@@ -48,7 +48,8 @@ from gateway.platforms.discord import DiscordAdapter # noqa: E402
async def test_send_retries_without_reference_when_reply_target_is_system_message():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
ref_msg = SimpleNamespace(id=99)
reference_obj = object()
ref_msg = SimpleNamespace(id=99, to_reference=MagicMock(return_value=reference_obj))
sent_msg = SimpleNamespace(id=1234)
send_calls = []
@@ -76,5 +77,83 @@ async def test_send_retries_without_reference_when_reply_target_is_system_messag
assert result.message_id == "1234"
assert channel.fetch_message.await_count == 1
assert channel.send.await_count == 2
assert send_calls[0]["reference"] is ref_msg
ref_msg.to_reference.assert_called_once_with(fail_if_not_exists=False)
assert send_calls[0]["reference"] is reference_obj
assert send_calls[1]["reference"] is None
@pytest.mark.asyncio
async def test_send_retries_without_reference_when_reply_target_is_deleted():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
reference_obj = object()
ref_msg = SimpleNamespace(id=99, to_reference=MagicMock(return_value=reference_obj))
sent_msgs = [SimpleNamespace(id=1001), SimpleNamespace(id=1002)]
send_calls = []
async def fake_send(*, content, reference=None):
send_calls.append({"content": content, "reference": reference})
if len(send_calls) == 1:
raise RuntimeError(
"400 Bad Request (error code: 10008): Unknown Message"
)
return sent_msgs[len(send_calls) - 2]
channel = SimpleNamespace(
fetch_message=AsyncMock(return_value=ref_msg),
send=AsyncMock(side_effect=fake_send),
)
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: channel,
fetch_channel=AsyncMock(),
)
long_text = "A" * (adapter.MAX_MESSAGE_LENGTH + 50)
result = await adapter.send("555", long_text, reply_to="99")
assert result.success is True
assert result.message_id == "1001"
assert channel.fetch_message.await_count == 1
assert channel.send.await_count == 3
ref_msg.to_reference.assert_called_once_with(fail_if_not_exists=False)
assert send_calls[0]["reference"] is reference_obj
assert send_calls[1]["reference"] is None
assert send_calls[2]["reference"] is None
@pytest.mark.asyncio
async def test_send_does_not_retry_on_unrelated_errors():
"""Regression guard: errors unrelated to the reply reference (e.g. 50013
Missing Permissions) must NOT trigger the no-reference retry path — they
should propagate out of the per-chunk loop and surface as a failed
SendResult so the caller sees the real problem instead of a silent retry.
"""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
reference_obj = object()
ref_msg = SimpleNamespace(id=99, to_reference=MagicMock(return_value=reference_obj))
send_calls = []
async def fake_send(*, content, reference=None):
send_calls.append({"content": content, "reference": reference})
raise RuntimeError(
"403 Forbidden (error code: 50013): Missing Permissions"
)
channel = SimpleNamespace(
fetch_message=AsyncMock(return_value=ref_msg),
send=AsyncMock(side_effect=fake_send),
)
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: channel,
fetch_channel=AsyncMock(),
)
result = await adapter.send("555", "hello", reply_to="99")
# Outer except in adapter.send() wraps propagated errors as SendResult.
assert result.success is False
assert "50013" in (result.error or "")
# Only the first attempt happens — no reference-retry replay.
assert channel.send.await_count == 1
assert send_calls[0]["reference"] is reference_obj

View File

@@ -11,52 +11,66 @@ from gateway.config import PlatformConfig
def _ensure_discord_mock():
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
# Real discord is installed — nothing to do.
return
discord_mod = MagicMock()
discord_mod.Intents.default.return_value = MagicMock()
discord_mod.DMChannel = type("DMChannel", (), {})
discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.Interaction = object
if sys.modules.get("discord") is None:
discord_mod = MagicMock()
discord_mod.Intents.default.return_value = MagicMock()
discord_mod.DMChannel = type("DMChannel", (), {})
discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.Interaction = object
# Lightweight mock for app_commands.Group and Command used by
# _register_skill_group.
class _FakeGroup:
def __init__(self, *, name, description, parent=None):
self.name = name
self.description = description
self.parent = parent
self._children: dict[str, object] = {}
if parent is not None:
parent.add_command(self)
# Lightweight mock for app_commands.Group and Command used by
# _register_skill_group.
class _FakeGroup:
def __init__(self, *, name, description, parent=None):
self.name = name
self.description = description
self.parent = parent
self._children: dict[str, object] = {}
if parent is not None:
parent.add_command(self)
def add_command(self, cmd):
self._children[cmd.name] = cmd
def add_command(self, cmd):
self._children[cmd.name] = cmd
class _FakeCommand:
def __init__(self, *, name, description, callback, parent=None):
self.name = name
self.description = description
self.callback = callback
self.parent = parent
class _FakeCommand:
def __init__(self, *, name, description, callback, parent=None):
self.name = name
self.description = description
self.callback = callback
self.parent = parent
discord_mod.app_commands = SimpleNamespace(
describe=lambda **kwargs: (lambda fn: fn),
choices=lambda **kwargs: (lambda fn: fn),
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
Group=_FakeGroup,
Command=_FakeCommand,
)
discord_mod.app_commands = SimpleNamespace(
describe=lambda **kwargs: (lambda fn: fn),
choices=lambda **kwargs: (lambda fn: fn),
autocomplete=lambda **kwargs: (lambda fn: fn),
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
Group=_FakeGroup,
Command=_FakeCommand,
)
ext_mod = MagicMock()
commands_mod = MagicMock()
commands_mod.Bot = MagicMock
ext_mod.commands = commands_mod
ext_mod = MagicMock()
commands_mod = MagicMock()
commands_mod.Bot = MagicMock
ext_mod.commands = commands_mod
sys.modules.setdefault("discord", discord_mod)
sys.modules.setdefault("discord.ext", ext_mod)
sys.modules.setdefault("discord.ext.commands", commands_mod)
sys.modules["discord"] = discord_mod
sys.modules.setdefault("discord.ext", ext_mod)
sys.modules.setdefault("discord.ext.commands", commands_mod)
# Whether we just installed the mock OR another test module installed
# it first via its own _ensure_discord_mock, force the decorators we
# need onto discord.app_commands — the flat /skill command uses
# @app_commands.autocomplete and not every other mock stub exposes it.
_app = getattr(sys.modules["discord"], "app_commands", None)
if _app is not None and not hasattr(_app, "autocomplete"):
try:
_app.autocomplete = lambda **kwargs: (lambda fn: fn)
except Exception:
pass
_ensure_discord_mock()
@@ -387,6 +401,8 @@ async def test_auto_create_thread_uses_message_content_as_name(adapter):
message = SimpleNamespace(
content="Hello world, how are you?",
create_thread=AsyncMock(return_value=thread),
channel=SimpleNamespace(send=AsyncMock()),
author=SimpleNamespace(display_name="Jezza"),
)
result = await adapter._auto_create_thread(message)
@@ -398,6 +414,48 @@ async def test_auto_create_thread_uses_message_content_as_name(adapter):
assert call_kwargs["auto_archive_duration"] == 1440
@pytest.mark.asyncio
async def test_auto_create_thread_strips_mention_syntax_from_name(adapter):
"""Thread names must not contain raw <@id>, <@&id>, or <#id> markers.
Regression guard for #6336 — previously a message like
``<@&1490963422786093149> help`` would spawn a thread literally
named ``<@&1490963422786093149> help``.
"""
thread = SimpleNamespace(id=999, name="help")
message = SimpleNamespace(
content="<@&1490963422786093149> <@555> please help <#123>",
create_thread=AsyncMock(return_value=thread),
channel=SimpleNamespace(send=AsyncMock()),
author=SimpleNamespace(display_name="Jezza"),
)
await adapter._auto_create_thread(message)
name = message.create_thread.await_args[1]["name"]
assert "<@" not in name, f"role/user mention leaked: {name!r}"
assert "<#" not in name, f"channel mention leaked: {name!r}"
assert name == "please help"
@pytest.mark.asyncio
async def test_auto_create_thread_falls_back_to_hermes_when_only_mentions(adapter):
"""If a message contains only mention syntax, the stripped content is
empty — fall back to the 'Hermes' default rather than ''."""
thread = SimpleNamespace(id=999, name="Hermes")
message = SimpleNamespace(
content="<@&1490963422786093149>",
create_thread=AsyncMock(return_value=thread),
channel=SimpleNamespace(send=AsyncMock()),
author=SimpleNamespace(display_name="Jezza"),
)
await adapter._auto_create_thread(message)
name = message.create_thread.await_args[1]["name"]
assert name == "Hermes"
@pytest.mark.asyncio
async def test_auto_create_thread_truncates_long_names(adapter):
long_text = "a" * 200
@@ -405,6 +463,8 @@ async def test_auto_create_thread_truncates_long_names(adapter):
message = SimpleNamespace(
content=long_text,
create_thread=AsyncMock(return_value=thread),
channel=SimpleNamespace(send=AsyncMock()),
author=SimpleNamespace(display_name="Jezza"),
)
result = await adapter._auto_create_thread(message)
@@ -416,10 +476,33 @@ async def test_auto_create_thread_truncates_long_names(adapter):
@pytest.mark.asyncio
async def test_auto_create_thread_returns_none_on_failure(adapter):
async def test_auto_create_thread_falls_back_to_seed_message(adapter):
thread = SimpleNamespace(id=555, name="Hello")
seed_message = SimpleNamespace(create_thread=AsyncMock(return_value=thread))
message = SimpleNamespace(
content="Hello",
create_thread=AsyncMock(side_effect=RuntimeError("no perms")),
channel=SimpleNamespace(send=AsyncMock(return_value=seed_message)),
author=SimpleNamespace(display_name="Jezza"),
)
result = await adapter._auto_create_thread(message)
assert result is thread
message.channel.send.assert_awaited_once_with("🧵 Thread created by Hermes: **Hello**")
seed_message.create_thread.assert_awaited_once_with(
name="Hello",
auto_archive_duration=1440,
reason="Auto-threaded from mention by Jezza",
)
@pytest.mark.asyncio
async def test_auto_create_thread_returns_none_when_direct_and_fallback_fail(adapter):
message = SimpleNamespace(
content="Hello",
create_thread=AsyncMock(side_effect=RuntimeError("no perms")),
channel=SimpleNamespace(send=AsyncMock(side_effect=RuntimeError("send failed"))),
author=SimpleNamespace(display_name="Jezza"),
)
result = await adapter._auto_create_thread(message)
@@ -599,12 +682,19 @@ def test_discord_auto_thread_config_bridge(monkeypatch, tmp_path):
# ------------------------------------------------------------------
# /skill group registration
# /skill command registration (flat + autocomplete)
# ------------------------------------------------------------------
def test_register_skill_group_creates_group(adapter):
"""_register_skill_group should register a '/skill' Group on the tree."""
def test_register_skill_command_is_flat_not_nested(adapter):
"""_register_skill_group should register a single flat ``/skill`` command.
The older layout nested categories as subcommand groups under ``/skill``.
That registered as one giant command whose serialized payload exceeded
Discord's 8KB per-command limit with the default skill catalog. The
flat layout sidesteps the limit — autocomplete options are fetched
dynamically by Discord and don't count against the registration budget.
"""
mock_categories = {
"creative": [
("ascii-art", "Generate ASCII art", "/ascii-art"),
@@ -625,22 +715,17 @@ def test_register_skill_group_creates_group(adapter):
adapter._register_slash_commands()
tree = adapter._client.tree
assert "skill" in tree.commands, "Expected /skill group to be registered"
skill_group = tree.commands["skill"]
assert skill_group.name == "skill"
# Should have 2 category subgroups + 1 uncategorized subcommand
children = skill_group._children
assert "creative" in children
assert "media" in children
assert "dogfood" in children
# Category groups should have their skills
assert "ascii-art" in children["creative"]._children
assert "excalidraw" in children["creative"]._children
assert "gif-search" in children["media"]._children
assert "skill" in tree.commands, "Expected /skill command to be registered"
skill_cmd = tree.commands["skill"]
assert skill_cmd.name == "skill"
# Flat command — NOT a Group — so it has no _children of category subgroups
assert not hasattr(skill_cmd, "_children") or not getattr(skill_cmd, "_children", {}), (
"Flat /skill command should not have subcommand children"
)
def test_register_skill_group_empty_skills_no_group(adapter):
"""No /skill group should be added when there are zero skills."""
def test_register_skill_command_empty_skills_no_command(adapter):
"""No /skill command should be registered when there are zero skills."""
with patch(
"hermes_cli.commands.discord_skill_commands_by_category",
return_value=({}, [], 0),
@@ -651,13 +736,134 @@ def test_register_skill_group_empty_skills_no_group(adapter):
assert "skill" not in tree.commands
def test_register_skill_group_handler_dispatches_command(adapter):
"""Skill subcommand handlers should dispatch the correct /cmd-key text."""
def test_register_skill_command_callback_dispatches_by_name(adapter):
"""The /skill callback should look up the skill by ``name`` and
dispatch via ``_run_simple_slash`` with the real command key.
"""
mock_categories = {
"media": [
("gif-search", "Search for GIFs", "/gif-search"),
],
}
mock_uncategorized = [
("dogfood", "QA testing", "/dogfood"),
]
with patch(
"hermes_cli.commands.discord_skill_commands_by_category",
return_value=(mock_categories, mock_uncategorized, 0),
):
adapter._register_slash_commands()
skill_cmd = adapter._client.tree.commands["skill"]
assert skill_cmd.callback is not None
# Stub out _run_simple_slash so we can verify the dispatched text.
dispatched: list[str] = []
async def fake_run(_interaction, text):
dispatched.append(text)
adapter._run_simple_slash = fake_run
import asyncio
fake_interaction = SimpleNamespace()
# gif-search → /gif-search with no args
asyncio.run(skill_cmd.callback(fake_interaction, name="gif-search"))
# dogfood with args
asyncio.run(skill_cmd.callback(fake_interaction, name="dogfood", args="my test"))
assert dispatched == ["/gif-search", "/dogfood my test"]
def test_register_skill_command_handles_unknown_skill_gracefully(adapter):
"""Passing a name that isn't a registered skill should respond with
an ephemeral error message, NOT crash the callback.
"""
with patch(
"hermes_cli.commands.discord_skill_commands_by_category",
return_value=({"media": [("gif-search", "GIFs", "/gif-search")]}, [], 0),
):
adapter._register_slash_commands()
skill_cmd = adapter._client.tree.commands["skill"]
sent: list[dict] = []
async def fake_send(text, ephemeral=False):
sent.append({"text": text, "ephemeral": ephemeral})
interaction = SimpleNamespace(
response=SimpleNamespace(send_message=fake_send),
)
import asyncio
asyncio.run(skill_cmd.callback(interaction, name="does-not-exist"))
assert len(sent) == 1
assert "Unknown skill" in sent[0]["text"]
assert "does-not-exist" in sent[0]["text"]
assert sent[0]["ephemeral"] is True
def test_register_skill_command_payload_fits_discord_8kb_limit(adapter):
"""The /skill command registration payload must stay under Discord's
~8000-byte per-command limit even with a large skill catalog.
This is the regression guard for #11321 / #10259. Simulates 500 skills
(20 categories × 25 — the hard cap per category in the collector) and
confirms the serialized command still fits. Autocomplete options are
not part of this payload, so the budget is essentially constant.
"""
import json
# Simulate the largest catalog the collector will ever produce:
# 20 categories × 25 skills each, with verbose 100-char descriptions.
large_categories: dict[str, list[tuple[str, str, str]]] = {}
long_desc = "A verbose description padded to approximately 100 chars " + "." * 42
for i in range(20):
cat = f"cat{i:02d}"
large_categories[cat] = [
(f"skill-{i:02d}-{j:02d}", long_desc, f"/skill-{i:02d}-{j:02d}")
for j in range(25)
]
with patch(
"hermes_cli.commands.discord_skill_commands_by_category",
return_value=(large_categories, [], 0),
):
adapter._register_slash_commands()
skill_cmd = adapter._client.tree.commands["skill"]
# Approximate the serialized registration payload (name + description only).
# Autocomplete options are NOT registered — they're fetched dynamically.
payload = json.dumps({
"name": skill_cmd.name,
"description": skill_cmd.description,
"options": [
{"name": "name", "description": "Which skill to run", "type": 3, "required": True},
{"name": "args", "description": "Optional arguments for the skill", "type": 3, "required": False},
],
})
assert len(payload) < 500, (
f"Flat /skill command payload is ~{len(payload)} bytes — the whole "
f"point of this design is that it stays small regardless of skill count"
)
def test_register_skill_command_autocomplete_filters_by_name_and_description(adapter):
"""The autocomplete callback should match on both skill name and
description so the user can search by either.
"""
mock_categories = {
"ocr": [
("ocr-and-documents", "Extract text from PDFs and scanned documents", "/ocr-and-documents"),
],
"media": [
("gif-search", "Search and download GIFs from Tenor", "/gif-search"),
],
}
with patch(
"hermes_cli.commands.discord_skill_commands_by_category",
@@ -665,10 +871,15 @@ def test_register_skill_group_handler_dispatches_command(adapter):
):
adapter._register_slash_commands()
skill_group = adapter._client.tree.commands["skill"]
media_group = skill_group._children["media"]
gif_cmd = media_group._children["gif-search"]
assert gif_cmd.callback is not None
# The callback name should reflect the skill
assert "gif_search" in gif_cmd.callback.__name__
skill_cmd = adapter._client.tree.commands["skill"]
# The callback has been wrapped with @autocomplete(name=...) — in our mock
# the decorator is pass-through, so we inspect the closed-over list by
# invoking the registered autocomplete function directly through the
# test API. Since the mock doesn't preserve the autocomplete binding,
# we re-derive the filter by building the same entries list.
#
# What we CAN verify at this layer: the callback dispatches correctly
# (covered in other tests). The autocomplete filter itself is exercised
# via direct function call in the real-discord integration path.
assert skill_cmd.callback is not None

View File

@@ -25,14 +25,6 @@ from unittest.mock import patch, MagicMock, AsyncMock
from gateway.platforms.base import SendResult
class TestPlatformEnum(unittest.TestCase):
"""Verify EMAIL is in the Platform enum."""
def test_email_in_platform_enum(self):
from gateway.config import Platform
self.assertEqual(Platform.EMAIL.value, "email")
class TestConfigEnvOverrides(unittest.TestCase):
"""Verify email config is loaded from environment variables."""
@@ -72,20 +64,6 @@ class TestConfigEnvOverrides(unittest.TestCase):
_apply_env_overrides(config)
self.assertNotIn(Platform.EMAIL, config.platforms)
@patch.dict(os.environ, {
"EMAIL_ADDRESS": "hermes@test.com",
"EMAIL_PASSWORD": "secret",
"EMAIL_IMAP_HOST": "imap.test.com",
"EMAIL_SMTP_HOST": "smtp.test.com",
}, clear=False)
def test_email_in_connected_platforms(self):
from gateway.config import GatewayConfig, Platform, _apply_env_overrides
config = GatewayConfig()
_apply_env_overrides(config)
connected = config.get_connected_platforms()
self.assertIn(Platform.EMAIL, connected)
class TestCheckRequirements(unittest.TestCase):
"""Verify check_email_requirements function."""
@@ -257,121 +235,6 @@ class TestExtractAttachments(unittest.TestCase):
mock_cache.assert_called_once()
class TestAuthorizationMaps(unittest.TestCase):
"""Verify email is in authorization maps in gateway/run.py."""
def test_email_in_adapter_factory(self):
"""Email adapter creation branch should exist."""
import gateway.run
import inspect
source = inspect.getsource(gateway.run.GatewayRunner._create_adapter)
self.assertIn("Platform.EMAIL", source)
def test_email_in_allowed_users_map(self):
"""EMAIL_ALLOWED_USERS should be in platform_env_map."""
import gateway.run
import inspect
source = inspect.getsource(gateway.run.GatewayRunner._is_user_authorized)
self.assertIn("EMAIL_ALLOWED_USERS", source)
def test_email_in_allow_all_map(self):
"""EMAIL_ALLOW_ALL_USERS should be in platform_allow_all_map."""
import gateway.run
import inspect
source = inspect.getsource(gateway.run.GatewayRunner._is_user_authorized)
self.assertIn("EMAIL_ALLOW_ALL_USERS", source)
class TestSendMessageToolRouting(unittest.TestCase):
"""Verify email routing in send_message_tool."""
def test_email_in_platform_map(self):
import tools.send_message_tool as smt
import inspect
source = inspect.getsource(smt._handle_send)
self.assertIn('"email"', source)
def test_send_to_platform_has_email_branch(self):
import tools.send_message_tool as smt
import inspect
source = inspect.getsource(smt._send_to_platform)
self.assertIn("Platform.EMAIL", source)
class TestCronDelivery(unittest.TestCase):
"""Verify email in cron scheduler platform_map."""
def test_email_in_cron_platform_map(self):
import cron.scheduler
import inspect
source = inspect.getsource(cron.scheduler)
self.assertIn('"email"', source)
class TestToolset(unittest.TestCase):
"""Verify email toolset is registered."""
def test_email_toolset_exists(self):
from toolsets import TOOLSETS
self.assertIn("hermes-email", TOOLSETS)
def test_email_in_gateway_toolset(self):
from toolsets import TOOLSETS
includes = TOOLSETS["hermes-gateway"]["includes"]
self.assertIn("hermes-email", includes)
class TestPlatformHints(unittest.TestCase):
"""Verify email platform hint is registered."""
def test_email_in_platform_hints(self):
from agent.prompt_builder import PLATFORM_HINTS
self.assertIn("email", PLATFORM_HINTS)
self.assertIn("email", PLATFORM_HINTS["email"].lower())
class TestChannelDirectory(unittest.TestCase):
"""Verify email in channel directory session-based discovery."""
def test_email_in_session_discovery(self):
from gateway.config import Platform
# Verify email is a Platform enum member — the dynamic loop in
# build_channel_directory iterates all Platform members, so email
# is included automatically as long as it's in the enum.
email_values = [p.value for p in Platform]
self.assertIn("email", email_values)
class TestGatewaySetup(unittest.TestCase):
"""Verify email in gateway setup wizard."""
def test_email_in_platforms_list(self):
from hermes_cli.gateway import _PLATFORMS
keys = [p["key"] for p in _PLATFORMS]
self.assertIn("email", keys)
def test_email_has_setup_vars(self):
from hermes_cli.gateway import _PLATFORMS
email_platform = next(p for p in _PLATFORMS if p["key"] == "email")
var_names = [v["name"] for v in email_platform["vars"]]
self.assertIn("EMAIL_ADDRESS", var_names)
self.assertIn("EMAIL_PASSWORD", var_names)
self.assertIn("EMAIL_IMAP_HOST", var_names)
self.assertIn("EMAIL_SMTP_HOST", var_names)
class TestEnvExample(unittest.TestCase):
"""Verify .env.example has email config."""
def test_env_example_has_email_vars(self):
env_path = Path(__file__).resolve().parents[2] / ".env.example"
content = env_path.read_text()
self.assertIn("EMAIL_ADDRESS", content)
self.assertIn("EMAIL_PASSWORD", content)
self.assertIn("EMAIL_IMAP_HOST", content)
self.assertIn("EMAIL_SMTP_HOST", content)
class TestDispatchMessage(unittest.TestCase):
"""Test email message dispatch logic."""

View File

@@ -29,13 +29,6 @@ def _mock_event_dispatcher_builder(mock_handler_class):
return mock_builder
class TestPlatformEnum(unittest.TestCase):
def test_feishu_in_platform_enum(self):
from gateway.config import Platform
self.assertEqual(Platform.FEISHU.value, "feishu")
class TestConfigEnvOverrides(unittest.TestCase):
@patch.dict(os.environ, {
"FEISHU_APP_ID": "cli_xxx",
@@ -82,24 +75,6 @@ class TestConfigEnvOverrides(unittest.TestCase):
self.assertIn(Platform.FEISHU, config.get_connected_platforms())
class TestGatewayIntegration(unittest.TestCase):
def test_feishu_in_adapter_factory(self):
source = Path("gateway/run.py").read_text(encoding="utf-8")
self.assertIn("Platform.FEISHU", source)
self.assertIn("FeishuAdapter", source)
def test_feishu_in_authorization_maps(self):
source = Path("gateway/run.py").read_text(encoding="utf-8")
self.assertIn("FEISHU_ALLOWED_USERS", source)
self.assertIn("FEISHU_ALLOW_ALL_USERS", source)
def test_feishu_toolset_exists(self):
from toolsets import TOOLSETS
self.assertIn("hermes-feishu", TOOLSETS)
self.assertIn("hermes-feishu", TOOLSETS["hermes-gateway"]["includes"])
class TestFeishuMessageNormalization(unittest.TestCase):
def test_normalize_merge_forward_preserves_summary_lines(self):
from gateway.platforms.feishu import normalize_feishu_message
@@ -472,27 +447,6 @@ class TestFeishuAdapterMessaging(unittest.TestCase):
self.assertEqual(info["type"], "group")
class TestAdapterModule(unittest.TestCase):
def test_adapter_requirement_helper_exists(self):
source = Path("gateway/platforms/feishu.py").read_text(encoding="utf-8")
self.assertIn("def check_feishu_requirements()", source)
self.assertIn("FEISHU_AVAILABLE", source)
def test_adapter_declares_websocket_scope(self):
source = Path("gateway/platforms/feishu.py").read_text(encoding="utf-8")
self.assertIn("Supported modes: websocket, webhook", source)
self.assertIn("FEISHU_CONNECTION_MODE", source)
def test_adapter_registers_message_read_noop_handler(self):
source = Path("gateway/platforms/feishu.py").read_text(encoding="utf-8")
self.assertIn("register_p2_im_message_message_read_v1", source)
self.assertIn("def _on_message_read_event", source)
def test_adapter_registers_reaction_and_card_handlers_for_websocket(self):
source = Path("gateway/platforms/feishu.py").read_text(encoding="utf-8")
self.assertIn("register_p2_im_message_reaction_created_v1", source)
self.assertIn("register_p2_im_message_reaction_deleted_v1", source)
self.assertIn("register_p2_card_action_trigger", source)
def test_load_settings_uses_sdk_defaults_for_invalid_ws_reconnect_values(self):
from gateway.platforms.feishu import FeishuAdapter
@@ -639,6 +593,14 @@ class TestAdapterBehavior(unittest.TestCase):
calls.append("bot_deleted")
return self
def register_p2_im_chat_access_event_bot_p2p_chat_entered_v1(self, _handler):
calls.append("p2p_chat_entered")
return self
def register_p2_im_message_recalled_v1(self, _handler):
calls.append("message_recalled")
return self
def build(self):
calls.append("build")
return "handler"
@@ -664,6 +626,8 @@ class TestAdapterBehavior(unittest.TestCase):
"card_action",
"bot_added",
"bot_deleted",
"p2p_chat_entered",
"message_recalled",
"build",
],
)
@@ -2536,6 +2500,152 @@ class TestAdapterBehavior(unittest.TestCase):
)
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
class TestPendingInboundQueue(unittest.TestCase):
"""Tests for the loop-not-ready race (#5499): inbound events arriving
before or during adapter loop transitions must be queued for replay
rather than silently dropped."""
@patch.dict(os.environ, {}, clear=True)
def test_event_queued_when_loop_not_ready(self):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
adapter._loop = None # Simulate "before start()" or "during reconnect"
with patch("gateway.platforms.feishu.threading.Thread") as thread_cls:
adapter._on_message_event(SimpleNamespace(tag="evt-1"))
adapter._on_message_event(SimpleNamespace(tag="evt-2"))
adapter._on_message_event(SimpleNamespace(tag="evt-3"))
# All three queued, none dropped.
self.assertEqual(len(adapter._pending_inbound_events), 3)
# Only ONE drainer thread scheduled, not one per event.
self.assertEqual(thread_cls.call_count, 1)
# Drain scheduled flag set.
self.assertTrue(adapter._pending_drain_scheduled)
@patch.dict(os.environ, {}, clear=True)
def test_drainer_replays_queued_events_when_loop_becomes_ready(self):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
adapter._loop = None
adapter._running = True
class _ReadyLoop:
def is_closed(self):
return False
# Queue three events while loop is None (simulate the race).
events = [SimpleNamespace(tag=f"evt-{i}") for i in range(3)]
with patch("gateway.platforms.feishu.threading.Thread"):
for ev in events:
adapter._on_message_event(ev)
self.assertEqual(len(adapter._pending_inbound_events), 3)
# Now the loop becomes ready; run the drainer inline (not as a thread)
# to verify it replays the queue.
adapter._loop = _ReadyLoop()
future = SimpleNamespace(add_done_callback=lambda *_a, **_kw: None)
submitted: list = []
def _submit(coro, _loop):
submitted.append(coro)
coro.close()
return future
with patch(
"gateway.platforms.feishu.asyncio.run_coroutine_threadsafe",
side_effect=_submit,
) as submit:
adapter._drain_pending_inbound_events()
# All three events dispatched to the loop.
self.assertEqual(submit.call_count, 3)
# Queue emptied.
self.assertEqual(len(adapter._pending_inbound_events), 0)
# Drain flag reset so a future race can schedule a new drainer.
self.assertFalse(adapter._pending_drain_scheduled)
@patch.dict(os.environ, {}, clear=True)
def test_drainer_drops_queue_when_adapter_shuts_down(self):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
adapter._loop = None
adapter._running = False # Shutdown state
with patch("gateway.platforms.feishu.threading.Thread"):
adapter._on_message_event(SimpleNamespace(tag="evt-lost"))
self.assertEqual(len(adapter._pending_inbound_events), 1)
# Drainer should drop the queue immediately since _running is False.
adapter._drain_pending_inbound_events()
self.assertEqual(len(adapter._pending_inbound_events), 0)
self.assertFalse(adapter._pending_drain_scheduled)
@patch.dict(os.environ, {}, clear=True)
def test_queue_cap_evicts_oldest_beyond_max_depth(self):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
adapter._loop = None
adapter._pending_inbound_max_depth = 3 # Shrink for test
with patch("gateway.platforms.feishu.threading.Thread"):
for i in range(5):
adapter._on_message_event(SimpleNamespace(tag=f"evt-{i}"))
# Only the last 3 should remain; evt-0 and evt-1 dropped.
self.assertEqual(len(adapter._pending_inbound_events), 3)
tags = [getattr(e, "tag", None) for e in adapter._pending_inbound_events]
self.assertEqual(tags, ["evt-2", "evt-3", "evt-4"])
@patch.dict(os.environ, {}, clear=True)
def test_normal_path_unchanged_when_loop_ready(self):
"""When the loop is ready, events should dispatch directly without
ever touching the pending queue."""
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
class _ReadyLoop:
def is_closed(self):
return False
adapter._loop = _ReadyLoop()
future = SimpleNamespace(add_done_callback=lambda *_a, **_kw: None)
def _submit(coro, _loop):
coro.close()
return future
with patch(
"gateway.platforms.feishu.asyncio.run_coroutine_threadsafe",
side_effect=_submit,
) as submit, patch(
"gateway.platforms.feishu.threading.Thread"
) as thread_cls:
adapter._on_message_event(SimpleNamespace(tag="evt"))
self.assertEqual(submit.call_count, 1)
self.assertEqual(len(adapter._pending_inbound_events), 0)
self.assertFalse(adapter._pending_drain_scheduled)
# No drainer thread spawned when the happy path runs.
self.assertEqual(thread_cls.call_count, 0)
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
class TestWebhookSecurity(unittest.TestCase):
"""Tests for webhook signature verification, rate limiting, and body size limits."""

View File

@@ -469,18 +469,6 @@ class TestConfigIntegration:
assert ha.extra["watch_domains"] == ["climate"]
assert ha.extra["cooldown_seconds"] == 45
def test_connected_platforms_includes_ha(self):
config = GatewayConfig(
platforms={
Platform.HOMEASSISTANT: PlatformConfig(enabled=True, token="tok"),
Platform.TELEGRAM: PlatformConfig(enabled=False, token="t"),
},
)
connected = config.get_connected_platforms()
assert Platform.HOMEASSISTANT in connected
assert Platform.TELEGRAM not in connected
# ---------------------------------------------------------------------------
# send() via REST API
# ---------------------------------------------------------------------------
@@ -582,27 +570,6 @@ class TestSendViaRestApi:
# ---------------------------------------------------------------------------
class TestToolsetIntegration:
def test_homeassistant_toolset_resolves(self):
from toolsets import resolve_toolset
tools = resolve_toolset("homeassistant")
assert set(tools) == {"ha_list_entities", "ha_get_state", "ha_call_service", "ha_list_services"}
def test_gateway_toolset_includes_ha_tools(self):
from toolsets import resolve_toolset
gateway_tools = resolve_toolset("hermes-gateway")
for tool in ("ha_list_entities", "ha_get_state", "ha_call_service", "ha_list_services"):
assert tool in gateway_tools
def test_hermes_core_tools_includes_ha(self):
from toolsets import _HERMES_CORE_TOOLS
for tool in ("ha_list_entities", "ha_get_state", "ha_call_service", "ha_list_services"):
assert tool in _HERMES_CORE_TOOLS
# ---------------------------------------------------------------------------
# WebSocket URL construction
# ---------------------------------------------------------------------------

View File

@@ -239,15 +239,6 @@ def _make_fake_mautrix():
# Platform & Config
# ---------------------------------------------------------------------------
class TestMatrixPlatformEnum:
def test_matrix_enum_exists(self):
assert Platform.MATRIX.value == "matrix"
def test_matrix_in_platform_list(self):
platforms = [p.value for p in Platform]
assert "matrix" in platforms
class TestMatrixConfigLoading:
def test_apply_env_overrides_with_access_token(self, monkeypatch):
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_abc123")

View File

@@ -184,8 +184,14 @@ class TestMatrixVoiceMessageDetection:
f"Expected MessageType.AUDIO for non-voice, got {captured_event.message_type}"
@pytest.mark.asyncio
async def test_regular_audio_has_http_url(self):
"""Regular audio uploads should keep HTTP URL (not cached locally)."""
async def test_regular_audio_is_cached_locally(self):
"""Regular audio uploads are cached locally for downstream tool access.
Since PR #bec02f37 (encrypted-media caching refactor), all media
types — photo, audio, video, document — are cached locally when
received so tools can read them as real files. This applies equally
to voice messages and regular audio.
"""
event = _make_audio_event(is_voice=False)
captured_event = None
@@ -200,10 +206,10 @@ class TestMatrixVoiceMessageDetection:
assert captured_event is not None
assert captured_event.media_urls is not None
# Should be HTTP URL, not local path
assert captured_event.media_urls[0].startswith("http"), \
f"Non-voice audio should have HTTP URL, got {captured_event.media_urls[0]}"
self.adapter._client.download_media.assert_not_awaited()
# Should be a local path, not an HTTP URL.
assert not captured_event.media_urls[0].startswith("http"), \
f"Regular audio should be cached locally, got {captured_event.media_urls[0]}"
self.adapter._client.download_media.assert_awaited_once()
assert captured_event.media_types == ["audio/ogg"]

View File

@@ -12,15 +12,6 @@ from gateway.config import Platform, PlatformConfig
# Platform & Config
# ---------------------------------------------------------------------------
class TestMattermostPlatformEnum:
def test_mattermost_enum_exists(self):
assert Platform.MATTERMOST.value == "mattermost"
def test_mattermost_in_platform_list(self):
platforms = [p.value for p in Platform]
assert "mattermost" in platforms
class TestMattermostConfigLoading:
def test_apply_env_overrides_mattermost(self, monkeypatch):
monkeypatch.setenv("MATTERMOST_TOKEN", "mm-tok-abc123")
@@ -46,17 +37,6 @@ class TestMattermostConfigLoading:
assert Platform.MATTERMOST not in config.platforms
def test_connected_platforms_includes_mattermost(self, monkeypatch):
monkeypatch.setenv("MATTERMOST_TOKEN", "mm-tok-abc123")
monkeypatch.setenv("MATTERMOST_URL", "https://mm.example.com")
from gateway.config import GatewayConfig, _apply_env_overrides
config = GatewayConfig()
_apply_env_overrides(config)
connected = config.get_connected_platforms()
assert Platform.MATTERMOST in connected
def test_mattermost_home_channel(self, monkeypatch):
monkeypatch.setenv("MATTERMOST_TOKEN", "mm-tok-abc123")
monkeypatch.setenv("MATTERMOST_URL", "https://mm.example.com")

View File

@@ -1,5 +1,6 @@
"""Tests for the QQ Bot platform adapter."""
import asyncio
import json
import os
import sys
@@ -149,6 +150,47 @@ class TestIsVoiceContentType:
assert self._fn("", "recording.amr") is True
# ---------------------------------------------------------------------------
# Voice attachment SSRF protection
# ---------------------------------------------------------------------------
class TestVoiceAttachmentSSRFProtection:
def _make_adapter(self, **extra):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra))
def test_stt_blocks_unsafe_download_url(self):
adapter = self._make_adapter(app_id="a", client_secret="b")
adapter._http_client = mock.AsyncMock()
with mock.patch("tools.url_safety.is_safe_url", return_value=False):
transcript = asyncio.run(
adapter._stt_voice_attachment(
"http://127.0.0.1/voice.silk",
"audio/silk",
"voice.silk",
)
)
assert transcript is None
adapter._http_client.get.assert_not_called()
def test_connect_uses_redirect_guard_hook(self):
from gateway.platforms.qqbot import QQAdapter, _ssrf_redirect_guard
client = mock.AsyncMock()
with mock.patch("gateway.platforms.qqbot.httpx.AsyncClient", return_value=client) as async_client_cls:
adapter = QQAdapter(_make_config(app_id="a", client_secret="b"))
adapter._ensure_token = mock.AsyncMock(side_effect=RuntimeError("stop after client creation"))
connected = asyncio.run(adapter.connect())
assert connected is False
assert async_client_cls.call_count == 1
kwargs = async_client_cls.call_args.kwargs
assert kwargs.get("follow_redirects") is True
assert kwargs.get("event_hooks", {}).get("response") == [_ssrf_redirect_guard]
# ---------------------------------------------------------------------------
# _strip_at_mention
# ---------------------------------------------------------------------------
@@ -458,3 +500,85 @@ class TestBuildTextBody:
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=False)
body = adapter._build_text_body("reply text", reply_to="msg_123")
assert body.get("message_reference", {}).get("message_id") == "msg_123"
# ---------------------------------------------------------------------------
# _wait_for_reconnection / send reconnection wait
# ---------------------------------------------------------------------------
class TestWaitForReconnection:
"""Test that send() waits for reconnection instead of silently dropping."""
def _make_adapter(self, **extra):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra))
@pytest.mark.asyncio
async def test_send_waits_and_succeeds_on_reconnect(self):
"""send() should wait for reconnection and then deliver the message."""
adapter = self._make_adapter(app_id="a", client_secret="b")
# Initially disconnected
adapter._running = False
adapter._http_client = mock.MagicMock()
# Simulate reconnection after 0.3s (faster than real interval)
async def fake_api_request(*args, **kwargs):
return {"id": "msg_123"}
adapter._api_request = fake_api_request
adapter._ensure_token = mock.AsyncMock()
adapter._RECONNECT_POLL_INTERVAL = 0.1
adapter._RECONNECT_WAIT_SECONDS = 5.0
# Schedule reconnection after a short delay
async def reconnect_after_delay():
await asyncio.sleep(0.3)
adapter._running = True
asyncio.get_event_loop().create_task(reconnect_after_delay())
result = await adapter.send("test_openid", "Hello, world!")
assert result.success
assert result.message_id == "msg_123"
@pytest.mark.asyncio
async def test_send_returns_retryable_after_timeout(self):
"""send() should return retryable=True if reconnection takes too long."""
adapter = self._make_adapter(app_id="a", client_secret="b")
adapter._running = False
adapter._RECONNECT_POLL_INTERVAL = 0.05
adapter._RECONNECT_WAIT_SECONDS = 0.2
result = await adapter.send("test_openid", "Hello, world!")
assert not result.success
assert result.retryable is True
assert "Not connected" in result.error
@pytest.mark.asyncio
async def test_send_succeeds_immediately_when_connected(self):
"""send() should not wait when already connected."""
adapter = self._make_adapter(app_id="a", client_secret="b")
adapter._running = True
adapter._http_client = mock.MagicMock()
async def fake_api_request(*args, **kwargs):
return {"id": "msg_immediate"}
adapter._api_request = fake_api_request
result = await adapter.send("test_openid", "Hello!")
assert result.success
assert result.message_id == "msg_immediate"
@pytest.mark.asyncio
async def test_send_media_waits_for_reconnect(self):
"""_send_media should also wait for reconnection."""
adapter = self._make_adapter(app_id="a", client_secret="b")
adapter._running = False
adapter._RECONNECT_POLL_INTERVAL = 0.05
adapter._RECONNECT_WAIT_SECONDS = 0.2
result = await adapter._send_media("test_openid", "http://example.com/img.jpg", 1, "image")
assert not result.success
assert result.retryable is True
assert "Not connected" in result.error

View File

@@ -42,15 +42,6 @@ def _stub_rpc(return_value):
# Platform & Config
# ---------------------------------------------------------------------------
class TestSignalPlatformEnum:
def test_signal_enum_exists(self):
assert Platform.SIGNAL.value == "signal"
def test_signal_in_platform_list(self):
platforms = [p.value for p in Platform]
assert "signal" in platforms
class TestSignalConfigLoading:
def test_apply_env_overrides_signal(self, monkeypatch):
monkeypatch.setenv("SIGNAL_HTTP_URL", "http://localhost:9090")
@@ -76,18 +67,6 @@ class TestSignalConfigLoading:
assert Platform.SIGNAL not in config.platforms
def test_connected_platforms_includes_signal(self, monkeypatch):
monkeypatch.setenv("SIGNAL_HTTP_URL", "http://localhost:8080")
monkeypatch.setenv("SIGNAL_ACCOUNT", "+15551234567")
from gateway.config import GatewayConfig, _apply_env_overrides
config = GatewayConfig()
_apply_env_overrides(config)
connected = config.get_connected_platforms()
assert Platform.SIGNAL in connected
# ---------------------------------------------------------------------------
# Adapter Init & Helpers
# ---------------------------------------------------------------------------
@@ -362,15 +341,6 @@ class TestSignalAuthorization:
# Send Message Tool
# ---------------------------------------------------------------------------
class TestSignalSendMessage:
def test_signal_in_platform_map(self):
"""Signal should be in the send_message tool's platform map."""
from tools.send_message_tool import send_message_tool
# Just verify the import works and Signal is a valid platform
from gateway.config import Platform
assert Platform.SIGNAL.value == "signal"
# ---------------------------------------------------------------------------
# send_image_file method (#5105)
# ---------------------------------------------------------------------------

View File

@@ -20,9 +20,6 @@ from gateway.config import Platform, PlatformConfig, HomeChannel
class TestSmsConfigLoading:
"""Verify _apply_env_overrides wires SMS correctly."""
def test_sms_platform_enum_exists(self):
assert Platform.SMS.value == "sms"
def test_env_overrides_create_sms_config(self):
from gateway.config import load_gateway_config
@@ -56,19 +53,6 @@ class TestSmsConfigLoading:
assert hc.name == "My Phone"
assert hc.platform == Platform.SMS
def test_sms_in_connected_platforms(self):
from gateway.config import load_gateway_config
env = {
"TWILIO_ACCOUNT_SID": "ACtest123",
"TWILIO_AUTH_TOKEN": "token_abc",
}
with patch.dict(os.environ, env, clear=False):
config = load_gateway_config()
connected = config.get_connected_platforms()
assert Platform.SMS in connected
# ── Format / truncate ───────────────────────────────────────────────
class TestSmsFormatAndTruncate:
@@ -180,44 +164,6 @@ class TestSmsRequirements:
# ── Toolset verification ───────────────────────────────────────────
class TestSmsToolset:
def test_hermes_sms_toolset_exists(self):
from toolsets import get_toolset
ts = get_toolset("hermes-sms")
assert ts is not None
assert "tools" in ts
def test_hermes_sms_in_gateway_includes(self):
from toolsets import get_toolset
gw = get_toolset("hermes-gateway")
assert gw is not None
assert "hermes-sms" in gw["includes"]
def test_sms_platform_hint_exists(self):
from agent.prompt_builder import PLATFORM_HINTS
assert "sms" in PLATFORM_HINTS
assert "concise" in PLATFORM_HINTS["sms"].lower()
def test_sms_in_scheduler_platform_map(self):
"""Verify cron scheduler recognizes 'sms' as a valid platform."""
# Just check the Platform enum has SMS — the scheduler imports it dynamically
assert Platform.SMS.value == "sms"
def test_sms_in_send_message_platform_map(self):
"""Verify send_message_tool recognizes 'sms'."""
# The platform_map is built inside _handle_send; verify SMS enum exists
assert hasattr(Platform, "SMS")
def test_sms_in_cronjob_deliver_description(self):
"""Verify cronjob_tools mentions sms in deliver description."""
from tools.cronjob_tools import CRONJOB_SCHEMA
deliver_desc = CRONJOB_SCHEMA["parameters"]["properties"]["deliver"]["description"]
assert "sms" in deliver_desc.lower()
# ── Webhook host configuration ─────────────────────────────────────
class TestWebhookHostConfig:

View File

@@ -21,6 +21,7 @@ def _clear_auth_env(monkeypatch) -> None:
"MATTERMOST_ALLOWED_USERS",
"MATRIX_ALLOWED_USERS",
"DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", "WECOM_ALLOWED_USERS",
"QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS",
"GATEWAY_ALLOWED_USERS",
"TELEGRAM_ALLOW_ALL_USERS",
"DISCORD_ALLOW_ALL_USERS",
@@ -32,6 +33,7 @@ def _clear_auth_env(monkeypatch) -> None:
"MATTERMOST_ALLOW_ALL_USERS",
"MATRIX_ALLOW_ALL_USERS",
"DINGTALK_ALLOW_ALL_USERS", "FEISHU_ALLOW_ALL_USERS", "WECOM_ALLOW_ALL_USERS",
"QQ_ALLOW_ALL_USERS",
"GATEWAY_ALLOW_ALL_USERS",
):
monkeypatch.delenv(key, raising=False)
@@ -130,6 +132,46 @@ def test_star_wildcard_works_for_any_platform(monkeypatch):
assert runner._is_user_authorized(source) is True
def test_qq_group_allowlist_authorizes_group_chat_without_user_allowlist(monkeypatch):
_clear_auth_env(monkeypatch)
monkeypatch.setenv("QQ_GROUP_ALLOWED_USERS", "group-openid-1")
runner, _adapter = _make_runner(
Platform.QQBOT,
GatewayConfig(platforms={Platform.QQBOT: PlatformConfig(enabled=True)}),
)
source = SessionSource(
platform=Platform.QQBOT,
user_id="member-openid-999",
chat_id="group-openid-1",
user_name="tester",
chat_type="group",
)
assert runner._is_user_authorized(source) is True
def test_qq_group_allowlist_does_not_authorize_other_groups(monkeypatch):
_clear_auth_env(monkeypatch)
monkeypatch.setenv("QQ_GROUP_ALLOWED_USERS", "group-openid-1")
runner, _adapter = _make_runner(
Platform.QQBOT,
GatewayConfig(platforms={Platform.QQBOT: PlatformConfig(enabled=True)}),
)
source = SessionSource(
platform=Platform.QQBOT,
user_id="member-openid-999",
chat_id="group-openid-2",
user_name="tester",
chat_type="group",
)
assert runner._is_user_authorized(source) is False
@pytest.mark.asyncio
async def test_unauthorized_dm_pairs_by_default(monkeypatch):
_clear_auth_env(monkeypatch)

View File

@@ -593,7 +593,3 @@ class TestInboundMessages:
await adapter._on_message(payload)
adapter.handle_message.assert_not_awaited()
class TestPlatformEnum:
def test_wecom_in_platform_enum(self):
assert Platform.WECOM.value == "wecom"

View File

@@ -1,12 +1,15 @@
"""Tests for the Weixin platform adapter."""
import asyncio
import base64
import json
import os
from pathlib import Path
from unittest.mock import AsyncMock, patch
from gateway.config import PlatformConfig
from gateway.config import GatewayConfig, HomeChannel, Platform, _apply_env_overrides
from gateway.platforms.base import SendResult
from gateway.platforms import weixin
from gateway.platforms.weixin import ContextTokenStore, WeixinAdapter
from tools.send_message_tool import _parse_target_ref, _send_to_platform
@@ -23,17 +26,14 @@ def _make_adapter() -> WeixinAdapter:
class TestWeixinFormatting:
def test_format_message_preserves_markdown_and_rewrites_headers(self):
def test_format_message_preserves_markdown(self):
adapter = _make_adapter()
content = "# Title\n\n## Plan\n\nUse **bold** and [docs](https://example.com)."
assert (
adapter.format_message(content)
== "【Title】\n\n**Plan**\n\nUse **bold** and docs (https://example.com)."
)
assert adapter.format_message(content) == content
def test_format_message_rewrites_markdown_tables(self):
def test_format_message_preserves_markdown_tables(self):
adapter = _make_adapter()
content = (
@@ -43,19 +43,14 @@ class TestWeixinFormatting:
"| Retries | 3 |\n"
)
assert adapter.format_message(content) == (
"- Setting: Timeout\n"
" Value: 30s\n"
"- Setting: Retries\n"
" Value: 3"
)
assert adapter.format_message(content) == content.strip()
def test_format_message_preserves_fenced_code_blocks(self):
adapter = _make_adapter()
content = "## Snippet\n\n```python\nprint('hi')\n```"
assert adapter.format_message(content) == "**Snippet**\n\n```python\nprint('hi')\n```"
assert adapter.format_message(content) == content
def test_format_message_returns_empty_string_for_none(self):
adapter = _make_adapter()
@@ -101,7 +96,7 @@ class TestWeixinChunking:
content = adapter.format_message("## 结论\n这是正文")
chunks = adapter._split_text(content)
assert chunks == ["**结论**\n这是正文"]
assert chunks == ["## 结论\n这是正文"]
def test_split_text_keeps_short_reformatted_table_in_single_chunk(self):
adapter = _make_adapter()
@@ -318,6 +313,7 @@ class TestWeixinChunkDelivery:
def _connected_adapter(self) -> WeixinAdapter:
adapter = _make_adapter()
adapter._session = object()
adapter._send_session = adapter._session
adapter._token = "test-token"
adapter._base_url = "https://weixin.example.com"
adapter._token_store.get = lambda account_id, chat_id: "ctx-token"
@@ -363,6 +359,115 @@ class TestWeixinChunkDelivery:
assert first_try["client_id"] == retry["client_id"]
class TestWeixinOutboundMedia:
def test_send_image_file_accepts_keyword_image_path(self):
adapter = _make_adapter()
expected = SendResult(success=True, message_id="msg-1")
adapter.send_document = AsyncMock(return_value=expected)
result = asyncio.run(
adapter.send_image_file(
chat_id="wxid_test123",
image_path="/tmp/demo.png",
caption="截图说明",
reply_to="reply-1",
metadata={"thread_id": "t-1"},
)
)
assert result == expected
adapter.send_document.assert_awaited_once_with(
chat_id="wxid_test123",
file_path="/tmp/demo.png",
caption="截图说明",
metadata={"thread_id": "t-1"},
)
def test_send_document_accepts_keyword_file_path(self):
adapter = _make_adapter()
adapter._session = object()
adapter._send_session = adapter._session
adapter._token = "test-token"
adapter._send_file = AsyncMock(return_value="msg-2")
result = asyncio.run(
adapter.send_document(
chat_id="wxid_test123",
file_path="/tmp/report.pdf",
caption="报告请看",
file_name="renamed.pdf",
reply_to="reply-1",
metadata={"thread_id": "t-1"},
)
)
assert result.success is True
assert result.message_id == "msg-2"
adapter._send_file.assert_awaited_once_with("wxid_test123", "/tmp/report.pdf", "报告请看")
def test_send_file_uses_post_for_upload_full_url_and_hex_encoded_aes_key(self, tmp_path):
class _UploadResponse:
def __init__(self):
self.status = 200
self.headers = {"x-encrypted-param": "enc-param"}
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def read(self):
return b""
async def text(self):
return ""
class _RecordingSession:
def __init__(self):
self.post_calls = []
def post(self, url, **kwargs):
self.post_calls.append((url, kwargs))
return _UploadResponse()
def put(self, *_args, **_kwargs):
raise AssertionError("upload_full_url branch should use POST")
image_path = tmp_path / "demo.png"
image_path.write_bytes(b"fake-png-bytes")
adapter = _make_adapter()
session = _RecordingSession()
adapter._session = session
adapter._send_session = session
adapter._token = "test-token"
adapter._base_url = "https://weixin.example.com"
adapter._cdn_base_url = "https://cdn.example.com/c2c"
adapter._token_store.get = lambda account_id, chat_id: None
aes_key = bytes(range(16))
expected_aes_key = base64.b64encode(aes_key.hex().encode("ascii")).decode("ascii")
with patch("gateway.platforms.weixin._get_upload_url", new=AsyncMock(return_value={"upload_full_url": "https://upload.example.com/media"})), \
patch("gateway.platforms.weixin._api_post", new_callable=AsyncMock) as api_post_mock, \
patch("gateway.platforms.weixin.secrets.token_hex", return_value="filekey-123"), \
patch("gateway.platforms.weixin.secrets.token_bytes", return_value=aes_key):
message_id = asyncio.run(adapter._send_file("wxid_test123", str(image_path), ""))
assert message_id.startswith("hermes-weixin-")
assert len(session.post_calls) == 1
upload_url, upload_kwargs = session.post_calls[0]
assert upload_url == "https://upload.example.com/media"
assert upload_kwargs["headers"] == {"Content-Type": "application/octet-stream"}
assert upload_kwargs["data"]
assert upload_kwargs["timeout"].total == 120
payload = api_post_mock.await_args.kwargs["payload"]
media = payload["msg"]["item_list"][0]["image_item"]["media"]
assert media["encrypt_query_param"] == "enc-param"
assert media["aes_key"] == expected_aes_key
class TestWeixinRemoteMediaSafety:
def test_download_remote_media_blocks_unsafe_urls(self):
adapter = _make_adapter()
@@ -377,16 +482,13 @@ class TestWeixinRemoteMediaSafety:
class TestWeixinMarkdownLinks:
"""Markdown links should be converted to plaintext since WeChat can't render them."""
"""Markdown links should be preserved so WeChat can render them natively."""
def test_format_message_converts_markdown_links_to_plain_text(self):
def test_format_message_preserves_markdown_links(self):
adapter = _make_adapter()
content = "Check [the docs](https://example.com) and [GitHub](https://github.com) for details"
assert (
adapter.format_message(content)
== "Check the docs (https://example.com) and GitHub (https://github.com) for details"
)
assert adapter.format_message(content) == content
def test_format_message_preserves_links_inside_code_blocks(self):
adapter = _make_adapter()
@@ -430,6 +532,7 @@ class TestWeixinBlankMessagePrevention:
def test_send_empty_content_does_not_call_send_message(self, send_message_mock):
adapter = _make_adapter()
adapter._session = object()
adapter._send_session = adapter._session
adapter._token = "test-token"
adapter._base_url = "https://weixin.example.com"
adapter._token_store.get = lambda account_id, chat_id: "ctx-token"
@@ -500,10 +603,10 @@ class TestWeixinMediaBuilder:
)
assert item["video_item"]["video_md5"] == "deadbeef"
def test_voice_builder_for_audio_files(self):
def test_voice_builder_for_audio_files_uses_file_attachment_type(self):
adapter = _make_adapter()
media_type, builder = adapter._outbound_media_builder("note.mp3")
assert media_type == weixin.MEDIA_VOICE
assert media_type == weixin.MEDIA_FILE
item = builder(
encrypt_query_param="eq",
@@ -513,10 +616,145 @@ class TestWeixinMediaBuilder:
filename="note.mp3",
rawfilemd5="abc",
)
assert item["type"] == weixin.ITEM_VOICE
assert "voice_item" in item
assert item["type"] == weixin.ITEM_FILE
assert item["file_item"]["file_name"] == "note.mp3"
def test_voice_builder_for_silk_files(self):
adapter = _make_adapter()
media_type, builder = adapter._outbound_media_builder("recording.silk")
assert media_type == weixin.MEDIA_VOICE
class TestWeixinSendImageFileParameterName:
"""Regression test for send_image_file parameter name mismatch.
The gateway calls send_image_file(chat_id=..., image_path=...) but the
WeixinAdapter previously used 'path' as the parameter name, causing
image sending to fail. This test ensures the interface stays correct.
"""
@patch.object(WeixinAdapter, "send_document", new_callable=AsyncMock)
def test_send_image_file_uses_image_path_parameter(self, send_document_mock):
"""Verify send_image_file accepts image_path and forwards to send_document."""
adapter = _make_adapter()
adapter._session = object()
adapter._send_session = adapter._session
adapter._token = "test-token"
send_document_mock.return_value = weixin.SendResult(success=True, message_id="test-id")
# This is the call pattern used by gateway/run.py extract_media
result = asyncio.run(
adapter.send_image_file(
chat_id="wxid_test123",
image_path="/tmp/test_image.png",
caption="Test caption",
metadata={"thread_id": "thread-123"},
)
)
assert result.success is True
send_document_mock.assert_awaited_once_with(
chat_id="wxid_test123",
file_path="/tmp/test_image.png",
caption="Test caption",
metadata={"thread_id": "thread-123"},
)
@patch.object(WeixinAdapter, "send_document", new_callable=AsyncMock)
def test_send_image_file_works_without_optional_params(self, send_document_mock):
"""Verify send_image_file works with minimal required params."""
adapter = _make_adapter()
adapter._session = object()
adapter._send_session = adapter._session
adapter._token = "test-token"
send_document_mock.return_value = weixin.SendResult(success=True, message_id="test-id")
result = asyncio.run(
adapter.send_image_file(
chat_id="wxid_test123",
image_path="/tmp/test_image.jpg",
)
)
assert result.success is True
send_document_mock.assert_awaited_once_with(
chat_id="wxid_test123",
file_path="/tmp/test_image.jpg",
caption=None,
metadata=None,
)
class TestWeixinVoiceSending:
def _connected_adapter(self) -> WeixinAdapter:
adapter = _make_adapter()
adapter._session = object()
adapter._send_session = adapter._session
adapter._token = "test-token"
adapter._base_url = "https://weixin.example.com"
adapter._token_store.get = lambda account_id, chat_id: "ctx-token"
return adapter
@patch.object(WeixinAdapter, "_send_file", new_callable=AsyncMock)
def test_send_voice_downgrades_to_document_attachment(self, send_file_mock, tmp_path):
adapter = self._connected_adapter()
source = tmp_path / "voice.ogg"
source.write_bytes(b"ogg")
send_file_mock.return_value = "msg-1"
result = asyncio.run(adapter.send_voice("wxid_test123", str(source)))
assert result.success is True
send_file_mock.assert_awaited_once_with(
"wxid_test123",
str(source),
"[voice message as attachment]",
force_file_attachment=True,
)
def test_voice_builder_for_silk_files_can_be_forced_to_file_attachment(self):
adapter = _make_adapter()
media_type, builder = adapter._outbound_media_builder(
"recording.silk",
force_file_attachment=True,
)
assert media_type == weixin.MEDIA_FILE
item = builder(
encrypt_query_param="eq",
aes_key_for_api="fakekey",
ciphertext_size=512,
plaintext_size=500,
filename="recording.silk",
rawfilemd5="abc",
)
assert item["type"] == weixin.ITEM_FILE
assert item["file_item"]["file_name"] == "recording.silk"
@patch.object(weixin, "_api_post", new_callable=AsyncMock)
@patch.object(weixin, "_upload_ciphertext", new_callable=AsyncMock)
@patch.object(weixin, "_get_upload_url", new_callable=AsyncMock)
def test_send_file_sets_voice_metadata_for_silk_payload(
self,
get_upload_url_mock,
upload_ciphertext_mock,
api_post_mock,
tmp_path,
):
adapter = self._connected_adapter()
silk = tmp_path / "voice.silk"
silk.write_bytes(b"\x02#!SILK_V3\x01\x00")
get_upload_url_mock.return_value = {"upload_full_url": "https://cdn.example.com/upload"}
upload_ciphertext_mock.return_value = "enc-q"
api_post_mock.return_value = {"success": True}
asyncio.run(adapter._send_file("wxid_test123", str(silk), ""))
payload = api_post_mock.await_args.kwargs["payload"]
voice_item = payload["msg"]["item_list"][0]["voice_item"]
assert voice_item.get("playtime", 0) == 0
assert voice_item["encode_type"] == 6
assert voice_item["sample_rate"] == 24000
assert voice_item["bits_per_sample"] == 16