{"repo":"NousResearch/hermes-agent","public_id":"32295","display_label":"Slack assistant 'is thinking' status stuck; related Slack fixes","summary":"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.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":64621,"merged_at":"2026-07-15T04:31:33+00:00"},"resolution":{"state":"resolved","by":"pr-64621"},"freshness":{"state":"verified","last_verified_at":"2026-07-15T04:31:33+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":32295,"pr_number":63709,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-14T20:58:37+00:00"},{"issue_number":32295,"pr_number":64621,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-15T04:31:33+00:00"}],"members":[{"kind":"issue","number":32295,"title":"Slack: \"is thinking...\" status indicator gets stuck when agent ends without sending a reply","url":null,"state":"closed"},{"kind":"pr","number":32340,"title":"fix(slack): clear stuck assistant status on /stop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"dbd870467","number":0},{"kind":"pr","value":"#64621","number":64621},{"kind":"pr","value":"#32295","number":32295},{"kind":"pr","value":"#63709","number":63709}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":63709,"title":"fix(slack): complete Agent View workspace routing","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -63,9 +63,17 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None)\n     ``direct_messages_topic_id`` when the Bot API supports it.\n     \"\"\"\n     thread_id = getattr(source, \"thread_id\", None)\n-    if thread_id is None:\n+    metadata = {\"thread_id\": thread_id} if thread_id is not None else {}\n+    # Slack workspace identity is durable routing state, not ephemeral event\n+    # metadata. Carry it on every outbound path (including unthreaded sends)\n+    # so a multi-workspace Socket Mode gateway never falls back to its primary\n+    # WebClient after an async, stream, or recovery boundary.\n+    if _platform_name(getattr(source, \"platform\", None)) == \"slack\":\n+        scope_id = getattr(source, \"scope_id\", None)\n+        if scope_id:\n+            metadata[\"slack_team_id\"] = str(scope_id)\n+    if not metadata:\n         return None\n-    metadata = {\"thread_id\": thread_id}\n     if _platform_name(getattr(source, \"platform\", None)) == \"telegram\" and getattr(source, \"chat_type\", None) == \"dm\":\n         metadata[\"telegram_dm_topic_reply_fallback\"] = True\n         tid = str(thread_id)","@@ -3202,6 +3210,30 @@ async def stop_typing(self, chat_id: str) -> None:\n         \"\"\"\n         pass\n \n+    async def _stop_typing_with_metadata(self, chat_id: str, metadata=None) -> None:\n+        \"\"\"Stop typing while preserving platform-specific routing metadata.\n+\n+        Most adapters key typing state by chat and retain the historical\n+        ``stop_typing(chat_id)`` signature. Slack AI status is per thread and\n+        workspace, however, so losing metadata can clear a sibling thread or\n+        leave the current one active. Introspect at this shared chokepoint so\n+        existing adapters remain source-compatible.\n+        \"\"\"\n+        if metadata:\n+            try:\n+                params = inspect.signature(self.stop_typing).parameters\n+                accepts_metadata = \"metadata\" in params or any(\n+                    param.kind is inspect.Parameter.VAR_KEYWORD\n+                    for param in params.values()\n+                )\n+            except (TypeError, ValueError):\n+                accepts_metadata = False\n+            if accepts_metadata:\n+                stop_typing = getattr(self, \"stop_typing\")\n+                await stop_typing(chat_id, metadata=metadata)\n+                return\n+        await self.stop_typing(chat_id)\n+\n     async def send_multiple_images(\n         self,\n         chat_id: str,","@@ -3879,7 +3911,7 @@ async def _keep_typing(\n             # Cancelling _keep_typing alone won't clean that up.\n             if hasattr(self, \"stop_typing\"):\n                 try:\n-                    await self.stop_typing(chat_id)\n+                    await self._stop_typing_with_metadata(chat_id, metadata)\n                 except Exception:\n                     pass\n             self._typing_paused.discard(chat_id)","@@ -3889,6 +3921,7 @@ async def _stop_typing_refresh(\n         chat_id: str,\n         typing_task: asyncio.Task | None = None,\n         *,\n+        metadata=None,\n         timeout: float = 0.5,\n         stop_attempts: int = 2,\n     ) -> None:","@@ -3908,7 +3941,7 @@ async def _stop_typing_refresh(\n             attempts = max(1, stop_attempts)\n             for attempt in range(attempts):\n                 try:\n-                    await self.stop_typing(chat_id)\n+                    await self._stop_typing_with_metadata(chat_id, metadata)\n                 except Exception:\n                     pass\n                 if attempt < attempts - 1:","@@ -3928,14 +3961,14 @@ def resume_typing_for_chat(self, chat_id: str) -> None:\n         \"\"\"Resume typing indicator for a chat after approval resolves.\"\"\"\n         self._typing_paused.discard(chat_id)\n \n-    async def interrupt_session_activity(self, session_key: str, chat_id: str) -> None:\n+    async def interrupt_session_activity(self, session_key: str, chat_id: str, metadata=None) -> None:\n         \"\"\"Signal the active session loop to stop and clear typing immediately.\"\"\"\n         if session_key:\n             interrupt_event = self._active_sessions.get(session_key)\n             if interrupt_event is not None:\n                 interrupt_event.set()\n         try:\n-            await self.stop_typing(chat_id)\n+            await self._stop_typing_with_metadata(chat_id, metadata)\n         except Exception:\n             pass\n ","@@ -4873,6 +4906,7 @@ async def _stop_typing_task() -> None:\n             await self._stop_typing_refresh(\n                 event.source.chat_id,\n                 typing_task,\n+                metadata=_thread_metadata,\n             )\n         \n         try:","@@ -5296,6 +5330,7 @@ async def _stop_typing_task() -> None:\n             await self._stop_typing_refresh(\n                 event.source.chat_id,\n                 None,\n+                metadata=_thread_metadata,\n                 stop_attempts=1,\n             )\n             # Final drain/release boundary: force-flush any timer that missed","@@ -5464,6 +5499,7 @@ def build_source(\n         user_id_alt: Optional[str] = None,\n         chat_id_alt: Optional[str] = None,\n         is_bot: bool = False,\n+        scope_id: Optional[str] = None,\n         guild_id: Optional[str] = None,\n         parent_chat_id: Optional[str] = None,\n         message_id: Optional[str] = None,","@@ -5487,6 +5523,7 @@ def build_source(\n             user_id_alt=user_id_alt,\n             chat_id_alt=chat_id_alt,\n             is_bot=is_bot,\n+            scope_id=str(scope_id) if scope_id else None,\n             guild_id=str(guild_id) if guild_id else None,\n             parent_chat_id=str(parent_chat_id) if parent_chat_id else None,\n             message_id=str(message_id) if message_id else None,"]},{"file":"gateway/run.py","hunks":["@@ -11686,10 +11686,23 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 persist_user_timestamp=persist_user_timestamp,\n             )\n \n-            # Stop persistent typing indicator now that the agent is done\n+            # Stop persistent typing indicator now that the agent is done.\n+            # Slack AI status is scoped to a thread/workspace, so preserve the\n+            # same routing metadata used by the response delivery path.\n             try:\n                 _typing_adapter = self._adapter_for_source(source)\n-                if _typing_adapter and hasattr(_typing_adapter, \"stop_typing\"):\n+                _stop_with_metadata = getattr(\n+                    type(_typing_adapter), \"_stop_typing_with_metadata\", None\n+                )\n+                _stop_typing = getattr(type(_typing_adapter), \"stop_typing\", None)\n+                if _typing_adapter and callable(_stop_with_metadata):\n+                    await _typing_adapter._stop_typing_with_metadata(\n+                        source.chat_id,\n+                        self._thread_metadata_for_source(\n+                            source, self._reply_anchor_for_event(event)\n+                        ),\n+                    )\n+                elif _typing_adapter and callable(_stop_typing):\n                     await _typing_adapter.stop_typing(source.chat_id)\n             except Exception:\n                 pass","@@ -12235,10 +12248,22 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n             return response\n             \n         except Exception as e:\n-            # Stop typing indicator on error too\n+            # Stop typing indicator on error too, retaining Slack thread/workspace\n+            # routing so a failed turn cannot leave its status visible.\n             try:\n                 _err_adapter = self._adapter_for_source(source)\n-                if _err_adapter and hasattr(_err_adapter, \"stop_typing\"):\n+                _stop_with_metadata = getattr(\n+                    type(_err_adapter), \"_stop_typing_with_metadata\", None\n+                )\n+                _stop_typing = getattr(type(_err_adapter), \"stop_typing\", None)\n+                if _err_adapter and callable(_stop_with_metadata):\n+                    await _err_adapter._stop_typing_with_metadata(\n+                        source.chat_id,\n+                        self._thread_metadata_for_source(\n+                            source, self._reply_anchor_for_event(event)\n+                        ),\n+                    )\n+                elif _err_adapter and callable(_stop_typing):\n                     await _err_adapter.stop_typing(source.chat_id)\n             except Exception:\n                 pass","@@ -14397,13 +14422,19 @@ def _thread_metadata_for_source(\n         reply_to_message_id: Optional[str] = None,\n     ) -> Optional[Dict[str, Any]]:\n         \"\"\"Build the metadata dict platforms need for thread-aware replies.\"\"\"\n-        return self._thread_metadata_for_target(\n+        metadata = self._thread_metadata_for_target(\n             getattr(source, \"platform\", None),\n             getattr(source, \"chat_id\", None),\n             getattr(source, \"thread_id\", None),\n             chat_type=getattr(source, \"chat_type\", None),\n             reply_to_message_id=reply_to_message_id or getattr(source, \"message_id\", None),\n         )\n+        if getattr(source, \"platform\", None) == Platform.SLACK:\n+            team_id = getattr(source, \"scope_id\", None)\n+            if team_id:\n+                metadata = dict(metadata or {})\n+                metadata[\"slack_team_id\"] = str(team_id)\n+        return metadata\n \n     def _thread_metadata_for_target(\n         self,","@@ -16301,8 +16332,25 @@ async def _interrupt_and_clear_session(\n             running_agent.interrupt(interrupt_reason)\n         self._invalidate_session_run_generation(session_key, reason=invalidation_reason)\n         adapter = self._adapter_for_source(source)\n-        if adapter and hasattr(adapter, \"interrupt_session_activity\"):\n-            await adapter.interrupt_session_activity(session_key, source.chat_id)\n+        interrupt_session_activity = getattr(\n+            type(adapter), \"interrupt_session_activity\", None\n+        )\n+        if adapter and callable(interrupt_session_activity):\n+            metadata = self._thread_metadata_for_source(source)\n+            try:\n+                params = inspect.signature(interrupt_session_activity).parameters\n+                accepts_metadata = \"metadata\" in params or any(\n+                    param.kind is inspect.Parameter.VAR_KEYWORD\n+                    for param in params.values()\n+                )\n+            except (TypeError, ValueError):\n+                accepts_metadata = False\n+            if accepts_metadata:\n+                await adapter.interrupt_session_activity(\n+                    session_key, source.chat_id, metadata=metadata\n+                )\n+            else:\n+                await adapter.interrupt_session_activity(session_key, source.chat_id)\n         if adapter and hasattr(adapter, \"get_pending_message\"):\n             adapter.get_pending_message(session_key)  # consume and discard\n         self._pending_messages.pop(session_key, None)"]},{"file":"hermes_cli/slack_cli.py","hunks":["@@ -27,6 +27,7 @@ def _build_full_manifest(\n     bot_name: str,\n     bot_description: str,\n     include_assistant: bool = True,\n+    messaging_experience: str | None = None,\n ) -> dict:\n     \"\"\"Build a full Slack manifest merging display info + our slash list.\n ","@@ -36,17 +37,24 @@ def _build_full_manifest(\n     for a Hermes deployment — users can tweak them in the Slack UI after\n     pasting.\n \n-    When ``include_assistant`` is True (default) the manifest opts the app\n-    into Slack's AI Assistant container: the ``assistant_view`` feature, the\n-    ``assistant:write`` scope, and the ``assistant_thread_*`` events. Slack\n-    then renders DMs as the right-hand Assistant split-pane, where every\n-    exchange is a thread and bare slash commands are not delivered as normal\n-    ``command`` events. Pass ``include_assistant=False`` (``--no-assistant``)\n-    to omit those three pieces and get a flat DM surface where ``/help``,\n-    ``/new``, etc. work inline.\n+    By default, this keeps Hermes on Slack's older Assistant messaging\n+    experience (``assistant_view``) for backward compatibility. Pass\n+    ``messaging_experience=\"agent\"`` (``--agent-view``) to emit Slack's Agent\n+    messaging experience (``agent_view`` + ``app_home_opened``). Pass\n+    ``include_assistant=False`` or ``messaging_experience=\"none\"``\n+    (``--no-assistant``) to omit Slack AI messaging features and get a flat DM\n+    surface where ``/help``, ``/new``, etc. work inline.\n     \"\"\"\n     from hermes_cli.commands import slack_app_manifest\n \n+    if messaging_experience is None:\n+        messaging_experience = \"assistant\" if include_assistant else \"none\"\n+    messaging_experience = str(messaging_experience).strip().lower()\n+    if messaging_experience not in {\"assistant\", \"agent\", \"none\"}:\n+        raise ValueError(\n+            \"messaging_experience must be one of: assistant, agent, none\"\n+        )\n+\n     partial = slack_app_manifest()\n     slashes = partial[\"features\"][\"slash_commands\"]\n ","@@ -89,7 +97,7 @@ def _build_full_manifest(\n         \"message.mpim\",\n     ]\n \n-    if include_assistant:\n+    if messaging_experience == \"assistant\":\n         features[\"assistant_view\"] = {\n             \"assistant_description\": \"Chat with Hermes in threads and DMs.\",\n         }","@@ -100,8 +108,18 @@ def _build_full_manifest(\n                 \"assistant_thread_started\",\n             ]\n         )\n-        bot_scopes.sort()\n-        bot_events.sort()\n+    elif messaging_experience == \"agent\":\n+        features[\"agent_view\"] = {\n+            \"agent_description\": \"Chat with Hermes in Slack Messages.\",\n+        }\n+        bot_scopes.append(\"assistant:write\")\n+        # Slack includes current viewing context in Agent DM events only after\n+        # this subscription is enabled; the adapter consumes that context to\n+        # preserve the referred channel across the agent turn.\n+        bot_events.extend([\"app_context_changed\", \"app_home_opened\"])\n+\n+    bot_scopes.sort()\n+    bot_events.sort()\n \n     return {\n         \"_metadata\": {","@@ -147,17 +165,29 @@ def slack_manifest_command(args) -> int:\n                       assistant:write scope, assistant_thread_* events) so\n                       DMs render as a flat chat where bare slash commands\n                       work inline instead of the Assistant thread pane.\n+      --agent-view    Use Slack's Agent messaging experience (agent_view,\n+                      app_home_opened + message.im) instead of the legacy\n+                      Assistant messaging experience.\n     \"\"\"\n     name = getattr(args, \"name\", None) or \"Hermes\"\n     description = getattr(args, \"description\", None) or \"Your Hermes agent on Slack\"\n-    include_assistant = not getattr(args, \"no_assistant\", False)\n+    if getattr(args, \"agent_view\", False):\n+        messaging_experience = \"agent\"\n+    elif getattr(args, \"no_assistant\", False):\n+        messaging_experience = \"none\"\n+    else:\n+        messaging_experience = \"assistant\"\n \n     if getattr(args, \"slashes_only\", False):\n         from hermes_cli.commands import slack_app_manifest\n \n         manifest = slack_app_manifest()[\"features\"][\"slash_commands\"]\n     else:\n-        manifest = _build_full_manifest(name, description, include_assistant=include_assistant)\n+        manifest = _build_full_manifest(\n+            name,\n+            description,\n+            messaging_experience=messaging_experience,\n+        )\n \n     payload = json.dumps(manifest, indent=2, ensure_ascii=False) + \"\\n\"\n "]},{"file":"hermes_cli/subcommands/slack.py","hunks":["@@ -57,7 +57,8 @@ def build_slack_parser(subparsers, *, cmd_slack: Callable) -> None:\n         help=\"Emit only the features.slash_commands array (for merging \"\n         \"into an existing manifest manually).\",\n     )\n-    slack_manifest.add_argument(\n+    slack_messaging = slack_manifest.add_mutually_exclusive_group()\n+    slack_messaging.add_argument(\n         \"--no-assistant\",\n         action=\"store_true\",\n         help=\"Omit Slack AI Assistant mode (assistant_view, assistant:write \"","@@ -65,4 +66,12 @@ def build_slack_parser(subparsers, *, cmd_slack: Callable) -> None:\n         \"where bare slash commands (/help, /new) work inline instead of \"\n         \"Slack's Assistant thread pane.\",\n     )\n+    slack_messaging.add_argument(\n+        \"--agent-view\",\n+        action=\"store_true\",\n+        help=\"Emit Slack's Agent messaging experience (agent_view, \"\n+        \"app_home_opened + message.im) instead of the legacy assistant_view \"\n+        \"experience. This changes Slack's app messaging surface and cannot \"\n+        \"be reversed in Slack after applying the manifest.\",\n+    )\n     slack_parser.set_defaults(func=cmd_slack)"]},{"file":"plugins/platforms/slack/adapter.py","hunks":["@@ -440,7 +440,10 @@ def __init__(self, config: PlatformConfig):\n         self._app: Optional[Any] = None\n         self._handler: Optional[Any] = None\n         self._bot_user_id: Optional[str] = None\n-        self._user_name_cache: Dict[str, str] = {}  # user_id → display name\n+        # Slack user IDs are workspace-local. Cache names by workspace as well\n+        # so a multi-workspace Socket Mode process never reuses another\n+        # tenant's display name.\n+        self._user_name_cache: Dict[Tuple[str, str], str] = {}\n         self._socket_mode_task: Optional[asyncio.Task] = None\n         # Multi-workspace support\n         self._team_clients: Dict[str, Any] = {}  # team_id → WebClient","@@ -460,20 +463,29 @@ def __init__(self, config: PlatformConfig):\n         # respond to ALL subsequent messages in that thread automatically.\n         self._mentioned_threads: set = set()\n         self._MENTIONED_THREADS_MAX = 5000\n-        # Assistant thread metadata keyed by (channel_id, thread_ts). Slack's\n-        # AI Assistant lifecycle events can arrive before/alongside message\n-        # events, and they carry the user/thread identity needed for stable\n-        # session + memory scoping.\n-        self._assistant_threads: Dict[Tuple[str, str], Dict[str, str]] = {}\n+        # Assistant thread metadata keyed by (team_id, channel_id, thread_ts).\n+        # Slack's AI Assistant lifecycle events can arrive before/alongside\n+        # message events, and carry identity needed for stable session scoping.\n+        self._assistant_threads: Dict[Tuple[str, str, str], Dict[str, str]] = {}\n         self._ASSISTANT_THREADS_MAX = 5000\n+        # Agent-view context is per workspace/user (not global): a context\n+        # change for one person's Slack split view must never appear in another\n+        # person's prompt. Slack also includes this in later DM events, but the\n+        # cache bridges lifecycle and message delivery ordering.\n+        self._agent_view_contexts: Dict[Tuple[str, str], Dict[str, str]] = {}\n+        self._AGENT_VIEW_CONTEXTS_MAX = 5000\n         # Cache for _fetch_thread_context results: cache_key → _ThreadContextCache\n         self._thread_context_cache: Dict[str, _ThreadContextCache] = {}\n         self._THREAD_CACHE_TTL = 60.0\n         # Track message IDs that should get reaction lifecycle (DMs / @mentions).\n         self._reacting_message_ids: set = set()\n-        # Track active assistant thread status indicators so stop_typing can\n-        # clear them (chat_id → thread_ts).\n-        self._active_status_threads: Dict[str, str] = {}\n+        # Track active Assistant statuses by (team_id, channel_id, thread_ts)\n+        # so cleanup cannot clear an overlapping Slack Connect workspace.\n+        self._active_status_threads: Dict[Tuple[str, str, str], Dict[str, str]] = {}\n+        # Best-effort guard so automatic Slack AI thread titles are set once\n+        # per visible DM thread instead of on every reply.\n+        self._titled_assistant_threads: set = set()\n+        self._TITLED_ASSISTANT_THREADS_MAX = 5000\n         # Slash-command contexts: stash response_url + user_id so send()\n         # can route the first reply ephemerally.  Keyed by\n         # (channel_id, user_id) to avoid cross-user collisions.","@@ -1085,8 +1097,8 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:\n \n             # Register message event handler\n             @self._app.event(\"message\")\n-            async def handle_message_event(event, say):\n-                await self._handle_slack_message(event)\n+            async def handle_message_event(event, say, body):\n+                await self._handle_slack_message(event, body)\n \n             # Handle app_mention explicitly. In some Slack app configurations,\n             # channel mentions arrive only as app_mention events rather than the","@@ -1096,15 +1108,23 @@ async def handle_message_event(event, say):\n             # @mention, they share the same event ts — the dedup in\n             # _handle_slack_message (MessageDeduplicator) suppresses the second.\n             @self._app.event(\"app_mention\")\n-            async def handle_app_mention(event, say):\n-                await self._handle_slack_message(event)\n+            async def handle_app_mention(event, say, body):\n+                await self._handle_slack_message(event, body)\n+\n+            @self._app.event(\"app_home_opened\")\n+            async def handle_app_home_opened(event, say, body):\n+                await self._handle_app_home_opened(event, body)\n+\n+            @self._app.event(\"app_context_changed\")\n+            async def handle_app_context_changed(event, say, body):\n+                await self._handle_app_context_changed(event, body)\n \n             # File lifecycle events can arrive around snippet uploads even when\n             # the actual user message is what we care about. Ack them so Slack\n             # doesn't log noisy 404 \"unhandled request\" warnings.\n             @self._app.event(\"file_shared\")\n-            async def handle_file_shared(event, say):\n-                await self._handle_slack_file_shared(event)\n+            async def handle_file_shared(event, say, body):\n+                await self._handle_slack_file_shared(event, body)\n \n             @self._app.event(\"file_created\")\n             async def handle_file_created(event, say):","@@ -1127,12 +1147,12 @@ async def handle_reaction_removed(event, say):\n                 pass\n \n             @self._app.event(\"assistant_thread_started\")\n-            async def handle_assistant_thread_started(event, say):\n-                await self._handle_assistant_thread_lifecycle_event(event)\n+            async def handle_assistant_thread_started(event, say, body):\n+                await self._handle_assistant_thread_lifecycle_event(event, body)\n \n             @self._app.event(\"assistant_thread_context_changed\")\n-            async def handle_assistant_thread_context_changed(event, say):\n-                await self._handle_assistant_thread_lifecycle_event(event)\n+            async def handle_assistant_thread_context_changed(event, say, body):\n+                await self._handle_assistant_thread_lifecycle_event(event, body)\n \n             # Register slash command handler(s)\n             #","@@ -1185,6 +1205,8 @@ async def handle_hermes_command(ack, command):\n             ):\n                 self._app.action(_action_id)(self._handle_slash_confirm_action)\n \n+            self._app.action(\"hermes_feedback\")(self._handle_feedback_action)\n+\n             # Register plugin-provided Block Kit action handlers.\n             #\n             # Plugins call ``ctx.register_slack_action_handler(action_id, cb)``","@@ -1342,8 +1364,22 @@ async def disconnect(self) -> None:\n \n         logger.info(\"[Slack] Disconnected\")\n \n-    def _get_client(self, chat_id: str) -> Any:\n+    @staticmethod\n+    def _metadata_team_id(metadata: Optional[Dict[str, Any]]) -> str:\n+        \"\"\"Return Slack workspace id from generic or Slack-specific metadata.\"\"\"\n+        if not metadata:\n+            return \"\"\n+        return str(\n+            metadata.get(\"team_id\")\n+            or metadata.get(\"team\")\n+            or metadata.get(\"slack_team_id\")\n+            or \"\"\n+        )\n+\n+    def _get_client(self, chat_id: str, team_id: Optional[str] = None) -> Any:\n         \"\"\"Return the workspace-specific WebClient for a channel.\"\"\"\n+        if team_id and team_id in self._team_clients:\n+            return self._team_clients[team_id]\n         team_id = self._channel_team.get(chat_id)\n         if team_id and team_id in self._team_clients:\n             return self._team_clients[team_id]","@@ -1360,6 +1396,7 @@ async def send(\n         if not self._app:\n             return SendResult(success=False, error=\"Not connected\")\n \n+        thread_ts = None\n         try:\n             # Check for a pending slash-command context.  When the user ran a\n             # native slash command (e.g. /q, /stop, /model), the initial ack","@@ -1407,11 +1444,13 @@ async def send(\n                     if broadcast and i == 0:\n                         kwargs[\"reply_broadcast\"] = True\n \n-                last_result = await self._get_client(chat_id).chat_postMessage(**kwargs)\n+                last_result = await self._get_client(\n+                    chat_id, team_id=self._metadata_team_id(metadata)\n+                ).chat_postMessage(**kwargs)\n \n             # Clear Slack Assistant status as soon as the final message is posted.\n             if thread_ts:\n-                await self.stop_typing(chat_id)\n+                await self.stop_typing(chat_id, metadata=metadata)\n \n             # Track the sent message ts so we can auto-respond to thread\n             # replies without requiring @mention.","@@ -1433,6 +1472,8 @@ async def send(\n             )\n \n         except Exception as e:  # pragma: no cover - defensive logging\n+            if thread_ts:\n+                await self.stop_typing(chat_id, metadata=metadata)\n             logger.error(\"[Slack] Send error: %s\", e, exc_info=True)\n             return SendResult(success=False, error=str(e))\n ","@@ -1462,7 +1503,9 @@ async def send_private_notice(\n             if thread_ts:\n                 kwargs[\"thread_ts\"] = thread_ts\n \n-            result = await self._get_client(chat_id).chat_postEphemeral(**kwargs)\n+            result = await self._get_client(\n+                chat_id, team_id=self._metadata_team_id(metadata)\n+            ).chat_postEphemeral(**kwargs)\n             return SendResult(\n                 success=True,\n                 message_id=result.get(\"message_ts\") or result.get(\"ts\"),","@@ -1479,6 +1522,7 @@ async def edit_message(\n         content: str,\n         *,\n         finalize: bool = False,\n+        metadata: Optional[Dict[str, Any]] = None,\n     ) -> SendResult:\n         \"\"\"Edit a previously sent Slack message.\"\"\"\n         if not self._app:","@@ -1498,11 +1542,15 @@ async def edit_message(\n                 blocks = self._maybe_blocks(content)\n                 if blocks:\n                     update_kwargs[\"blocks\"] = blocks\n-            await self._get_client(chat_id).chat_update(**update_kwargs)\n+            await self._get_client(\n+                chat_id, team_id=self._metadata_team_id(metadata)\n+            ).chat_update(**update_kwargs)\n             if finalize:\n-                await self.stop_typing(chat_id)\n+                await self.stop_typing(chat_id, metadata=metadata)\n             return SendResult(success=True, message_id=message_id)\n         except Exception as e:  # pragma: no cover - defensive logging\n+            if finalize:\n+                await self.stop_typing(chat_id, metadata=metadata)\n             logger.error(\n                 \"[Slack] Failed to edit message %s in channel %s: %s\",\n                 message_id,","@@ -1529,9 +1577,18 @@ async def send_typing(self, chat_id: str, metadata=None) -> None:\n         if not thread_ts:\n             return  # Can only set status in a thread context\n \n-        self._active_status_threads[chat_id] = thread_ts\n+        team_id = self._metadata_team_id(metadata)\n+        if not team_id:\n+            team_id = self._channel_team.get(chat_id, \"\")\n+\n+        status_key = self._workspace_thread_key(team_id, chat_id, str(thread_ts))\n+        if status_key:\n+            self._active_status_threads[status_key] = {\n+                \"thread_ts\": str(thread_ts),\n+                \"team_id\": str(team_id) if team_id else \"\",\n+            }\n         try:\n-            await self._get_client(chat_id).assistant_threads_setStatus(\n+            await self._get_client(chat_id, team_id=team_id).assistant_threads_setStatus(\n                 channel_id=chat_id,\n                 thread_ts=thread_ts,\n                 status=\"is thinking...\",","@@ -1545,11 +1602,56 @@ async def stop_typing(self, chat_id: str, metadata=None) -> None:\n         \"\"\"Clear the assistant thread status indicator.\"\"\"\n         if not self._app:\n             return\n-        thread_ts = self._active_status_threads.pop(chat_id, None)\n+        requested_thread_ts = \"\"\n+        if metadata:\n+            requested_thread_ts = str(\n+                metadata.get(\"thread_id\") or metadata.get(\"thread_ts\") or \"\"\n+            )\n+        requested_team_id = self._metadata_team_id(metadata)\n+        active = None\n+        if requested_thread_ts:\n+            if requested_team_id:\n+                active_key = self._workspace_thread_key(\n+                    requested_team_id, chat_id, requested_thread_ts\n+                )\n+                if active_key:\n+                    active = self._active_status_threads.pop(active_key, None)\n+            else:\n+                # Do not trust the mutable channel-only workspace fallback for\n+                # a thread-specific cleanup: Slack Connect workspaces can share\n+                # a channel ID. Clear the uniquely matching tracked status and\n+                # let its stored team choose the correct client.\n+                matching_keys = [\n+                    key\n+                    for key in self._active_status_threads\n+                    if key[1] == str(chat_id) and key[2] == requested_thread_ts\n+                ]\n+                if len(matching_keys) == 1:\n+                    active = self._active_status_threads.pop(matching_keys[0], None)\n+        else:\n+            # Metadata-free cleanup is safe only if exactly one status exists\n+            # for this channel; otherwise it may clear another Slack Connect\n+            # workspace's Assistant status.\n+            matching_keys = [\n+                key\n+                for key in self._active_status_threads\n+                if key[1] == str(chat_id)\n+            ]\n+            if len(matching_keys) == 1:\n+                active = self._active_status_threads.pop(matching_keys[0], None)\n+        if isinstance(active, str):\n+            thread_ts = active\n+            team_id = \"\"\n+        else:\n+            active = active or {}\n+            thread_ts = active.get(\"thread_ts\", \"\")\n+            team_id = active.get(\"team_id\", \"\")\n+        if metadata:\n+            team_id = self._metadata_team_id(metadata) or team_id\n         if not thread_ts:\n             return\n         try:\n-            await self._get_client(chat_id).assistant_threads_setStatus(\n+            await self._get_client(chat_id, team_id=team_id).assistant_threads_setStatus(\n                 channel_id=chat_id,\n                 thread_ts=thread_ts,\n                 status=\"\",","@@ -1684,7 +1786,9 @@ async def _upload_file(\n         last_exc = None\n         for attempt in range(3):\n             try:\n-                result = await self._get_client(chat_id).files_upload_v2(\n+                result = await self._get_client(\n+                    chat_id, team_id=self._metadata_team_id(metadata)\n+                ).files_upload_v2(\n                     channel=chat_id,\n                     file=file_path,\n                     filename=os.path.basename(file_path),","@@ -1811,7 +1915,9 @@ async def send_multiple_images(\n                     chunk_idx + 1,\n                     len(chunks),\n                 )\n-                result = await self._get_client(chat_id).files_upload_v2(\n+                result = await self._get_client(\n+                    chat_id, team_id=self._metadata_team_id(metadata)\n+                ).files_upload_v2(\n                     channel=chat_id,\n                     file_uploads=file_uploads,\n                     initial_comment=initial_comment,","@@ -1885,6 +1991,47 @@ def _rich_blocks_enabled(self) -> bool:\n             return False\n         return str(raw).strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n \n+    def _feedback_buttons_enabled(self) -> bool:\n+        \"\"\"Whether to include Slack AI feedback buttons on final responses.\"\"\"\n+        raw = self.config.extra.get(\"feedback_buttons\")\n+        if raw is None:\n+            return False\n+        return str(raw).strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n+\n+    def _feedback_block(self) -> Dict[str, Any]:\n+        \"\"\"Return Slack AI feedback controls from the Agent docs.\"\"\"\n+        return {\n+            \"type\": \"context_actions\",\n+            \"elements\": [\n+                {\n+                    \"type\": \"feedback_buttons\",\n+                    \"action_id\": \"hermes_feedback\",\n+                    \"positive_button\": {\n+                        \"text\": {\"type\": \"plain_text\", \"text\": \"Good Response\"},\n+                        \"accessibility_label\": (\n+                            \"Submit positive feedback on this response\"\n+                        ),\n+                        \"value\": \"positive\",\n+                    },\n+                    \"negative_button\": {\n+                        \"text\": {\"type\": \"plain_text\", \"text\": \"Bad Response\"},\n+                        \"accessibility_label\": (\n+                            \"Submit negative feedback on this response\"\n+                        ),\n+                        \"value\": \"negative\",\n+                    },\n+                }\n+            ],\n+        }\n+\n+    def _append_feedback_block(self, blocks: Optional[list]) -> Optional[list]:\n+        \"\"\"Append response feedback controls when enabled and block budget allows.\"\"\"\n+        if not blocks or not self._feedback_buttons_enabled():\n+            return blocks\n+        if len(blocks) >= 50:\n+            return blocks\n+        return [*blocks, self._feedback_block()]\n+\n     def _maybe_blocks(self, content: str) -> Optional[list]:\n         \"\"\"Render ``content`` to Block Kit blocks when the feature is enabled.\n ","@@ -1896,7 +2043,8 @@ def _maybe_blocks(self, content: str) -> Optional[list]:\n         if not self._rich_blocks_enabled():\n             return None\n         try:\n-            return render_blocks(content, mrkdwn_fn=self.format_message)\n+            blocks = render_blocks(content, mrkdwn_fn=self.format_message)\n+            return self._append_feedback_block(blocks)\n         except Exception:  # pragma: no cover - renderer already guards itself\n             logger.debug(\"[Slack] block render failed; using plain text\", exc_info=True)\n             return None","@@ -2013,12 +2161,14 @@ def _convert_header(m):\n \n     # ----- Reactions -----\n \n-    async def _add_reaction(self, channel: str, timestamp: str, emoji: str) -> bool:\n+    async def _add_reaction(\n+        self, channel: str, timestamp: str, emoji: str, team_id: str = \"\"\n+    ) -> bool:\n         \"\"\"Add an emoji reaction to a message. Returns True on success.\"\"\"\n         if not self._app:\n             return False\n         try:\n-            await self._get_client(channel).reactions_add(\n+            await self._get_client(channel, team_id=team_id or None).reactions_add(\n                 channel=channel, timestamp=timestamp, name=emoji\n             )\n             return True","@@ -2027,12 +2177,14 @@ async def _add_reaction(self, channel: str, timestamp: str, emoji: str) -> bool:\n             logger.debug(\"[Slack] reactions.add failed (%s): %s\", emoji, e)\n             return False\n \n-    async def _remove_reaction(self, channel: str, timestamp: str, emoji: str) -> bool:\n+    async def _remove_reaction(\n+        self, channel: str, timestamp: str, emoji: str, team_id: str = \"\"\n+    ) -> bool:\n         \"\"\"Remove an emoji reaction from a message. Returns True on success.\"\"\"\n         if not self._app:\n             return False\n         try:\n-            await self._get_client(channel).reactions_remove(\n+            await self._get_client(channel, team_id=team_id or None).reactions_remove(\n                 channel=channel, timestamp=timestamp, name=emoji\n             )\n             return True","@@ -2053,7 +2205,9 @@ async def on_processing_start(self, event: MessageEvent) -> None:\n             return\n         channel_id = getattr(event.source, \"chat_id\", None)\n         if channel_id:\n-            await self._add_reaction(channel_id, ts, \"eyes\")\n+            await self._add_reaction(\n+                channel_id, ts, \"eyes\", str(getattr(event.source, \"scope_id\", \"\") or \"\")\n+            )\n \n     async def on_processing_complete(\n         self, event: MessageEvent, outcome: ProcessingOutcome","@@ -2068,26 +2222,36 @@ async def on_processing_complete(\n         channel_id = getattr(event.source, \"chat_id\", None)\n         if not channel_id:\n             return\n-        await self._remove_reaction(channel_id, ts, \"eyes\")\n+        team_id = str(getattr(event.source, \"scope_id\", \"\") or \"\")\n+        await self._remove_reaction(channel_id, ts, \"eyes\", team_id)\n         if outcome == ProcessingOutcome.SUCCESS:\n-            await self._add_reaction(channel_id, ts, \"white_check_mark\")\n+            await self._add_reaction(channel_id, ts, \"white_check_mark\", team_id)\n         elif outcome == ProcessingOutcome.FAILURE:\n-            await self._add_reaction(channel_id, ts, \"x\")\n+            await self._add_reaction(channel_id, ts, \"x\", team_id)\n \n     # ----- User identity resolution -----\n \n-    async def _resolve_user_name(self, user_id: str, chat_id: str = \"\") -> str:\n-        \"\"\"Resolve a Slack user ID to a display name, with caching.\"\"\"\n+    async def _resolve_user_name(\n+        self, user_id: str, chat_id: str = \"\", team_id: str = \"\"\n+    ) -> str:\n+        \"\"\"Resolve a workspace-local Slack user ID to a display name.\"\"\"\n         if not user_id:\n             return \"\"\n-        if user_id in self._user_name_cache:\n-            return self._user_name_cache[user_id]\n+        team_id = str(team_id or self._channel_team.get(chat_id, \"\"))\n+        cache_key = (team_id, str(user_id))\n+        cached_name = self._user_name_cache.get(cache_key)\n+        if cached_name is not None:\n+            return cached_name\n \n         if not self._app:\n             return user_id\n \n         try:\n-            client = self._get_client(chat_id) if chat_id else self._app.client\n+            client = (\n+                self._get_client(chat_id, team_id=team_id or None)\n+                if chat_id\n+                else self._app.client\n+            )\n             result = await client.users_info(user=user_id)\n             user = result.get(\"user\", {})\n             # Prefer display_name → real_name → user_id","@@ -2099,11 +2263,11 @@ async def _resolve_user_name(self, user_id: str, chat_id: str = \"\") -> str:\n                 or user.get(\"name\")\n                 or user_id\n             )\n-            self._user_name_cache[user_id] = name\n+            self._user_name_cache[cache_key] = name\n             return name\n         except Exception as e:\n             logger.debug(\"[Slack] users.info failed for %s: %s\", user_id, e)\n-            self._user_name_cache[user_id] = user_id\n+            self._user_name_cache[cache_key] = user_id\n             return user_id\n \n     async def send_image_file(","@@ -2177,7 +2341,9 @@ async def _ssrf_redirect_guard(response):\n                 response.raise_for_status()\n \n             thread_ts = self._resolve_thread_ts(reply_to, metadata)\n-            result = await self._get_client(chat_id).files_upload_v2(\n+            result = await self._get_client(\n+                chat_id, team_id=self._metadata_team_id(metadata)\n+            ).files_upload_v2(\n                 channel=chat_id,\n                 content=response.content,\n                 filename=\"image.png\",","@@ -2253,7 +2419,9 @@ async def send_video(\n             last_exc = None\n             for attempt in range(3):\n                 try:\n-                    result = await self._get_client(chat_id).files_upload_v2(\n+                    result = await self._get_client(\n+                        chat_id, team_id=self._metadata_team_id(metadata)\n+                    ).files_upload_v2(\n                         channel=chat_id,\n                         file=video_path,\n                         filename=os.path.basename(video_path),","@@ -2313,7 +2481,9 @@ async def send_document(\n             last_exc = None\n             for attempt in range(3):\n                 try:\n-                    result = await self._get_client(chat_id).files_upload_v2(\n+                    result = await self._get_client(\n+                        chat_id, team_id=self._metadata_team_id(metadata)\n+                    ).files_upload_v2(\n                         channel=chat_id,\n                         file=file_path,\n                         filename=display_name,","@@ -2377,18 +2547,119 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:\n \n     # ----- Internal handlers -----\n \n-    def _assistant_thread_key(\n-        self, channel_id: str, thread_ts: str\n-    ) -> Optional[Tuple[str, str]]:\n-        \"\"\"Return a stable cache key for Slack assistant thread metadata.\"\"\"\n+    @staticmethod\n+    def _workspace_thread_key(\n+        team_id: str, channel_id: str, thread_ts: str\n+    ) -> Optional[Tuple[str, str, str]]:\n+        \"\"\"Return a workspace-scoped key for Slack thread-local state.\n+\n+        Slack Connect can expose the same channel/thread IDs in more than one\n+        workspace. Keys for cached thread data and Assistant status therefore\n+        need the workspace identity as well as Slack's channel/thread pair.\n+        \"\"\"\n         if not channel_id or not thread_ts:\n             return None\n-        return (str(channel_id), str(thread_ts))\n+        return (str(team_id or \"\"), str(channel_id), str(thread_ts))\n+\n+    @staticmethod\n+    def _agent_view_context_key(team_id: str, user_id: str) -> Optional[Tuple[str, str]]:\n+        \"\"\"Return a per-workspace, per-user Agent-view context cache key.\"\"\"\n+        if not team_id or not user_id:\n+            return None\n+        return (str(team_id), str(user_id))\n+\n+    def _cache_agent_view_context(self, metadata: Dict[str, str]) -> None:\n+        \"\"\"Remember a user's current Slack Agent-view context.\"\"\"\n+        key = self._agent_view_context_key(\n+            metadata.get(\"team_id\", \"\"), metadata.get(\"user_id\", \"\")\n+        )\n+        if not key:\n+            return\n+        contexts = getattr(self, \"_agent_view_contexts\", None)\n+        if not isinstance(contexts, dict):\n+            contexts = {}\n+            self._agent_view_contexts = contexts\n+        contexts[key] = {\n+            field: value\n+            for field, value in metadata.items()\n+            if field in {\"channel_id\", \"context_channel_id\", \"team_id\", \"user_id\"}\n+            and value\n+        }\n+        max_contexts = getattr(self, \"_AGENT_VIEW_CONTEXTS_MAX\", 5000)\n+        if len(contexts) > max_contexts:\n+            excess = len(contexts) - max_contexts // 2\n+            for old_key in list(contexts)[:excess]:\n+                del contexts[old_key]\n+\n+    def _agent_view_context_for_event(\n+        self, event: dict, team_id: str, user_id: str\n+    ) -> Dict[str, str]:\n+        \"\"\"Read Slack's inline Agent context, falling back to lifecycle state.\"\"\"\n+        context = event.get(\"app_context\") or event.get(\"context\") or {}\n+        context_channel_id = self._context_channel_id(context)\n+        key = self._agent_view_context_key(team_id, user_id)\n+        contexts = getattr(self, \"_agent_view_contexts\", {})\n+        cached = contexts.get(key, {}) if isinstance(contexts, dict) and key else {}\n+        return {\n+            \"context_channel_id\": context_channel_id or cached.get(\"context_channel_id\", \"\"),\n+            \"team_id\": team_id,\n+            \"user_id\": user_id,\n+        }\n \n-    def _extract_assistant_thread_metadata(self, event: dict) -> Dict[str, str]:\n+    @staticmethod\n+    def _event_team_id(event: dict, body: Optional[dict] = None) -> str:\n+        \"\"\"Resolve a workspace ID from an event plus Bolt's outer payload.\n+\n+        Bolt injects only the inner ``event`` into an event listener, while\n+        Slack places ``team_id`` on the outer Events API payload. Reading both\n+        keeps multi-workspace client routing correct after a process boundary.\n+        \"\"\"\n+        for payload in (event, body or {}):\n+            if not isinstance(payload, dict):\n+                continue\n+            team = payload.get(\"team_id\") or payload.get(\"team\")\n+            if isinstance(team, str) and team:\n+                return team\n+            if isinstance(team, dict) and team.get(\"id\"):\n+                return str(team[\"id\"])\n+        authorizations = (body or {}).get(\"authorizations\") if isinstance(body, dict) else None\n+        for authorization in authorizations or []:\n+            if isinstance(authorization, dict) and authorization.get(\"team_id\"):\n+                return str(authorization[\"team_id\"])\n+        return \"\"\n+\n+    @staticmethod\n+    def _context_channel_id(context: Any) -> str:\n+        \"\"\"Extract the actively viewed channel from either Slack context shape.\"\"\"\n+        if not isinstance(context, dict):\n+            return \"\"\n+        channel_id = context.get(\"channel_id\")\n+        if channel_id:\n+            return str(channel_id)\n+        for entity in context.get(\"entities\") or []:\n+            if not isinstance(entity, dict):\n+                continue\n+            value = entity.get(\"value\")\n+            if isinstance(value, dict) and value.get(\"channel_id\"):\n+                return str(value[\"channel_id\"])\n+            if (\n+                isinstance(value, str)\n+                and str(entity.get(\"type\") or \"\").endswith(\"channel_id\")\n+            ):\n+                return value\n+        return \"\"\n+\n+    def _extract_assistant_thread_metadata(\n+        self, event: dict, body: Optional[dict] = None\n+    ) -> Dict[str, str]:\n         \"\"\"Extract Slack Assistant thread identity data from an event payload.\"\"\"\n         assistant_thread = event.get(\"assistant_thread\") or {}\n-        context = assistant_thread.get(\"context\") or event.get(\"context\") or {}\n+        context = (\n+            assistant_thread.get(\"context\")\n+            or event.get(\"app_context\")\n+            or event.get(\"context\")\n+            or {}\n+        )\n \n         channel_id = (\n             assistant_thread.get(\"channel_id\")","@@ -2408,13 +2679,10 @@ def _extract_assistant_thread_metadata(self, event: dict) -> Dict[str, str]:\n             or context.get(\"user_id\")\n             or \"\"\n         )\n-        team_id = (\n-            event.get(\"team\")\n-            or event.get(\"team_id\")\n-            or assistant_thread.get(\"team_id\")\n-            or \"\"\n+        team_id = self._event_team_id(event, body) or str(\n+            assistant_thread.get(\"team_id\") or \"\"\n         )\n-        context_channel_id = context.get(\"channel_id\") or \"\"\n+        context_channel_id = self._context_channel_id(context)\n \n         return {\n             \"channel_id\": str(channel_id) if channel_id else \"\",","@@ -2425,10 +2693,11 @@ def _extract_assistant_thread_metadata(self, event: dict) -> Dict[str, str]:\n         }\n \n     def _cache_assistant_thread_metadata(self, metadata: Dict[str, str]) -> None:\n-        \"\"\"Remember assistant thread identity data for later message events.\"\"\"\n+        \"\"\"Remember workspace-local assistant identity for later message events.\"\"\"\n         channel_id = metadata.get(\"channel_id\", \"\")\n         thread_ts = metadata.get(\"thread_ts\", \"\")\n-        key = self._assistant_thread_key(channel_id, thread_ts)\n+        team_id = metadata.get(\"team_id\", \"\")\n+        key = self._workspace_thread_key(team_id, channel_id, thread_ts)\n         if not key:\n             return\n ","@@ -2437,30 +2706,35 @@ def _cache_assistant_thread_metadata(self, metadata: Dict[str, str]) -> None:\n         merged.update({k: v for k, v in metadata.items() if v})\n         self._assistant_threads[key] = merged\n \n-        # Evict oldest entries when the cache exceeds the limit\n+        # Evict oldest entries when the cache exceeds the limit.\n         if len(self._assistant_threads) > self._ASSISTANT_THREADS_MAX:\n             excess = len(self._assistant_threads) - self._ASSISTANT_THREADS_MAX // 2\n             for old_key in list(self._assistant_threads)[:excess]:\n                 del self._assistant_threads[old_key]\n \n-        team_id = merged.get(\"team_id\", \"\")\n         if team_id and channel_id:\n             self._channel_team[channel_id] = team_id\n \n     def _lookup_assistant_thread_metadata(\n         self,\n         event: dict,\n+        *,\n         channel_id: str = \"\",\n         thread_ts: str = \"\",\n+        team_id: str = \"\",\n+        body: Optional[dict] = None,\n     ) -> Dict[str, str]:\n-        \"\"\"Load cached assistant-thread metadata that matches the current event.\"\"\"\n-        metadata = self._extract_assistant_thread_metadata(event)\n+        \"\"\"Load workspace-scoped assistant metadata for the current event.\"\"\"\n+        metadata = self._extract_assistant_thread_metadata(event, body)\n         if channel_id and not metadata.get(\"channel_id\"):\n             metadata[\"channel_id\"] = channel_id\n         if thread_ts and not metadata.get(\"thread_ts\"):\n             metadata[\"thread_ts\"] = thread_ts\n+        if team_id and not metadata.get(\"team_id\"):\n+            metadata[\"team_id\"] = str(team_id)\n \n-        key = self._assistant_thread_key(\n+        key = self._workspace_thread_key(\n+            metadata.get(\"team_id\", \"\"),\n             metadata.get(\"channel_id\", \"\"),\n             metadata.get(\"thread_ts\", \"\"),\n         )","@@ -2471,6 +2745,123 @@ def _lookup_assistant_thread_metadata(\n             return merged\n         return metadata\n \n+    def _assistant_suggested_prompts(self) -> Tuple[str, List[Dict[str, str]]]:\n+        \"\"\"Return config.yaml-defined Slack AI suggested prompts.\n+\n+        Supported shapes under ``platforms.slack.extra.suggested_prompts``:\n+\n+        - ``[{title, message}, ...]``\n+        - ``{title: \"...\", prompts: [{title, message}, ...]}``\n+\n+        Invalid rows are ignored. Slack recommends up to four suggestions; keep\n+        that bound here so a broad config cannot produce an invalid API call.\n+        \"\"\"\n+        raw = self.config.extra.get(\"suggested_prompts\")\n+        title = \"\"\n+        prompt_rows = raw\n+        if isinstance(raw, dict):\n+            title = str(raw.get(\"title\") or \"\").strip()\n+            prompt_rows = raw.get(\"prompts\")\n+        if not isinstance(prompt_rows, list):\n+            return title, []\n+\n+        prompts: List[Dict[str, str]] = []\n+        for item in prompt_rows:\n+            if not isinstance(item, dict):\n+                continue\n+            prompt_title = str(item.get(\"title\") or \"\").strip()\n+            prompt_message = str(item.get(\"message\") or \"\").strip()\n+            if not prompt_title or not prompt_message:\n+                continue\n+            prompts.append({\"title\": prompt_title[:75], \"message\": prompt_message})\n+            if len(prompts) >= 4:\n+                break\n+        return title, prompts\n+\n+    async def _set_assistant_suggested_prompts(\n+        self,\n+        channel_id: str,\n+        *,\n+        team_id: str = \"\",\n+        thread_ts: str = \"\",\n+    ) -> None:\n+        \"\"\"Best-effort Slack AI suggested prompts setup.\"\"\"\n+        if not self._app or not channel_id:\n+            return\n+\n+        title, prompts = self._assistant_suggested_prompts()\n+        if not prompts:\n+            return\n+\n+        kwargs: Dict[str, Any] = {\n+            \"channel_id\": channel_id,\n+            \"prompts\": prompts,\n+        }\n+        if title:\n+            kwargs[\"title\"] = title\n+        if thread_ts:\n+            kwargs[\"thread_ts\"] = thread_ts\n+\n+        try:\n+            await self._get_client(channel_id, team_id=team_id).assistant_threads_setSuggestedPrompts(\n+                **kwargs\n+            )\n+        except Exception as e:\n+            logger.debug(\"[Slack] assistant.threads.setSuggestedPrompts failed: %s\", e)\n+\n+    def _assistant_thread_title_enabled(self) -> bool:\n+        raw = self.config.extra.get(\"assistant_thread_titles\", True)\n+        if isinstance(raw, str):\n+            return raw.strip().lower() not in {\"0\", \"false\", \"no\", \"off\"}\n+        return bool(raw)\n+\n+    async def _set_assistant_thread_title(\n+        self,\n+        channel_id: str,\n+        thread_ts: str,\n+        title_source: str,\n+        *,\n+        team_id: str = \"\",\n+    ) -> None:\n+        \"\"\"Best-effort title for visible Slack AI DM threads.\"\"\"\n+        if (\n+            not self._app\n+            or not channel_id\n+            or not thread_ts\n+            or not title_source\n+            or not self._assistant_thread_title_enabled()\n+        ):\n+            return\n+\n+        key = self._workspace_thread_key(team_id, channel_id, thread_ts)\n+        if not key or key in self._titled_assistant_threads:\n+            return\n+\n+        title = re.sub(r\"\\s+\", \" \", title_source).strip()\n+        if not title or title.startswith(\"/\"):\n+            return\n+        if len(title) > 80:\n+            title = title[:77].rstrip() + \"...\"\n+\n+        try:\n+            await self._get_client(channel_id, team_id=team_id).assistant_threads_setTitle(\n+                channel_id=channel_id,\n+                thread_ts=thread_ts,\n+                title=title,\n+            )\n+        except Exception as e:\n+            logger.debug(\"[Slack] assistant.threads.setTitle failed: %s\", e)\n+            return\n+\n+        self._titled_assistant_threads.add(key)\n+        if len(self._titled_assistant_threads) > self._TITLED_ASSISTANT_THREADS_MAX:\n+            excess = (\n+                len(self._titled_assistant_threads)\n+                - self._TITLED_ASSISTANT_THREADS_MAX // 2\n+            )\n+            for old_key in list(self._titled_assistant_threads)[:excess]:\n+                self._titled_assistant_threads.discard(old_key)\n+\n     def _seed_assistant_thread_session(self, metadata: Dict[str, str]) -> None:\n         \"\"\"Prime the session store so assistant threads get stable user scoping.\"\"\"\n         session_store = getattr(self, \"_session_store\", None)","@@ -2490,6 +2881,7 @@ def _seed_assistant_thread_session(self, metadata: Dict[str, str]) -> None:\n             user_id=user_id,\n             thread_id=thread_ts,\n             chat_topic=metadata.get(\"context_channel_id\") or None,\n+            scope_id=metadata.get(\"team_id\") or None,\n         )\n \n         try:","@@ -2502,13 +2894,107 @@ def _seed_assistant_thread_session(self, metadata: Dict[str, str]) -> None:\n                 exc_info=True,\n             )\n \n-    async def _handle_assistant_thread_lifecycle_event(self, event: dict) -> None:\n+    def _seed_agent_dm_session(self, metadata: Dict[str, str]) -> None:\n+        \"\"\"Prime the session store when Slack reports a user opened the DM.\n+\n+        In Slack's Agent messaging experience, ``app_home_opened`` with\n+        ``tab == \"messages\"`` replaces ``assistant_thread_started`` as the\n+        \"user opened the DM\" signal. It is only a lifecycle signal: do not send\n+        a welcome message or enter the agent loop from this event.\n+        \"\"\"\n+        session_store = getattr(self, \"_session_store\", None)\n+        if not session_store:\n+            return\n+\n+        channel_id = metadata.get(\"channel_id\", \"\")\n+        user_id = metadata.get(\"user_id\", \"\")\n+        if not channel_id or not user_id:\n+            return\n+\n+        source = self.build_source(\n+            chat_id=channel_id,\n+            chat_name=channel_id,\n+            chat_type=\"dm\",\n+            user_id=user_id,\n+            chat_topic=metadata.get(\"context_channel_id\") or None,\n+            scope_id=metadata.get(\"team_id\") or None,\n+        )\n+\n+        try:\n+            session_store.get_or_create_session(source)\n+        except Exception:\n+            logger.debug(\n+                \"[Slack] Failed to seed agent DM session for %s\",\n+                channel_id,\n+                exc_info=True,\n+            )\n+\n+    async def _handle_assistant_thread_lifecycle_event(\n+        self, event: dict, body: Optional[dict] = None\n+    ) -> None:\n         \"\"\"Handle Slack Assistant lifecycle events that carry user/thread identity.\"\"\"\n-        metadata = self._extract_assistant_thread_metadata(event)\n+        metadata = self._extract_assistant_thread_metadata(event, body)\n         self._cache_assistant_thread_metadata(metadata)\n         self._seed_assistant_thread_session(metadata)\n+        await self._set_assistant_suggested_prompts(\n+            metadata.get(\"channel_id\", \"\"),\n+            team_id=metadata.get(\"team_id\", \"\"),\n+            thread_ts=metadata.get(\"thread_ts\", \"\"),\n+        )\n+\n+    async def _handle_app_context_changed(\n+        self, event: dict, body: Optional[dict] = None\n+    ) -> None:\n+        \"\"\"Cache the current Agent-view context without entering the agent loop.\"\"\"\n+        context = event.get(\"context\") or event.get(\"app_context\") or {}\n+        context_channel_id = self._context_channel_id(context)\n+        user_id = event.get(\"user\") or event.get(\"user_id\") or \"\"\n+        team_id = self._event_team_id(event, body)\n+        # ``context_channel_id`` is a channel the user is viewing, not the DM\n+        # Hermes owns. Do not write it into _channel_team: channel IDs can be\n+        # shared across Slack Connect workspaces, so doing so can misroute a\n+        # later unrelated send. Workspace ownership is recorded from actual\n+        # inbound DM/channel events below.\n+        self._cache_agent_view_context(\n+            {\n+                \"context_channel_id\": str(context_channel_id) if context_channel_id else \"\",\n+                \"user_id\": str(user_id) if user_id else \"\",\n+                \"team_id\": str(team_id) if team_id else \"\",\n+            }\n+        )\n+\n+    async def _handle_app_home_opened(\n+        self, event: dict, body: Optional[dict] = None\n+    ) -> None:\n+        \"\"\"Handle Slack Agent DM-open lifecycle events without producing replies.\"\"\"\n+        if event.get(\"tab\") != \"messages\":\n+            return\n+\n+        context = event.get(\"context\") or event.get(\"app_context\") or {}\n+        channel_id = event.get(\"channel\") or event.get(\"channel_id\") or \"\"\n+        user_id = event.get(\"user\") or event.get(\"user_id\") or \"\"\n+        team_id = self._event_team_id(event, body)\n+        context_channel_id = self._context_channel_id(context)\n+\n+        if team_id and channel_id:\n+            self._channel_team[str(channel_id)] = str(team_id)\n+\n+        metadata = {\n+            \"channel_id\": str(channel_id) if channel_id else \"\",\n+            \"user_id\": str(user_id) if user_id else \"\",\n+            \"team_id\": str(team_id) if team_id else \"\",\n+            \"context_channel_id\": context_channel_id,\n+        }\n+        self._cache_agent_view_context(metadata)\n+        self._seed_agent_dm_session(metadata)\n+        await self._set_assistant_suggested_prompts(\n+            metadata[\"channel_id\"],\n+            team_id=metadata[\"team_id\"],\n+        )\n \n-    async def _handle_slack_file_shared(self, event: dict) -> None:\n+    async def _handle_slack_file_shared(\n+        self, event: dict, body: Optional[dict] = None\n+    ) -> None:\n         \"\"\"Fallback for Slack file shares that do not arrive as message.files.\n \n         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:\n         if not channel_id or not file_id:\n             return\n \n-        team_id = event.get(\"team_id\") or event.get(\"team\") or \"\"\n+        team_id = self._event_team_id(event, body)\n         try:\n             client = self._team_clients.get(team_id) if team_id else None\n             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:\n             fallback_event[\"thread_ts\"] = thread_ts\n         await self._handle_slack_message(fallback_event)\n \n-    async def _handle_slack_message(self, event: dict) -> None:\n+    async def _handle_slack_message(\n+        self, event: dict, payload: Optional[dict] = None\n+    ) -> None:\n         \"\"\"Handle an incoming Slack message event.\"\"\"\n         # Dedup: Slack Socket Mode can redeliver events after reconnects (#4777)\n         event_ts = event.get(\"ts\", \"\")","@@ -2738,18 +3226,20 @@ async def _handle_slack_message(self, event: dict) -> None:\n \n         channel_id = event.get(\"channel\", \"\")\n         ts = event.get(\"ts\", \"\")\n+        outer_team_id = self._event_team_id(event, payload)\n         assistant_meta = self._lookup_assistant_thread_metadata(\n             event,\n             channel_id=channel_id,\n             thread_ts=event.get(\"thread_ts\", \"\"),\n+            team_id=outer_team_id,\n+            body=payload,\n         )\n         user_id = event.get(\"user\") or assistant_meta.get(\"user_id\", \"\")\n         if not channel_id:\n             channel_id = assistant_meta.get(\"channel_id\", \"\")\n-        team_id = (\n-            event.get(\"team\")\n-            or event.get(\"team_id\")\n-            or assistant_meta.get(\"team_id\", \"\")\n+        team_id = outer_team_id or assistant_meta.get(\"team_id\", \"\")\n+        agent_context = self._agent_view_context_for_event(\n+            event, str(team_id or \"\"), str(user_id or \"\")\n         )\n \n         # Track which workspace owns this channel","@@ -2859,6 +3349,7 @@ async def _handle_slack_message(self, event: dict) -> None:\n                     channel_id=channel_id,\n                     thread_ts=event_thread_ts,\n                     user_id=user_id,\n+                    team_id=team_id,\n                 )\n                 if (\n                     not reply_to_bot_thread","@@ -2889,6 +3380,7 @@ async def _handle_slack_message(self, event: dict) -> None:\n             channel_id=channel_id,\n             thread_ts=event_thread_ts,\n             user_id=user_id,\n+            team_id=team_id,\n         ):\n             thread_context = await self._fetch_thread_context(\n                 channel_id=channel_id,","@@ -2920,9 +3412,9 @@ async def _handle_slack_message(self, event: dict) -> None:\n                 if not file_id:\n                     continue\n                 try:\n-                    info_resp = await self._get_client(channel_id).files_info(\n-                        file=file_id\n-                    )\n+                    info_resp = await self._get_client(\n+                        channel_id, team_id=team_id\n+                    ).files_info(file=file_id)\n                     if info_resp.get(\"ok\"):\n                         f = info_resp[\"file\"]\n                     else:","@@ -3158,7 +3650,20 @@ async def _handle_slack_message(self, event: dict) -> None:\n                 msg_type = MessageType.DOCUMENT\n \n         # Resolve user display name (cached after first lookup)\n-        user_name = await self._resolve_user_name(user_id, chat_id=channel_id)\n+        user_name = await self._resolve_user_name(\n+            user_id, chat_id=channel_id, team_id=team_id\n+        )\n+\n+        # Slack's AI Agent Messages tab shows visible app threads; title the\n+        # first DM thread turn from the user's prompt when Slack AI APIs are\n+        # available. This is best-effort and configurable via config.yaml.\n+        if is_dm and thread_ts and msg_type != MessageType.COMMAND:\n+            await self._set_assistant_thread_title(\n+                channel_id,\n+                thread_ts,\n+                original_text or text,\n+                team_id=team_id,\n+            )\n \n         # Build source\n         source = self.build_source(","@@ -3168,6 +3673,7 @@ async def _handle_slack_message(self, event: dict) -> None:\n             user_id=user_id,\n             user_name=user_name,\n             thread_id=thread_ts,\n+            scope_id=str(team_id) if team_id else None,\n             # Slack Workflow Builder / app posts arrive as\n             # subtype=bot_message with user=None; flag them so the\n             # gateway SLACK_ALLOW_BOTS bypass can authorize them","@@ -3223,6 +3729,11 @@ async def _handle_slack_message(self, event: dict) -> None:\n             channel_prompt=_channel_prompt,\n             reply_to_text=reply_to_text,\n             auto_skill=_auto_skill,\n+            metadata={\n+                \"slack_team_id\": team_id,\n+                \"slack_channel_id\": channel_id,\n+                \"slack_thread_ts\": thread_ts,\n+            },\n         )\n \n         # 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:\n         if _should_react:\n             self._reacting_message_ids.add(ts)\n \n+        # App-context is per-turn, user-controlled Slack UI state. Surface it\n+        # with the inbound user message rather than storing it on SessionSource:\n+        # putting it in the cached system prompt would rebuild the agent whenever\n+        # the user switches views and would let stale context bleed into later\n+        # turns. The agent receives an inert label, never a fetched channel body.\n+        context_channel_id = agent_context.get(\"context_channel_id\", \"\")\n+        if context_channel_id and context_channel_id != channel_id:\n+            msg_event.text = (\n+                f\"[Slack app context: user is viewing channel {context_channel_id}]\\n\\n\"\n+                f\"{msg_event.text}\"\n+            )\n+\n         await self.handle_message(msg_event)\n \n     # ----- Approval button support (Block Kit) -----","@@ -3321,7 +3844,9 @@ async def send_exec_approval(\n             if thread_ts:\n                 kwargs[\"thread_ts\"] = thread_ts\n \n-            result = await self._get_client(chat_id).chat_postMessage(**kwargs)\n+            result = await self._get_client(\n+                chat_id, team_id=self._metadata_team_id(metadata)\n+            ).chat_postMessage(**kwargs)\n             msg_ts = result.get(\"ts\", \"\")\n             if msg_ts:\n                 self._approval_resolved[msg_ts] = False","@@ -3399,7 +3924,9 @@ async def send_slash_confirm(\n             if thread_ts:\n                 kwargs[\"thread_ts\"] = thread_ts\n \n-            result = await self._get_client(chat_id).chat_postMessage(**kwargs)\n+            result = await self._get_client(\n+                chat_id, team_id=self._metadata_team_id(metadata)\n+            ).chat_postMessage(**kwargs)\n             return SendResult(\n                 success=True, message_id=result.get(\"ts\", \"\"), raw_response=result\n             )","@@ -3413,6 +3940,7 @@ def _is_interactive_user_authorized(\n         *,\n         channel_id: str = \"\",\n         user_name: Optional[str] = None,\n+        team_id: str = \"\",\n     ) -> bool:\n         \"\"\"Return whether a Slack interactive caller may perform gated actions.\"\"\"\n         normalized_user_id = str(user_id or \"\").strip()","@@ -3431,6 +3959,7 @@ def _is_interactive_user_authorized(\n                     chat_type=\"dm\" if str(channel_id or \"\").startswith(\"D\") else \"group\",\n                     user_id=normalized_user_id,\n                     user_name=str(user_name).strip() if user_name else None,\n+                    scope_id=str(team_id) if team_id else None,\n                 )\n                 return bool(auth_fn(source))\n             except Exception:","@@ -3460,6 +3989,7 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None:\n         \"\"\"Handle a slash-confirm button click from Block Kit.\"\"\"\n         await ack()\n \n+        team_id = self._event_team_id({}, body)\n         action_id = action.get(\"action_id\", \"\")\n         value = action.get(\"value\", \"\")\n         message = body.get(\"message\", {})","@@ -3471,6 +4001,7 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None:\n             user_id,\n             channel_id=channel_id,\n             user_name=user_name,\n+            team_id=team_id,\n         ):\n             logger.warning(\n                 \"[Slack] Unauthorized slash-confirm click by %s (%s) - ignoring\",","@@ -3535,7 +4066,7 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None:\n         ]\n \n         try:\n-            await self._get_client(channel_id).chat_update(\n+            await self._get_client(channel_id, team_id=team_id or None).chat_update(\n                 channel=channel_id,\n                 ts=msg_ts,\n                 text=decision_text,","@@ -3560,7 +4091,9 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None:\n                 thread_ts = message.get(\"thread_ts\") or msg_ts\n                 if thread_ts:\n                     post_kwargs[\"thread_ts\"] = thread_ts\n-                await self._get_client(channel_id).chat_postMessage(**post_kwargs)\n+                await self._get_client(\n+                    channel_id, team_id=team_id or None\n+                ).chat_postMessage(**post_kwargs)\n             logger.info(\n                 \"Slack button resolved slash-confirm for session %s (choice=%s, user=%s)\",\n                 session_key,","@@ -3574,10 +4107,27 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None:\n                 exc_info=True,\n             )\n \n+    async def _handle_feedback_action(self, ack, body, action) -> None:\n+        \"\"\"Ack Slack AI feedback button clicks and log the choice.\"\"\"\n+        await ack()\n+\n+        value = str(action.get(\"value\") or \"\")\n+        message = body.get(\"message\", {}) or {}\n+        channel_id = (body.get(\"channel\") or {}).get(\"id\", \"\")\n+        user_id = (body.get(\"user\") or {}).get(\"id\", \"\")\n+        logger.info(\n+            \"[Slack] Feedback button clicked: value=%s user=%s channel=%s ts=%s\",\n+            value,\n+            user_id,\n+            channel_id,\n+            message.get(\"ts\", \"\"),\n+        )\n+\n     async def _handle_approval_action(self, ack, body, action) -> None:\n         \"\"\"Handle an approval button click from Block Kit.\"\"\"\n         await ack()\n \n+        team_id = self._event_team_id({}, body)\n         action_id = action.get(\"action_id\", \"\")\n         session_key = action.get(\"value\", \"\")\n         message = body.get(\"message\", {})","@@ -3590,6 +4140,7 @@ async def _handle_approval_action(self, ack, body, action) -> None:\n             user_id,\n             channel_id=channel_id,\n             user_name=user_name,\n+            team_id=team_id,\n         ):\n             logger.warning(\n                 \"[Slack] Unauthorized approval click by %s (%s) - ignoring\",","@@ -3657,7 +4208,7 @@ async def _handle_approval_action(self, ack, body, action) -> None:\n         ]\n \n         try:\n-            await self._get_client(channel_id).chat_update(\n+            await self._get_client(channel_id, team_id=team_id or None).chat_update(\n                 channel=channel_id,\n                 ts=msg_ts,\n                 text=decision_text,","@@ -3717,7 +4268,7 @@ async def _fetch_thread_context(\n             return cached.content\n \n         try:\n-            client = self._get_client(channel_id)\n+            client = self._get_client(channel_id, team_id=team_id)\n \n             # Retry with exponential backoff for Tier-3 rate limits (429).\n             result = None","@@ -3802,7 +4353,9 @@ async def _fetch_thread_context(\n                 # Prefer the bot's own name when the message is a bot post.\n                 if is_bot and not display_user:\n                     display_user = msg.get(\"username\") or \"bot\"\n-                name = await self._resolve_user_name(display_user, chat_id=channel_id)\n+                name = await self._resolve_user_name(\n+                    display_user, chat_id=channel_id, team_id=team_id\n+                )\n \n                 # Mark senders not on the allowlist as [unverified] so the LLM\n                 # treats their content as background reference rather than","@@ -3878,7 +4431,7 @@ async def _fetch_thread_parent_text(\n             return cached.parent_text\n \n         try:\n-            client = self._get_client(channel_id)\n+            client = self._get_client(channel_id, team_id=team_id)\n             result = await client.conversations_replies(\n                 channel=channel_id,\n                 ts=thread_ts,","@@ -3961,6 +4514,7 @@ async def _handle_slash_command(self, command: dict) -> None:\n             chat_id=channel_id,\n             chat_type=\"dm\" if is_dm else \"group\",\n             user_id=user_id,\n+            scope_id=team_id or None,\n         )\n \n         event = MessageEvent(","@@ -3998,6 +4552,7 @@ def _has_active_session_for_thread(\n         channel_id: str,\n         thread_ts: str,\n         user_id: str,\n+        team_id: str = \"\",\n     ) -> bool:\n         \"\"\"Check if there's an active session for a thread.\n ","@@ -4022,6 +4577,7 @@ def _has_active_session_for_thread(\n                 chat_type=\"group\",\n                 user_id=user_id,\n                 thread_id=thread_ts,\n+                scope_id=team_id or None,\n             )\n \n             # Read session isolation settings from the store's config"]},{"file":"pyproject.toml","hunks":["@@ -158,9 +158,9 @@ modal = [\"modal==1.3.4\"]\n daytona = [\"daytona==0.155.0\"]\n hindsight = [\"hindsight-client==0.6.1\"]\n 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)\n-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\n+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\n cron = []  # croniter is now a core dependency; this extra kept for back-compat\n-slack = [\"slack-bolt==1.27.0\", \"slack-sdk==3.40.1\", \"aiohttp==3.14.1\"]\n+slack = [\"slack-bolt==1.29.0\", \"slack-sdk==3.43.0\", \"aiohttp==3.14.1\"]\n 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)\n # WeCom callback-mode adapter — parses untrusted XML POST bodies from\n # WeCom-controlled callback endpoints, so we use defusedxml (drop-in"]},{"file":"scripts/release.py","hunks":["@@ -47,6 +47,7 @@\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"mguttmann\",  # PR #63738 salvage (Anthropic setup-token pool auth normalization)\n     \"[redacted:email]\": \"VrtxOmega\",  # PR #43809 salvage (desktop: WSL folder-picker path bridge)\n+    \"[redacted:email]\": \"SemonCat\",  # PR #56786 salvage (Slack Agent View manifests and Assistant APIs)\n     \"[redacted:email]\": \"jakelongvu-bot\",  # PR #36683 partial salvage (approval: honor canonical approvals.timeout in gateway waits)\n     \"luigi@users.noreply.github.com\": \"Tortugasaur\",  # PR #43205 salvage (desktop: profile-aware three-way approval mode statusbar control)\n     \"[redacted:email]\": \"kavioavio\",  # Issue #46544 / PR #47705 evolution (smart DENY exact-operation owner override)"]},{"file":"tests/gateway/test_base_topic_sessions.py","hunks":["@@ -41,6 +41,9 @@ async def send_typing(self, chat_id: str, metadata=None) -> None:\n         self.typing.append({\"chat_id\": chat_id, \"metadata\": metadata})\n         return None\n \n+    async def stop_typing(self, chat_id: str, metadata=None) -> None:\n+        self.typing.append({\"chat_id\": chat_id, \"stopped\": True, \"metadata\": metadata})\n+\n     async def get_chat_info(self, chat_id: str):\n         return {\"id\": chat_id}\n ","@@ -143,6 +146,11 @@ async def hold_typing(_chat_id, interval=2.0, metadata=None):\n                 \"metadata\": {\"thread_id\": \"17585\"},\n             }\n         ]\n+        assert {\n+            \"chat_id\": \"-1001\",\n+            \"stopped\": True,\n+            \"metadata\": {\"thread_id\": \"17585\"},\n+        } in adapter.typing\n         assert adapter.processing_hooks == [\n             (\"start\", \"1\"),\n             (\"complete\", \"1\", ProcessingOutcome.SUCCESS),"]},{"file":"tests/gateway/test_slack.py","hunks":["@@ -17,6 +17,7 @@\n import pytest\n \n from gateway.config import Platform, PlatformConfig\n+from gateway.run import GatewayRunner\n from gateway.platforms.base import (\n     MessageEvent,\n     MessageType,","@@ -148,6 +149,7 @@ async def test_channel_slash_command_uses_group_session_semantics(self, adapter)\n         assert event.source.chat_type == \"group\"\n         assert event.source.chat_id == \"C123\"\n         assert event.source.user_id == \"U123\"\n+        assert event.source.scope_id == \"T123\"\n \n     @pytest.mark.asyncio\n     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):\n         assert event.source.chat_type == \"dm\"\n         assert event.source.chat_id == \"D123\"\n         assert event.source.user_id == \"U123\"\n+        assert event.source.scope_id == \"T123\"\n \n \n # ---------------------------------------------------------------------------","@@ -238,6 +241,8 @@ def decorator(fn):\n \n         assert \"message\" in registered_events\n         assert \"app_mention\" in registered_events\n+        assert \"app_home_opened\" in registered_events\n+        assert \"app_context_changed\" in registered_events\n         assert \"reaction_added\" in registered_events\n         assert \"reaction_removed\" in registered_events\n         assert \"assistant_thread_started\" in registered_events","@@ -799,6 +804,7 @@ async def close_async(self):\n         assert adapter._handler is not None\n         assert adapter._handler.proxy == \"http://proxy.example.com:3128\"\n         assert adapter._handler.client.proxy == \"http://proxy.example.com:3128\"\n+        assert \"hermes_feedback\" in created_apps[0].registered_actions\n \n     @pytest.mark.asyncio\n     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):\n         assert call_kwargs[\"filename\"] == \"report.pdf\"\n         assert call_kwargs[\"initial_comment\"] == \"Here's the report\"\n \n+    @pytest.mark.asyncio\n+    async def test_send_document_uses_metadata_workspace_client(self, adapter, tmp_path):\n+        \"\"\"Outbound media follows the inbound Slack workspace across gateway boundaries.\"\"\"\n+        test_file = tmp_path / \"report.pdf\"\n+        test_file.write_bytes(b\"%PDF-1.4 fake content\")\n+        secondary_client = AsyncMock()\n+        secondary_client.files_upload_v2 = AsyncMock(return_value={\"ok\": True})\n+        adapter._team_clients[\"T_SECONDARY\"] = secondary_client\n+\n+        result = await adapter.send_document(\n+            chat_id=\"C123\",\n+            file_path=str(test_file),\n+            metadata={\"slack_team_id\": \"T_SECONDARY\"},\n+        )\n+\n+        assert result.success\n+        secondary_client.files_upload_v2.assert_awaited_once()\n+        adapter._app.client.files_upload_v2.assert_not_called()\n+\n     @pytest.mark.asyncio\n     async def test_send_document_custom_name(self, adapter, tmp_path):\n         test_file = tmp_path / \"data.csv\"","@@ -2072,7 +2097,7 @@ async def test_stop_typing_clears_tracked_thread(self, adapter):\n             thread_ts=\"parent_ts\",\n             status=\"\",\n         )\n-        assert \"C123\" not in adapter._active_status_threads\n+        assert (\"\", \"C123\", \"parent_ts\") not in adapter._active_status_threads\n \n     @pytest.mark.asyncio\n     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):\n \n     @pytest.mark.asyncio\n     async def test_stop_typing_handles_api_error_gracefully(self, adapter):\n-        adapter._active_status_threads[\"C123\"] = \"parent_ts\"\n+        adapter._active_status_threads[(\"\", \"C123\", \"parent_ts\")] = {\n+            \"thread_ts\": \"parent_ts\",\n+            \"team_id\": \"\",\n+        }\n         adapter._app.client.assistant_threads_setStatus = AsyncMock(\n             side_effect=Exception(\"missing_scope\")\n         )","@@ -2096,7 +2124,7 @@ async def test_stop_typing_handles_api_error_gracefully(self, adapter):\n             thread_ts=\"parent_ts\",\n             status=\"\",\n         )\n-        assert \"C123\" not in adapter._active_status_threads\n+        assert (\"\", \"C123\", \"parent_ts\") not in adapter._active_status_threads\n \n     @pytest.mark.asyncio\n     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):\n             return_value={\"ts\": \"reply_ts\"}\n         )\n         adapter._app.client.assistant_threads_setStatus = AsyncMock()\n-        adapter._active_status_threads[\"C123\"] = \"parent_ts\"\n+        adapter._active_status_threads[(\"\", \"C123\", \"parent_ts\")] = {\n+            \"thread_ts\": \"parent_ts\",\n+            \"team_id\": \"\",\n+        }\n \n         result = await adapter.send(\"C123\", \"done\", metadata={\"thread_id\": \"parent_ts\"})\n ","@@ -2115,13 +2146,16 @@ async def test_send_clears_status_after_final_post(self, adapter):\n             thread_ts=\"parent_ts\",\n             status=\"\",\n         )\n-        assert \"C123\" not in adapter._active_status_threads\n+        assert (\"\", \"C123\", \"parent_ts\") not in adapter._active_status_threads\n \n     @pytest.mark.asyncio\n     async def test_streaming_final_edit_clears_status(self, adapter):\n         adapter._app.client.chat_update = AsyncMock()\n         adapter._app.client.assistant_threads_setStatus = AsyncMock()\n-        adapter._active_status_threads[\"C123\"] = \"parent_ts\"\n+        adapter._active_status_threads[(\"\", \"C123\", \"parent_ts\")] = {\n+            \"thread_ts\": \"parent_ts\",\n+            \"team_id\": \"\",\n+        }\n \n         result = await adapter.edit_message(\n             \"C123\",","@@ -2141,13 +2175,16 @@ async def test_streaming_final_edit_clears_status(self, adapter):\n             thread_ts=\"parent_ts\",\n             status=\"\",\n         )\n-        assert \"C123\" not in adapter._active_status_threads\n+        assert (\"\", \"C123\", \"parent_ts\") not in adapter._active_status_threads\n \n     @pytest.mark.asyncio\n     async def test_streaming_intermediate_edit_keeps_status(self, adapter):\n         adapter._app.client.chat_update = AsyncMock()\n         adapter._app.client.assistant_threads_setStatus = AsyncMock()\n-        adapter._active_status_threads[\"C123\"] = \"parent_ts\"\n+        adapter._active_status_threads[(\"\", \"C123\", \"parent_ts\")] = {\n+            \"thread_ts\": \"parent_ts\",\n+            \"team_id\": \"\",\n+        }\n \n         result = await adapter.edit_message(\n             \"C123\",","@@ -2158,7 +2195,195 @@ async def test_streaming_intermediate_edit_keeps_status(self, adapter):\n \n         assert result.success\n         adapter._app.client.assistant_threads_setStatus.assert_not_called()\n-        assert adapter._active_status_threads[\"C123\"] == \"parent_ts\"\n+        assert adapter._active_status_threads[(\"\", \"C123\", \"parent_ts\")][\n+            \"thread_ts\"\n+        ] == \"parent_ts\"\n+\n+    @pytest.mark.asyncio\n+    async def test_status_uses_workspace_client_from_metadata(self, adapter):\n+        team_client = AsyncMock()\n+        adapter._team_clients[\"T_OTHER\"] = team_client\n+\n+        await adapter.send_typing(\n+            \"D123\",\n+            metadata={\"thread_id\": \"parent_ts\", \"team_id\": \"T_OTHER\"},\n+        )\n+        await adapter.stop_typing(\"D123\")\n+\n+        assert team_client.assistant_threads_setStatus.call_args_list == [\n+            call(channel_id=\"D123\", thread_ts=\"parent_ts\", status=\"is thinking...\"),\n+            call(channel_id=\"D123\", thread_ts=\"parent_ts\", status=\"\"),\n+        ]\n+        adapter._app.client.assistant_threads_setStatus.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_status_accepts_slack_team_metadata_key(self, adapter):\n+        team_client = AsyncMock()\n+        adapter._team_clients[\"T_OTHER\"] = team_client\n+\n+        await adapter.send_typing(\n+            \"D123\",\n+            metadata={\"thread_id\": \"parent_ts\", \"slack_team_id\": \"T_OTHER\"},\n+        )\n+        await adapter.stop_typing(\"D123\", metadata={\"slack_team_id\": \"T_OTHER\"})\n+\n+        assert team_client.assistant_threads_setStatus.call_args_list == [\n+            call(channel_id=\"D123\", thread_ts=\"parent_ts\", status=\"is thinking...\"),\n+            call(channel_id=\"D123\", thread_ts=\"parent_ts\", status=\"\"),\n+        ]\n+        adapter._app.client.assistant_threads_setStatus.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_status_tracking_is_per_thread(self, adapter):\n+        adapter._app.client.assistant_threads_setStatus = AsyncMock()\n+\n+        await adapter.send_typing(\"D123\", metadata={\"thread_id\": \"thread_a\"})\n+        await adapter.send_typing(\"D123\", metadata={\"thread_id\": \"thread_b\"})\n+        await adapter.stop_typing(\"D123\", metadata={\"thread_id\": \"thread_a\"})\n+\n+        assert adapter._app.client.assistant_threads_setStatus.call_args_list == [\n+            call(channel_id=\"D123\", thread_ts=\"thread_a\", status=\"is thinking...\"),\n+            call(channel_id=\"D123\", thread_ts=\"thread_b\", status=\"is thinking...\"),\n+            call(channel_id=\"D123\", thread_ts=\"thread_a\", status=\"\"),\n+        ]\n+        assert (\"\", \"D123\", \"thread_a\") not in adapter._active_status_threads\n+        assert adapter._active_status_threads[(\"\", \"D123\", \"thread_b\")] == {\n+            \"thread_ts\": \"thread_b\",\n+            \"team_id\": \"\",\n+        }\n+\n+    @pytest.mark.asyncio\n+    async def test_stop_typing_with_metadata_preserves_sibling_status(self, adapter):\n+        adapter._app.client.assistant_threads_setStatus = AsyncMock()\n+        await adapter.send_typing(\"D123\", metadata={\"thread_id\": \"thread_a\"})\n+        await adapter.send_typing(\"D123\", metadata={\"thread_id\": \"thread_b\"})\n+\n+        await adapter._stop_typing_with_metadata(\n+            \"D123\", {\"thread_id\": \"thread_a\"}\n+        )\n+\n+        assert adapter._app.client.assistant_threads_setStatus.call_args_list == [\n+            call(channel_id=\"D123\", thread_ts=\"thread_a\", status=\"is thinking...\"),\n+            call(channel_id=\"D123\", thread_ts=\"thread_b\", status=\"is thinking...\"),\n+            call(channel_id=\"D123\", thread_ts=\"thread_a\", status=\"\"),\n+        ]\n+        assert (\"\", \"D123\", \"thread_b\") in adapter._active_status_threads\n+\n+    @pytest.mark.asyncio\n+    async def test_status_tracking_is_scoped_per_workspace(self, adapter):\n+        one, two = AsyncMock(), AsyncMock()\n+        adapter._team_clients.update({\"T_ONE\": one, \"T_TWO\": two})\n+\n+        await adapter.send_typing(\n+            \"D_SHARED\", metadata={\"thread_id\": \"171.000\", \"slack_team_id\": \"T_ONE\"}\n+        )\n+        await adapter.send_typing(\n+            \"D_SHARED\", metadata={\"thread_id\": \"171.000\", \"slack_team_id\": \"T_TWO\"}\n+        )\n+        await adapter.stop_typing(\n+            \"D_SHARED\", metadata={\"thread_id\": \"171.000\", \"slack_team_id\": \"T_ONE\"}\n+        )\n+\n+        assert one.assistant_threads_setStatus.call_args_list[-1] == call(\n+            channel_id=\"D_SHARED\", thread_ts=\"171.000\", status=\"\"\n+        )\n+        assert two.assistant_threads_setStatus.call_args_list[-1] == call(\n+            channel_id=\"D_SHARED\", thread_ts=\"171.000\", status=\"is thinking...\"\n+        )\n+        assert (\"T_TWO\", \"D_SHARED\", \"171.000\") in adapter._active_status_threads\n+\n+    @pytest.mark.asyncio\n+    async def test_stop_typing_without_team_uses_unique_thread_status(self, adapter):\n+        \"\"\"A stale channel fallback must not strand a uniquely tracked status.\"\"\"\n+        team_one, team_two = AsyncMock(), AsyncMock()\n+        adapter._team_clients.update({\"T_ONE\": team_one, \"T_TWO\": team_two})\n+        await adapter.send_typing(\n+            \"D_SHARED\",\n+            metadata={\"thread_id\": \"171.000\", \"slack_team_id\": \"T_ONE\"},\n+        )\n+        # Another workspace can overwrite this channel-only fallback map.\n+        adapter._channel_team[\"D_SHARED\"] = \"T_TWO\"\n+\n+        await adapter.stop_typing(\"D_SHARED\", metadata={\"thread_id\": \"171.000\"})\n+\n+        assert team_one.assistant_threads_setStatus.call_args_list[-1] == call(\n+            channel_id=\"D_SHARED\", thread_ts=\"171.000\", status=\"\"\n+        )\n+        assert (\"T_ONE\", \"D_SHARED\", \"171.000\") not in adapter._active_status_threads\n+        team_two.assistant_threads_setStatus.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_stop_typing_without_team_preserves_ambiguous_thread_statuses(\n+        self, adapter\n+    ):\n+        \"\"\"Without team metadata, matching workspace statuses must not be guessed.\"\"\"\n+        team_one, team_two = AsyncMock(), AsyncMock()\n+        adapter._team_clients.update({\"T_ONE\": team_one, \"T_TWO\": team_two})\n+        for team_id in (\"T_ONE\", \"T_TWO\"):\n+            await adapter.send_typing(\n+                \"D_SHARED\",\n+                metadata={\"thread_id\": \"171.000\", \"slack_team_id\": team_id},\n+            )\n+\n+        await adapter.stop_typing(\"D_SHARED\", metadata={\"thread_id\": \"171.000\"})\n+\n+        assert (\"T_ONE\", \"D_SHARED\", \"171.000\") in adapter._active_status_threads\n+        assert (\"T_TWO\", \"D_SHARED\", \"171.000\") in adapter._active_status_threads\n+        assert team_one.assistant_threads_setStatus.call_count == 1\n+        assert team_two.assistant_threads_setStatus.call_count == 1\n+\n+    @pytest.mark.asyncio\n+    async def test_streaming_final_edit_uses_workspace_client_from_metadata(\n+        self, adapter\n+    ):\n+        team_client = AsyncMock()\n+        team_client.chat_update = AsyncMock()\n+        team_client.assistant_threads_setStatus = AsyncMock()\n+        adapter._team_clients[\"T_OTHER\"] = team_client\n+        adapter._active_status_threads[(\"T_OTHER\", \"D123\", \"parent_ts\")] = {\n+            \"thread_ts\": \"parent_ts\",\n+            \"team_id\": \"T_OTHER\",\n+        }\n+\n+        result = await adapter.edit_message(\n+            \"D123\",\n+            \"reply_ts\",\n+            \"done\",\n+            finalize=True,\n+            metadata={\"thread_id\": \"parent_ts\", \"slack_team_id\": \"T_OTHER\"},\n+        )\n+\n+        assert result.success\n+        team_client.chat_update.assert_awaited_once_with(\n+            channel=\"D123\",\n+            ts=\"reply_ts\",\n+            text=\"done\",\n+        )\n+        team_client.assistant_threads_setStatus.assert_awaited_once_with(\n+            channel_id=\"D123\",\n+            thread_ts=\"parent_ts\",\n+            status=\"\",\n+        )\n+        adapter._app.client.chat_update.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_send_failure_clears_status(self, adapter):\n+        adapter._app.client.chat_postMessage = AsyncMock(side_effect=Exception(\"boom\"))\n+        adapter._app.client.assistant_threads_setStatus = AsyncMock()\n+        adapter._active_status_threads[(\"\", \"C123\", \"parent_ts\")] = {\n+            \"thread_ts\": \"parent_ts\",\n+            \"team_id\": \"\",\n+        }\n+\n+        result = await adapter.send(\"C123\", \"done\", metadata={\"thread_id\": \"parent_ts\"})\n+\n+        assert not result.success\n+        adapter._app.client.assistant_threads_setStatus.assert_called_once_with(\n+            channel_id=\"C123\",\n+            thread_ts=\"parent_ts\",\n+            status=\"\",\n+        )\n+        assert (\"\", \"C123\", \"parent_ts\") not in adapter._active_status_threads\n \n \n # ---------------------------------------------------------------------------","@@ -2914,7 +3139,7 @@ async def test_no_session_store_ignores_thread_replies(self, adapter):\n \n \n class TestAssistantThreadLifecycle:\n-    \"\"\"Slack Assistant lifecycle events should seed session/user context.\"\"\"\n+    \"\"\"Slack AI lifecycle events should seed session/user context.\"\"\"\n \n     @pytest.fixture()\n     def mock_session_store(self):","@@ -2957,7 +3182,9 @@ async def test_lifecycle_event_seeds_session_store(\n         await assistant_adapter._handle_assistant_thread_lifecycle_event(event)\n \n         assert (\n-            assistant_adapter._assistant_threads[(\"D123\", \"171.000\")][\"user_id\"]\n+            assistant_adapter._assistant_threads[(\"T_TEAM\", \"D123\", \"171.000\")][\n+                \"user_id\"\n+            ]\n             == \"U_USER\"\n         )\n         mock_session_store.get_or_create_session.assert_called_once()","@@ -2968,11 +3195,51 @@ async def test_lifecycle_event_seeds_session_store(\n         assert source.thread_id == \"171.000\"\n         assert source.chat_topic == \"C_ORIGIN\"\n \n+    @pytest.mark.asyncio\n+    async def test_app_home_messages_tab_seeds_dm_session(\n+        self, assistant_adapter, mock_session_store\n+    ):\n+        event = {\n+            \"type\": \"app_home_opened\",\n+            \"tab\": \"messages\",\n+            \"team\": \"T_TEAM\",\n+            \"channel\": \"D123\",\n+            \"user\": \"U_USER\",\n+        }\n+\n+        await assistant_adapter._handle_app_home_opened(event)\n+\n+        mock_session_store.get_or_create_session.assert_called_once()\n+        source = mock_session_store.get_or_create_session.call_args[0][0]\n+        assert source.chat_id == \"D123\"\n+        assert source.chat_type == \"dm\"\n+        assert source.user_id == \"U_USER\"\n+        assert source.thread_id is None\n+        assert assistant_adapter._channel_team[\"D123\"] == \"T_TEAM\"\n+        assistant_adapter.handle_message.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_app_home_non_messages_tab_is_ignored(\n+        self, assistant_adapter, mock_session_store\n+    ):\n+        event = {\n+            \"type\": \"app_home_opened\",\n+            \"tab\": \"home\",\n+            \"team\": \"T_TEAM\",\n+            \"channel\": \"D123\",\n+            \"user\": \"U_USER\",\n+        }\n+\n+        await assistant_adapter._handle_app_home_opened(event)\n+\n+        mock_session_store.get_or_create_session.assert_not_called()\n+        assistant_adapter.handle_message.assert_not_called()\n+\n     @pytest.mark.asyncio\n     async def test_message_uses_cached_assistant_thread_identity(\n         self, assistant_adapter\n     ):\n-        assistant_adapter._assistant_threads[(\"D123\", \"171.000\")] = {\n+        assistant_adapter._assistant_threads[(\"T_TEAM\", \"D123\", \"171.000\")] = {\n             \"channel_id\": \"D123\",\n             \"thread_ts\": \"171.000\",\n             \"user_id\": \"U_USER\",","@@ -3023,8 +3290,270 @@ def test_assistant_threads_cache_eviction(self, assistant_adapter):\n             }\n         )\n         assert len(assistant_adapter._assistant_threads) <= 10\n-        # The newest entry must survive eviction\n-        assert (\"D999\", \"999.000\") in assistant_adapter._assistant_threads\n+        # The newest entry must survive eviction.\n+        assert (\"\", \"D999\", \"999.000\") in assistant_adapter._assistant_threads\n+\n+    def test_suggested_prompts_config_accepts_dict_shape(self, assistant_adapter):\n+        assistant_adapter.config.extra[\"suggested_prompts\"] = {\n+            \"title\": \"Try these\",\n+            \"prompts\": [\n+                {\"title\": \"Summarize\", \"message\": \"Summarize this conversation\"},\n+                {\"title\": \"\", \"message\": \"skip me\"},\n+                {\"title\": \"Draft\", \"message\": \"Draft a reply\"},\n+            ],\n+        }\n+\n+        title, prompts = assistant_adapter._assistant_suggested_prompts()\n+\n+        assert title == \"Try these\"\n+        assert prompts == [\n+            {\"title\": \"Summarize\", \"message\": \"Summarize this conversation\"},\n+            {\"title\": \"Draft\", \"message\": \"Draft a reply\"},\n+        ]\n+\n+    def test_suggested_prompts_config_caps_at_four(self, assistant_adapter):\n+        assistant_adapter.config.extra[\"suggested_prompts\"] = [\n+            {\"title\": f\"Prompt {i}\", \"message\": f\"Message {i}\"}\n+            for i in range(6)\n+        ]\n+\n+        _title, prompts = assistant_adapter._assistant_suggested_prompts()\n+\n+        assert len(prompts) == 4\n+        assert prompts[-1] == {\"title\": \"Prompt 3\", \"message\": \"Message 3\"}\n+\n+    @pytest.mark.asyncio\n+    async def test_app_home_messages_tab_sets_agent_suggested_prompts(\n+        self, assistant_adapter\n+    ):\n+        assistant_adapter.config.extra[\"suggested_prompts\"] = {\n+            \"title\": \"Start here\",\n+            \"prompts\": [{\"title\": \"Plan\", \"message\": \"Help me plan the work\"}],\n+        }\n+        assistant_adapter._app.client.assistant_threads_setSuggestedPrompts = (\n+            AsyncMock()\n+        )\n+        event = {\n+            \"type\": \"app_home_opened\",\n+            \"tab\": \"messages\",\n+            \"team\": \"T_TEAM\",\n+            \"channel\": \"D123\",\n+            \"user\": \"U_USER\",\n+        }\n+\n+        await assistant_adapter._handle_app_home_opened(event)\n+\n+        assistant_adapter._app.client.assistant_threads_setSuggestedPrompts.assert_awaited_once_with(\n+            channel_id=\"D123\",\n+            title=\"Start here\",\n+            prompts=[{\"title\": \"Plan\", \"message\": \"Help me plan the work\"}],\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_assistant_lifecycle_sets_thread_suggested_prompts(\n+        self, assistant_adapter\n+    ):\n+        assistant_adapter.config.extra[\"suggested_prompts\"] = [\n+            {\"title\": \"Summarize\", \"message\": \"Summarize the current thread\"}\n+        ]\n+        assistant_adapter._app.client.assistant_threads_setSuggestedPrompts = (\n+            AsyncMock()\n+        )\n+        event = {\n+            \"type\": \"assistant_thread_started\",\n+            \"team_id\": \"T_TEAM\",\n+            \"assistant_thread\": {\n+                \"channel_id\": \"D123\",\n+                \"thread_ts\": \"171.000\",\n+                \"user_id\": \"U_USER\",\n+            },\n+        }\n+\n+        await assistant_adapter._handle_assistant_thread_lifecycle_event(event)\n+\n+        assistant_adapter._app.client.assistant_threads_setSuggestedPrompts.assert_awaited_once_with(\n+            channel_id=\"D123\",\n+            prompts=[\n+                {\"title\": \"Summarize\", \"message\": \"Summarize the current thread\"}\n+            ],\n+            thread_ts=\"171.000\",\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_agent_view_context_is_scoped_per_workspace_and_user(\n+        self, assistant_adapter\n+    ):\n+        await assistant_adapter._handle_app_context_changed(\n+            {\n+                \"type\": \"app_context_changed\",\n+                \"user\": \"U_ONE\",\n+                \"context\": {\n+                    \"entities\": [\n+                        {\n+                            \"type\": \"slack#/types/channel_id\",\n+                            \"value\": \"C_CONTEXT_ONE\",\n+                        }\n+                    ]\n+                },\n+            },\n+            {\"team_id\": \"T_ONE\"},\n+        )\n+        await assistant_adapter._handle_app_context_changed(\n+            {\n+                \"type\": \"app_context_changed\",\n+                \"user\": \"U_TWO\",\n+                \"context\": {\n+                    \"entities\": [\n+                        {\n+                            \"type\": \"slack#/types/channel_id\",\n+                            \"value\": \"C_CONTEXT_TWO\",\n+                        }\n+                    ]\n+                },\n+            },\n+            {\"team_id\": \"T_TWO\"},\n+        )\n+\n+        assert assistant_adapter._agent_view_context_for_event(\n+            {}, \"T_ONE\", \"U_ONE\"\n+        )[\"context_channel_id\"] == \"C_CONTEXT_ONE\"\n+        assert assistant_adapter._agent_view_context_for_event(\n+            {}, \"T_TWO\", \"U_TWO\"\n+        )[\"context_channel_id\"] == \"C_CONTEXT_TWO\"\n+        assert \"C_CONTEXT_ONE\" not in assistant_adapter._channel_team\n+\n+    @pytest.mark.asyncio\n+    async def test_assistant_thread_cache_is_scoped_per_workspace(\n+        self, assistant_adapter\n+    ):\n+        \"\"\"Slack Connect can reuse a channel/thread pair in multiple workspaces.\"\"\"\n+        for team_id, user_id in ((\"T_ONE\", \"U_ONE\"), (\"T_TWO\", \"U_TWO\")):\n+            await assistant_adapter._handle_assistant_thread_lifecycle_event(\n+                {\n+                    \"type\": \"assistant_thread_started\",\n+                    \"team_id\": team_id,\n+                    \"assistant_thread\": {\n+                        \"channel_id\": \"D_SHARED\",\n+                        \"thread_ts\": \"171.000\",\n+                        \"user_id\": user_id,\n+                    },\n+                }\n+            )\n+\n+        assert assistant_adapter._assistant_threads[\n+            (\"T_ONE\", \"D_SHARED\", \"171.000\")\n+        ][\"user_id\"] == \"U_ONE\"\n+        assert assistant_adapter._assistant_threads[\n+            (\"T_TWO\", \"D_SHARED\", \"171.000\")\n+        ][\"user_id\"] == \"U_TWO\"\n+        assert assistant_adapter._lookup_assistant_thread_metadata(\n+            {}, channel_id=\"D_SHARED\", thread_ts=\"171.000\", team_id=\"T_ONE\"\n+        )[\"user_id\"] == \"U_ONE\"\n+        assert assistant_adapter._lookup_assistant_thread_metadata(\n+            {}, channel_id=\"D_SHARED\", thread_ts=\"171.000\", team_id=\"T_TWO\"\n+        )[\"user_id\"] == \"U_TWO\"\n+\n+    @pytest.mark.asyncio\n+    async def test_agent_view_message_preserves_outer_team_and_turn_context(\n+        self, assistant_adapter\n+    ):\n+        assistant_adapter._app.client.users_info = AsyncMock(\n+            return_value={\"user\": {\"profile\": {\"display_name\": \"Tyler\"}}}\n+        )\n+        assistant_adapter._app.client.reactions_add = AsyncMock()\n+        assistant_adapter._app.client.reactions_remove = AsyncMock()\n+        await assistant_adapter._handle_app_context_changed(\n+            {\n+                \"type\": \"app_context_changed\",\n+                \"user\": \"U_USER\",\n+                \"context\": {\n+                    \"entities\": [\n+                        {\n+                            \"type\": \"slack#/types/channel_id\",\n+                            \"value\": \"C_ACTIVE\",\n+                        }\n+                    ]\n+                },\n+            },\n+            {\"team_id\": \"T_OTHER\"},\n+        )\n+\n+        await assistant_adapter._handle_slack_message(\n+            {\n+                \"text\": \"help me plan\",\n+                \"channel\": \"D123\",\n+                \"channel_type\": \"im\",\n+                \"ts\": \"171.111\",\n+                \"user\": \"U_USER\",\n+            },\n+            {\"team_id\": \"T_OTHER\"},\n+        )\n+\n+        msg_event = assistant_adapter.handle_message.await_args.args[0]\n+        assert msg_event.source.scope_id == \"T_OTHER\"\n+        assert msg_event.metadata[\"slack_team_id\"] == \"T_OTHER\"\n+        assert msg_event.source.thread_id == \"171.111\"\n+        assert msg_event.text.startswith(\n+            \"[Slack app context: user is viewing channel C_ACTIVE]\"\n+        )\n+\n+        runner = object.__new__(GatewayRunner)\n+        assert runner._thread_metadata_for_source(msg_event.source) == {\n+            \"thread_id\": \"171.111\",\n+            \"slack_team_id\": \"T_OTHER\",\n+        }\n+\n+    @pytest.mark.asyncio\n+    async def test_dm_message_sets_assistant_thread_title_once(\n+        self, assistant_adapter\n+    ):\n+        assistant_adapter._app.client.users_info = AsyncMock(\n+            return_value={\"user\": {\"profile\": {\"display_name\": \"Tyler\"}}}\n+        )\n+        assistant_adapter._app.client.reactions_add = AsyncMock()\n+        assistant_adapter._app.client.reactions_remove = AsyncMock()\n+        assistant_adapter._app.client.assistant_threads_setTitle = AsyncMock()\n+        event = {\n+            \"text\": \"Please summarize this incident thread\",\n+            \"channel\": \"D123\",\n+            \"channel_type\": \"im\",\n+            \"ts\": \"171.111\",\n+            \"team\": \"T_TEAM\",\n+            \"user\": \"U_USER\",\n+        }\n+\n+        await assistant_adapter._handle_slack_message(event)\n+        await assistant_adapter._handle_slack_message(\n+            {**event, \"ts\": \"171.222\", \"thread_ts\": \"171.111\"}\n+        )\n+\n+        assistant_adapter._app.client.assistant_threads_setTitle.assert_awaited_once_with(\n+            channel_id=\"D123\",\n+            thread_ts=\"171.111\",\n+            title=\"Please summarize this incident thread\",\n+        )\n+        msg_event = assistant_adapter.handle_message.call_args[0][0]\n+        assert msg_event.metadata[\"slack_team_id\"] == \"T_TEAM\"\n+\n+    @pytest.mark.asyncio\n+    async def test_dm_message_title_can_be_disabled(self, assistant_adapter):\n+        assistant_adapter.config.extra[\"assistant_thread_titles\"] = False\n+        assistant_adapter._app.client.users_info = AsyncMock(return_value={\"user\": {}})\n+        assistant_adapter._app.client.reactions_add = AsyncMock()\n+        assistant_adapter._app.client.reactions_remove = AsyncMock()\n+        assistant_adapter._app.client.assistant_threads_setTitle = AsyncMock()\n+        event = {\n+            \"text\": \"title me\",\n+            \"channel\": \"D123\",\n+            \"channel_type\": \"im\",\n+            \"ts\": \"171.111\",\n+            \"team\": \"T_TEAM\",\n+            \"user\": \"U_USER\",\n+        }\n+\n+        await assistant_adapter._handle_slack_message(event)\n+\n+        assistant_adapter._app.client.assistant_threads_setTitle.assert_not_called()\n \n \n # ---------------------------------------------------------------------------","@@ -3073,6 +3602,23 @@ async def test_handles_api_error(self, adapter):\n         name = await adapter._resolve_user_name(\"U123\")\n         assert name == \"U123\"  # Falls back to user_id\n \n+    @pytest.mark.asyncio\n+    async def test_workspace_scoped_cache_uses_each_workspace_client(self, adapter):\n+        \"\"\"The same Slack user ID can resolve differently in another workspace.\"\"\"\n+        team_one, team_two = AsyncMock(), AsyncMock()\n+        team_one.users_info = AsyncMock(\n+            return_value={\"user\": {\"profile\": {\"display_name\": \"Alice\"}}}\n+        )\n+        team_two.users_info = AsyncMock(\n+            return_value={\"user\": {\"profile\": {\"display_name\": \"Bob\"}}}\n+        )\n+        adapter._team_clients.update({\"T_ONE\": team_one, \"T_TWO\": team_two})\n+\n+        assert await adapter._resolve_user_name(\"U_SHARED\", \"D_SHARED\", \"T_ONE\") == \"Alice\"\n+        assert await adapter._resolve_user_name(\"U_SHARED\", \"D_SHARED\", \"T_TWO\") == \"Bob\"\n+        team_one.users_info.assert_awaited_once_with(user=\"U_SHARED\")\n+        team_two.users_info.assert_awaited_once_with(user=\"U_SHARED\")\n+\n     @pytest.mark.asyncio\n     async def test_user_name_in_message_source(self, adapter):\n         \"\"\"Message source should include resolved user name.\"\"\"","@@ -4055,6 +4601,27 @@ async def test_no_auth_check_preserves_legacy_format(self, adapter):\n         assert \"identity hasn't\" not in content\n         assert \"[Thread context — prior messages in this thread (not yet in conversation history):]\" in content\n \n+    @pytest.mark.asyncio\n+    async def test_thread_context_uses_workspace_client(self, adapter):\n+        team_client = AsyncMock()\n+        team_client.conversations_replies = self._make_replies(self._thread_messages())\n+        adapter._team_clients[\"T_OTHER\"] = team_client\n+        adapter._thread_context_cache.clear()\n+\n+        with patch.object(\n+            adapter, \"_resolve_user_name\",\n+            new=AsyncMock(side_effect=lambda uid, **_: uid),\n+        ):\n+            await adapter._fetch_thread_context(\n+                channel_id=\"C1\",\n+                thread_ts=\"100.0\",\n+                current_ts=\"999.0\",\n+                team_id=\"T_OTHER\",\n+            )\n+\n+        team_client.conversations_replies.assert_awaited_once()\n+        adapter._app.client.conversations_replies.assert_not_called()\n+\n     @pytest.mark.asyncio\n     async def test_all_authorized_no_tags(self, adapter):\n         \"\"\"Auth callback returning True for every sender → no [unverified] tags.\"\"\""]},{"file":"tests/gateway/test_slack_approval_buttons.py","hunks":["@@ -323,6 +323,18 @@ def test_delegates_to_gateway_runner_auth(self):\n         assert runner.seen_sources[0].chat_id == \"C1\"\n         assert runner.seen_sources[0].chat_type == \"group\"\n \n+    def test_passes_workspace_scope_to_gateway_runner_auth(self):\n+        adapter = _make_adapter()\n+        runner = _attach_auth_runner(adapter)\n+\n+        assert adapter._is_interactive_user_authorized(\n+            \"U_OK\",\n+            channel_id=\"C1\",\n+            user_name=\"operator\",\n+            team_id=\"T1\",\n+        ) is True\n+        assert runner.seen_sources[0].scope_id == \"T1\"\n+\n \n class TestSlackSlashConfirmAction:\n     @pytest.mark.asyncio","@@ -364,6 +376,40 @@ async def test_global_allowlist_allows_authorized_click(self, monkeypatch):\n         mock_client.chat_update.assert_called_once()\n         mock_client.chat_postMessage.assert_called_once()\n \n+    @pytest.mark.asyncio\n+    async def test_action_uses_outer_payload_workspace_client(self, monkeypatch):\n+        adapter = _make_adapter()\n+        secondary_client = AsyncMock()\n+        adapter._team_clients[\"T2\"] = secondary_client\n+        monkeypatch.delenv(\"SLACK_ALLOWED_USERS\", raising=False)\n+        monkeypatch.delenv(\"SLACK_ALLOW_ALL_USERS\", raising=False)\n+        monkeypatch.delenv(\"GATEWAY_ALLOW_ALL_USERS\", raising=False)\n+        monkeypatch.setenv(\"GATEWAY_ALLOWED_USERS\", \"U_OWNER\")\n+\n+        ack = AsyncMock()\n+        body = {\n+            \"team_id\": \"T2\",\n+            \"message\": {\n+                \"ts\": \"2222.3333\",\n+                \"blocks\": [\n+                    {\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Original prompt\"}},\n+                ],\n+            },\n+            \"channel\": {\"id\": \"C1\"},\n+            \"user\": {\"name\": \"owner\", \"id\": \"U_OWNER\"},\n+        }\n+        action = {\n+            \"action_id\": \"hermes_confirm_once\",\n+            \"value\": \"agent:main:slack:group:C1:1111|confirm-1\",\n+        }\n+\n+        with patch(\"tools.slash_confirm.resolve\", new=AsyncMock(return_value=\"follow-up\")):\n+            await adapter._handle_slash_confirm_action(ack, body, action)\n+\n+        secondary_client.chat_update.assert_awaited_once()\n+        secondary_client.chat_postMessage.assert_awaited_once()\n+        adapter._team_clients[\"T1\"].chat_update.assert_not_called()\n+\n \n # ===========================================================================\n # _fetch_thread_context","@@ -385,7 +431,7 @@ async def test_fetches_and_formats_context(self):\n         })\n \n         # Mock user name resolution\n-        adapter._user_name_cache = {\"U1\": \"Alice\", \"U2\": \"Bob\"}\n+        adapter._user_name_cache = {(\"T1\", \"U1\"): \"Alice\", (\"T1\", \"U2\"): \"Bob\"}\n \n         context = await adapter._fetch_thread_context(\n             channel_id=\"C1\",","@@ -432,7 +478,10 @@ async def test_skips_bot_messages(self):\n                 {\"ts\": \"1000.2\", \"user\": \"U1\", \"text\": \"Current\"},\n             ]\n         })\n-        adapter._user_name_cache = {\"U1\": \"Alice\", \"U_OTHER_BOT\": \"DeployBot\"}\n+        adapter._user_name_cache = {\n+            (\"T1\", \"U1\"): \"Alice\",\n+            (\"T1\", \"U_OTHER_BOT\"): \"DeployBot\",\n+        }\n \n         context = await adapter._fetch_thread_context(\n             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):\n                 {\"ts\": \"1000.1\", \"user\": \"U1\", \"text\": \"詳細を教えて\"},\n             ]\n         })\n-        adapter._user_name_cache = {\"U1\": \"Alice\"}\n+        adapter._user_name_cache = {(\"T1\", \"U1\"): \"Alice\"}\n \n         context = await adapter._fetch_thread_context(\n             channel_id=\"C1\",","@@ -519,7 +568,7 @@ async def test_fetch_thread_context_excludes_self_bot_replies(self):\n                 {\"ts\": \"1000.3\", \"user\": \"U1\", \"text\": \"Current\"},\n             ]\n         })\n-        adapter._user_name_cache = {\"U1\": \"Alice\"}\n+        adapter._user_name_cache = {(\"T1\", \"U1\"): \"Alice\"}\n \n         context = await adapter._fetch_thread_context(\n             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):\n                 {\"ts\": \"2000.3\", \"user\": \"U2\", \"text\": \"Current\"},\n             ]\n         })\n-        adapter._user_name_cache = {\"U2\": \"Bob\"}\n+        adapter._user_name_cache = {(\"T2\", \"U2\"): \"Bob\"}\n \n         context = await adapter._fetch_thread_context(\n             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):\n                 {\"ts\": \"1000.1\", \"user\": \"U1\", \"text\": \"DO NOT INCLUDE THIS\"},\n             ]\n         })\n-        adapter._user_name_cache = {\"U1\": \"Alice\"}\n+        adapter._user_name_cache = {(\"T1\", \"U1\"): \"Alice\"}\n \n         context = await adapter._fetch_thread_context(\n             channel_id=\"C1\", thread_ts=\"1000.0\", current_ts=\"1000.1\", team_id=\"T1\""]},{"file":"tests/gateway/test_slack_block_kit_adapter.py","hunks":["@@ -77,6 +77,27 @@ async def test_multichunk_message_no_blocks(self):\n             assert \"blocks\" not in c.kwargs\n             assert c.kwargs[\"text\"]\n \n+    @pytest.mark.asyncio\n+    async def test_feedback_buttons_opt_in_appended_to_blocks(self):\n+        adapter, client = _make_adapter({\"rich_blocks\": True, \"feedback_buttons\": True})\n+\n+        await adapter.send(\"C1\", \"final answer\")\n+\n+        blocks = client.chat_postMessage.await_args.kwargs[\"blocks\"]\n+        feedback = blocks[-1]\n+        assert feedback[\"type\"] == \"context_actions\"\n+        assert feedback[\"elements\"][0][\"type\"] == \"feedback_buttons\"\n+        assert feedback[\"elements\"][0][\"action_id\"] == \"hermes_feedback\"\n+\n+    @pytest.mark.asyncio\n+    async def test_feedback_buttons_require_rich_blocks(self):\n+        \"\"\"feedback_buttons alone must not implicitly enable Block Kit rendering.\"\"\"\n+        adapter, client = _make_adapter({\"feedback_buttons\": True})\n+\n+        await adapter.send(\"C1\", \"final answer\")\n+\n+        assert \"blocks\" not in client.chat_postMessage.await_args.kwargs\n+\n \n class TestEditMessageBlocks:\n     @pytest.mark.asyncio","@@ -95,6 +116,13 @@ async def test_finalize_edit_gets_blocks(self):\n         assert \"blocks\" in kwargs and kwargs[\"blocks\"]\n         assert kwargs[\"text\"]\n \n+    @pytest.mark.asyncio\n+    async def test_finalize_edit_gets_feedback_buttons_when_enabled(self):\n+        adapter, client = _make_adapter({\"rich_blocks\": True, \"feedback_buttons\": True})\n+        await adapter.edit_message(\"C1\", \"111.222\", RICH_MD, finalize=True)\n+        blocks = client.chat_update.await_args.kwargs[\"blocks\"]\n+        assert blocks[-1][\"elements\"][0][\"type\"] == \"feedback_buttons\"\n+\n     @pytest.mark.asyncio\n     async def test_finalize_edit_disabled_no_blocks(self):\n         adapter, client = _make_adapter()  # rich_blocks off"]},{"file":"tests/hermes_cli/test_slack_cli.py","hunks":["@@ -15,7 +15,7 @@ def _parse_slack_args(argv):\n \n \n class TestSlackManifestArgparse:\n-    \"\"\"The `--no-assistant` flag wires through argparse to `no_assistant`.\"\"\"\n+    \"\"\"Slack manifest messaging-experience flags wire through argparse.\"\"\"\n \n     def test_no_assistant_flag_defaults_false(self):\n         args = _parse_slack_args([\"slack\", \"manifest\"])","@@ -25,6 +25,13 @@ def test_no_assistant_flag_sets_true(self):\n         args = _parse_slack_args([\"slack\", \"manifest\", \"--no-assistant\"])\n         assert args.no_assistant is True\n \n+    def test_agent_view_flag_defaults_false(self):\n+        args = _parse_slack_args([\"slack\", \"manifest\"])\n+        assert getattr(args, \"agent_view\", False) is False\n+\n+    def test_agent_view_flag_sets_true(self):\n+        args = _parse_slack_args([\"slack\", \"manifest\", \"--agent-view\"])\n+        assert args.agent_view is True\n \n \n class TestSlackFullManifest:","@@ -77,6 +84,7 @@ def test_assistant_features_remain_enabled(self):\n         manifest = _build_full_manifest(\"Hermes\", \"Your Hermes agent on Slack\")\n \n         assert \"assistant_view\" in manifest[\"features\"]\n+        assert \"agent_view\" not in manifest[\"features\"]\n         assert \"assistant:write\" in manifest[\"oauth_config\"][\"scopes\"][\"bot\"]\n         bot_events = manifest[\"settings\"][\"event_subscriptions\"][\"bot_events\"]\n         assert \"assistant_thread_started\" in bot_events","@@ -89,11 +97,39 @@ def test_no_assistant_omits_assistant_pieces(self):\n         # assistant_view feature is gone -> Slack renders a flat DM, not the\n         # Assistant thread pane (where bare slash commands don't dispatch).\n         assert \"assistant_view\" not in manifest[\"features\"]\n+        assert \"agent_view\" not in manifest[\"features\"]\n         assert \"assistant:write\" not in manifest[\"oauth_config\"][\"scopes\"][\"bot\"]\n         bot_events = manifest[\"settings\"][\"event_subscriptions\"][\"bot_events\"]\n         assert \"assistant_thread_started\" not in bot_events\n         assert \"assistant_thread_context_changed\" not in bot_events\n \n+    def test_agent_view_uses_agent_manifest_surface(self):\n+        manifest = _build_full_manifest(\n+            \"Hermes\",\n+            \"Your Hermes agent on Slack\",\n+            messaging_experience=\"agent\",\n+        )\n+\n+        assert manifest[\"features\"][\"agent_view\"] == {\n+            \"agent_description\": \"Chat with Hermes in Slack Messages.\",\n+        }\n+        assert \"assistant_view\" not in manifest[\"features\"]\n+        assert \"assistant:write\" in manifest[\"oauth_config\"][\"scopes\"][\"bot\"]\n+\n+    def test_agent_view_uses_agent_event_subscriptions(self):\n+        manifest = _build_full_manifest(\n+            \"Hermes\",\n+            \"Your Hermes agent on Slack\",\n+            messaging_experience=\"agent\",\n+        )\n+\n+        bot_events = manifest[\"settings\"][\"event_subscriptions\"][\"bot_events\"]\n+        assert \"app_home_opened\" in bot_events\n+        assert \"app_context_changed\" in bot_events\n+        assert \"message.im\" in bot_events\n+        assert \"assistant_thread_started\" not in bot_events\n+        assert \"assistant_thread_context_changed\" not in bot_events\n+\n     def test_no_assistant_preserves_core_surface(self):\n         \"\"\"Dropping assistant mode must NOT strip the regular messaging surface.\"\"\"\n         manifest = _build_full_manifest("]},{"file":"tools/lazy_deps.py","hunks":["@@ -168,8 +168,8 @@\n         \"aiohttp==3.14.1\",  # CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265\n     ),\n     \"platform.slack\": (\n-        \"slack-bolt==1.27.0\",\n-        \"slack-sdk==3.40.1\",\n+        \"slack-bolt==1.29.0\",\n+        \"slack-sdk==3.43.0\",\n         \"aiohttp==3.14.1\",  # CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265\n     ),\n     \"platform.matrix\": ("]},{"file":"uv.lock","hunks":["@@ -1834,10 +1834,10 @@ requires-dist = [\n     { name = \"ruff\", marker = \"extra == 'dev'\", specifier = \"==0.15.10\" },\n     { name = \"setuptools\", marker = \"extra == 'dev'\", specifier = \"==81.0.0\" },\n     { name = \"simple-term-menu\", marker = \"extra == 'cli'\", specifier = \"==1.6.6\" },\n-    { name = \"slack-bolt\", marker = \"extra == 'messaging'\", specifier = \"==1.27.0\" },\n-    { name = \"slack-bolt\", marker = \"extra == 'slack'\", specifier = \"==1.27.0\" },\n-    { name = \"slack-sdk\", marker = \"extra == 'messaging'\", specifier = \"==3.40.1\" },\n-    { name = \"slack-sdk\", marker = \"extra == 'slack'\", specifier = \"==3.40.1\" },\n+    { name = \"slack-bolt\", marker = \"extra == 'messaging'\", specifier = \"==1.29.0\" },\n+    { name = \"slack-bolt\", marker = \"extra == 'slack'\", specifier = \"==1.29.0\" },\n+    { name = \"slack-sdk\", marker = \"extra == 'messaging'\", specifier = \"==3.43.0\" },\n+    { name = \"slack-sdk\", marker = \"extra == 'slack'\", specifier = \"==3.43.0\" },\n     { name = \"sounddevice\", marker = \"extra == 'voice'\", specifier = \"==0.5.5\" },\n     { name = \"starlette\", marker = \"extra == 'computer-use'\", specifier = \"==1.0.1\" },\n     { name = \"starlette\", marker = \"extra == 'dev'\", specifier = \"==1.0.1\" },","@@ -3889,23 +3889,23 @@ wheels = [\n \n [[package]]\n name = \"slack-bolt\"\n-version = \"1.27.0\"\n+version = \"1.29.0\"\n source = { registry = \"https://pypi.org/simple\" }\n dependencies = [\n     { name = \"slack-sdk\" },\n ]\n-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\" }\n+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\" }\n wheels = [\n-    { 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\" },\n+    { 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\" },\n ]\n \n [[package]]\n name = \"slack-sdk\"\n-version = \"3.40.1\"\n+version = \"3.43.0\"\n source = { registry = \"https://pypi.org/simple\" }\n-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\" }\n+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\" }\n wheels = [\n-    { 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\" },\n+    { 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\" },\n ]\n \n [[package]]"]},{"file":"website/docs/user-guide/messaging/slack.md","hunks":["@@ -36,12 +36,13 @@ Mode — all at once.\n \n ### Option A: From a Hermes-generated manifest (recommended)\n \n-1. Generate the manifest:\n+1. Generate the manifest. New Slack apps must use Agent view:\n    ```bash\n-   hermes slack manifest --write\n+   hermes slack manifest --agent-view --write\n    ```\n    This writes `~/.hermes/slack-manifest.json` and prints paste-in\n-   instructions.\n+   instructions. Existing apps that still use Slack's legacy Assistant view\n+   can omit `--agent-view` until they are ready to migrate.\n 2. Go to [https://api.slack.com/apps](https://api.slack.com/apps) →\n    **Create New App** → **From an app manifest**\n 3. Pick your workspace, paste the JSON contents, review, click **Next**","@@ -243,6 +244,23 @@ Step 1, Option A) that declares every command in\n as a slash command. In Socket Mode, Slack routes the command event\n through the WebSocket regardless of the manifest's `url` field.\n \n+### Agent messaging experience\n+\n+New Slack apps use Slack's **Agent** messaging experience. Existing Hermes\n+Assistant apps can migrate by regenerating the manifest with `--agent-view`:\n+\n+```bash\n+hermes slack manifest --agent-view --write\n+```\n+\n+Update the manifest in **Features → App Manifest**, then reinstall the app if\n+Slack asks. Agent view cannot be reverted to Assistant view, and users may need\n+to hard-refresh Slack after the switch. The generated Agent manifest subscribes\n+to `message.im`, `app_home_opened`, and `app_context_changed`, so Hermes can\n+identify a Messages-tab DM and receive the user's active Slack context with a\n+turn. Hermes only supplies that context as a label; it does not read the viewed\n+channel's history.\n+\n ### Refreshing slash commands after updates\n \n When Hermes adds new commands (e.g. after `hermes update`), regenerate","@@ -353,6 +371,19 @@ platforms:\n       # gracefully fall back to aligned monospace.\n       rich_blocks: false\n \n+      # Append Slack-native feedback controls to final Block Kit replies.\n+      # Requires rich_blocks: true. Default: false.\n+      feedback_buttons: false\n+\n+      # Suggested prompts pinned at the top of Agent view's Messages tab.\n+      # Either a list of {title, message} rows, or a titled object:\n+      # {title: \"Start here\", prompts: [{title: \"Plan\", message: \"...\"}]}\n+      suggested_prompts: []\n+\n+      # Title Agent/Assistant DM threads from the first user message.\n+      # Default: true. Set false to leave Slack's default thread titles.\n+      assistant_thread_titles: true\n+\n       # Continuable-cron delivery surface (default: \"thread\").\n       # \"in_channel\" delivers a continuable cron job FLAT into the channel\n       # (no dedicated thread); pair with reply_in_thread: false (and","@@ -367,6 +398,9 @@ platforms:\n | `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. |\n | `platforms.slack.extra.reply_broadcast` | `false` | When `true`, thread replies are also posted to the main channel. Only the first chunk is broadcast. |\n | `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. |\n+| `platforms.slack.extra.feedback_buttons` | `false` | When `true` with `rich_blocks`, appends Slack-native feedback controls to final replies. |\n+| `platforms.slack.extra.suggested_prompts` | `[]` | Up to four `{title, message}` prompts for Agent/Assistant DM entry points; accepts either a list or `{title, prompts}`. |\n+| `platforms.slack.extra.assistant_thread_titles` | `true` | When `true`, names Agent/Assistant DM threads from the first user message. |\n | `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. |\n \n ### Session Isolation\n"]}]},{"kind":"pr","number":64621,"title":"fix(slack): clear stuck assistant status on /stop and resume typing after clarify","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -9208,6 +9208,21 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                         \"Gateway intercepted clarify text response (session=%s, id=%s)\",\n                         _quick_key, _pending_clarify.clarify_id,\n                     )\n+                    # The clarify callback pauses the platform typing/status\n+                    # indicator while waiting so Slack users can type their\n+                    # answer. The active agent resumes as soon as this reply\n+                    # resolves the wait, so re-enable its indicator here too.\n+                    # Without this, Slack stays silent until the independent\n+                    # long-running heartbeat fires (three minutes by default).\n+                    _clarify_adapter = self._adapter_for_source(source)\n+                    if _clarify_adapter:\n+                        try:\n+                            _clarify_adapter.resume_typing_for_chat(source.chat_id)\n+                        except Exception:\n+                            logger.debug(\n+                                \"Failed to resume typing after clarify response\",\n+                                exc_info=True,\n+                            )\n                     # Acknowledge with empty string so adapters that emit\n                     # the agent's response don't double-post.  The agent\n                     # itself will produce the next user-facing message."]},{"file":"gateway/slash_commands.py","hunks":["@@ -1112,6 +1112,26 @@ async def _handle_stop_command(self, event: MessageEvent) -> Union[str, Ephemera\n             )\n             return EphemeralReply(t(\"gateway.stop.stopped\"))\n \n+        # No running agent anywhere for this scope. A platform status\n+        # indicator can still be stuck — e.g. Slack's persistent\n+        # assistant.threads.setStatus survives a gateway restart or a turn\n+        # that died without a final send (#32295). Best-effort clear so\n+        # /stop always dismisses a phantom \"is thinking...\".\n+        adapter = getattr(self, \"adapters\", {}).get(source.platform)\n+        if adapter and hasattr(adapter, \"_stop_typing_with_metadata\"):\n+            try:\n+                await adapter._stop_typing_with_metadata(\n+                    source.chat_id,\n+                    self._thread_metadata_for_source(\n+                        source, self._reply_anchor_for_event(event)\n+                    ),\n+                )\n+            except Exception:\n+                logger.debug(\n+                    \"Failed to clear typing on /stop with no active agent\",\n+                    exc_info=True,\n+                )\n+\n         return t(\"gateway.stop.no_active\")\n \n     async def _handle_platform_command(self, event: MessageEvent) -> str:"]},{"file":"plugins/platforms/slack/adapter.py","hunks":["@@ -1609,6 +1609,7 @@ async def stop_typing(self, chat_id: str, metadata=None) -> None:\n             )\n         requested_team_id = self._metadata_team_id(metadata)\n         active = None\n+        ambiguous_tracked = False\n         if requested_thread_ts:\n             if requested_team_id:\n                 active_key = self._workspace_thread_key(","@@ -1628,6 +1629,7 @@ async def stop_typing(self, chat_id: str, metadata=None) -> None:\n                 ]\n                 if len(matching_keys) == 1:\n                     active = self._active_status_threads.pop(matching_keys[0], None)\n+                ambiguous_tracked = len(matching_keys) > 1\n         else:\n             # Metadata-free cleanup is safe only if exactly one status exists\n             # 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:\n             team_id = active.get(\"team_id\", \"\")\n         if metadata:\n             team_id = self._metadata_team_id(metadata) or team_id\n+        if not thread_ts and requested_thread_ts and not ambiguous_tracked:\n+            # No tracked entry (gateway restart, eviction, or a status set\n+            # before this process started) but the caller identified the exact\n+            # thread to clear. Issue the clear anyway so a stuck \"is\n+            # thinking...\" can always be dismissed — clearing an unset status\n+            # is a harmless no-op on Slack's side. Skipped when MULTIPLE\n+            # workspaces track this channel+thread (ambiguous_tracked): a\n+            # team-less clear there could hit the wrong Slack Connect\n+            # workspace. Client routing uses the caller's team when given,\n+            # else the channel→team fallback.\n+            thread_ts = requested_thread_ts\n+            team_id = requested_team_id or team_id\n         if not thread_ts:\n             return\n         try:"]},{"file":"scripts/release.py","hunks":["@@ -50,6 +50,7 @@\n     \"[redacted:email]\": \"mguttmann\",  # PR #63738 salvage (Anthropic setup-token pool auth normalization)\n     \"[redacted:email]\": \"VrtxOmega\",  # PR #43809 salvage (desktop: WSL folder-picker path bridge)\n     \"[redacted:email]\": \"SemonCat\",  # PR #56786 salvage (Slack Agent View manifests and Assistant APIs)\n+    \"KCAYAAI@users.noreply.github.com\": \"KCAYAAI\",  # PR #62248 partial salvage (resume typing after clarify reply)\n     \"[redacted:email]\": \"jakelongvu-bot\",  # PR #36683 partial salvage (approval: honor canonical approvals.timeout in gateway waits)\n     \"luigi@users.noreply.github.com\": \"Tortugasaur\",  # PR #43205 salvage (desktop: profile-aware three-way approval mode statusbar control)\n     \"[redacted:email]\": \"kavioavio\",  # Issue #46544 / PR #47705 evolution (smart DENY exact-operation owner override)"]},{"file":"tests/gateway/test_clarify_active_session_bypass.py","hunks":["@@ -1,7 +1,7 @@\n \"\"\"Regression tests for clarify replies while a gateway session is busy.\"\"\"\n \n import asyncio\n-from unittest.mock import AsyncMock\n+from unittest.mock import AsyncMock, patch\n \n import pytest\n ","@@ -85,3 +85,37 @@ async def test_active_session_routes_typed_choice_clarify_reply_to_runner_not_bu\n     adapter._message_handler.assert_awaited_once_with(event)\n     adapter._busy_session_handler.assert_not_awaited()\n     assert adapter._pending_messages == {}\n+\n+\n[redacted:email]\n+async def test_gateway_clarify_reply_resumes_typing_before_returning_empty_ack():\n+    \"\"\"A clarify answer must re-enable the active run's typing indicator.\n+\n+    Clarify pauses typing while waiting so Slack's Assistant API does not\n+    disable the compose box. The typed answer is intercepted by the gateway\n+    and returns an empty acknowledgment instead of starting a second run; that\n+    interception path must therefore resume the original run's indicator.\n+    \"\"\"\n+    _clear_clarify_state()\n+    from gateway.run import GatewayRunner\n+    from tools import clarify_gateway as cm\n+\n+    adapter = _ClarifyBypassAdapter()\n+    adapter.pause_typing_for_chat(\"12345\")\n+    event = _event(\"the missing details\")\n+\n+    runner = GatewayRunner.__new__(GatewayRunner)\n+    runner._startup_restore_in_progress = False\n+    runner._scale_to_zero_note_real_inbound = lambda: None\n+    runner._is_user_authorized = lambda source: True\n+    runner._session_key_for_source = lambda source: \"clarify-session\"\n+    runner._adapter_for_source = lambda source: adapter\n+    runner._update_prompt_pending = {}\n+\n+    cm.register(\"clarify-2\", \"clarify-session\", \"What is missing?\", None)\n+\n+    with patch(\"hermes_cli.plugins.invoke_hook\", return_value=[]):\n+        result = await runner._handle_message(event)\n+\n+    assert result == \"\"\n+    assert \"12345\" not in adapter._typing_paused"]},{"file":"tests/gateway/test_slack.py","hunks":["@@ -2107,6 +2107,45 @@ async def test_stop_typing_noop_without_tracked_thread(self, adapter):\n \n         adapter._app.client.assistant_threads_setStatus.assert_not_called()\n \n+    @pytest.mark.asyncio\n+    async def test_stop_typing_clears_untracked_thread_from_metadata(self, adapter):\n+        \"\"\"Explicit thread metadata clears a status the map no longer tracks.\n+\n+        A gateway restart (or cache eviction) wipes _active_status_threads\n+        while Slack's persistent assistant status stays visible. A caller\n+        that names the exact thread must still be able to dismiss it (#32295).\n+        \"\"\"\n+        adapter._app.client.assistant_threads_setStatus = AsyncMock()\n+        assert adapter._active_status_threads == {}\n+\n+        await adapter.stop_typing(\"C123\", metadata={\"thread_id\": \"stuck_ts\"})\n+\n+        adapter._app.client.assistant_threads_setStatus.assert_called_once_with(\n+            channel_id=\"C123\",\n+            thread_ts=\"stuck_ts\",\n+            status=\"\",\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_stop_typing_untracked_fallback_respects_ambiguous_workspaces(\n+        self, adapter\n+    ):\n+        \"\"\"Team-less clear must NOT fire when multiple workspaces track the thread.\"\"\"\n+        team_one, team_two = AsyncMock(), AsyncMock()\n+        adapter._team_clients.update({\"T_ONE\": team_one, \"T_TWO\": team_two})\n+        for team_id in (\"T_ONE\", \"T_TWO\"):\n+            await adapter.send_typing(\n+                \"D_SHARED\",\n+                metadata={\"thread_id\": \"171.000\", \"slack_team_id\": team_id},\n+            )\n+        adapter._app.client.assistant_threads_setStatus = AsyncMock()\n+\n+        await adapter.stop_typing(\"D_SHARED\", metadata={\"thread_id\": \"171.000\"})\n+\n+        adapter._app.client.assistant_threads_setStatus.assert_not_called()\n+        assert (\"T_ONE\", \"D_SHARED\", \"171.000\") in adapter._active_status_threads\n+        assert (\"T_TWO\", \"D_SHARED\", \"171.000\") in adapter._active_status_threads\n+\n     @pytest.mark.asyncio\n     async def test_stop_typing_handles_api_error_gracefully(self, adapter):\n         adapter._active_status_threads[(\"\", \"C123\", \"parent_ts\")] = {"]},{"file":"tests/gateway/test_stop_thread_sibling.py","hunks":["@@ -156,3 +156,67 @@ async def _fake_interrupt(session_key, source, *, interrupt_reason, invalidation\n \n     assert interrupted == []\n     assert \"no active\" in str(getattr(result, \"text\", result)).lower()\n+\n+\n+# ---------------------------------------------------------------------------\n+# /stop with no active agent still clears a stuck platform status (#32295)\n+# ---------------------------------------------------------------------------\n+\n+\n+class _FakeStatusAdapter:\n+    def __init__(self):\n+        self.cleared = []\n+\n+    async def _stop_typing_with_metadata(self, chat_id, metadata=None):\n+        self.cleared.append((chat_id, metadata))\n+\n+\n[redacted:email]\n+async def test_stop_no_active_agent_clears_stuck_status():\n+    runner = object.__new__(GatewayRunner)\n+    runner._running_agents = {}\n+    key = _per_user_key(\"userA\")\n+    runner.session_store = _FakeStore(key)\n+    runner._is_user_authorized = lambda source: True\n+\n+    adapter = _FakeStatusAdapter()\n+    runner.adapters = {Platform.DISCORD: adapter}\n+    runner._thread_metadata_for_source = (\n+        lambda source, reply_to_message_id=None: {\"thread_id\": source.thread_id}\n+    )\n+    runner._reply_anchor_for_event = lambda event: None\n+\n+    event = MessageEvent(\n+        text=\"/stop\", message_type=MessageType.TEXT, source=_thread_source(\"userA\")\n+    )\n+    result = await runner._handle_stop_command(event)\n+\n+    assert \"no active\" in str(getattr(result, \"text\", result)).lower()\n+    assert adapter.cleared == [(\"chan1\", {\"thread_id\": \"thr1\"})]\n+\n+\n[redacted:email]\n+async def test_stop_no_active_agent_survives_status_clear_failure():\n+    \"\"\"A failing adapter clear must not break the /stop reply.\"\"\"\n+    runner = object.__new__(GatewayRunner)\n+    runner._running_agents = {}\n+    key = _per_user_key(\"userA\")\n+    runner.session_store = _FakeStore(key)\n+    runner._is_user_authorized = lambda source: True\n+\n+    class _BoomAdapter:\n+        async def _stop_typing_with_metadata(self, chat_id, metadata=None):\n+            raise RuntimeError(\"boom\")\n+\n+    runner.adapters = {Platform.DISCORD: _BoomAdapter()}\n+    runner._thread_metadata_for_source = (\n+        lambda source, reply_to_message_id=None: None\n+    )\n+    runner._reply_anchor_for_event = lambda event: None\n+\n+    event = MessageEvent(\n+        text=\"/stop\", message_type=MessageType.TEXT, source=_thread_source(\"userA\")\n+    )\n+    result = await runner._handle_stop_command(event)\n+\n+    assert \"no active\" in str(getattr(result, \"text\", result)).lower()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}