mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
fix(discord): bound component labels by UTF-16 units
This commit is contained in:
parent
b8ce583e05
commit
87be36c240
3 changed files with 98 additions and 9 deletions
|
|
@ -52,6 +52,9 @@ _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0
|
|||
# every slash command — not just the overflow ones. We keep the desired set
|
||||
# at or below this limit at registration time.
|
||||
_DISCORD_MAX_APP_COMMANDS = 100
|
||||
_DISCORD_SELECT_FIELD_LIMIT = 100
|
||||
_DISCORD_BUTTON_LABEL_LIMIT = 80
|
||||
_DISCORD_ELLIPSIS = "\u2026"
|
||||
_DISCORD_NONCONVERSATIONAL_METADATA_KEYS = frozenset({
|
||||
"non_conversational",
|
||||
"non_conversational_history",
|
||||
|
|
@ -117,11 +120,18 @@ from gateway.platforms.base import (
|
|||
cache_document_from_bytes,
|
||||
SUPPORTED_DOCUMENT_TYPES,
|
||||
_TEXT_INJECT_EXTENSIONS,
|
||||
_prefix_within_utf16_limit,
|
||||
utf16_len,
|
||||
validate_inbound_media_size,
|
||||
)
|
||||
from tools.url_safety import is_safe_url
|
||||
|
||||
|
||||
def _truncate_discord_component_text(text: str, limit: int) -> str:
|
||||
"""Return text within Discord's UTF-16 component field budget."""
|
||||
return _prefix_within_utf16_limit(str(text or ""), max(0, limit))
|
||||
|
||||
|
||||
async def _wait_for_ready_or_bot_exit(
|
||||
ready_event: asyncio.Event,
|
||||
bot_task: asyncio.Task,
|
||||
|
|
@ -6888,7 +6898,10 @@ def _define_discord_view_classes() -> None:
|
|||
desc = "current" if p.get("is_current") else None
|
||||
options.append(
|
||||
discord.SelectOption(
|
||||
label=label[:100],
|
||||
label=_truncate_discord_component_text(
|
||||
label,
|
||||
_DISCORD_SELECT_FIELD_LIMIT,
|
||||
),
|
||||
value=p["slug"],
|
||||
description=desc,
|
||||
)
|
||||
|
|
@ -6925,8 +6938,14 @@ def _define_discord_view_classes() -> None:
|
|||
short = model_id.split("/")[-1] if "/" in model_id else model_id
|
||||
options.append(
|
||||
discord.SelectOption(
|
||||
label=short[:100],
|
||||
value=model_id[:100],
|
||||
label=_truncate_discord_component_text(
|
||||
short,
|
||||
_DISCORD_SELECT_FIELD_LIMIT,
|
||||
),
|
||||
value=_truncate_discord_component_text(
|
||||
model_id,
|
||||
_DISCORD_SELECT_FIELD_LIMIT,
|
||||
),
|
||||
)
|
||||
)
|
||||
if not options:
|
||||
|
|
@ -7205,15 +7224,18 @@ def _define_discord_view_classes() -> None:
|
|||
# budget (hyphen, comma, period, paren)
|
||||
# 3. Hard cut at the budget limit (last resort)
|
||||
prefix = f"{index + 1}. "
|
||||
budget = 80 - len(prefix)
|
||||
if len(choice) <= budget:
|
||||
budget = _DISCORD_BUTTON_LABEL_LIMIT - utf16_len(prefix)
|
||||
if utf16_len(choice) <= budget:
|
||||
label_body = choice
|
||||
else:
|
||||
truncated = choice[: budget - 1].rstrip()
|
||||
truncated = _prefix_within_utf16_limit(
|
||||
choice,
|
||||
max(0, budget - utf16_len(_DISCORD_ELLIPSIS)),
|
||||
).rstrip()
|
||||
cut_at = -1
|
||||
# 1. Last space in the trailing half of the budget.
|
||||
space = truncated.rfind(" ")
|
||||
if space >= budget // 2:
|
||||
if space >= len(truncated) // 2:
|
||||
cut_at = space
|
||||
# 2. Soft boundary — only if no word boundary found.
|
||||
# Find the latest soft boundary in the trailing half
|
||||
|
|
@ -7226,11 +7248,11 @@ def _define_discord_view_classes() -> None:
|
|||
(truncated.rfind(s) for s in ("-", ",", ".", ")")),
|
||||
default=-1,
|
||||
)
|
||||
if latest_soft >= budget // 2:
|
||||
if latest_soft >= len(truncated) // 2:
|
||||
cut_at = latest_soft + 1
|
||||
if cut_at > 0:
|
||||
truncated = truncated[:cut_at]
|
||||
label_body = truncated.rstrip() + "…"
|
||||
label_body = truncated.rstrip() + _DISCORD_ELLIPSIS
|
||||
button = discord.ui.Button(
|
||||
label=f"{prefix}{label_body}",
|
||||
style=discord.ButtonStyle.primary,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from plugins.platforms.discord.adapter import ( # noqa: E402
|
|||
DiscordAdapter,
|
||||
)
|
||||
from gateway.config import PlatformConfig # noqa: E402
|
||||
from gateway.platforms.base import utf16_len # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -130,6 +131,19 @@ class TestClarifyChoiceViewConstruction:
|
|||
# Final label total <= 80 (Discord cap on button labels)
|
||||
assert len(first_label) <= 80
|
||||
|
||||
def test_truncates_emoji_choice_label_by_utf16_limit(self):
|
||||
long_choice = "\U0001f600" * 80
|
||||
view = ClarifyChoiceView(
|
||||
choices=[long_choice],
|
||||
clarify_id="cidEmoji",
|
||||
allowed_user_ids=set(),
|
||||
)
|
||||
|
||||
first_label = view.children[0].label
|
||||
assert first_label.startswith("1. ")
|
||||
assert first_label.endswith("\u2026")
|
||||
assert utf16_len(first_label) <= 80
|
||||
|
||||
def test_truncates_long_choice_label_breaks_on_word_boundary(self):
|
||||
# Long choice with spaces — should cut at the last whole word so the
|
||||
# trailing text stays readable on Discord mobile.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from unittest.mock import AsyncMock
|
|||
|
||||
import pytest
|
||||
|
||||
from gateway.platforms.base import utf16_len
|
||||
from plugins.platforms.discord.adapter import ModelPickerView
|
||||
|
||||
|
||||
|
|
@ -82,6 +83,58 @@ async def test_model_picker_clears_controls_before_running_switch_callback():
|
|||
interaction.edit_original_response.assert_awaited_once()
|
||||
|
||||
|
||||
def test_model_picker_provider_labels_fit_discord_utf16_limit():
|
||||
provider_name = "Provider " + ("\U0001f600" * 80)
|
||||
|
||||
view = ModelPickerView(
|
||||
providers=[
|
||||
{
|
||||
"slug": "emoji",
|
||||
"name": provider_name,
|
||||
"models": ["gpt-5-mini"],
|
||||
"total_models": 1,
|
||||
"is_current": False,
|
||||
}
|
||||
],
|
||||
current_model="gpt-5-mini",
|
||||
current_provider="emoji",
|
||||
session_key="session-1",
|
||||
on_model_selected=AsyncMock(return_value="ok"),
|
||||
allowed_user_ids={"123"},
|
||||
)
|
||||
|
||||
provider_select = view.children[0]
|
||||
option = provider_select.options[0]
|
||||
assert utf16_len(option.label) <= 100
|
||||
|
||||
|
||||
def test_model_picker_model_labels_and_values_fit_discord_utf16_limit():
|
||||
model_id = "emoji/" + ("\U0001f600" * 80)
|
||||
|
||||
view = ModelPickerView(
|
||||
providers=[
|
||||
{
|
||||
"slug": "emoji",
|
||||
"name": "Emoji",
|
||||
"models": [model_id],
|
||||
"total_models": 1,
|
||||
"is_current": False,
|
||||
}
|
||||
],
|
||||
current_model="gpt-5-mini",
|
||||
current_provider="emoji",
|
||||
session_key="session-1",
|
||||
on_model_selected=AsyncMock(return_value="ok"),
|
||||
allowed_user_ids={"123"},
|
||||
)
|
||||
|
||||
view._build_model_select("emoji")
|
||||
model_select = view.children[0]
|
||||
option = model_select.options[0]
|
||||
assert utf16_len(option.label) <= 100
|
||||
assert utf16_len(option.value) <= 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expensive_model_requires_confirmation(monkeypatch):
|
||||
events: list[object] = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue