fix(discord): auto-thread failure must not silently fall back to inline reply

When discord.auto_thread is enabled and a top-level server-channel message
should be routed to a new thread, a transient thread-create failure (e.g.
Cannot connect to host discord.com:443) returned None and _handle_message
fell through to an inline parent-channel reply — dumping a new task into a
shared channel and breaking thread-first workflows.

- _auto_create_thread retries the primary + seed-message paths once after a
  750ms backoff for transient connect errors.
- _handle_message treats None as a hard failure: posts a short visible notice
  in the parent channel and returns without invoking the agent. The notify
  send is wrapped so a secondary connect error can't raise.

Fixes #20243
This commit is contained in:
0xsir0000 2026-06-30 23:36:15 -07:00 committed by Teknium
parent a537baa81d
commit 50a7dce6bd
3 changed files with 144 additions and 21 deletions

View file

@ -5020,7 +5020,11 @@ class DiscordAdapter(BasePlatformAdapter):
async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
"""Create a thread from a user message for auto-threading.
Returns the created thread object, or ``None`` on failure.
Returns the created thread object, or ``None`` on failure. Both the
primary ``message.create_thread`` and the seed-message fallback are
retried once after a short backoff so transient connect errors
(e.g. ``Cannot connect to host discord.com:443``) don't immediately
burn through to the caller's failure path (#20243).
"""
# Build a short thread name from the message. Strip Discord mention
# syntax (users / roles / channels) so thread titles don't end up
@ -5035,28 +5039,44 @@ class DiscordAdapter(BasePlatformAdapter):
if len(content) > 80:
thread_name = thread_name[:77] + "..."
try:
thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)
return thread
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}"
display_name = getattr(getattr(message, "author", None), "display_name", None) or "unknown user"
reason = f"Auto-threaded from mention by {display_name}"
last_direct_error: Exception | None = None
last_fallback_error: Exception | None = None
for attempt in range(2):
try:
seed_msg = await message.channel.send(f"\U0001f9f5 Thread created by Hermes: **{thread_name}**")
thread = await seed_msg.create_thread(
name=thread_name,
auto_archive_duration=1440,
reason=reason,
)
thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)
return thread
except Exception as fallback_error:
logger.warning(
"[%s] Auto-thread creation failed. Direct error: %s. Fallback error: %s",
self.name,
direct_error,
fallback_error,
)
return None
except Exception as direct_error:
last_direct_error = direct_error
try:
seed_msg = await message.channel.send(
f"\U0001f9f5 Thread created by Hermes: **{thread_name}**"
)
thread = await seed_msg.create_thread(
name=thread_name,
auto_archive_duration=1440,
reason=reason,
)
return thread
except Exception as fallback_error:
last_fallback_error = fallback_error
if attempt == 0:
# Brief backoff before the second attempt — most failures
# in this path are transient connect errors that recover
# within a second or two.
await asyncio.sleep(0.75)
continue
logger.warning(
"[%s] Auto-thread creation failed after retry. Direct error: %s. Fallback error: %s",
self.name,
last_direct_error,
last_fallback_error,
)
return None
async def create_handoff_thread(
self,
@ -5742,6 +5762,26 @@ class DiscordAdapter(BasePlatformAdapter):
# event is dropped before it can trigger a second agent run.
# Fixes #51057.
self._dedup.is_duplicate(str(thread.id))
else:
# Auto-threading is the configured routing target for this
# message; if it fails we must NOT silently fall back to an
# inline parent-channel reply (#20243). That breaks
# thread-first Discord workflows by dumping a new task into
# a shared channel. Surface a short visible error so the
# user can retry once Discord recovers, and skip agent
# invocation for this message.
try:
await message.channel.send(
"⚠️ Hermes could not create a Discord thread for "
"this message, so the request was not processed. Please retry."
)
except Exception as notify_error:
logger.warning(
"[%s] Failed to notify user of auto-thread failure: %s",
self.name,
notify_error,
)
return
referenced_attachments = []
reference = getattr(message, "reference", None)

View file

@ -140,6 +140,11 @@ async def test_non_ignored_channel_processes_normally(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500,600")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
# Stub auto-thread creation so this test focuses on ignored-channel
# routing only — auto-thread failures now correctly skip agent invocation
# (#20243), which would otherwise mask the assertion below.
adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999))
message = make_message(channel=FakeTextChannel(channel_id=700), content="hello")
await adapter._handle_message(message)
@ -167,6 +172,11 @@ async def test_ignored_channels_empty_string_ignores_nothing(adapter, monkeypatc
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
# Stub auto-thread creation so this test focuses on ignored-channel
# routing only — auto-thread failures now correctly skip agent invocation
# (#20243), which would otherwise mask the assertion below.
adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999))
message = make_message(channel=FakeTextChannel(channel_id=500), content="hello")
await adapter._handle_message(message)
@ -281,6 +291,71 @@ async def test_no_thread_with_auto_thread_disabled_is_noop(adapter, monkeypatch)
adapter.handle_message.assert_awaited_once()
# ── auto-thread failure must not silently fall back to inline (#20243) ──
@pytest.mark.asyncio
async def test_auto_thread_failure_skips_agent_and_notifies_user(adapter, monkeypatch):
"""Auto-thread creation failure must not trigger an inline parent-channel reply.
Before #20243, ``effective_channel = auto_threaded_channel or message.channel``
silently routed the response back to the parent channel when thread creation
failed, breaking thread-first Discord workflows. The fix surfaces a short
visible error to the parent channel and skips agent invocation entirely so
the user can retry.
"""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
monkeypatch.delenv("DISCORD_NO_THREAD_CHANNELS", raising=False)
monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False)
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
adapter._auto_create_thread = AsyncMock(return_value=None)
channel = FakeTextChannel(channel_id=800)
channel.send = AsyncMock()
message = make_message(channel=channel, content="hello")
await adapter._handle_message(message)
adapter._auto_create_thread.assert_awaited_once()
# Agent must NOT be invoked when the routing target failed.
adapter.handle_message.assert_not_awaited()
# User gets a visible explanation in the parent channel instead of a silent
# inline reply.
channel.send.assert_awaited_once()
sent_text = channel.send.await_args.args[0]
assert "could not create" in sent_text.lower()
assert "thread" in sent_text.lower()
@pytest.mark.asyncio
async def test_auto_thread_failure_notify_error_does_not_crash(adapter, monkeypatch):
"""If even the failure-notification send raises, we still skip the agent.
``message.channel.send`` itself can fail (the same connect issue that
killed thread creation often kills plain sends too). The handler should
swallow the secondary error and still avoid invoking the agent.
"""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
monkeypatch.delenv("DISCORD_NO_THREAD_CHANNELS", raising=False)
monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False)
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
adapter._auto_create_thread = AsyncMock(return_value=None)
channel = FakeTextChannel(channel_id=800)
channel.send = AsyncMock(side_effect=RuntimeError("Cannot connect to host discord.com:443"))
message = make_message(channel=channel, content="hello")
# No exception must propagate.
await adapter._handle_message(message)
adapter._auto_create_thread.assert_awaited_once()
adapter.handle_message.assert_not_awaited()
channel.send.assert_awaited_once()
# ── config.py bridging ───────────────────────────────────────────────

View file

@ -202,6 +202,10 @@ async def test_discord_defaults_to_require_mention(adapter, monkeypatch):
async def test_discord_free_response_in_server_channels(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
# Auto-thread failures now correctly skip agent invocation (#20243), and
# FakeTextChannel has no real ``create_thread``. Disable auto-thread so the
# routing assertion below stays focused on free-response gating.
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
message = make_message(channel=FakeTextChannel(channel_id=123), content="hello from channel")
@ -334,6 +338,10 @@ async def test_discord_forum_parent_in_free_response_list_allows_forum_thread(ad
async def test_discord_accepts_and_strips_bot_mentions_when_required(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
# Auto-thread failures now correctly skip agent invocation (#20243).
# FakeTextChannel can't satisfy the real ``create_thread`` API, so disable
# auto-thread to keep this test focused on mention-strip behaviour.
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
bot_user = adapter._client.user
message = make_message(