feat(slack): cover agent view assistant APIs

This commit is contained in:
Edison42 2026-07-02 15:15:35 +08:00 committed by Teknium
parent 9a3b676fed
commit f1328a6bfd
3 changed files with 558 additions and 16 deletions

View file

@ -472,8 +472,14 @@ class SlackAdapter(BasePlatformAdapter):
# Track message IDs that should get reaction lifecycle (DMs / @mentions).
self._reacting_message_ids: set = set()
# Track active assistant thread status indicators so stop_typing can
# clear them (chat_id → thread_ts).
self._active_status_threads: Dict[str, str] = {}
# clear them ((chat_id, thread_ts) → {"thread_ts": str, "team_id": str}).
# Keeping team_id avoids clearing via the wrong WebClient in
# multi-workspace Socket Mode installs.
self._active_status_threads: Dict[Any, Dict[str, str]] = {}
# Best-effort guard so automatic Slack AI thread titles are set once
# per visible DM thread instead of on every reply.
self._titled_assistant_threads: set = set()
self._TITLED_ASSISTANT_THREADS_MAX = 5000
# Slash-command contexts: stash response_url + user_id so send()
# can route the first reply ephemerally. Keyed by
# (channel_id, user_id) to avoid cross-user collisions.
@ -1189,6 +1195,8 @@ class SlackAdapter(BasePlatformAdapter):
):
self._app.action(_action_id)(self._handle_slash_confirm_action)
self._app.action("hermes_feedback")(self._handle_feedback_action)
# Register plugin-provided Block Kit action handlers.
#
# Plugins call ``ctx.register_slack_action_handler(action_id, cb)``
@ -1346,8 +1354,27 @@ class SlackAdapter(BasePlatformAdapter):
logger.info("[Slack] Disconnected")
def _get_client(self, chat_id: str) -> Any:
@staticmethod
def _metadata_team_id(metadata: Optional[Dict[str, Any]]) -> str:
"""Return Slack workspace id from generic or Slack-specific metadata."""
if not metadata:
return ""
return str(
metadata.get("team_id")
or metadata.get("team")
or metadata.get("slack_team_id")
or ""
)
@staticmethod
def _active_status_key(chat_id: str, thread_ts: str) -> Tuple[str, str]:
"""Return the per-thread key for active Slack AI status tracking."""
return (str(chat_id), str(thread_ts))
def _get_client(self, chat_id: str, team_id: Optional[str] = None) -> Any:
"""Return the workspace-specific WebClient for a channel."""
if team_id and team_id in self._team_clients:
return self._team_clients[team_id]
team_id = self._channel_team.get(chat_id)
if team_id and team_id in self._team_clients:
return self._team_clients[team_id]
@ -1364,6 +1391,7 @@ class SlackAdapter(BasePlatformAdapter):
if not self._app:
return SendResult(success=False, error="Not connected")
thread_ts = None
try:
# Check for a pending slash-command context. When the user ran a
# native slash command (e.g. /q, /stop, /model), the initial ack
@ -1411,11 +1439,13 @@ class SlackAdapter(BasePlatformAdapter):
if broadcast and i == 0:
kwargs["reply_broadcast"] = True
last_result = await self._get_client(chat_id).chat_postMessage(**kwargs)
last_result = await self._get_client(
chat_id, team_id=self._metadata_team_id(metadata)
).chat_postMessage(**kwargs)
# Clear Slack Assistant status as soon as the final message is posted.
if thread_ts:
await self.stop_typing(chat_id)
await self.stop_typing(chat_id, metadata=metadata)
# Track the sent message ts so we can auto-respond to thread
# replies without requiring @mention.
@ -1437,6 +1467,8 @@ class SlackAdapter(BasePlatformAdapter):
)
except Exception as e: # pragma: no cover - defensive logging
if thread_ts:
await self.stop_typing(chat_id, metadata=metadata)
logger.error("[Slack] Send error: %s", e, exc_info=True)
return SendResult(success=False, error=str(e))
@ -1466,7 +1498,9 @@ class SlackAdapter(BasePlatformAdapter):
if thread_ts:
kwargs["thread_ts"] = thread_ts
result = await self._get_client(chat_id).chat_postEphemeral(**kwargs)
result = await self._get_client(
chat_id, team_id=self._metadata_team_id(metadata)
).chat_postEphemeral(**kwargs)
return SendResult(
success=True,
message_id=result.get("message_ts") or result.get("ts"),
@ -1483,6 +1517,7 @@ class SlackAdapter(BasePlatformAdapter):
content: str,
*,
finalize: bool = False,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Edit a previously sent Slack message."""
if not self._app:
@ -1502,11 +1537,15 @@ class SlackAdapter(BasePlatformAdapter):
blocks = self._maybe_blocks(content)
if blocks:
update_kwargs["blocks"] = blocks
await self._get_client(chat_id).chat_update(**update_kwargs)
await self._get_client(
chat_id, team_id=self._metadata_team_id(metadata)
).chat_update(**update_kwargs)
if finalize:
await self.stop_typing(chat_id)
await self.stop_typing(chat_id, metadata=metadata)
return SendResult(success=True, message_id=message_id)
except Exception as e: # pragma: no cover - defensive logging
if finalize:
await self.stop_typing(chat_id, metadata=metadata)
logger.error(
"[Slack] Failed to edit message %s in channel %s: %s",
message_id,
@ -1533,9 +1572,16 @@ class SlackAdapter(BasePlatformAdapter):
if not thread_ts:
return # Can only set status in a thread context
self._active_status_threads[chat_id] = thread_ts
team_id = self._metadata_team_id(metadata)
if not team_id:
team_id = self._channel_team.get(chat_id, "")
self._active_status_threads[self._active_status_key(chat_id, str(thread_ts))] = {
"thread_ts": str(thread_ts),
"team_id": str(team_id) if team_id else "",
}
try:
await self._get_client(chat_id).assistant_threads_setStatus(
await self._get_client(chat_id, team_id=team_id).assistant_threads_setStatus(
channel_id=chat_id,
thread_ts=thread_ts,
status="is thinking...",
@ -1549,11 +1595,40 @@ class SlackAdapter(BasePlatformAdapter):
"""Clear the assistant thread status indicator."""
if not self._app:
return
thread_ts = self._active_status_threads.pop(chat_id, None)
requested_thread_ts = ""
if metadata:
requested_thread_ts = str(
metadata.get("thread_id") or metadata.get("thread_ts") or ""
)
active_key = (
self._active_status_key(chat_id, requested_thread_ts)
if requested_thread_ts
else chat_id
)
active = self._active_status_threads.pop(active_key, None)
if active is None and requested_thread_ts:
active = self._active_status_threads.pop(chat_id, None)
if active is None and not requested_thread_ts:
matching_keys = [
key
for key in self._active_status_threads
if isinstance(key, tuple) and key[0] == str(chat_id)
]
if len(matching_keys) == 1:
active = self._active_status_threads.pop(matching_keys[0], None)
if isinstance(active, str):
thread_ts = active
team_id = ""
else:
active = active or {}
thread_ts = active.get("thread_ts", "")
team_id = active.get("team_id", "")
if metadata:
team_id = self._metadata_team_id(metadata) or team_id
if not thread_ts:
return
try:
await self._get_client(chat_id).assistant_threads_setStatus(
await self._get_client(chat_id, team_id=team_id).assistant_threads_setStatus(
channel_id=chat_id,
thread_ts=thread_ts,
status="",
@ -1889,6 +1964,47 @@ class SlackAdapter(BasePlatformAdapter):
return False
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
def _feedback_buttons_enabled(self) -> bool:
"""Whether to include Slack AI feedback buttons on final responses."""
raw = self.config.extra.get("feedback_buttons")
if raw is None:
return False
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
def _feedback_block(self) -> Dict[str, Any]:
"""Return Slack AI feedback controls from the Agent docs."""
return {
"type": "context_actions",
"elements": [
{
"type": "feedback_buttons",
"action_id": "hermes_feedback",
"positive_button": {
"text": {"type": "plain_text", "text": "Good Response"},
"accessibility_label": (
"Submit positive feedback on this response"
),
"value": "positive",
},
"negative_button": {
"text": {"type": "plain_text", "text": "Bad Response"},
"accessibility_label": (
"Submit negative feedback on this response"
),
"value": "negative",
},
}
],
}
def _append_feedback_block(self, blocks: Optional[list]) -> Optional[list]:
"""Append response feedback controls when enabled and block budget allows."""
if not blocks or not self._feedback_buttons_enabled():
return blocks
if len(blocks) >= 50:
return blocks
return [*blocks, self._feedback_block()]
def _maybe_blocks(self, content: str) -> Optional[list]:
"""Render ``content`` to Block Kit blocks when the feature is enabled.
@ -1897,10 +2013,11 @@ class SlackAdapter(BasePlatformAdapter):
falls back to the plain ``text`` payload. A ``text`` fallback is ALWAYS
sent alongside blocks, so this can safely return ``None`` at any time.
"""
if not self._rich_blocks_enabled():
if not self._rich_blocks_enabled() and not self._feedback_buttons_enabled():
return None
try:
return render_blocks(content, mrkdwn_fn=self.format_message)
blocks = render_blocks(content, mrkdwn_fn=self.format_message)
return self._append_feedback_block(blocks)
except Exception: # pragma: no cover - renderer already guards itself
logger.debug("[Slack] block render failed; using plain text", exc_info=True)
return None
@ -2475,6 +2592,123 @@ class SlackAdapter(BasePlatformAdapter):
return merged
return metadata
def _assistant_suggested_prompts(self) -> Tuple[str, List[Dict[str, str]]]:
"""Return config.yaml-defined Slack AI suggested prompts.
Supported shapes under ``platforms.slack.extra.suggested_prompts``:
- ``[{title, message}, ...]``
- ``{title: "...", prompts: [{title, message}, ...]}``
Invalid rows are ignored. Slack recommends up to four suggestions; keep
that bound here so a broad config cannot produce an invalid API call.
"""
raw = self.config.extra.get("suggested_prompts")
title = ""
prompt_rows = raw
if isinstance(raw, dict):
title = str(raw.get("title") or "").strip()
prompt_rows = raw.get("prompts")
if not isinstance(prompt_rows, list):
return title, []
prompts: List[Dict[str, str]] = []
for item in prompt_rows:
if not isinstance(item, dict):
continue
prompt_title = str(item.get("title") or "").strip()
prompt_message = str(item.get("message") or "").strip()
if not prompt_title or not prompt_message:
continue
prompts.append({"title": prompt_title[:75], "message": prompt_message})
if len(prompts) >= 4:
break
return title, prompts
async def _set_assistant_suggested_prompts(
self,
channel_id: str,
*,
team_id: str = "",
thread_ts: str = "",
) -> None:
"""Best-effort Slack AI suggested prompts setup."""
if not self._app or not channel_id:
return
title, prompts = self._assistant_suggested_prompts()
if not prompts:
return
kwargs: Dict[str, Any] = {
"channel_id": channel_id,
"prompts": prompts,
}
if title:
kwargs["title"] = title
if thread_ts:
kwargs["thread_ts"] = thread_ts
try:
await self._get_client(channel_id, team_id=team_id).assistant_threads_setSuggestedPrompts(
**kwargs
)
except Exception as e:
logger.debug("[Slack] assistant.threads.setSuggestedPrompts failed: %s", e)
def _assistant_thread_title_enabled(self) -> bool:
raw = self.config.extra.get("assistant_thread_titles", True)
if isinstance(raw, str):
return raw.strip().lower() not in {"0", "false", "no", "off"}
return bool(raw)
async def _set_assistant_thread_title(
self,
channel_id: str,
thread_ts: str,
title_source: str,
*,
team_id: str = "",
) -> None:
"""Best-effort title for visible Slack AI DM threads."""
if (
not self._app
or not channel_id
or not thread_ts
or not title_source
or not self._assistant_thread_title_enabled()
):
return
key = self._assistant_thread_key(channel_id, thread_ts)
if not key or key in self._titled_assistant_threads:
return
title = re.sub(r"\s+", " ", title_source).strip()
if not title or title.startswith("/"):
return
if len(title) > 80:
title = title[:77].rstrip() + "..."
try:
await self._get_client(channel_id, team_id=team_id).assistant_threads_setTitle(
channel_id=channel_id,
thread_ts=thread_ts,
title=title,
)
except Exception as e:
logger.debug("[Slack] assistant.threads.setTitle failed: %s", e)
return
self._titled_assistant_threads.add(key)
if len(self._titled_assistant_threads) > self._TITLED_ASSISTANT_THREADS_MAX:
excess = (
len(self._titled_assistant_threads)
- self._TITLED_ASSISTANT_THREADS_MAX // 2
)
for old_key in list(self._titled_assistant_threads)[:excess]:
self._titled_assistant_threads.discard(old_key)
def _seed_assistant_thread_session(self, metadata: Dict[str, str]) -> None:
"""Prime the session store so assistant threads get stable user scoping."""
session_store = getattr(self, "_session_store", None)
@ -2544,6 +2778,11 @@ class SlackAdapter(BasePlatformAdapter):
metadata = self._extract_assistant_thread_metadata(event)
self._cache_assistant_thread_metadata(metadata)
self._seed_assistant_thread_session(metadata)
await self._set_assistant_suggested_prompts(
metadata.get("channel_id", ""),
team_id=metadata.get("team_id", ""),
thread_ts=metadata.get("thread_ts", ""),
)
async def _handle_app_home_opened(self, event: dict) -> None:
"""Handle Slack Agent DM-open lifecycle events without producing replies."""
@ -2564,6 +2803,10 @@ class SlackAdapter(BasePlatformAdapter):
"team_id": str(team_id) if team_id else "",
}
)
await self._set_assistant_suggested_prompts(
str(channel_id) if channel_id else "",
team_id=str(team_id) if team_id else "",
)
async def _handle_slack_file_shared(self, event: dict) -> None:
"""Fallback for Slack file shares that do not arrive as message.files.
@ -3217,6 +3460,17 @@ class SlackAdapter(BasePlatformAdapter):
# Resolve user display name (cached after first lookup)
user_name = await self._resolve_user_name(user_id, chat_id=channel_id)
# Slack's AI Agent Messages tab shows visible app threads; title the
# first DM thread turn from the user's prompt when Slack AI APIs are
# available. This is best-effort and configurable via config.yaml.
if is_dm and thread_ts and msg_type != MessageType.COMMAND:
await self._set_assistant_thread_title(
channel_id,
thread_ts,
original_text or text,
team_id=team_id,
)
# Build source
source = self.build_source(
chat_id=channel_id,
@ -3280,6 +3534,11 @@ class SlackAdapter(BasePlatformAdapter):
channel_prompt=_channel_prompt,
reply_to_text=reply_to_text,
auto_skill=_auto_skill,
metadata={
"slack_team_id": team_id,
"slack_channel_id": channel_id,
"slack_thread_ts": thread_ts,
},
)
# Only react when bot is directly addressed (1:1 DM or @mention).
@ -3631,6 +3890,22 @@ class SlackAdapter(BasePlatformAdapter):
exc_info=True,
)
async def _handle_feedback_action(self, ack, body, action) -> None:
"""Ack Slack AI feedback button clicks and log the choice."""
await ack()
value = str(action.get("value") or "")
message = body.get("message", {}) or {}
channel_id = (body.get("channel") or {}).get("id", "")
user_id = (body.get("user") or {}).get("id", "")
logger.info(
"[Slack] Feedback button clicked: value=%s user=%s channel=%s ts=%s",
value,
user_id,
channel_id,
message.get("ts", ""),
)
async def _handle_approval_action(self, ack, body, action) -> None:
"""Handle an approval button click from Block Kit."""
await ack()

