Slack assistant 'is thinking' status stuck; related Slack fixes
Cluster 32295 · gateway. Data as of
Nothing open on GitHub. Fix merged in #64621 on 15 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #64621. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #64621. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Most members concern the Slack 'is thinking...' assistant status remaining visible, including when an agent ends without replying and around /stop and clarify handling. One member, about Agent View workspace routing, appears to be a related Slack change but not clearly the same issue.
Open issues0
Open PRs0
Fixes on record
-
#63709 fix(slack): complete Agent View workspace routing
files:
gateway/platforms/base.py,gateway/run.py,hermes_cli/slack_cli.py,hermes_cli/subcommands/slack.py,plugins/platforms/slack/adapter.py,pyproject.toml,scripts/release.py,tests/gateway/test_base_topic_sessions.py,tests/gateway/test_slack.py,tests/gateway/test_slack_approval_buttons.py,tests/gateway/test_slack_block_kit_adapter.py,tests/hermes_cli/test_slack_cli.py,tools/lazy_deps.py,website/docs/user-guide/messaging/slack.mdShow the diff
-
gateway/platforms/base.py@@ -63,9 +63,17 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None) ``direct_messages_topic_id`` when the Bot API supports it. """ thread_id = getattr(source, "thread_id", None) - if thread_id is None: + metadata = {"thread_id": thread_id} if thread_id is not None else {} + # Slack workspace identity is durable routing state, not ephemeral event + # metadata. Carry it on every outbound path (including unthreaded sends) + # so a multi-workspace Socket Mode gateway never falls back to its primary + # WebClient after an async, stream, or recovery boundary. + if _platform_name(getattr(source, "platform", None)) == "slack": + scope_id = getattr(source, "scope_id", None) + if scope_id: + metadata["slack_team_id"] = str(scope_id) + if not metadata: return None - metadata = {"thread_id": thread_id} if _platform_name(getattr(source, "platform", None)) == "telegram" and getattr(source, "chat_type", None) == "dm": metadata["telegram_dm_topic_reply_fallback"] = True tid = str(thread_id) @@ -3202,6 +3210,30 @@ async def stop_typing(self, chat_id: str) -> None: """ pass + async def _stop_typing_with_metadata(self, chat_id: str, metadata=None) -> None: + """Stop typing while preserving platform-specific routing metadata. + + Most adapters key typing state by chat and retain the historical + ``stop_typing(chat_id)`` signature. Slack AI status is per thread and + workspace, however, so losing metadata can clear a sibling thread or + leave the current one active. Introspect at this shared chokepoint so + existing adapters remain source-compatible. + """ + if metadata: + try: + params = inspect.signature(self.stop_typing).parameters + accepts_metadata = "metadata" in params or any( + param.kind is inspect.Parameter.VAR_KEYWORD + for param in params.values() + ) + except (TypeError, ValueError): + accepts_metadata = False + if accepts_metadata: + stop_typing = getattr(self, "stop_typing") + await stop_typing(chat_id, metadata=metadata) + return + await self.stop_typing(chat_id) + async def send_multiple_images( self, chat_id: str, @@ -3879,7 +3911,7 @@ async def _keep_typing( # Cancelling _keep_typing alone won't clean that up. if hasattr(self, "stop_typing"): try: - await self.stop_typing(chat_id) + await self._stop_typing_with_metadata(chat_id, metadata) except Exception: pass self._typing_paused.discard(chat_id) @@ -3889,6 +3921,7 @@ async def _stop_typing_refresh( chat_id: str, typing_task: asyncio.Task | None = None, *, + metadata=None, timeout: float = 0.5, stop_attempts: int = 2, ) -> None: @@ -3908,7 +3941,7 @@ async def _stop_typing_refresh( attempts = max(1, stop_attempts) for attempt in range(attempts): try: - await self.stop_typing(chat_id) + await self._stop_typing_with_metadata(chat_id, metadata) except Exception: pass if attempt < attempts - 1: @@ -3928,14 +3961,14 @@ def resume_typing_for_chat(self, chat_id: str) -> None: """Resume typing indicator for a chat after approval resolves.""" self._typing_paused.discard(chat_id) - async def interrupt_session_activity(self, session_key: str, chat_id: str) -> None: + async def interrupt_session_activity(self, session_key: str, chat_id: str, metadata=None) -> None: """Signal the active session loop to stop and clear typing immediately.""" if session_key: interrupt_event = self._active_sessions.get(session_key) if interrupt_event is not None: interrupt_event.set() try: - await self.stop_typing(chat_id) + await self._stop_typing_with_metadata(chat_id, metadata) except Exception: pass @@ -4873,6 +4906,7 @@ async def _stop_typing_task() -> None: await self._stop_typing_refresh( event.source.chat_id, typing_task, + metadata=_thread_metadata, ) try: @@ -5296,6 +5330,7 @@ async def _stop_typing_task() -> None: await self._stop_typing_refresh( event.source.chat_id, None, + metadata=_thread_metadata, stop_attempts=1, ) # Final drain/release boundary: force-flush any timer that missed @@ -5464,6 +5499,7 @@ def build_source( user_id_alt: Optional[str] = None, chat_id_alt: Optional[str] = None, is_bot: bool = False, + scope_id: Optional[str] = None, guild_id: Optional[str] = None, parent_chat_id: Optional[str] = None, message_id: Optional[str] = None, @@ -5487,6 +5523,7 @@ def build_source( user_id_alt=user_id_alt, chat_id_alt=chat_id_alt, is_bot=is_bot, + scope_id=str(scope_id) if scope_id else None, guild_id=str(guild_id) if guild_id else None, parent_chat_id=str(parent_chat_id) if parent_chat_id else None, message_id=str(message_id) if message_id else None, -
gateway/run.py@@ -11686,10 +11686,23 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g persist_user_timestamp=persist_user_timestamp, ) - # Stop persistent typing indicator now that the agent is done + # Stop persistent typing indicator now that the agent is done. + # Slack AI status is scoped to a thread/workspace, so preserve the + # same routing metadata used by the response delivery path. try: _typing_adapter = self._adapter_for_source(source) - if _typing_adapter and hasattr(_typing_adapter, "stop_typing"): + _stop_with_metadata = getattr( + type(_typing_adapter), "_stop_typing_with_metadata", None + ) + _stop_typing = getattr(type(_typing_adapter), "stop_typing", None) + if _typing_adapter and callable(_stop_with_metadata): + await _typing_adapter._stop_typing_with_metadata( + source.chat_id, + self._thread_metadata_for_source( + source, self._reply_anchor_for_event(event) + ), + ) + elif _typing_adapter and callable(_stop_typing): await _typing_adapter.stop_typing(source.chat_id) except Exception: pass @@ -12235,10 +12248,22 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g return response except Exception as e: - # Stop typing indicator on error too + # Stop typing indicator on error too, retaining Slack thread/workspace + # routing so a failed turn cannot leave its status visible. try: _err_adapter = self._adapter_for_source(source) - if _err_adapter and hasattr(_err_adapter, "stop_typing"): + _stop_with_metadata = getattr( + type(_err_adapter), "_stop_typing_with_metadata", None + ) + _stop_typing = getattr(type(_err_adapter), "stop_typing", None) + if _err_adapter and callable(_stop_with_metadata): + await _err_adapter._stop_typing_with_metadata( + source.chat_id, + self._thread_metadata_for_source( + source, self._reply_anchor_for_event(event) + ), + ) + elif _err_adapter and callable(_stop_typing): await _err_adapter.stop_typing(source.chat_id) except Exception: pass @@ -14397,13 +14422,19 @@ def _thread_metadata_for_source( reply_to_message_id: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Build the metadata dict platforms need for thread-aware replies.""" - return self._thread_metadata_for_target( + metadata = self._thread_metadata_for_target( getattr(source, "platform", None), getattr(source, "chat_id", None), getattr(source, "thread_id", None), chat_type=getattr(source, "chat_type", None), reply_to_message_id=reply_to_message_id or getattr(source, "message_id", None), ) + if getattr(source, "platform", None) == Platform.SLACK: + team_id = getattr(source, "scope_id", None) + if team_id: + metadata = dict(metadata or {}) + metadata["slack_team_id"] = str(team_id) + return metadata def _thread_metadata_for_target( self, @@ -16301,8 +16332,25 @@ async def _interrupt_and_clear_session( running_agent.interrupt(interrupt_reason) self._invalidate_session_run_generation(session_key, reason=invalidation_reason) adapter = self._adapter_for_source(source) - if adapter and hasattr(adapter, "interrupt_session_activity"): - await adapter.interrupt_session_activity(session_key, source.chat_id) + interrupt_session_activity = getattr( + type(adapter), "interrupt_session_activity", None + ) + if adapter and callable(interrupt_session_activity): + metadata = self._thread_metadata_for_source(source) + try: + params = inspect.signature(interrupt_session_activity).parameters + accepts_metadata = "metadata" in params or any( + param.kind is inspect.Parameter.VAR_KEYWORD + for param in params.values() + ) + except (TypeError, ValueError): + accepts_metadata = False + if accepts_metadata: + await adapter.interrupt_session_activity( + session_key, source.chat_id, metadata=metadata + ) + else: + await adapter.interrupt_session_activity(session_key, source.chat_id) if adapter and hasattr(adapter, "get_pending_message"): adapter.get_pending_message(session_key) # consume and discard self._pending_messages.pop(session_key, None) -
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,18 @@ 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") + # Slack includes current viewing context in Agent DM events only after + # this subscription is enabled; the adapter consumes that context to + # preserve the referred channel across the agent turn. + bot_events.extend(["app_context_changed", "app_home_opened"]) + + bot_scopes.sort() + bot_events.sort() return { "_metadata": { @@ -147,17 +165,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" -
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) -
plugins/platforms/slack/adapter.py@@ -440,7 +440,10 @@ def __init__(self, config: PlatformConfig): self._app: Optional[Any] = None self._handler: Optional[Any] = None self._bot_user_id: Optional[str] = None - self._user_name_cache: Dict[str, str] = {} # user_id → display name + # Slack user IDs are workspace-local. Cache names by workspace as well + # so a multi-workspace Socket Mode process never reuses another + # tenant's display name. + self._user_name_cache: Dict[Tuple[str, str], str] = {} self._socket_mode_task: Optional[asyncio.Task] = None # Multi-workspace support self._team_clients: Dict[str, Any] = {} # team_id → WebClient @@ -460,20 +463,29 @@ def __init__(self, config: PlatformConfig): # respond to ALL subsequent messages in that thread automatically. self._mentioned_threads: set = set() self._MENTIONED_THREADS_MAX = 5000 - # Assistant thread metadata keyed by (channel_id, thread_ts). Slack's - # AI Assistant lifecycle events can arrive before/alongside message - # events, and they carry the user/thread identity needed for stable - # session + memory scoping. - self._assistant_threads: Dict[Tuple[str, str], Dict[str, str]] = {} + # Assistant thread metadata keyed by (team_id, channel_id, thread_ts). + # Slack's AI Assistant lifecycle events can arrive before/alongside + # message events, and carry identity needed for stable session scoping. + self._assistant_threads: Dict[Tuple[str, str, str], Dict[str, str]] = {} self._ASSISTANT_THREADS_MAX = 5000 + # Agent-view context is per workspace/user (not global): a context + # change for one person's Slack split view must never appear in another + # person's prompt. Slack also includes this in later DM events, but the + # cache bridges lifecycle and message delivery ordering. + self._agent_view_contexts: Dict[Tuple[str, str], Dict[str, str]] = {} + self._AGENT_VIEW_CONTEXTS_MAX = 5000 # Cache for _fetch_thread_context results: cache_key → _ThreadContextCache self._thread_context_cache: Dict[str, _ThreadContextCache] = {} self._THREAD_CACHE_TTL = 60.0 # 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] = {} + # Track active Assistant statuses by (team_id, channel_id, thread_ts) + # so cleanup cannot clear an overlapping Slack Connect workspace. + self._active_status_threads: Dict[Tuple[str, str, str], 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. @@ -1085,8 +1097,8 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: # Register message event handler @self._app.event("message") - async def handle_message_event(event, say): - await self._handle_slack_message(event) + async def handle_message_event(event, say, body): + await self._handle_slack_message(event, body) # Handle app_mention explicitly. In some Slack app configurations, # channel mentions arrive only as app_mention events rather than the @@ -1096,15 +1108,23 @@ async def handle_message_event(event, say): # @mention, they share the same event ts — the dedup in # _handle_slack_message (MessageDeduplicator) suppresses the second. @self._app.event("app_mention") - async def handle_app_mention(event, say): - await self._handle_slack_message(event) + async def handle_app_mention(event, say, body): + await self._handle_slack_message(event, body) + + @self._app.event("app_home_opened") + async def handle_app_home_opened(event, say, body): + await self._handle_app_home_opened(event, body) + + @self._app.event("app_context_changed") + async def handle_app_context_changed(event, say, body): + await self._handle_app_context_changed(event, body) # 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. @self._app.event("file_shared") - async def handle_file_shared(event, say): - await self._handle_slack_file_shared(event) + async def handle_file_shared(event, say, body): + await self._handle_slack_file_shared(event, body) @self._app.event("file_created") async def handle_file_created(event, say): @@ -1127,12 +1147,12 @@ async def handle_reaction_removed(event, say): pass @self._app.event("assistant_thread_started") - async def handle_assistant_thread_started(event, say): - await self._handle_assistant_thread_lifecycle_event(event) + async def handle_assistant_thread_started(event, say, body): + await self._handle_assistant_thread_lifecycle_event(event, body) @self._app.event("assistant_thread_context_changed") - async def handle_assistant_thread_context_changed(event, say): - await self._handle_assistant_thread_lifecycle_event(event) + async def handle_assistant_thread_context_changed(event, say, body): + await self._handle_assistant_thread_lifecycle_event(event, body) # Register slash command handler(s) # @@ -1185,6 +1205,8 @@ async def handle_hermes_command(ack, command): ): 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)`` @@ -1342,8 +1364,22 @@ async def disconnect(self) -> None: 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 "" + ) + + 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] @@ -1360,6 +1396,7 @@ async def send( 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 @@ -1407,11 +1444,13 @@ async def send( 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. @@ -1433,6 +1472,8 @@ async def send( ) 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)) @@ -1462,7 +1503,9 @@ async def send_private_notice( 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"), @@ -1479,6 +1522,7 @@ async def edit_message( content: str, *, finalize: bool = False, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Edit a previously sent Slack message.""" if not self._app: @@ -1498,11 +1542,15 @@ async def edit_message( 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, @@ -1529,9 +1577,18 @@ async def send_typing(self, chat_id: str, metadata=None) -> None: 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, "") + + status_key = self._workspace_thread_key(team_id, chat_id, str(thread_ts)) + if status_key: + self._active_status_threads[status_key] = { + "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...", @@ -1545,11 +1602,56 @@ async def stop_typing(self, chat_id: str, metadata=None) -> None: """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 "" + ) + requested_team_id = self._metadata_team_id(metadata) + active = None + if requested_thread_ts: + if requested_team_id: + active_key = self._workspace_thread_key( + requested_team_id, chat_id, requested_thread_ts + ) + if active_key: + active = self._active_status_threads.pop(active_key, None) + else: + # Do not trust the mutable channel-only workspace fallback for + # a thread-specific cleanup: Slack Connect workspaces can share + # a channel ID. Clear the uniquely matching tracked status and + # let its stored team choose the correct client. + matching_keys = [ + key + for key in self._active_status_threads + if key[1] == str(chat_id) and key[2] == requested_thread_ts + ] + if len(matching_keys) == 1: + active = self._active_status_threads.pop(matching_keys[0], None) + else: + # Metadata-free cleanup is safe only if exactly one status exists + # for this channel; otherwise it may clear another Slack Connect + # workspace's Assistant status. + matching_keys = [ + key + for key in self._active_status_threads + if key[1] == 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="", @@ -1684,7 +1786,9 @@ async def _upload_file( last_exc = None for attempt in range(3): try: - result = await self._get_client(chat_id).files_upload_v2( + result = await self._get_client( + chat_id, team_id=self._metadata_team_id(metadata) + ).files_upload_v2( channel=chat_id, file=file_path, filename=os.path.basename(file_path), @@ -1811,7 +1915,9 @@ async def send_multiple_images( chunk_idx + 1, len(chunks), ) - result = await self._get_client(chat_id).files_upload_v2( + result = await self._get_client( + chat_id, team_id=self._metadata_team_id(metadata) + ).files_upload_v2( channel=chat_id, file_uploads=file_uploads, initial_comment=initial_comment, @@ -1885,6 +1991,47 @@ def _rich_blocks_enabled(self) -> bool: 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. @@ -1896,7 +2043,8 @@ def _maybe_blocks(self, content: str) -> Optional[list]: if not self._rich_blocks_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 @@ -2013,12 +2161,14 @@ def _convert_header(m): # ----- Reactions ----- - async def _add_reaction(self, channel: str, timestamp: str, emoji: str) -> bool: + async def _add_reaction( + self, channel: str, timestamp: str, emoji: str, team_id: str = "" + ) -> bool: """Add an emoji reaction to a message. Returns True on success.""" if not self._app: return False try: - await self._get_client(channel).reactions_add( + await self._get_client(channel, team_id=team_id or None).reactions_add( channel=channel, timestamp=timestamp, name=emoji ) return True @@ -2027,12 +2177,14 @@ async def _add_reaction(self, channel: str, timestamp: str, emoji: str) -> bool: logger.debug("[Slack] reactions.add failed (%s): %s", emoji, e) return False - async def _remove_reaction(self, channel: str, timestamp: str, emoji: str) -> bool: + async def _remove_reaction( + self, channel: str, timestamp: str, emoji: str, team_id: str = "" + ) -> bool: """Remove an emoji reaction from a message. Returns True on success.""" if not self._app: return False try: - await self._get_client(channel).reactions_remove( + await self._get_client(channel, team_id=team_id or None).reactions_remove( channel=channel, timestamp=timestamp, name=emoji ) return True @@ -2053,7 +2205,9 @@ async def on_processing_start(self, event: MessageEvent) -> None: return channel_id = getattr(event.source, "chat_id", None) if channel_id: - await self._add_reaction(channel_id, ts, "eyes") + await self._add_reaction( + channel_id, ts, "eyes", str(getattr(event.source, "scope_id", "") or "") + ) async def on_processing_complete( self, event: MessageEvent, outcome: ProcessingOutcome @@ -2068,26 +2222,36 @@ async def on_processing_complete( channel_id = getattr(event.source, "chat_id", None) if not channel_id: return - await self._remove_reaction(channel_id, ts, "eyes") + team_id = str(getattr(event.source, "scope_id", "") or "") + await self._remove_reaction(channel_id, ts, "eyes", team_id) if outcome == ProcessingOutcome.SUCCESS: - await self._add_reaction(channel_id, ts, "white_check_mark") + await self._add_reaction(channel_id, ts, "white_check_mark", team_id) elif outcome == ProcessingOutcome.FAILURE: - await self._add_reaction(channel_id, ts, "x") + await self._add_reaction(channel_id, ts, "x", team_id) # ----- User identity resolution ----- - async def _resolve_user_name(self, user_id: str, chat_id: str = "") -> str: - """Resolve a Slack user ID to a display name, with caching.""" + async def _resolve_user_name( + self, user_id: str, chat_id: str = "", team_id: str = "" + ) -> str: + """Resolve a workspace-local Slack user ID to a display name.""" if not user_id: return "" - if user_id in self._user_name_cache: - return self._user_name_cache[user_id] + team_id = str(team_id or self._channel_team.get(chat_id, "")) + cache_key = (team_id, str(user_id)) + cached_name = self._user_name_cache.get(cache_key) + if cached_name is not None: + return cached_name if not self._app: return user_id try: - client = self._get_client(chat_id) if chat_id else self._app.client + client = ( + self._get_client(chat_id, team_id=team_id or None) + if chat_id + else self._app.client + ) result = await client.users_info(user=user_id) user = result.get("user", {}) # Prefer display_name → real_name → user_id @@ -2099,11 +2263,11 @@ async def _resolve_user_name(self, user_id: str, chat_id: str = "") -> str: or user.get("name") or user_id ) - self._user_name_cache[user_id] = name + self._user_name_cache[cache_key] = name return name except Exception as e: logger.debug("[Slack] users.info failed for %s: %s", user_id, e) - self._user_name_cache[user_id] = user_id + self._user_name_cache[cache_key] = user_id return user_id async def send_image_file( @@ -2177,7 +2341,9 @@ async def _ssrf_redirect_guard(response): response.raise_for_status() thread_ts = self._resolve_thread_ts(reply_to, metadata) - result = await self._get_client(chat_id).files_upload_v2( + result = await self._get_client( + chat_id, team_id=self._metadata_team_id(metadata) + ).files_upload_v2( channel=chat_id, content=response.content, filename="image.png", @@ -2253,7 +2419,9 @@ async def send_video( last_exc = None for attempt in range(3): try: - result = await self._get_client(chat_id).files_upload_v2( + result = await self._get_client( + chat_id, team_id=self._metadata_team_id(metadata) + ).files_upload_v2( channel=chat_id, file=video_path, filename=os.path.basename(video_path), @@ -2313,7 +2481,9 @@ async def send_document( last_exc = None for attempt in range(3): try: - result = await self._get_client(chat_id).files_upload_v2( + result = await self._get_client( + chat_id, team_id=self._metadata_team_id(metadata) + ).files_upload_v2( channel=chat_id, file=file_path, filename=display_name, @@ -2377,18 +2547,119 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: # ----- Internal handlers ----- - def _assistant_thread_key( - self, channel_id: str, thread_ts: str - ) -> Optional[Tuple[str, str]]: - """Return a stable cache key for Slack assistant thread metadata.""" + @staticmethod + def _workspace_thread_key( + team_id: str, channel_id: str, thread_ts: str + ) -> Optional[Tuple[str, str, str]]: + """Return a workspace-scoped key for Slack thread-local state. + + Slack Connect can expose the same channel/thread IDs in more than one + workspace. Keys for cached thread data and Assistant status therefore + need the workspace identity as well as Slack's channel/thread pair. + """ if not channel_id or not thread_ts: return None - return (str(channel_id), str(thread_ts)) + return (str(team_id or ""), str(channel_id), str(thread_ts)) + + @staticmethod + def _agent_view_context_key(team_id: str, user_id: str) -> Optional[Tuple[str, str]]: + """Return a per-workspace, per-user Agent-view context cache key.""" + if not team_id or not user_id: + return None + return (str(team_id), str(user_id)) + + def _cache_agent_view_context(self, metadata: Dict[str, str]) -> None: + """Remember a user's current Slack Agent-view context.""" + key = self._agent_view_context_key( + metadata.get("team_id", ""), metadata.get("user_id", "") + ) + if not key: + return + contexts = getattr(self, "_agent_view_contexts", None) + if not isinstance(contexts, dict): + contexts = {} + self._agent_view_contexts = contexts + contexts[key] = { + field: value + for field, value in metadata.items() + if field in {"channel_id", "context_channel_id", "team_id", "user_id"} + and value + } + max_contexts = getattr(self, "_AGENT_VIEW_CONTEXTS_MAX", 5000) + if len(contexts) > max_contexts: + excess = len(contexts) - max_contexts // 2 + for old_key in list(contexts)[:excess]: + del contexts[old_key] + + def _agent_view_context_for_event( + self, event: dict, team_id: str, user_id: str + ) -> Dict[str, str]: + """Read Slack's inline Agent context, falling back to lifecycle state.""" + context = event.get("app_context") or event.get("context") or {} + context_channel_id = self._context_channel_id(context) + key = self._agent_view_context_key(team_id, user_id) + contexts = getattr(self, "_agent_view_contexts", {}) + cached = contexts.get(key, {}) if isinstance(contexts, dict) and key else {} + return { + "context_channel_id": context_channel_id or cached.get("context_channel_id", ""), + "team_id": team_id, + "user_id": user_id, + } - def _extract_assistant_thread_metadata(self, event: dict) -> Dict[str, str]: + @staticmethod + def _event_team_id(event: dict, body: Optional[dict] = None) -> str: + """Resolve a workspace ID from an event plus Bolt's outer payload. + + Bolt injects only the inner ``event`` into an event listener, while + Slack places ``team_id`` on the outer Events API payload. Reading both + keeps multi-workspace client routing correct after a process boundary. + """ + for payload in (event, body or {}): + if not isinstance(payload, dict): + continue + team = payload.get("team_id") or payload.get("team") + if isinstance(team, str) and team: + return team + if isinstance(team, dict) and team.get("id"): + return str(team["id"]) + authorizations = (body or {}).get("authorizations") if isinstance(body, dict) else None + for authorization in authorizations or []: + if isinstance(authorization, dict) and authorization.get("team_id"): + return str(authorization["team_id"]) + return "" + + @staticmethod + def _context_channel_id(context: Any) -> str: + """Extract the actively viewed channel from either Slack context shape.""" + if not isinstance(context, dict): + return "" + channel_id = context.get("channel_id") + if channel_id: + return str(channel_id) + for entity in context.get("entities") or []: + if not isinstance(entity, dict): + continue + value = entity.get("value") + if isinstance(value, dict) and value.get("channel_id"): + return str(value["channel_id"]) + if ( + isinstance(value, str) + and str(entity.get("type") or "").endswith("channel_id") + ): + return value + return "" + + def _extract_assistant_thread_metadata( + self, event: dict, body: Optional[dict] = None + ) -> Dict[str, str]: """Extract Slack Assistant thread identity data from an event payload.""" assistant_thread = event.get("assistant_thread") or {} - context = assistant_thread.get("context") or event.get("context") or {} + context = ( + assistant_thread.get("context") + or event.get("app_context") + or event.get("context") + or {} + ) channel_id = ( assistant_thread.get("channel_id") @@ -2408,13 +2679,10 @@ def _extract_assistant_thread_metadata(self, event: dict) -> Dict[str, str]: or context.get("user_id") or "" ) - team_id = ( - event.get("team") - or event.get("team_id") - or assistant_thread.get("team_id") - or "" + team_id = self._event_team_id(event, body) or str( + assistant_thread.get("team_id") or "" ) - context_channel_id = context.get("channel_id") or "" + context_channel_id = self._context_channel_id(context) return { "channel_id": str(channel_id) if channel_id else "", @@ -2425,10 +2693,11 @@ def _extract_assistant_thread_metadata(self, event: dict) -> Dict[str, str]: } def _cache_assistant_thread_metadata(self, metadata: Dict[str, str]) -> None: - """Remember assistant thread identity data for later message events.""" + """Remember workspace-local assistant identity for later message events.""" channel_id = metadata.get("channel_id", "") thread_ts = metadata.get("thread_ts", "") - key = self._assistant_thread_key(channel_id, thread_ts) + team_id = metadata.get("team_id", "") + key = self._workspace_thread_key(team_id, channel_id, thread_ts) if not key: return @@ -2437,30 +2706,35 @@ def _cache_assistant_thread_metadata(self, metadata: Dict[str, str]) -> None: merged.update({k: v for k, v in metadata.items() if v}) self._assistant_threads[key] = merged - # Evict oldest entries when the cache exceeds the limit + # Evict oldest entries when the cache exceeds the limit. if len(self._assistant_threads) > self._ASSISTANT_THREADS_MAX: excess = len(self._assistant_threads) - self._ASSISTANT_THREADS_MAX // 2 for old_key in list(self._assistant_threads)[:excess]: del self._assistant_threads[old_key] - team_id = merged.get("team_id", "") if team_id and channel_id: self._channel_team[channel_id] = team_id def _lookup_assistant_thread_metadata( self, event: dict, + *, channel_id: str = "", thread_ts: str = "", + team_id: str = "", + body: Optional[dict] = None, ) -> Dict[str, str]: - """Load cached assistant-thread metadata that matches the current event.""" - metadata = self._extract_assistant_thread_metadata(event) + """Load workspace-scoped assistant metadata for the current event.""" + metadata = self._extract_assistant_thread_metadata(event, body) if channel_id and not metadata.get("channel_id"): metadata["channel_id"] = channel_id if thread_ts and not metadata.get("thread_ts"): metadata["thread_ts"] = thread_ts + if team_id and not metadata.get("team_id"): + metadata["team_id"] = str(team_id) - key = self._assistant_thread_key( + key = self._workspace_thread_key( + metadata.get("team_id", ""), metadata.get("channel_id", ""), metadata.get("thread_ts", ""), ) @@ -2471,6 +2745,123 @@ def _lookup_assistant_thread_metadata( 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._workspace_thread_key(team_id, 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) @@ -2490,6 +2881,7 @@ def _seed_assistant_thread_session(self, metadata: Dict[str, str]) -> None: user_id=user_id, thread_id=thread_ts, chat_topic=metadata.get("context_channel_id") or None, + scope_id=metadata.get("team_id") or None, ) try: @@ -2502,13 +2894,107 @@ def _seed_assistant_thread_session(self, metadata: Dict[str, str]) -> None: exc_info=True, ) - async def _handle_assistant_thread_lifecycle_event(self, event: dict) -> None: + 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, + chat_topic=metadata.get("context_channel_id") or None, + scope_id=metadata.get("team_id") or None, + ) + + 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, body: Optional[dict] = None + ) -> None: """Handle Slack Assistant lifecycle events that carry user/thread identity.""" - metadata = self._extract_assistant_thread_metadata(event) + metadata = self._extract_assistant_thread_metadata(event, body) 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_context_changed( + self, event: dict, body: Optional[dict] = None + ) -> None: + """Cache the current Agent-view context without entering the agent loop.""" + context = event.get("context") or event.get("app_context") or {} + context_channel_id = self._context_channel_id(context) + user_id = event.get("user") or event.get("user_id") or "" + team_id = self._event_team_id(event, body) + # ``context_channel_id`` is a channel the user is viewing, not the DM + # Hermes owns. Do not write it into _channel_team: channel IDs can be + # shared across Slack Connect workspaces, so doing so can misroute a + # later unrelated send. Workspace ownership is recorded from actual + # inbound DM/channel events below. + self._cache_agent_view_context( + { + "context_channel_id": str(context_channel_id) if context_channel_id else "", + "user_id": str(user_id) if user_id else "", + "team_id": str(team_id) if team_id else "", + } + ) + + async def _handle_app_home_opened( + self, event: dict, body: Optional[dict] = None + ) -> None: + """Handle Slack Agent DM-open lifecycle events without producing replies.""" + if event.get("tab") != "messages": + return + + context = event.get("context") or event.get("app_context") or {} + channel_id = event.get("channel") or event.get("channel_id") or "" + user_id = event.get("user") or event.get("user_id") or "" + team_id = self._event_team_id(event, body) + context_channel_id = self._context_channel_id(context) + + if team_id and channel_id: + self._channel_team[str(channel_id)] = str(team_id) + + metadata = { + "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 "", + "context_channel_id": context_channel_id, + } + self._cache_agent_view_context(metadata) + self._seed_agent_dm_session(metadata) + await self._set_assistant_suggested_prompts( + metadata["channel_id"], + team_id=metadata["team_id"], + ) - async def _handle_slack_file_shared(self, event: dict) -> None: + async def _handle_slack_file_shared( + self, event: dict, body: Optional[dict] = None + ) -> None: """Fallback for Slack file shares that do not arrive as message.files. Slack documents ``file_shared`` as a file-ID-only event; callers must @@ -2522,7 +3008,7 @@ async def _handle_slack_file_shared(self, event: dict) -> None: if not channel_id or not file_id: return - team_id = event.get("team_id") or event.get("team") or "" + team_id = self._event_team_id(event, body) try: client = self._team_clients.get(team_id) if team_id else None info_resp = await (client or self._get_client(channel_id)).files_info( @@ -2586,7 +3072,9 @@ async def _handle_slack_file_shared(self, event: dict) -> None: fallback_event["thread_ts"] = thread_ts await self._handle_slack_message(fallback_event) - async def _handle_slack_message(self, event: dict) -> None: + async def _handle_slack_message( + self, event: dict, payload: Optional[dict] = None + ) -> None: """Handle an incoming Slack message event.""" # Dedup: Slack Socket Mode can redeliver events after reconnects (#4777) event_ts = event.get("ts", "") @@ -2738,18 +3226,20 @@ async def _handle_slack_message(self, event: dict) -> None: channel_id = event.get("channel", "") ts = event.get("ts", "") + outer_team_id = self._event_team_id(event, payload) assistant_meta = self._lookup_assistant_thread_metadata( event, channel_id=channel_id, thread_ts=event.get("thread_ts", ""), + team_id=outer_team_id, + body=payload, ) user_id = event.get("user") or assistant_meta.get("user_id", "") if not channel_id: channel_id = assistant_meta.get("channel_id", "") - team_id = ( - event.get("team") - or event.get("team_id") - or assistant_meta.get("team_id", "") + team_id = outer_team_id or assistant_meta.get("team_id", "") + agent_context = self._agent_view_context_for_event( + event, str(team_id or ""), str(user_id or "") ) # Track which workspace owns this channel @@ -2859,6 +3349,7 @@ async def _handle_slack_message(self, event: dict) -> None: channel_id=channel_id, thread_ts=event_thread_ts, user_id=user_id, + team_id=team_id, ) if ( not reply_to_bot_thread @@ -2889,6 +3380,7 @@ async def _handle_slack_message(self, event: dict) -> None: channel_id=channel_id, thread_ts=event_thread_ts, user_id=user_id, + team_id=team_id, ): thread_context = await self._fetch_thread_context( channel_id=channel_id, @@ -2920,9 +3412,9 @@ async def _handle_slack_message(self, event: dict) -> None: if not file_id: continue try: - info_resp = await self._get_client(channel_id).files_info( - file=file_id - ) + info_resp = await self._get_client( + channel_id, team_id=team_id + ).files_info(file=file_id) if info_resp.get("ok"): f = info_resp["file"] else: @@ -3158,7 +3650,20 @@ async def _handle_slack_message(self, event: dict) -> None: msg_type = MessageType.DOCUMENT # Resolve user display name (cached after first lookup) - user_name = await self._resolve_user_name(user_id, chat_id=channel_id) + user_name = await self._resolve_user_name( + user_id, chat_id=channel_id, team_id=team_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( @@ -3168,6 +3673,7 @@ async def _handle_slack_message(self, event: dict) -> None: user_id=user_id, user_name=user_name, thread_id=thread_ts, + scope_id=str(team_id) if team_id else None, # Slack Workflow Builder / app posts arrive as # subtype=bot_message with user=None; flag them so the # gateway SLACK_ALLOW_BOTS bypass can authorize them @@ -3223,6 +3729,11 @@ async def _handle_slack_message(self, event: dict) -> None: 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). @@ -3233,6 +3744,18 @@ async def _handle_slack_message(self, event: dict) -> None: if _should_react: self._reacting_message_ids.add(ts) + # App-context is per-turn, user-controlled Slack UI state. Surface it + # with the inbound user message rather than storing it on SessionSource: + # putting it in the cached system prompt would rebuild the agent whenever + # the user switches views and would let stale context bleed into later + # turns. The agent receives an inert label, never a fetched channel body. + context_channel_id = agent_context.get("context_channel_id", "") + if context_channel_id and context_channel_id != channel_id: + msg_event.text = ( + f"[Slack app context: user is viewing channel {context_channel_id}]\n\n" + f"{msg_event.text}" + ) + await self.handle_message(msg_event) # ----- Approval button support (Block Kit) ----- @@ -3321,7 +3844,9 @@ async def send_exec_approval( if thread_ts: kwargs["thread_ts"] = thread_ts - result = await self._get_client(chat_id).chat_postMessage(**kwargs) + result = await self._get_client( + chat_id, team_id=self._metadata_team_id(metadata) + ).chat_postMessage(**kwargs) msg_ts = result.get("ts", "") if msg_ts: self._approval_resolved[msg_ts] = False @@ -3399,7 +3924,9 @@ async def send_slash_confirm( if thread_ts: kwargs["thread_ts"] = thread_ts - result = await self._get_client(chat_id).chat_postMessage(**kwargs) + result = await self._get_client( + chat_id, team_id=self._metadata_team_id(metadata) + ).chat_postMessage(**kwargs) return SendResult( success=True, message_id=result.get("ts", ""), raw_response=result ) @@ -3413,6 +3940,7 @@ def _is_interactive_user_authorized( *, channel_id: str = "", user_name: Optional[str] = None, + team_id: str = "", ) -> bool: """Return whether a Slack interactive caller may perform gated actions.""" normalized_user_id = str(user_id or "").strip() @@ -3431,6 +3959,7 @@ def _is_interactive_user_authorized( chat_type="dm" if str(channel_id or "").startswith("D") else "group", user_id=normalized_user_id, user_name=str(user_name).strip() if user_name else None, + scope_id=str(team_id) if team_id else None, ) return bool(auth_fn(source)) except Exception: @@ -3460,6 +3989,7 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None: """Handle a slash-confirm button click from Block Kit.""" await ack() + team_id = self._event_team_id({}, body) action_id = action.get("action_id", "") value = action.get("value", "") message = body.get("message", {}) @@ -3471,6 +4001,7 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None: user_id, channel_id=channel_id, user_name=user_name, + team_id=team_id, ): logger.warning( "[Slack] Unauthorized slash-confirm click by %s (%s) - ignoring", @@ -3535,7 +4066,7 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None: ] try: - await self._get_client(channel_id).chat_update( + await self._get_client(channel_id, team_id=team_id or None).chat_update( channel=channel_id, ts=msg_ts, text=decision_text, @@ -3560,7 +4091,9 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None: thread_ts = message.get("thread_ts") or msg_ts if thread_ts: post_kwargs["thread_ts"] = thread_ts - await self._get_client(channel_id).chat_postMessage(**post_kwargs) + await self._get_client( + channel_id, team_id=team_id or None + ).chat_postMessage(**post_kwargs) logger.info( "Slack button resolved slash-confirm for session %s (choice=%s, user=%s)", session_key, @@ -3574,10 +4107,27 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None: 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() + team_id = self._event_team_id({}, body) action_id = action.get("action_id", "") session_key = action.get("value", "") message = body.get("message", {}) @@ -3590,6 +4140,7 @@ async def _handle_approval_action(self, ack, body, action) -> None: user_id, channel_id=channel_id, user_name=user_name, + team_id=team_id, ): logger.warning( "[Slack] Unauthorized approval click by %s (%s) - ignoring", @@ -3657,7 +4208,7 @@ async def _handle_approval_action(self, ack, body, action) -> None: ] try: - await self._get_client(channel_id).chat_update( + await self._get_client(channel_id, team_id=team_id or None).chat_update( channel=channel_id, ts=msg_ts, text=decision_text, @@ -3717,7 +4268,7 @@ async def _fetch_thread_context( return cached.content try: - client = self._get_client(channel_id) + client = self._get_client(channel_id, team_id=team_id) # Retry with exponential backoff for Tier-3 rate limits (429). result = None @@ -3802,7 +4353,9 @@ async def _fetch_thread_context( # Prefer the bot's own name when the message is a bot post. if is_bot and not display_user: display_user = msg.get("username") or "bot" - name = await self._resolve_user_name(display_user, chat_id=channel_id) + name = await self._resolve_user_name( + display_user, chat_id=channel_id, team_id=team_id + ) # Mark senders not on the allowlist as [unverified] so the LLM # treats their content as background reference rather than @@ -3878,7 +4431,7 @@ async def _fetch_thread_parent_text( return cached.parent_text try: - client = self._get_client(channel_id) + client = self._get_client(channel_id, team_id=team_id) result = await client.conversations_replies( channel=channel_id, ts=thread_ts, @@ -3961,6 +4514,7 @@ async def _handle_slash_command(self, command: dict) -> None: chat_id=channel_id, chat_type="dm" if is_dm else "group", user_id=user_id, + scope_id=team_id or None, ) event = MessageEvent( @@ -3998,6 +4552,7 @@ def _has_active_session_for_thread( channel_id: str, thread_ts: str, user_id: str, + team_id: str = "", ) -> bool: """Check if there's an active session for a thread. @@ -4022,6 +4577,7 @@ def _has_active_session_for_thread( chat_type="group", user_id=user_id, thread_id=thread_ts, + scope_id=team_id or None, ) # Read session isolation settings from the store's config -
pyproject.toml@@ -158,9 +158,9 @@ modal = ["modal==1.3.4"] daytona = ["daytona==0.155.0"] hindsight = ["hindsight-client==0.6.1"] dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==81.0.0"] # starlette: CVE-2026-48710; setuptools: latest <82 (torch >=2.11 caps setuptools<82) -messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.14.1", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] # aiohttp 3.14.1: CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265 +messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.14.1", "brotlicffi==1.2.0.1", "slack-bolt==1.29.0", "slack-sdk==3.43.0", "qrcode==7.4.2"] # aiohttp 3.14.1: CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265 cron = [] # croniter is now a core dependency; this extra kept for back-compat -slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.14.1"] +slack = ["slack-bolt==1.29.0", "slack-sdk==3.43.0", "aiohttp==3.14.1"] matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0", "aiohttp==3.14.1"] # aiohttp 3.14.1: CVE-2026-34993(RCE)/47265 + 34513/34518/34519/34520/34525 (mautrix/aiohttp-socks only cap aiohttp<4 / >=3.10, so pin the patched floor directly) # WeCom callback-mode adapter — parses untrusted XML POST bodies from # WeCom-controlled callback endpoints, so we use defusedxml (drop-in -
scripts/release.py@@ -47,6 +47,7 @@ AUTHOR_MAP = { "[redacted:email]": "mguttmann", # PR #63738 salvage (Anthropic setup-token pool auth normalization) "[redacted:email]": "VrtxOmega", # PR #43809 salvage (desktop: WSL folder-picker path bridge) + "[redacted:email]": "SemonCat", # PR #56786 salvage (Slack Agent View manifests and Assistant APIs) "[redacted:email]": "jakelongvu-bot", # PR #36683 partial salvage (approval: honor canonical approvals.timeout in gateway waits) "[email protected]": "Tortugasaur", # PR #43205 salvage (desktop: profile-aware three-way approval mode statusbar control) "[redacted:email]": "kavioavio", # Issue #46544 / PR #47705 evolution (smart DENY exact-operation owner override) -
tests/gateway/test_base_topic_sessions.py@@ -41,6 +41,9 @@ async def send_typing(self, chat_id: str, metadata=None) -> None: self.typing.append({"chat_id": chat_id, "metadata": metadata}) return None + async def stop_typing(self, chat_id: str, metadata=None) -> None: + self.typing.append({"chat_id": chat_id, "stopped": True, "metadata": metadata}) + async def get_chat_info(self, chat_id: str): return {"id": chat_id} @@ -143,6 +146,11 @@ async def hold_typing(_chat_id, interval=2.0, metadata=None): "metadata": {"thread_id": "17585"}, } ] + assert { + "chat_id": "-1001", + "stopped": True, + "metadata": {"thread_id": "17585"}, + } in adapter.typing assert adapter.processing_hooks == [ ("start", "1"), ("complete", "1", ProcessingOutcome.SUCCESS), -
tests/gateway/test_slack.py@@ -17,6 +17,7 @@ import pytest from gateway.config import Platform, PlatformConfig +from gateway.run import GatewayRunner from gateway.platforms.base import ( MessageEvent, MessageType, @@ -148,6 +149,7 @@ async def test_channel_slash_command_uses_group_session_semantics(self, adapter) assert event.source.chat_type == "group" assert event.source.chat_id == "C123" assert event.source.user_id == "U123" + assert event.source.scope_id == "T123" @pytest.mark.asyncio async def test_dm_slash_command_keeps_dm_session_semantics(self, adapter): @@ -165,6 +167,7 @@ async def test_dm_slash_command_keeps_dm_session_semantics(self, adapter): assert event.source.chat_type == "dm" assert event.source.chat_id == "D123" assert event.source.user_id == "U123" + assert event.source.scope_id == "T123" # --------------------------------------------------------------------------- @@ -238,6 +241,8 @@ def decorator(fn): assert "message" in registered_events assert "app_mention" in registered_events + assert "app_home_opened" in registered_events + assert "app_context_changed" in registered_events assert "reaction_added" in registered_events assert "reaction_removed" in registered_events assert "assistant_thread_started" in registered_events @@ -799,6 +804,7 @@ async def close_async(self): 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): @@ -915,6 +921,25 @@ async def test_send_document_success(self, adapter, tmp_path): assert call_kwargs["filename"] == "report.pdf" assert call_kwargs["initial_comment"] == "Here's the report" + @pytest.mark.asyncio + async def test_send_document_uses_metadata_workspace_client(self, adapter, tmp_path): + """Outbound media follows the inbound Slack workspace across gateway boundaries.""" + test_file = tmp_path / "report.pdf" + test_file.write_bytes(b"%PDF-1.4 fake content") + secondary_client = AsyncMock() + secondary_client.files_upload_v2 = AsyncMock(return_value={"ok": True}) + adapter._team_clients["T_SECONDARY"] = secondary_client + + result = await adapter.send_document( + chat_id="C123", + file_path=str(test_file), + metadata={"slack_team_id": "T_SECONDARY"}, + ) + + assert result.success + secondary_client.files_upload_v2.assert_awaited_once() + adapter._app.client.files_upload_v2.assert_not_called() + @pytest.mark.asyncio async def test_send_document_custom_name(self, adapter, tmp_path): test_file = tmp_path / "data.csv" @@ -2072,7 +2097,7 @@ async def test_stop_typing_clears_tracked_thread(self, adapter): thread_ts="parent_ts", status="", ) - assert "C123" not in adapter._active_status_threads + assert ("", "C123", "parent_ts") not in adapter._active_status_threads @pytest.mark.asyncio async def test_stop_typing_noop_without_tracked_thread(self, adapter): @@ -2084,7 +2109,10 @@ async def test_stop_typing_noop_without_tracked_thread(self, adapter): @pytest.mark.asyncio async def test_stop_typing_handles_api_error_gracefully(self, adapter): - adapter._active_status_threads["C123"] = "parent_ts" + adapter._active_status_threads[("", "C123", "parent_ts")] = { + "thread_ts": "parent_ts", + "team_id": "", + } adapter._app.client.assistant_threads_setStatus = AsyncMock( side_effect=Exception("missing_scope") ) @@ -2096,7 +2124,7 @@ async def test_stop_typing_handles_api_error_gracefully(self, adapter): thread_ts="parent_ts", status="", ) - assert "C123" not in adapter._active_status_threads + assert ("", "C123", "parent_ts") not in adapter._active_status_threads @pytest.mark.asyncio async def test_send_clears_status_after_final_post(self, adapter): @@ -2104,7 +2132,10 @@ async def test_send_clears_status_after_final_post(self, adapter): return_value={"ts": "reply_ts"} ) adapter._app.client.assistant_threads_setStatus = AsyncMock() - adapter._active_status_threads["C123"] = "parent_ts" + adapter._active_status_threads[("", "C123", "parent_ts")] = { + "thread_ts": "parent_ts", + "team_id": "", + } result = await adapter.send("C123", "done", metadata={"thread_id": "parent_ts"}) @@ -2115,13 +2146,16 @@ async def test_send_clears_status_after_final_post(self, adapter): thread_ts="parent_ts", status="", ) - assert "C123" not in adapter._active_status_threads + assert ("", "C123", "parent_ts") not in adapter._active_status_threads @pytest.mark.asyncio async def test_streaming_final_edit_clears_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", "parent_ts")] = { + "thread_ts": "parent_ts", + "team_id": "", + } result = await adapter.edit_message( "C123", @@ -2141,13 +2175,16 @@ async def test_streaming_final_edit_clears_status(self, adapter): thread_ts="parent_ts", status="", ) - assert "C123" not in adapter._active_status_threads + assert ("", "C123", "parent_ts") not in adapter._active_status_threads @pytest.mark.asyncio 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", "parent_ts")] = { + "thread_ts": "parent_ts", + "team_id": "", + } result = await adapter.edit_message( "C123", @@ -2158,7 +2195,195 @@ async def test_streaming_intermediate_edit_keeps_status(self, adapter): 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", "parent_ts")][ + "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_stop_typing_with_metadata_preserves_sibling_status(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_with_metadata( + "D123", {"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_b") in adapter._active_status_threads + + @pytest.mark.asyncio + async def test_status_tracking_is_scoped_per_workspace(self, adapter): + one, two = AsyncMock(), AsyncMock() + adapter._team_clients.update({"T_ONE": one, "T_TWO": two}) + + await adapter.send_typing( + "D_SHARED", metadata={"thread_id": "171.000", "slack_team_id": "T_ONE"} + ) + await adapter.send_typing( + "D_SHARED", metadata={"thread_id": "171.000", "slack_team_id": "T_TWO"} + ) + await adapter.stop_typing( + "D_SHARED", metadata={"thread_id": "171.000", "slack_team_id": "T_ONE"} + ) + + assert one.assistant_threads_setStatus.call_args_list[-1] == call( + channel_id="D_SHARED", thread_ts="171.000", status="" + ) + assert two.assistant_threads_setStatus.call_args_list[-1] == call( + channel_id="D_SHARED", thread_ts="171.000", status="is thinking..." + ) + assert ("T_TWO", "D_SHARED", "171.000") in adapter._active_status_threads + + @pytest.mark.asyncio + async def test_stop_typing_without_team_uses_unique_thread_status(self, adapter): + """A stale channel fallback must not strand a uniquely tracked status.""" + team_one, team_two = AsyncMock(), AsyncMock() + adapter._team_clients.update({"T_ONE": team_one, "T_TWO": team_two}) + await adapter.send_typing( + "D_SHARED", + metadata={"thread_id": "171.000", "slack_team_id": "T_ONE"}, + ) + # Another workspace can overwrite this channel-only fallback map. + adapter._channel_team["D_SHARED"] = "T_TWO" + + await adapter.stop_typing("D_SHARED", metadata={"thread_id": "171.000"}) + + assert team_one.assistant_threads_setStatus.call_args_list[-1] == call( + channel_id="D_SHARED", thread_ts="171.000", status="" + ) + assert ("T_ONE", "D_SHARED", "171.000") not in adapter._active_status_threads + team_two.assistant_threads_setStatus.assert_not_called() + + @pytest.mark.asyncio + async def test_stop_typing_without_team_preserves_ambiguous_thread_statuses( + self, adapter + ): + """Without team metadata, matching workspace statuses must not be guessed.""" + team_one, team_two = AsyncMock(), AsyncMock() + adapter._team_clients.update({"T_ONE": team_one, "T_TWO": team_two}) + for team_id in ("T_ONE", "T_TWO"): + await adapter.send_typing( + "D_SHARED", + metadata={"thread_id": "171.000", "slack_team_id": team_id}, + ) + + await adapter.stop_typing("D_SHARED", metadata={"thread_id": "171.000"}) + + assert ("T_ONE", "D_SHARED", "171.000") in adapter._active_status_threads + assert ("T_TWO", "D_SHARED", "171.000") in adapter._active_status_threads + assert team_one.assistant_threads_setStatus.call_count == 1 + assert team_two.assistant_threads_setStatus.call_count == 1 + + @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[("T_OTHER", "D123", "parent_ts")] = { + "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", "parent_ts")] = { + "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", "parent_ts") not in adapter._active_status_threads # --------------------------------------------------------------------------- @@ -2914,7 +3139,7 @@ async def test_no_session_store_ignores_thread_replies(self, adapter): 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): @@ -2957,7 +3182,9 @@ async def test_lifecycle_event_seeds_session_store( await assistant_adapter._handle_assistant_thread_lifecycle_event(event) assert ( - assistant_adapter._assistant_threads[("D123", "171.000")]["user_id"] + assistant_adapter._assistant_threads[("T_TEAM", "D123", "171.000")][ + "user_id" + ] == "U_USER" ) mock_session_store.get_or_create_session.assert_called_once() @@ -2968,11 +3195,51 @@ async def test_lifecycle_event_seeds_session_store( 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 ): - assistant_adapter._assistant_threads[("D123", "171.000")] = { + assistant_adapter._assistant_threads[("T_TEAM", "D123", "171.000")] = { "channel_id": "D123", "thread_ts": "171.000", "user_id": "U_USER", @@ -3023,8 +3290,270 @@ def test_assistant_threads_cache_eviction(self, assistant_adapter): } ) assert len(assistant_adapter._assistant_threads) <= 10 - # The newest entry must survive eviction - assert ("D999", "999.000") in assistant_adapter._assistant_threads + # 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_agent_view_context_is_scoped_per_workspace_and_user( + self, assistant_adapter + ): + await assistant_adapter._handle_app_context_changed( + { + "type": "app_context_changed", + "user": "U_ONE", + "context": { + "entities": [ + { + "type": "slack#/types/channel_id", + "value": "C_CONTEXT_ONE", + } + ] + }, + }, + {"team_id": "T_ONE"}, + ) + await assistant_adapter._handle_app_context_changed( + { + "type": "app_context_changed", + "user": "U_TWO", + "context": { + "entities": [ + { + "type": "slack#/types/channel_id", + "value": "C_CONTEXT_TWO", + } + ] + }, + }, + {"team_id": "T_TWO"}, + ) + + assert assistant_adapter._agent_view_context_for_event( + {}, "T_ONE", "U_ONE" + )["context_channel_id"] == "C_CONTEXT_ONE" + assert assistant_adapter._agent_view_context_for_event( + {}, "T_TWO", "U_TWO" + )["context_channel_id"] == "C_CONTEXT_TWO" + assert "C_CONTEXT_ONE" not in assistant_adapter._channel_team + + @pytest.mark.asyncio + async def test_assistant_thread_cache_is_scoped_per_workspace( + self, assistant_adapter + ): + """Slack Connect can reuse a channel/thread pair in multiple workspaces.""" + for team_id, user_id in (("T_ONE", "U_ONE"), ("T_TWO", "U_TWO")): + await assistant_adapter._handle_assistant_thread_lifecycle_event( + { + "type": "assistant_thread_started", + "team_id": team_id, + "assistant_thread": { + "channel_id": "D_SHARED", + "thread_ts": "171.000", + "user_id": user_id, + }, + } + ) + + assert assistant_adapter._assistant_threads[ + ("T_ONE", "D_SHARED", "171.000") + ]["user_id"] == "U_ONE" + assert assistant_adapter._assistant_threads[ + ("T_TWO", "D_SHARED", "171.000") + ]["user_id"] == "U_TWO" + assert assistant_adapter._lookup_assistant_thread_metadata( + {}, channel_id="D_SHARED", thread_ts="171.000", team_id="T_ONE" + )["user_id"] == "U_ONE" + assert assistant_adapter._lookup_assistant_thread_metadata( + {}, channel_id="D_SHARED", thread_ts="171.000", team_id="T_TWO" + )["user_id"] == "U_TWO" + + @pytest.mark.asyncio + async def test_agent_view_message_preserves_outer_team_and_turn_context( + 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() + await assistant_adapter._handle_app_context_changed( + { + "type": "app_context_changed", + "user": "U_USER", + "context": { + "entities": [ + { + "type": "slack#/types/channel_id", + "value": "C_ACTIVE", + } + ] + }, + }, + {"team_id": "T_OTHER"}, + ) + + await assistant_adapter._handle_slack_message( + { + "text": "help me plan", + "channel": "D123", + "channel_type": "im", + "ts": "171.111", + "user": "U_USER", + }, + {"team_id": "T_OTHER"}, + ) + + msg_event = assistant_adapter.handle_message.await_args.args[0] + assert msg_event.source.scope_id == "T_OTHER" + assert msg_event.metadata["slack_team_id"] == "T_OTHER" + assert msg_event.source.thread_id == "171.111" + assert msg_event.text.startswith( + "[Slack app context: user is viewing channel C_ACTIVE]" + ) + + runner = object.__new__(GatewayRunner) + assert runner._thread_metadata_for_source(msg_event.source) == { + "thread_id": "171.111", + "slack_team_id": "T_OTHER", + } + + @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() # --------------------------------------------------------------------------- @@ -3073,6 +3602,23 @@ async def test_handles_api_error(self, adapter): name = await adapter._resolve_user_name("U123") assert name == "U123" # Falls back to user_id + @pytest.mark.asyncio + async def test_workspace_scoped_cache_uses_each_workspace_client(self, adapter): + """The same Slack user ID can resolve differently in another workspace.""" + team_one, team_two = AsyncMock(), AsyncMock() + team_one.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Alice"}}} + ) + team_two.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Bob"}}} + ) + adapter._team_clients.update({"T_ONE": team_one, "T_TWO": team_two}) + + assert await adapter._resolve_user_name("U_SHARED", "D_SHARED", "T_ONE") == "Alice" + assert await adapter._resolve_user_name("U_SHARED", "D_SHARED", "T_TWO") == "Bob" + team_one.users_info.assert_awaited_once_with(user="U_SHARED") + team_two.users_info.assert_awaited_once_with(user="U_SHARED") + @pytest.mark.asyncio async def test_user_name_in_message_source(self, adapter): """Message source should include resolved user name.""" @@ -4055,6 +4601,27 @@ async def test_no_auth_check_preserves_legacy_format(self, adapter): assert "identity hasn't" not in content assert "[Thread context — prior messages in this thread (not yet in conversation history):]" in content + @pytest.mark.asyncio + async def test_thread_context_uses_workspace_client(self, adapter): + team_client = AsyncMock() + team_client.conversations_replies = self._make_replies(self._thread_messages()) + adapter._team_clients["T_OTHER"] = team_client + adapter._thread_context_cache.clear() + + with patch.object( + adapter, "_resolve_user_name", + new=AsyncMock(side_effect=lambda uid, **_: uid), + ): + await adapter._fetch_thread_context( + channel_id="C1", + thread_ts="100.0", + current_ts="999.0", + team_id="T_OTHER", + ) + + team_client.conversations_replies.assert_awaited_once() + adapter._app.client.conversations_replies.assert_not_called() + @pytest.mark.asyncio async def test_all_authorized_no_tags(self, adapter): """Auth callback returning True for every sender → no [unverified] tags.""" -
tests/gateway/test_slack_approval_buttons.py@@ -323,6 +323,18 @@ def test_delegates_to_gateway_runner_auth(self): assert runner.seen_sources[0].chat_id == "C1" assert runner.seen_sources[0].chat_type == "group" + def test_passes_workspace_scope_to_gateway_runner_auth(self): + adapter = _make_adapter() + runner = _attach_auth_runner(adapter) + + assert adapter._is_interactive_user_authorized( + "U_OK", + channel_id="C1", + user_name="operator", + team_id="T1", + ) is True + assert runner.seen_sources[0].scope_id == "T1" + class TestSlackSlashConfirmAction: @pytest.mark.asyncio @@ -364,6 +376,40 @@ async def test_global_allowlist_allows_authorized_click(self, monkeypatch): mock_client.chat_update.assert_called_once() mock_client.chat_postMessage.assert_called_once() + @pytest.mark.asyncio + async def test_action_uses_outer_payload_workspace_client(self, monkeypatch): + adapter = _make_adapter() + secondary_client = AsyncMock() + adapter._team_clients["T2"] = secondary_client + monkeypatch.delenv("SLACK_ALLOWED_USERS", raising=False) + monkeypatch.delenv("SLACK_ALLOW_ALL_USERS", raising=False) + monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False) + monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "U_OWNER") + + ack = AsyncMock() + body = { + "team_id": "T2", + "message": { + "ts": "2222.3333", + "blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": "Original prompt"}}, + ], + }, + "channel": {"id": "C1"}, + "user": {"name": "owner", "id": "U_OWNER"}, + } + action = { + "action_id": "hermes_confirm_once", + "value": "agent:main:slack:group:C1:1111|confirm-1", + } + + with patch("tools.slash_confirm.resolve", new=AsyncMock(return_value="follow-up")): + await adapter._handle_slash_confirm_action(ack, body, action) + + secondary_client.chat_update.assert_awaited_once() + secondary_client.chat_postMessage.assert_awaited_once() + adapter._team_clients["T1"].chat_update.assert_not_called() + # =========================================================================== # _fetch_thread_context @@ -385,7 +431,7 @@ async def test_fetches_and_formats_context(self): }) # Mock user name resolution - adapter._user_name_cache = {"U1": "Alice", "U2": "Bob"} + adapter._user_name_cache = {("T1", "U1"): "Alice", ("T1", "U2"): "Bob"} context = await adapter._fetch_thread_context( channel_id="C1", @@ -432,7 +478,10 @@ async def test_skips_bot_messages(self): {"ts": "1000.2", "user": "U1", "text": "Current"}, ] }) - adapter._user_name_cache = {"U1": "Alice", "U_OTHER_BOT": "DeployBot"} + adapter._user_name_cache = { + ("T1", "U1"): "Alice", + ("T1", "U_OTHER_BOT"): "DeployBot", + } context = await adapter._fetch_thread_context( channel_id="C1", thread_ts="1000.0", current_ts="1000.2", team_id="T1" @@ -485,7 +534,7 @@ async def test_fetch_thread_context_includes_bot_parent(self): {"ts": "1000.1", "user": "U1", "text": "詳細を教えて"}, ] }) - adapter._user_name_cache = {"U1": "Alice"} + adapter._user_name_cache = {("T1", "U1"): "Alice"} context = await adapter._fetch_thread_context( channel_id="C1", @@ -519,7 +568,7 @@ async def test_fetch_thread_context_excludes_self_bot_replies(self): {"ts": "1000.3", "user": "U1", "text": "Current"}, ] }) - adapter._user_name_cache = {"U1": "Alice"} + adapter._user_name_cache = {("T1", "U1"): "Alice"} context = await adapter._fetch_thread_context( channel_id="C1", thread_ts="1000.0", current_ts="1000.3", team_id="T1" @@ -567,7 +616,7 @@ async def test_fetch_thread_context_multi_workspace(self): {"ts": "2000.3", "user": "U2", "text": "Current"}, ] }) - adapter._user_name_cache = {"U2": "Bob"} + adapter._user_name_cache = {("T2", "U2"): "Bob"} context = await adapter._fetch_thread_context( channel_id="C2", thread_ts="2000.0", current_ts="2000.3", team_id="T2" @@ -590,7 +639,7 @@ async def test_fetch_thread_context_current_ts_excluded(self): {"ts": "1000.1", "user": "U1", "text": "DO NOT INCLUDE THIS"}, ] }) - adapter._user_name_cache = {"U1": "Alice"} + adapter._user_name_cache = {("T1", "U1"): "Alice"} context = await adapter._fetch_thread_context( channel_id="C1", thread_ts="1000.0", current_ts="1000.1", team_id="T1" -
tests/gateway/test_slack_block_kit_adapter.py@@ -77,6 +77,27 @@ async def test_multichunk_message_no_blocks(self): 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({"rich_blocks": True, "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" + + @pytest.mark.asyncio + async def test_feedback_buttons_require_rich_blocks(self): + """feedback_buttons alone must not implicitly enable Block Kit rendering.""" + adapter, client = _make_adapter({"feedback_buttons": True}) + + await adapter.send("C1", "final answer") + + assert "blocks" not in client.chat_postMessage.await_args.kwargs + class TestEditMessageBlocks: @pytest.mark.asyncio @@ -95,6 +116,13 @@ async def test_finalize_edit_gets_blocks(self): 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 -
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 @@ def test_no_assistant_flag_sets_true(self): 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 @@ def test_assistant_features_remain_enabled(self): 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,39 @@ def test_no_assistant_omits_assistant_pieces(self): # 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 "app_context_changed" 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( -
tools/lazy_deps.py@@ -168,8 +168,8 @@ "aiohttp==3.14.1", # CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265 ), "platform.slack": ( - "slack-bolt==1.27.0", - "slack-sdk==3.40.1", + "slack-bolt==1.29.0", + "slack-sdk==3.43.0", "aiohttp==3.14.1", # CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265 ), "platform.matrix": ( -
uv.lock@@ -1834,10 +1834,10 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" }, { name = "setuptools", marker = "extra == 'dev'", specifier = "==81.0.0" }, { name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" }, - { name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.27.0" }, - { name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.27.0" }, - { name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.40.1" }, - { name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.40.1" }, + { name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.29.0" }, + { name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.29.0" }, + { name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.43.0" }, + { name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.43.0" }, { name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" }, { name = "starlette", marker = "extra == 'computer-use'", specifier = "==1.0.1" }, { name = "starlette", marker = "extra == 'dev'", specifier = "==1.0.1" }, @@ -3889,23 +3889,23 @@ wheels = [ [[package]] name = "slack-bolt" -version = "1.27.0" +version = "1.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "slack-sdk" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/28/50ed0b86e48b48e6ddcc71de93b91c8ac14a55d1249e4bff0586494a2f90/slack_bolt-1.27.0.tar.gz", hash = "sha256:3db91d64e277e176a565c574ae82748aa8554f19e41a4fceadca4d65374ce1e0", size = 129101, upload-time = "2025-11-13T20:17:46.878Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/2c/7434f5d8c52eafb1e702012e9f291b013bd05985a5b32bde568b2279a28a/slack_bolt-1.29.0.tar.gz", hash = "sha256:b6271ba0a9b71e319c86b40632e6cb6240aacd0433773615b76b890b9a574762", size = 131151, upload-time = "2026-06-30T20:24:44.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/a8/1acb355759747ba4da5f45c1a33d641994b9e04b914908c9434f18bd97e8/slack_bolt-1.27.0-py2.py3-none-any.whl", hash = "sha256:c43c94bf34740f2adeb9b55566c83f1e73fed6ba2878bd346cdfd6fd8ad22360", size = 230428, upload-time = "2025-11-13T20:17:45.465Z" }, + { url = "https://files.pythonhosted.org/packages/2f/cc/e5f78a80a1775a4cbb2cc41e8ef433602d5e755ff0d829bd43145015740a/slack_bolt-1.29.0-py2.py3-none-any.whl", hash = "sha256:1835b66b778158f3af0da77603aa18d7dfd82fd9b9a985e25c752f95050ab826", size = 235345, upload-time = "2026-06-30T20:24:42.558Z" }, ] [[package]] name = "slack-sdk" -version = "3.40.1" +version = "3.43.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/18/784859b33a3f9c8cdaa1eda4115eb9fe72a0a37304718887d12991eeb2fd/slack_sdk-3.40.1.tar.gz", hash = "sha256:a215333bc251bc90abf5f5110899497bf61a3b5184b6d9ee35d73ebf09ec3fd0", size = 250379, upload-time = "2026-02-18T22:11:01.819Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/75/a4964eb771a0c74d79ee7a3bee6fb5d9718909dd1b675e80d62a6a0ad90a/slack_sdk-3.43.0.tar.gz", hash = "sha256:0553152e46c4259eb69f7464cdadc35ba4802ca10f9f5a849c92cf03d6c2ba07", size = 252769, upload-time = "2026-06-30T18:04:41.59Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/e1/bb81f93c9f403e3b573c429dd4838ec9b44e4ef35f3b0759eb49557ab6e3/slack_sdk-3.40.1-py2.py3-none-any.whl", hash = "sha256:cd8902252979aa248092b0d77f3a9ea3cc605bc5d53663ad728e892e26e14a65", size = 313687, upload-time = "2026-02-18T22:11:00.027Z" }, + { url = "https://files.pythonhosted.org/packages/e4/55/42141b8338d46323d5b3c6095201b044c670c20f898643b322ea9b1543a1/slack_sdk-3.43.0-py2.py3-none-any.whl", hash = "sha256:4b6557c65577fc172f685af218b811f9f3b4909e24cddd839ada09565f10c585", size = 315866, upload-time = "2026-06-30T18:04:39.636Z" }, ] [[package]] -
website/docs/user-guide/messaging/slack.md@@ -36,12 +36,13 @@ Mode — all at once. ### Option A: From a Hermes-generated manifest (recommended) -1. Generate the manifest: +1. Generate the manifest. New Slack apps must use Agent view: ```bash - hermes slack manifest --write + hermes slack manifest --agent-view --write ``` This writes `~/.hermes/slack-manifest.json` and prints paste-in - instructions. + instructions. Existing apps that still use Slack's legacy Assistant view + can omit `--agent-view` until they are ready to migrate. 2. Go to [https://api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From an app manifest** 3. Pick your workspace, paste the JSON contents, review, click **Next** @@ -243,6 +244,23 @@ Step 1, Option A) that declares every command in as a slash command. In Socket Mode, Slack routes the command event through the WebSocket regardless of the manifest's `url` field. +### Agent messaging experience + +New Slack apps use Slack's **Agent** messaging experience. Existing Hermes +Assistant apps can migrate by regenerating the manifest with `--agent-view`: + +```bash +hermes slack manifest --agent-view --write +``` + +Update the manifest in **Features → App Manifest**, then reinstall the app if +Slack asks. Agent view cannot be reverted to Assistant view, and users may need +to hard-refresh Slack after the switch. The generated Agent manifest subscribes +to `message.im`, `app_home_opened`, and `app_context_changed`, so Hermes can +identify a Messages-tab DM and receive the user's active Slack context with a +turn. Hermes only supplies that context as a label; it does not read the viewed +channel's history. + ### Refreshing slash commands after updates When Hermes adds new commands (e.g. after `hermes update`), regenerate @@ -353,6 +371,19 @@ platforms: # gracefully fall back to aligned monospace. rich_blocks: false + # Append Slack-native feedback controls to final Block Kit replies. + # Requires rich_blocks: true. Default: false. + feedback_buttons: false + + # Suggested prompts pinned at the top of Agent view's Messages tab. + # Either a list of {title, message} rows, or a titled object: + # {title: "Start here", prompts: [{title: "Plan", message: "..."}]} + suggested_prompts: [] + + # Title Agent/Assistant DM threads from the first user message. + # Default: true. Set false to leave Slack's default thread titles. + assistant_thread_titles: true + # Continuable-cron delivery surface (default: "thread"). # "in_channel" delivers a continuable cron job FLAT into the channel # (no dedicated thread); pair with reply_in_thread: false (and @@ -367,6 +398,9 @@ platforms: | `platforms.slack.extra.reply_in_thread` | `true` | When `false`, channel messages get direct replies instead of threads. Messages inside existing threads still reply in-thread. | | `platforms.slack.extra.reply_broadcast` | `false` | When `true`, thread replies are also posted to the main channel. Only the first chunk is broadcast. | | `platforms.slack.extra.rich_blocks` | `false` | When `true`, agent messages are rendered as [Block Kit](https://docs.slack.dev/block-kit/) blocks (headers, dividers, true nested lists, and native tables). A plain-text fallback is always sent. Tables over Slack's limits fall back to aligned monospace. No app reinstall required — it's a send-side change only. | +| `platforms.slack.extra.feedback_buttons` | `false` | When `true` with `rich_blocks`, appends Slack-native feedback controls to final replies. | +| `platforms.slack.extra.suggested_prompts` | `[]` | Up to four `{title, message}` prompts for Agent/Assistant DM entry points; accepts either a list or `{title, prompts}`. | +| `platforms.slack.extra.assistant_thread_titles` | `true` | When `true`, names Agent/Assistant DM threads from the first user message. | | `platforms.slack.extra.cron_continuable_surface` | `"thread"` | Delivery surface for [continuable cron jobs](../features/cron.md#flat-in-channel-continuation-slack). `"thread"` opens a dedicated thread per delivery (default); `"in_channel"` delivers flat into the channel timeline. Pair `in_channel` with `reply_in_thread: false` (and `require_mention: false`) so a plain channel reply continues the job. | ### Session Isolation
-
-
#64621 fix(slack): clear stuck assistant status on /stop and resume typing after clarify
files:
gateway/run.py,gateway/slash_commands.py,plugins/platforms/slack/adapter.py,scripts/release.py,tests/gateway/test_clarify_active_session_bypass.py,tests/gateway/test_slack.py,tests/gateway/test_stop_thread_sibling.pyShow the diff
-
gateway/run.py@@ -9208,6 +9208,21 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: "Gateway intercepted clarify text response (session=%s, id=%s)", _quick_key, _pending_clarify.clarify_id, ) + # The clarify callback pauses the platform typing/status + # indicator while waiting so Slack users can type their + # answer. The active agent resumes as soon as this reply + # resolves the wait, so re-enable its indicator here too. + # Without this, Slack stays silent until the independent + # long-running heartbeat fires (three minutes by default). + _clarify_adapter = self._adapter_for_source(source) + if _clarify_adapter: + try: + _clarify_adapter.resume_typing_for_chat(source.chat_id) + except Exception: + logger.debug( + "Failed to resume typing after clarify response", + exc_info=True, + ) # Acknowledge with empty string so adapters that emit # the agent's response don't double-post. The agent # itself will produce the next user-facing message. -
gateway/slash_commands.py@@ -1112,6 +1112,26 @@ async def _handle_stop_command(self, event: MessageEvent) -> Union[str, Ephemera ) return EphemeralReply(t("gateway.stop.stopped")) + # No running agent anywhere for this scope. A platform status + # indicator can still be stuck — e.g. Slack's persistent + # assistant.threads.setStatus survives a gateway restart or a turn + # that died without a final send (#32295). Best-effort clear so + # /stop always dismisses a phantom "is thinking...". + adapter = getattr(self, "adapters", {}).get(source.platform) + if adapter and hasattr(adapter, "_stop_typing_with_metadata"): + try: + await adapter._stop_typing_with_metadata( + source.chat_id, + self._thread_metadata_for_source( + source, self._reply_anchor_for_event(event) + ), + ) + except Exception: + logger.debug( + "Failed to clear typing on /stop with no active agent", + exc_info=True, + ) + return t("gateway.stop.no_active") async def _handle_platform_command(self, event: MessageEvent) -> str: -
plugins/platforms/slack/adapter.py@@ -1609,6 +1609,7 @@ async def stop_typing(self, chat_id: str, metadata=None) -> None: ) requested_team_id = self._metadata_team_id(metadata) active = None + ambiguous_tracked = False if requested_thread_ts: if requested_team_id: active_key = self._workspace_thread_key( @@ -1628,6 +1629,7 @@ async def stop_typing(self, chat_id: str, metadata=None) -> None: ] if len(matching_keys) == 1: active = self._active_status_threads.pop(matching_keys[0], None) + ambiguous_tracked = len(matching_keys) > 1 else: # Metadata-free cleanup is safe only if exactly one status exists # for this channel; otherwise it may clear another Slack Connect @@ -1648,6 +1650,18 @@ async def stop_typing(self, chat_id: str, metadata=None) -> None: team_id = active.get("team_id", "") if metadata: team_id = self._metadata_team_id(metadata) or team_id + if not thread_ts and requested_thread_ts and not ambiguous_tracked: + # No tracked entry (gateway restart, eviction, or a status set + # before this process started) but the caller identified the exact + # thread to clear. Issue the clear anyway so a stuck "is + # thinking..." can always be dismissed — clearing an unset status + # is a harmless no-op on Slack's side. Skipped when MULTIPLE + # workspaces track this channel+thread (ambiguous_tracked): a + # team-less clear there could hit the wrong Slack Connect + # workspace. Client routing uses the caller's team when given, + # else the channel→team fallback. + thread_ts = requested_thread_ts + team_id = requested_team_id or team_id if not thread_ts: return try: -
scripts/release.py@@ -50,6 +50,7 @@ "[redacted:email]": "mguttmann", # PR #63738 salvage (Anthropic setup-token pool auth normalization) "[redacted:email]": "VrtxOmega", # PR #43809 salvage (desktop: WSL folder-picker path bridge) "[redacted:email]": "SemonCat", # PR #56786 salvage (Slack Agent View manifests and Assistant APIs) + "[email protected]": "KCAYAAI", # PR #62248 partial salvage (resume typing after clarify reply) "[redacted:email]": "jakelongvu-bot", # PR #36683 partial salvage (approval: honor canonical approvals.timeout in gateway waits) "[email protected]": "Tortugasaur", # PR #43205 salvage (desktop: profile-aware three-way approval mode statusbar control) "[redacted:email]": "kavioavio", # Issue #46544 / PR #47705 evolution (smart DENY exact-operation owner override) -
tests/gateway/test_clarify_active_session_bypass.py@@ -1,7 +1,7 @@ """Regression tests for clarify replies while a gateway session is busy.""" import asyncio -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest @@ -85,3 +85,37 @@ async def test_active_session_routes_typed_choice_clarify_reply_to_runner_not_bu adapter._message_handler.assert_awaited_once_with(event) adapter._busy_session_handler.assert_not_awaited() assert adapter._pending_messages == {} + + [redacted:email] +async def test_gateway_clarify_reply_resumes_typing_before_returning_empty_ack(): + """A clarify answer must re-enable the active run's typing indicator. + + Clarify pauses typing while waiting so Slack's Assistant API does not + disable the compose box. The typed answer is intercepted by the gateway + and returns an empty acknowledgment instead of starting a second run; that + interception path must therefore resume the original run's indicator. + """ + _clear_clarify_state() + from gateway.run import GatewayRunner + from tools import clarify_gateway as cm + + adapter = _ClarifyBypassAdapter() + adapter.pause_typing_for_chat("12345") + event = _event("the missing details") + + runner = GatewayRunner.__new__(GatewayRunner) + runner._startup_restore_in_progress = False + runner._scale_to_zero_note_real_inbound = lambda: None + runner._is_user_authorized = lambda source: True + runner._session_key_for_source = lambda source: "clarify-session" + runner._adapter_for_source = lambda source: adapter + runner._update_prompt_pending = {} + + cm.register("clarify-2", "clarify-session", "What is missing?", None) + + with patch("hermes_cli.plugins.invoke_hook", return_value=[]): + result = await runner._handle_message(event) + + assert result == "" + assert "12345" not in adapter._typing_paused -
tests/gateway/test_slack.py@@ -2107,6 +2107,45 @@ async def test_stop_typing_noop_without_tracked_thread(self, adapter): adapter._app.client.assistant_threads_setStatus.assert_not_called() + @pytest.mark.asyncio + async def test_stop_typing_clears_untracked_thread_from_metadata(self, adapter): + """Explicit thread metadata clears a status the map no longer tracks. + + A gateway restart (or cache eviction) wipes _active_status_threads + while Slack's persistent assistant status stays visible. A caller + that names the exact thread must still be able to dismiss it (#32295). + """ + adapter._app.client.assistant_threads_setStatus = AsyncMock() + assert adapter._active_status_threads == {} + + await adapter.stop_typing("C123", metadata={"thread_id": "stuck_ts"}) + + adapter._app.client.assistant_threads_setStatus.assert_called_once_with( + channel_id="C123", + thread_ts="stuck_ts", + status="", + ) + + @pytest.mark.asyncio + async def test_stop_typing_untracked_fallback_respects_ambiguous_workspaces( + self, adapter + ): + """Team-less clear must NOT fire when multiple workspaces track the thread.""" + team_one, team_two = AsyncMock(), AsyncMock() + adapter._team_clients.update({"T_ONE": team_one, "T_TWO": team_two}) + for team_id in ("T_ONE", "T_TWO"): + await adapter.send_typing( + "D_SHARED", + metadata={"thread_id": "171.000", "slack_team_id": team_id}, + ) + adapter._app.client.assistant_threads_setStatus = AsyncMock() + + await adapter.stop_typing("D_SHARED", metadata={"thread_id": "171.000"}) + + adapter._app.client.assistant_threads_setStatus.assert_not_called() + assert ("T_ONE", "D_SHARED", "171.000") in adapter._active_status_threads + assert ("T_TWO", "D_SHARED", "171.000") in adapter._active_status_threads + @pytest.mark.asyncio async def test_stop_typing_handles_api_error_gracefully(self, adapter): adapter._active_status_threads[("", "C123", "parent_ts")] = { -
tests/gateway/test_stop_thread_sibling.py@@ -156,3 +156,67 @@ async def _fake_interrupt(session_key, source, *, interrupt_reason, invalidation assert interrupted == [] assert "no active" in str(getattr(result, "text", result)).lower() + + +# --------------------------------------------------------------------------- +# /stop with no active agent still clears a stuck platform status (#32295) +# --------------------------------------------------------------------------- + + +class _FakeStatusAdapter: + def __init__(self): + self.cleared = [] + + async def _stop_typing_with_metadata(self, chat_id, metadata=None): + self.cleared.append((chat_id, metadata)) + + [redacted:email] +async def test_stop_no_active_agent_clears_stuck_status(): + runner = object.__new__(GatewayRunner) + runner._running_agents = {} + key = _per_user_key("userA") + runner.session_store = _FakeStore(key) + runner._is_user_authorized = lambda source: True + + adapter = _FakeStatusAdapter() + runner.adapters = {Platform.DISCORD: adapter} + runner._thread_metadata_for_source = ( + lambda source, reply_to_message_id=None: {"thread_id": source.thread_id} + ) + runner._reply_anchor_for_event = lambda event: None + + event = MessageEvent( + text="/stop", message_type=MessageType.TEXT, source=_thread_source("userA") + ) + result = await runner._handle_stop_command(event) + + assert "no active" in str(getattr(result, "text", result)).lower() + assert adapter.cleared == [("chan1", {"thread_id": "thr1"})] + + [redacted:email] +async def test_stop_no_active_agent_survives_status_clear_failure(): + """A failing adapter clear must not break the /stop reply.""" + runner = object.__new__(GatewayRunner) + runner._running_agents = {} + key = _per_user_key("userA") + runner.session_store = _FakeStore(key) + runner._is_user_authorized = lambda source: True + + class _BoomAdapter: + async def _stop_typing_with_metadata(self, chat_id, metadata=None): + raise RuntimeError("boom") + + runner.adapters = {Platform.DISCORD: _BoomAdapter()} + runner._thread_metadata_for_source = ( + lambda source, reply_to_message_id=None: None + ) + runner._reply_anchor_for_event = lambda event: None + + event = MessageEvent( + text="/stop", message_type=MessageType.TEXT, source=_thread_source("userA") + ) + result = await runner._handle_stop_command(event) + + assert "no active" in str(getattr(result, "text", result)).lower()
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#32340 fix(slack): clear stuck assistant status on /stop · merged via another change →
dbd8704,#64621,#32295,#63709
Closed issues · 1
- Closed#32295Slack: "is thinking..." status indicator gets stuck when agent ends without sending a reply
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab