fix(discord): unwrap dict choices + soft-boundary truncate clarify buttons

Two bugs surfaced from production usage in #37134:

1. Dict choices rendered as Python repr. LLMs sometimes emit
   [{"description": "..."}] instead of bare strings; the old
   str(c).strip() coercion turned the whole dict into
   "{'description': '...'}" on the button label.

   Fix: add a _flatten_choice helper that unwraps dicts against
   the canonical LLM tool-call user-facing keys (label, description,
   text, title) in that order. Dicts with none of those keys are
   dropped. The "name" and "value" keys are deliberately NOT in the
   priority list — they're Discord-component-shaped fields that
   could appear in dicts that aren't meant to be choices (a
   developer-error wiring that passes a Button-shaped object);
   picking them would leak raw enum values or 4-char model
   identifiers onto user-facing buttons.

2. Mid-word truncation on long button labels. The old
   choice[:72] + "..." cut at position 72, mid-word. Worse, the
   three-char ellipsis ate into the 80-char Discord label cap,
   leaving only 75 chars of body.

   Fix: budget-aware cut strategy with three tiers:
     a. Last space in the trailing half of the budget (word boundary).
     b. Last soft boundary (- , . )) in the trailing half — used
        only when no word boundary exists.
     c. Hard cut at the budget limit (last resort).
   Use single U+2026 (…) to fit the cap. Cut AT soft boundaries
   (inclusive) so the label ends on the boundary char rather than
   on the alpha char that followed it.

Tests:
- test_unwraps_dict_choices_to_description: reproduces the
  screenshot in #37134, asserts the Python repr is gone.
- test_unwrap_prefers_description_over_name_in_multi_key_dict:
  regression guard for the name-key order in the unwrap list.
- test_unwrap_prefers_label_over_description: regression guard
  for label winning over description.
- test_unwrap_does_not_pick_value_or_name_alone: regression
  guard for the "name"/"value" fields being absent.
- test_truncates_long_choice_label: 200-char input, asserts
  total <= 80 and U+2026.
- test_truncates_long_choice_label_breaks_on_word_boundary:
  asserts the cut is on a space, not mid-word.
- test_truncates_long_no_space_choice_on_soft_boundary:
  adversarial input where position 76 is mid-word alpha, asserts
  the renderer falls back to a soft boundary.

Parity: telegram clarify suite (12 tests) still passes; the
helper is a Discord adapter local, not shared with the gateway.

Follow-up: gateway/platforms/telegram.py has the same str(c).strip()
pattern in its own send_clarify and will need a similar fix
(separate PR to keep this diff reviewable).

Fixes #37134
This commit is contained in:
Kenny John Jacob 2026-06-02 02:01:27 +00:00 committed by Teknium
parent 069011dd0c
commit bce1e36b57
2 changed files with 253 additions and 6 deletions

View file

