mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
380 lines
14 KiB
Python
380 lines
14 KiB
Python
import asyncio
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from gateway.config import PlatformConfig
|
|
|
|
|
|
def _ensure_discord_mock():
|
|
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 plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
|
|
|
|
|
def _voice_adapter(reference_obj, *, native_result=None, native_error=None):
|
|
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
|
ref_msg = SimpleNamespace(id=99, to_reference=MagicMock(return_value=reference_obj))
|
|
channel = SimpleNamespace(
|
|
id=555,
|
|
fetch_message=AsyncMock(return_value=ref_msg),
|
|
send=AsyncMock(return_value=SimpleNamespace(id=888)),
|
|
)
|
|
request = AsyncMock(return_value=native_result or {"id": "777"})
|
|
if native_error is not None:
|
|
request.side_effect = native_error
|
|
adapter._client = SimpleNamespace(
|
|
get_channel=lambda _chat_id: channel,
|
|
fetch_channel=AsyncMock(),
|
|
http=SimpleNamespace(request=request),
|
|
)
|
|
return adapter, channel, request
|
|
|
|
|
|
def _native_voice_payload(request):
|
|
form = request.await_args.kwargs["form"]
|
|
payload = next(part["value"] for part in form if part["name"] == "payload_json")
|
|
return json.loads(payload)
|
|
|
|
|
|
@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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Forum channel tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
import discord as _discord_mod # noqa: E402 — imported after _ensure_discord_mock
|
|
|
|
|
|
class TestIsForumParent:
|
|
def test_none_returns_false(self):
|
|
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
|
assert adapter._is_forum_parent(None) is False
|
|
|
|
def test_forum_channel_class_instance(self):
|
|
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
|
forum_cls = getattr(_discord_mod, "ForumChannel", None)
|
|
if forum_cls is None:
|
|
# Re-create a type for the mock
|
|
forum_cls = type("ForumChannel", (), {})
|
|
_discord_mod.ForumChannel = forum_cls
|
|
ch = forum_cls()
|
|
assert adapter._is_forum_parent(ch) is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Forum follow-up chunk failure reporting + media on forum paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forum_post_file_creates_thread_with_attachment():
|
|
"""_forum_post_file routes file-bearing sends to create_thread with file kwarg."""
|
|
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
|
|
|
thread_ch = SimpleNamespace(id=777, send=AsyncMock())
|
|
thread = SimpleNamespace(
|
|
id=777,
|
|
message=SimpleNamespace(
|
|
id=800,
|
|
attachments=[SimpleNamespace(filename="photo.png")],
|
|
),
|
|
thread=thread_ch,
|
|
)
|
|
forum_channel = _discord_mod.ForumChannel()
|
|
forum_channel.id = 999
|
|
forum_channel.name = "ideas"
|
|
forum_channel.create_thread = AsyncMock(return_value=thread)
|
|
|
|
# discord.File is a real class; build a MagicMock that looks like one
|
|
fake_file = SimpleNamespace(filename="photo.png")
|
|
|
|
result = await adapter._forum_post_file(
|
|
forum_channel,
|
|
content="here is a photo",
|
|
file=fake_file,
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.message_id == "800"
|
|
forum_channel.create_thread.assert_awaited_once()
|
|
call_kwargs = forum_channel.create_thread.await_args.kwargs
|
|
assert call_kwargs["file"] is fake_file
|
|
assert call_kwargs["content"] == "here is a photo"
|
|
# Thread name derived from content's first line
|
|
assert call_kwargs["name"] == "here is a photo"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forum_post_file_fails_when_starter_has_no_attachments():
|
|
"""Forum create_thread can succeed yet return an attachmentless starter (#66797)."""
|
|
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
|
|
|
thread = SimpleNamespace(
|
|
id=7,
|
|
message=SimpleNamespace(id=8, attachments=[]),
|
|
thread=SimpleNamespace(id=7, send=AsyncMock()),
|
|
)
|
|
forum_channel = _discord_mod.ForumChannel()
|
|
forum_channel.id = 999
|
|
forum_channel.create_thread = AsyncMock(return_value=thread)
|
|
|
|
fake_file = SimpleNamespace(filename="clip.mp4")
|
|
result = await adapter._forum_post_file(
|
|
forum_channel,
|
|
content="video clip",
|
|
files=[fake_file],
|
|
)
|
|
|
|
assert result.success is False
|
|
assert "no files" in (result.error or "").lower()
|
|
forum_channel.create_thread.assert_awaited_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Typing indicator task lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_typing_restartable_after_error():
|
|
"""After a typing error, send_typing should start a new task (not blocked by stale entry)."""
|
|
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
|
adapter._client = MagicMock()
|
|
adapter._client.http = MagicMock()
|
|
adapter._typing_tasks = {}
|
|
|
|
# First call fails
|
|
adapter._client.http.request = AsyncMock(side_effect=Exception("503"))
|
|
await adapter.send_typing("12345")
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Second call should work
|
|
adapter._client.http.request = AsyncMock()
|
|
await adapter.send_typing("12345")
|
|
|
|
assert "12345" in adapter._typing_tasks, \
|
|
"Should restart typing after previous failure"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #66797 — outbound MEDIA video must reach channel.send as a real attachment
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_video_uses_path_based_files_kwarg(tmp_path, monkeypatch):
|
|
"""Regression for #66797: video MEDIA delivery must use path-based
|
|
``discord.File`` via ``files=[...]`` (same pattern as image batching).
|
|
|
|
The previous open-handle + singular ``file=`` form could return a successful
|
|
message with zero attachments after an earlier image batch on the same
|
|
channel — silent drop from the user's perspective.
|
|
"""
|
|
import plugins.platforms.discord.adapter as discord_platform
|
|
|
|
video = tmp_path / "clip.mp4"
|
|
video.write_bytes(b"\x00\x00\x00\x18ftypmp42fake")
|
|
|
|
captured = {}
|
|
|
|
class _FakeFile:
|
|
def __init__(self, fp, filename=None, **kwargs):
|
|
captured["fp"] = fp
|
|
captured["filename"] = filename
|
|
|
|
monkeypatch.setattr(discord_platform.discord, "File", _FakeFile)
|
|
|
|
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
|
sent_msg = SimpleNamespace(
|
|
id=4242,
|
|
attachments=[SimpleNamespace(filename="clip.mp4", url="https://cdn.example/clip.mp4")],
|
|
)
|
|
channel = SimpleNamespace(
|
|
send=AsyncMock(return_value=sent_msg),
|
|
type=0,
|
|
)
|
|
adapter._client = SimpleNamespace(
|
|
get_channel=lambda _chat_id: channel,
|
|
fetch_channel=AsyncMock(),
|
|
)
|
|
monkeypatch.setattr(adapter, "_is_forum_parent", lambda _ch: False)
|
|
|
|
result = await adapter.send_video("555", str(video))
|
|
|
|
assert result.success is True
|
|
assert result.message_id == "4242"
|
|
assert captured["fp"] == str(video)
|
|
assert captured["filename"] == "clip.mp4"
|
|
channel.send.assert_awaited_once()
|
|
send_kwargs = channel.send.await_args.kwargs
|
|
assert send_kwargs.get("file") is None
|
|
assert isinstance(send_kwargs.get("files"), list) and len(send_kwargs["files"]) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_video_fails_loud_when_message_has_no_attachments(tmp_path, monkeypatch):
|
|
"""If Discord accepts the message but attaches nothing, fail loud (#66797)."""
|
|
import plugins.platforms.discord.adapter as discord_platform
|
|
|
|
video = tmp_path / "clip.mp4"
|
|
video.write_bytes(b"fake-mp4")
|
|
|
|
monkeypatch.setattr(
|
|
discord_platform.discord,
|
|
"File",
|
|
lambda fp, filename=None, **kwargs: SimpleNamespace(fp=fp, filename=filename),
|
|
)
|
|
|
|
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
|
# Message id present, but no attachments — the silent-drop failure mode.
|
|
sent_msg = SimpleNamespace(id=99, attachments=[])
|
|
channel = SimpleNamespace(send=AsyncMock(return_value=sent_msg), type=0)
|
|
adapter._client = SimpleNamespace(
|
|
get_channel=lambda _chat_id: channel,
|
|
fetch_channel=AsyncMock(),
|
|
)
|
|
monkeypatch.setattr(adapter, "_is_forum_parent", lambda _ch: False)
|
|
|
|
result = await adapter.send_video("555", str(video))
|
|
|
|
assert result.success is False
|
|
assert "no files" in (result.error or "").lower()
|
|
channel.send.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_video_missing_file_fails_fast_without_touching_channel():
|
|
"""A missing MEDIA path must fail loud before any Discord I/O (#66797).
|
|
|
|
The pre-flight ``os.path.isfile`` guard turns a would-be crash inside
|
|
``discord.File`` into an actionable ``File not found`` result, and must
|
|
short-circuit before the channel is ever resolved.
|
|
"""
|
|
def _boom(*_args, **_kwargs):
|
|
raise AssertionError("channel must not be resolved for a missing file")
|
|
|
|
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
|
adapter._client = SimpleNamespace(get_channel=_boom, fetch_channel=AsyncMock(side_effect=_boom))
|
|
|
|
result = await adapter.send_video("555", "/no/such/clip.mp4")
|
|
|
|
assert result.success is False
|
|
assert "not found" in (result.error or "").lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_file_attachment_forum_uses_files_kwarg(tmp_path, monkeypatch):
|
|
"""Forum-parent delivery must also route the path-based file through the
|
|
plural ``files=[...]`` kwarg (#66797), so the create_thread starter message
|
|
carries the attachment rather than silently dropping it."""
|
|
import plugins.platforms.discord.adapter as discord_platform
|
|
|
|
video = tmp_path / "clip.mp4"
|
|
video.write_bytes(b"fake-mp4")
|
|
|
|
monkeypatch.setattr(
|
|
discord_platform.discord,
|
|
"File",
|
|
lambda fp, filename=None, **kwargs: SimpleNamespace(fp=fp, filename=filename),
|
|
)
|
|
|
|
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
|
created_thread = SimpleNamespace(
|
|
id=7,
|
|
message=SimpleNamespace(
|
|
id=8,
|
|
attachments=[SimpleNamespace(filename="clip.mp4")],
|
|
),
|
|
)
|
|
forum_channel = SimpleNamespace(
|
|
id=7,
|
|
create_thread=AsyncMock(return_value=created_thread),
|
|
)
|
|
adapter._client = SimpleNamespace(
|
|
get_channel=lambda _chat_id: forum_channel,
|
|
fetch_channel=AsyncMock(),
|
|
)
|
|
monkeypatch.setattr(adapter, "_is_forum_parent", lambda _ch: True)
|
|
|
|
result = await adapter.send_video("555", str(video))
|
|
|
|
assert result.success is True
|
|
forum_channel.create_thread.assert_awaited_once()
|
|
thread_kwargs = forum_channel.create_thread.await_args.kwargs
|
|
assert thread_kwargs.get("file") is None
|
|
assert isinstance(thread_kwargs.get("files"), list) and len(thread_kwargs["files"]) == 1
|
|
|
|
|