diff --git a/hermes_cli/slack_cli.py b/hermes_cli/slack_cli.py index 75dcdfb2fdd6..681303c089ed 100644 --- a/hermes_cli/slack_cli.py +++ b/hermes_cli/slack_cli.py @@ -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" diff --git a/hermes_cli/subcommands/slack.py b/hermes_cli/subcommands/slack.py index 7debedf95a2c..0cce8929ea8f 100644 --- a/hermes_cli/subcommands/slack.py +++ b/hermes_cli/subcommands/slack.py @@ -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) diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index c0095d5add07..ae3d168e1146 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -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. diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index de3edf2db272..4dd864cd7510 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -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 diff --git a/tests/hermes_cli/test_slack_cli.py b/tests/hermes_cli/test_slack_cli.py index bdc937a59c8d..f2e6bda52399 100644 --- a/tests/hermes_cli/test_slack_cli.py +++ b/tests/hermes_cli/test_slack_cli.py @@ -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(