mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
feat(slack): support agent view manifests
This commit is contained in:
parent
bdb1c87247
commit
9a3b676fed
5 changed files with 185 additions and 16 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue