mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
fix(discord): mirror all interactive prompt payloads into message content
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).
This commit is contained in:
parent
2acbdd1848
commit
009b42d008
3 changed files with 171 additions and 3 deletions
|
|
@ -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)])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
123
tests/gateway/test_discord_prompt_content_siblings.py
Normal file
123
tests/gateway/test_discord_prompt_content_siblings.py
Normal file
|
|
@ -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"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue