NousResearch/hermes-agent

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

0open issues
0open PRs
4items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #63709 fix(slack): complete Agent View workspace routing

    merged to main

    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.md

    Show 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

    merged to main

    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.py

    Show 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.00 means 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

At a glance

Subsystem
gateway
Merged PRs
#63709, #64621
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
15 Jul 2026

For your agent

The same facts, typed.