mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Follow-up to the reply-reference fix: ensure errors unrelated to the reply reference (e.g. 50013 Missing Permissions) do NOT trigger the no-reference retry path and still surface as a failed SendResult. Keeps the wider retry condition from silently swallowing unrelated API errors. Proposed in the original issue writeup (#11342) as test case `test_non_reference_errors_still_propagate`.
159 lines
5.9 KiB
Python
159 lines
5.9 KiB
Python
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
import sys
|
|
|
|
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 gateway.platforms.discord import DiscordAdapter # noqa: E402
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_retries_without_reference_when_reply_target_is_system_message():
|
|
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
|
|
|
reference_obj = object()
|
|
ref_msg = SimpleNamespace(id=99, to_reference=MagicMock(return_value=reference_obj))
|
|
sent_msg = SimpleNamespace(id=1234)
|
|
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: 50035): Invalid Form Body\n"
|
|
"In message_reference: Cannot reply to a system message"
|
|
)
|
|
return sent_msg
|
|
|
|
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")
|
|
|
|
assert result.success is True
|
|
assert result.message_id == "1234"
|
|
assert channel.fetch_message.await_count == 1
|
|
assert channel.send.await_count == 2
|
|
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
|