From 009b42d008b81c18af39414dded9ecdf06082d93 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:01:49 -0700 Subject: [PATCH] fix(discord): mirror all interactive prompt payloads into message content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the send_exec_approval embed-invisibility fix to its three sibling prompt surfaces — send_slash_confirm, send_clarify, and send_update_prompt — via a shared _self_contained_prompt_content() helper. All four interactive views now carry their payload in plain content next to the buttons; the embed stays as progressive enhancement for clients that render it. Adds gold to the conftest discord Color mock (update prompt is the only gold user). --- plugins/platforms/discord/adapter.py | 50 ++++++- tests/gateway/conftest.py | 1 + .../test_discord_prompt_content_siblings.py | 123 ++++++++++++++++++ 3 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 tests/gateway/test_discord_prompt_content_siblings.py diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 027cc73949c..dc48a336f33 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -5429,6 +5429,29 @@ class DiscordAdapter(BasePlatformAdapter): ) return None + def _self_contained_prompt_content( + self, header: str, body: str, *, code_block: bool = False, tail: str = "" + ) -> str: + """Build plain message content that mirrors an embed's payload. + + Discord embeds can be invisible or visually separated from the + component row on some clients (notably web/mobile), so interactive + prompts must carry their payload in plain ``content`` next to the + buttons. The embed stays as progressive enhancement. + """ + body = str(body or "") + if code_block: + prefix = f"{header}\n```bash\n" + suffix = f"\n```{tail}" + else: + prefix = f"{header}\n\n" + suffix = tail + truncated_suffix = "\n... [truncated]" + budget = max(0, self.MAX_MESSAGE_LENGTH - len(prefix) - len(suffix)) + if len(body) > budget: + body = body[: max(0, budget - len(truncated_suffix))] + truncated_suffix + return f"{prefix}{body}{suffix}" + async def send_exec_approval( self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", @@ -5535,6 +5558,11 @@ class DiscordAdapter(BasePlatformAdapter): description=body, color=discord.Color.orange(), ) + # Mirror the payload in plain content — embeds are invisible on + # some clients (see send_exec_approval). + content = self._self_contained_prompt_content( + f"**{title or 'Confirm'}**", message + ) view = SlashConfirmView( session_key=session_key, @@ -5543,7 +5571,7 @@ class DiscordAdapter(BasePlatformAdapter): allowed_role_ids=self._allowed_role_ids, ) - msg = await channel.send(embed=embed, view=view) + msg = await channel.send(content=content, embed=embed, view=view) view._message = msg # store for on_timeout expiration editing return SendResult(success=True, message_id=str(msg.id)) except Exception as e: @@ -5657,7 +5685,18 @@ class DiscordAdapter(BasePlatformAdapter): ) view = None - msg = await channel.send(embed=embed, view=view) if view else await channel.send(embed=embed) + # Mirror the question in plain content — embeds are invisible on + # some clients (see send_exec_approval). + clarify_tail = ( + "\n\nPick one below, or click ✏️ Other to type a custom answer." + if clean_choices + else "\n\nReply in this channel with your answer." + ) + content = self._self_contained_prompt_content( + "❓ **Hermes needs your input**", str(question or "").strip(), + tail=clarify_tail, + ) + msg = await channel.send(content=content, embed=embed, view=view) if view else await channel.send(content=content, embed=embed) if view: view._message = msg # store for on_timeout expiration editing return SendResult(success=True, message_id=str(msg.id)) @@ -5694,7 +5733,12 @@ class DiscordAdapter(BasePlatformAdapter): allowed_user_ids=self._allowed_user_ids, allowed_role_ids=self._allowed_role_ids, ) - msg = await channel.send(embed=embed, view=view) + # Mirror the prompt in plain content — embeds are invisible on + # some clients (see send_exec_approval). + content = self._self_contained_prompt_content( + "⚕ **Update Needs Your Input**", f"{prompt}{default_hint}" + ) + msg = await channel.send(content=content, embed=embed, view=view) view._message = msg # store for on_timeout expiration editing if _metadata_marks_nonconversational(metadata): self._nonconversational_messages.mark_many([str(msg.id)]) diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index d1f47c700b2..3546b09cc75 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -191,6 +191,7 @@ def _ensure_discord_mock() -> None: discord_mod.Color = SimpleNamespace( orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5, greyple=lambda: 6, + gold=lambda: 7, ) # app_commands — needed by _register_slash_commands auto-registration diff --git a/tests/gateway/test_discord_prompt_content_siblings.py b/tests/gateway/test_discord_prompt_content_siblings.py new file mode 100644 index 00000000000..77629f1412b --- /dev/null +++ b/tests/gateway/test_discord_prompt_content_siblings.py @@ -0,0 +1,123 @@ +"""Sibling coverage for the embed-invisibility fix (send_exec_approval got it +in the same PR): slash confirm, clarify, and update prompts must also mirror +their payload into plain message content, since embeds don't render on some +Discord clients (web/mobile).""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway.config import PlatformConfig +from plugins.platforms.discord.adapter import DiscordAdapter + + +def _capture_channel(adapter): + sent = {} + + async def fake_send(**kwargs): + sent.update(kwargs) + return SimpleNamespace(id=1234) + + channel = SimpleNamespace(send=AsyncMock(side_effect=fake_send)) + adapter._client = SimpleNamespace( + get_channel=lambda _chat_id: channel, + fetch_channel=AsyncMock(), + ) + return sent + + +@pytest.mark.asyncio +async def test_slash_confirm_mirrors_message_into_content(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + sent = _capture_channel(adapter) + + result = await adapter.send_slash_confirm( + chat_id="555", + title="Reset session?", + message="This will clear the current conversation history.", + session_key="discord:555", + confirm_id="c1", + ) + + assert result.success is True + assert sent["view"] is not None + assert sent["embed"] is not None + assert "Reset session?" in sent["content"] + assert "clear the current conversation history" in sent["content"] + + +@pytest.mark.asyncio +async def test_slash_confirm_truncates_long_message_in_content(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + sent = _capture_channel(adapter) + + result = await adapter.send_slash_confirm( + chat_id="555", + title="Confirm", + message="y" * 5000, + session_key="discord:555", + confirm_id="c2", + ) + + assert result.success is True + assert len(sent["content"]) <= adapter.MAX_MESSAGE_LENGTH + assert "... [truncated]" in sent["content"] + + +@pytest.mark.asyncio +async def test_clarify_with_choices_mirrors_question_into_content(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + sent = _capture_channel(adapter) + + result = await adapter.send_clarify( + chat_id="555", + question="Which environment should I deploy to?", + choices=["staging", "production"], + clarify_id="cl1", + session_key="discord:555", + ) + + assert result.success is True + assert sent["view"] is not None + assert "Hermes needs your input" in sent["content"] + assert "Which environment should I deploy to?" in sent["content"] + assert "Pick one below" in sent["content"] + + +@pytest.mark.asyncio +async def test_clarify_without_choices_mirrors_question_and_reply_hint(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + sent = _capture_channel(adapter) + + result = await adapter.send_clarify( + chat_id="555", + question="What should the cron schedule be?", + choices=[], + clarify_id="cl2", + session_key="discord:555", + ) + + assert result.success is True + assert sent.get("view") is None + assert "What should the cron schedule be?" in sent["content"] + assert "Reply in this channel" in sent["content"] + + +@pytest.mark.asyncio +async def test_update_prompt_mirrors_prompt_into_content(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + sent = _capture_channel(adapter) + + result = await adapter.send_update_prompt( + chat_id="555", + prompt="Restore stashed changes?", + default="yes", + session_key="discord:555", + ) + + assert result.success is True + assert sent["view"] is not None + assert "Update Needs Your Input" in sent["content"] + assert "Restore stashed changes?" in sent["content"] + assert "(default: yes)" in sent["content"]