fix(slack): complete agent view workspace routing

This commit is contained in:
kshitijk4poor 2026-07-13 15:55:08 +05:30 committed by Teknium
parent f1328a6bfd
commit 4554fe128a
10 changed files with 599 additions and 84 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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),

View file

@ -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."""

View file

@ -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

View file

@ -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

View file

@ -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