mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
fix(slack): guard rich_text builders against empty content rejected as invalid_blocks
Slack rejects a rich_text_section / rich_text_preformatted / rich_text_quote
whose elements list is empty or contains a zero-length text element, and a
header whose plain_text is empty. The Block Kit renderer emits exactly those
shapes for common inputs: a markdown table with a blank cell or a ragged
(short) row, an empty fenced code block, a blank quote line, an empty list
item, and an emphasis-only header ("# ***"). Any one of them poisons the
whole payload — chat.postMessage fails with invalid_blocks ("missing element"
/ "must be more than 0 characters") and the message loses its rich rendering
entirely.
Route every rich_text builder's child elements through _nonempty_elements
(drop zero-length text elements; substitute a single space when nothing
remains) and skip headers that reduce to empty after markdown-marker
stripping. Observed in production via live chat.postMessage rejections;
regression tests cover each case plus a well-formed-content control.
Complementary to #56618 (column_settings hardening + no-blocks retry): that
change makes Block Kit rejections recoverable, this one makes the common
empty-content cases render correctly in the first place. No overlapping hunks.
This commit is contained in:
parent
870770b31e
commit
d91a143977
2 changed files with 118 additions and 7 deletions
|
|
@ -146,9 +146,31 @@ def _inline_elements(text: str) -> List[Dict[str, Any]]:
|
|||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _header_block(text: str) -> Block:
|
||||
def _nonempty_elements(elements: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Make a rich_text child-element list safe for Slack.
|
||||
|
||||
Slack rejects any ``rich_text_section`` / ``rich_text_preformatted`` /
|
||||
``rich_text_quote`` whose ``elements`` list is empty or contains a ``text``
|
||||
element of zero length (``invalid_blocks``: "missing element" / "must be
|
||||
more than 0 characters"). Empty content is common — ragged table rows are
|
||||
padded with ``""``, agents emit empty code fences around empty tool output,
|
||||
blank quote lines and empty list items occur in the wild — so drop
|
||||
zero-length text elements and, if nothing remains, substitute a single
|
||||
space, which renders as blank yet stays schema-valid. Used by every
|
||||
rich_text builder so empty content can never poison the whole payload.
|
||||
"""
|
||||
els = [e for e in elements if not (e.get("type") == "text" and not e.get("text"))]
|
||||
return els or [{"type": "text", "text": " "}]
|
||||
|
||||
|
||||
def _header_block(text: str) -> Optional[Block]:
|
||||
# header blocks are plain_text only, 150 char cap.
|
||||
clean = re.sub(r"[*_~`]", "", text).strip()
|
||||
if not clean:
|
||||
# Emphasis-/whitespace-only header (e.g. "# ***" or "# ") reduces to
|
||||
# empty; Slack rejects an empty plain_text with invalid_blocks. Skip it
|
||||
# (caller drops None) rather than poison the whole payload.
|
||||
return None
|
||||
if len(clean) > MAX_HEADER_TEXT:
|
||||
clean = clean[: MAX_HEADER_TEXT - 1] + "…"
|
||||
return {"type": "header", "text": {"type": "plain_text", "text": clean, "emoji": True}}
|
||||
|
|
@ -165,7 +187,7 @@ def _preformatted_block(text: str) -> Block:
|
|||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"elements": [{"type": "text", "text": text.rstrip("\n")}],
|
||||
"elements": _nonempty_elements([{"type": "text", "text": text.rstrip("\n")}]),
|
||||
}
|
||||
],
|
||||
}
|
||||
|
|
@ -179,7 +201,7 @@ def _quote_block(lines: List[str]) -> Block:
|
|||
section_children.extend(_inline_elements(ln))
|
||||
return {
|
||||
"type": "rich_text",
|
||||
"elements": [{"type": "rich_text_quote", "elements": section_children}],
|
||||
"elements": [{"type": "rich_text_quote", "elements": _nonempty_elements(section_children)}],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -207,7 +229,7 @@ def _list_block(items: List[Tuple[int, bool, str]]) -> Block:
|
|||
cur_key = key
|
||||
assert cur is not None
|
||||
cur["elements"].append(
|
||||
{"type": "rich_text_section", "elements": _inline_elements(text)}
|
||||
{"type": "rich_text_section", "elements": _nonempty_elements(_inline_elements(text))}
|
||||
)
|
||||
return {"type": "rich_text", "elements": elements}
|
||||
|
||||
|
|
@ -252,11 +274,16 @@ def _split_row(row: str) -> List[str]:
|
|||
|
||||
|
||||
def _rich_text_cell(text: str) -> Dict[str, Any]:
|
||||
"""A ``rich_text`` table cell carrying inline-formatted content."""
|
||||
"""A ``rich_text`` table cell carrying inline-formatted content.
|
||||
|
||||
Empty cells are common (ragged rows are padded with ``""``); Slack rejects
|
||||
a cell whose section is empty or carries a zero-length text element, so the
|
||||
elements are routed through ``_nonempty_elements``.
|
||||
"""
|
||||
return {
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{"type": "rich_text_section", "elements": _inline_elements(text)}
|
||||
{"type": "rich_text_section", "elements": _nonempty_elements(_inline_elements(text))}
|
||||
],
|
||||
}
|
||||
|
||||
|
|
@ -409,7 +436,9 @@ def render_blocks(
|
|||
hm = _HEADER_RE.match(line)
|
||||
if hm:
|
||||
flush_para()
|
||||
blocks.append(_header_block(hm.group(2)))
|
||||
header = _header_block(hm.group(2))
|
||||
if header is not None:
|
||||
blocks.append(header)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -251,3 +251,85 @@ class TestLimits:
|
|||
for junk in ["```unterminated\ncode", "| broken | table", "> ", "#" * 10]:
|
||||
# must not raise; either blocks or None
|
||||
render_blocks(junk)
|
||||
|
||||
|
||||
class TestEmptyContentGuards:
|
||||
"""Empty content must never produce a Slack-rejected (invalid_blocks) payload.
|
||||
|
||||
Slack rejects a rich_text_section / rich_text_preformatted /
|
||||
rich_text_quote whose ``elements`` is empty or contains a zero-length
|
||||
``text`` element, and a ``header`` whose plain_text is empty. Each guard
|
||||
below corresponds to a real chat.postMessage rejection observed in
|
||||
production ("missing element" / "must be more than 0 characters").
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _assert_schema_valid(blocks):
|
||||
def walk(o):
|
||||
if isinstance(o, dict):
|
||||
if o.get("type") in (
|
||||
"rich_text_section", "rich_text_preformatted", "rich_text_quote"
|
||||
):
|
||||
assert o.get("elements"), f"empty {o['type']} elements"
|
||||
if o.get("type") == "text":
|
||||
assert len(o.get("text", "")) > 0, "zero-length text element"
|
||||
if o.get("type") == "header":
|
||||
assert o["text"]["text"], "empty plain_text header"
|
||||
for v in o.values():
|
||||
walk(v)
|
||||
elif isinstance(o, list):
|
||||
for v in o:
|
||||
walk(v)
|
||||
|
||||
walk(blocks)
|
||||
|
||||
def test_ragged_and_empty_table_cells_are_schema_valid(self):
|
||||
# Blank middle cell + ragged short row (padded with "") must not emit
|
||||
# an empty section or a 0-char text element.
|
||||
md = (
|
||||
"| x | y | z |\n"
|
||||
"| --- | --- | --- |\n"
|
||||
"| 1 | | 3 |\n" # blank middle cell
|
||||
"| 4 |" # ragged row -> padded with empty cells
|
||||
)
|
||||
blocks = render_blocks(md)
|
||||
assert blocks[0]["type"] == "table"
|
||||
self._assert_schema_valid(blocks)
|
||||
|
||||
def test_empty_code_fence_quote_and_list_item_are_schema_valid(self):
|
||||
# Empty fenced code block (common around empty tool output), blank
|
||||
# quote line, and empty list item must all stay schema-valid.
|
||||
md = "```\n```\n\n> \n\n- \n- real item"
|
||||
blocks = render_blocks(md)
|
||||
assert blocks is not None
|
||||
self._assert_schema_valid(blocks)
|
||||
|
||||
def test_multiline_quote_preserves_newline_separators(self):
|
||||
# _quote_block separates lines with length-1 "\n" text elements; the
|
||||
# guard must KEEP them so a multi-line blockquote stays multi-line.
|
||||
blocks = render_blocks("> alpha\n> bravo")
|
||||
quote = None
|
||||
for b in blocks:
|
||||
for el in b.get("elements", []):
|
||||
if isinstance(el, dict) and el.get("type") == "rich_text_quote":
|
||||
quote = el
|
||||
assert quote is not None, "no rich_text_quote produced"
|
||||
texts = [e.get("text") for e in quote["elements"] if e.get("type") == "text"]
|
||||
assert "\n" in texts, "newline separator dropped from multi-line quote"
|
||||
assert any("alpha" in (t or "") for t in texts)
|
||||
assert any("bravo" in (t or "") for t in texts)
|
||||
|
||||
def test_emphasis_only_header_is_dropped_not_empty(self):
|
||||
# "# ***" reduces to "" after marker-strip; an empty plain_text header
|
||||
# is rejected by Slack, so the header is skipped entirely.
|
||||
blocks = render_blocks("# ***\n\nreal body")
|
||||
assert not any(b.get("type") == "header" for b in blocks)
|
||||
self._assert_schema_valid(blocks)
|
||||
|
||||
def test_normal_content_unaffected(self):
|
||||
# Guard must not alter well-formed content.
|
||||
md = "# Title\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n> quoted\n\n- item"
|
||||
blocks = render_blocks(md)
|
||||
assert any(b.get("type") == "header" for b in blocks)
|
||||
assert any(b.get("type") == "table" for b in blocks)
|
||||
self._assert_schema_valid(blocks)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue