feat(slack): support agent view manifests

This commit is contained in:
Edison42 2026-07-02 12:17:10 +08:00 committed by Teknium
parent bdb1c87247
commit 9a3b676fed
5 changed files with 185 additions and 16 deletions

View file

@ -27,6 +27,7 @@ def _build_full_manifest(
bot_name: str,
bot_description: str,
include_assistant: bool = True,
messaging_experience: str | None = None,
) -> dict:
"""Build a full Slack manifest merging display info + our slash list.
@ -36,17 +37,24 @@ def _build_full_manifest(
for a Hermes deployment users can tweak them in the Slack UI after
pasting.
When ``include_assistant`` is True (default) the manifest opts the app
into Slack's AI Assistant container: the ``assistant_view`` feature, the
``assistant:write`` scope, and the ``assistant_thread_*`` events. Slack
then renders DMs as the right-hand Assistant split-pane, where every
exchange is a thread and bare slash commands are not delivered as normal
``command`` events. Pass ``include_assistant=False`` (``--no-assistant``)
to omit those three pieces and get a flat DM surface where ``/help``,
``/new``, etc. work inline.
By default, this keeps Hermes on Slack's older Assistant messaging
experience (``assistant_view``) for backward compatibility. Pass
``messaging_experience="agent"`` (``--agent-view``) to emit Slack's Agent
messaging experience (``agent_view`` + ``app_home_opened``). Pass
``include_assistant=False`` or ``messaging_experience="none"``
(``--no-assistant``) to omit Slack AI messaging features and get a flat DM
surface where ``/help``, ``/new``, etc. work inline.
"""
from hermes_cli.commands import slack_app_manifest
if messaging_experience is None:
messaging_experience = "assistant" if include_assistant else "none"
messaging_experience = str(messaging_experience).strip().lower()
if messaging_experience not in {"assistant", "agent", "none"}:
raise ValueError(
"messaging_experience must be one of: assistant, agent, none"
)
partial = slack_app_manifest()
slashes = partial["features"]["slash_commands"]
@ -89,7 +97,7 @@ def _build_full_manifest(
"message.mpim",
]
if include_assistant:
if messaging_experience == "assistant":
features["assistant_view"] = {
"assistant_description": "Chat with Hermes in threads and DMs.",
}
@ -100,8 +108,15 @@ def _build_full_manifest(
"assistant_thread_started",
]
)
bot_scopes.sort()
bot_events.sort()
elif messaging_experience == "agent":
features["agent_view"] = {
"agent_description": "Chat with Hermes in Slack Messages.",
}
bot_scopes.append("assistant:write")
bot_events.append("app_home_opened")
bot_scopes.sort()
bot_events.sort()
return {
"_metadata": {
@ -147,17 +162,29 @@ def slack_manifest_command(args) -> int:
assistant:write scope, assistant_thread_* events) so
DMs render as a flat chat where bare slash commands
work inline instead of the Assistant thread pane.
--agent-view Use Slack's Agent messaging experience (agent_view,
app_home_opened + message.im) instead of the legacy
Assistant messaging experience.
"""
name = getattr(args, "name", None) or "Hermes"
description = getattr(args, "description", None) or "Your Hermes agent on Slack"
include_assistant = not getattr(args, "no_assistant", False)
if getattr(args, "agent_view", False):
messaging_experience = "agent"
elif getattr(args, "no_assistant", False):
messaging_experience = "none"
else:
messaging_experience = "assistant"
if getattr(args, "slashes_only", False):
from hermes_cli.commands import slack_app_manifest
manifest = slack_app_manifest()["features"]["slash_commands"]
else:
manifest = _build_full_manifest(name, description, include_assistant=include_assistant)
manifest = _build_full_manifest(
name,
description,
messaging_experience=messaging_experience,
)
payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"

View file

@ -57,7 +57,8 @@ def build_slack_parser(subparsers, *, cmd_slack: Callable) -> None:
help="Emit only the features.slash_commands array (for merging "
"into an existing manifest manually).",
)
slack_manifest.add_argument(
slack_messaging = slack_manifest.add_mutually_exclusive_group()
slack_messaging.add_argument(
"--no-assistant",
action="store_true",
help="Omit Slack AI Assistant mode (assistant_view, assistant:write "
@ -65,4 +66,12 @@ def build_slack_parser(subparsers, *, cmd_slack: Callable) -> None:
"where bare slash commands (/help, /new) work inline instead of "
"Slack's Assistant thread pane.",
)
slack_messaging.add_argument(
"--agent-view",
action="store_true",
help="Emit Slack's Agent messaging experience (agent_view, "
"app_home_opened + message.im) instead of the legacy assistant_view "
"experience. This changes Slack's app messaging surface and cannot "
"be reversed in Slack after applying the manifest.",
)
slack_parser.set_defaults(func=cmd_slack)

View file

@ -1099,6 +1099,10 @@ class SlackAdapter(BasePlatformAdapter):
async def handle_app_mention(event, say):
await self._handle_slack_message(event)
@self._app.event("app_home_opened")
async def handle_app_home_opened(event, say):
await self._handle_app_home_opened(event)
# File lifecycle events can arrive around snippet uploads even when
# the actual user message is what we care about. Ack them so Slack
# doesn't log noisy 404 "unhandled request" warnings.
@ -2502,12 +2506,65 @@ class SlackAdapter(BasePlatformAdapter):
exc_info=True,
)
def _seed_agent_dm_session(self, metadata: Dict[str, str]) -> None:
"""Prime the session store when Slack reports a user opened the DM.
In Slack's Agent messaging experience, ``app_home_opened`` with
``tab == "messages"`` replaces ``assistant_thread_started`` as the
"user opened the DM" signal. It is only a lifecycle signal: do not send
a welcome message or enter the agent loop from this event.
"""
session_store = getattr(self, "_session_store", None)
if not session_store:
return
channel_id = metadata.get("channel_id", "")
user_id = metadata.get("user_id", "")
if not channel_id or not user_id:
return
source = self.build_source(
chat_id=channel_id,
chat_name=channel_id,
chat_type="dm",
user_id=user_id,
)
try:
session_store.get_or_create_session(source)
except Exception:
logger.debug(
"[Slack] Failed to seed agent DM session for %s",
channel_id,
exc_info=True,
)
async def _handle_assistant_thread_lifecycle_event(self, event: dict) -> None:
"""Handle Slack Assistant lifecycle events that carry user/thread identity."""
metadata = self._extract_assistant_thread_metadata(event)
self._cache_assistant_thread_metadata(metadata)
self._seed_assistant_thread_session(metadata)
async def _handle_app_home_opened(self, event: dict) -> None:
"""Handle Slack Agent DM-open lifecycle events without producing replies."""
if event.get("tab") != "messages":
return
channel_id = event.get("channel") or event.get("channel_id") or ""
user_id = event.get("user") or event.get("user_id") or ""
team_id = event.get("team") or event.get("team_id") or ""
if team_id and channel_id:
self._channel_team[str(channel_id)] = str(team_id)
self._seed_agent_dm_session(
{
"channel_id": str(channel_id) if channel_id else "",
"user_id": str(user_id) if user_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.

View file

@ -238,6 +238,7 @@ class TestAppMentionHandler:
assert "message" in registered_events
assert "app_mention" in registered_events
assert "app_home_opened" in registered_events
assert "reaction_added" in registered_events
assert "reaction_removed" in registered_events
assert "assistant_thread_started" in registered_events
@ -2914,7 +2915,7 @@ class TestThreadReplyHandling:
class TestAssistantThreadLifecycle:
"""Slack Assistant lifecycle events should seed session/user context."""
"""Slack AI lifecycle events should seed session/user context."""
@pytest.fixture()
def mock_session_store(self):
@ -2968,6 +2969,46 @@ class TestAssistantThreadLifecycle:
assert source.thread_id == "171.000"
assert source.chat_topic == "C_ORIGIN"
@pytest.mark.asyncio
async def test_app_home_messages_tab_seeds_dm_session(
self, assistant_adapter, mock_session_store
):
event = {
"type": "app_home_opened",
"tab": "messages",
"team": "T_TEAM",
"channel": "D123",
"user": "U_USER",
}
await assistant_adapter._handle_app_home_opened(event)
mock_session_store.get_or_create_session.assert_called_once()
source = mock_session_store.get_or_create_session.call_args[0][0]
assert source.chat_id == "D123"
assert source.chat_type == "dm"
assert source.user_id == "U_USER"
assert source.thread_id is None
assert assistant_adapter._channel_team["D123"] == "T_TEAM"
assistant_adapter.handle_message.assert_not_called()
@pytest.mark.asyncio
async def test_app_home_non_messages_tab_is_ignored(
self, assistant_adapter, mock_session_store
):
event = {
"type": "app_home_opened",
"tab": "home",
"team": "T_TEAM",
"channel": "D123",
"user": "U_USER",
}
await assistant_adapter._handle_app_home_opened(event)
mock_session_store.get_or_create_session.assert_not_called()
assistant_adapter.handle_message.assert_not_called()
@pytest.mark.asyncio
async def test_message_uses_cached_assistant_thread_identity(
self, assistant_adapter

View file

@ -15,7 +15,7 @@ def _parse_slack_args(argv):
class TestSlackManifestArgparse:
"""The `--no-assistant` flag wires through argparse to `no_assistant`."""
"""Slack manifest messaging-experience flags wire through argparse."""
def test_no_assistant_flag_defaults_false(self):
args = _parse_slack_args(["slack", "manifest"])
@ -25,6 +25,13 @@ class TestSlackManifestArgparse:
args = _parse_slack_args(["slack", "manifest", "--no-assistant"])
assert args.no_assistant is True
def test_agent_view_flag_defaults_false(self):
args = _parse_slack_args(["slack", "manifest"])
assert getattr(args, "agent_view", False) is False
def test_agent_view_flag_sets_true(self):
args = _parse_slack_args(["slack", "manifest", "--agent-view"])
assert args.agent_view is True
class TestSlackFullManifest:
@ -77,6 +84,7 @@ class TestSlackFullManifest:
manifest = _build_full_manifest("Hermes", "Your Hermes agent on Slack")
assert "assistant_view" in manifest["features"]
assert "agent_view" not in manifest["features"]
assert "assistant:write" in manifest["oauth_config"]["scopes"]["bot"]
bot_events = manifest["settings"]["event_subscriptions"]["bot_events"]
assert "assistant_thread_started" in bot_events
@ -89,11 +97,38 @@ class TestSlackFullManifest:
# assistant_view feature is gone -> Slack renders a flat DM, not the
# Assistant thread pane (where bare slash commands don't dispatch).
assert "assistant_view" not in manifest["features"]
assert "agent_view" not in manifest["features"]
assert "assistant:write" not in manifest["oauth_config"]["scopes"]["bot"]
bot_events = manifest["settings"]["event_subscriptions"]["bot_events"]
assert "assistant_thread_started" not in bot_events
assert "assistant_thread_context_changed" not in bot_events
def test_agent_view_uses_agent_manifest_surface(self):
manifest = _build_full_manifest(
"Hermes",
"Your Hermes agent on Slack",
messaging_experience="agent",
)
assert manifest["features"]["agent_view"] == {
"agent_description": "Chat with Hermes in Slack Messages.",
}
assert "assistant_view" not in manifest["features"]
assert "assistant:write" in manifest["oauth_config"]["scopes"]["bot"]
def test_agent_view_uses_agent_event_subscriptions(self):
manifest = _build_full_manifest(
"Hermes",
"Your Hermes agent on Slack",
messaging_experience="agent",
)
bot_events = manifest["settings"]["event_subscriptions"]["bot_events"]
assert "app_home_opened" in bot_events
assert "message.im" in bot_events
assert "assistant_thread_started" not in bot_events
assert "assistant_thread_context_changed" not in bot_events
def test_no_assistant_preserves_core_surface(self):
"""Dropping assistant mode must NOT strip the regular messaging surface."""
manifest = _build_full_manifest(