Compare commits

...

1 Commits

Author SHA1 Message Date
Ben Barclay
6add84a6a1 fix(discord): delete orphaned auto-thread seed message on fallback failure
When auto-threading is rate-limited, _auto_create_thread() posted the
'Thread created by Hermes' announcement before confirming the fallback
create_thread() succeeded. On a 429 the announcement was left orphaned
and the agent replied inline, so users saw 'Thread created' with no
thread behind it.

Delete the seed message when the fallback create_thread() fails so the
announcement only survives when a real thread exists.

Adds tests/gateway/test_discord_auto_thread_orphan_seed.py covering the
three paths (fallback-fail deletes seed, fallback-success keeps seed,
direct-success posts no seed).

Fixes #52422
2026-06-25 08:46:53 +00:00
2 changed files with 161 additions and 0 deletions

View File

@@ -4594,6 +4594,7 @@ class DiscordAdapter(BasePlatformAdapter):
except Exception as direct_error:
display_name = getattr(getattr(message, "author", None), "display_name", None) or "unknown user"
reason = f"Auto-threaded from mention by {display_name}"
seed_msg = None
try:
seed_msg = await message.channel.send(f"\U0001f9f5 Thread created by Hermes: **{thread_name}**")
thread = await seed_msg.create_thread(
@@ -4603,6 +4604,23 @@ class DiscordAdapter(BasePlatformAdapter):
)
return thread
except Exception as fallback_error:
# The seed message announces "Thread created by Hermes" *before*
# create_thread() is confirmed to succeed. When the thread call
# then fails (commonly a Discord 429 rate-limit on thread
# creation), the announcement is left orphaned in the channel and
# the caller falls back to replying inline — so the user sees a
# "Thread created" message with no thread behind it (#issue).
# Delete the orphaned seed message so the announcement only ever
# survives when a real thread exists.
if seed_msg is not None:
try:
await seed_msg.delete()
except Exception as cleanup_error:
logger.debug(
"[%s] Could not delete orphaned auto-thread seed message: %s",
self.name,
cleanup_error,
)
logger.warning(
"[%s] Auto-thread creation failed. Direct error: %s. Fallback error: %s",
self.name,

View File

@@ -0,0 +1,143 @@
"""Tests for Discord auto-thread orphaned-seed-message cleanup.
When auto-threading is enabled, _auto_create_thread() first tries
``message.create_thread()``. If that fails (commonly a Discord 429
rate-limit on thread creation), it falls back to posting a seed
announcement message ("🧵 Thread created by Hermes: ...") and creating
the thread *from that message*.
The bug: the seed announcement is posted **before** the fallback
``create_thread()`` is confirmed to succeed. When the fallback also
fails (e.g. the rate-limit is still in effect), the announcement is left
orphaned in the channel and the caller responds inline — so users see a
"Thread created by Hermes" message with no thread behind it, and the
answer in the main channel.
Fix: on fallback failure, delete the orphaned seed message so the
announcement only ever survives when a real thread exists.
"""
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from gateway.config import PlatformConfig
import plugins.platforms.discord.adapter as discord_platform # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
class _RateLimited(Exception):
"""Stand-in for discord.HTTPException 429."""
class _SeedMessage:
"""Fake seed message returned by channel.send()."""
def __init__(self, create_thread_succeeds: bool, thread=None):
self._create_thread_succeeds = create_thread_succeeds
self._thread = thread
self.deleted = False
self.create_thread = AsyncMock(side_effect=self._create_thread)
self.delete = AsyncMock(side_effect=self._delete)
async def _create_thread(self, *args, **kwargs):
if self._create_thread_succeeds:
return self._thread
raise _RateLimited("Too many requests. Retry in 222.97 seconds.")
async def _delete(self, *args, **kwargs):
self.deleted = True
class _Channel:
def __init__(self, seed_message):
self.id = 100
self.name = "general"
self.guild = SimpleNamespace(name="Test Server", id=1)
self.send = AsyncMock(return_value=seed_message)
class _Thread:
def __init__(self, thread_id=55555, parent=None):
self.id = thread_id
self.name = "thread"
self.parent = parent
def _make_message(*, channel, content="hello bot", direct_create_succeeds=False,
thread=None):
async def _create_thread(*args, **kwargs):
if direct_create_succeeds:
return thread
raise _RateLimited("Too many requests. Retry in 278.53 seconds.")
return SimpleNamespace(
id=42,
content=content,
channel=channel,
author=SimpleNamespace(id=7, display_name="Alice", name="Alice", bot=False),
created_at=datetime.now(timezone.utc),
type=discord_platform.discord.MessageType.default,
create_thread=AsyncMock(side_effect=_create_thread),
)
@pytest.fixture
def adapter(monkeypatch):
for var in ("DISCORD_AUTO_THREAD", "DISCORD_NO_THREAD_CHANNELS"):
monkeypatch.delenv(var, raising=False)
config = PlatformConfig(enabled=True, token="***")
a = DiscordAdapter(config)
a._client = SimpleNamespace(user=SimpleNamespace(id=999, bot=True))
return a
class TestAutoThreadOrphanSeed:
@pytest.mark.asyncio
async def test_fallback_failure_deletes_orphaned_seed(self, adapter):
"""Direct create 429s, fallback create 429s → seed message deleted, None returned."""
seed = _SeedMessage(create_thread_succeeds=False)
channel = _Channel(seed)
message = _make_message(channel=channel, direct_create_succeeds=False)
result = await adapter._auto_create_thread(message)
assert result is None, "must return None when both create paths fail"
channel.send.assert_awaited_once() # seed announcement was posted
assert seed.deleted is True, (
"orphaned seed announcement must be deleted when fallback "
"create_thread fails, so users never see a 'Thread created' "
"message with no thread behind it"
)
@pytest.mark.asyncio
async def test_fallback_success_keeps_seed(self, adapter):
"""Direct create 429s but fallback create succeeds → seed kept, thread returned."""
thread = _Thread()
seed = _SeedMessage(create_thread_succeeds=True, thread=thread)
channel = _Channel(seed)
message = _make_message(channel=channel, direct_create_succeeds=False)
result = await adapter._auto_create_thread(message)
assert result is thread
channel.send.assert_awaited_once()
assert seed.deleted is False, "seed must survive when a real thread was created"
@pytest.mark.asyncio
async def test_direct_success_posts_no_seed(self, adapter):
"""Direct create succeeds → no seed announcement, no fallback."""
thread = _Thread()
seed = _SeedMessage(create_thread_succeeds=True, thread=thread)
channel = _Channel(seed)
message = _make_message(
channel=channel, direct_create_succeeds=True, thread=thread
)
result = await adapter._auto_create_thread(message)
assert result is thread
channel.send.assert_not_awaited() # no seed message on the happy path