Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion plugins/platforms/slack/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,47 @@ def _walk_elements(elements: list, quote_depth: int = 0, bullet: str = "") -> No
return "\n".join(parts)


def _extract_text_from_slack_attachments(attachments: list) -> str:
"""Extract readable text from legacy Slack message ``attachments``.

Apps such as Alertmanager, Grafana, PagerDuty, and CI bots post messages
with an empty top-level ``text`` and the real content inside ``attachments``
(Slack's legacy secondary-content format) or nested Block Kit ``blocks``.
Without this, such messages are invisible when the agent reads thread
history — e.g. an alert that started the very thread the agent was asked to
investigate would come through blank.

Prefers structured fields (``pretext``/``title``/``text``/``fields``) and
only falls back to an attachment's ``fallback`` string when it carries
nothing else.
"""
if not attachments:
return ""

lines: list[str] = []
for att in attachments:
if not isinstance(att, dict):
continue
got: list[str] = [
str(att[key]) for key in ("pretext", "title", "text") if att.get(key)
]
for field in att.get("fields", []) or []:
if not isinstance(field, dict):
continue
got += [str(field[k]) for k in ("title", "value") if field.get(k)]
nested = att.get("blocks")
if nested:
block_text = _extract_text_from_slack_blocks(nested)
if block_text:
got.append(block_text)
# Only use the (often duplicative) fallback when nothing structured exists.
if not got and att.get("fallback"):
got.append(str(att["fallback"]))
lines += got

return "\n".join(line for line in lines if line).strip()


def _serialize_slack_blocks_for_agent(blocks: list, max_chars: int = 6000) -> str:
"""Return a compact, redacted JSON view of the current message's Block Kit payload."""
if not blocks:
Expand Down Expand Up @@ -3785,7 +3826,18 @@ async def _fetch_thread_context(
):
continue

msg_text = msg.get("text", "").strip()
msg_text = (msg.get("text") or "").strip()
# Apps (Alertmanager, Grafana, CI bots) often post with an empty
# ``text`` and the content in blocks/attachments — fall back so
# messages that started or populate the thread aren't dropped.
if not msg_text:
msg_text = _extract_text_from_slack_blocks(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_extract_text_from_slack_blocks currently renders only rich_text blocks (adapter.py:203-205). This leaves ordinary text-bearing section blocks empty here, so the message is still skipped. Please cover standard Block Kit text blocks as well and add a matching regression test.

msg.get("blocks")
).strip()
if not msg_text:
msg_text = _extract_text_from_slack_attachments(
msg.get("attachments")
).strip()
if not msg_text:
continue

Expand Down Expand Up @@ -3889,6 +3941,14 @@ async def _fetch_thread_parent_text(
return ""
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
text = (parent.get("text") or "").strip()
# App-posted parents (e.g. an Alertmanager alert) carry their content
# in blocks/attachments with an empty ``text`` — fall back to those.
if not text:
text = _extract_text_from_slack_blocks(parent.get("blocks")).strip()
if not text:
text = _extract_text_from_slack_attachments(
parent.get("attachments")
).strip()
if bot_uid:
text = text.replace(f"<@{bot_uid}>", "").strip()
return text
Expand Down
111 changes: 111 additions & 0 deletions tests/gateway/test_slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -4187,3 +4187,114 @@ async def test_auth_check_exception_does_not_crash_fetch(self, adapter):
# Renders successfully without trust tag (exception → unknown trust).
assert "U_X: hello" in content
assert "[unverified]" not in content


# ---------------------------------------------------------------------------
# TestThreadContextAppMessages
# ---------------------------------------------------------------------------


class TestThreadContextAppMessages:
"""App-posted messages (Alertmanager, Grafana, CI bots) frequently carry
their content in ``attachments``/``blocks`` with an empty top-level
``text``. Thread-context must fall back to those so, e.g., an alert that
started the thread the bot was asked to investigate is not dropped."""

@staticmethod
def _make_replies(messages):
return AsyncMock(return_value={"messages": messages})

@pytest.mark.asyncio
async def test_attachment_only_parent_is_included(self, adapter):
"""Alertmanager-style parent: empty text, content in a legacy attachment."""
adapter._thread_context_cache.clear()
messages = [
{ # parent posted by the Alertmanager app: text="" , content in attachment
"ts": "100.0",
"bot_id": "B_ALERTMGR",
"subtype": "bot_message",
"username": "Alertmanager",
"text": "",
"attachments": [
{
"fallback": "[FIRING:1] KubeJobFailed cluster-01 "
"batch-job-123456",
"color": "danger",
}
],
},
{"ts": "101.0", "user": "U_BOB", "text": "<@U_BOT> investigate"},
]
adapter._app.client.conversations_replies = self._make_replies(messages)

with patch.object(
adapter, "_resolve_user_name",
new=AsyncMock(side_effect=lambda uid, **_: uid),
):
content = await adapter._fetch_thread_context(
channel_id="C1", thread_ts="100.0", current_ts="999.0",
)

# The alert text (previously dropped) is now present in the context.
assert "KubeJobFailed" in content
assert "batch-job-123456" in content
assert "[thread parent]" in content

@pytest.mark.asyncio
async def test_blocks_only_message_is_included(self, adapter):
"""Block Kit message with empty text falls back to block text."""
adapter._thread_context_cache.clear()
messages = [
{"ts": "100.0", "user": "U_BOB", "text": "kickoff"},
{
"ts": "101.0",
"bot_id": "B_CI",
"subtype": "bot_message",
"username": "CI",
"text": "",
"blocks": [
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_section",
"elements": [
{"type": "text", "text": "deploy #42 succeeded"}
],
}
],
}
],
},
]
adapter._app.client.conversations_replies = self._make_replies(messages)

with patch.object(
adapter, "_resolve_user_name",
new=AsyncMock(side_effect=lambda uid, **_: uid),
):
content = await adapter._fetch_thread_context(
channel_id="C1", thread_ts="100.0", current_ts="999.0",
)

assert "deploy #42 succeeded" in content

@pytest.mark.asyncio
async def test_message_without_any_text_is_skipped(self, adapter):
"""A message with no text/blocks/attachments is still skipped (no crash)."""
adapter._thread_context_cache.clear()
messages = [
{"ts": "100.0", "user": "U_BOB", "text": "hello"},
{"ts": "101.0", "bot_id": "B_X", "subtype": "bot_message", "text": ""},
]
adapter._app.client.conversations_replies = self._make_replies(messages)

with patch.object(
adapter, "_resolve_user_name",
new=AsyncMock(side_effect=lambda uid, **_: uid),
):
content = await adapter._fetch_thread_context(
channel_id="C1", thread_ts="100.0", current_ts="999.0",
)

assert "hello" in content # the real message survives; empty bot msg dropped