diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 14a7bc336972..d595d7033b7b 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -63,9 +63,17 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None) ``direct_messages_topic_id`` when the Bot API supports it. """ thread_id = getattr(source, "thread_id", None) - if thread_id is None: + metadata = {"thread_id": thread_id} if thread_id is not None else {} + # Slack workspace identity is durable routing state, not ephemeral event + # metadata. Carry it on every outbound path (including unthreaded sends) + # so a multi-workspace Socket Mode gateway never falls back to its primary + # WebClient after an async, stream, or recovery boundary. + if _platform_name(getattr(source, "platform", None)) == "slack": + scope_id = getattr(source, "scope_id", None) + if scope_id: + metadata["slack_team_id"] = str(scope_id) + if not metadata: return None - metadata = {"thread_id": thread_id} if _platform_name(getattr(source, "platform", None)) == "telegram" and getattr(source, "chat_type", None) == "dm": metadata["telegram_dm_topic_reply_fallback"] = True tid = str(thread_id) @@ -3202,6 +3210,30 @@ class BasePlatformAdapter(ABC): """ pass + async def _stop_typing_with_metadata(self, chat_id: str, metadata=None) -> None: + """Stop typing while preserving platform-specific routing metadata. + + Most adapters key typing state by chat and retain the historical + ``stop_typing(chat_id)`` signature. Slack AI status is per thread and + workspace, however, so losing metadata can clear a sibling thread or + leave the current one active. Introspect at this shared chokepoint so + existing adapters remain source-compatible. + """ + if metadata: + try: + params = inspect.signature(self.stop_typing).parameters + accepts_metadata = "metadata" in params or any( + param.kind is inspect.Parameter.VAR_KEYWORD + for param in params.values() + ) + except (TypeError, ValueError): + accepts_metadata = False + if accepts_metadata: + stop_typing = getattr(self, "stop_typing") + await stop_typing(chat_id, metadata=metadata) + return + await self.stop_typing(chat_id) + async def send_multiple_images( self, chat_id: str, @@ -3879,7 +3911,7 @@ class BasePlatformAdapter(ABC): # Cancelling _keep_typing alone won't clean that up. if hasattr(self, "stop_typing"): try: - await self.stop_typing(chat_id) + await self._stop_typing_with_metadata(chat_id, metadata) except Exception: pass self._typing_paused.discard(chat_id) @@ -3889,6 +3921,7 @@ class BasePlatformAdapter(ABC): chat_id: str, typing_task: asyncio.Task | None = None, *, + metadata=None, timeout: float = 0.5, stop_attempts: int = 2, ) -> None: @@ -3908,7 +3941,7 @@ class BasePlatformAdapter(ABC): attempts = max(1, stop_attempts) for attempt in range(attempts): try: - await self.stop_typing(chat_id) + await self._stop_typing_with_metadata(chat_id, metadata) except Exception: pass if attempt < attempts - 1: @@ -3928,14 +3961,14 @@ class BasePlatformAdapter(ABC): """Resume typing indicator for a chat after approval resolves.""" self._typing_paused.discard(chat_id) - async def interrupt_session_activity(self, session_key: str, chat_id: str) -> None: + async def interrupt_session_activity(self, session_key: str, chat_id: str, metadata=None) -> None: """Signal the active session loop to stop and clear typing immediately.""" if session_key: interrupt_event = self._active_sessions.get(session_key) if interrupt_event is not None: interrupt_event.set() try: - await self.stop_typing(chat_id) + await self._stop_typing_with_metadata(chat_id, metadata) except Exception: pass @@ -4873,6 +4906,7 @@ class BasePlatformAdapter(ABC): await self._stop_typing_refresh( event.source.chat_id, typing_task, + metadata=_thread_metadata, ) try: @@ -5296,6 +5330,7 @@ class BasePlatformAdapter(ABC): await self._stop_typing_refresh( event.source.chat_id, None, + metadata=_thread_metadata, stop_attempts=1, ) # Final drain/release boundary: force-flush any timer that missed @@ -5464,6 +5499,7 @@ class BasePlatformAdapter(ABC): user_id_alt: Optional[str] = None, chat_id_alt: Optional[str] = None, is_bot: bool = False, + scope_id: Optional[str] = None, guild_id: Optional[str] = None, parent_chat_id: Optional[str] = None, message_id: Optional[str] = None, @@ -5487,6 +5523,7 @@ class BasePlatformAdapter(ABC): user_id_alt=user_id_alt, chat_id_alt=chat_id_alt, is_bot=is_bot, + scope_id=str(scope_id) if scope_id else None, guild_id=str(guild_id) if guild_id else None, parent_chat_id=str(parent_chat_id) if parent_chat_id else None, message_id=str(message_id) if message_id else None, diff --git a/gateway/run.py b/gateway/run.py index 3bea0eb4034b..f584089ebffd 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11756,10 +11756,23 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew persist_user_timestamp=persist_user_timestamp, ) - # Stop persistent typing indicator now that the agent is done + # Stop persistent typing indicator now that the agent is done. + # Slack AI status is scoped to a thread/workspace, so preserve the + # same routing metadata used by the response delivery path. try: _typing_adapter = self._adapter_for_source(source) - if _typing_adapter and hasattr(_typing_adapter, "stop_typing"): + _stop_with_metadata = getattr( + type(_typing_adapter), "_stop_typing_with_metadata", None + ) + _stop_typing = getattr(type(_typing_adapter), "stop_typing", None) + if _typing_adapter and callable(_stop_with_metadata): + await _typing_adapter._stop_typing_with_metadata( + source.chat_id, + self._thread_metadata_for_source( + source, self._reply_anchor_for_event(event) + ), + ) + elif _typing_adapter and callable(_stop_typing): await _typing_adapter.stop_typing(source.chat_id) except Exception: pass @@ -12305,10 +12318,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew return response except Exception as e: - # Stop typing indicator on error too + # Stop typing indicator on error too, retaining Slack thread/workspace + # routing so a failed turn cannot leave its status visible. try: _err_adapter = self._adapter_for_source(source) - if _err_adapter and hasattr(_err_adapter, "stop_typing"): + _stop_with_metadata = getattr( + type(_err_adapter), "_stop_typing_with_metadata", None + ) + _stop_typing = getattr(type(_err_adapter), "stop_typing", None) + if _err_adapter and callable(_stop_with_metadata): + await _err_adapter._stop_typing_with_metadata( + source.chat_id, + self._thread_metadata_for_source( + source, self._reply_anchor_for_event(event) + ), + ) + elif _err_adapter and callable(_stop_typing): await _err_adapter.stop_typing(source.chat_id) except Exception: pass @@ -14469,13 +14494,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew reply_to_message_id: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Build the metadata dict platforms need for thread-aware replies.""" - return self._thread_metadata_for_target( + metadata = self._thread_metadata_for_target( getattr(source, "platform", None), getattr(source, "chat_id", None), getattr(source, "thread_id", None), chat_type=getattr(source, "chat_type", None), reply_to_message_id=reply_to_message_id or getattr(source, "message_id", None), ) + if getattr(source, "platform", None) == Platform.SLACK: + team_id = getattr(source, "scope_id", None) + if team_id: + metadata = dict(metadata or {}) + metadata["slack_team_id"] = str(team_id) + return metadata def _thread_metadata_for_target( self, @@ -16373,8 +16404,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew running_agent.interrupt(interrupt_reason) self._invalidate_session_run_generation(session_key, reason=invalidation_reason) adapter = self._adapter_for_source(source) - if adapter and hasattr(adapter, "interrupt_session_activity"): - await adapter.interrupt_session_activity(session_key, source.chat_id) + interrupt_session_activity = getattr( + type(adapter), "interrupt_session_activity", None + ) + if adapter and callable(interrupt_session_activity): + metadata = self._thread_metadata_for_source(source) + try: + params = inspect.signature(interrupt_session_activity).parameters + accepts_metadata = "metadata" in params or any( + param.kind is inspect.Parameter.VAR_KEYWORD + for param in params.values() + ) + except (TypeError, ValueError): + accepts_metadata = False + if accepts_metadata: + await adapter.interrupt_session_activity( + session_key, source.chat_id, metadata=metadata + ) + else: + await adapter.interrupt_session_activity(session_key, source.chat_id) if adapter and hasattr(adapter, "get_pending_message"): adapter.get_pending_message(session_key) # consume and discard self._pending_messages.pop(session_key, None) diff --git a/hermes_cli/slack_cli.py b/hermes_cli/slack_cli.py index 681303c089ed..a517da56c5c9 100644 --- a/hermes_cli/slack_cli.py +++ b/hermes_cli/slack_cli.py @@ -113,7 +113,10 @@ def _build_full_manifest( "agent_description": "Chat with Hermes in Slack Messages.", } bot_scopes.append("assistant:write") - bot_events.append("app_home_opened") + # Slack includes current viewing context in Agent DM events only after + # this subscription is enabled; the adapter consumes that context to + # preserve the referred channel across the agent turn. + bot_events.extend(["app_context_changed", "app_home_opened"]) bot_scopes.sort() bot_events.sort() diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index cb5a6897e494..9b55888be227 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -466,6 +466,12 @@ class SlackAdapter(BasePlatformAdapter): # session + memory scoping. self._assistant_threads: Dict[Tuple[str, str], Dict[str, str]] = {} self._ASSISTANT_THREADS_MAX = 5000 + # Agent-view context is per workspace/user (not global): a context + # change for one person's Slack split view must never appear in another + # person's prompt. Slack also includes this in later DM events, but the + # cache bridges lifecycle and message delivery ordering. + self._agent_view_contexts: Dict[Tuple[str, str], Dict[str, str]] = {} + self._AGENT_VIEW_CONTEXTS_MAX = 5000 # Cache for _fetch_thread_context results: cache_key → _ThreadContextCache self._thread_context_cache: Dict[str, _ThreadContextCache] = {} self._THREAD_CACHE_TTL = 60.0 @@ -1091,8 +1097,8 @@ class SlackAdapter(BasePlatformAdapter): # Register message event handler @self._app.event("message") - async def handle_message_event(event, say): - await self._handle_slack_message(event) + async def handle_message_event(event, say, body): + await self._handle_slack_message(event, body) # Handle app_mention explicitly. In some Slack app configurations, # channel mentions arrive only as app_mention events rather than the @@ -1102,19 +1108,23 @@ class SlackAdapter(BasePlatformAdapter): # @mention, they share the same event ts — the dedup in # _handle_slack_message (MessageDeduplicator) suppresses the second. @self._app.event("app_mention") - async def handle_app_mention(event, say): - await self._handle_slack_message(event) + async def handle_app_mention(event, say, body): + await self._handle_slack_message(event, body) @self._app.event("app_home_opened") - async def handle_app_home_opened(event, say): - await self._handle_app_home_opened(event) + async def handle_app_home_opened(event, say, body): + await self._handle_app_home_opened(event, body) + + @self._app.event("app_context_changed") + async def handle_app_context_changed(event, say, body): + await self._handle_app_context_changed(event, body) # File lifecycle events can arrive around snippet uploads even when # the actual user message is what we care about. Ack them so Slack # doesn't log noisy 404 "unhandled request" warnings. @self._app.event("file_shared") - async def handle_file_shared(event, say): - await self._handle_slack_file_shared(event) + async def handle_file_shared(event, say, body): + await self._handle_slack_file_shared(event, body) @self._app.event("file_created") async def handle_file_created(event, say): @@ -1137,12 +1147,12 @@ class SlackAdapter(BasePlatformAdapter): pass @self._app.event("assistant_thread_started") - async def handle_assistant_thread_started(event, say): - await self._handle_assistant_thread_lifecycle_event(event) + async def handle_assistant_thread_started(event, say, body): + await self._handle_assistant_thread_lifecycle_event(event, body) @self._app.event("assistant_thread_context_changed") - async def handle_assistant_thread_context_changed(event, say): - await self._handle_assistant_thread_lifecycle_event(event) + async def handle_assistant_thread_context_changed(event, say, body): + await self._handle_assistant_thread_lifecycle_event(event, body) # Register slash command handler(s) # @@ -1763,7 +1773,9 @@ class SlackAdapter(BasePlatformAdapter): last_exc = None for attempt in range(3): try: - result = await self._get_client(chat_id).files_upload_v2( + result = await self._get_client( + chat_id, team_id=self._metadata_team_id(metadata) + ).files_upload_v2( channel=chat_id, file=file_path, filename=os.path.basename(file_path), @@ -1890,7 +1902,9 @@ class SlackAdapter(BasePlatformAdapter): chunk_idx + 1, len(chunks), ) - result = await self._get_client(chat_id).files_upload_v2( + result = await self._get_client( + chat_id, team_id=self._metadata_team_id(metadata) + ).files_upload_v2( channel=chat_id, file_uploads=file_uploads, initial_comment=initial_comment, @@ -2134,12 +2148,14 @@ class SlackAdapter(BasePlatformAdapter): # ----- Reactions ----- - async def _add_reaction(self, channel: str, timestamp: str, emoji: str) -> bool: + async def _add_reaction( + self, channel: str, timestamp: str, emoji: str, team_id: str = "" + ) -> bool: """Add an emoji reaction to a message. Returns True on success.""" if not self._app: return False try: - await self._get_client(channel).reactions_add( + await self._get_client(channel, team_id=team_id or None).reactions_add( channel=channel, timestamp=timestamp, name=emoji ) return True @@ -2148,12 +2164,14 @@ class SlackAdapter(BasePlatformAdapter): logger.debug("[Slack] reactions.add failed (%s): %s", emoji, e) return False - async def _remove_reaction(self, channel: str, timestamp: str, emoji: str) -> bool: + async def _remove_reaction( + self, channel: str, timestamp: str, emoji: str, team_id: str = "" + ) -> bool: """Remove an emoji reaction from a message. Returns True on success.""" if not self._app: return False try: - await self._get_client(channel).reactions_remove( + await self._get_client(channel, team_id=team_id or None).reactions_remove( channel=channel, timestamp=timestamp, name=emoji ) return True @@ -2174,7 +2192,9 @@ class SlackAdapter(BasePlatformAdapter): return channel_id = getattr(event.source, "chat_id", None) if channel_id: - await self._add_reaction(channel_id, ts, "eyes") + await self._add_reaction( + channel_id, ts, "eyes", str(getattr(event.source, "scope_id", "") or "") + ) async def on_processing_complete( self, event: MessageEvent, outcome: ProcessingOutcome @@ -2189,11 +2209,12 @@ class SlackAdapter(BasePlatformAdapter): channel_id = getattr(event.source, "chat_id", None) if not channel_id: return - await self._remove_reaction(channel_id, ts, "eyes") + team_id = str(getattr(event.source, "scope_id", "") or "") + await self._remove_reaction(channel_id, ts, "eyes", team_id) if outcome == ProcessingOutcome.SUCCESS: - await self._add_reaction(channel_id, ts, "white_check_mark") + await self._add_reaction(channel_id, ts, "white_check_mark", team_id) elif outcome == ProcessingOutcome.FAILURE: - await self._add_reaction(channel_id, ts, "x") + await self._add_reaction(channel_id, ts, "x", team_id) # ----- User identity resolution ----- @@ -2298,7 +2319,9 @@ class SlackAdapter(BasePlatformAdapter): response.raise_for_status() thread_ts = self._resolve_thread_ts(reply_to, metadata) - result = await self._get_client(chat_id).files_upload_v2( + result = await self._get_client( + chat_id, team_id=self._metadata_team_id(metadata) + ).files_upload_v2( channel=chat_id, content=response.content, filename="image.png", @@ -2374,7 +2397,9 @@ class SlackAdapter(BasePlatformAdapter): last_exc = None for attempt in range(3): try: - result = await self._get_client(chat_id).files_upload_v2( + result = await self._get_client( + chat_id, team_id=self._metadata_team_id(metadata) + ).files_upload_v2( channel=chat_id, file=video_path, filename=os.path.basename(video_path), @@ -2434,7 +2459,9 @@ class SlackAdapter(BasePlatformAdapter): last_exc = None for attempt in range(3): try: - result = await self._get_client(chat_id).files_upload_v2( + result = await self._get_client( + chat_id, team_id=self._metadata_team_id(metadata) + ).files_upload_v2( channel=chat_id, file=file_path, filename=display_name, @@ -2506,10 +2533,105 @@ class SlackAdapter(BasePlatformAdapter): return None return (str(channel_id), str(thread_ts)) - def _extract_assistant_thread_metadata(self, event: dict) -> Dict[str, str]: + @staticmethod + def _agent_view_context_key(team_id: str, user_id: str) -> Optional[Tuple[str, str]]: + """Return a per-workspace, per-user Agent-view context cache key.""" + if not team_id or not user_id: + return None + return (str(team_id), str(user_id)) + + def _cache_agent_view_context(self, metadata: Dict[str, str]) -> None: + """Remember a user's current Slack Agent-view context.""" + key = self._agent_view_context_key( + metadata.get("team_id", ""), metadata.get("user_id", "") + ) + if not key: + return + contexts = getattr(self, "_agent_view_contexts", None) + if not isinstance(contexts, dict): + contexts = {} + self._agent_view_contexts = contexts + contexts[key] = { + field: value + for field, value in metadata.items() + if field in {"channel_id", "context_channel_id", "team_id", "user_id"} + and value + } + max_contexts = getattr(self, "_AGENT_VIEW_CONTEXTS_MAX", 5000) + if len(contexts) > max_contexts: + excess = len(contexts) - max_contexts // 2 + for old_key in list(contexts)[:excess]: + del contexts[old_key] + + def _agent_view_context_for_event( + self, event: dict, team_id: str, user_id: str + ) -> Dict[str, str]: + """Read Slack's inline Agent context, falling back to lifecycle state.""" + context = event.get("app_context") or event.get("context") or {} + context_channel_id = self._context_channel_id(context) + key = self._agent_view_context_key(team_id, user_id) + contexts = getattr(self, "_agent_view_contexts", {}) + cached = contexts.get(key, {}) if isinstance(contexts, dict) and key else {} + return { + "context_channel_id": context_channel_id or cached.get("context_channel_id", ""), + "team_id": team_id, + "user_id": user_id, + } + + @staticmethod + def _event_team_id(event: dict, body: Optional[dict] = None) -> str: + """Resolve a workspace ID from an event plus Bolt's outer payload. + + Bolt injects only the inner ``event`` into an event listener, while + Slack places ``team_id`` on the outer Events API payload. Reading both + keeps multi-workspace client routing correct after a process boundary. + """ + for payload in (event, body or {}): + if not isinstance(payload, dict): + continue + team = payload.get("team_id") or payload.get("team") + if isinstance(team, str) and team: + return team + if isinstance(team, dict) and team.get("id"): + return str(team["id"]) + authorizations = (body or {}).get("authorizations") if isinstance(body, dict) else None + for authorization in authorizations or []: + if isinstance(authorization, dict) and authorization.get("team_id"): + return str(authorization["team_id"]) + return "" + + @staticmethod + def _context_channel_id(context: Any) -> str: + """Extract the actively viewed channel from either Slack context shape.""" + if not isinstance(context, dict): + return "" + channel_id = context.get("channel_id") + if channel_id: + return str(channel_id) + for entity in context.get("entities") or []: + if not isinstance(entity, dict): + continue + value = entity.get("value") + if isinstance(value, dict) and value.get("channel_id"): + return str(value["channel_id"]) + if ( + isinstance(value, str) + and str(entity.get("type") or "").endswith("channel_id") + ): + return value + return "" + + def _extract_assistant_thread_metadata( + self, event: dict, body: Optional[dict] = None + ) -> Dict[str, str]: """Extract Slack Assistant thread identity data from an event payload.""" assistant_thread = event.get("assistant_thread") or {} - context = assistant_thread.get("context") or event.get("context") or {} + context = ( + assistant_thread.get("context") + or event.get("app_context") + or event.get("context") + or {} + ) channel_id = ( assistant_thread.get("channel_id") @@ -2529,13 +2651,10 @@ class SlackAdapter(BasePlatformAdapter): or context.get("user_id") or "" ) - team_id = ( - event.get("team") - or event.get("team_id") - or assistant_thread.get("team_id") - or "" + team_id = self._event_team_id(event, body) or str( + assistant_thread.get("team_id") or "" ) - context_channel_id = context.get("channel_id") or "" + context_channel_id = self._context_channel_id(context) return { "channel_id": str(channel_id) if channel_id else "", @@ -2728,6 +2847,7 @@ class SlackAdapter(BasePlatformAdapter): user_id=user_id, thread_id=thread_ts, chat_topic=metadata.get("context_channel_id") or None, + scope_id=metadata.get("team_id") or None, ) try: @@ -2762,6 +2882,8 @@ class SlackAdapter(BasePlatformAdapter): chat_name=channel_id, chat_type="dm", user_id=user_id, + chat_topic=metadata.get("context_channel_id") or None, + scope_id=metadata.get("team_id") or None, ) try: @@ -2773,9 +2895,11 @@ class SlackAdapter(BasePlatformAdapter): exc_info=True, ) - async def _handle_assistant_thread_lifecycle_event(self, event: dict) -> None: + async def _handle_assistant_thread_lifecycle_event( + self, event: dict, body: Optional[dict] = None + ) -> None: """Handle Slack Assistant lifecycle events that carry user/thread identity.""" - metadata = self._extract_assistant_thread_metadata(event) + metadata = self._extract_assistant_thread_metadata(event, body) self._cache_assistant_thread_metadata(metadata) self._seed_assistant_thread_session(metadata) await self._set_assistant_suggested_prompts( @@ -2784,31 +2908,59 @@ class SlackAdapter(BasePlatformAdapter): thread_ts=metadata.get("thread_ts", ""), ) - async def _handle_app_home_opened(self, event: dict) -> None: - """Handle Slack Agent DM-open lifecycle events without producing replies.""" - if event.get("tab") != "messages": - return - - channel_id = event.get("channel") or event.get("channel_id") or "" + async def _handle_app_context_changed( + self, event: dict, body: Optional[dict] = None + ) -> None: + """Cache the current Agent-view context without entering the agent loop.""" + context = event.get("context") or event.get("app_context") or {} + context_channel_id = self._context_channel_id(context) user_id = event.get("user") or event.get("user_id") or "" - team_id = event.get("team") or event.get("team_id") or "" - - if team_id and channel_id: - self._channel_team[str(channel_id)] = str(team_id) - - self._seed_agent_dm_session( + team_id = self._event_team_id(event, body) + # ``context_channel_id`` is a channel the user is viewing, not the DM + # Hermes owns. Do not write it into _channel_team: channel IDs can be + # shared across Slack Connect workspaces, so doing so can misroute a + # later unrelated send. Workspace ownership is recorded from actual + # inbound DM/channel events below. + self._cache_agent_view_context( { - "channel_id": str(channel_id) if channel_id else "", + "context_channel_id": str(context_channel_id) if context_channel_id else "", "user_id": str(user_id) if user_id else "", "team_id": str(team_id) if team_id else "", } ) + + async def _handle_app_home_opened( + self, event: dict, body: Optional[dict] = None + ) -> None: + """Handle Slack Agent DM-open lifecycle events without producing replies.""" + if event.get("tab") != "messages": + return + + context = event.get("context") or event.get("app_context") or {} + channel_id = event.get("channel") or event.get("channel_id") or "" + user_id = event.get("user") or event.get("user_id") or "" + team_id = self._event_team_id(event, body) + context_channel_id = self._context_channel_id(context) + + if team_id and channel_id: + self._channel_team[str(channel_id)] = str(team_id) + + metadata = { + "channel_id": str(channel_id) if channel_id else "", + "user_id": str(user_id) if user_id else "", + "team_id": str(team_id) if team_id else "", + "context_channel_id": context_channel_id, + } + self._cache_agent_view_context(metadata) + self._seed_agent_dm_session(metadata) await self._set_assistant_suggested_prompts( - str(channel_id) if channel_id else "", - team_id=str(team_id) if team_id else "", + metadata["channel_id"], + team_id=metadata["team_id"], ) - async def _handle_slack_file_shared(self, event: dict) -> None: + async def _handle_slack_file_shared( + self, event: dict, body: Optional[dict] = None + ) -> None: """Fallback for Slack file shares that do not arrive as message.files. Slack documents ``file_shared`` as a file-ID-only event; callers must @@ -2822,7 +2974,7 @@ class SlackAdapter(BasePlatformAdapter): if not channel_id or not file_id: return - team_id = event.get("team_id") or event.get("team") or "" + team_id = self._event_team_id(event, body) try: client = self._team_clients.get(team_id) if team_id else None info_resp = await (client or self._get_client(channel_id)).files_info( @@ -2886,7 +3038,9 @@ class SlackAdapter(BasePlatformAdapter): fallback_event["thread_ts"] = thread_ts await self._handle_slack_message(fallback_event) - async def _handle_slack_message(self, event: dict) -> None: + async def _handle_slack_message( + self, event: dict, payload: Optional[dict] = None + ) -> None: """Handle an incoming Slack message event.""" # Dedup: Slack Socket Mode can redeliver events after reconnects (#4777) event_ts = event.get("ts", "") @@ -3046,10 +3200,9 @@ class SlackAdapter(BasePlatformAdapter): user_id = event.get("user") or assistant_meta.get("user_id", "") if not channel_id: channel_id = assistant_meta.get("channel_id", "") - team_id = ( - event.get("team") - or event.get("team_id") - or assistant_meta.get("team_id", "") + team_id = self._event_team_id(event, payload) or assistant_meta.get("team_id", "") + agent_context = self._agent_view_context_for_event( + event, str(team_id or ""), str(user_id or "") ) # Track which workspace owns this channel @@ -3159,6 +3312,7 @@ class SlackAdapter(BasePlatformAdapter): channel_id=channel_id, thread_ts=event_thread_ts, user_id=user_id, + team_id=team_id, ) if ( not reply_to_bot_thread @@ -3189,6 +3343,7 @@ class SlackAdapter(BasePlatformAdapter): channel_id=channel_id, thread_ts=event_thread_ts, user_id=user_id, + team_id=team_id, ): thread_context = await self._fetch_thread_context( channel_id=channel_id, @@ -3220,9 +3375,9 @@ class SlackAdapter(BasePlatformAdapter): if not file_id: continue try: - info_resp = await self._get_client(channel_id).files_info( - file=file_id - ) + info_resp = await self._get_client( + channel_id, team_id=team_id + ).files_info(file=file_id) if info_resp.get("ok"): f = info_resp["file"] else: @@ -3479,6 +3634,7 @@ class SlackAdapter(BasePlatformAdapter): user_id=user_id, user_name=user_name, thread_id=thread_ts, + scope_id=str(team_id) if team_id else None, # Slack Workflow Builder / app posts arrive as # subtype=bot_message with user=None; flag them so the # gateway SLACK_ALLOW_BOTS bypass can authorize them @@ -3549,6 +3705,18 @@ class SlackAdapter(BasePlatformAdapter): if _should_react: self._reacting_message_ids.add(ts) + # App-context is per-turn, user-controlled Slack UI state. Surface it + # with the inbound user message rather than storing it on SessionSource: + # putting it in the cached system prompt would rebuild the agent whenever + # the user switches views and would let stale context bleed into later + # turns. The agent receives an inert label, never a fetched channel body. + context_channel_id = agent_context.get("context_channel_id", "") + if context_channel_id and context_channel_id != channel_id: + msg_event.text = ( + f"[Slack app context: user is viewing channel {context_channel_id}]\n\n" + f"{msg_event.text}" + ) + await self.handle_message(msg_event) # ----- Approval button support (Block Kit) ----- @@ -3637,7 +3805,9 @@ class SlackAdapter(BasePlatformAdapter): if thread_ts: kwargs["thread_ts"] = thread_ts - result = await self._get_client(chat_id).chat_postMessage(**kwargs) + result = await self._get_client( + chat_id, team_id=self._metadata_team_id(metadata) + ).chat_postMessage(**kwargs) msg_ts = result.get("ts", "") if msg_ts: self._approval_resolved[msg_ts] = False @@ -3715,7 +3885,9 @@ class SlackAdapter(BasePlatformAdapter): if thread_ts: kwargs["thread_ts"] = thread_ts - result = await self._get_client(chat_id).chat_postMessage(**kwargs) + result = await self._get_client( + chat_id, team_id=self._metadata_team_id(metadata) + ).chat_postMessage(**kwargs) return SendResult( success=True, message_id=result.get("ts", ""), raw_response=result ) @@ -3729,6 +3901,7 @@ class SlackAdapter(BasePlatformAdapter): *, channel_id: str = "", user_name: Optional[str] = None, + team_id: str = "", ) -> bool: """Return whether a Slack interactive caller may perform gated actions.""" normalized_user_id = str(user_id or "").strip() @@ -3747,6 +3920,7 @@ class SlackAdapter(BasePlatformAdapter): chat_type="dm" if str(channel_id or "").startswith("D") else "group", user_id=normalized_user_id, user_name=str(user_name).strip() if user_name else None, + scope_id=str(team_id) if team_id else None, ) return bool(auth_fn(source)) except Exception: @@ -3776,6 +3950,7 @@ class SlackAdapter(BasePlatformAdapter): """Handle a slash-confirm button click from Block Kit.""" await ack() + team_id = self._event_team_id({}, body) action_id = action.get("action_id", "") value = action.get("value", "") message = body.get("message", {}) @@ -3787,6 +3962,7 @@ class SlackAdapter(BasePlatformAdapter): user_id, channel_id=channel_id, user_name=user_name, + team_id=team_id, ): logger.warning( "[Slack] Unauthorized slash-confirm click by %s (%s) - ignoring", @@ -3851,7 +4027,7 @@ class SlackAdapter(BasePlatformAdapter): ] try: - await self._get_client(channel_id).chat_update( + await self._get_client(channel_id, team_id=team_id or None).chat_update( channel=channel_id, ts=msg_ts, text=decision_text, @@ -3876,7 +4052,9 @@ class SlackAdapter(BasePlatformAdapter): thread_ts = message.get("thread_ts") or msg_ts if thread_ts: post_kwargs["thread_ts"] = thread_ts - await self._get_client(channel_id).chat_postMessage(**post_kwargs) + await self._get_client( + channel_id, team_id=team_id or None + ).chat_postMessage(**post_kwargs) logger.info( "Slack button resolved slash-confirm for session %s (choice=%s, user=%s)", session_key, @@ -3910,6 +4088,7 @@ class SlackAdapter(BasePlatformAdapter): """Handle an approval button click from Block Kit.""" await ack() + team_id = self._event_team_id({}, body) action_id = action.get("action_id", "") session_key = action.get("value", "") message = body.get("message", {}) @@ -3922,6 +4101,7 @@ class SlackAdapter(BasePlatformAdapter): user_id, channel_id=channel_id, user_name=user_name, + team_id=team_id, ): logger.warning( "[Slack] Unauthorized approval click by %s (%s) - ignoring", @@ -3989,7 +4169,7 @@ class SlackAdapter(BasePlatformAdapter): ] try: - await self._get_client(channel_id).chat_update( + await self._get_client(channel_id, team_id=team_id or None).chat_update( channel=channel_id, ts=msg_ts, text=decision_text, @@ -4049,7 +4229,7 @@ class SlackAdapter(BasePlatformAdapter): return cached.content try: - client = self._get_client(channel_id) + client = self._get_client(channel_id, team_id=team_id) # Retry with exponential backoff for Tier-3 rate limits (429). result = None @@ -4210,7 +4390,7 @@ class SlackAdapter(BasePlatformAdapter): return cached.parent_text try: - client = self._get_client(channel_id) + client = self._get_client(channel_id, team_id=team_id) result = await client.conversations_replies( channel=channel_id, ts=thread_ts, @@ -4293,6 +4473,7 @@ class SlackAdapter(BasePlatformAdapter): chat_id=channel_id, chat_type="dm" if is_dm else "group", user_id=user_id, + scope_id=team_id or None, ) event = MessageEvent( @@ -4330,6 +4511,7 @@ class SlackAdapter(BasePlatformAdapter): channel_id: str, thread_ts: str, user_id: str, + team_id: str = "", ) -> bool: """Check if there's an active session for a thread. @@ -4354,6 +4536,7 @@ class SlackAdapter(BasePlatformAdapter): chat_type="group", user_id=user_id, thread_id=thread_ts, + scope_id=team_id or None, ) # Read session isolation settings from the store's config diff --git a/scripts/release.py b/scripts/release.py index 556b0ee7bf59..1daa8093829f 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -49,6 +49,7 @@ AUTHOR_MAP = { "neo@neodeMac-mini.local": "neo-claw-bot", # PR #58465 salvage (moa: drop empty user turns from advisory view) "m.guttmann@journaway.com": "mguttmann", # PR #63738 salvage (Anthropic setup-token pool auth normalization) "VrtxOmega@pm.me": "VrtxOmega", # PR #43809 salvage (desktop: WSL folder-picker path bridge) + "gn00742754@gmail.com": "SemonCat", # PR #56786 salvage (Slack Agent View manifests and Assistant APIs) "jake.long.vu@vucar.net": "jakelongvu-bot", # PR #36683 partial salvage (approval: honor canonical approvals.timeout in gateway waits) "luigi@users.noreply.github.com": "Tortugasaur", # PR #43205 salvage (desktop: profile-aware three-way approval mode statusbar control) "kavi@local.hermes": "kavioavio", # Issue #46544 / PR #47705 evolution (smart DENY exact-operation owner override) diff --git a/tests/gateway/test_base_topic_sessions.py b/tests/gateway/test_base_topic_sessions.py index 4de540b49d1d..03934d69d629 100644 --- a/tests/gateway/test_base_topic_sessions.py +++ b/tests/gateway/test_base_topic_sessions.py @@ -41,6 +41,9 @@ class DummyTelegramAdapter(BasePlatformAdapter): self.typing.append({"chat_id": chat_id, "metadata": metadata}) return None + async def stop_typing(self, chat_id: str, metadata=None) -> None: + self.typing.append({"chat_id": chat_id, "stopped": True, "metadata": metadata}) + async def get_chat_info(self, chat_id: str): return {"id": chat_id} @@ -143,6 +146,11 @@ class TestBasePlatformTopicSessions: "metadata": {"thread_id": "17585"}, } ] + assert { + "chat_id": "-1001", + "stopped": True, + "metadata": {"thread_id": "17585"}, + } in adapter.typing assert adapter.processing_hooks == [ ("start", "1"), ("complete", "1", ProcessingOutcome.SUCCESS), diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 94ef40ebfe6e..0c934074c250 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -17,6 +17,7 @@ from unittest.mock import AsyncMock, MagicMock, patch, call import pytest from gateway.config import Platform, PlatformConfig +from gateway.run import GatewayRunner from gateway.platforms.base import ( MessageEvent, MessageType, @@ -148,6 +149,7 @@ class TestSlashCommandSessionIsolation: assert event.source.chat_type == "group" assert event.source.chat_id == "C123" assert event.source.user_id == "U123" + assert event.source.scope_id == "T123" @pytest.mark.asyncio async def test_dm_slash_command_keeps_dm_session_semantics(self, adapter): @@ -165,6 +167,7 @@ class TestSlashCommandSessionIsolation: assert event.source.chat_type == "dm" assert event.source.chat_id == "D123" assert event.source.user_id == "U123" + assert event.source.scope_id == "T123" # --------------------------------------------------------------------------- @@ -239,6 +242,7 @@ class TestAppMentionHandler: assert "message" in registered_events assert "app_mention" in registered_events assert "app_home_opened" in registered_events + assert "app_context_changed" in registered_events assert "reaction_added" in registered_events assert "reaction_removed" in registered_events assert "assistant_thread_started" in registered_events @@ -917,6 +921,25 @@ class TestSendDocument: assert call_kwargs["filename"] == "report.pdf" assert call_kwargs["initial_comment"] == "Here's the report" + @pytest.mark.asyncio + async def test_send_document_uses_metadata_workspace_client(self, adapter, tmp_path): + """Outbound media follows the inbound Slack workspace across gateway boundaries.""" + test_file = tmp_path / "report.pdf" + test_file.write_bytes(b"%PDF-1.4 fake content") + secondary_client = AsyncMock() + secondary_client.files_upload_v2 = AsyncMock(return_value={"ok": True}) + adapter._team_clients["T_SECONDARY"] = secondary_client + + result = await adapter.send_document( + chat_id="C123", + file_path=str(test_file), + metadata={"slack_team_id": "T_SECONDARY"}, + ) + + assert result.success + secondary_client.files_upload_v2.assert_awaited_once() + adapter._app.client.files_upload_v2.assert_not_called() + @pytest.mark.asyncio async def test_send_document_custom_name(self, adapter, tmp_path): test_file = tmp_path / "data.csv" @@ -2218,6 +2241,23 @@ class TestSendTyping: "team_id": "", } + @pytest.mark.asyncio + async def test_stop_typing_with_metadata_preserves_sibling_status(self, adapter): + adapter._app.client.assistant_threads_setStatus = AsyncMock() + await adapter.send_typing("D123", metadata={"thread_id": "thread_a"}) + await adapter.send_typing("D123", metadata={"thread_id": "thread_b"}) + + await adapter._stop_typing_with_metadata( + "D123", {"thread_id": "thread_a"} + ) + + assert adapter._app.client.assistant_threads_setStatus.call_args_list == [ + call(channel_id="D123", thread_ts="thread_a", status="is thinking..."), + call(channel_id="D123", thread_ts="thread_b", status="is thinking..."), + call(channel_id="D123", thread_ts="thread_a", status=""), + ] + assert ("D123", "thread_b") in adapter._active_status_threads + @pytest.mark.asyncio async def test_streaming_final_edit_uses_workspace_client_from_metadata( self, adapter @@ -3263,6 +3303,99 @@ class TestAssistantThreadLifecycle: thread_ts="171.000", ) + @pytest.mark.asyncio + async def test_agent_view_context_is_scoped_per_workspace_and_user( + self, assistant_adapter + ): + await assistant_adapter._handle_app_context_changed( + { + "type": "app_context_changed", + "user": "U_ONE", + "context": { + "entities": [ + { + "type": "slack#/types/channel_id", + "value": "C_CONTEXT_ONE", + } + ] + }, + }, + {"team_id": "T_ONE"}, + ) + await assistant_adapter._handle_app_context_changed( + { + "type": "app_context_changed", + "user": "U_TWO", + "context": { + "entities": [ + { + "type": "slack#/types/channel_id", + "value": "C_CONTEXT_TWO", + } + ] + }, + }, + {"team_id": "T_TWO"}, + ) + + assert assistant_adapter._agent_view_context_for_event( + {}, "T_ONE", "U_ONE" + )["context_channel_id"] == "C_CONTEXT_ONE" + assert assistant_adapter._agent_view_context_for_event( + {}, "T_TWO", "U_TWO" + )["context_channel_id"] == "C_CONTEXT_TWO" + assert "C_CONTEXT_ONE" not in assistant_adapter._channel_team + + @pytest.mark.asyncio + async def test_agent_view_message_preserves_outer_team_and_turn_context( + self, assistant_adapter + ): + assistant_adapter._app.client.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Tyler"}}} + ) + assistant_adapter._app.client.reactions_add = AsyncMock() + assistant_adapter._app.client.reactions_remove = AsyncMock() + await assistant_adapter._handle_app_context_changed( + { + "type": "app_context_changed", + "user": "U_USER", + "context": { + "entities": [ + { + "type": "slack#/types/channel_id", + "value": "C_ACTIVE", + } + ] + }, + }, + {"team_id": "T_OTHER"}, + ) + + await assistant_adapter._handle_slack_message( + { + "text": "help me plan", + "channel": "D123", + "channel_type": "im", + "ts": "171.111", + "user": "U_USER", + }, + {"team_id": "T_OTHER"}, + ) + + msg_event = assistant_adapter.handle_message.await_args.args[0] + assert msg_event.source.scope_id == "T_OTHER" + assert msg_event.metadata["slack_team_id"] == "T_OTHER" + assert msg_event.source.thread_id == "171.111" + assert msg_event.text.startswith( + "[Slack app context: user is viewing channel C_ACTIVE]" + ) + + runner = object.__new__(GatewayRunner) + assert runner._thread_metadata_for_source(msg_event.source) == { + "thread_id": "171.111", + "slack_team_id": "T_OTHER", + } + @pytest.mark.asyncio async def test_dm_message_sets_assistant_thread_title_once( self, assistant_adapter @@ -4344,6 +4477,27 @@ class TestThreadContextUnverifiedTagging: assert "identity hasn't" not in content assert "[Thread context — prior messages in this thread (not yet in conversation history):]" in content + @pytest.mark.asyncio + async def test_thread_context_uses_workspace_client(self, adapter): + team_client = AsyncMock() + team_client.conversations_replies = self._make_replies(self._thread_messages()) + adapter._team_clients["T_OTHER"] = team_client + adapter._thread_context_cache.clear() + + with patch.object( + adapter, "_resolve_user_name", + new=AsyncMock(side_effect=lambda uid, **_: uid), + ): + await adapter._fetch_thread_context( + channel_id="C1", + thread_ts="100.0", + current_ts="999.0", + team_id="T_OTHER", + ) + + team_client.conversations_replies.assert_awaited_once() + adapter._app.client.conversations_replies.assert_not_called() + @pytest.mark.asyncio async def test_all_authorized_no_tags(self, adapter): """Auth callback returning True for every sender → no [unverified] tags.""" diff --git a/tests/gateway/test_slack_approval_buttons.py b/tests/gateway/test_slack_approval_buttons.py index 2f0f6835804e..4627fccafa69 100644 --- a/tests/gateway/test_slack_approval_buttons.py +++ b/tests/gateway/test_slack_approval_buttons.py @@ -323,6 +323,18 @@ class TestSlackInteractiveAuth: assert runner.seen_sources[0].chat_id == "C1" assert runner.seen_sources[0].chat_type == "group" + def test_passes_workspace_scope_to_gateway_runner_auth(self): + adapter = _make_adapter() + runner = _attach_auth_runner(adapter) + + assert adapter._is_interactive_user_authorized( + "U_OK", + channel_id="C1", + user_name="operator", + team_id="T1", + ) is True + assert runner.seen_sources[0].scope_id == "T1" + class TestSlackSlashConfirmAction: @pytest.mark.asyncio @@ -364,6 +376,40 @@ class TestSlackSlashConfirmAction: mock_client.chat_update.assert_called_once() mock_client.chat_postMessage.assert_called_once() + @pytest.mark.asyncio + async def test_action_uses_outer_payload_workspace_client(self, monkeypatch): + adapter = _make_adapter() + secondary_client = AsyncMock() + adapter._team_clients["T2"] = secondary_client + monkeypatch.delenv("SLACK_ALLOWED_USERS", raising=False) + monkeypatch.delenv("SLACK_ALLOW_ALL_USERS", raising=False) + monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False) + monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "U_OWNER") + + ack = AsyncMock() + body = { + "team_id": "T2", + "message": { + "ts": "2222.3333", + "blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": "Original prompt"}}, + ], + }, + "channel": {"id": "C1"}, + "user": {"name": "owner", "id": "U_OWNER"}, + } + action = { + "action_id": "hermes_confirm_once", + "value": "agent:main:slack:group:C1:1111|confirm-1", + } + + with patch("tools.slash_confirm.resolve", new=AsyncMock(return_value="follow-up")): + await adapter._handle_slash_confirm_action(ack, body, action) + + secondary_client.chat_update.assert_awaited_once() + secondary_client.chat_postMessage.assert_awaited_once() + adapter._team_clients["T1"].chat_update.assert_not_called() + # =========================================================================== # _fetch_thread_context diff --git a/tests/hermes_cli/test_slack_cli.py b/tests/hermes_cli/test_slack_cli.py index f2e6bda52399..6de85a3007c0 100644 --- a/tests/hermes_cli/test_slack_cli.py +++ b/tests/hermes_cli/test_slack_cli.py @@ -125,6 +125,7 @@ class TestSlackFullManifest: bot_events = manifest["settings"]["event_subscriptions"]["bot_events"] assert "app_home_opened" in bot_events + assert "app_context_changed" in bot_events assert "message.im" in bot_events assert "assistant_thread_started" not in bot_events assert "assistant_thread_context_changed" not in bot_events diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index 63e8033f3eb9..42acf9f3edb7 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -36,12 +36,13 @@ Mode — all at once. ### Option A: From a Hermes-generated manifest (recommended) -1. Generate the manifest: +1. Generate the manifest. New Slack apps must use Agent view: ```bash - hermes slack manifest --write + hermes slack manifest --agent-view --write ``` This writes `~/.hermes/slack-manifest.json` and prints paste-in - instructions. + instructions. Existing apps that still use Slack's legacy Assistant view + can omit `--agent-view` until they are ready to migrate. 2. Go to [https://api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From an app manifest** 3. Pick your workspace, paste the JSON contents, review, click **Next** @@ -243,6 +244,23 @@ Step 1, Option A) that declares every command in as a slash command. In Socket Mode, Slack routes the command event through the WebSocket regardless of the manifest's `url` field. +### Agent messaging experience + +New Slack apps use Slack's **Agent** messaging experience. Existing Hermes +Assistant apps can migrate by regenerating the manifest with `--agent-view`: + +```bash +hermes slack manifest --agent-view --write +``` + +Update the manifest in **Features → App Manifest**, then reinstall the app if +Slack asks. Agent view cannot be reverted to Assistant view, and users may need +to hard-refresh Slack after the switch. The generated Agent manifest subscribes +to `message.im`, `app_home_opened`, and `app_context_changed`, so Hermes can +identify a Messages-tab DM and receive the user's active Slack context with a +turn. Hermes only supplies that context as a label; it does not read the viewed +channel's history. + ### Refreshing slash commands after updates When Hermes adds new commands (e.g. after `hermes update`), regenerate @@ -353,6 +371,19 @@ platforms: # gracefully fall back to aligned monospace. rich_blocks: false + # Append Slack-native feedback controls to final Block Kit replies. + # Requires rich_blocks: true. Default: false. + feedback_buttons: false + + # Suggested prompts pinned at the top of Agent view's Messages tab. + # Either a list of {title, message} rows, or a titled object: + # {title: "Start here", prompts: [{title: "Plan", message: "..."}]} + suggested_prompts: [] + + # Title Agent/Assistant DM threads from the first user message. + # Default: true. Set false to leave Slack's default thread titles. + assistant_thread_titles: true + # Continuable-cron delivery surface (default: "thread"). # "in_channel" delivers a continuable cron job FLAT into the channel # (no dedicated thread); pair with reply_in_thread: false (and @@ -367,6 +398,9 @@ platforms: | `platforms.slack.extra.reply_in_thread` | `true` | When `false`, channel messages get direct replies instead of threads. Messages inside existing threads still reply in-thread. | | `platforms.slack.extra.reply_broadcast` | `false` | When `true`, thread replies are also posted to the main channel. Only the first chunk is broadcast. | | `platforms.slack.extra.rich_blocks` | `false` | When `true`, agent messages are rendered as [Block Kit](https://docs.slack.dev/block-kit/) blocks (headers, dividers, true nested lists, and native tables). A plain-text fallback is always sent. Tables over Slack's limits fall back to aligned monospace. No app reinstall required — it's a send-side change only. | +| `platforms.slack.extra.feedback_buttons` | `false` | When `true` with `rich_blocks`, appends Slack-native feedback controls to final replies. | +| `platforms.slack.extra.suggested_prompts` | `[]` | Up to four `{title, message}` prompts for Agent/Assistant DM entry points; accepts either a list or `{title, prompts}`. | +| `platforms.slack.extra.assistant_thread_titles` | `true` | When `true`, names Agent/Assistant DM threads from the first user message. | | `platforms.slack.extra.cron_continuable_surface` | `"thread"` | Delivery surface for [continuable cron jobs](../features/cron.md#flat-in-channel-continuation-slack). `"thread"` opens a dedicated thread per delivery (default); `"in_channel"` delivers flat into the channel timeline. Pair `in_channel` with `reply_in_thread: false` (and `require_mention: false`) so a plain channel reply continues the job. | ### Session Isolation