@ -4566,6 +4566,13 @@ class DiscordAdapter(BasePlatformAdapter):
Open-ended mode (``choices`` empty/None): renders the question as
plain embed text no buttons. The gateway's text-intercept captures
the next message in this session and resolves the clarify.
Choice normalisation: ``choices`` may contain bare strings OR dicts
(LLMs sometimes emit ``[{"description": "..."}]`` instead of bare
strings, which would otherwise render as raw Python repr on the
button label). Dict choices are unwrapped against the canonical
LLM tool-call keys ``label``, ``description``, ``text``, ``title``
in that order. Dicts with none of those keys are dropped.
"""
if not self._client or not DISCORD_AVAILABLE:
return SendResult(success=False, error="Not connected")
@ -4591,8 +4598,37 @@ class DiscordAdapter(BasePlatformAdapter):
color=discord.Color.orange(),
)
# Normalise choices: LLMs sometimes emit `[{"description": "..."}]`
# instead of bare strings, which would render as raw Python repr on
# the button label. Unwrap the common shapes, then stringify.
def _flatten_choice(c):
if c is None:
return ""
if isinstance(c, str):
return c.strip()
if isinstance(c, dict):
# Prefer the canonical LLM tool-call user-facing keys
# in the order the LLM is most likely to emit them.
# 'name' and 'value' are deliberately NOT here: they're
# Discord-component-shaped fields that could appear in
# dicts that aren't meant to be choices (e.g., a
# developer-error wiring that passes a Button-shaped
# object). Picking them would leak raw enum values
# or 4-char model identifiers onto user-facing buttons.
# If a dict has none of the canonical keys, drop it
# rather than picking some random field — a garbage
# button label is worse than no button at all.
for key in ("label", "description", "text", "title"):
v = c.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
return ""
if isinstance(c, (list, tuple)):
return " ".join(_flatten_choice(x) for x in c).strip()
return str(c).strip()
clean_choices = [
str(c).strip() for c in (choices or []) if c is not None and str(c).strip()
s for s in (_flatten_choice(c) for c in (choices or [])) if s
]
# Discord allows up to 5 buttons per row, 5 rows per view = 25.
# We reserve one slot for the "Other" button, so cap at 24 choices.
@ -6129,10 +6165,47 @@ def _define_discord_view_classes() -> None:
self.resolved = False
for index, choice in enumerate(self.choices):
# Discord button labels are capped at 80 chars.
label_body = choice if len(choice) <= 75 else choice[:72] + "..."
# Discord button labels are capped at 80 chars. On mobile the
# visible width is much narrower (often <40 chars before it
# wraps to 2 lines and the second line gets cut off), so we
# cap aggressively and cut at a word boundary when possible
# to keep the trailing text readable.
#
# Cut strategy (most-preferred to least-preferred):
# 1. Last space in the trailing half of the budget
# (cleanest word boundary)
# 2. Last soft boundary in the trailing half of the
# 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:
label_body = choice
else:
truncated = choice[: budget - 1].rstrip()
cut_at = -1
# 1. Last space in the trailing half of the budget.
space = truncated.rfind(" ")
if space >= budget // 2:
cut_at = space
# 2. Soft boundary — only if no word boundary found.
# Find the latest soft boundary in the trailing half
# of the budget; that maximizes preserved text length.
# Cut AT the soft boundary (inclusive) so the label
# ends on the soft char (e.g. "-" or ",") rather than
# on the alpha char that followed it.
if cut_at < 0:
latest_soft = max(
(truncated.rfind(s) for s in ("-", ",", ".", ")")),
default=-1,
)
if latest_soft >= budget // 2:
cut_at = latest_soft + 1
if cut_at > 0:
truncated = truncated[:cut_at]
label_body = truncated.rstrip() + ""
button = discord.ui.Button(
label=f"{index + 1}. {label_body}",
label=f"{prefix}{label_body}",
style=discord.ButtonStyle.primary,
custom_id=f"clarify:{clarify_id}:{index}",
)

View file

@ -122,13 +122,56 @@ class TestClarifyChoiceViewConstruction:
clarify_id="cidZ",
allowed_user_ids=set(),
)
# 75 chars + 3 ellipsis chars in the body, plus "1. " prefix
# 78 chars + single-char ellipsis in the body, plus "1. " prefix.
# Uses U+2026 (…) instead of "..." to fit the 80-char Discord cap.
first_label = view.children[0].label
assert first_label.startswith("1. ")
assert first_label.endswith("...")
assert first_label.endswith("\u2026")
# Final label total <= 80 (Discord cap on button labels)
assert 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.
long_choice = (
"Tight, well-illustrated, covers all 3 audiences "
"(patients, families, curious general readers)"
)
view = ClarifyChoiceView(
choices=[long_choice],
clarify_id="cidW",
allowed_user_ids=set(),
)
first_label = view.children[0].label
assert first_label.startswith("1. ")
assert first_label.endswith("\u2026")
# No mid-word fragment before the ellipsis.
assert not first_label.rstrip("\u2026").endswith("(")
def test_truncates_long_no_space_choice_on_soft_boundary(self):
# A long choice with soft boundaries (commas, hyphens) but no spaces
# should still cut on a soft boundary, not mid-word. We use an input
# where position 76 is NOT a soft boundary — the test only passes
# if the renderer actively searches backward for a soft char
# rather than blindly cutting at the budget limit.
long_choice = "a" * 30 + "-" + "b" * 30 + "-" + "c" * 30 + "-" + "d" * 30
# 30a-30b-30c-30d = 30 + 1 + 30 + 1 + 30 + 1 + 30 = 123 chars
# Position 76 is 'b' (a mid-word alpha). The renderer must look back
# for a '-' to cut on.
view = ClarifyChoiceView(
choices=[long_choice],
clarify_id="cidSB",
allowed_user_ids=set(),
)
first_label = view.children[0].label
assert first_label.endswith("\u2026")
assert len(first_label) <= 80
body = first_label[len("1. "):].rstrip("\u2026")
last_char = body[-1]
assert last_char in {"-", ",", ".", ")", " "}, (
f"Label cuts mid-word at {last_char!r}: {first_label!r}"
)
# ===========================================================================
# Choice callback → resolve_gateway_clarify
@ -404,3 +447,134 @@ class TestDiscordSendClarify:
# Only 1 real choice + 1 Other = 2 children
assert len(view.children) == 2
assert "real-choice" in view.children[0].label
@pytest.mark.asyncio
async def test_unwraps_dict_choices_to_description(self):
# LLMs sometimes emit [{"description": "..."}] instead of bare strings
# — the renderer must unwrap common dict shapes, not str() the whole
# dict into a Python repr on the button label.
adapter = _make_adapter()
channel = MagicMock()
sent_msg = MagicMock()
sent_msg.id = 555
channel.send = AsyncMock(return_value=sent_msg)
adapter._client.get_channel = MagicMock(return_value=channel)
malformed = [
{"description": "Tight, well-illustrated"},
{"label": "Use label key"},
{"text": "Use text key"},
"normal-string", # strings still pass through
]
await adapter.send_clarify(
chat_id="9001",
question="?",
choices=malformed,
clarify_id="cidU",
session_key="sk-U",
)
kwargs = channel.send.call_args.kwargs
view = kwargs["view"]
labels = [b.label for b in view.children[:-1]] # exclude Other
# No raw Python repr should leak onto any label.
for label in labels:
assert "{'" not in label
assert "':" not in label
# Each dict unwrapped to its inner string.
assert any("Tight, well-illustrated" in lbl for lbl in labels)
assert any("Use label key" in lbl for lbl in labels)
assert any("Use text key" in lbl for lbl in labels)
assert any("normal-string" in lbl for lbl in labels)
@pytest.mark.asyncio
async def test_unwrap_prefers_description_over_name_in_multi_key_dict(self):
# When the LLM emits both 'name' (often a short identifier in
# OpenAI-style tool calls) and 'description' (the user-facing text),
# the renderer must surface 'description'. The user should never see
# a 4-char model identifier on a button label.
adapter = _make_adapter()
channel = MagicMock()
sent_msg = MagicMock()
sent_msg.id = 666
channel.send = AsyncMock(return_value=sent_msg)
adapter._client.get_channel = MagicMock(return_value=channel)
await adapter.send_clarify(
chat_id="9001",
question="?",
choices=[{"name": "tight", "description": "Tight, well-illustrated"}],
clarify_id="cidN",
session_key="sk-N",
)
kwargs = channel.send.call_args.kwargs
view = kwargs["view"]
choice_label = view.children[0].label
assert "Tight, well-illustrated" in choice_label
# The 'name' value (a short identifier) must NOT have leaked.
body = choice_label.split("1. ", 1)[1].rstrip("\u2026")
assert "tight" not in body, f"'name' leaked onto button: {choice_label!r}"
@pytest.mark.asyncio
async def test_unwrap_prefers_label_over_description(self):
# When both 'label' and 'description' are present, 'label' wins.
# 'label' is the canonical short user-facing text in most LLM tool
# conventions; 'description' is the longer explanation.
adapter = _make_adapter()
channel = MagicMock()
sent_msg = MagicMock()
sent_msg.id = 777
channel.send = AsyncMock(return_value=sent_msg)
adapter._client.get_channel = MagicMock(return_value=channel)
await adapter.send_clarify(
chat_id="9001",
question="?",
choices=[{"label": "Short", "description": "Long verbose explanation"}],
clarify_id="cidL",
session_key="sk-L",
)
kwargs = channel.send.call_args.kwargs
view = kwargs["view"]
choice_label = view.children[0].label
assert "Short" in choice_label
# The longer description must NOT have leaked.
assert "Long verbose" not in choice_label, (
f"'description' leaked over 'label': {choice_label!r}"
)
@pytest.mark.asyncio
async def test_unwrap_does_not_pick_value_or_name_alone(self):
# 'name' and 'value' are Discord-component-shaped fields that could
# accidentally appear in dicts not intended as choices (e.g., a
# developer-error in the gateway wiring). The renderer should not
# surface them as button labels — only the well-known LLM tool-call
# keys (label, description, text, title) should win.
adapter = _make_adapter()
channel = MagicMock()
sent_msg = MagicMock()
sent_msg.id = 888
channel.send = AsyncMock(return_value=sent_msg)
adapter._client.get_channel = MagicMock(return_value=channel)
await adapter.send_clarify(
chat_id="9001",
question="?",
choices=[
{"name": "only_name_here"}, # should be filtered out
{"value": "only_value_here"}, # should be filtered out
{"description": "real choice"},
],
clarify_id="cidNV",
session_key="sk-NV",
)
kwargs = channel.send.call_args.kwargs
view = kwargs["view"]
choice_labels = [b.label for b in view.children[:-1]] # exclude Other
# Only the well-formed dict survives.
assert len(choice_labels) == 1, (
f"Expected 1 choice, got {len(choice_labels)}: {choice_labels!r}"
)
assert "real choice" in choice_labels[0]
for label in choice_labels:
assert "only_name_here" not in label, f"name leaked: {label!r}"
assert "only_value_here" not in label, f"value leaked: {label!r}"