View file

@ -800,6 +800,7 @@ class TestSlackProxyBehavior:
assert adapter._handler is not None
assert adapter._handler.proxy == "http://proxy.example.com:3128"
assert adapter._handler.client.proxy == "http://proxy.example.com:3128"
assert "hermes_feedback" in created_apps[0].registered_actions
@pytest.mark.asyncio
async def test_connect_clears_proxy_when_no_proxy_matches_slack(self):
@ -2148,7 +2149,10 @@ class TestSendTyping:
async def test_streaming_intermediate_edit_keeps_status(self, adapter):
adapter._app.client.chat_update = AsyncMock()
adapter._app.client.assistant_threads_setStatus = AsyncMock()
adapter._active_status_threads["C123"] = "parent_ts"
adapter._active_status_threads["C123"] = {
"thread_ts": "parent_ts",
"team_id": "",
}
result = await adapter.edit_message(
"C123",
@ -2159,7 +2163,113 @@ class TestSendTyping:
assert result.success
adapter._app.client.assistant_threads_setStatus.assert_not_called()
assert adapter._active_status_threads["C123"] == "parent_ts"
assert adapter._active_status_threads["C123"]["thread_ts"] == "parent_ts"
@pytest.mark.asyncio
async def test_status_uses_workspace_client_from_metadata(self, adapter):
team_client = AsyncMock()
adapter._team_clients["T_OTHER"] = team_client
await adapter.send_typing(
"D123",
metadata={"thread_id": "parent_ts", "team_id": "T_OTHER"},
)
await adapter.stop_typing("D123")
assert team_client.assistant_threads_setStatus.call_args_list == [
call(channel_id="D123", thread_ts="parent_ts", status="is thinking..."),
call(channel_id="D123", thread_ts="parent_ts", status=""),
]
adapter._app.client.assistant_threads_setStatus.assert_not_called()
@pytest.mark.asyncio
async def test_status_accepts_slack_team_metadata_key(self, adapter):
team_client = AsyncMock()
adapter._team_clients["T_OTHER"] = team_client
await adapter.send_typing(
"D123",
metadata={"thread_id": "parent_ts", "slack_team_id": "T_OTHER"},
)
await adapter.stop_typing("D123", metadata={"slack_team_id": "T_OTHER"})
assert team_client.assistant_threads_setStatus.call_args_list == [
call(channel_id="D123", thread_ts="parent_ts", status="is thinking..."),
call(channel_id="D123", thread_ts="parent_ts", status=""),
]
adapter._app.client.assistant_threads_setStatus.assert_not_called()
@pytest.mark.asyncio
async def test_status_tracking_is_per_thread(self, adapter):
adapter._app.client.assistant_threads_setStatus = AsyncMock()
await adapter.send_typing("D123", metadata={"thread_id": "thread_a"})
await adapter.send_typing("D123", metadata={"thread_id": "thread_b"})
await adapter.stop_typing("D123", metadata={"thread_id": "thread_a"})
assert adapter._app.client.assistant_threads_setStatus.call_args_list == [
call(channel_id="D123", thread_ts="thread_a", status="is thinking..."),
call(channel_id="D123", thread_ts="thread_b", status="is thinking..."),
call(channel_id="D123", thread_ts="thread_a", status=""),
]
assert ("D123", "thread_a") not in adapter._active_status_threads
assert adapter._active_status_threads[("D123", "thread_b")] == {
"thread_ts": "thread_b",
"team_id": "",
}
@pytest.mark.asyncio
async def test_streaming_final_edit_uses_workspace_client_from_metadata(
self, adapter
):
team_client = AsyncMock()
team_client.chat_update = AsyncMock()
team_client.assistant_threads_setStatus = AsyncMock()
adapter._team_clients["T_OTHER"] = team_client
adapter._active_status_threads["D123"] = {
"thread_ts": "parent_ts",
"team_id": "T_OTHER",
}
result = await adapter.edit_message(
"D123",
"reply_ts",
"done",
finalize=True,
metadata={"thread_id": "parent_ts", "slack_team_id": "T_OTHER"},
)
assert result.success
team_client.chat_update.assert_awaited_once_with(
channel="D123",
ts="reply_ts",
text="done",
)
team_client.assistant_threads_setStatus.assert_awaited_once_with(
channel_id="D123",
thread_ts="parent_ts",
status="",
)
adapter._app.client.chat_update.assert_not_called()
@pytest.mark.asyncio
async def test_send_failure_clears_status(self, adapter):
adapter._app.client.chat_postMessage = AsyncMock(side_effect=Exception("boom"))
adapter._app.client.assistant_threads_setStatus = AsyncMock()
adapter._active_status_threads["C123"] = {
"thread_ts": "parent_ts",
"team_id": "",
}
result = await adapter.send("C123", "done", metadata={"thread_id": "parent_ts"})
assert not result.success
adapter._app.client.assistant_threads_setStatus.assert_called_once_with(
channel_id="C123",
thread_ts="parent_ts",
status="",
)
assert "C123" not in adapter._active_status_threads
# ---------------------------------------------------------------------------
@ -3067,6 +3177,144 @@ class TestAssistantThreadLifecycle:
# The newest entry must survive eviction
assert ("D999", "999.000") in assistant_adapter._assistant_threads
def test_suggested_prompts_config_accepts_dict_shape(self, assistant_adapter):
assistant_adapter.config.extra["suggested_prompts"] = {
"title": "Try these",
"prompts": [
{"title": "Summarize", "message": "Summarize this conversation"},
{"title": "", "message": "skip me"},
{"title": "Draft", "message": "Draft a reply"},
],
}
title, prompts = assistant_adapter._assistant_suggested_prompts()
assert title == "Try these"
assert prompts == [
{"title": "Summarize", "message": "Summarize this conversation"},
{"title": "Draft", "message": "Draft a reply"},
]
def test_suggested_prompts_config_caps_at_four(self, assistant_adapter):
assistant_adapter.config.extra["suggested_prompts"] = [
{"title": f"Prompt {i}", "message": f"Message {i}"}
for i in range(6)
]
_title, prompts = assistant_adapter._assistant_suggested_prompts()
assert len(prompts) == 4
assert prompts[-1] == {"title": "Prompt 3", "message": "Message 3"}
@pytest.mark.asyncio
async def test_app_home_messages_tab_sets_agent_suggested_prompts(
self, assistant_adapter
):
assistant_adapter.config.extra["suggested_prompts"] = {
"title": "Start here",
"prompts": [{"title": "Plan", "message": "Help me plan the work"}],
}
assistant_adapter._app.client.assistant_threads_setSuggestedPrompts = (
AsyncMock()
)
event = {
"type": "app_home_opened",
"tab": "messages",
"team": "T_TEAM",
"channel": "D123",
"user": "U_USER",
}
await assistant_adapter._handle_app_home_opened(event)
assistant_adapter._app.client.assistant_threads_setSuggestedPrompts.assert_awaited_once_with(
channel_id="D123",
title="Start here",
prompts=[{"title": "Plan", "message": "Help me plan the work"}],
)
@pytest.mark.asyncio
async def test_assistant_lifecycle_sets_thread_suggested_prompts(
self, assistant_adapter
):
assistant_adapter.config.extra["suggested_prompts"] = [
{"title": "Summarize", "message": "Summarize the current thread"}
]
assistant_adapter._app.client.assistant_threads_setSuggestedPrompts = (
AsyncMock()
)
event = {
"type": "assistant_thread_started",
"team_id": "T_TEAM",
"assistant_thread": {
"channel_id": "D123",
"thread_ts": "171.000",
"user_id": "U_USER",
},
}
await assistant_adapter._handle_assistant_thread_lifecycle_event(event)
assistant_adapter._app.client.assistant_threads_setSuggestedPrompts.assert_awaited_once_with(
channel_id="D123",
prompts=[
{"title": "Summarize", "message": "Summarize the current thread"}
],
thread_ts="171.000",
)
@pytest.mark.asyncio
async def test_dm_message_sets_assistant_thread_title_once(
self, assistant_adapter
):
assistant_adapter._app.client.users_info = AsyncMock(
return_value={"user": {"profile": {"display_name": "Tyler"}}}
)
assistant_adapter._app.client.reactions_add = AsyncMock()
assistant_adapter._app.client.reactions_remove = AsyncMock()
assistant_adapter._app.client.assistant_threads_setTitle = AsyncMock()
event = {
"text": "Please summarize this incident thread",
"channel": "D123",
"channel_type": "im",
"ts": "171.111",
"team": "T_TEAM",
"user": "U_USER",
}
await assistant_adapter._handle_slack_message(event)
await assistant_adapter._handle_slack_message(
{**event, "ts": "171.222", "thread_ts": "171.111"}
)
assistant_adapter._app.client.assistant_threads_setTitle.assert_awaited_once_with(
channel_id="D123",
thread_ts="171.111",
title="Please summarize this incident thread",
)
msg_event = assistant_adapter.handle_message.call_args[0][0]
assert msg_event.metadata["slack_team_id"] == "T_TEAM"
@pytest.mark.asyncio
async def test_dm_message_title_can_be_disabled(self, assistant_adapter):
assistant_adapter.config.extra["assistant_thread_titles"] = False
assistant_adapter._app.client.users_info = AsyncMock(return_value={"user": {}})
assistant_adapter._app.client.reactions_add = AsyncMock()
assistant_adapter._app.client.reactions_remove = AsyncMock()
assistant_adapter._app.client.assistant_threads_setTitle = AsyncMock()
event = {
"text": "title me",
"channel": "D123",
"channel_type": "im",
"ts": "171.111",
"team": "T_TEAM",
"user": "U_USER",
}
await assistant_adapter._handle_slack_message(event)
assistant_adapter._app.client.assistant_threads_setTitle.assert_not_called()
# ---------------------------------------------------------------------------
# TestUserNameResolution

View file

@ -77,6 +77,18 @@ class TestSendMessageBlocks:
assert "blocks" not in c.kwargs
assert c.kwargs["text"]
@pytest.mark.asyncio
async def test_feedback_buttons_opt_in_appended_to_blocks(self):
adapter, client = _make_adapter({"feedback_buttons": True})
await adapter.send("C1", "final answer")
blocks = client.chat_postMessage.await_args.kwargs["blocks"]
feedback = blocks[-1]
assert feedback["type"] == "context_actions"
assert feedback["elements"][0]["type"] == "feedback_buttons"
assert feedback["elements"][0]["action_id"] == "hermes_feedback"
class TestEditMessageBlocks:
@pytest.mark.asyncio
@ -95,6 +107,13 @@ class TestEditMessageBlocks:
assert "blocks" in kwargs and kwargs["blocks"]
assert kwargs["text"]
@pytest.mark.asyncio
async def test_finalize_edit_gets_feedback_buttons_when_enabled(self):
adapter, client = _make_adapter({"rich_blocks": True, "feedback_buttons": True})
await adapter.edit_message("C1", "111.222", RICH_MD, finalize=True)
blocks = client.chat_update.await_args.kwargs["blocks"]
assert blocks[-1]["elements"][0]["type"] == "feedback_buttons"
@pytest.mark.asyncio
async def test_finalize_edit_disabled_no_blocks(self):
adapter, client = _make_adapter() # rich_blocks off