diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py
index ec8d2a84b379..2ea4fb4693e1 100644
--- a/gateway/channel_directory.py
+++ b/gateway/channel_directory.py
@@ -101,6 +101,9 @@ def _build_discord(adapter) -> List[Dict[str, str]]:
"name": ch.name,
"guild": guild.name,
"type": "channel",
+ "qualified_name": f"{guild.name}/{ch.name}",
+ "mention": f"<#{ch.id}>",
+ "target": f"channel:{ch.id}",
})
# Also include DM-capable users we've interacted with is not
# feasible via guild enumeration; those come from sessions.
@@ -190,7 +193,18 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:
if not channels:
return None
- query = name.lstrip("#").lower()
+ raw_query = name.strip()
+ if platform_name == "discord":
+ if raw_query.startswith("<#") and raw_query.endswith(">"):
+ mention_id = raw_query[2:-1].strip()
+ if mention_id.isdigit():
+ return mention_id
+ if raw_query.lower().startswith("channel:"):
+ channel_id = raw_query.split(":", 1)[1].strip()
+ if channel_id.isdigit():
+ return channel_id
+
+ query = raw_query.lstrip("#").lower()
# 1. Exact name match
for ch in channels:
diff --git a/gateway/command_catalog.py b/gateway/command_catalog.py
new file mode 100644
index 000000000000..5b6f6eee87e0
--- /dev/null
+++ b/gateway/command_catalog.py
@@ -0,0 +1,281 @@
+"""Helpers for long-tail gateway command surfaces.
+
+This module keeps config/env editing, session export, and value formatting
+out of ``gateway.run`` so the command handlers stay readable.
+"""
+
+from __future__ import annotations
+
+import html
+import json
+import os
+import re
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+from hermes_cli.config import (
+ get_config_path,
+ get_env_path,
+ get_env_value,
+ load_config,
+ save_config,
+ save_env_value,
+)
+
+
+_ENV_LIKE_EXPLICIT_KEYS = {
+ "OPENROUTER_API_KEY",
+ "OPENAI_API_KEY",
+ "ANTHROPIC_API_KEY",
+ "VOICE_TOOLS_OPENAI_KEY",
+ "PARALLEL_API_KEY",
+ "FIRECRAWL_API_KEY",
+ "FIRECRAWL_API_URL",
+ "TAVILY_API_KEY",
+ "BROWSERBASE_API_KEY",
+ "BROWSERBASE_PROJECT_ID",
+ "BROWSER_USE_API_KEY",
+ "FAL_KEY",
+ "TELEGRAM_BOT_TOKEN",
+ "DISCORD_BOT_TOKEN",
+ "TERMINAL_SSH_HOST",
+ "TERMINAL_SSH_USER",
+ "TERMINAL_SSH_KEY",
+ "SUDO_PASSWORD",
+ "SLACK_BOT_TOKEN",
+ "SLACK_APP_TOKEN",
+ "GITHUB_TOKEN",
+ "HONCHO_API_KEY",
+ "WANDB_API_KEY",
+ "TINKER_API_KEY",
+}
+_ENV_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
+
+
+def is_env_like_key(key: str) -> bool:
+ """Return True when ``key`` should be treated as an env var path."""
+ normalized = str(key or "").strip().upper()
+ if not normalized:
+ return False
+ if normalized in _ENV_LIKE_EXPLICIT_KEYS:
+ return True
+ if normalized.endswith("_API_KEY") or normalized.endswith("_TOKEN"):
+ return True
+ if normalized.startswith("TERMINAL_SSH"):
+ return True
+ return bool(_ENV_NAME_RE.match(normalized))
+
+
+def parse_value_text(raw: str) -> Any:
+ """Parse a user value using YAML semantics, falling back to a string."""
+ text = str(raw or "").strip()
+ if not text:
+ return ""
+ try:
+ value = yaml.safe_load(text)
+ except Exception:
+ return text
+ return text if value is None else value
+
+
+def load_raw_user_config() -> dict[str, Any]:
+ """Load the on-disk user config without default merging."""
+ config_path = get_config_path()
+ if not config_path.exists():
+ return {}
+ try:
+ with open(config_path, encoding="utf-8") as handle:
+ return yaml.safe_load(handle) or {}
+ except Exception:
+ return {}
+
+
+def save_raw_user_config(config: dict[str, Any]) -> Path:
+ """Persist the raw user config to the canonical config path."""
+ save_config(config)
+ return get_config_path()
+
+
+def get_nested_value(mapping: dict[str, Any], path: str) -> Any:
+ """Resolve a dotted path from a nested dict."""
+ current: Any = mapping
+ for part in str(path or "").split("."):
+ if not part:
+ continue
+ if not isinstance(current, dict) or part not in current:
+ raise KeyError(path)
+ current = current[part]
+ return current
+
+
+def set_nested_value(mapping: dict[str, Any], path: str, value: Any) -> Any:
+ """Set a dotted path in a nested dict, creating parent dicts as needed."""
+ parts = [part for part in str(path or "").split(".") if part]
+ if not parts:
+ raise KeyError(path)
+
+ current = mapping
+ for part in parts[:-1]:
+ next_value = current.get(part)
+ if not isinstance(next_value, dict):
+ next_value = {}
+ current[part] = next_value
+ current = next_value
+ current[parts[-1]] = value
+ return value
+
+
+def unset_nested_value(mapping: dict[str, Any], path: str) -> bool:
+ """Delete a dotted path from a nested dict. Returns True if removed."""
+ parts = [part for part in str(path or "").split(".") if part]
+ if not parts:
+ return False
+ current = mapping
+ parents: list[tuple[dict[str, Any], str]] = []
+ for part in parts[:-1]:
+ if not isinstance(current, dict) or part not in current or not isinstance(current[part], dict):
+ return False
+ parents.append((current, part))
+ current = current[part]
+ if not isinstance(current, dict) or parts[-1] not in current:
+ return False
+ current.pop(parts[-1], None)
+ for parent, key in reversed(parents):
+ child = parent.get(key)
+ if isinstance(child, dict) and not child:
+ parent.pop(key, None)
+ else:
+ break
+ return True
+
+
+def read_config_or_env_value(key: str) -> tuple[str, Any]:
+ """Return (kind, value) where kind is ``env`` or ``config``."""
+ if is_env_like_key(key):
+ return "env", get_env_value(key.upper())
+ return "config", get_nested_value(load_raw_user_config(), key)
+
+
+def write_config_or_env_value(key: str, raw_value: str) -> tuple[str, Any, Path]:
+ """Write a config or env value and return (kind, stored_value, path)."""
+ if is_env_like_key(key):
+ normalized = key.upper()
+ save_env_value(normalized, str(raw_value))
+ return "env", str(raw_value), get_env_path()
+
+ config = load_raw_user_config()
+ value = parse_value_text(raw_value)
+ set_nested_value(config, key, value)
+ path = save_raw_user_config(config)
+ return "config", value, path
+
+
+def unset_config_or_env_value(key: str) -> tuple[str, bool, Path]:
+ """Unset a config or env value and return (kind, removed, path)."""
+ if is_env_like_key(key):
+ return "env", unset_env_key(key.upper()), get_env_path()
+
+ config = load_raw_user_config()
+ removed = unset_nested_value(config, key)
+ if removed:
+ save_raw_user_config(config)
+ return "config", removed, get_config_path()
+
+
+def unset_env_key(key: str) -> bool:
+ """Remove an environment variable from ``~/.hermes/.env`` and ``os.environ``."""
+ env_path = get_env_path()
+ if not env_path.exists():
+ os.environ.pop(key, None)
+ return False
+
+ with open(env_path, encoding="utf-8") as handle:
+ lines = handle.readlines()
+ kept = [
+ line
+ for line in lines
+ if not line.strip().startswith(f"{key}=")
+ ]
+ if kept == lines:
+ os.environ.pop(key, None)
+ return False
+
+ with open(env_path, "w", encoding="utf-8") as handle:
+ handle.writelines(kept)
+ os.environ.pop(key, None)
+ return True
+
+
+def format_yaml_block(value: Any) -> str:
+ """Render ``value`` as a YAML code block."""
+ rendered = yaml.safe_dump(value, sort_keys=False, allow_unicode=True).strip()
+ return f"```yaml\n{rendered or 'null'}\n```"
+
+
+def default_export_path(session_id: str, cwd: str | Path | None, suffix: str = ".html") -> Path:
+ """Build the default export path inside ``cwd``."""
+ base_dir = Path(cwd or os.getcwd())
+ export_dir = base_dir / ".hermes-exports"
+ export_dir.mkdir(parents=True, exist_ok=True)
+ safe_session_id = re.sub(r"[^A-Za-z0-9_.-]+", "-", session_id or "session").strip("-") or "session"
+ return export_dir / f"{safe_session_id}{suffix}"
+
+
+def resolve_export_path(raw_path: str, cwd: str | Path | None, *, session_id: str) -> Path:
+ """Resolve a user path relative to ``cwd`` or fall back to a default export path."""
+ candidate = str(raw_path or "").strip()
+ if not candidate:
+ return default_export_path(session_id, cwd, ".html")
+ path = Path(candidate).expanduser()
+ if not path.is_absolute():
+ path = Path(cwd or os.getcwd()) / path
+ path.parent.mkdir(parents=True, exist_ok=True)
+ return path
+
+
+def build_session_export_html(snapshot: dict[str, Any]) -> str:
+ """Render an HTML session export with metadata, prompt, and transcript."""
+ meta = snapshot.get("session") or {}
+ prompt = snapshot.get("context_prompt") or ""
+ transcript = snapshot.get("messages") or []
+
+ message_rows: list[str] = []
+ for message in transcript:
+ role = html.escape(str(message.get("role") or "unknown"))
+ content = html.escape(str(message.get("content") or ""))
+ message_rows.append(
+ ""
+ f"{role}
"
+ f"{content}"
+ ""
+ )
+
+ meta_json = html.escape(json.dumps(meta, ensure_ascii=False, indent=2))
+ prompt_html = html.escape(prompt)
+ return f"""
+
+
+
+ Hermes Session Export
+
+
+
+ Hermes Session Export
+ Session Metadata
+ {meta_json}
+ Current Session Context Prompt
+ {prompt_html}
+ Transcript
+ {''.join(message_rows) or 'No transcript messages available.
'}
+
+
+"""
+
diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py
index a7a809bbcfb7..6b1ba343ade9 100644
--- a/gateway/platforms/base.py
+++ b/gateway/platforms/base.py
@@ -298,6 +298,7 @@ class MessageEvent:
# Timestamps
timestamp: datetime = field(default_factory=datetime.now)
+ metadata: Dict[str, Any] = field(default_factory=dict)
def is_command(self) -> bool:
"""Check if this is a command message (e.g., /new, /reset)."""
@@ -1160,6 +1161,7 @@ def build_source(
chat_topic: Optional[str] = None,
user_id_alt: Optional[str] = None,
chat_id_alt: Optional[str] = None,
+ session_namespace: Optional[str] = None,
) -> SessionSource:
"""Helper to build a SessionSource for this platform."""
# Normalize empty topic to None
@@ -1176,6 +1178,7 @@ def build_source(
chat_topic=chat_topic.strip() if chat_topic else None,
user_id_alt=user_id_alt,
chat_id_alt=chat_id_alt,
+ session_namespace=session_namespace,
)
@abstractmethod
diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py
index af36d568241b..b95825c0372c 100644
--- a/gateway/platforms/discord.py
+++ b/gateway/platforms/discord.py
@@ -19,7 +19,8 @@
import threading
import time
from collections import defaultdict
-from pathlib import Path
+from dataclasses import asdict
+from datetime import datetime
from typing import Callable, Dict, List, Optional, Any
logger = logging.getLogger(__name__)
@@ -51,23 +52,17 @@
cache_image_from_url,
cache_audio_from_url,
)
-
-
-def _clean_discord_id(entry: str) -> str:
- """Strip common prefixes from a Discord user ID or username entry.
-
- Users sometimes paste IDs with prefixes like ``user:123``, ``<@123>``,
- or ``<@!123>`` from Discord's UI or other tools. This normalises the
- entry to just the bare ID or username.
- """
- entry = entry.strip()
- # Strip Discord mention syntax: <@123> or <@!123>
- if entry.startswith("<@") and entry.endswith(">"):
- entry = entry.lstrip("<@!").rstrip(">")
- # Strip "user:" prefix (seen in some Discord tools / onboarding pastes)
- if entry.lower().startswith("user:"):
- entry = entry[5:]
- return entry.strip()
+from gateway.platforms.discord_impl import config as discord_config
+from gateway.platforms.discord_impl import delivery as discord_delivery
+from gateway.platforms.discord_impl import history as discord_history
+from gateway.platforms.discord_impl import intake as discord_intake
+from gateway.platforms.discord_impl import interactions as discord_interactions
+from gateway.platforms.discord_impl import messaging as discord_messaging
+from gateway.platforms.discord_impl import native_commands as discord_native_commands
+from gateway.platforms.discord_impl import permissions as discord_permissions
+from gateway.platforms.discord_impl import runtime_state as discord_runtime_state
+from gateway.platforms.discord_impl import state as discord_state
+from gateway.platforms.discord_impl import threads as discord_threads
def check_discord_requirements() -> bool:
@@ -425,7 +420,11 @@ def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.DISCORD)
self._client: Optional[commands.Bot] = None
self._ready_event = asyncio.Event()
+ self._discord_policy: Optional[discord_config.DiscordPolicyConfig] = None
+ self._discord_policy_overrides: Dict[str, Any] = {}
+ self._component_runtime = discord_interactions.create_component_runtime()
self._allowed_user_ids: set = set() # For button approval authorization
+ self._resolve_exec_approval: Optional[Callable[..., Any]] = None
# Voice channel state (per-guild)
self._voice_clients: Dict[int, Any] = {} # guild_id -> VoiceClient
self._voice_text_channels: Dict[int, int] = {} # guild_id -> text_channel_id
@@ -439,8 +438,29 @@ def __init__(self, config: PlatformConfig):
# in those threads don't require @mention. Persisted to disk so the
# set survives gateway restarts.
self._bot_participated_threads: set = self._load_participated_threads()
+ self._thread_bindings, self._activation_overrides = self._load_runtime_state()
# Cap to prevent unbounded growth (Discord threads get archived).
self._MAX_TRACKED_THREADS = 500
+
+ def _get_discord_policy(self) -> discord_config.DiscordPolicyConfig:
+ """Return the adapter's cached Discord policy snapshot."""
+ if self._discord_policy is None:
+ self._discord_policy = discord_config.load_policy_config(
+ self.config,
+ overrides=self._discord_policy_overrides,
+ )
+ return self._discord_policy
+
+ def apply_runtime_policy_overrides(
+ self,
+ overrides: Optional[Dict[str, Any]] = None,
+ ) -> discord_config.DiscordPolicyConfig:
+ """Apply runtime-only policy overrides for the active Discord connection."""
+ self._discord_policy_overrides = dict(overrides or {})
+ self._discord_policy = None
+ policy = self._get_discord_policy()
+ self._allowed_user_ids = set(policy.allowed_users)
+ return policy
async def connect(self) -> bool:
"""Connect to Discord and start receiving events."""
@@ -493,12 +513,7 @@ async def connect(self) -> bool:
)
# Parse allowed user entries (may contain usernames or IDs)
- allowed_env = os.getenv("DISCORD_ALLOWED_USERS", "")
- if allowed_env:
- self._allowed_user_ids = {
- _clean_discord_id(uid) for uid in allowed_env.split(",")
- if uid.strip()
- }
+ self._allowed_user_ids = set(self._get_discord_policy().allowed_users)
adapter_self = self # capture for closure
@@ -528,14 +543,14 @@ async def on_message(message: DiscordMessage):
# "none" — ignore all other bots (default)
# "mentions" — accept bot messages only when they @mention us
# "all" — accept all bot messages
- if getattr(message.author, "bot", False):
- allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip()
- if allow_bots == "none":
- return
- elif allow_bots == "mentions":
- if not self._client.user or self._client.user not in message.mentions:
- return
- # "all" falls through to handle_message
+ is_mentioned = bool(self._client.user and self._client.user in message.mentions)
+ policy = adapter_self._get_discord_policy()
+ if discord_intake.should_filter_bot_message(
+ is_bot=getattr(message.author, "bot", False),
+ policy=policy.bot_filter_policy,
+ is_mentioned=is_mentioned,
+ ):
+ return
await self._handle_message(message)
@@ -623,61 +638,37 @@ async def send(
return SendResult(success=False, error="Not connected")
try:
- # Get the channel
- channel = self._client.get_channel(int(chat_id))
- if not channel:
- channel = await self._client.fetch_channel(int(chat_id))
-
+ channel = await discord_delivery.resolve_channel(self._client, chat_id)
if not channel:
return SendResult(success=False, error=f"Channel {chat_id} not found")
-
- # Format and split message if needed
+
formatted = self.format_message(content)
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
-
message_ids = []
reference = None
-
+
if reply_to:
try:
ref_msg = await channel.fetch_message(int(reply_to))
reference = ref_msg
except Exception as e:
logger.debug("Could not fetch reply-to message: %s", e)
-
+
for i, chunk in enumerate(chunks):
chunk_reference = reference if i == 0 else None
- try:
- msg = await channel.send(
- content=chunk,
- reference=chunk_reference,
- )
- except Exception as e:
- err_text = str(e)
- if (
- chunk_reference is not None
- and "error code: 50035" in err_text
- and "Cannot reply to a system message" in err_text
- ):
- logger.warning(
- "[%s] Reply target %s is a Discord system message; retrying send without reply reference",
- self.name,
- reply_to,
- )
- msg = await channel.send(
- content=chunk,
- reference=None,
- )
- else:
- raise
+ msg = await discord_delivery.send_text_message(
+ channel,
+ chunk,
+ reference=chunk_reference,
+ )
message_ids.append(str(msg.id))
-
+
return SendResult(
success=True,
message_id=message_ids[0] if message_ids else None,
raw_response={"message_ids": message_ids}
)
-
+
except Exception as e: # pragma: no cover - defensive logging
logger.error("[%s] Failed to send Discord message: %s", self.name, e, exc_info=True)
return SendResult(success=False, error=str(e))
@@ -689,21 +680,86 @@ async def edit_message(
content: str,
) -> SendResult:
"""Edit a previously sent Discord message."""
- if not self._client:
- return SendResult(success=False, error="Not connected")
- try:
- channel = self._client.get_channel(int(chat_id))
- if not channel:
- channel = await self._client.fetch_channel(int(chat_id))
- msg = await channel.fetch_message(int(message_id))
- formatted = self.format_message(content)
- if len(formatted) > self.MAX_MESSAGE_LENGTH:
- formatted = formatted[:self.MAX_MESSAGE_LENGTH - 3] + "..."
- await msg.edit(content=formatted)
- return SendResult(success=True, message_id=message_id)
- except Exception as e: # pragma: no cover - defensive logging
- logger.error("[%s] Failed to edit Discord message %s: %s", self.name, message_id, e, exc_info=True)
- return SendResult(success=False, error=str(e))
+ result = await discord_messaging.edit_message(
+ self._client,
+ chat_id,
+ message_id,
+ content,
+ format_message=self.format_message,
+ max_message_length=self.MAX_MESSAGE_LENGTH,
+ )
+ if not result.success and result.error:
+ logger.error(
+ "[%s] Failed to edit Discord message %s: %s",
+ self.name,
+ message_id,
+ result.error,
+ )
+ return result
+
+ async def delete_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ ) -> SendResult:
+ """Delete a Discord message."""
+ result = await discord_messaging.delete_message(
+ self._client,
+ chat_id,
+ message_id,
+ )
+ if not result.success and result.error:
+ logger.error(
+ "[%s] Failed to delete Discord message %s: %s",
+ self.name,
+ message_id,
+ result.error,
+ )
+ return result
+
+ async def add_reaction(
+ self,
+ chat_id: str,
+ message_id: str,
+ emoji: Any,
+ ) -> SendResult:
+ """Add a reaction to a Discord message."""
+ result = await discord_messaging.add_reaction(
+ self._client,
+ chat_id,
+ message_id,
+ emoji,
+ )
+ if not result.success and result.error:
+ logger.error(
+ "[%s] Failed to add reaction to Discord message %s: %s",
+ self.name,
+ message_id,
+ result.error,
+ )
+ return result
+
+ async def remove_reaction(
+ self,
+ chat_id: str,
+ message_id: str,
+ emoji: Any,
+ ) -> SendResult:
+ """Remove the connected bot's reaction from a Discord message."""
+ result = await discord_messaging.remove_reaction(
+ self._client,
+ chat_id,
+ message_id,
+ emoji,
+ )
+ if not result.success and result.error:
+ logger.error(
+ "[%s] Failed to remove reaction from Discord message %s: %s",
+ self.name,
+ message_id,
+ result.error,
+ )
+ return result
async def _send_file_attachment(
self,
@@ -716,15 +772,17 @@ async def _send_file_attachment(
if not self._client:
return SendResult(success=False, error="Not connected")
- channel = self._client.get_channel(int(chat_id))
- if not channel:
- channel = await self._client.fetch_channel(int(chat_id))
+ channel = await discord_delivery.resolve_channel(self._client, chat_id)
if not channel:
return SendResult(success=False, error=f"Channel {chat_id} not found")
+ discord_mod = sys.modules.get("discord", discord)
+ if discord_mod is None: # pragma: no cover - import guard
+ return SendResult(success=False, error="discord.py not installed")
+
filename = file_name or os.path.basename(file_path)
with open(file_path, "rb") as fh:
- file = discord.File(fh, filename=filename)
+ file = discord_mod.File(fh, filename=filename)
msg = await channel.send(content=caption if caption else None, file=file)
return SendResult(success=True, message_id=str(msg.id))
@@ -1152,10 +1210,8 @@ async def send_image(
try:
import aiohttp
-
- channel = self._client.get_channel(int(chat_id))
- if not channel:
- channel = await self._client.fetch_channel(int(chat_id))
+
+ channel = await discord_delivery.resolve_channel(self._client, chat_id)
if not channel:
return SendResult(success=False, error=f"Channel {chat_id} not found")
@@ -1242,7 +1298,7 @@ async def send_typing(self, chat_id: str, metadata=None) -> None:
"""Send typing indicator."""
if self._client:
try:
- channel = self._client.get_channel(int(chat_id))
+ channel = await discord_delivery.resolve_channel(self._client, chat_id)
if channel:
await channel.typing()
except Exception:
@@ -1286,6 +1342,184 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
except Exception as e: # pragma: no cover - defensive logging
logger.error("[%s] Failed to get chat info for %s: %s", self.name, chat_id, e, exc_info=True)
return {"name": str(chat_id), "type": "dm", "error": str(e)}
+
+ async def fetch_channel_history(
+ self,
+ channel_id: str,
+ limit: int = 100,
+ before: Optional[str] = None,
+ after: Optional[str] = None,
+ ) -> list:
+ """Fetch bounded message history."""
+ if not self._client:
+ return []
+ messages = await discord_history.fetch_history(
+ self._client,
+ channel_id,
+ limit=limit,
+ before=before,
+ after=after,
+ )
+ return [asdict(message) for message in messages]
+
+ async def list_threads(
+ self,
+ channel_id: str,
+ include_archived: bool = False,
+ limit: int = 100,
+ before: Optional[Any] = None,
+ private: bool = False,
+ joined: bool = False,
+ ) -> list:
+ """List active and optionally archived threads for a channel."""
+ if not self._client:
+ return []
+ return await discord_messaging.list_threads(
+ self._client,
+ channel_id,
+ include_archived=include_archived,
+ limit=limit,
+ before=before,
+ private=private,
+ joined=joined,
+ )
+
+ async def reply_in_thread(
+ self,
+ thread_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ ) -> SendResult:
+ """Send a message to a Discord thread."""
+ result = await discord_messaging.reply_in_thread(
+ self._client,
+ thread_id,
+ content,
+ reply_to=reply_to,
+ format_message=self.format_message,
+ truncate_message=self.truncate_message,
+ max_message_length=self.MAX_MESSAGE_LENGTH,
+ send_text_message=discord_delivery.send_text_message,
+ )
+ if not result.success and result.error:
+ logger.error(
+ "[%s] Failed to reply in Discord thread %s: %s",
+ self.name,
+ thread_id,
+ result.error,
+ )
+ return result
+
+ async def search_channel_history(
+ self,
+ channel_id: str,
+ query: str,
+ limit: int = 50,
+ author_id: Optional[str] = None,
+ ) -> list:
+ """Search channel history."""
+ if not self._client:
+ return []
+ messages = await discord_history.search_history(
+ self._client,
+ channel_id,
+ query,
+ limit=limit,
+ author_id=author_id,
+ )
+ return [asdict(message) for message in messages]
+
+ async def get_channel_permissions(self, channel_id: str) -> Optional[dict]:
+ """Get bot permissions for a channel."""
+ if not self._client:
+ return None
+ permissions = await discord_permissions.check_channel_permissions(self._client, channel_id)
+ return asdict(permissions) if permissions else None
+
+ async def list_pins(
+ self,
+ channel_id: str,
+ limit: int = 50,
+ before: Optional[Any] = None,
+ oldest_first: bool = False,
+ ) -> list:
+ """List pinned messages for a Discord channel or thread."""
+ if not self._client:
+ return []
+ return await discord_messaging.list_pins(
+ self._client,
+ channel_id,
+ limit=limit,
+ before=before,
+ oldest_first=oldest_first,
+ )
+
+ async def list_reactions(
+ self,
+ chat_id: str,
+ message_id: str,
+ limit: int = 100,
+ ) -> list:
+ """List reactions for a Discord message."""
+ if not self._client:
+ return []
+ return await discord_messaging.list_reactions(
+ self._client,
+ chat_id,
+ message_id,
+ limit=limit,
+ )
+
+ async def pin_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ reason: Optional[str] = None,
+ ) -> SendResult:
+ """Pin a Discord message."""
+ result = await discord_messaging.pin_message(
+ self._client,
+ chat_id,
+ message_id,
+ reason=reason,
+ )
+ if not result.success and result.error:
+ logger.error(
+ "[%s] Failed to pin Discord message %s: %s",
+ self.name,
+ message_id,
+ result.error,
+ )
+ return result
+
+ async def unpin_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ reason: Optional[str] = None,
+ ) -> SendResult:
+ """Unpin a Discord message."""
+ result = await discord_messaging.unpin_message(
+ self._client,
+ chat_id,
+ message_id,
+ reason=reason,
+ )
+ if not result.success and result.error:
+ logger.error(
+ "[%s] Failed to unpin Discord message %s: %s",
+ self.name,
+ message_id,
+ result.error,
+ )
+ return result
+
+ async def get_accessible_channels(self, guild_id: Optional[str] = None) -> list:
+ """List accessible channels."""
+ if not self._client:
+ return []
+ channels = await discord_permissions.list_accessible_channels(self._client, guild_id=guild_id)
+ return [asdict(channel) for channel in channels]
async def _resolve_allowed_usernames(self) -> None:
"""
@@ -1368,164 +1602,63 @@ async def _run_simple_slash(
) -> None:
"""Common handler for simple slash commands that dispatch a command string."""
await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, command_text)
- await self.handle_message(event)
+ response = await self._invoke_native_slash_command(interaction, command_text)
+ if response:
+ await self._send_native_slash_content(interaction, response)
+ return
if followup_msg:
try:
await interaction.followup.send(followup_msg, ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
+ async def _invoke_native_slash_command(
+ self,
+ interaction: discord.Interaction,
+ command_text: str,
+ ) -> str | None:
+ """Run a native slash command directly through the gateway handler."""
+ if not self._message_handler:
+ return None
+ event = self._build_slash_event(interaction, command_text)
+ return await self._message_handler(event)
+
+ async def _send_native_slash_content(
+ self,
+ interaction: discord.Interaction,
+ content: str,
+ ) -> None:
+ """Send a native slash response back through the Discord interaction."""
+ formatted = self.format_message(content)
+ chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
+ response = getattr(interaction, "response", None)
+ followup = getattr(interaction, "followup", None)
+
+ first_chunk_sent = False
+ if response is not None and hasattr(response, "send_message"):
+ is_done = getattr(response, "is_done", None)
+ if not callable(is_done) or not is_done():
+ await response.send_message(chunks[0], ephemeral=True)
+ first_chunk_sent = True
+
+ start_index = 1 if first_chunk_sent else 0
+ if followup is not None and hasattr(followup, "send"):
+ for chunk in chunks[start_index:]:
+ await followup.send(chunk, ephemeral=True)
+ return
+
+ if not first_chunk_sent and response is not None and hasattr(response, "send_message"):
+ await response.send_message(chunks[0], ephemeral=True)
+
def _register_slash_commands(self) -> None:
"""Register Discord slash commands on the command tree."""
if not self._client:
return
-
- tree = self._client.tree
-
- @tree.command(name="new", description="Start a new conversation")
- async def slash_new(interaction: discord.Interaction):
- await self._run_simple_slash(interaction, "/reset", "New conversation started~")
-
- @tree.command(name="reset", description="Reset your Hermes session")
- async def slash_reset(interaction: discord.Interaction):
- await self._run_simple_slash(interaction, "/reset", "Session reset~")
-
- @tree.command(name="model", description="Show or change the model")
- @discord.app_commands.describe(name="Model name (e.g. anthropic/claude-sonnet-4). Leave empty to see current.")
- async def slash_model(interaction: discord.Interaction, name: str = ""):
- await self._run_simple_slash(interaction, f"/model {name}".strip())
-
- @tree.command(name="reasoning", description="Show or change reasoning effort")
- @discord.app_commands.describe(effort="Reasoning effort: xhigh, high, medium, low, minimal, or none.")
- async def slash_reasoning(interaction: discord.Interaction, effort: str = ""):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, f"/reasoning {effort}".strip())
- await self.handle_message(event)
-
- @tree.command(name="personality", description="Set a personality")
- @discord.app_commands.describe(name="Personality name. Leave empty to list available.")
- async def slash_personality(interaction: discord.Interaction, name: str = ""):
- await self._run_simple_slash(interaction, f"/personality {name}".strip())
-
- @tree.command(name="retry", description="Retry your last message")
- async def slash_retry(interaction: discord.Interaction):
- await self._run_simple_slash(interaction, "/retry", "Retrying~")
-
- @tree.command(name="undo", description="Remove the last exchange")
- async def slash_undo(interaction: discord.Interaction):
- await self._run_simple_slash(interaction, "/undo")
-
- @tree.command(name="status", description="Show Hermes session status")
- async def slash_status(interaction: discord.Interaction):
- await self._run_simple_slash(interaction, "/status", "Status sent~")
-
- @tree.command(name="sethome", description="Set this chat as the home channel")
- async def slash_sethome(interaction: discord.Interaction):
- await self._run_simple_slash(interaction, "/sethome")
-
- @tree.command(name="stop", description="Stop the running Hermes agent")
- async def slash_stop(interaction: discord.Interaction):
- await self._run_simple_slash(interaction, "/stop", "Stop requested~")
-
- @tree.command(name="compress", description="Compress conversation context")
- async def slash_compress(interaction: discord.Interaction):
- await self._run_simple_slash(interaction, "/compress")
-
- @tree.command(name="title", description="Set or show the session title")
- @discord.app_commands.describe(name="Session title. Leave empty to show current.")
- async def slash_title(interaction: discord.Interaction, name: str = ""):
- await self._run_simple_slash(interaction, f"/title {name}".strip())
-
- @tree.command(name="resume", description="Resume a previously-named session")
- @discord.app_commands.describe(name="Session name to resume. Leave empty to list sessions.")
- async def slash_resume(interaction: discord.Interaction, name: str = ""):
- await self._run_simple_slash(interaction, f"/resume {name}".strip())
-
- @tree.command(name="usage", description="Show token usage for this session")
- async def slash_usage(interaction: discord.Interaction):
- await self._run_simple_slash(interaction, "/usage")
-
- @tree.command(name="provider", description="Show available providers")
- async def slash_provider(interaction: discord.Interaction):
- await self._run_simple_slash(interaction, "/provider")
-
- @tree.command(name="help", description="Show available commands")
- async def slash_help(interaction: discord.Interaction):
- await self._run_simple_slash(interaction, "/help")
-
- @tree.command(name="insights", description="Show usage insights and analytics")
- @discord.app_commands.describe(days="Number of days to analyze (default: 7)")
- async def slash_insights(interaction: discord.Interaction, days: int = 7):
- await self._run_simple_slash(interaction, f"/insights {days}")
-
- @tree.command(name="reload-mcp", description="Reload MCP servers from config")
- async def slash_reload_mcp(interaction: discord.Interaction):
- await self._run_simple_slash(interaction, "/reload-mcp")
-
- @tree.command(name="voice", description="Toggle voice reply mode")
- @discord.app_commands.describe(mode="Voice mode: on, off, tts, channel, leave, or status")
- @discord.app_commands.choices(mode=[
- discord.app_commands.Choice(name="channel — join your voice channel", value="channel"),
- discord.app_commands.Choice(name="leave — leave voice channel", value="leave"),
- discord.app_commands.Choice(name="on — voice reply to voice messages", value="on"),
- discord.app_commands.Choice(name="tts — voice reply to all messages", value="tts"),
- discord.app_commands.Choice(name="off — text only", value="off"),
- discord.app_commands.Choice(name="status — show current mode", value="status"),
- ])
- async def slash_voice(interaction: discord.Interaction, mode: str = ""):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, f"/voice {mode}".strip())
- await self.handle_message(event)
-
- @tree.command(name="update", description="Update Hermes Agent to the latest version")
- async def slash_update(interaction: discord.Interaction):
- await self._run_simple_slash(interaction, "/update", "Update initiated~")
-
- @tree.command(name="thread", description="Create a new thread and start a Hermes session in it")
- @discord.app_commands.describe(
- name="Thread name",
- message="Optional first message to send to Hermes in the thread",
- auto_archive_duration="Auto-archive in minutes (60, 1440, 4320, 10080)",
- )
- async def slash_thread(
- interaction: discord.Interaction,
- name: str,
- message: str = "",
- auto_archive_duration: int = 1440,
- ):
- await interaction.response.defer(ephemeral=True)
- await self._handle_thread_create_slash(interaction, name, message, auto_archive_duration)
+ discord_interactions.register_slash_commands(self._client.tree, self)
def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:
"""Build a MessageEvent from a Discord slash command interaction."""
- is_dm = isinstance(interaction.channel, discord.DMChannel)
- chat_type = "dm" if is_dm else "group"
- chat_name = ""
- if not is_dm and hasattr(interaction.channel, "name"):
- chat_name = interaction.channel.name
- if hasattr(interaction.channel, "guild") and interaction.channel.guild:
- chat_name = f"{interaction.channel.guild.name} / #{chat_name}"
-
- # Get channel topic (if available)
- chat_topic = getattr(interaction.channel, "topic", None)
-
- source = self.build_source(
- chat_id=str(interaction.channel_id),
- chat_name=chat_name,
- chat_type=chat_type,
- user_id=str(interaction.user.id),
- user_name=interaction.user.display_name,
- chat_topic=chat_topic,
- )
-
- msg_type = MessageType.COMMAND if text.startswith("/") else MessageType.TEXT
- return MessageEvent(
- text=text,
- message_type=msg_type,
- source=source,
- raw_message=interaction,
- )
+ return discord_interactions.build_slash_event(self, interaction, text)
# ------------------------------------------------------------------
# Thread creation helpers
@@ -1539,34 +1672,14 @@ async def _handle_thread_create_slash(
auto_archive_duration: int = 1440,
) -> None:
"""Create a Discord thread from a slash command and start a session in it."""
- result = await self._create_thread(
+ await discord_threads.handle_thread_create_slash(
+ self,
interaction,
- name=name,
- message=message,
- auto_archive_duration=auto_archive_duration,
+ name,
+ message,
+ auto_archive_duration,
)
- if not result.get("success"):
- error = result.get("error", "unknown error")
- await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True)
- return
-
- thread_id = result.get("thread_id")
- thread_name = result.get("thread_name") or name
-
- # Tell the user where the thread is
- link = f"<#{thread_id}>" if thread_id else f"**{thread_name}**"
- await interaction.followup.send(f"Created thread {link}", ephemeral=True)
-
- # Track thread participation so follow-ups don't require @mention
- if thread_id:
- self._track_thread(thread_id)
-
- # If a message was provided, kick off a new Hermes session in the thread
- starter = (message or "").strip()
- if starter and thread_id:
- await self._dispatch_thread_session(interaction, thread_id, thread_name, starter)
-
async def _dispatch_thread_session(
self,
interaction: discord.Interaction,
@@ -1575,50 +1688,21 @@ async def _dispatch_thread_session(
text: str,
) -> None:
"""Build a MessageEvent pointing at a thread and send it through handle_message."""
- guild_name = ""
- if hasattr(interaction, "guild") and interaction.guild:
- guild_name = interaction.guild.name
-
- chat_name = f"{guild_name} / {thread_name}" if guild_name else thread_name
-
- source = self.build_source(
- chat_id=thread_id,
- chat_name=chat_name,
- chat_type="thread",
- user_id=str(interaction.user.id),
- user_name=interaction.user.display_name,
- thread_id=thread_id,
- )
-
- event = MessageEvent(
- text=text,
- message_type=MessageType.TEXT,
- source=source,
- raw_message=interaction,
+ await discord_threads.dispatch_thread_session(
+ self,
+ interaction,
+ thread_id,
+ thread_name,
+ text,
)
- await self.handle_message(event)
def _thread_parent_channel(self, channel: Any) -> Any:
"""Return the parent text channel when invoked from a thread."""
- return getattr(channel, "parent", None) or channel
+ return discord_threads.thread_parent_channel(channel)
async def _resolve_interaction_channel(self, interaction: discord.Interaction) -> Optional[Any]:
"""Return the interaction channel, fetching it if the payload is partial."""
- channel = getattr(interaction, "channel", None)
- if channel is not None:
- return channel
- if not self._client:
- return None
- channel_id = getattr(interaction, "channel_id", None)
- if channel_id is None:
- return None
- channel = self._client.get_channel(int(channel_id))
- if channel is not None:
- return channel
- try:
- return await self._client.fetch_channel(int(channel_id))
- except Exception:
- return None
+ return await discord_threads.resolve_interaction_channel(self._client, interaction)
async def _create_thread(
self,
@@ -1634,62 +1718,14 @@ async def _create_thread(
that (e.g. permission issues), falls back to sending a seed message
and creating the thread from it.
"""
- name = (name or "").strip()
- if not name:
- return {"error": "Thread name is required."}
-
- if auto_archive_duration not in VALID_THREAD_AUTO_ARCHIVE_MINUTES:
- allowed = ", ".join(str(v) for v in sorted(VALID_THREAD_AUTO_ARCHIVE_MINUTES))
- return {"error": f"auto_archive_duration must be one of: {allowed}."}
-
- channel = await self._resolve_interaction_channel(interaction)
- if channel is None:
- return {"error": "Could not resolve the current Discord channel."}
- if isinstance(channel, discord.DMChannel):
- return {"error": "Discord threads can only be created inside server text channels, not DMs."}
-
- parent_channel = self._thread_parent_channel(channel)
- if parent_channel is None:
- return {"error": "Could not determine a parent text channel for the new thread."}
-
- display_name = getattr(getattr(interaction, "user", None), "display_name", None) or "unknown user"
- reason = f"Requested by {display_name} via /thread"
- starter_message = (message or "").strip()
-
- try:
- thread = await parent_channel.create_thread(
- name=name,
- auto_archive_duration=auto_archive_duration,
- reason=reason,
- )
- if starter_message:
- await thread.send(starter_message)
- return {
- "success": True,
- "thread_id": str(thread.id),
- "thread_name": getattr(thread, "name", None) or name,
- }
- except Exception as direct_error:
- try:
- seed_content = starter_message or f"\U0001f9f5 Thread created by Hermes: **{name}**"
- seed_msg = await parent_channel.send(seed_content)
- thread = await seed_msg.create_thread(
- name=name,
- auto_archive_duration=auto_archive_duration,
- reason=reason,
- )
- return {
- "success": True,
- "thread_id": str(thread.id),
- "thread_name": getattr(thread, "name", None) or name,
- }
- except Exception as fallback_error:
- return {
- "error": (
- "Discord rejected direct thread creation and the fallback also failed. "
- f"Direct error: {direct_error}. Fallback error: {fallback_error}"
- )
- }
+ return await discord_threads.create_thread(
+ self._client,
+ interaction,
+ name,
+ message,
+ auto_archive_duration,
+ discord_threads.resolve_interaction_channel,
+ )
# ------------------------------------------------------------------
# Auto-thread helpers
@@ -1700,18 +1736,7 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
Returns the created thread object, or ``None`` on failure.
"""
- # Build a short thread name from the message
- content = (message.content or "").strip()
- thread_name = content[:80] if content else "Hermes"
- if len(content) > 80:
- thread_name = thread_name[:77] + "..."
-
- try:
- thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)
- return thread
- except Exception as e:
- logger.warning("[%s] Auto-thread creation failed: %s", self.name, e)
- return None
+ return await discord_threads.auto_create_thread(message)
async def send_exec_approval(
self, chat_id: str, command: str, approval_id: str
@@ -1725,9 +1750,9 @@ async def send_exec_approval(
return SendResult(success=False, error="Not connected")
try:
- channel = self._client.get_channel(int(chat_id))
+ channel = await discord_delivery.resolve_channel(self._client, chat_id)
if not channel:
- channel = await self._client.fetch_channel(int(chat_id))
+ return SendResult(success=False, error=f"Channel {chat_id} not found")
# Discord embed description limit is 4096; show full command up to that
max_desc = 4088
@@ -1739,12 +1764,16 @@ async def send_exec_approval(
)
embed.set_footer(text=f"Approval ID: {approval_id}")
- view = ExecApprovalView(
+ view = discord_interactions.create_exec_approval_view(
+ self,
approval_id=approval_id,
allowed_user_ids=self._allowed_user_ids,
+ runtime=self._component_runtime,
)
msg = await channel.send(embed=embed, view=view)
+ if hasattr(view, "bind_message"):
+ view.bind_message(str(msg.id))
return SendResult(success=True, message_id=str(msg.id))
except Exception as e:
@@ -1752,86 +1781,208 @@ async def send_exec_approval(
def _get_parent_channel_id(self, channel: Any) -> Optional[str]:
"""Return the parent channel ID for a Discord thread-like channel, if present."""
- parent = getattr(channel, "parent", None)
- if parent is not None and getattr(parent, "id", None) is not None:
- return str(parent.id)
- parent_id = getattr(channel, "parent_id", None)
- if parent_id is not None:
- return str(parent_id)
- return None
+ return discord_intake.get_parent_channel_id(channel)
+
+ @staticmethod
+ def _runtime_state_path():
+ """Path to the persisted Discord runtime controls."""
+ return discord_runtime_state.runtime_state_path()
+
+ @classmethod
+ def _load_runtime_state(cls) -> tuple[dict[str, discord_runtime_state.DiscordThreadBinding], dict[str, str]]:
+ """Load persisted Discord runtime controls."""
+ return discord_runtime_state.load_runtime_state()
+
+ def _save_runtime_state(self) -> None:
+ """Persist Discord thread bindings and activation overrides."""
+ discord_runtime_state.save_runtime_state(
+ self._thread_bindings,
+ self._activation_overrides,
+ )
+
+ def get_thread_binding(self, thread_id: str) -> Optional[discord_runtime_state.DiscordThreadBinding]:
+ """Return the persisted focus binding for a thread, if any."""
+ return self._thread_bindings.get(str(thread_id))
+
+ def list_thread_bindings(self, parent_chat_id: Optional[str] = None) -> list[discord_runtime_state.DiscordThreadBinding]:
+ """Return all thread bindings, optionally scoped to one parent channel."""
+ bindings = list(self._thread_bindings.values())
+ if parent_chat_id is None:
+ return sorted(bindings, key=lambda binding: (binding.parent_chat_id or "", binding.chat_name, binding.thread_id))
+ target_parent = str(parent_chat_id)
+ return sorted(
+ [binding for binding in bindings if str(binding.parent_chat_id or "") == target_parent],
+ key=lambda binding: (binding.chat_name, binding.thread_id),
+ )
+
+ def focus_thread_binding(
+ self,
+ *,
+ thread_id: str,
+ session_key: str,
+ chat_name: str,
+ parent_chat_id: Optional[str],
+ bound_by: str,
+ idle_timeout_minutes: Optional[int] = None,
+ max_age_minutes: Optional[int] = None,
+ ) -> discord_runtime_state.DiscordThreadBinding:
+ """Create or update a persisted focus binding for a Discord thread."""
+ now = datetime.now().isoformat()
+ existing = self._thread_bindings.get(str(thread_id))
+ binding = discord_runtime_state.DiscordThreadBinding(
+ thread_id=str(thread_id),
+ session_key=session_key,
+ chat_id=str(thread_id),
+ parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
+ chat_name=chat_name,
+ bound_by=bound_by,
+ bound_at=existing.bound_at if existing and existing.bound_at else now,
+ last_activity_at=now,
+ idle_timeout_minutes=(
+ int(idle_timeout_minutes)
+ if idle_timeout_minutes is not None
+ else (existing.idle_timeout_minutes if existing else discord_runtime_state.DEFAULT_THREAD_BINDING_IDLE_MINUTES)
+ ),
+ max_age_minutes=(
+ int(max_age_minutes)
+ if max_age_minutes is not None
+ else (existing.max_age_minutes if existing else discord_runtime_state.DEFAULT_THREAD_BINDING_MAX_AGE_MINUTES)
+ ),
+ )
+ self._thread_bindings[binding.thread_id] = binding
+ self._track_thread(binding.thread_id)
+ self._save_runtime_state()
+ return binding
+
+ def unfocus_thread_binding(self, thread_id: str) -> Optional[discord_runtime_state.DiscordThreadBinding]:
+ """Remove a persisted focus binding for a thread."""
+ binding = self._thread_bindings.pop(str(thread_id), None)
+ if binding:
+ self._bot_participated_threads.discard(str(thread_id))
+ self._save_participated_threads()
+ self._save_runtime_state()
+ return binding
+
+ def update_thread_binding_limits(
+ self,
+ thread_id: str,
+ *,
+ idle_timeout_minutes: Optional[int] = None,
+ max_age_minutes: Optional[int] = None,
+ ) -> Optional[discord_runtime_state.DiscordThreadBinding]:
+ """Update idle/max-age settings for an existing thread binding."""
+ existing = self._thread_bindings.get(str(thread_id))
+ if existing is None:
+ return None
+ updated = discord_runtime_state.DiscordThreadBinding(
+ thread_id=existing.thread_id,
+ session_key=existing.session_key,
+ chat_id=existing.chat_id,
+ parent_chat_id=existing.parent_chat_id,
+ chat_name=existing.chat_name,
+ bound_by=existing.bound_by,
+ bound_at=existing.bound_at,
+ last_activity_at=existing.last_activity_at,
+ idle_timeout_minutes=(
+ int(idle_timeout_minutes)
+ if idle_timeout_minutes is not None
+ else existing.idle_timeout_minutes
+ ),
+ max_age_minutes=(
+ int(max_age_minutes)
+ if max_age_minutes is not None
+ else existing.max_age_minutes
+ ),
+ )
+ self._thread_bindings[updated.thread_id] = updated
+ self._save_runtime_state()
+ return updated
+
+ def touch_thread_binding(
+ self,
+ thread_id: str,
+ *,
+ now: Optional[datetime] = None,
+ ) -> Optional[discord_runtime_state.DiscordThreadBinding]:
+ """Refresh last-activity time for a thread binding."""
+ binding = self._thread_bindings.get(str(thread_id))
+ if binding is None:
+ return None
+ updated = discord_runtime_state.touch_binding(binding, now=now)
+ self._thread_bindings[updated.thread_id] = updated
+ self._save_runtime_state()
+ return updated
+
+ def expire_thread_binding_if_needed(
+ self,
+ thread_id: str,
+ *,
+ now: Optional[datetime] = None,
+ ) -> Optional[str]:
+ """Expire a focused thread binding when idle/max-age thresholds are reached."""
+ binding = self._thread_bindings.get(str(thread_id))
+ if binding is None:
+ return None
+ reason = discord_runtime_state.binding_expiration_reason(binding, now=now)
+ if reason is None:
+ return None
+ self.unfocus_thread_binding(thread_id)
+ return reason
+
+ def get_activation_mode(self, chat_id: str) -> Optional[str]:
+ """Return the Discord activation override for a chat, if any."""
+ return self._activation_overrides.get(str(chat_id))
+
+ def set_activation_mode(self, chat_id: str, mode: str) -> Optional[str]:
+ """Set or clear the Discord activation override for a chat."""
+ normalized = str(mode or "").strip().lower()
+ key = str(chat_id)
+ if normalized in {"", "inherit", "default", "reset"}:
+ previous = self._activation_overrides.pop(key, None)
+ self._save_runtime_state()
+ return previous
+ if normalized not in {"mention", "always"}:
+ raise ValueError(f"Unsupported activation mode: {mode}")
+ self._activation_overrides[key] = normalized
+ self._save_runtime_state()
+ return normalized
def _is_forum_parent(self, channel: Any) -> bool:
"""Best-effort check for whether a Discord channel is a forum channel."""
- if channel is None:
- return False
- forum_cls = getattr(discord, "ForumChannel", None)
- if forum_cls and isinstance(channel, forum_cls):
- return True
- channel_type = getattr(channel, "type", None)
- if channel_type is not None:
- type_value = getattr(channel_type, "value", channel_type)
- if type_value == 15:
- return True
- return False
+ return discord_intake.is_forum_parent(channel)
def _format_thread_chat_name(self, thread: Any) -> str:
"""Build a readable chat name for thread-like Discord channels, including forum context when available."""
- thread_name = getattr(thread, "name", None) or str(getattr(thread, "id", "thread"))
- parent = getattr(thread, "parent", None)
- guild = getattr(thread, "guild", None) or getattr(parent, "guild", None)
- guild_name = getattr(guild, "name", None)
- parent_name = getattr(parent, "name", None)
-
- if self._is_forum_parent(parent) and guild_name and parent_name:
- return f"{guild_name} / {parent_name} / {thread_name}"
- if parent_name and guild_name:
- return f"{guild_name} / #{parent_name} / {thread_name}"
- if parent_name:
- return f"{parent_name} / {thread_name}"
- return thread_name
+ return discord_intake.format_thread_chat_name(thread, self._is_forum_parent)
# ------------------------------------------------------------------
# Thread participation persistence
# ------------------------------------------------------------------
@staticmethod
- def _thread_state_path() -> Path:
+ def _thread_state_path():
"""Path to the persisted thread participation set."""
- from hermes_cli.config import get_hermes_home
- return get_hermes_home() / "discord_threads.json"
+ return discord_state.thread_state_path()
@classmethod
def _load_participated_threads(cls) -> set:
"""Load persisted thread IDs from disk."""
- path = cls._thread_state_path()
- try:
- if path.exists():
- data = json.loads(path.read_text(encoding="utf-8"))
- if isinstance(data, list):
- return set(data)
- except Exception as e:
- logger.debug("Could not load discord thread state: %s", e)
- return set()
+ return discord_state.load_participated_threads()
def _save_participated_threads(self) -> None:
"""Persist the current thread set to disk (best-effort)."""
- path = self._thread_state_path()
- try:
- # Trim to most recent entries if over cap
- thread_list = list(self._bot_participated_threads)
- if len(thread_list) > self._MAX_TRACKED_THREADS:
- thread_list = thread_list[-self._MAX_TRACKED_THREADS:]
- self._bot_participated_threads = set(thread_list)
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(json.dumps(thread_list), encoding="utf-8")
- except Exception as e:
- logger.debug("Could not save discord thread state: %s", e)
+ self._bot_participated_threads = discord_state.save_participated_threads(
+ self._bot_participated_threads,
+ max_threads=self._MAX_TRACKED_THREADS,
+ )
def _track_thread(self, thread_id: str) -> None:
"""Add a thread to the participation set and persist."""
- if thread_id not in self._bot_participated_threads:
- self._bot_participated_threads.add(thread_id)
- self._save_participated_threads()
+ self._bot_participated_threads = discord_state.track_thread(
+ self._bot_participated_threads,
+ thread_id,
+ max_threads=self._MAX_TRACKED_THREADS,
+ )
async def _handle_message(self, message: DiscordMessage) -> None:
"""Handle incoming Discord messages."""
@@ -1846,40 +1997,66 @@ async def _handle_message(self, message: DiscordMessage) -> None:
thread_id = None
parent_channel_id = None
- is_thread = isinstance(message.channel, discord.Thread)
+ is_thread = isinstance(message.channel, discord.Thread) or getattr(message.channel, "parent", None) is not None
+ policy = self._get_discord_policy()
+ active_binding = None
if is_thread:
thread_id = str(message.channel.id)
parent_channel_id = self._get_parent_channel_id(message.channel)
+ self.expire_thread_binding_if_needed(thread_id)
+ active_binding = self.get_thread_binding(thread_id)
if not isinstance(message.channel, discord.DMChannel):
- free_channels_raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "")
- free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()}
+ free_channels = policy.free_response_channels
channel_ids = {str(message.channel.id)}
if parent_channel_id:
channel_ids.add(parent_channel_id)
- require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no")
+ require_mention = policy.require_mention
is_free_channel = bool(channel_ids & free_channels)
+ for channel_id in channel_ids:
+ activation_mode = self.get_activation_mode(channel_id)
+ if activation_mode == "always":
+ require_mention = False
+ is_free_channel = True
+ break
+ if activation_mode == "mention":
+ require_mention = True
+ is_free_channel = False
# Skip the mention check if the message is in a thread where
# the bot has previously participated (auto-created or replied in).
- in_bot_thread = is_thread and thread_id in self._bot_participated_threads
+ in_bot_thread = bool(
+ is_thread
+ and thread_id
+ and (
+ thread_id in self._bot_participated_threads
+ or active_binding is not None
+ )
+ )
+ is_mentioned = bool(self._client.user and self._client.user in message.mentions)
+
+ if discord_intake.should_skip_for_mention(
+ require_mention=require_mention,
+ is_free_channel=is_free_channel,
+ in_bot_thread=in_bot_thread,
+ is_mentioned=is_mentioned,
+ ):
+ return
- if require_mention and not is_free_channel and not in_bot_thread:
- if self._client.user not in message.mentions:
- return
+ if is_mentioned and self._client.user:
+ message.content = discord_intake.strip_mention(message.content, self._client.user.id)
- if self._client.user and self._client.user in message.mentions:
- message.content = message.content.replace(f"<@{self._client.user.id}>", "").strip()
- message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip()
+ inline_command, remaining_text = discord_native_commands.extract_inline_shortcut(
+ message.content
+ )
# Auto-thread: when enabled, automatically create a thread for every
# @mention in a text channel so each conversation is isolated (like Slack).
# Messages already inside threads or DMs are unaffected.
auto_threaded_channel = None
if not is_thread and not isinstance(message.channel, discord.DMChannel):
- auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes")
- if auto_thread:
+ if policy.auto_thread:
thread = await self._auto_create_thread(message)
if thread:
is_thread = True
@@ -1888,22 +2065,9 @@ async def _handle_message(self, message: DiscordMessage) -> None:
self._track_thread(thread_id)
# Determine message type
- msg_type = MessageType.TEXT
- if message.content.startswith("/"):
- msg_type = MessageType.COMMAND
- elif message.attachments:
- # Check attachment types
- for att in message.attachments:
- if att.content_type:
- if att.content_type.startswith("image/"):
- msg_type = MessageType.PHOTO
- elif att.content_type.startswith("video/"):
- msg_type = MessageType.VIDEO
- elif att.content_type.startswith("audio/"):
- msg_type = MessageType.AUDIO
- else:
- msg_type = MessageType.DOCUMENT
- break
+ msg_type = MessageType(
+ discord_intake.classify_message_type(message.content, message.attachments)
+ )
# When auto-threading kicked in, route responses to the new thread
effective_channel = auto_threaded_channel or message.channel
@@ -1986,100 +2150,28 @@ async def _handle_message(self, message: DiscordMessage) -> None:
timestamp=message.created_at,
)
+ if inline_command:
+ inline_event = MessageEvent(
+ text=f"/{inline_command}",
+ message_type=MessageType.COMMAND,
+ source=source,
+ raw_message=message,
+ message_id=str(message.id),
+ reply_to_message_id=event.reply_to_message_id,
+ timestamp=message.created_at,
+ metadata={"is_inline_shortcut": True},
+ )
+ await self.handle_message(inline_event)
+ if not remaining_text and not media_urls:
+ return
+ event.text = remaining_text
+ event.message_type = MessageType.TEXT
+
# Track thread participation so the bot won't require @mention for
# follow-up messages in threads it has already engaged in.
if thread_id:
self._track_thread(thread_id)
+ if active_binding is not None:
+ self.touch_thread_binding(thread_id)
await self.handle_message(event)
-
-
-# ---------------------------------------------------------------------------
-# Discord UI Components (outside the adapter class)
-# ---------------------------------------------------------------------------
-
-if DISCORD_AVAILABLE:
-
- class ExecApprovalView(discord.ui.View):
- """
- Interactive button view for exec approval of dangerous commands.
-
- Shows three buttons: Allow Once (green), Always Allow (blue), Deny (red).
- Only users in the allowed list can click. The view times out after 5 minutes.
- """
-
- def __init__(self, approval_id: str, allowed_user_ids: set):
- super().__init__(timeout=300) # 5-minute timeout
- self.approval_id = approval_id
- self.allowed_user_ids = allowed_user_ids
- self.resolved = False
-
- def _check_auth(self, interaction: discord.Interaction) -> bool:
- """Verify the user clicking is authorized."""
- if not self.allowed_user_ids:
- return True # No allowlist = anyone can approve
- return str(interaction.user.id) in self.allowed_user_ids
-
- async def _resolve(
- self, interaction: discord.Interaction, action: str, color: discord.Color
- ):
- """Resolve the approval and update the message."""
- if self.resolved:
- await interaction.response.send_message(
- "This approval has already been resolved~", ephemeral=True
- )
- return
-
- if not self._check_auth(interaction):
- await interaction.response.send_message(
- "You're not authorized to approve commands~", ephemeral=True
- )
- return
-
- self.resolved = True
-
- # Update the embed with the decision
- embed = interaction.message.embeds[0] if interaction.message.embeds else None
- if embed:
- embed.color = color
- embed.set_footer(text=f"{action} by {interaction.user.display_name}")
-
- # Disable all buttons
- for child in self.children:
- child.disabled = True
-
- await interaction.response.edit_message(embed=embed, view=self)
-
- # Store the approval decision
- try:
- from tools.approval import approve_permanent
- if action == "allow_once":
- pass # One-time approval handled by gateway
- elif action == "allow_always":
- approve_permanent(self.approval_id)
- except ImportError:
- pass
-
- @discord.ui.button(label="Allow Once", style=discord.ButtonStyle.green)
- async def allow_once(
- self, interaction: discord.Interaction, button: discord.ui.Button
- ):
- await self._resolve(interaction, "allow_once", discord.Color.green())
-
- @discord.ui.button(label="Always Allow", style=discord.ButtonStyle.blurple)
- async def allow_always(
- self, interaction: discord.Interaction, button: discord.ui.Button
- ):
- await self._resolve(interaction, "allow_always", discord.Color.blue())
-
- @discord.ui.button(label="Deny", style=discord.ButtonStyle.red)
- async def deny(
- self, interaction: discord.Interaction, button: discord.ui.Button
- ):
- await self._resolve(interaction, "deny", discord.Color.red())
-
- async def on_timeout(self):
- """Handle view timeout -- disable buttons and mark as expired."""
- self.resolved = True
- for child in self.children:
- child.disabled = True
diff --git a/gateway/platforms/discord_impl/__init__.py b/gateway/platforms/discord_impl/__init__.py
new file mode 100644
index 000000000000..59ad48e4c8ea
--- /dev/null
+++ b/gateway/platforms/discord_impl/__init__.py
@@ -0,0 +1,10 @@
+# Internal implementation package for Discord v2.
+# The public import surface remains gateway.platforms.discord (discord.py).
+# External consumers should NEVER import from this package directly.
+# Imports flow: discord.py -> discord_impl.*, not the reverse.
+"""Internal implementation package for Discord v2.
+
+This package exists only to house Discord-specific implementation modules
+behind the stable public adapter surface in ``gateway.platforms.discord``.
+It is not a public API.
+"""
diff --git a/gateway/platforms/discord_impl/command_sessions.py b/gateway/platforms/discord_impl/command_sessions.py
new file mode 100644
index 000000000000..213a7d7ee515
--- /dev/null
+++ b/gateway/platforms/discord_impl/command_sessions.py
@@ -0,0 +1,102 @@
+"""Discord command-session helpers."""
+
+from __future__ import annotations
+
+from dataclasses import asdict, is_dataclass, replace
+import re
+from typing import Any, Optional, Tuple
+
+from gateway.platforms.base import MessageEvent, MessageType
+
+try:
+ import discord
+except ImportError: # pragma: no cover - import guard
+ discord = None
+
+
+INLINE_SHORTCUT_COMMANDS = ("help", "commands", "status", "whoami", "id")
+_INLINE_SHORTCUT_RE = re.compile(
+ r"(? Tuple[Optional[str], str]:
+ """Return the first supported inline shortcut and the stripped remaining text."""
+ match = _INLINE_SHORTCUT_RE.search(text)
+ if not match:
+ return None, text
+
+ command = match.group(1).lstrip("/").lower()
+ remaining = (text[:match.start()] + " " + text[match.end():]).strip()
+ remaining = re.sub(r"\s{2,}", " ", remaining)
+ return command, remaining
+
+
+def _resolve_target_chat(adapter: Any, interaction: Any) -> tuple[str, str, Optional[str]]:
+ dm_channel_cls = getattr(discord, "DMChannel", None) if discord else None
+ thread_cls = getattr(discord, "Thread", None) if discord else None
+
+ channel = interaction.channel
+ channel_id = str(getattr(channel, "id", getattr(interaction, "channel_id", "")) or "")
+ is_dm = isinstance(channel, dm_channel_cls) if dm_channel_cls else False
+ is_thread = isinstance(channel, thread_cls) if thread_cls else False
+ thread_id = channel_id if is_thread else None
+
+ if is_dm:
+ chat_type = "dm"
+ chat_name = getattr(interaction.user, "display_name", None) or str(interaction.user.id)
+ return chat_type, chat_name, thread_id
+
+ if is_thread:
+ chat_type = "thread"
+ formatter = getattr(adapter, "_format_thread_chat_name", None)
+ if callable(formatter):
+ return chat_type, formatter(channel), thread_id
+ return chat_type, getattr(channel, "name", channel_id or "unknown"), thread_id
+
+ chat_type = "group"
+ chat_name = getattr(channel, "name", channel_id or "unknown")
+ guild = getattr(channel, "guild", None)
+ if guild:
+ chat_name = f"{guild.name} / #{chat_name}"
+ return chat_type, chat_name, thread_id
+
+
+def build_slash_event(adapter: Any, interaction: Any, text: str) -> MessageEvent:
+ """Build a slash MessageEvent with isolated command-session metadata."""
+ chat_type, chat_name, thread_id = _resolve_target_chat(adapter, interaction)
+ chat_topic = getattr(interaction.channel, "topic", None)
+ user_id = str(interaction.user.id)
+
+ target_source = adapter.build_source(
+ chat_id=str(interaction.channel_id),
+ chat_name=chat_name,
+ chat_type=chat_type,
+ user_id=user_id,
+ user_name=interaction.user.display_name,
+ thread_id=thread_id,
+ chat_topic=chat_topic,
+ )
+ session_namespace = f"slash:{user_id}"
+ if is_dataclass(target_source):
+ session_source = replace(target_source, session_namespace=session_namespace)
+ else:
+ source_kwargs = dict(asdict(target_source)) if hasattr(target_source, "__dataclass_fields__") else dict(
+ getattr(target_source, "__dict__", {})
+ )
+ source_kwargs["session_namespace"] = session_namespace
+ session_source = adapter.build_source(**source_kwargs)
+
+ msg_type = MessageType.COMMAND if text.startswith("/") else MessageType.TEXT
+ return MessageEvent(
+ text=text,
+ message_type=msg_type,
+ source=target_source,
+ raw_message=interaction,
+ metadata={
+ "session_source": session_source,
+ "command_target_source": target_source,
+ "is_native_slash": True,
+ },
+ )
diff --git a/gateway/platforms/discord_impl/components.py b/gateway/platforms/discord_impl/components.py
new file mode 100644
index 000000000000..af02f331281d
--- /dev/null
+++ b/gateway/platforms/discord_impl/components.py
@@ -0,0 +1,891 @@
+"""Generic Discord component runtime for buttons, selects, and modals."""
+
+from __future__ import annotations
+
+import secrets
+import time
+from dataclasses import dataclass, field
+from typing import Any, Awaitable, Callable, Optional, Sequence
+
+try:
+ import discord
+
+ DISCORD_AVAILABLE = True
+except ImportError: # pragma: no cover - import guard
+ discord = None
+ DISCORD_AVAILABLE = False
+
+
+COMPONENT_CUSTOM_ID_PREFIX = "hermes:cmp"
+MODAL_CUSTOM_ID_PREFIX = "hermes:mdl"
+DEFAULT_UNAUTHORIZED_MESSAGE = "You're not authorized to use this interaction~"
+DEFAULT_MISSING_MESSAGE = "This interaction is no longer available~"
+DEFAULT_EXPIRED_MESSAGE = "This interaction has expired~"
+DEFAULT_USED_MESSAGE = "This interaction has already been used~"
+
+ButtonHandler = Callable[["DiscordComponentInvocation"], Awaitable[bool | None] | bool | None]
+ModalHandler = Callable[["DiscordModalInvocation"], Awaitable[bool | None] | bool | None]
+
+
+def _now() -> float:
+ return time.time()
+
+
+def _new_entry_id(prefix: str) -> str:
+ return f"{prefix}{secrets.token_urlsafe(6)}"
+
+
+def encode_component_custom_id(entry_id: str) -> str:
+ return f"{COMPONENT_CUSTOM_ID_PREFIX}:{entry_id}"
+
+
+def decode_component_custom_id(custom_id: str) -> Optional[str]:
+ prefix = f"{COMPONENT_CUSTOM_ID_PREFIX}:"
+ if not custom_id.startswith(prefix):
+ return None
+ return custom_id[len(prefix):] or None
+
+
+def encode_modal_custom_id(entry_id: str) -> str:
+ return f"{MODAL_CUSTOM_ID_PREFIX}:{entry_id}"
+
+
+def decode_modal_custom_id(custom_id: str) -> Optional[str]:
+ prefix = f"{MODAL_CUSTOM_ID_PREFIX}:"
+ if not custom_id.startswith(prefix):
+ return None
+ return custom_id[len(prefix):] or None
+
+
+def _button_style(style: str) -> Any:
+ button_style = getattr(discord, "ButtonStyle", None)
+ if button_style is None:
+ return style
+ mapping = {
+ "primary": getattr(button_style, "primary", getattr(button_style, "blurple", 1)),
+ "secondary": getattr(button_style, "secondary", 2),
+ "success": getattr(button_style, "success", getattr(button_style, "green", 3)),
+ "danger": getattr(button_style, "danger", getattr(button_style, "red", 4)),
+ "link": getattr(button_style, "link", 5),
+ }
+ return mapping.get(style, mapping["secondary"])
+
+
+def _text_style(style: str) -> Any:
+ text_style = getattr(discord, "TextStyle", None)
+ if text_style is None:
+ return style
+ if style == "paragraph":
+ return getattr(text_style, "paragraph", getattr(text_style, "long", 2))
+ return getattr(text_style, "short", 1)
+
+
+def _select_option(**kwargs: Any) -> Any:
+ option_cls = getattr(discord, "SelectOption", None)
+ if option_cls is None:
+ return kwargs
+ return option_cls(**kwargs)
+
+
+def _coerce_choice_values(values: Sequence[Any] | None) -> tuple[str, ...]:
+ if not values:
+ return ()
+ return tuple(str(value) for value in values)
+
+
+async def _await_maybe(result: Any) -> Any:
+ if hasattr(result, "__await__"):
+ return await result
+ return result
+
+
+async def send_ephemeral_message(interaction: Any, content: str) -> None:
+ """Send an ephemeral response, preferring the initial interaction response."""
+ response = getattr(interaction, "response", None)
+ if response is not None and hasattr(response, "send_message"):
+ is_done = getattr(response, "is_done", None)
+ if not callable(is_done) or not is_done():
+ await response.send_message(content, ephemeral=True)
+ return
+
+ followup = getattr(interaction, "followup", None)
+ if followup is not None and hasattr(followup, "send"):
+ await followup.send(content, ephemeral=True)
+
+
+@dataclass(frozen=True)
+class DiscordSelectOptionSpec:
+ label: str
+ value: str
+ description: Optional[str] = None
+ default: bool = False
+
+
+@dataclass(frozen=True)
+class DiscordButtonSpec:
+ label: str
+ handler: Optional[ButtonHandler] = None
+ style: str = "secondary"
+ row: Optional[int] = None
+ disabled: bool = False
+ emoji: Optional[Any] = None
+ url: Optional[str] = None
+ allowed_user_ids: tuple[str, ...] = ()
+ reusable: bool = False
+ timeout_seconds: Optional[float] = 300.0
+ state: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class DiscordSelectSpec:
+ select_type: str
+ handler: ButtonHandler
+ placeholder: Optional[str] = None
+ min_values: int = 1
+ max_values: int = 1
+ options: tuple[DiscordSelectOptionSpec, ...] = ()
+ row: Optional[int] = None
+ disabled: bool = False
+ allowed_user_ids: tuple[str, ...] = ()
+ reusable: bool = False
+ timeout_seconds: Optional[float] = 300.0
+ state: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class DiscordModalFieldSpec:
+ field_id: str
+ label: str
+ style: str = "short"
+ placeholder: Optional[str] = None
+ default: Optional[str] = None
+ required: bool = True
+ min_length: Optional[int] = None
+ max_length: Optional[int] = None
+
+
+@dataclass(frozen=True)
+class DiscordModalSpec:
+ title: str
+ fields: tuple[DiscordModalFieldSpec, ...]
+ handler: ModalHandler
+ allowed_user_ids: tuple[str, ...] = ()
+ reusable: bool = False
+ timeout_seconds: Optional[float] = 300.0
+ state: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class DiscordModalTriggerSpec:
+ label: str
+ modal: DiscordModalSpec
+ style: str = "primary"
+ row: Optional[int] = None
+ disabled: bool = False
+ emoji: Optional[Any] = None
+ allowed_user_ids: tuple[str, ...] = ()
+ reusable: bool = False
+ timeout_seconds: Optional[float] = 300.0
+ state: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class DiscordComponentEntry:
+ entry_id: str
+ kind: str
+ handler: Optional[ButtonHandler]
+ allowed_user_ids: tuple[str, ...]
+ reusable: bool
+ created_at: float
+ expires_at: Optional[float]
+ message_id: Optional[str] = None
+ state: dict[str, Any] = field(default_factory=dict)
+ consumed: bool = False
+
+
+@dataclass
+class DiscordModalEntry:
+ entry_id: str
+ fields: tuple[DiscordModalFieldSpec, ...]
+ handler: ModalHandler
+ title: str
+ allowed_user_ids: tuple[str, ...]
+ reusable: bool
+ created_at: float
+ expires_at: Optional[float]
+ message_id: Optional[str] = None
+ state: dict[str, Any] = field(default_factory=dict)
+ consumed: bool = False
+
+
+@dataclass
+class DiscordComponentInvocation:
+ interaction: Any
+ runtime: "DiscordComponentRuntime"
+ entry: DiscordComponentEntry
+ view: Any = None
+ component: Any = None
+ values: tuple[str, ...] = ()
+
+ async def deny(self, content: str = DEFAULT_UNAUTHORIZED_MESSAGE) -> None:
+ await send_ephemeral_message(self.interaction, content)
+
+ def consume(self) -> None:
+ self.runtime.consume_entry(self.entry.entry_id)
+
+ def disable_all(self) -> None:
+ if self.view is None:
+ return
+ for child in getattr(self.view, "children", []):
+ child.disabled = True
+
+
+@dataclass
+class DiscordModalInvocation:
+ interaction: Any
+ runtime: "DiscordComponentRuntime"
+ entry: DiscordModalEntry
+ modal: Any = None
+ values: dict[str, str] = field(default_factory=dict)
+
+ async def deny(self, content: str = DEFAULT_UNAUTHORIZED_MESSAGE) -> None:
+ await send_ephemeral_message(self.interaction, content)
+
+ def consume(self) -> None:
+ self.runtime.consume_modal(self.entry.entry_id)
+
+
+class DiscordComponentRuntime:
+ """Registry-backed runtime for Discord buttons, selects, and modals."""
+
+ def __init__(self):
+ self._component_entries: dict[str, DiscordComponentEntry] = {}
+ self._modal_entries: dict[str, DiscordModalEntry] = {}
+
+ def register_button(self, spec: DiscordButtonSpec) -> Optional[DiscordComponentEntry]:
+ if spec.url:
+ return None
+ return self._register_component(
+ kind="button",
+ handler=spec.handler,
+ allowed_user_ids=spec.allowed_user_ids,
+ reusable=spec.reusable,
+ timeout_seconds=spec.timeout_seconds,
+ state=spec.state,
+ )
+
+ def register_select(self, spec: DiscordSelectSpec) -> DiscordComponentEntry:
+ return self._register_component(
+ kind=f"{spec.select_type}_select",
+ handler=spec.handler,
+ allowed_user_ids=spec.allowed_user_ids,
+ reusable=spec.reusable,
+ timeout_seconds=spec.timeout_seconds,
+ state=spec.state,
+ )
+
+ def register_modal(self, spec: DiscordModalSpec) -> DiscordModalEntry:
+ entry_id = _new_entry_id("mdl_")
+ now = _now()
+ expires_at = now + spec.timeout_seconds if spec.timeout_seconds else None
+ entry = DiscordModalEntry(
+ entry_id=entry_id,
+ fields=spec.fields,
+ handler=spec.handler,
+ title=spec.title,
+ allowed_user_ids=tuple(str(user_id) for user_id in spec.allowed_user_ids),
+ reusable=spec.reusable,
+ created_at=now,
+ expires_at=expires_at,
+ state=dict(spec.state),
+ )
+ self._modal_entries[entry.entry_id] = entry
+ return entry
+
+ def _register_component(
+ self,
+ *,
+ kind: str,
+ handler: Optional[ButtonHandler],
+ allowed_user_ids: Sequence[str],
+ reusable: bool,
+ timeout_seconds: Optional[float],
+ state: dict[str, Any],
+ ) -> DiscordComponentEntry:
+ entry_id = _new_entry_id("cmp_")
+ now = _now()
+ expires_at = now + timeout_seconds if timeout_seconds else None
+ entry = DiscordComponentEntry(
+ entry_id=entry_id,
+ kind=kind,
+ handler=handler,
+ allowed_user_ids=tuple(str(user_id) for user_id in allowed_user_ids),
+ reusable=reusable,
+ created_at=now,
+ expires_at=expires_at,
+ state=dict(state),
+ )
+ self._component_entries[entry.entry_id] = entry
+ return entry
+
+ def get_entry(self, entry_id: str) -> Optional[DiscordComponentEntry]:
+ return self._component_entries.get(entry_id)
+
+ def get_modal(self, entry_id: str) -> Optional[DiscordModalEntry]:
+ return self._modal_entries.get(entry_id)
+
+ def bind_message(self, entry_ids: Sequence[str], message_id: str) -> None:
+ for entry_id in entry_ids:
+ if entry_id in self._component_entries:
+ self._component_entries[entry_id].message_id = message_id
+ if entry_id in self._modal_entries:
+ self._modal_entries[entry_id].message_id = message_id
+
+ def consume_entry(self, entry_id: str) -> None:
+ entry = self._component_entries.get(entry_id)
+ if entry is not None:
+ entry.consumed = True
+
+ def consume_modal(self, entry_id: str) -> None:
+ entry = self._modal_entries.get(entry_id)
+ if entry is not None:
+ entry.consumed = True
+
+ async def dispatch_component(
+ self,
+ interaction: Any,
+ entry_id: str,
+ *,
+ view: Any = None,
+ component: Any = None,
+ values: Sequence[Any] | None = None,
+ ) -> None:
+ entry = self._component_entries.get(entry_id)
+ failure = self._validate_component_entry(entry, interaction)
+ if failure:
+ await send_ephemeral_message(interaction, failure)
+ return
+
+ invocation = DiscordComponentInvocation(
+ interaction=interaction,
+ runtime=self,
+ entry=entry,
+ view=view,
+ component=component,
+ values=_coerce_choice_values(values),
+ )
+ result = await _await_maybe(entry.handler(invocation) if entry and entry.handler else None)
+ if entry and not entry.reusable and result is not False:
+ self.consume_entry(entry.entry_id)
+
+ async def dispatch_modal(
+ self,
+ interaction: Any,
+ entry_id: str,
+ values: dict[str, str],
+ *,
+ modal: Any = None,
+ ) -> None:
+ entry = self._modal_entries.get(entry_id)
+ failure = self._validate_modal_entry(entry, interaction)
+ if failure:
+ await send_ephemeral_message(interaction, failure)
+ return
+
+ invocation = DiscordModalInvocation(
+ interaction=interaction,
+ runtime=self,
+ entry=entry,
+ modal=modal,
+ values=dict(values),
+ )
+ result = await _await_maybe(entry.handler(invocation))
+ if entry and not entry.reusable and result is not False:
+ self.consume_modal(entry.entry_id)
+
+ def _validate_component_entry(
+ self,
+ entry: Optional[DiscordComponentEntry],
+ interaction: Any,
+ ) -> Optional[str]:
+ if entry is None:
+ return DEFAULT_MISSING_MESSAGE
+ if entry.expires_at is not None and _now() > entry.expires_at:
+ return DEFAULT_EXPIRED_MESSAGE
+ if entry.consumed:
+ return DEFAULT_USED_MESSAGE
+ if entry.allowed_user_ids and str(getattr(getattr(interaction, "user", None), "id", "")) not in entry.allowed_user_ids:
+ return DEFAULT_UNAUTHORIZED_MESSAGE
+ return None
+
+ def _validate_modal_entry(
+ self,
+ entry: Optional[DiscordModalEntry],
+ interaction: Any,
+ ) -> Optional[str]:
+ if entry is None:
+ return DEFAULT_MISSING_MESSAGE
+ if entry.expires_at is not None and _now() > entry.expires_at:
+ return DEFAULT_EXPIRED_MESSAGE
+ if entry.consumed:
+ return DEFAULT_USED_MESSAGE
+ if entry.allowed_user_ids and str(getattr(getattr(interaction, "user", None), "id", "")) not in entry.allowed_user_ids:
+ return DEFAULT_UNAUTHORIZED_MESSAGE
+ return None
+
+
+if DISCORD_AVAILABLE:
+
+ class ManagedButton(discord.ui.Button):
+ def __init__(
+ self,
+ runtime: DiscordComponentRuntime,
+ entry_id: str,
+ *,
+ label: str,
+ style: str,
+ row: Optional[int] = None,
+ disabled: bool = False,
+ emoji: Optional[Any] = None,
+ ):
+ super().__init__(
+ label=label,
+ style=_button_style(style),
+ custom_id=encode_component_custom_id(entry_id),
+ row=row,
+ disabled=disabled,
+ emoji=emoji,
+ )
+ self._runtime = runtime
+ self._entry_id = entry_id
+
+ async def callback(self, interaction: discord.Interaction):
+ await self._runtime.dispatch_component(
+ interaction,
+ self._entry_id,
+ view=self.view,
+ component=self,
+ )
+
+
+ class StaticLinkButton(discord.ui.Button):
+ def __init__(
+ self,
+ *,
+ label: str,
+ style: str,
+ url: str,
+ row: Optional[int] = None,
+ disabled: bool = False,
+ emoji: Optional[Any] = None,
+ ):
+ super().__init__(
+ label=label,
+ style=_button_style(style),
+ url=url,
+ row=row,
+ disabled=disabled,
+ emoji=emoji,
+ )
+
+
+ _string_select_base = getattr(discord.ui, "Select", None)
+ _user_select_base = getattr(discord.ui, "UserSelect", None)
+ _role_select_base = getattr(discord.ui, "RoleSelect", None)
+ _mentionable_select_base = getattr(discord.ui, "MentionableSelect", None)
+ _channel_select_base = getattr(discord.ui, "ChannelSelect", None)
+ _modal_base = getattr(discord.ui, "Modal", None)
+ _text_input_cls = getattr(discord.ui, "TextInput", None)
+
+ if _string_select_base is not None:
+
+ class ManagedStringSelect(_string_select_base):
+ def __init__(
+ self,
+ runtime: DiscordComponentRuntime,
+ entry_id: str,
+ *,
+ placeholder: Optional[str],
+ min_values: int,
+ max_values: int,
+ options: Sequence[DiscordSelectOptionSpec],
+ row: Optional[int] = None,
+ disabled: bool = False,
+ ):
+ super().__init__(
+ placeholder=placeholder,
+ min_values=min_values,
+ max_values=max_values,
+ options=[
+ _select_option(
+ label=option.label,
+ value=option.value,
+ description=option.description,
+ default=option.default,
+ )
+ for option in options
+ ],
+ custom_id=encode_component_custom_id(entry_id),
+ row=row,
+ disabled=disabled,
+ )
+ self._runtime = runtime
+ self._entry_id = entry_id
+
+ async def callback(self, interaction: discord.Interaction):
+ await self._runtime.dispatch_component(
+ interaction,
+ self._entry_id,
+ view=self.view,
+ component=self,
+ values=getattr(self, "values", ()),
+ )
+
+ else: # pragma: no cover - import guard
+ ManagedStringSelect = None
+
+ if _user_select_base is not None:
+
+ class ManagedUserSelect(_user_select_base):
+ def __init__(
+ self,
+ runtime: DiscordComponentRuntime,
+ entry_id: str,
+ *,
+ placeholder: Optional[str],
+ min_values: int,
+ max_values: int,
+ row: Optional[int] = None,
+ disabled: bool = False,
+ ):
+ super().__init__(
+ placeholder=placeholder,
+ min_values=min_values,
+ max_values=max_values,
+ custom_id=encode_component_custom_id(entry_id),
+ row=row,
+ disabled=disabled,
+ )
+ self._runtime = runtime
+ self._entry_id = entry_id
+
+ async def callback(self, interaction: discord.Interaction):
+ await self._runtime.dispatch_component(
+ interaction,
+ self._entry_id,
+ view=self.view,
+ component=self,
+ values=getattr(self, "values", ()),
+ )
+
+ else: # pragma: no cover - import guard
+ ManagedUserSelect = None
+
+ if _role_select_base is not None:
+
+ class ManagedRoleSelect(_role_select_base):
+ def __init__(
+ self,
+ runtime: DiscordComponentRuntime,
+ entry_id: str,
+ *,
+ placeholder: Optional[str],
+ min_values: int,
+ max_values: int,
+ row: Optional[int] = None,
+ disabled: bool = False,
+ ):
+ super().__init__(
+ placeholder=placeholder,
+ min_values=min_values,
+ max_values=max_values,
+ custom_id=encode_component_custom_id(entry_id),
+ row=row,
+ disabled=disabled,
+ )
+ self._runtime = runtime
+ self._entry_id = entry_id
+
+ async def callback(self, interaction: discord.Interaction):
+ await self._runtime.dispatch_component(
+ interaction,
+ self._entry_id,
+ view=self.view,
+ component=self,
+ values=getattr(self, "values", ()),
+ )
+
+ else: # pragma: no cover - import guard
+ ManagedRoleSelect = None
+
+ if _mentionable_select_base is not None:
+
+ class ManagedMentionableSelect(_mentionable_select_base):
+ def __init__(
+ self,
+ runtime: DiscordComponentRuntime,
+ entry_id: str,
+ *,
+ placeholder: Optional[str],
+ min_values: int,
+ max_values: int,
+ row: Optional[int] = None,
+ disabled: bool = False,
+ ):
+ super().__init__(
+ placeholder=placeholder,
+ min_values=min_values,
+ max_values=max_values,
+ custom_id=encode_component_custom_id(entry_id),
+ row=row,
+ disabled=disabled,
+ )
+ self._runtime = runtime
+ self._entry_id = entry_id
+
+ async def callback(self, interaction: discord.Interaction):
+ await self._runtime.dispatch_component(
+ interaction,
+ self._entry_id,
+ view=self.view,
+ component=self,
+ values=getattr(self, "values", ()),
+ )
+
+ else: # pragma: no cover - import guard
+ ManagedMentionableSelect = None
+
+ if _channel_select_base is not None:
+
+ class ManagedChannelSelect(_channel_select_base):
+ def __init__(
+ self,
+ runtime: DiscordComponentRuntime,
+ entry_id: str,
+ *,
+ placeholder: Optional[str],
+ min_values: int,
+ max_values: int,
+ row: Optional[int] = None,
+ disabled: bool = False,
+ ):
+ super().__init__(
+ placeholder=placeholder,
+ min_values=min_values,
+ max_values=max_values,
+ custom_id=encode_component_custom_id(entry_id),
+ row=row,
+ disabled=disabled,
+ )
+ self._runtime = runtime
+ self._entry_id = entry_id
+
+ async def callback(self, interaction: discord.Interaction):
+ await self._runtime.dispatch_component(
+ interaction,
+ self._entry_id,
+ view=self.view,
+ component=self,
+ values=getattr(self, "values", ()),
+ )
+
+ else: # pragma: no cover - import guard
+ ManagedChannelSelect = None
+
+ if _modal_base is not None and _text_input_cls is not None:
+
+ class ManagedModal(_modal_base):
+ def __init__(
+ self,
+ runtime: DiscordComponentRuntime,
+ entry: DiscordModalEntry,
+ ):
+ super().__init__(
+ title=entry.title,
+ custom_id=encode_modal_custom_id(entry.entry_id),
+ timeout=max((entry.expires_at or _now()) - _now(), 1.0) if entry.expires_at else None,
+ )
+ self._runtime = runtime
+ self._entry = entry
+ self._inputs: dict[str, Any] = {}
+ for field_spec in entry.fields:
+ text_input = _text_input_cls(
+ label=field_spec.label,
+ placeholder=field_spec.placeholder,
+ default=field_spec.default,
+ required=field_spec.required,
+ min_length=field_spec.min_length,
+ max_length=field_spec.max_length,
+ style=_text_style(field_spec.style),
+ )
+ self._inputs[field_spec.field_id] = text_input
+ self.add_item(text_input)
+
+ async def on_submit(self, interaction: discord.Interaction):
+ values = {
+ field_id: getattr(text_input, "value", "")
+ for field_id, text_input in self._inputs.items()
+ }
+ await self._runtime.dispatch_modal(
+ interaction,
+ self._entry.entry_id,
+ values,
+ modal=self,
+ )
+
+ else: # pragma: no cover - import guard
+ ManagedModal = None
+
+
+ class ManagedModalTriggerButton(ManagedButton):
+ def __init__(
+ self,
+ runtime: DiscordComponentRuntime,
+ entry_id: str,
+ modal_entry: DiscordModalEntry,
+ *,
+ label: str,
+ style: str,
+ row: Optional[int] = None,
+ disabled: bool = False,
+ emoji: Optional[Any] = None,
+ ):
+ super().__init__(
+ runtime,
+ entry_id,
+ label=label,
+ style=style,
+ row=row,
+ disabled=disabled,
+ emoji=emoji,
+ )
+ self._modal_entry = modal_entry
+
+ async def callback(self, interaction: discord.Interaction):
+ failure = self._runtime._validate_component_entry(
+ self._runtime.get_entry(self._entry_id),
+ interaction,
+ )
+ if failure:
+ await send_ephemeral_message(interaction, failure)
+ return
+ modal_failure = self._runtime._validate_modal_entry(self._modal_entry, interaction)
+ if modal_failure:
+ await send_ephemeral_message(interaction, modal_failure)
+ return
+ if ManagedModal is None:
+ await send_ephemeral_message(
+ interaction,
+ "Discord modal support is unavailable in this runtime~",
+ )
+ return
+ modal = ManagedModal(self._runtime, self._modal_entry)
+ await interaction.response.send_modal(modal)
+ entry = self._runtime.get_entry(self._entry_id)
+ if entry is not None and not entry.reusable:
+ self._runtime.consume_entry(entry.entry_id)
+
+
+ class ManagedComponentView(discord.ui.View):
+ def __init__(self, runtime: DiscordComponentRuntime, *, timeout: Optional[float] = 300.0):
+ super().__init__(timeout=timeout)
+ self.runtime = runtime
+ self._entry_ids: list[str] = []
+
+ @property
+ def entry_ids(self) -> tuple[str, ...]:
+ return tuple(self._entry_ids)
+
+ def bind_message(self, message_id: str) -> None:
+ self.runtime.bind_message(self._entry_ids, message_id)
+
+ def add_button(self, spec: DiscordButtonSpec) -> Any:
+ if spec.url:
+ button = StaticLinkButton(
+ label=spec.label,
+ style=spec.style,
+ url=spec.url,
+ row=spec.row,
+ disabled=spec.disabled,
+ emoji=spec.emoji,
+ )
+ self.add_item(button)
+ return button
+
+ entry = self.runtime.register_button(spec)
+ button = ManagedButton(
+ self.runtime,
+ entry.entry_id,
+ label=spec.label,
+ style=spec.style,
+ row=spec.row,
+ disabled=spec.disabled,
+ emoji=spec.emoji,
+ )
+ self._entry_ids.append(entry.entry_id)
+ self.add_item(button)
+ return button
+
+ def add_select(self, spec: DiscordSelectSpec) -> Any:
+ entry = self.runtime.register_select(spec)
+ builder_map = {
+ "string": ManagedStringSelect,
+ "user": ManagedUserSelect,
+ "role": ManagedRoleSelect,
+ "mentionable": ManagedMentionableSelect,
+ "channel": ManagedChannelSelect,
+ }
+ builder = builder_map.get(spec.select_type)
+ if builder is None:
+ raise RuntimeError(
+ f"Discord select type '{spec.select_type}' is unavailable in this runtime"
+ )
+ kwargs = dict(
+ placeholder=spec.placeholder,
+ min_values=spec.min_values,
+ max_values=spec.max_values,
+ row=spec.row,
+ disabled=spec.disabled,
+ )
+ if spec.select_type == "string":
+ kwargs["options"] = spec.options
+ select = builder(self.runtime, entry.entry_id, **kwargs)
+ self._entry_ids.append(entry.entry_id)
+ self.add_item(select)
+ return select
+
+ def add_modal_trigger(self, spec: DiscordModalTriggerSpec) -> Any:
+ modal_entry = self.runtime.register_modal(spec.modal)
+ trigger_entry = self.runtime.register_button(
+ DiscordButtonSpec(
+ label=spec.label,
+ style=spec.style,
+ row=spec.row,
+ disabled=spec.disabled,
+ emoji=spec.emoji,
+ allowed_user_ids=spec.allowed_user_ids,
+ reusable=spec.reusable,
+ timeout_seconds=spec.timeout_seconds,
+ state=spec.state,
+ )
+ )
+ button = ManagedModalTriggerButton(
+ self.runtime,
+ trigger_entry.entry_id,
+ modal_entry,
+ label=spec.label,
+ style=spec.style,
+ row=spec.row,
+ disabled=spec.disabled,
+ emoji=spec.emoji,
+ )
+ self._entry_ids.extend((trigger_entry.entry_id, modal_entry.entry_id))
+ self.add_item(button)
+ return button
+
+ async def on_timeout(self):
+ for child in self.children:
+ child.disabled = True
+
+else: # pragma: no cover - import guard
+ ManagedComponentView = None
diff --git a/gateway/platforms/discord_impl/config.py b/gateway/platforms/discord_impl/config.py
new file mode 100644
index 000000000000..79f759e4eac2
--- /dev/null
+++ b/gateway/platforms/discord_impl/config.py
@@ -0,0 +1,158 @@
+"""Discord configuration and policy helpers."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+import os
+from typing import Any, Iterable, Mapping
+
+
+@dataclass(frozen=True)
+class DiscordPolicyConfig:
+ """Typed Discord policy snapshot for a single adapter instance."""
+ allowed_users: set[str] = field(default_factory=set)
+ bot_filter_policy: str = "none"
+ free_response_channels: set[str] = field(default_factory=set)
+ require_mention: bool = True
+ auto_thread: bool = True
+
+
+def _coerce_bool(value: Any, *, default: bool) -> bool:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in ("true", "1", "yes", "on"):
+ return True
+ if normalized in ("false", "0", "no", "off"):
+ return False
+ return bool(value)
+
+
+def _normalize_bot_filter_policy(value: Any, *, default: str = "none") -> str:
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in {"none", "mentions", "all"}:
+ return normalized
+ return default
+
+
+def _split_entries(value: Any) -> list[str]:
+ if value is None:
+ return []
+ if isinstance(value, str):
+ return [entry.strip() for entry in value.split(",") if entry.strip()]
+ if isinstance(value, Iterable):
+ entries: list[str] = []
+ for entry in value:
+ if entry is None:
+ continue
+ text = str(entry).strip()
+ if text:
+ entries.append(text)
+ return entries
+ text = str(value).strip()
+ return [text] if text else []
+
+
+def clean_discord_id(entry: str) -> str:
+ """Normalize a Discord user ID or username entry."""
+ entry = entry.strip()
+ if entry.startswith("<@") and entry.endswith(">"):
+ entry = entry.lstrip("<@!").rstrip(">")
+ if entry.lower().startswith("user:"):
+ entry = entry[5:]
+ return entry.strip()
+
+
+def parse_allowed_users(value: Any) -> set[str]:
+ """Parse Discord allowed-user config into cleaned entries."""
+ return {
+ clean_discord_id(entry)
+ for entry in _split_entries(value)
+ if clean_discord_id(entry)
+ }
+
+
+def parse_free_response_channels(value: Any) -> set[str]:
+ """Parse Discord free-response channel config into channel IDs."""
+ return {entry for entry in _split_entries(value) if entry}
+
+
+def load_policy_config(
+ config: Any | None = None,
+ *,
+ env: Mapping[str, str] | None = None,
+ overrides: Mapping[str, Any] | None = None,
+) -> DiscordPolicyConfig:
+ """Load a typed Discord policy snapshot from config.extra with env fallback."""
+ env_map = env or os.environ
+ override_map = overrides or {}
+ extra = getattr(config, "extra", None)
+ if not isinstance(extra, dict):
+ extra = {}
+
+ allowed_users = extra.get("allowed_users")
+ if allowed_users is None:
+ allowed_users = env_map.get("DISCORD_ALLOWED_USERS", "")
+
+ bot_filter_policy = extra.get("allow_bots")
+ if bot_filter_policy is None:
+ bot_filter_policy = env_map.get("DISCORD_ALLOW_BOTS", "none")
+
+ free_response_channels = extra.get("free_response_channels")
+ if free_response_channels is None:
+ free_response_channels = env_map.get("DISCORD_FREE_RESPONSE_CHANNELS", "")
+
+ require_mention = extra.get("require_mention")
+ if require_mention is None:
+ require_mention = env_map.get("DISCORD_REQUIRE_MENTION")
+
+ auto_thread = extra.get("auto_thread")
+ if auto_thread is None:
+ auto_thread = env_map.get("DISCORD_AUTO_THREAD")
+
+ if "allowed_users" in override_map:
+ allowed_users = override_map["allowed_users"]
+ if "allow_bots" in override_map:
+ bot_filter_policy = override_map["allow_bots"]
+ if "free_response_channels" in override_map:
+ free_response_channels = override_map["free_response_channels"]
+ if "require_mention" in override_map:
+ require_mention = override_map["require_mention"]
+ if "auto_thread" in override_map:
+ auto_thread = override_map["auto_thread"]
+
+ return DiscordPolicyConfig(
+ allowed_users=parse_allowed_users(allowed_users),
+ bot_filter_policy=_normalize_bot_filter_policy(bot_filter_policy, default="none"),
+ free_response_channels=parse_free_response_channels(free_response_channels),
+ require_mention=_coerce_bool(require_mention, default=True),
+ auto_thread=_coerce_bool(auto_thread, default=True),
+ )
+
+
+def get_bot_filter_policy(config: Any | None = None, *, env: Mapping[str, str] | None = None) -> str:
+ """Return the effective bot-message filtering policy."""
+ return load_policy_config(config, env=env).bot_filter_policy
+
+
+def get_free_response_channels(
+ config: Any | None = None,
+ *,
+ env: Mapping[str, str] | None = None,
+) -> set[str]:
+ """Return the effective free-response channel ID set."""
+ return load_policy_config(config, env=env).free_response_channels
+
+
+def is_mention_required(config: Any | None = None, *, env: Mapping[str, str] | None = None) -> bool:
+ """Return whether Discord server messages require an explicit mention."""
+ return load_policy_config(config, env=env).require_mention
+
+
+def is_auto_thread_enabled(config: Any | None = None, *, env: Mapping[str, str] | None = None) -> bool:
+ """Return whether Discord auto-threading is enabled."""
+ return load_policy_config(config, env=env).auto_thread
diff --git a/gateway/platforms/discord_impl/delivery.py b/gateway/platforms/discord_impl/delivery.py
new file mode 100644
index 000000000000..0944c5f35c26
--- /dev/null
+++ b/gateway/platforms/discord_impl/delivery.py
@@ -0,0 +1,80 @@
+"""Discord message delivery helpers."""
+
+from __future__ import annotations
+
+import logging
+import os
+from typing import Any, Optional
+
+from gateway.platforms.base import SendResult
+
+
+logger = logging.getLogger(__name__)
+
+try:
+ import discord
+except ImportError: # pragma: no cover - import guard
+ discord = None
+
+
+async def resolve_channel(client: Any, chat_id: str) -> Optional[Any]:
+ """Get or fetch a Discord channel by ID."""
+ if not client:
+ return None
+
+ try:
+ channel_id = int(chat_id)
+ except (TypeError, ValueError):
+ return None
+
+ channel = client.get_channel(channel_id)
+ if channel is not None:
+ return channel
+
+ try:
+ return await client.fetch_channel(channel_id)
+ except Exception:
+ return None
+
+
+async def send_text_message(channel: Any, content: str, reference: Any = None) -> Any:
+ """Send a single Discord text message chunk with reply fallback."""
+ try:
+ return await channel.send(content=content, reference=reference)
+ except Exception as exc:
+ err_text = str(exc)
+ if (
+ reference is not None
+ and "error code: 50035" in err_text
+ and "Cannot reply to a system message" in err_text
+ ):
+ logger.warning(
+ "Reply target is a Discord system message; retrying send without reply reference"
+ )
+ return await channel.send(content=content, reference=None)
+ raise
+
+
+async def send_file_attachment(
+ client: Any,
+ chat_id: str,
+ file_path: str,
+ caption: str | None = None,
+ file_name: str | None = None,
+) -> SendResult:
+ """Send a local file as a Discord attachment."""
+ if not client:
+ return SendResult(success=False, error="Not connected")
+
+ channel = await resolve_channel(client, chat_id)
+ if not channel:
+ return SendResult(success=False, error=f"Channel {chat_id} not found")
+
+ if discord is None: # pragma: no cover - import guard
+ return SendResult(success=False, error="discord.py not installed")
+
+ filename = file_name or os.path.basename(file_path)
+ with open(file_path, "rb") as fh:
+ file = discord.File(fh, filename=filename)
+ msg = await channel.send(content=caption if caption else None, file=file)
+ return SendResult(success=True, message_id=str(msg.id))
diff --git a/gateway/platforms/discord_impl/history.py b/gateway/platforms/discord_impl/history.py
new file mode 100644
index 000000000000..28dce8262a32
--- /dev/null
+++ b/gateway/platforms/discord_impl/history.py
@@ -0,0 +1,193 @@
+"""Discord history fetch and search.
+
+Bounded read-only channel/thread history retrieval with permission checks.
+"""
+
+from __future__ import annotations
+
+import inspect
+from dataclasses import dataclass
+from datetime import datetime
+from types import SimpleNamespace
+from typing import Any, Optional
+
+from gateway.platforms.discord_impl.delivery import resolve_channel
+from gateway.platforms.discord_impl import permissions as discord_permissions
+
+SEARCH_SCAN_LIMIT = 500
+SEARCH_PAGE_SIZE = 100
+
+
+@dataclass
+class HistoryMessage:
+ """Lightweight representation of a Discord message from history."""
+
+ id: str
+ author_id: str
+ author_name: str
+ content: str
+ timestamp: str
+ is_bot: bool
+ attachments: list[str]
+ reply_to: Optional[str] = None
+
+
+def _clamp_limit(limit: int) -> int:
+ try:
+ value = int(limit)
+ except (TypeError, ValueError):
+ value = 100
+ return max(1, min(500, value))
+
+
+def _history_anchor(message_id: Optional[str]) -> Optional[Any]:
+ if message_id is None:
+ return None
+ try:
+ anchor_id: Any = int(message_id)
+ except (TypeError, ValueError):
+ anchor_id = str(message_id)
+ return SimpleNamespace(id=anchor_id)
+
+
+async def _collect_history(history_iter: Any) -> list[Any]:
+ if history_iter is None:
+ return []
+
+ if inspect.isawaitable(history_iter):
+ history_iter = await history_iter
+
+ if hasattr(history_iter, "__aiter__"):
+ return [message async for message in history_iter]
+
+ return list(history_iter)
+
+
+def _timestamp_to_iso(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, datetime):
+ return value.isoformat()
+ isoformat = getattr(value, "isoformat", None)
+ if callable(isoformat):
+ return isoformat()
+ return str(value)
+
+
+def _author_name(message: Any) -> str:
+ author = getattr(message, "author", None)
+ if author is None:
+ return "unknown"
+ return (
+ getattr(author, "display_name", None)
+ or getattr(author, "name", None)
+ or str(getattr(author, "id", "unknown"))
+ )
+
+
+def _attachments(message: Any) -> list[str]:
+ urls: list[str] = []
+ for attachment in getattr(message, "attachments", []) or []:
+ url = getattr(attachment, "url", None)
+ if url:
+ urls.append(str(url))
+ return urls
+
+
+def _reply_to(message: Any) -> Optional[str]:
+ reference = getattr(message, "reference", None)
+ if reference is None:
+ return None
+
+ reference_id = getattr(reference, "message_id", None)
+ if reference_id is None:
+ resolved = getattr(reference, "resolved", None)
+ reference_id = getattr(resolved, "id", None)
+ if reference_id is None:
+ return None
+ return str(reference_id)
+
+
+def _to_history_message(message: Any) -> HistoryMessage:
+ author = getattr(message, "author", None)
+ return HistoryMessage(
+ id=str(getattr(message, "id", "")),
+ author_id=str(getattr(author, "id", "")),
+ author_name=_author_name(message),
+ content=str(getattr(message, "content", "") or ""),
+ timestamp=_timestamp_to_iso(getattr(message, "created_at", None)),
+ is_bot=bool(getattr(author, "bot", False)),
+ attachments=_attachments(message),
+ reply_to=_reply_to(message),
+ )
+
+
+async def fetch_history(
+ client: Any,
+ channel_id: str,
+ limit: int = 100,
+ before: Optional[str] = None,
+ after: Optional[str] = None,
+) -> list[HistoryMessage]:
+ """Fetch bounded message history from a Discord channel."""
+ channel = await resolve_channel(client, channel_id)
+ if channel is None:
+ return []
+
+ channel_permissions = discord_permissions._build_channel_permissions(client, channel)
+ if not (channel_permissions.can_read and channel_permissions.can_read_history):
+ return []
+
+ history_iter = channel.history(
+ limit=_clamp_limit(limit),
+ before=_history_anchor(before),
+ after=_history_anchor(after),
+ )
+ return [_to_history_message(message) for message in await _collect_history(history_iter)]
+
+
+async def search_history(
+ client: Any,
+ channel_id: str,
+ query: str,
+ limit: int = 50,
+ author_id: Optional[str] = None,
+) -> list[HistoryMessage]:
+ """Search recent channel history with bounded text matching."""
+ normalized_query = (query or "").strip().lower()
+ if not normalized_query:
+ return []
+
+ clamped_limit = _clamp_limit(limit)
+ filtered_messages: list[HistoryMessage] = []
+ author_filter = str(author_id) if author_id is not None else None
+ remaining_scan = SEARCH_SCAN_LIMIT
+ before: Optional[str] = None
+
+ while remaining_scan > 0 and len(filtered_messages) < clamped_limit:
+ page_size = min(SEARCH_PAGE_SIZE, remaining_scan)
+ messages = await fetch_history(
+ client,
+ channel_id,
+ limit=page_size,
+ before=before,
+ )
+ if not messages:
+ break
+
+ remaining_scan -= len(messages)
+
+ for message in messages:
+ if author_filter is not None and message.author_id != author_filter:
+ continue
+ if normalized_query not in message.content.lower():
+ continue
+ filtered_messages.append(message)
+ if len(filtered_messages) >= clamped_limit:
+ break
+
+ if len(messages) < page_size:
+ break
+ before = messages[-1].id
+
+ return filtered_messages
diff --git a/gateway/platforms/discord_impl/intake.py b/gateway/platforms/discord_impl/intake.py
new file mode 100644
index 000000000000..3240297e5bbc
--- /dev/null
+++ b/gateway/platforms/discord_impl/intake.py
@@ -0,0 +1,104 @@
+"""Discord message intake and preflight helpers."""
+
+from __future__ import annotations
+
+from typing import Any, Callable, Iterable, Optional
+
+try:
+ import discord
+except ImportError: # pragma: no cover - import guard
+ discord = None
+
+
+def should_filter_bot_message(is_bot: bool, policy: str, is_mentioned: bool) -> bool:
+ """Return whether a bot-authored message should be filtered."""
+ if not is_bot:
+ return False
+ if policy == "none":
+ return True
+ if policy == "mentions" and not is_mentioned:
+ return True
+ return False
+
+
+def should_skip_for_mention(
+ require_mention: bool,
+ is_free_channel: bool,
+ in_bot_thread: bool,
+ is_mentioned: bool,
+) -> bool:
+ """Return whether a guild message should be skipped for mention gating."""
+ return require_mention and not is_free_channel and not in_bot_thread and not is_mentioned
+
+
+def strip_mention(content: str, bot_user_id: int) -> str:
+ """Strip direct bot mention syntax from the message content."""
+ content = content.replace(f"<@{bot_user_id}>", "").strip()
+ content = content.replace(f"<@!{bot_user_id}>", "").strip()
+ return content
+
+
+def classify_message_type(content: str, attachments: Iterable[Any]) -> str:
+ """Return the normalized Discord message type string."""
+ if content.startswith("/"):
+ return "command"
+
+ for attachment in attachments:
+ content_type = getattr(attachment, "content_type", None)
+ if not content_type:
+ continue
+ if content_type.startswith("image/"):
+ return "photo"
+ if content_type.startswith("video/"):
+ return "video"
+ if content_type.startswith("audio/"):
+ return "audio"
+ return "document"
+
+ return "text"
+
+
+def get_parent_channel_id(channel: Any) -> Optional[str]:
+ """Return the parent channel ID for a Discord thread-like channel, if present."""
+ parent = getattr(channel, "parent", None)
+ if parent is not None and getattr(parent, "id", None) is not None:
+ return str(parent.id)
+ parent_id = getattr(channel, "parent_id", None)
+ if parent_id is not None:
+ return str(parent_id)
+ return None
+
+
+def is_forum_parent(channel: Any) -> bool:
+ """Best-effort check for whether a Discord channel is a forum channel."""
+ if channel is None:
+ return False
+
+ forum_cls = getattr(discord, "ForumChannel", None) if discord else None
+ if forum_cls and isinstance(channel, forum_cls):
+ return True
+
+ channel_type = getattr(channel, "type", None)
+ if channel_type is not None:
+ type_value = getattr(channel_type, "value", channel_type)
+ if type_value == 15:
+ return True
+
+ return False
+
+
+def format_thread_chat_name(thread: Any, is_forum_fn: Callable[[Any], bool]) -> str:
+ """Build a readable chat name for thread-like Discord channels."""
+ thread_name = getattr(thread, "name", None) or str(getattr(thread, "id", "thread"))
+ parent = getattr(thread, "parent", None)
+ guild = getattr(thread, "guild", None) or getattr(parent, "guild", None)
+ guild_name = getattr(guild, "name", None)
+ parent_name = getattr(parent, "name", None)
+
+ if is_forum_fn(parent) and guild_name and parent_name:
+ return f"{guild_name} / {parent_name} / {thread_name}"
+ if parent_name and guild_name:
+ return f"{guild_name} / #{parent_name} / {thread_name}"
+ if parent_name:
+ return f"{parent_name} / {thread_name}"
+ return thread_name
diff --git a/gateway/platforms/discord_impl/interactions.py b/gateway/platforms/discord_impl/interactions.py
new file mode 100644
index 000000000000..00aa36b9cc7b
--- /dev/null
+++ b/gateway/platforms/discord_impl/interactions.py
@@ -0,0 +1,108 @@
+"""Discord slash command wiring and interaction helpers."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from gateway.platforms.discord_impl import components as discord_components
+from gateway.platforms.discord_impl import native_commands
+
+try:
+ import discord
+ DISCORD_AVAILABLE = True
+except ImportError: # pragma: no cover - import guard
+ discord = None
+ DISCORD_AVAILABLE = False
+
+
+def register_slash_commands(tree: Any, adapter: Any) -> None:
+ """Register Discord slash commands on the command tree."""
+ native_commands.register_slash_commands(tree, adapter)
+
+
+def build_slash_event(adapter: Any, interaction: discord.Interaction, text: str):
+ """Build a MessageEvent from a Discord slash command interaction."""
+ return native_commands.build_slash_event(adapter, interaction, text)
+
+
+def create_component_runtime() -> discord_components.DiscordComponentRuntime:
+ """Create a generic Discord component runtime."""
+ return discord_components.DiscordComponentRuntime()
+
+
+if DISCORD_AVAILABLE:
+
+ def create_exec_approval_view(
+ adapter: Any,
+ approval_id: str,
+ allowed_user_ids: set,
+ runtime: discord_components.DiscordComponentRuntime | None = None,
+ ):
+ """Build a generic component-runtime approval view."""
+ runtime = runtime or create_component_runtime()
+ view = discord_components.ManagedComponentView(runtime, timeout=300)
+ allowed = tuple(str(user_id) for user_id in allowed_user_ids)
+
+ async def _resolve(invocation: discord_components.DiscordComponentInvocation, decision: str, color: Any) -> bool:
+ resolver = getattr(adapter, "_resolve_exec_approval", None)
+ if not callable(resolver):
+ await invocation.deny("Approval resolver is unavailable~")
+ return False
+
+ result = resolver(decision=decision, approval_id=approval_id)
+ if hasattr(result, "__await__"):
+ result = await result
+
+ embed = invocation.interaction.message.embeds[0] if invocation.interaction.message.embeds else None
+ if embed:
+ embed.color = color
+ embed.set_footer(text=f"{decision} by {invocation.interaction.user.display_name}")
+
+ invocation.disable_all()
+ await invocation.interaction.response.edit_message(embed=embed, view=view)
+
+ followup = getattr(invocation.interaction, "followup", None)
+ if followup is not None and hasattr(followup, "send"):
+ await followup.send(str(result), ephemeral=True)
+ return True
+
+ view.add_button(
+ discord_components.DiscordButtonSpec(
+ label="Allow Once",
+ style="success",
+ allowed_user_ids=allowed,
+ handler=lambda invocation: _resolve(
+ invocation,
+ "allow-once",
+ discord.Color.green(),
+ ),
+ )
+ )
+ view.add_button(
+ discord_components.DiscordButtonSpec(
+ label="Always Allow",
+ style="primary",
+ allowed_user_ids=allowed,
+ handler=lambda invocation: _resolve(
+ invocation,
+ "allow-always",
+ discord.Color.blue(),
+ ),
+ )
+ )
+ view.add_button(
+ discord_components.DiscordButtonSpec(
+ label="Deny",
+ style="danger",
+ allowed_user_ids=allowed,
+ handler=lambda invocation: _resolve(
+ invocation,
+ "deny",
+ discord.Color.red(),
+ ),
+ )
+ )
+ return view
+
+else: # pragma: no cover - import guard
+ create_exec_approval_view = None
diff --git a/gateway/platforms/discord_impl/messaging.py b/gateway/platforms/discord_impl/messaging.py
new file mode 100644
index 000000000000..c1d7bca71e2c
--- /dev/null
+++ b/gateway/platforms/discord_impl/messaging.py
@@ -0,0 +1,458 @@
+"""Discord message-operation helpers."""
+
+from __future__ import annotations
+
+import inspect
+from datetime import datetime
+from typing import Any, Callable, Optional
+
+from gateway.platforms.base import SendResult
+from gateway.platforms.discord_impl.delivery import resolve_channel
+
+
+def normalize_edit_content(
+ content: str,
+ *,
+ format_message: Optional[Callable[[str], str]] = None,
+ max_message_length: int = 2000,
+) -> str:
+ """Format and truncate edited message content to Discord limits."""
+ formatter = format_message or (lambda value: value)
+ formatted = formatter(content)
+ if len(formatted) > max_message_length:
+ return formatted[: max_message_length - 3] + "..."
+ return formatted
+
+
+def _timestamp_to_iso(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, datetime):
+ return value.isoformat()
+ isoformat = getattr(value, "isoformat", None)
+ if callable(isoformat):
+ return isoformat()
+ return str(value)
+
+
+def _author_name(message: Any) -> str:
+ author = getattr(message, "author", None)
+ if author is None:
+ return "unknown"
+ return (
+ getattr(author, "display_name", None)
+ or getattr(author, "name", None)
+ or str(getattr(author, "id", "unknown"))
+ )
+
+
+def _serialize_message(message: Any) -> dict[str, Any]:
+ author = getattr(message, "author", None)
+ attachments = [
+ str(getattr(attachment, "url"))
+ for attachment in (getattr(message, "attachments", []) or [])
+ if getattr(attachment, "url", None)
+ ]
+ reference = getattr(message, "reference", None)
+ reply_to = getattr(reference, "message_id", None) if reference is not None else None
+ return {
+ "id": str(getattr(message, "id", "")),
+ "author_id": str(getattr(author, "id", "")),
+ "author_name": _author_name(message),
+ "content": str(getattr(message, "content", "") or ""),
+ "timestamp": _timestamp_to_iso(getattr(message, "created_at", None)),
+ "is_bot": bool(getattr(author, "bot", False)),
+ "attachments": attachments,
+ "reply_to": str(reply_to) if reply_to is not None else None,
+ }
+
+
+async def _collect_items(iterable: Any) -> list[Any]:
+ if iterable is None:
+ return []
+ if inspect.isawaitable(iterable):
+ iterable = await iterable
+ if hasattr(iterable, "__aiter__"):
+ return [item async for item in iterable]
+ return list(iterable)
+
+
+def _clamp_limit(limit: int, *, default: int, minimum: int, maximum: int) -> int:
+ try:
+ value = int(limit)
+ except (TypeError, ValueError):
+ value = default
+ return max(minimum, min(maximum, value))
+
+
+def _thread_dict(thread: Any) -> dict[str, Any]:
+ parent = getattr(thread, "parent", None)
+ guild = getattr(thread, "guild", None) or getattr(parent, "guild", None)
+ return {
+ "id": str(getattr(thread, "id", "")),
+ "name": getattr(thread, "name", "") or "",
+ "parent_id": str(getattr(parent, "id", "")) if getattr(parent, "id", None) is not None else None,
+ "parent_name": getattr(parent, "name", None),
+ "guild_id": str(getattr(guild, "id", "")) if getattr(guild, "id", None) is not None else None,
+ "guild_name": getattr(guild, "name", None),
+ "archived": bool(getattr(thread, "archived", False)),
+ "locked": bool(getattr(thread, "locked", False)),
+ "message_count": getattr(thread, "message_count", None),
+ "member_count": getattr(thread, "member_count", None),
+ }
+
+
+def _emoji_dict(emoji: Any) -> dict[str, Any]:
+ emoji_id = getattr(emoji, "id", None)
+ emoji_name = getattr(emoji, "name", None)
+ if emoji_name is None and isinstance(emoji, str):
+ emoji_name = emoji
+ return {
+ "id": str(emoji_id) if emoji_id is not None else None,
+ "name": emoji_name,
+ "raw": str(emoji) if emoji is not None else "",
+ }
+
+
+def _user_dict(user: Any) -> dict[str, Any]:
+ username = getattr(user, "username", None) or getattr(user, "name", None)
+ discriminator = getattr(user, "discriminator", None)
+ if username and discriminator and str(discriminator) != "0":
+ tag = f"{username}#{discriminator}"
+ else:
+ tag = username
+ return {
+ "id": str(getattr(user, "id", "")),
+ "username": username,
+ "tag": tag,
+ }
+
+
+def _normalize_emoji(emoji: Any) -> Any:
+ if isinstance(emoji, str):
+ return emoji.strip()
+ return emoji
+
+
+async def fetch_channel_message(
+ client: Any,
+ chat_id: str,
+ message_id: str,
+) -> tuple[Any | None, Any | None, SendResult | None]:
+ """Resolve a Discord channel and fetch a specific message from it."""
+ channel = await resolve_channel(client, chat_id)
+ if not channel:
+ return None, None, SendResult(success=False, error=f"Channel {chat_id} not found")
+
+ try:
+ message = await channel.fetch_message(int(message_id))
+ except Exception as exc:
+ return channel, None, SendResult(success=False, error=str(exc))
+
+ return channel, message, None
+
+
+async def edit_message(
+ client: Any,
+ chat_id: str,
+ message_id: str,
+ content: str,
+ *,
+ format_message: Optional[Callable[[str], str]] = None,
+ max_message_length: int = 2000,
+) -> SendResult:
+ """Edit a previously sent Discord message."""
+ if not client:
+ return SendResult(success=False, error="Not connected")
+
+ _channel, message, error = await fetch_channel_message(client, chat_id, message_id)
+ if error is not None:
+ return error
+
+ formatted = normalize_edit_content(
+ content,
+ format_message=format_message,
+ max_message_length=max_message_length,
+ )
+
+ try:
+ await message.edit(content=formatted)
+ except Exception as exc:
+ return SendResult(success=False, error=str(exc))
+
+ return SendResult(success=True, message_id=message_id)
+
+
+async def delete_message(
+ client: Any,
+ chat_id: str,
+ message_id: str,
+) -> SendResult:
+ """Delete a Discord message."""
+ if not client:
+ return SendResult(success=False, error="Not connected")
+
+ _channel, message, error = await fetch_channel_message(client, chat_id, message_id)
+ if error is not None:
+ return error
+
+ try:
+ await message.delete()
+ except Exception as exc:
+ return SendResult(success=False, error=str(exc))
+
+ return SendResult(success=True, message_id=message_id)
+
+
+async def add_reaction(
+ client: Any,
+ chat_id: str,
+ message_id: str,
+ emoji: Any,
+) -> SendResult:
+ """Add a reaction to a Discord message."""
+ if not client:
+ return SendResult(success=False, error="Not connected")
+
+ normalized_emoji = _normalize_emoji(emoji)
+ if not normalized_emoji:
+ return SendResult(success=False, error="Emoji is required")
+
+ _channel, message, error = await fetch_channel_message(client, chat_id, message_id)
+ if error is not None:
+ return error
+
+ try:
+ await message.add_reaction(normalized_emoji)
+ except Exception as exc:
+ return SendResult(success=False, error=str(exc))
+
+ return SendResult(success=True, message_id=message_id)
+
+
+async def remove_reaction(
+ client: Any,
+ chat_id: str,
+ message_id: str,
+ emoji: Any,
+) -> SendResult:
+ """Remove the connected bot's reaction from a Discord message."""
+ if not client:
+ return SendResult(success=False, error="Not connected")
+
+ normalized_emoji = _normalize_emoji(emoji)
+ if not normalized_emoji:
+ return SendResult(success=False, error="Emoji is required")
+
+ member = getattr(client, "user", None)
+ if member is None:
+ return SendResult(success=False, error="Client user unavailable")
+
+ _channel, message, error = await fetch_channel_message(client, chat_id, message_id)
+ if error is not None:
+ return error
+
+ try:
+ await message.remove_reaction(normalized_emoji, member)
+ except Exception as exc:
+ return SendResult(success=False, error=str(exc))
+
+ return SendResult(success=True, message_id=message_id)
+
+
+async def list_reactions(
+ client: Any,
+ chat_id: str,
+ message_id: str,
+ *,
+ limit: int = 100,
+) -> list[dict[str, Any]]:
+ """List reactions on a Discord message with bounded user summaries."""
+ if not client:
+ return []
+
+ _channel, message, error = await fetch_channel_message(client, chat_id, message_id)
+ if error is not None or message is None:
+ return []
+
+ user_limit = _clamp_limit(limit, default=100, minimum=1, maximum=100)
+ summaries: list[dict[str, Any]] = []
+ for reaction in (getattr(message, "reactions", []) or []):
+ users_fn = getattr(reaction, "users", None)
+ try:
+ users = await _collect_items(users_fn(limit=user_limit)) if callable(users_fn) else []
+ except Exception:
+ users = []
+ summaries.append(
+ {
+ "emoji": _emoji_dict(getattr(reaction, "emoji", None)),
+ "count": int(getattr(reaction, "count", 0) or 0),
+ "users": [_user_dict(user) for user in users],
+ }
+ )
+ return summaries
+
+
+async def list_threads(
+ client: Any,
+ channel_id: str,
+ *,
+ include_archived: bool = False,
+ limit: int = 100,
+ before: Any = None,
+ private: bool = False,
+ joined: bool = False,
+) -> list[dict[str, Any]]:
+ """List active and optionally archived threads for a channel."""
+ channel = await resolve_channel(client, channel_id)
+ if channel is None:
+ return []
+
+ active_threads = [_thread_dict(thread) for thread in (getattr(channel, "threads", []) or [])]
+ if not include_archived:
+ return active_threads
+
+ archived_threads_fn = getattr(channel, "archived_threads", None)
+ if not callable(archived_threads_fn):
+ return active_threads
+
+ archived_threads = await _collect_items(
+ archived_threads_fn(
+ private=private,
+ joined=joined,
+ limit=_clamp_limit(limit, default=100, minimum=1, maximum=100),
+ before=before,
+ )
+ )
+ return active_threads + [_thread_dict(thread) for thread in archived_threads]
+
+
+async def reply_in_thread(
+ client: Any,
+ thread_id: str,
+ content: str,
+ *,
+ reply_to: Optional[str] = None,
+ format_message: Optional[Callable[[str], str]] = None,
+ truncate_message: Optional[Callable[[str, int], list[str]]] = None,
+ max_message_length: int = 2000,
+ send_text_message: Optional[Callable[..., Any]] = None,
+) -> SendResult:
+ """Send a message to a Discord thread after validating the target."""
+ if not client:
+ return SendResult(success=False, error="Not connected")
+
+ channel = await resolve_channel(client, thread_id)
+ if channel is None:
+ return SendResult(success=False, error=f"Channel {thread_id} not found")
+
+ if getattr(channel, "parent", None) is None:
+ return SendResult(success=False, error=f"Channel {thread_id} is not a thread")
+
+ formatter = format_message or (lambda value: value)
+ truncater = truncate_message or (lambda value, _max_len: [value])
+ sender = send_text_message
+ if sender is None:
+ from gateway.platforms.discord_impl.delivery import send_text_message as default_send_text_message
+ sender = default_send_text_message
+
+ formatted = formatter(content)
+ chunks = truncater(formatted, max_message_length)
+ message_ids: list[str] = []
+ reference = None
+
+ if reply_to:
+ try:
+ reference = await channel.fetch_message(int(reply_to))
+ except Exception:
+ reference = None
+
+ try:
+ for index, chunk in enumerate(chunks):
+ message = await sender(
+ channel,
+ chunk,
+ reference=reference if index == 0 else None,
+ )
+ message_ids.append(str(message.id))
+ except Exception as exc:
+ return SendResult(success=False, error=str(exc))
+
+ return SendResult(
+ success=True,
+ message_id=message_ids[0] if message_ids else None,
+ raw_response={"message_ids": message_ids},
+ )
+
+
+async def list_pins(
+ client: Any,
+ channel_id: str,
+ *,
+ limit: int = 50,
+ before: Any = None,
+ oldest_first: bool = False,
+) -> list[dict[str, Any]]:
+ """List pinned messages for a channel or thread."""
+ channel = await resolve_channel(client, channel_id)
+ if channel is None:
+ return []
+
+ pins_fn = getattr(channel, "pins", None)
+ if not callable(pins_fn):
+ return []
+
+ pins = await _collect_items(
+ pins_fn(
+ limit=_clamp_limit(limit, default=50, minimum=1, maximum=50),
+ before=before,
+ oldest_first=oldest_first,
+ )
+ )
+ return [_serialize_message(message) for message in pins]
+
+
+async def pin_message(
+ client: Any,
+ chat_id: str,
+ message_id: str,
+ *,
+ reason: Optional[str] = None,
+) -> SendResult:
+ """Pin a Discord message."""
+ if not client:
+ return SendResult(success=False, error="Not connected")
+
+ _channel, message, error = await fetch_channel_message(client, chat_id, message_id)
+ if error is not None:
+ return error
+
+ try:
+ await message.pin(reason=reason)
+ except Exception as exc:
+ return SendResult(success=False, error=str(exc))
+
+ return SendResult(success=True, message_id=message_id)
+
+
+async def unpin_message(
+ client: Any,
+ chat_id: str,
+ message_id: str,
+ *,
+ reason: Optional[str] = None,
+) -> SendResult:
+ """Unpin a Discord message."""
+ if not client:
+ return SendResult(success=False, error="Not connected")
+
+ _channel, message, error = await fetch_channel_message(client, chat_id, message_id)
+ if error is not None:
+ return error
+
+ try:
+ await message.unpin(reason=reason)
+ except Exception as exc:
+ return SendResult(success=False, error=str(exc))
+
+ return SendResult(success=True, message_id=message_id)
diff --git a/gateway/platforms/discord_impl/model_picker.py b/gateway/platforms/discord_impl/model_picker.py
new file mode 100644
index 000000000000..351a1632da60
--- /dev/null
+++ b/gateway/platforms/discord_impl/model_picker.py
@@ -0,0 +1,556 @@
+"""Discord-native model picker built on the generic component runtime."""
+
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Awaitable, Callable, Optional, TypeVar
+
+from gateway.platforms.discord_impl import components as discord_components
+from hermes_cli.models import curated_models_for_provider, list_available_providers, provider_label
+
+
+RECENTS_FILE = "discord_model_recents.json"
+RECENTS_LIMIT = 5
+RECENT_BUTTONS = 3
+PROVIDER_PAGE_SIZE = 25
+MODEL_PAGE_SIZE = 25
+
+ApplySelectionFn = Callable[[str, str, Optional[str]], Awaitable[str]]
+T = TypeVar("T")
+
+
+@dataclass(frozen=True)
+class RecentModel:
+ provider: str
+ model: str
+
+
+@dataclass(frozen=True)
+class ProviderItem:
+ provider_id: str
+ label: str
+ authenticated: bool
+
+
+@dataclass
+class ModelPickerState:
+ command_name: str
+ user_id: str
+ current_provider: str
+ current_model: str
+ pending_provider: str
+ pending_model: Optional[str] = None
+ provider_page: int = 1
+ model_page: int = 1
+
+ def reset(self) -> None:
+ self.pending_provider = self.current_provider
+ self.pending_model = self.current_model
+ self.provider_page = 1
+ self.model_page = 1
+
+ @property
+ def has_pending_change(self) -> bool:
+ return (
+ self.pending_provider != self.current_provider
+ or (self.pending_model or "") != self.current_model
+ )
+
+
+def _hermes_home() -> Path:
+ return Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes")))
+
+
+def _recents_path(hermes_home: Optional[Path] = None) -> Path:
+ return (hermes_home or _hermes_home()) / RECENTS_FILE
+
+
+def _read_recents(hermes_home: Optional[Path] = None) -> dict[str, list[dict[str, str]]]:
+ path = _recents_path(hermes_home)
+ if not path.exists():
+ return {}
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+
+
+def _write_recents(payload: dict[str, list[dict[str, str]]], hermes_home: Optional[Path] = None) -> None:
+ path = _recents_path(hermes_home)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
+
+
+def record_recent_model(
+ user_id: Optional[str],
+ provider: str,
+ model: str,
+ *,
+ hermes_home: Optional[Path] = None,
+) -> None:
+ user_key = str(user_id or "").strip()
+ provider_key = str(provider or "").strip()
+ model_key = str(model or "").strip()
+ if not user_key or not provider_key or not model_key:
+ return
+
+ payload = _read_recents(hermes_home)
+ entries = payload.get(user_key, [])
+ filtered = [
+ entry
+ for entry in entries
+ if not (
+ str(entry.get("provider", "")).strip() == provider_key
+ and str(entry.get("model", "")).strip() == model_key
+ )
+ ]
+ filtered.insert(0, {"provider": provider_key, "model": model_key})
+ payload[user_key] = filtered[:RECENTS_LIMIT]
+ _write_recents(payload, hermes_home)
+
+
+def load_recent_models(
+ user_id: Optional[str],
+ *,
+ hermes_home: Optional[Path] = None,
+ providers: Optional[list[ProviderItem]] = None,
+) -> list[RecentModel]:
+ user_key = str(user_id or "").strip()
+ if not user_key:
+ return []
+
+ provider_items = providers or get_provider_items()
+ provider_ids = {item.provider_id for item in provider_items}
+ recents = _read_recents(hermes_home).get(user_key, [])
+ filtered: list[RecentModel] = []
+
+ for entry in recents:
+ provider_id = str(entry.get("provider", "")).strip()
+ model_id = str(entry.get("model", "")).strip()
+ if not provider_id or not model_id or provider_id not in provider_ids:
+ continue
+ catalog = {model for model, _desc in curated_models_for_provider(provider_id)}
+ if catalog and model_id not in catalog:
+ continue
+ filtered.append(RecentModel(provider=provider_id, model=model_id))
+
+ return filtered[:RECENTS_LIMIT]
+
+
+def get_provider_items() -> list[ProviderItem]:
+ items: list[ProviderItem] = []
+ for provider in list_available_providers():
+ provider_id = str(provider.get("id", "")).strip()
+ if not provider_id:
+ continue
+ items.append(
+ ProviderItem(
+ provider_id=provider_id,
+ label=str(provider.get("label") or provider_id),
+ authenticated=bool(provider.get("authenticated")),
+ )
+ )
+ return items
+
+
+def _recent_button_label(recent: RecentModel) -> str:
+ label = recent.model.split("/")[-1] if "/" in recent.model else recent.model
+ if len(label) > 20:
+ return label[:17] + "..."
+ return label
+
+
+def _paginate(items: list[T], page: int, page_size: int) -> tuple[list[T], int, int]:
+ if page_size < 1:
+ page_size = 1
+ total_pages = max(1, (len(items) + page_size - 1) // page_size)
+ current_page = max(1, min(page, total_pages))
+ start = (current_page - 1) * page_size
+ end = start + page_size
+ return items[start:end], current_page, total_pages
+
+
+async def _send_initial_response(interaction: Any, content: str, view: Any) -> None:
+ response = getattr(interaction, "response", None)
+ if response is not None:
+ is_done = getattr(response, "is_done", None)
+ if callable(is_done) and not is_done():
+ await response.send_message(content, ephemeral=True, view=view)
+ return
+ followup = getattr(interaction, "followup", None)
+ if followup is not None and hasattr(followup, "send"):
+ message = await followup.send(content, ephemeral=True, view=view)
+ if message is not None and hasattr(view, "bind_message") and getattr(message, "id", None) is not None:
+ view.bind_message(str(message.id))
+
+
+async def _edit_response(interaction: Any, content: str, view: Any | None) -> None:
+ message = getattr(interaction, "message", None)
+ if message is not None and hasattr(view, "bind_message") and getattr(message, "id", None) is not None:
+ view.bind_message(str(message.id))
+ await interaction.response.edit_message(content=content, view=view)
+
+
+class DiscordModelPickerController:
+ """Ephemeral provider/model picker scoped to one invoking Discord user."""
+
+ def __init__(
+ self,
+ *,
+ runtime: discord_components.DiscordComponentRuntime,
+ command_name: str,
+ user_id: str,
+ current_provider: str,
+ current_model: str,
+ apply_selection: ApplySelectionFn,
+ hermes_home: Optional[Path] = None,
+ ):
+ self.runtime = runtime
+ self.state = ModelPickerState(
+ command_name=command_name,
+ user_id=str(user_id),
+ current_provider=current_provider,
+ current_model=current_model,
+ pending_provider=current_provider,
+ pending_model=current_model,
+ )
+ self._apply_selection = apply_selection
+ self._hermes_home = hermes_home
+ self._providers = get_provider_items()
+
+ @property
+ def allowed_user_ids(self) -> tuple[str, ...]:
+ return (self.state.user_id,)
+
+ def _recent_models(self) -> list[RecentModel]:
+ return load_recent_models(
+ self.state.user_id,
+ hermes_home=self._hermes_home,
+ providers=self._providers,
+ )
+
+ async def open(self, interaction: Any) -> None:
+ content, view = self._build_provider_view()
+ await _send_initial_response(interaction, content, view)
+
+ def _provider_line(self, provider_id: str) -> str:
+ return f"`{provider_label(provider_id)}` (`{provider_id}`)"
+
+ def _model_line(self, provider_id: str, model_id: str) -> str:
+ return f"`{model_id}` via {provider_label(provider_id)}"
+
+ def _provider_options(
+ self,
+ page_items: list[ProviderItem],
+ ) -> tuple[discord_components.DiscordSelectOptionSpec, ...]:
+ return tuple(
+ discord_components.DiscordSelectOptionSpec(
+ label=item.label[:100],
+ value=item.provider_id,
+ description="configured" if item.authenticated else "auth required",
+ default=item.provider_id == self.state.pending_provider,
+ )
+ for item in page_items
+ )
+
+ def _model_options(
+ self,
+ models: list[tuple[str, str]],
+ page: int,
+ ) -> tuple[discord_components.DiscordSelectOptionSpec, ...]:
+ page_items, current_page, _total_pages = _paginate(models, page, MODEL_PAGE_SIZE)
+ self.state.model_page = current_page
+ return tuple(
+ discord_components.DiscordSelectOptionSpec(
+ label=model_id[:100],
+ value=model_id,
+ description=(desc or provider_label(self.state.pending_provider))[:100],
+ default=model_id == self.state.pending_model,
+ )
+ for model_id, desc in page_items
+ )
+
+ def _provider_header(self, page: int, total_pages: int) -> str:
+ lines = [
+ "🧠 **Discord Model Picker**",
+ "",
+ f"**Current:** {self._model_line(self.state.current_provider, self.state.current_model)}",
+ f"**Pending:** {self._model_line(self.state.pending_provider, self.state.pending_model or self.state.current_model)}",
+ "",
+ f"Choose a provider for `/{self.state.command_name}`.",
+ f"**Providers:** page {page}/{total_pages}",
+ ]
+ recents = self._recent_models()
+ if recents:
+ lines.extend(
+ [
+ "",
+ "**Recent models:**",
+ *[
+ f"• {self._model_line(recent.provider, recent.model)}"
+ for recent in recents[:RECENT_BUTTONS]
+ ],
+ ]
+ )
+ return "\n".join(lines)
+
+ def _model_header(self, page: int, total_pages: int) -> str:
+ selected = self.state.pending_model or self.state.current_model
+ return "\n".join(
+ [
+ "🧠 **Discord Model Picker**",
+ "",
+ f"**Current:** {self._model_line(self.state.current_provider, self.state.current_model)}",
+ f"**Pending:** {self._model_line(self.state.pending_provider, selected)}",
+ "",
+ f"Choose a model from {self._provider_line(self.state.pending_provider)}.",
+ f"**Models:** page {page}/{total_pages}",
+ "Submit applies the pending selection on the next message.",
+ ]
+ )
+
+ def _build_provider_view(self) -> tuple[str, Any]:
+ view = discord_components.ManagedComponentView(self.runtime, timeout=300)
+ page_items, current_page, total_pages = _paginate(
+ self._providers,
+ self.state.provider_page,
+ PROVIDER_PAGE_SIZE,
+ )
+ self.state.provider_page = current_page
+ view.add_select(
+ discord_components.DiscordSelectSpec(
+ select_type="string",
+ placeholder="Choose a provider",
+ options=self._provider_options(page_items),
+ allowed_user_ids=self.allowed_user_ids,
+ reusable=True,
+ handler=self._handle_provider_select,
+ row=0,
+ )
+ )
+
+ recents = self._recent_models()[:RECENT_BUTTONS]
+ for index, recent in enumerate(recents):
+ view.add_button(
+ discord_components.DiscordButtonSpec(
+ label=_recent_button_label(recent),
+ style="secondary",
+ allowed_user_ids=self.allowed_user_ids,
+ reusable=True,
+ handler=self._make_recent_handler(recent),
+ row=1,
+ )
+ )
+
+ view.add_button(
+ discord_components.DiscordButtonSpec(
+ label="Reset",
+ style="secondary",
+ allowed_user_ids=self.allowed_user_ids,
+ reusable=True,
+ disabled=not self.state.has_pending_change,
+ handler=self._handle_reset,
+ row=2,
+ )
+ )
+ view.add_button(
+ discord_components.DiscordButtonSpec(
+ label="Cancel",
+ style="danger",
+ allowed_user_ids=self.allowed_user_ids,
+ reusable=True,
+ handler=self._handle_cancel,
+ row=2,
+ )
+ )
+ return self._provider_header(current_page, total_pages), view
+
+ def _build_model_view(self) -> tuple[str, Any]:
+ view = discord_components.ManagedComponentView(self.runtime, timeout=300)
+ models = curated_models_for_provider(self.state.pending_provider)
+ _page_items, current_page, total_pages = _paginate(models, self.state.model_page, MODEL_PAGE_SIZE)
+ self.state.model_page = current_page
+ if models:
+ view.add_select(
+ discord_components.DiscordSelectSpec(
+ select_type="string",
+ placeholder="Choose a model",
+ options=self._model_options(models, current_page),
+ allowed_user_ids=self.allowed_user_ids,
+ reusable=True,
+ handler=self._handle_model_select,
+ row=0,
+ )
+ )
+ else:
+ self.state.pending_model = None
+ if total_pages > 1:
+ view.add_button(
+ discord_components.DiscordButtonSpec(
+ label="Prev",
+ style="secondary",
+ allowed_user_ids=self.allowed_user_ids,
+ reusable=True,
+ disabled=current_page <= 1,
+ handler=self._handle_prev_models,
+ row=1,
+ )
+ )
+ view.add_button(
+ discord_components.DiscordButtonSpec(
+ label="Next",
+ style="secondary",
+ allowed_user_ids=self.allowed_user_ids,
+ reusable=True,
+ disabled=current_page >= total_pages,
+ handler=self._handle_next_models,
+ row=1,
+ )
+ )
+ view.add_button(
+ discord_components.DiscordButtonSpec(
+ label="Back",
+ style="secondary",
+ allowed_user_ids=self.allowed_user_ids,
+ reusable=True,
+ handler=self._handle_back,
+ row=2,
+ )
+ )
+ view.add_button(
+ discord_components.DiscordButtonSpec(
+ label="Reset",
+ style="secondary",
+ allowed_user_ids=self.allowed_user_ids,
+ reusable=True,
+ disabled=not self.state.has_pending_change,
+ handler=self._handle_reset,
+ row=2,
+ )
+ )
+ view.add_button(
+ discord_components.DiscordButtonSpec(
+ label="Cancel",
+ style="danger",
+ allowed_user_ids=self.allowed_user_ids,
+ reusable=True,
+ handler=self._handle_cancel,
+ row=2,
+ )
+ )
+ view.add_button(
+ discord_components.DiscordButtonSpec(
+ label="Submit",
+ style="success",
+ allowed_user_ids=self.allowed_user_ids,
+ reusable=True,
+ disabled=not self.state.has_pending_change,
+ handler=self._handle_submit,
+ row=2,
+ )
+ )
+ content = self._model_header(current_page, total_pages)
+ if not models:
+ content += "\n\nNo models are currently available for this provider."
+ return content, view
+
+ def _make_recent_handler(self, recent: RecentModel) -> Callable[[discord_components.DiscordComponentInvocation], Awaitable[bool | None]]:
+ async def handler(invocation: discord_components.DiscordComponentInvocation) -> bool | None:
+ self.state.pending_provider = recent.provider
+ self.state.pending_model = recent.model
+ self.state.model_page = 1
+ content, view = self._build_model_view()
+ await _edit_response(invocation.interaction, content, view)
+ return False
+
+ return handler
+
+ async def _handle_provider_select(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None:
+ selected = invocation.values[0] if invocation.values else ""
+ if not selected:
+ return False
+ self.state.pending_provider = selected
+ catalog = curated_models_for_provider(selected)
+ self.state.pending_model = catalog[0][0] if catalog else None
+ self.state.model_page = 1
+ content, view = self._build_model_view()
+ await _edit_response(invocation.interaction, content, view)
+ return False
+
+ async def _handle_model_select(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None:
+ selected = invocation.values[0] if invocation.values else ""
+ if not selected:
+ return False
+ self.state.pending_model = selected
+ content, view = self._build_model_view()
+ await _edit_response(invocation.interaction, content, view)
+ return False
+
+ async def _handle_prev_models(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None:
+ self.state.model_page = max(1, self.state.model_page - 1)
+ content, view = self._build_model_view()
+ await _edit_response(invocation.interaction, content, view)
+ return False
+
+ async def _handle_next_models(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None:
+ self.state.model_page += 1
+ content, view = self._build_model_view()
+ await _edit_response(invocation.interaction, content, view)
+ return False
+
+ async def _handle_back(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None:
+ content, view = self._build_provider_view()
+ await _edit_response(invocation.interaction, content, view)
+ return False
+
+ async def _handle_reset(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None:
+ self.state.reset()
+ content, view = self._build_provider_view()
+ await _edit_response(invocation.interaction, content, view)
+ return False
+
+ async def _handle_cancel(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None:
+ await _edit_response(
+ invocation.interaction,
+ "🧠 **Discord Model Picker**\n\nCancelled. Current model unchanged.",
+ None,
+ )
+ return None
+
+ async def _handle_submit(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None:
+ model_name = self.state.pending_model or self.state.current_model
+ result = await self._apply_selection(
+ self.state.pending_provider,
+ model_name,
+ self.state.user_id,
+ )
+ await _edit_response(invocation.interaction, result, None)
+ return None
+
+
+async def open_model_picker(
+ *,
+ adapter: Any,
+ interaction: Any,
+ command_name: str,
+ user_id: str,
+ current_provider: str,
+ current_model: str,
+ apply_selection: ApplySelectionFn,
+) -> None:
+ """Open the interactive model picker for a Discord slash interaction."""
+ runtime = getattr(adapter, "_component_runtime", None)
+ if runtime is None:
+ raise RuntimeError("Discord component runtime is unavailable")
+ controller = DiscordModelPickerController(
+ runtime=runtime,
+ command_name=command_name,
+ user_id=user_id,
+ current_provider=current_provider,
+ current_model=current_model,
+ apply_selection=apply_selection,
+ )
+ await controller.open(interaction)
diff --git a/gateway/platforms/discord_impl/native_commands.py b/gateway/platforms/discord_impl/native_commands.py
new file mode 100644
index 000000000000..56f311c20980
--- /dev/null
+++ b/gateway/platforms/discord_impl/native_commands.py
@@ -0,0 +1,1347 @@
+"""Structured Discord native command registration and command UX helpers."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Callable, Optional, Sequence
+
+from gateway.platforms.discord_impl import components as discord_components
+from gateway.platforms.discord_impl import command_sessions
+
+try:
+ import discord
+
+ DISCORD_AVAILABLE = True
+except ImportError: # pragma: no cover - import guard
+ discord = None
+ DISCORD_AVAILABLE = False
+
+
+@dataclass(frozen=True)
+class DiscordChoiceSpec:
+ name: str
+ value: str
+ description: Optional[str] = None
+
+
+@dataclass(frozen=True)
+class DiscordArgSpec:
+ name: str
+ description: str
+ kind: str = "str" # "str" or "int"
+ default: Any = ""
+ required: bool = False
+ choices: tuple[DiscordChoiceSpec, ...] = ()
+ prefer_autocomplete: bool = False
+ allow_fallback_menu: bool = False
+
+
+@dataclass(frozen=True)
+class DiscordNativeCommandSpec:
+ name: str
+ description: str
+ route: str # "simple", "dispatch", "thread"
+ command_factory: Callable[..., str] | None = None
+ args: tuple[DiscordArgSpec, ...] = ()
+ followup_msg: Optional[str] = None
+ defer_ephemeral: bool = True
+
+
+def _join_command(base: str, *parts: Any) -> str:
+ suffix = " ".join(str(part).strip() for part in parts if str(part).strip())
+ return f"{base} {suffix}".strip()
+
+
+def get_command_specs() -> tuple[DiscordNativeCommandSpec, ...]:
+ """Return the structured Discord-native command spec set."""
+ voice_choices = (
+ DiscordChoiceSpec("channel — join your voice channel", "channel"),
+ DiscordChoiceSpec("leave — leave voice channel", "leave"),
+ DiscordChoiceSpec("on — voice reply to voice messages", "on"),
+ DiscordChoiceSpec("tts — voice reply to all messages", "tts"),
+ DiscordChoiceSpec("off — text only", "off"),
+ DiscordChoiceSpec("status — show current mode", "status"),
+ )
+ vc_choices = (
+ DiscordChoiceSpec("join — join your current voice channel", "join"),
+ DiscordChoiceSpec("leave — leave the active voice channel", "leave"),
+ DiscordChoiceSpec("status — show current voice channel status", "status"),
+ )
+ think_choices = (
+ DiscordChoiceSpec("off — disable reasoning effort", "off"),
+ DiscordChoiceSpec("minimal — minimum reasoning", "minimal"),
+ DiscordChoiceSpec("low — lighter reasoning", "low"),
+ DiscordChoiceSpec("medium — balanced reasoning", "medium"),
+ DiscordChoiceSpec("high — deeper reasoning", "high"),
+ DiscordChoiceSpec("xhigh — maximum reasoning", "xhigh"),
+ )
+ reasoning_choices = (
+ DiscordChoiceSpec("off — hide reasoning blocks", "off"),
+ DiscordChoiceSpec("on — show reasoning blocks", "on"),
+ DiscordChoiceSpec("hide — hide reasoning blocks", "hide"),
+ DiscordChoiceSpec("show — show reasoning blocks", "show"),
+ DiscordChoiceSpec("none — disable reasoning effort", "none"),
+ DiscordChoiceSpec("minimal — minimum reasoning", "minimal"),
+ DiscordChoiceSpec("low — lighter reasoning", "low"),
+ DiscordChoiceSpec("medium — balanced reasoning", "medium"),
+ DiscordChoiceSpec("high — deeper reasoning", "high"),
+ DiscordChoiceSpec("xhigh — maximum reasoning", "xhigh"),
+ )
+ approval_choices = (
+ DiscordChoiceSpec("allow-once — run this command once", "allow-once"),
+ DiscordChoiceSpec("allow-always — permanently allow this pattern", "allow-always"),
+ DiscordChoiceSpec("deny — reject this command", "deny"),
+ )
+ send_choices = (
+ DiscordChoiceSpec("on — allow send_message for this session", "on"),
+ DiscordChoiceSpec("off — block send_message for this session", "off"),
+ DiscordChoiceSpec("inherit — use the default behavior", "inherit"),
+ )
+ activation_choices = (
+ DiscordChoiceSpec("mention — require an explicit mention", "mention"),
+ DiscordChoiceSpec("always — respond without a mention", "always"),
+ )
+ session_choices = (
+ DiscordChoiceSpec("idle — inactivity auto-unfocus window", "idle"),
+ DiscordChoiceSpec("max-age — hard focus lifetime", "max-age"),
+ DiscordChoiceSpec("status — show current focus binding", "status"),
+ )
+ context_choices = (
+ DiscordChoiceSpec("list — concise context summary", "list"),
+ DiscordChoiceSpec("detail — full context breakdown", "detail"),
+ DiscordChoiceSpec("json — machine-readable snapshot", "json"),
+ )
+ allowlist_choices = (
+ DiscordChoiceSpec("list — show approved command patterns", "list"),
+ DiscordChoiceSpec("add — permanently approve a pattern", "add"),
+ DiscordChoiceSpec("remove — remove a stored pattern", "remove"),
+ )
+ config_choices = (
+ DiscordChoiceSpec("show — show the current on-disk config", "show"),
+ DiscordChoiceSpec("get — read one config or env key", "get"),
+ DiscordChoiceSpec("set — write one config or env key", "set"),
+ DiscordChoiceSpec("unset — remove one config or env key", "unset"),
+ )
+ debug_choices = (
+ DiscordChoiceSpec("show — show runtime override status", "show"),
+ DiscordChoiceSpec("set — set a runtime override", "set"),
+ DiscordChoiceSpec("unset — remove a runtime override", "unset"),
+ DiscordChoiceSpec("reset — clear all runtime overrides", "reset"),
+ )
+ subagent_choices = (
+ DiscordChoiceSpec("list — list sub-agent runs", "list"),
+ DiscordChoiceSpec("kill — kill a sub-agent run", "kill"),
+ DiscordChoiceSpec("log — show sub-agent logs", "log"),
+ DiscordChoiceSpec("info — inspect a sub-agent run", "info"),
+ DiscordChoiceSpec("send — send input to a sub-agent", "send"),
+ DiscordChoiceSpec("steer — steer a sub-agent", "steer"),
+ DiscordChoiceSpec("spawn — spawn a sub-agent", "spawn"),
+ )
+ acp_choices = (
+ DiscordChoiceSpec("spawn — create an ACP session", "spawn"),
+ DiscordChoiceSpec("cancel — cancel ACP work", "cancel"),
+ DiscordChoiceSpec("steer — steer ACP work", "steer"),
+ DiscordChoiceSpec("close — close an ACP session", "close"),
+ DiscordChoiceSpec("status — show ACP status", "status"),
+ DiscordChoiceSpec("set-mode — update ACP mode", "set-mode"),
+ DiscordChoiceSpec("set — set ACP runtime options", "set"),
+ DiscordChoiceSpec("cwd — change ACP cwd", "cwd"),
+ DiscordChoiceSpec("permissions — change ACP permissions", "permissions"),
+ DiscordChoiceSpec("timeout — change ACP timeout", "timeout"),
+ DiscordChoiceSpec("model — change ACP model", "model"),
+ DiscordChoiceSpec("reset-options — clear ACP options", "reset-options"),
+ DiscordChoiceSpec("doctor — inspect ACP health", "doctor"),
+ DiscordChoiceSpec("install — install ACP tooling", "install"),
+ DiscordChoiceSpec("sessions — list ACP sessions", "sessions"),
+ )
+
+ return (
+ DiscordNativeCommandSpec(
+ "new",
+ "Start a new conversation",
+ "simple",
+ lambda: "/reset",
+ followup_msg="New conversation started~",
+ ),
+ DiscordNativeCommandSpec(
+ "reset",
+ "Reset your Hermes session",
+ "simple",
+ lambda: "/reset",
+ followup_msg="Session reset~",
+ ),
+ DiscordNativeCommandSpec("help", "Show available commands", "simple", lambda: "/help"),
+ DiscordNativeCommandSpec(
+ "commands",
+ "Show the full command catalog",
+ "simple",
+ lambda: "/commands",
+ ),
+ DiscordNativeCommandSpec(
+ "context",
+ "Show context summary, detail, or JSON",
+ "simple",
+ lambda mode="": _join_command("/context", mode),
+ args=(
+ DiscordArgSpec(
+ "mode",
+ "Context mode: list, detail, or json",
+ choices=context_choices,
+ default="list",
+ prefer_autocomplete=True,
+ allow_fallback_menu=True,
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "export-session",
+ "Export the current session snapshot",
+ "simple",
+ lambda path="": _join_command("/export-session", path),
+ args=(
+ DiscordArgSpec(
+ "path",
+ "Optional export path (.html or .json). Leave empty for the default HTML export.",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "export",
+ "Alias for /export-session",
+ "simple",
+ lambda path="": _join_command("/export", path),
+ args=(
+ DiscordArgSpec(
+ "path",
+ "Optional export path (.html or .json). Leave empty for the default HTML export.",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "whoami",
+ "Show the Discord identity Hermes sees",
+ "simple",
+ lambda: "/whoami",
+ ),
+ DiscordNativeCommandSpec(
+ "focus",
+ "Bind the current Discord thread to this Hermes session",
+ "dispatch",
+ lambda name="": _join_command("/focus", name),
+ args=(
+ DiscordArgSpec(
+ "name",
+ "Optional label for the current thread binding",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "unfocus",
+ "Remove the current Discord thread binding",
+ "simple",
+ lambda: "/unfocus",
+ ),
+ DiscordNativeCommandSpec(
+ "agents",
+ "Show Discord thread bindings for this session",
+ "simple",
+ lambda: "/agents",
+ ),
+ DiscordNativeCommandSpec(
+ "session",
+ "Manage thread binding idle and max-age controls",
+ "dispatch",
+ lambda mode="", value="": _join_command("/session", mode, value),
+ args=(
+ DiscordArgSpec(
+ "mode",
+ "Thread binding control: idle, max-age, or status",
+ choices=session_choices,
+ prefer_autocomplete=True,
+ allow_fallback_menu=True,
+ ),
+ DiscordArgSpec(
+ "value",
+ "Duration like 30m, 2h, 1d, or off",
+ default="",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "id",
+ "Alias for /whoami",
+ "simple",
+ lambda: "/id",
+ ),
+ DiscordNativeCommandSpec(
+ "status",
+ "Show Hermes session status",
+ "simple",
+ lambda: "/status",
+ followup_msg="Status sent~",
+ ),
+ DiscordNativeCommandSpec(
+ "model",
+ "Show or change the model",
+ "simple",
+ lambda name="": _join_command("/model", name),
+ args=(
+ DiscordArgSpec(
+ "name",
+ "Model name (e.g. anthropic/claude-sonnet-4). Leave empty to see current.",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "models",
+ "Open the interactive model picker",
+ "simple",
+ lambda: "/models",
+ ),
+ DiscordNativeCommandSpec(
+ "reasoning",
+ "Show or change reasoning effort",
+ "dispatch",
+ lambda effort="": _join_command("/reasoning", effort),
+ args=(
+ DiscordArgSpec(
+ "effort",
+ "Reasoning effort: xhigh, high, medium, low, minimal, or none.",
+ choices=reasoning_choices,
+ prefer_autocomplete=True,
+ allow_fallback_menu=True,
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "think",
+ "Set reasoning effort quickly",
+ "dispatch",
+ lambda effort="": _join_command("/think", effort),
+ args=(
+ DiscordArgSpec(
+ "effort",
+ "Thinking effort: off, minimal, low, medium, high, or xhigh.",
+ choices=think_choices,
+ prefer_autocomplete=True,
+ allow_fallback_menu=True,
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "personality",
+ "Set a personality",
+ "simple",
+ lambda name="": _join_command("/personality", name),
+ args=(
+ DiscordArgSpec(
+ "name",
+ "Personality name. Leave empty to list available.",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "retry",
+ "Retry your last message",
+ "simple",
+ lambda: "/retry",
+ followup_msg="Retrying~",
+ ),
+ DiscordNativeCommandSpec("undo", "Remove the last exchange", "simple", lambda: "/undo"),
+ DiscordNativeCommandSpec(
+ "sethome",
+ "Set this chat as the home channel",
+ "simple",
+ lambda: "/sethome",
+ ),
+ DiscordNativeCommandSpec(
+ "stop",
+ "Stop the running Hermes agent",
+ "simple",
+ lambda: "/stop",
+ followup_msg="Stop requested~",
+ ),
+ DiscordNativeCommandSpec(
+ "compact",
+ "Compress conversation context with optional instructions",
+ "simple",
+ lambda instructions="": _join_command("/compact", instructions),
+ args=(
+ DiscordArgSpec(
+ "instructions",
+ "Optional compression guidance for what to preserve.",
+ default="",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "compress",
+ "Compress conversation context",
+ "simple",
+ lambda: "/compress",
+ ),
+ DiscordNativeCommandSpec(
+ "title",
+ "Set or show the session title",
+ "simple",
+ lambda name="": _join_command("/title", name),
+ args=(
+ DiscordArgSpec(
+ "name",
+ "Session title. Leave empty to show current.",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "resume",
+ "Resume a previously-named session",
+ "simple",
+ lambda name="": _join_command("/resume", name),
+ args=(
+ DiscordArgSpec(
+ "name",
+ "Session name to resume. Leave empty to list sessions.",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "usage",
+ "Show token usage for this session",
+ "simple",
+ lambda: "/usage",
+ ),
+ DiscordNativeCommandSpec(
+ "provider",
+ "Show available providers",
+ "simple",
+ lambda: "/provider",
+ ),
+ DiscordNativeCommandSpec(
+ "insights",
+ "Show usage insights and analytics",
+ "simple",
+ lambda days=7: _join_command("/insights", days),
+ args=(
+ DiscordArgSpec(
+ "days",
+ "Number of days to analyze (default: 7)",
+ kind="int",
+ default=7,
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "reload-mcp",
+ "Reload MCP servers from config",
+ "simple",
+ lambda: "/reload-mcp",
+ ),
+ DiscordNativeCommandSpec(
+ "skill",
+ "Run a skill by name",
+ "dispatch",
+ lambda name="", input="": _join_command("/skill", name, input),
+ args=(
+ DiscordArgSpec(
+ "name",
+ "Skill name to invoke",
+ default="",
+ prefer_autocomplete=True,
+ allow_fallback_menu=True,
+ ),
+ DiscordArgSpec(
+ "input",
+ "Optional user input for the skill",
+ default="",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "subagents",
+ "Inspect or control sub-agent runs",
+ "simple",
+ lambda mode="", payload="": _join_command("/subagents", mode, payload),
+ args=(
+ DiscordArgSpec(
+ "mode",
+ "Sub-agent action: list, kill, log, info, send, steer, or spawn",
+ choices=subagent_choices,
+ default="list",
+ prefer_autocomplete=True,
+ allow_fallback_menu=True,
+ ),
+ DiscordArgSpec(
+ "payload",
+ "Optional sub-agent command arguments",
+ default="",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "kill",
+ "Abort a running sub-agent",
+ "simple",
+ lambda target="": _join_command("/kill", target),
+ args=(
+ DiscordArgSpec(
+ "target",
+ "Target sub-agent id, ordinal, or all",
+ default="",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "steer",
+ "Steer a running sub-agent",
+ "simple",
+ lambda target="", message="": _join_command("/steer", target, message),
+ args=(
+ DiscordArgSpec(
+ "target",
+ "Target sub-agent id or ordinal",
+ default="",
+ ),
+ DiscordArgSpec(
+ "message",
+ "Steer message",
+ default="",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "tell",
+ "Alias for /steer",
+ "simple",
+ lambda target="", message="": _join_command("/tell", target, message),
+ args=(
+ DiscordArgSpec(
+ "target",
+ "Target sub-agent id or ordinal",
+ default="",
+ ),
+ DiscordArgSpec(
+ "message",
+ "Steer message",
+ default="",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "acp",
+ "Inspect or control ACP runtime sessions",
+ "simple",
+ lambda mode="", payload="": _join_command("/acp", mode, payload),
+ args=(
+ DiscordArgSpec(
+ "mode",
+ "ACP action to run",
+ choices=acp_choices,
+ default="status",
+ prefer_autocomplete=True,
+ allow_fallback_menu=True,
+ ),
+ DiscordArgSpec(
+ "payload",
+ "Optional ACP command arguments",
+ default="",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "bash",
+ "Run a host shell command through gateway control",
+ "simple",
+ lambda command="": _join_command("/bash", command),
+ args=(
+ DiscordArgSpec(
+ "command",
+ "Host shell command to run",
+ default="",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "voice",
+ "Toggle voice reply mode",
+ "dispatch",
+ lambda mode="": _join_command("/voice", mode),
+ args=(
+ DiscordArgSpec(
+ "mode",
+ "Voice mode: on, off, tts, channel, leave, or status",
+ choices=voice_choices,
+ prefer_autocomplete=True,
+ allow_fallback_menu=True,
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "vc",
+ "Join, leave, or inspect Discord voice channel state",
+ "dispatch",
+ lambda mode="": _join_command("/vc", mode),
+ args=(
+ DiscordArgSpec(
+ "mode",
+ "Voice channel command: join, leave, or status",
+ choices=vc_choices,
+ prefer_autocomplete=True,
+ allow_fallback_menu=True,
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "approve",
+ "Resolve a pending command approval",
+ "dispatch",
+ lambda decision="", approval_id="": _join_command("/approve", approval_id, decision),
+ args=(
+ DiscordArgSpec(
+ "decision",
+ "Approval decision: allow-once, allow-always, or deny",
+ choices=approval_choices,
+ prefer_autocomplete=True,
+ allow_fallback_menu=True,
+ ),
+ DiscordArgSpec(
+ "approval_id",
+ "Approval ID shown in the approval prompt footer. Optional for the current chat.",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "allowlist",
+ "Inspect or edit the command allowlist",
+ "simple",
+ lambda mode="", entry="": _join_command("/allowlist", mode, entry),
+ args=(
+ DiscordArgSpec(
+ "mode",
+ "Allowlist action: list, add, or remove",
+ choices=allowlist_choices,
+ default="list",
+ prefer_autocomplete=True,
+ allow_fallback_menu=True,
+ ),
+ DiscordArgSpec(
+ "entry",
+ "Pattern key to add or remove. Leave empty for list.",
+ default="",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "config",
+ "Show or update Hermes configuration",
+ "simple",
+ lambda mode="", key="", value="": _join_command("/config", mode, key, value),
+ args=(
+ DiscordArgSpec(
+ "mode",
+ "Config action: show, get, set, or unset",
+ choices=config_choices,
+ default="show",
+ prefer_autocomplete=True,
+ allow_fallback_menu=True,
+ ),
+ DiscordArgSpec(
+ "key",
+ "Dotted config path or env var name",
+ default="",
+ ),
+ DiscordArgSpec(
+ "value",
+ "Value to set. YAML scalars and JSON-style lists work.",
+ default="",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "debug",
+ "Inspect runtime override parity status",
+ "simple",
+ lambda mode="", key="", value="": _join_command("/debug", mode, key, value),
+ args=(
+ DiscordArgSpec(
+ "mode",
+ "Debug action: show, set, unset, or reset",
+ choices=debug_choices,
+ default="show",
+ prefer_autocomplete=True,
+ allow_fallback_menu=True,
+ ),
+ DiscordArgSpec(
+ "key",
+ "Runtime override key",
+ default="",
+ ),
+ DiscordArgSpec(
+ "value",
+ "Runtime override value",
+ default="",
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "send",
+ "Control whether this session may use send_message",
+ "dispatch",
+ lambda mode="": _join_command("/send", mode),
+ args=(
+ DiscordArgSpec(
+ "mode",
+ "Send policy: on, off, or inherit",
+ choices=send_choices,
+ prefer_autocomplete=True,
+ allow_fallback_menu=True,
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "activation",
+ "Control mention-vs-always activation for this Discord chat",
+ "dispatch",
+ lambda mode="": _join_command("/activation", mode),
+ args=(
+ DiscordArgSpec(
+ "mode",
+ "Activation mode: mention or always",
+ choices=activation_choices,
+ prefer_autocomplete=True,
+ allow_fallback_menu=True,
+ ),
+ ),
+ ),
+ DiscordNativeCommandSpec(
+ "update",
+ "Update Hermes Agent to the latest version",
+ "simple",
+ lambda: "/update",
+ followup_msg="Update initiated~",
+ ),
+ DiscordNativeCommandSpec(
+ "restart",
+ "Restart the Hermes gateway",
+ "simple",
+ lambda: "/restart",
+ followup_msg="Restart scheduled~",
+ ),
+ DiscordNativeCommandSpec(
+ "dock-telegram",
+ "Dock replies for this session to the Telegram home channel",
+ "simple",
+ lambda: "/dock-telegram",
+ ),
+ DiscordNativeCommandSpec(
+ "dock-discord",
+ "Dock replies for this session to the Discord home channel",
+ "simple",
+ lambda: "/dock-discord",
+ ),
+ DiscordNativeCommandSpec(
+ "dock-slack",
+ "Dock replies for this session to the Slack home channel",
+ "simple",
+ lambda: "/dock-slack",
+ ),
+ DiscordNativeCommandSpec(
+ "thread",
+ "Create a new thread and start a Hermes session in it",
+ "thread",
+ args=(
+ DiscordArgSpec("name", "Thread name"),
+ DiscordArgSpec(
+ "message",
+ "Optional first message to send to Hermes in the thread",
+ default="",
+ ),
+ DiscordArgSpec(
+ "auto_archive_duration",
+ "Auto-archive in minutes (60, 1440, 4320, 10080)",
+ kind="int",
+ default=1440,
+ ),
+ ),
+ ),
+ )
+
+
+def extract_inline_shortcut(text: str) -> tuple[str | None, str]:
+ """Return the first supported inline shortcut and remaining text."""
+ return command_sessions.extract_inline_shortcut(text)
+
+
+def _resolve_arg_choices(
+ adapter: Any,
+ spec: DiscordNativeCommandSpec,
+ arg: DiscordArgSpec,
+ *,
+ interaction: Any | None = None,
+ current_kwargs: Optional[dict[str, Any]] = None,
+) -> tuple[DiscordChoiceSpec, ...]:
+ del adapter, interaction, current_kwargs
+ if spec.name == "skill" and arg.name == "name":
+ try:
+ from agent.skill_commands import get_skill_commands
+
+ choices: list[DiscordChoiceSpec] = []
+ for command_name, payload in sorted(get_skill_commands().items()):
+ value = command_name.lstrip("/")
+ description = str(payload.get("description") or "Skill command")
+ choices.append(DiscordChoiceSpec(value, value, description[:100] or None))
+ return tuple(choices)
+ except Exception:
+ return ()
+ return arg.choices
+
+
+def _format_choice_label(label: str, *, limit: int = 80) -> str:
+ if len(label) <= limit:
+ return label
+ return label[: limit - 3] + "..."
+
+
+async def _send_or_edit_interaction_view(interaction: Any, content: str, view: Any) -> None:
+ response = getattr(interaction, "response", None)
+ if response is not None and hasattr(response, "edit_message") and getattr(interaction, "message", None) is not None:
+ try:
+ await response.edit_message(content=content, view=view)
+ return
+ except Exception:
+ pass
+ if response is not None and hasattr(response, "send_message"):
+ is_done = getattr(response, "is_done", None)
+ if not callable(is_done) or not is_done():
+ await response.send_message(content, ephemeral=True, view=view)
+ return
+ followup = getattr(interaction, "followup", None)
+ if followup is not None and hasattr(followup, "send"):
+ await followup.send(content, ephemeral=True, view=view)
+
+
+async def _open_arg_fallback(
+ adapter: Any,
+ interaction: Any,
+ spec: DiscordNativeCommandSpec,
+ arg: DiscordArgSpec,
+ current_kwargs: dict[str, Any],
+) -> bool:
+ choices = _resolve_arg_choices(adapter, spec, arg, interaction=interaction, current_kwargs=current_kwargs)
+ if not choices or not arg.allow_fallback_menu:
+ return False
+
+ runtime = getattr(adapter, "_component_runtime", None)
+ if runtime is None or getattr(discord_components, "ManagedComponentView", None) is None:
+ return False
+
+ view = discord_components.ManagedComponentView(runtime, timeout=300)
+ allowed_user_id = str(getattr(getattr(interaction, "user", None), "id", "") or "")
+ prompt = f"Choose `{arg.name}` for `/{spec.name}`."
+
+ async def _choose(invocation: discord_components.DiscordComponentInvocation, value: str) -> bool:
+ next_kwargs = dict(current_kwargs)
+ next_kwargs[arg.name] = value
+ await _dispatch(adapter, invocation.interaction, spec, **next_kwargs)
+ return True
+
+ if len(choices) <= 5:
+ for choice in choices:
+ view.add_button(
+ discord_components.DiscordButtonSpec(
+ label=_format_choice_label(choice.name, limit=40),
+ style="primary",
+ allowed_user_ids=(allowed_user_id,),
+ handler=lambda invocation, value=choice.value: _choose(invocation, value),
+ )
+ )
+ else:
+ view.add_select(
+ discord_components.DiscordSelectSpec(
+ select_type="string",
+ placeholder=arg.description,
+ options=tuple(
+ discord_components.DiscordSelectOptionSpec(
+ label=_format_choice_label(choice.name, limit=100),
+ value=choice.value,
+ description=choice.description,
+ )
+ for choice in choices[:25]
+ ),
+ allowed_user_ids=(allowed_user_id,),
+ handler=lambda invocation: _choose(invocation, invocation.values[0]),
+ )
+ )
+
+ await _send_or_edit_interaction_view(interaction, prompt, view)
+ return True
+
+
+async def _autocomplete_choices(
+ adapter: Any,
+ spec: DiscordNativeCommandSpec,
+ arg: DiscordArgSpec,
+ interaction: Any,
+ current: str,
+) -> list[Any]:
+ choices = _resolve_arg_choices(adapter, spec, arg, interaction=interaction)
+ query = str(current or "").strip().lower()
+ filtered = [
+ choice
+ for choice in choices
+ if not query
+ or query in choice.name.lower()
+ or query in choice.value.lower()
+ ]
+ return [
+ discord.app_commands.Choice(name=choice.name, value=choice.value)
+ for choice in filtered[:25]
+ ]
+
+
+def _build_choices_decorator(
+ adapter: Any,
+ spec: DiscordNativeCommandSpec,
+ arg: DiscordArgSpec,
+):
+ choices = _resolve_arg_choices(adapter, spec, arg)
+ use_autocomplete = bool(arg.prefer_autocomplete and choices)
+ if use_autocomplete:
+ autocomplete_factory = getattr(discord.app_commands, "autocomplete", None)
+ if autocomplete_factory is None:
+ return (lambda fn: fn), None
+
+ async def autocomplete_callback(interaction, current):
+ return await _autocomplete_choices(
+ adapter,
+ spec,
+ arg,
+ interaction,
+ current,
+ )
+
+ return None, discord.app_commands.autocomplete(
+ **{arg.name: autocomplete_callback}
+ )
+
+ if choices:
+ return discord.app_commands.choices(
+ **{
+ arg.name: [
+ discord.app_commands.Choice(name=choice.name, value=choice.value)
+ for choice in choices[:25]
+ ]
+ }
+ ), None
+
+ identity = lambda fn: fn
+ return identity, None
+
+
+def _register_zero_arg_command(tree: Any, adapter: Any, spec: DiscordNativeCommandSpec) -> None:
+ @tree.command(name=spec.name, description=spec.description)
+ async def callback(interaction: Any):
+ await _dispatch(adapter, interaction, spec)
+
+
+def _register_single_arg_command(tree: Any, adapter: Any, spec: DiscordNativeCommandSpec) -> None:
+ arg = spec.args[0]
+ choices_decorator, autocomplete_decorator = _build_choices_decorator(adapter, spec, arg)
+ if choices_decorator is None:
+ choices_decorator = lambda fn: fn
+ if autocomplete_decorator is None:
+ autocomplete_decorator = lambda fn: fn
+
+ if arg.name == "name":
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(name=arg.description)
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ name: str = arg.default,
+ ):
+ await _dispatch(adapter, interaction, spec, name=name)
+ return
+
+ if arg.name == "effort":
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(effort=arg.description)
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ effort: str = arg.default,
+ ):
+ await _dispatch(adapter, interaction, spec, effort=effort)
+ return
+
+ if arg.name == "days":
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(days=arg.description)
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ days: int = arg.default,
+ ):
+ await _dispatch(adapter, interaction, spec, days=days)
+ return
+
+ if arg.name == "mode":
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(mode=arg.description)
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ mode: str = arg.default,
+ ):
+ await _dispatch(adapter, interaction, spec, mode=mode)
+ return
+
+ if arg.name == "path":
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(path=arg.description)
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ path: str = arg.default,
+ ):
+ await _dispatch(adapter, interaction, spec, path=path)
+ return
+
+ if arg.name == "instructions":
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(instructions=arg.description)
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ instructions: str = arg.default,
+ ):
+ await _dispatch(adapter, interaction, spec, instructions=instructions)
+ return
+
+ if arg.name == "target":
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(target=arg.description)
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ target: str = arg.default,
+ ):
+ await _dispatch(adapter, interaction, spec, target=target)
+ return
+
+ if arg.name == "command":
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(command=arg.description)
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ command: str = arg.default,
+ ):
+ await _dispatch(adapter, interaction, spec, command=command)
+ return
+
+ if arg.name == "decision":
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(decision=arg.description)
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ decision: str = arg.default,
+ ):
+ await _dispatch(adapter, interaction, spec, decision=decision)
+ return
+
+ raise ValueError(f"Unsupported Discord arg registration for {spec.name}:{arg.name}")
+
+
+def _register_double_arg_command(tree: Any, adapter: Any, spec: DiscordNativeCommandSpec) -> None:
+ first, second = spec.args
+ choices_decorator, autocomplete_decorator = _build_choices_decorator(adapter, spec, first)
+ if choices_decorator is None:
+ choices_decorator = lambda fn: fn
+ if autocomplete_decorator is None:
+ autocomplete_decorator = lambda fn: fn
+
+ if (first.name, second.name) == ("decision", "approval_id"):
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(
+ decision=first.description,
+ approval_id=second.description,
+ )
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ decision: str = first.default,
+ approval_id: str = second.default,
+ ):
+ await _dispatch(
+ adapter,
+ interaction,
+ spec,
+ decision=decision,
+ approval_id=approval_id,
+ )
+ return
+
+ if (first.name, second.name) == ("mode", "value"):
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(
+ mode=first.description,
+ value=second.description,
+ )
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ mode: str = first.default,
+ value: str = second.default,
+ ):
+ await _dispatch(
+ adapter,
+ interaction,
+ spec,
+ mode=mode,
+ value=value,
+ )
+ return
+
+ if (first.name, second.name) == ("mode", "entry"):
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(
+ mode=first.description,
+ entry=second.description,
+ )
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ mode: str = first.default,
+ entry: str = second.default,
+ ):
+ await _dispatch(
+ adapter,
+ interaction,
+ spec,
+ mode=mode,
+ entry=entry,
+ )
+ return
+
+ if (first.name, second.name) == ("name", "input"):
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(
+ name=first.description,
+ input=second.description,
+ )
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ name: str = first.default,
+ input: str = second.default,
+ ):
+ await _dispatch(
+ adapter,
+ interaction,
+ spec,
+ name=name,
+ input=input,
+ )
+ return
+
+ if (first.name, second.name) == ("mode", "payload"):
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(
+ mode=first.description,
+ payload=second.description,
+ )
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ mode: str = first.default,
+ payload: str = second.default,
+ ):
+ await _dispatch(
+ adapter,
+ interaction,
+ spec,
+ mode=mode,
+ payload=payload,
+ )
+ return
+
+ if (first.name, second.name) == ("target", "message"):
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(
+ target=first.description,
+ message=second.description,
+ )
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ target: str = first.default,
+ message: str = second.default,
+ ):
+ await _dispatch(
+ adapter,
+ interaction,
+ spec,
+ target=target,
+ message=message,
+ )
+ return
+
+ raise ValueError(
+ f"Unsupported Discord command spec shape for {spec.name}:{first.name},{second.name}"
+ )
+
+
+def _register_triple_arg_command(tree: Any, adapter: Any, spec: DiscordNativeCommandSpec) -> None:
+ first, second, third = spec.args
+ choices_decorator, autocomplete_decorator = _build_choices_decorator(adapter, spec, first)
+ if choices_decorator is None:
+ choices_decorator = lambda fn: fn
+ if autocomplete_decorator is None:
+ autocomplete_decorator = lambda fn: fn
+
+ if (first.name, second.name, third.name) == ("mode", "key", "value"):
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(
+ mode=first.description,
+ key=second.description,
+ value=third.description,
+ )
+ @autocomplete_decorator
+ @choices_decorator
+ async def callback(
+ interaction: Any,
+ mode: str = first.default,
+ key: str = second.default,
+ value: str = third.default,
+ ):
+ await _dispatch(
+ adapter,
+ interaction,
+ spec,
+ mode=mode,
+ key=key,
+ value=value,
+ )
+ return
+
+ raise ValueError(
+ f"Unsupported Discord command spec shape for {spec.name}:{first.name},{second.name},{third.name}"
+ )
+
+
+def _register_thread_command(tree: Any, adapter: Any, spec: DiscordNativeCommandSpec) -> None:
+ @tree.command(name=spec.name, description=spec.description)
+ @discord.app_commands.describe(
+ name=spec.args[0].description,
+ message=spec.args[1].description,
+ auto_archive_duration=spec.args[2].description,
+ )
+ async def callback(
+ interaction: Any,
+ name: str,
+ message: str = "",
+ auto_archive_duration: int = 1440,
+ ):
+ await _dispatch(
+ adapter,
+ interaction,
+ spec,
+ name=name,
+ message=message,
+ auto_archive_duration=auto_archive_duration,
+ )
+
+
+def register_slash_commands(tree: Any, adapter: Any) -> None:
+ """Register Discord slash commands from structured specs."""
+ if tree is None or not DISCORD_AVAILABLE:
+ return
+
+ for spec in get_command_specs():
+ if spec.route == "thread":
+ _register_thread_command(tree, adapter, spec)
+ continue
+ if len(spec.args) == 0:
+ _register_zero_arg_command(tree, adapter, spec)
+ continue
+ if len(spec.args) == 1:
+ _register_single_arg_command(tree, adapter, spec)
+ continue
+ if len(spec.args) == 2:
+ _register_double_arg_command(tree, adapter, spec)
+ continue
+ if len(spec.args) == 3:
+ _register_triple_arg_command(tree, adapter, spec)
+ continue
+ raise ValueError(f"Unsupported Discord command spec shape for {spec.name}")
+
+
+def build_slash_event(adapter: Any, interaction: Any, text: str):
+ """Build a slash event via the command-session helper."""
+ return command_sessions.build_slash_event(adapter, interaction, text)
+
+
+async def _dispatch(adapter: Any, interaction: Any, spec: DiscordNativeCommandSpec, **kwargs: Any) -> None:
+ missing_choice_arg = next(
+ (
+ arg
+ for arg in spec.args
+ if arg.allow_fallback_menu
+ and not str(kwargs.get(arg.name, "") or "").strip()
+ and _resolve_arg_choices(adapter, spec, arg, interaction=interaction, current_kwargs=kwargs)
+ ),
+ None,
+ )
+ if missing_choice_arg is not None:
+ opened = await _open_arg_fallback(
+ adapter,
+ interaction,
+ spec,
+ missing_choice_arg,
+ kwargs,
+ )
+ if opened:
+ return
+
+ if spec.route == "simple":
+ command_text = spec.command_factory(**kwargs) if spec.command_factory else f"/{spec.name}"
+ await adapter._run_simple_slash(interaction, command_text, spec.followup_msg)
+ return
+
+ if spec.route == "dispatch":
+ if spec.defer_ephemeral:
+ await interaction.response.defer(ephemeral=True)
+ command_text = spec.command_factory(**kwargs) if spec.command_factory else f"/{spec.name}"
+ response = await adapter._invoke_native_slash_command(interaction, command_text)
+ if response:
+ await adapter._send_native_slash_content(interaction, response)
+ return
+
+ if spec.route == "thread":
+ if spec.defer_ephemeral:
+ await interaction.response.defer(ephemeral=True)
+ await adapter._handle_thread_create_slash(
+ interaction,
+ kwargs["name"],
+ kwargs.get("message", ""),
+ kwargs.get("auto_archive_duration", 1440),
+ )
+ return
+
+ raise ValueError(f"Unsupported Discord command route: {spec.route}")
diff --git a/gateway/platforms/discord_impl/permissions.py b/gateway/platforms/discord_impl/permissions.py
new file mode 100644
index 000000000000..8fa7bd91db54
--- /dev/null
+++ b/gateway/platforms/discord_impl/permissions.py
@@ -0,0 +1,202 @@
+"""Discord permission introspection.
+
+Channel/thread access checks, bot permission queries, and visibility helpers.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Optional
+
+from gateway.platforms.discord_impl.delivery import resolve_channel
+
+try:
+ import discord
+except ImportError: # pragma: no cover - import guard
+ discord = None
+
+
+@dataclass
+class ChannelPermissions:
+ """Bot's effective permissions in a Discord channel."""
+
+ channel_id: str
+ channel_name: str
+ can_read: bool
+ can_send: bool
+ can_read_history: bool
+ can_attach_files: bool
+ can_embed_links: bool
+ can_add_reactions: bool
+ can_manage_threads: bool
+ can_create_threads: bool
+
+
+@dataclass
+class AccessibleChannel:
+ """Readable Discord channel entry with normalized target metadata."""
+
+ channel_id: str
+ channel_name: str
+ guild_id: Optional[str]
+ guild_name: Optional[str]
+ channel_kind: str
+ qualified_name: str
+ mention: str
+ can_read: bool
+ can_send: bool
+ can_read_history: bool
+ can_attach_files: bool
+ can_embed_links: bool
+ can_add_reactions: bool
+ can_manage_threads: bool
+ can_create_threads: bool
+
+
+def _channel_name(channel: Any) -> str:
+ recipient = getattr(channel, "recipient", None)
+ recipient_name = getattr(recipient, "name", None)
+ return (
+ getattr(channel, "name", None)
+ or recipient_name
+ or str(getattr(channel, "id", "unknown"))
+ )
+
+
+def _permission_flag(perms: Any, *names: str) -> bool:
+ for name in names:
+ value = getattr(perms, name, None)
+ if value is not None:
+ return bool(value)
+ return False
+
+
+def _is_dm_channel(channel: Any) -> bool:
+ dm_cls = getattr(discord, "DMChannel", None) if discord else None
+ if dm_cls and isinstance(channel, dm_cls):
+ return True
+
+ channel_type = getattr(channel, "type", None)
+ type_value = getattr(channel_type, "value", channel_type)
+ if type_value in {"dm", "private"}:
+ return True
+
+ return getattr(channel, "guild", None) is None and getattr(channel, "recipient", None) is not None
+
+
+def _build_channel_permissions(client: Any, channel: Any) -> ChannelPermissions:
+ if _is_dm_channel(channel):
+ return ChannelPermissions(
+ channel_id=str(getattr(channel, "id", "")),
+ channel_name=_channel_name(channel),
+ can_read=True,
+ can_send=True,
+ can_read_history=True,
+ can_attach_files=True,
+ can_embed_links=True,
+ can_add_reactions=True,
+ can_manage_threads=False,
+ can_create_threads=False,
+ )
+
+ guild = getattr(channel, "guild", None)
+ member = getattr(guild, "me", None) or getattr(client, "user", None)
+ permissions_for = getattr(channel, "permissions_for", None)
+
+ if guild is None or member is None or not callable(permissions_for):
+ return ChannelPermissions(
+ channel_id=str(getattr(channel, "id", "")),
+ channel_name=_channel_name(channel),
+ can_read=False,
+ can_send=False,
+ can_read_history=False,
+ can_attach_files=False,
+ can_embed_links=False,
+ can_add_reactions=False,
+ can_manage_threads=False,
+ can_create_threads=False,
+ )
+
+ perms = permissions_for(member)
+ return ChannelPermissions(
+ channel_id=str(getattr(channel, "id", "")),
+ channel_name=_channel_name(channel),
+ can_read=_permission_flag(perms, "view_channel", "read_messages"),
+ can_send=_permission_flag(perms, "send_messages"),
+ can_read_history=_permission_flag(perms, "read_message_history"),
+ can_attach_files=_permission_flag(perms, "attach_files"),
+ can_embed_links=_permission_flag(perms, "embed_links"),
+ can_add_reactions=_permission_flag(perms, "add_reactions"),
+ can_manage_threads=_permission_flag(perms, "manage_threads"),
+ can_create_threads=_permission_flag(
+ perms,
+ "create_public_threads",
+ "create_private_threads",
+ ),
+ )
+
+
+def _build_accessible_channel(client: Any, channel: Any) -> AccessibleChannel:
+ perms = _build_channel_permissions(client, channel)
+ guild = getattr(channel, "guild", None)
+ guild_id = str(getattr(guild, "id", "")) if getattr(guild, "id", None) is not None else None
+ guild_name = getattr(guild, "name", None)
+ channel_name = _channel_name(channel)
+ qualified_name = f"{guild_name}/{channel_name}" if guild_name else channel_name
+ return AccessibleChannel(
+ channel_id=perms.channel_id,
+ channel_name=channel_name,
+ guild_id=guild_id,
+ guild_name=guild_name,
+ channel_kind="channel",
+ qualified_name=qualified_name,
+ mention=f"<#{perms.channel_id}>",
+ can_read=perms.can_read,
+ can_send=perms.can_send,
+ can_read_history=perms.can_read_history,
+ can_attach_files=perms.can_attach_files,
+ can_embed_links=perms.can_embed_links,
+ can_add_reactions=perms.can_add_reactions,
+ can_manage_threads=perms.can_manage_threads,
+ can_create_threads=perms.can_create_threads,
+ )
+
+
+async def check_channel_permissions(
+ client: Any,
+ channel_id: str,
+) -> Optional[ChannelPermissions]:
+ """Check bot's effective permissions in a channel."""
+ channel = await resolve_channel(client, channel_id)
+ if channel is None:
+ return None
+ return _build_channel_permissions(client, channel)
+
+
+async def list_accessible_channels(
+ client: Any,
+ guild_id: Optional[str] = None,
+) -> list[AccessibleChannel]:
+ """List channels the bot can access, optionally filtered by guild."""
+ if not client:
+ return []
+
+ accessible_channels: list[AccessibleChannel] = []
+ for guild in getattr(client, "guilds", []) or []:
+ if guild_id is not None and str(getattr(guild, "id", "")) != str(guild_id):
+ continue
+
+ for channel in getattr(guild, "text_channels", []) or []:
+ accessible_channel = _build_accessible_channel(client, channel)
+ if accessible_channel.can_read:
+ accessible_channels.append(accessible_channel)
+
+ return accessible_channels
+
+
+async def can_read_channel(client: Any, channel_id: str) -> bool:
+ """Quick check: can the bot read messages in this channel?"""
+ channel_permissions = await check_channel_permissions(client, channel_id)
+ if channel_permissions is None:
+ return False
+ return channel_permissions.can_read and channel_permissions.can_read_history
diff --git a/gateway/platforms/discord_impl/runtime_state.py b/gateway/platforms/discord_impl/runtime_state.py
new file mode 100644
index 000000000000..46804044827f
--- /dev/null
+++ b/gateway/platforms/discord_impl/runtime_state.py
@@ -0,0 +1,154 @@
+"""Persisted Discord runtime state for thread bindings and activation overrides."""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import asdict, dataclass
+from datetime import datetime, timedelta
+from typing import Any, Optional
+
+from hermes_cli.config import get_hermes_home
+
+logger = logging.getLogger(__name__)
+
+RUNTIME_STATE_VERSION = 1
+DEFAULT_THREAD_BINDING_IDLE_MINUTES = 24 * 60
+DEFAULT_THREAD_BINDING_MAX_AGE_MINUTES = 0
+
+
+@dataclass
+class DiscordThreadBinding:
+ """Persisted thread-focus binding for Discord thread UX controls."""
+
+ thread_id: str
+ session_key: str
+ chat_id: str
+ parent_chat_id: Optional[str] = None
+ chat_name: str = ""
+ bound_by: str = ""
+ bound_at: str = ""
+ last_activity_at: str = ""
+ idle_timeout_minutes: int = DEFAULT_THREAD_BINDING_IDLE_MINUTES
+ max_age_minutes: int = DEFAULT_THREAD_BINDING_MAX_AGE_MINUTES
+
+
+def runtime_state_path():
+ """Return the persisted Discord runtime state path."""
+ return get_hermes_home() / "discord_runtime_state.json"
+
+
+def _parse_datetime(raw: str) -> Optional[datetime]:
+ text = str(raw or "").strip()
+ if not text:
+ return None
+ try:
+ return datetime.fromisoformat(text)
+ except Exception:
+ return None
+
+
+def load_runtime_state() -> tuple[dict[str, DiscordThreadBinding], dict[str, str]]:
+ """Load persisted Discord thread bindings and activation overrides."""
+ path = runtime_state_path()
+ if not path.exists():
+ return {}, {}
+
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except Exception as exc:
+ logger.debug("Could not load Discord runtime state: %s", exc)
+ return {}, {}
+
+ bindings_payload = payload.get("thread_bindings") or {}
+ bindings: dict[str, DiscordThreadBinding] = {}
+ for thread_id, raw in bindings_payload.items():
+ if not isinstance(raw, dict):
+ continue
+ try:
+ binding = DiscordThreadBinding(
+ thread_id=str(raw.get("thread_id") or thread_id),
+ session_key=str(raw.get("session_key") or ""),
+ chat_id=str(raw.get("chat_id") or raw.get("thread_id") or thread_id),
+ parent_chat_id=str(raw.get("parent_chat_id") or "").strip() or None,
+ chat_name=str(raw.get("chat_name") or ""),
+ bound_by=str(raw.get("bound_by") or ""),
+ bound_at=str(raw.get("bound_at") or ""),
+ last_activity_at=str(raw.get("last_activity_at") or raw.get("bound_at") or ""),
+ idle_timeout_minutes=int(raw.get("idle_timeout_minutes") or DEFAULT_THREAD_BINDING_IDLE_MINUTES),
+ max_age_minutes=int(raw.get("max_age_minutes") or DEFAULT_THREAD_BINDING_MAX_AGE_MINUTES),
+ )
+ except Exception:
+ continue
+ if binding.thread_id and binding.session_key:
+ bindings[binding.thread_id] = binding
+
+ activation_payload = payload.get("activation_overrides") or {}
+ activation_overrides = {
+ str(chat_id): str(mode).strip().lower()
+ for chat_id, mode in activation_payload.items()
+ if str(chat_id).strip() and str(mode).strip().lower() in {"mention", "always"}
+ }
+ return bindings, activation_overrides
+
+
+def save_runtime_state(
+ bindings: dict[str, DiscordThreadBinding],
+ activation_overrides: dict[str, str],
+) -> None:
+ """Persist Discord thread bindings and activation overrides."""
+ path = runtime_state_path()
+ payload = {
+ "version": RUNTIME_STATE_VERSION,
+ "thread_bindings": {
+ thread_id: asdict(binding)
+ for thread_id, binding in sorted(bindings.items())
+ },
+ "activation_overrides": {
+ str(chat_id): str(mode)
+ for chat_id, mode in sorted(activation_overrides.items())
+ if str(mode).strip().lower() in {"mention", "always"}
+ },
+ }
+ try:
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
+ except Exception as exc:
+ logger.debug("Could not save Discord runtime state: %s", exc)
+
+
+def binding_expiration_reason(
+ binding: DiscordThreadBinding,
+ *,
+ now: Optional[datetime] = None,
+) -> Optional[str]:
+ """Return ``idle`` or ``max-age`` when the binding has expired."""
+ now = now or datetime.now()
+ last_activity_at = _parse_datetime(binding.last_activity_at) or _parse_datetime(binding.bound_at) or now
+ bound_at = _parse_datetime(binding.bound_at) or last_activity_at
+
+ if binding.max_age_minutes and now >= bound_at + timedelta(minutes=binding.max_age_minutes):
+ return "max-age"
+ if binding.idle_timeout_minutes and now >= last_activity_at + timedelta(minutes=binding.idle_timeout_minutes):
+ return "idle"
+ return None
+
+
+def touch_binding(
+ binding: DiscordThreadBinding,
+ *,
+ now: Optional[datetime] = None,
+) -> DiscordThreadBinding:
+ """Return a copy of ``binding`` with refreshed activity time."""
+ now = now or datetime.now()
+ return DiscordThreadBinding(
+ thread_id=binding.thread_id,
+ session_key=binding.session_key,
+ chat_id=binding.chat_id,
+ parent_chat_id=binding.parent_chat_id,
+ chat_name=binding.chat_name,
+ bound_by=binding.bound_by,
+ bound_at=binding.bound_at,
+ last_activity_at=now.isoformat(),
+ idle_timeout_minutes=binding.idle_timeout_minutes,
+ max_age_minutes=binding.max_age_minutes,
+ )
diff --git a/gateway/platforms/discord_impl/runtime_views.py b/gateway/platforms/discord_impl/runtime_views.py
new file mode 100644
index 000000000000..856a7e8c4f83
--- /dev/null
+++ b/gateway/platforms/discord_impl/runtime_views.py
@@ -0,0 +1,630 @@
+"""Discord-native runtime status, help, command, and identity renderers."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime
+import json
+import os
+from typing import Any, Optional
+
+from gateway.platforms.base import MessageEvent
+from gateway.session import SessionSource, build_session_context, build_session_context_prompt
+from hermes_cli.commands import (
+ CommandDef,
+ format_gateway_command_signature,
+ gateway_command_defs,
+ gateway_commands_by_category,
+)
+from hermes_cli.models import provider_label
+
+
+@dataclass(frozen=True)
+class DiscordStatusSnapshot:
+ session_id: str
+ session_key: str
+ created_at: datetime
+ updated_at: datetime
+ source: SessionSource
+ target_source: SessionSource
+ configured_model: str
+ configured_provider: str
+ active_model: str
+ active_provider: str
+ is_fallback: bool
+ runtime_provider: str
+ api_mode: str
+ base_url: str
+ credentials_configured: bool
+ credential_source: Optional[str]
+ transport_command: Optional[str]
+ runtime_error: Optional[str]
+ context_length: Optional[int]
+ input_tokens: int
+ output_tokens: int
+ cache_read_tokens: int
+ cache_write_tokens: int
+ total_tokens: int
+ last_prompt_tokens: int
+ estimated_cost_usd: float
+ cost_status: str
+ is_running: bool
+ has_pending_message: bool
+ has_pending_approval: bool
+ approval_command_preview: Optional[str]
+ has_background_process: bool
+ voice_mode: str
+ send_policy: str
+ dock_target: Optional[str]
+ activation_mode: Optional[str]
+ focused_thread_summary: Optional[str]
+ connected_platforms: tuple[str, ...]
+
+
+@dataclass(frozen=True)
+class DiscordWhoamiSnapshot:
+ source: SessionSource
+ target_source: SessionSource
+ session_key: str
+
+
+@dataclass(frozen=True)
+class DiscordContextSnapshot:
+ session_id: str
+ session_key: str
+ source: SessionSource
+ target_source: SessionSource
+ message_count: int
+ role_counts: dict[str, int]
+ transcript_tokens: int
+ context_prompt_tokens: int
+ context_prompt_chars: int
+ current_model: str
+ current_provider: str
+ connected_platforms: tuple[str, ...]
+ context_files: tuple[str, ...]
+ skill_commands: tuple[str, ...]
+ has_soul: bool
+ context_prompt: str
+
+
+def _format_timestamp(value: datetime) -> str:
+ return value.strftime("%Y-%m-%d %H:%M")
+
+
+def _short_id(value: str, limit: int = 12) -> str:
+ if len(value) <= limit:
+ return value
+ return value[:limit] + "..."
+
+
+def _yes_no(value: bool, yes: str = "Yes", no: str = "No") -> str:
+ return yes if value else no
+
+
+def _alias_note(cmd: CommandDef) -> str:
+ visible_aliases = [
+ f"`/{alias}`"
+ for alias in cmd.aliases
+ if alias.replace("-", "_") != cmd.name.replace("-", "_")
+ ]
+ if not visible_aliases:
+ return ""
+ return f" (alias: {', '.join(visible_aliases)})"
+
+
+def _command_lookup() -> dict[str, CommandDef]:
+ return {cmd.name: cmd for cmd in gateway_command_defs()}
+
+
+def _load_skill_commands() -> dict[str, str]:
+ try:
+ from agent.skill_commands import get_skill_commands
+
+ skill_cmds = get_skill_commands()
+ except Exception:
+ return {}
+
+ result: dict[str, str] = {}
+ for name, payload in sorted(skill_cmds.items()):
+ result[name] = str(payload.get("description") or "Skill command")
+ return result
+
+
+def _resolve_target_source(event: MessageEvent, session_source: SessionSource) -> SessionSource:
+ metadata = getattr(event, "metadata", None)
+ if isinstance(metadata, dict):
+ target = metadata.get("command_target_source")
+ if isinstance(target, SessionSource):
+ return target
+ return event.source if isinstance(event.source, SessionSource) else session_source
+
+
+def _resolve_context_length(active_model: str, runtime: dict[str, Any]) -> Optional[int]:
+ if not active_model:
+ return None
+ try:
+ from agent.model_metadata import get_model_context_length
+
+ return get_model_context_length(
+ active_model,
+ base_url=str(runtime.get("base_url") or ""),
+ api_key=str(runtime.get("api_key") or ""),
+ )
+ except Exception:
+ return None
+
+
+def collect_discord_status_snapshot(runner: Any, event: MessageEvent) -> DiscordStatusSnapshot:
+ """Collect Discord-native status data without rendering concerns."""
+ session_source = runner._session_source_for_event(event)
+ target_source = _resolve_target_source(event, session_source)
+ session_entry = runner.session_store.get_or_create_session(session_source)
+ session_key = runner._session_key_for_source(session_source)
+ state = runner._load_current_model_selection()
+ configured_model = str(state.get("current_model") or "")
+ configured_provider = str(state.get("current_provider") or "openrouter")
+ active_model = str(getattr(runner, "_effective_model", None) or configured_model)
+ active_provider = str(getattr(runner, "_effective_provider", None) or configured_provider)
+ is_fallback = bool(getattr(runner, "_effective_model", None))
+
+ runtime_requested = active_provider or configured_provider
+ runtime, runtime_error = runner._resolve_model_runtime_details(runtime_requested)
+ runtime_provider = str(runtime.get("provider") or runtime_requested)
+ api_mode = str(runtime.get("api_mode") or "unknown")
+ base_url = str(runtime.get("base_url") or "unknown")
+ transport_command = str(runtime.get("command") or "").strip() or None
+ credential_source = str(runtime.get("source") or "").strip() or None
+ credentials_configured = bool(str(runtime.get("api_key") or "").strip() or transport_command)
+ context_length = _resolve_context_length(active_model, runtime)
+
+ try:
+ from tools.process_registry import process_registry
+
+ has_background_process = process_registry.has_active_for_session(session_key)
+ except Exception:
+ has_background_process = False
+
+ pending_approval = getattr(runner, "_pending_approvals", {}).get(session_key)
+ approval_command_preview = None
+ if isinstance(pending_approval, dict):
+ approval_command = str(pending_approval.get("command") or "").strip()
+ if approval_command:
+ approval_command_preview = approval_command[:80]
+ if len(approval_command) > 80:
+ approval_command_preview += "..."
+
+ connected_platforms = tuple(sorted(platform.value for platform in getattr(runner, "adapters", {}).keys()))
+ send_policy = "inherit"
+ if hasattr(runner, "_session_send_policy"):
+ try:
+ send_policy = str(runner._session_send_policy(session_key) or "inherit")
+ except Exception:
+ send_policy = "inherit"
+
+ dock_target = None
+ if hasattr(runner, "_dock_target_for_session"):
+ try:
+ dock_target_state = runner._dock_target_for_session(session_key)
+ except Exception:
+ dock_target_state = None
+ if dock_target_state and hasattr(runner, "_format_dock_target"):
+ try:
+ dock_target = runner._format_dock_target(dock_target_state)
+ except Exception:
+ dock_target = None
+
+ activation_mode = None
+ focused_thread_summary = None
+ if target_source.platform.value == "discord":
+ adapter = getattr(runner, "adapters", {}).get(target_source.platform)
+ if adapter is not None and target_source.chat_type != "dm":
+ if hasattr(adapter, "get_activation_mode"):
+ try:
+ activation_mode = adapter.get_activation_mode(target_source.chat_id)
+ except Exception:
+ activation_mode = None
+ if activation_mode is None and hasattr(adapter, "_get_discord_policy"):
+ try:
+ policy = adapter._get_discord_policy()
+ activation_mode = "mention" if getattr(policy, "require_mention", True) else "always"
+ except Exception:
+ activation_mode = None
+ if adapter is not None and target_source.thread_id and hasattr(adapter, "get_thread_binding"):
+ try:
+ binding = adapter.get_thread_binding(target_source.thread_id)
+ except Exception:
+ binding = None
+ if binding is not None:
+ idle = getattr(binding, "idle_timeout_minutes", 0) or 0
+ max_age = getattr(binding, "max_age_minutes", 0) or 0
+ focused_thread_summary = (
+ f"yes ({getattr(binding, 'chat_name', '') or target_source.thread_id}; "
+ f"idle {idle}m; max-age {max_age}m)"
+ )
+
+ return DiscordStatusSnapshot(
+ session_id=session_entry.session_id,
+ session_key=session_key,
+ created_at=session_entry.created_at,
+ updated_at=session_entry.updated_at,
+ source=session_source,
+ target_source=target_source,
+ configured_model=configured_model,
+ configured_provider=configured_provider,
+ active_model=active_model,
+ active_provider=active_provider,
+ is_fallback=is_fallback,
+ runtime_provider=runtime_provider,
+ api_mode=api_mode,
+ base_url=base_url,
+ credentials_configured=credentials_configured,
+ credential_source=credential_source,
+ transport_command=transport_command,
+ runtime_error=runtime_error,
+ context_length=context_length,
+ input_tokens=session_entry.input_tokens,
+ output_tokens=session_entry.output_tokens,
+ cache_read_tokens=session_entry.cache_read_tokens,
+ cache_write_tokens=session_entry.cache_write_tokens,
+ total_tokens=session_entry.total_tokens,
+ last_prompt_tokens=session_entry.last_prompt_tokens,
+ estimated_cost_usd=session_entry.estimated_cost_usd,
+ cost_status=session_entry.cost_status,
+ is_running=session_key in getattr(runner, "_running_agents", {}),
+ has_pending_message=bool(getattr(runner, "_pending_messages", {}).get(session_key)),
+ has_pending_approval=bool(pending_approval),
+ approval_command_preview=approval_command_preview,
+ has_background_process=has_background_process,
+ voice_mode=getattr(runner, "_voice_mode", {}).get(target_source.chat_id, "off"),
+ send_policy=send_policy,
+ dock_target=dock_target,
+ activation_mode=activation_mode,
+ focused_thread_summary=focused_thread_summary,
+ connected_platforms=connected_platforms,
+ )
+
+
+def _estimate_tokens(messages: list[dict[str, str]]) -> int:
+ try:
+ from agent.model_metadata import estimate_messages_tokens_rough
+
+ return int(estimate_messages_tokens_rough(messages) or 0)
+ except Exception:
+ return 0
+
+
+def _extract_context_file_names(context_prompt: str) -> tuple[str, ...]:
+ names: list[str] = []
+ for line in context_prompt.splitlines():
+ if line.startswith("## "):
+ names.append(line[3:].strip())
+ return tuple(names)
+
+
+def collect_discord_context_snapshot(runner: Any, event: MessageEvent) -> DiscordContextSnapshot:
+ """Collect a Discord-native snapshot of the current transcript and prompt context."""
+ target_source = runner._command_target_source_for_event(event)
+ session_entry = runner.session_store.get_or_create_session(target_source)
+ history = runner.session_store.load_transcript(session_entry.session_id)
+ role_counts: dict[str, int] = {}
+ transcript_messages: list[dict[str, str]] = []
+ for item in history:
+ role = str(item.get("role") or "unknown")
+ role_counts[role] = role_counts.get(role, 0) + 1
+ content = str(item.get("content") or "")
+ if content:
+ transcript_messages.append({"role": role, "content": content})
+
+ current_state = runner._load_current_model_selection()
+ current_model = str(current_state.get("current_model") or "")
+ current_provider = str(current_state.get("current_provider") or "")
+ connected_platforms = tuple(sorted(platform.value for platform in getattr(runner, "adapters", {}).keys()))
+
+ cwd = os.environ.get("TERMINAL_CWD") or os.environ.get("MESSAGING_CWD") or os.getcwd()
+ try:
+ from agent.prompt_builder import (
+ build_context_files_prompt,
+ build_skills_system_prompt,
+ load_soul_md,
+ )
+
+ soul = load_soul_md() or ""
+ context_files_prompt = build_context_files_prompt(cwd, skip_soul=bool(soul))
+ skills_prompt = build_skills_system_prompt()
+ except Exception:
+ soul = ""
+ context_files_prompt = ""
+ skills_prompt = ""
+
+ session_context = build_session_context(target_source, runner.config, session_entry)
+ session_context_prompt = build_session_context_prompt(session_context)
+ prompt_parts = [part for part in (soul, skills_prompt, context_files_prompt, session_context_prompt) if part]
+ context_prompt = "\n\n".join(prompt_parts)
+ skill_commands = tuple(sorted(_load_skill_commands().keys()))
+
+ return DiscordContextSnapshot(
+ session_id=session_entry.session_id,
+ session_key=session_entry.session_key,
+ source=runner._session_source_for_event(event),
+ target_source=target_source,
+ message_count=len(history),
+ role_counts=role_counts,
+ transcript_tokens=_estimate_tokens(transcript_messages),
+ context_prompt_tokens=_estimate_tokens([{"role": "system", "content": context_prompt}]) if context_prompt else 0,
+ context_prompt_chars=len(context_prompt),
+ current_model=current_model,
+ current_provider=current_provider,
+ connected_platforms=connected_platforms,
+ context_files=_extract_context_file_names(context_files_prompt),
+ skill_commands=skill_commands,
+ has_soul=bool(soul),
+ context_prompt=context_prompt,
+ )
+
+
+def render_discord_status(snapshot: DiscordStatusSnapshot) -> str:
+ """Render Discord status output in a multi-section, chat-readable format."""
+ lines = [
+ "📊 **Hermes Status**",
+ "",
+ "**Session**",
+ f"• Session ID: `{_short_id(snapshot.session_id)}`",
+ f"• Session Key: `{_short_id(snapshot.session_key, limit=18)}`",
+ f"• Created: {_format_timestamp(snapshot.created_at)}",
+ f"• Last Activity: {_format_timestamp(snapshot.updated_at)}",
+ f"• Chat: {snapshot.target_source.chat_name or snapshot.target_source.chat_id}",
+ f"• Chat Type: `{snapshot.target_source.chat_type}`",
+ ]
+ if snapshot.target_source.thread_id:
+ lines.append(f"• Thread ID: `{snapshot.target_source.thread_id}`")
+ if snapshot.target_source.chat_topic:
+ lines.append(f"• Chat Topic: {snapshot.target_source.chat_topic}")
+ if snapshot.source.session_namespace:
+ lines.append(f"• Session Namespace: `{snapshot.source.session_namespace}`")
+
+ lines.extend(
+ [
+ "",
+ "**Model**",
+ f"• Configured Model: `{snapshot.configured_model}`",
+ f"• Configured Provider: {provider_label(snapshot.configured_provider)} (`{snapshot.configured_provider}`)",
+ f"• Active Model: `{snapshot.active_model}`{' (fallback)' if snapshot.is_fallback else ''}",
+ f"• Active Provider: {provider_label(snapshot.active_provider)} (`{snapshot.active_provider}`)",
+ ]
+ )
+ if snapshot.runtime_error:
+ lines.append(f"• Runtime Resolution: failed ({snapshot.runtime_error})")
+ else:
+ lines.extend(
+ [
+ f"• Runtime Provider: {provider_label(snapshot.runtime_provider)} (`{snapshot.runtime_provider}`)",
+ f"• API Mode: `{snapshot.api_mode}`",
+ f"• Base URL: `{snapshot.base_url}`",
+ f"• Credentials: {'configured ✓' if snapshot.credentials_configured else 'missing ⚠️'}",
+ ]
+ )
+ if snapshot.credential_source:
+ lines.append(f"• Credential Source: `{snapshot.credential_source}`")
+ if snapshot.transport_command:
+ lines.append(f"• Transport Command: `{snapshot.transport_command}`")
+
+ lines.extend(
+ [
+ "",
+ "**Usage & Context**",
+ f"• Total Tokens: {snapshot.total_tokens:,}",
+ f"• Last Prompt Tokens: {snapshot.last_prompt_tokens:,}",
+ f"• Input / Output: {snapshot.input_tokens:,} / {snapshot.output_tokens:,}",
+ f"• Cache Read / Write: {snapshot.cache_read_tokens:,} / {snapshot.cache_write_tokens:,}",
+ ]
+ )
+ if snapshot.context_length is not None:
+ lines.append(f"• Context Window: {snapshot.context_length:,} tokens")
+ if snapshot.estimated_cost_usd:
+ lines.append(
+ f"• Estimated Cost: ${snapshot.estimated_cost_usd:.4f} ({snapshot.cost_status or 'unknown'})"
+ )
+ elif snapshot.cost_status and snapshot.cost_status != "unknown":
+ lines.append(f"• Cost Status: `{snapshot.cost_status}`")
+
+ lines.extend(
+ [
+ "",
+ "**Runtime**",
+ f"• Agent Running: {_yes_no(snapshot.is_running, 'Yes ⚡', 'No')}",
+ f"• Pending Interrupt: {_yes_no(snapshot.has_pending_message)}",
+ f"• Pending Approval: {_yes_no(snapshot.has_pending_approval)}",
+ f"• Background Processes: {_yes_no(snapshot.has_background_process)}",
+ f"• Voice Mode: `{snapshot.voice_mode}`",
+ f"• Send Policy: `{snapshot.send_policy}`",
+ ]
+ )
+ if snapshot.approval_command_preview:
+ lines.append(f"• Approval Command: `{snapshot.approval_command_preview}`")
+ if snapshot.dock_target:
+ lines.append(f"• Dock Target: {snapshot.dock_target}")
+ if snapshot.activation_mode:
+ lines.append(f"• Activation: `{snapshot.activation_mode}`")
+ if snapshot.focused_thread_summary:
+ lines.append(f"• Focused Thread: {snapshot.focused_thread_summary}")
+
+ lines.extend(
+ [
+ "",
+ "**Platforms**",
+ f"• Connected: {', '.join(snapshot.connected_platforms) if snapshot.connected_platforms else 'none'}",
+ ]
+ )
+ return "\n".join(lines)
+
+
+def render_discord_context(snapshot: DiscordContextSnapshot, mode: str = "list") -> str:
+ """Render Discord context output in list, detail, or JSON form."""
+ mode = str(mode or "list").strip().lower()
+ if mode == "json":
+ payload = {
+ "session_id": snapshot.session_id,
+ "session_key": snapshot.session_key,
+ "source": snapshot.source.to_dict(),
+ "target_source": snapshot.target_source.to_dict(),
+ "message_count": snapshot.message_count,
+ "role_counts": snapshot.role_counts,
+ "transcript_tokens": snapshot.transcript_tokens,
+ "context_prompt_tokens": snapshot.context_prompt_tokens,
+ "context_prompt_chars": snapshot.context_prompt_chars,
+ "current_model": snapshot.current_model,
+ "current_provider": snapshot.current_provider,
+ "connected_platforms": list(snapshot.connected_platforms),
+ "context_files": list(snapshot.context_files),
+ "skill_commands": list(snapshot.skill_commands),
+ "has_soul": snapshot.has_soul,
+ }
+ return f"```json\n{json.dumps(payload, indent=2, ensure_ascii=False)}\n```"
+
+ lines = [
+ "🧠 **Hermes Context**",
+ "",
+ f"• Session ID: `{_short_id(snapshot.session_id)}`",
+ f"• Session Key: `{_short_id(snapshot.session_key, limit=18)}`",
+ f"• Chat: {snapshot.target_source.chat_name or snapshot.target_source.chat_id}",
+ f"• Messages: {snapshot.message_count}",
+ f"• Transcript Tokens: ~{snapshot.transcript_tokens:,}",
+ f"• Context Prompt: ~{snapshot.context_prompt_tokens:,} tokens / {snapshot.context_prompt_chars:,} chars",
+ f"• Model: `{snapshot.current_model or 'unknown'}`",
+ ]
+ if snapshot.current_provider:
+ lines.append(f"• Provider: `{snapshot.current_provider}`")
+ if snapshot.connected_platforms:
+ lines.append(f"• Connected Platforms: {', '.join(snapshot.connected_platforms)}")
+ if snapshot.role_counts:
+ role_summary = ", ".join(f"{role}={count}" for role, count in sorted(snapshot.role_counts.items()))
+ lines.append(f"• Roles: {role_summary}")
+
+ if mode != "detail":
+ return "\n".join(lines)
+
+ lines.extend(
+ [
+ "",
+ "**Prompt Sources**",
+ f"• SOUL.md Loaded: {_yes_no(snapshot.has_soul)}",
+ f"• Context Files: {', '.join(snapshot.context_files) if snapshot.context_files else 'none'}",
+ f"• Skill Commands Indexed: {', '.join(snapshot.skill_commands) if snapshot.skill_commands else 'none'}",
+ "",
+ "**Prompt Preview**",
+ "```text",
+ snapshot.context_prompt[:3500] + ("..." if len(snapshot.context_prompt) > 3500 else ""),
+ "```",
+ ]
+ )
+ return "\n".join(lines)
+
+
+def _render_command_lines(command_names: tuple[str, ...]) -> list[str]:
+ lookup = _command_lookup()
+ lines: list[str] = []
+ for name in command_names:
+ cmd = lookup.get(name)
+ if cmd is None:
+ continue
+ lines.append(
+ f"`{format_gateway_command_signature(cmd)}` — {cmd.description}{_alias_note(cmd)}"
+ )
+ return lines
+
+
+def render_discord_help() -> str:
+ """Render concise Discord help with clear separation from /commands."""
+ skill_cmds = _load_skill_commands()
+ lines = [
+ "📖 **Hermes Help**",
+ "",
+ "Use `/commands` for the full command catalog.",
+ "",
+ "**Quick Start**",
+ *(
+ _render_command_lines(
+ ("help", "commands", "status", "whoami", "model", "models")
+ )
+ ),
+ "",
+ "**Session**",
+ *(_render_command_lines(("new", "retry", "undo", "thread", "resume", "title", "compact", "context", "stop"))),
+ "",
+ "**Configuration**",
+ *(_render_command_lines(("model", "models", "provider", "reasoning", "personality", "voice", "allowlist", "config"))),
+ "",
+ "**Info & Maintenance**",
+ *(_render_command_lines(("usage", "insights", "export-session", "reload-mcp", "update"))),
+ ]
+ if skill_cmds:
+ lines.extend(
+ [
+ "",
+ f"**Skill Commands** ({len(skill_cmds)} installed)",
+ ]
+ )
+ for name, description in skill_cmds.items():
+ lines.append(f"`{name}` — {description}")
+ return "\n".join(lines)
+
+
+def render_discord_commands() -> str:
+ """Render the full grouped Discord command catalog."""
+ skill_cmds = _load_skill_commands()
+ lines = [
+ "🧭 **Hermes Command Catalog**",
+ "",
+ "Use `/help` for the guided overview.",
+ ]
+ for category, commands in gateway_commands_by_category():
+ lines.extend(["", f"**{category}**"])
+ for cmd in commands:
+ lines.append(
+ f"`{format_gateway_command_signature(cmd)}` — {cmd.description}{_alias_note(cmd)}"
+ )
+ if skill_cmds:
+ lines.extend(["", "**Skill Commands**"])
+ for name, description in skill_cmds.items():
+ lines.append(f"`{name}` — {description}")
+ return "\n".join(lines)
+
+
+def collect_discord_whoami_snapshot(runner: Any, event: MessageEvent) -> DiscordWhoamiSnapshot:
+ """Collect Discord identity and routing details."""
+ session_source = runner._session_source_for_event(event)
+ target_source = _resolve_target_source(event, session_source)
+ session_key = runner._session_key_for_source(session_source)
+ return DiscordWhoamiSnapshot(
+ source=session_source,
+ target_source=target_source,
+ session_key=session_key,
+ )
+
+
+def render_discord_whoami(snapshot: DiscordWhoamiSnapshot) -> str:
+ """Render Discord sender and routing identity."""
+ source = snapshot.source
+ target = snapshot.target_source
+ lines = [
+ "👤 **Hermes Sees You As**",
+ "",
+ "**Identity**",
+ f"• Platform: {source.platform.value if source.platform else 'unknown'}",
+ f"• User ID: `{source.user_id or 'unknown'}`",
+ f"• User Name: {source.user_name or 'unknown'}",
+ "",
+ "**Routing**",
+ f"• Chat ID: `{target.chat_id}`",
+ f"• Chat Type: `{target.chat_type}`",
+ f"• Chat Name: {target.chat_name or 'unknown'}",
+ ]
+ if target.thread_id:
+ lines.append(f"• Thread ID: `{target.thread_id}`")
+ if target.chat_topic:
+ lines.append(f"• Chat Topic: {target.chat_topic}")
+ if source.session_namespace:
+ lines.append(f"• Session Namespace: `{source.session_namespace}`")
+ lines.append(f"• Session Key: `{_short_id(snapshot.session_key, limit=18)}`")
+ return "\n".join(lines)
diff --git a/gateway/platforms/discord_impl/state.py b/gateway/platforms/discord_impl/state.py
new file mode 100644
index 000000000000..f9d67298f40a
--- /dev/null
+++ b/gateway/platforms/discord_impl/state.py
@@ -0,0 +1,54 @@
+"""Discord thread participation persistence helpers."""
+
+from __future__ import annotations
+
+import json
+import logging
+
+from hermes_cli.config import get_hermes_home
+
+
+logger = logging.getLogger(__name__)
+
+
+def thread_state_path():
+ """Return the persisted thread participation state path."""
+ return get_hermes_home() / "discord_threads.json"
+
+
+def load_participated_threads() -> set[str]:
+ """Load persisted thread IDs from disk."""
+ path = thread_state_path()
+ try:
+ if path.exists():
+ data = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(data, list):
+ return set(data)
+ except Exception as exc:
+ logger.debug("Could not load discord thread state: %s", exc)
+ return set()
+
+
+def save_participated_threads(threads: set[str], max_threads: int = 500) -> set[str]:
+ """Persist the current thread set to disk and return the trimmed set."""
+ path = thread_state_path()
+ try:
+ thread_list = list(threads)
+ if len(thread_list) > max_threads:
+ thread_list = thread_list[-max_threads:]
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(thread_list), encoding="utf-8")
+ return set(thread_list)
+ except Exception as exc:
+ logger.debug("Could not save discord thread state: %s", exc)
+ return set(threads)
+
+
+def track_thread(threads: set[str], thread_id: str, max_threads: int = 500) -> set[str]:
+ """Add a thread to the participation set, persist it, and return the updated set."""
+ if thread_id in threads:
+ return set(threads)
+
+ updated_threads = set(threads)
+ updated_threads.add(thread_id)
+ return save_participated_threads(updated_threads, max_threads=max_threads)
diff --git a/gateway/platforms/discord_impl/threads.py b/gateway/platforms/discord_impl/threads.py
new file mode 100644
index 000000000000..3685a8b3add6
--- /dev/null
+++ b/gateway/platforms/discord_impl/threads.py
@@ -0,0 +1,193 @@
+"""Discord thread creation and routing helpers."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Optional
+
+from gateway.platforms.base import MessageEvent, MessageType
+
+
+logger = logging.getLogger(__name__)
+
+VALID_THREAD_AUTO_ARCHIVE_MINUTES = {60, 1440, 4320, 10080}
+
+try:
+ import discord
+except ImportError: # pragma: no cover - import guard
+ discord = None
+
+
+async def auto_create_thread(message: Any, max_name_len: int = 80) -> Optional[Any]:
+ """Create a thread from a Discord message for auto-threading."""
+ content = (message.content or "").strip()
+ thread_name = content[:max_name_len] if content else "Hermes"
+ if len(content) > max_name_len:
+ thread_name = thread_name[: max_name_len - 3] + "..."
+
+ try:
+ return await message.create_thread(name=thread_name, auto_archive_duration=1440)
+ except Exception as exc:
+ logger.warning("Auto-thread creation failed: %s", exc)
+ return None
+
+
+async def create_thread(
+ client: Any,
+ interaction: Any,
+ name: str,
+ message: str,
+ auto_archive_duration: int,
+ resolve_channel_fn,
+) -> dict[str, Any]:
+ """Create a Discord thread in the interaction's current channel."""
+ name = (name or "").strip()
+ if not name:
+ return {"error": "Thread name is required."}
+
+ if auto_archive_duration not in VALID_THREAD_AUTO_ARCHIVE_MINUTES:
+ allowed = ", ".join(str(v) for v in sorted(VALID_THREAD_AUTO_ARCHIVE_MINUTES))
+ return {"error": f"auto_archive_duration must be one of: {allowed}."}
+
+ channel = await resolve_channel_fn(client, interaction)
+ if channel is None:
+ return {"error": "Could not resolve the current Discord channel."}
+
+ dm_channel_cls = getattr(discord, "DMChannel", None) if discord else None
+ if dm_channel_cls and isinstance(channel, dm_channel_cls):
+ return {"error": "Discord threads can only be created inside server text channels, not DMs."}
+
+ parent_channel = thread_parent_channel(channel)
+ if parent_channel is None:
+ return {"error": "Could not determine a parent text channel for the new thread."}
+
+ display_name = getattr(getattr(interaction, "user", None), "display_name", None) or "unknown user"
+ reason = f"Requested by {display_name} via /thread"
+ starter_message = (message or "").strip()
+
+ try:
+ thread = await parent_channel.create_thread(
+ name=name,
+ auto_archive_duration=auto_archive_duration,
+ reason=reason,
+ )
+ if starter_message:
+ await thread.send(starter_message)
+ return {
+ "success": True,
+ "thread_id": str(thread.id),
+ "thread_name": getattr(thread, "name", None) or name,
+ }
+ except Exception as direct_error:
+ try:
+ seed_content = starter_message or f"\U0001f9f5 Thread created by Hermes: **{name}**"
+ seed_msg = await parent_channel.send(seed_content)
+ thread = await seed_msg.create_thread(
+ name=name,
+ auto_archive_duration=auto_archive_duration,
+ reason=reason,
+ )
+ return {
+ "success": True,
+ "thread_id": str(thread.id),
+ "thread_name": getattr(thread, "name", None) or name,
+ }
+ except Exception as fallback_error:
+ return {
+ "error": (
+ "Discord rejected direct thread creation and the fallback also failed. "
+ f"Direct error: {direct_error}. Fallback error: {fallback_error}"
+ )
+ }
+
+
+async def handle_thread_create_slash(
+ adapter: Any,
+ interaction: Any,
+ name: str,
+ message: str = "",
+ auto_archive_duration: int = 1440,
+) -> None:
+ """Create a Discord thread from a slash command and start a session in it."""
+ result = await adapter._create_thread(
+ interaction,
+ name=name,
+ message=message,
+ auto_archive_duration=auto_archive_duration,
+ )
+
+ if not result.get("success"):
+ error = result.get("error", "unknown error")
+ await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True)
+ return
+
+ thread_id = result.get("thread_id")
+ thread_name = result.get("thread_name") or name
+
+ link = f"<#{thread_id}>" if thread_id else f"**{thread_name}**"
+ await interaction.followup.send(f"Created thread {link}", ephemeral=True)
+
+ if thread_id:
+ adapter._track_thread(thread_id)
+
+ starter = (message or "").strip()
+ if starter and thread_id:
+ await adapter._dispatch_thread_session(interaction, thread_id, thread_name, starter)
+
+
+async def dispatch_thread_session(
+ adapter: Any,
+ interaction: Any,
+ thread_id: str,
+ thread_name: str,
+ text: str,
+) -> None:
+ """Build a thread MessageEvent and send it through the adapter handler."""
+ guild_name = ""
+ if hasattr(interaction, "guild") and interaction.guild:
+ guild_name = interaction.guild.name
+
+ chat_name = f"{guild_name} / {thread_name}" if guild_name else thread_name
+ source = adapter.build_source(
+ chat_id=thread_id,
+ chat_name=chat_name,
+ chat_type="thread",
+ user_id=str(interaction.user.id),
+ user_name=interaction.user.display_name,
+ thread_id=thread_id,
+ )
+
+ event = MessageEvent(
+ text=text,
+ message_type=MessageType.TEXT,
+ source=source,
+ raw_message=interaction,
+ )
+ await adapter.handle_message(event)
+
+
+def thread_parent_channel(channel: Any) -> Any:
+ """Return the parent text channel when invoked from a thread."""
+ return getattr(channel, "parent", None) or channel
+
+
+async def resolve_interaction_channel(client: Any, interaction: Any) -> Optional[Any]:
+ """Return the interaction channel, fetching it if the payload is partial."""
+ channel = getattr(interaction, "channel", None)
+ if channel is not None:
+ return channel
+ if not client:
+ return None
+
+ channel_id = getattr(interaction, "channel_id", None)
+ if channel_id is None:
+ return None
+
+ channel = client.get_channel(int(channel_id))
+ if channel is not None:
+ return channel
+
+ try:
+ return await client.fetch_channel(int(channel_id))
+ except Exception:
+ return None
diff --git a/gateway/run.py b/gateway/run.py
index 9547387480e9..2247d673bf6e 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -24,6 +24,7 @@
import tempfile
import threading
import time
+import uuid
from logging.handlers import RotatingFileHandler
from pathlib import Path
from datetime import datetime
@@ -219,6 +220,15 @@ def _ensure_ssl_certs() -> None:
)
from gateway.delivery import DeliveryRouter, DeliveryTarget
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType
+from gateway.command_catalog import (
+ build_session_export_html,
+ format_yaml_block,
+ load_raw_user_config,
+ read_config_or_env_value,
+ resolve_export_path,
+ unset_config_or_env_value,
+ write_config_or_env_value,
+)
logger = logging.getLogger(__name__)
@@ -315,6 +325,7 @@ class GatewayRunner:
def __init__(self, config: Optional[GatewayConfig] = None):
self.config = config or load_gateway_config()
self.adapters: Dict[Platform, BasePlatformAdapter] = {}
+ self._runtime_debug_overrides: Dict[str, Any] = {}
# Load ephemeral config from config.yaml / env vars.
# Both are injected at API-call time only and never persisted.
@@ -342,6 +353,12 @@ def __init__(self, config: Optional[GatewayConfig] = None):
# Key: session_key, Value: AIAgent instance
self._running_agents: Dict[str, Any] = {}
self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt
+ self._bash_jobs: Dict[str, str] = {}
+ self._subagent_runtime: Dict[str, Dict[str, Any]] = {}
+ self._acp_session_bindings: Dict[str, str] = {}
+ self._acp_session_meta: Dict[str, Dict[str, Any]] = {}
+ self._acp_running_tasks: Dict[str, asyncio.Task] = {}
+ self._acp_session_manager = None
# Track active fallback model/provider when primary is rate-limited.
# Set after an agent run where fallback was activated; cleared when
@@ -384,6 +401,322 @@ def __init__(self, config: Optional[GatewayConfig] = None):
# Per-chat voice reply mode: "off" | "voice_only" | "all"
self._voice_mode: Dict[str, str] = self._load_voice_modes()
+ runtime_controls = self._load_runtime_controls()
+ self._session_send_policies: Dict[str, str] = runtime_controls["send_policies"]
+ self._session_docks: Dict[str, Dict[str, Any]] = runtime_controls["dock_targets"]
+
+ _DEBUG_REASONING_LEVELS = {"none", "minimal", "low", "medium", "high", "xhigh"}
+ _DEBUG_BACKGROUND_NOTIFICATION_MODES = {"all", "result", "error", "off"}
+ _DEBUG_PROVIDER_DATA_COLLECTION_MODES = {"allow", "deny"}
+ _DEBUG_DISCORD_BOT_FILTER_POLICIES = {"none", "mentions", "all"}
+ _DEBUG_SUPPORTED_KEYS: Dict[str, str] = {
+ "agent.system_prompt": "Ephemeral system prompt for new turns in the running gateway process.",
+ "agent.reasoning_effort": "Runtime reasoning effort (none|minimal|low|medium|high|xhigh).",
+ "display.show_reasoning": "Show or hide model reasoning in responses.",
+ "display.background_process_notifications": "Background process watcher notifications (all|result|error|off).",
+ "provider_routing.only": "Preferred provider allowlist for OpenRouter routing.",
+ "provider_routing.ignore": "Providers to skip for OpenRouter routing.",
+ "provider_routing.order": "Explicit provider preference order for OpenRouter routing.",
+ "provider_routing.sort": "Provider routing sort strategy.",
+ "provider_routing.require_parameters": "Require providers that support all request parameters.",
+ "provider_routing.data_collection": "Provider data collection policy (allow|deny).",
+ "fallback_model.provider": "Fallback provider used when the primary route fails.",
+ "fallback_model.model": "Fallback model used when the primary route fails.",
+ "smart_model_routing.enabled": "Enable or disable cheap-vs-strong smart routing.",
+ "smart_model_routing.max_simple_chars": "Maximum message characters for cheap-route eligibility.",
+ "smart_model_routing.max_simple_words": "Maximum word count for cheap-route eligibility.",
+ "smart_model_routing.cheap_model.provider": "Cheap-route provider.",
+ "smart_model_routing.cheap_model.model": "Cheap-route model.",
+ "discord.allow_bots": "Discord bot-message policy (none|mentions|all).",
+ "discord.free_response_channels": "Discord channel IDs that bypass mention gating.",
+ "discord.require_mention": "Require @mention in Discord server channels.",
+ "discord.auto_thread": "Auto-create Discord threads on @mention.",
+ }
+
+ def _get_runtime_debug_overrides(self) -> Dict[str, Any]:
+ overrides = getattr(self, "_runtime_debug_overrides", None)
+ if overrides is None:
+ overrides = {}
+ self._runtime_debug_overrides = overrides
+ return overrides
+
+ @staticmethod
+ def _parse_debug_scalar(raw_value: str) -> Any:
+ import yaml
+
+ text = (raw_value or "").strip()
+ if not text:
+ return ""
+ try:
+ return yaml.safe_load(text)
+ except Exception:
+ return text
+
+ @classmethod
+ def _parse_debug_bool(cls, raw_value: str) -> bool:
+ parsed = cls._parse_debug_scalar(raw_value)
+ if isinstance(parsed, bool):
+ return parsed
+ if isinstance(parsed, str):
+ normalized = parsed.strip().lower()
+ if normalized in {"1", "true", "yes", "on"}:
+ return True
+ if normalized in {"0", "false", "no", "off"}:
+ return False
+ raise ValueError("expected a boolean value")
+
+ @classmethod
+ def _parse_debug_string_list(cls, raw_value: str) -> List[str]:
+ parsed = cls._parse_debug_scalar(raw_value)
+ if parsed is None:
+ return []
+ if isinstance(parsed, (list, tuple, set)):
+ items = list(parsed)
+ elif isinstance(parsed, str):
+ if "," in parsed:
+ items = [item.strip() for item in parsed.split(",")]
+ else:
+ items = [parsed.strip()]
+ else:
+ items = [str(parsed).strip()]
+ return [str(item).strip() for item in items if str(item).strip()]
+
+ @classmethod
+ def _parse_debug_optional_string(cls, raw_value: str) -> Optional[str]:
+ parsed = cls._parse_debug_scalar(raw_value)
+ if parsed is None:
+ return None
+ text = str(parsed).strip()
+ if not text:
+ return None
+ return text
+
+ @classmethod
+ def _parse_debug_int(cls, raw_value: str) -> int:
+ parsed = cls._parse_debug_scalar(raw_value)
+ try:
+ return int(parsed)
+ except (TypeError, ValueError) as exc:
+ raise ValueError("expected an integer value") from exc
+
+ @classmethod
+ def _parse_debug_value(cls, key: str, raw_value: str) -> Any:
+ if key == "agent.system_prompt":
+ return str(raw_value)
+
+ if key == "agent.reasoning_effort":
+ normalized = str(raw_value).strip().lower()
+ if normalized == "off":
+ normalized = "none"
+ if normalized not in cls._DEBUG_REASONING_LEVELS:
+ raise ValueError(
+ "expected one of: none, minimal, low, medium, high, xhigh"
+ )
+ return normalized
+
+ if key == "display.show_reasoning":
+ return cls._parse_debug_bool(raw_value)
+
+ if key == "display.background_process_notifications":
+ normalized = str(raw_value).strip().lower()
+ if normalized not in cls._DEBUG_BACKGROUND_NOTIFICATION_MODES:
+ raise ValueError("expected one of: all, result, error, off")
+ return normalized
+
+ if key in {
+ "provider_routing.only",
+ "provider_routing.ignore",
+ "provider_routing.order",
+ "discord.free_response_channels",
+ }:
+ return cls._parse_debug_string_list(raw_value)
+
+ if key == "provider_routing.sort":
+ return cls._parse_debug_optional_string(raw_value)
+
+ if key == "provider_routing.require_parameters":
+ return cls._parse_debug_bool(raw_value)
+
+ if key == "provider_routing.data_collection":
+ value = cls._parse_debug_optional_string(raw_value)
+ if value is None:
+ return None
+ normalized = value.lower()
+ if normalized not in cls._DEBUG_PROVIDER_DATA_COLLECTION_MODES:
+ raise ValueError("expected one of: allow, deny")
+ return normalized
+
+ if key in {"fallback_model.provider", "fallback_model.model"}:
+ return cls._parse_debug_optional_string(raw_value)
+
+ if key == "smart_model_routing.enabled":
+ return cls._parse_debug_bool(raw_value)
+
+ if key in {"smart_model_routing.max_simple_chars", "smart_model_routing.max_simple_words"}:
+ return cls._parse_debug_int(raw_value)
+
+ if key in {"smart_model_routing.cheap_model.provider", "smart_model_routing.cheap_model.model"}:
+ return cls._parse_debug_optional_string(raw_value)
+
+ if key == "discord.allow_bots":
+ normalized = str(raw_value).strip().lower()
+ if normalized not in cls._DEBUG_DISCORD_BOT_FILTER_POLICIES:
+ raise ValueError("expected one of: none, mentions, all")
+ return normalized
+
+ if key in {"discord.require_mention", "discord.auto_thread"}:
+ return cls._parse_debug_bool(raw_value)
+
+ raise KeyError(key)
+
+ @staticmethod
+ def _format_runtime_debug_value(value: Any) -> str:
+ return format_yaml_block(value)
+
+ @staticmethod
+ def _reasoning_config_to_level(reasoning_config: dict | None) -> str:
+ if reasoning_config is None:
+ return "medium"
+ if reasoning_config.get("enabled") is False:
+ return "none"
+ return str(reasoning_config.get("effort") or "medium").strip().lower()
+
+ @classmethod
+ def _reasoning_level_to_config(cls, level: str) -> dict | None:
+ normalized = (level or "").strip().lower()
+ if not normalized or normalized == "medium":
+ return {"enabled": True, "effort": "medium"}
+ if normalized == "none":
+ return {"enabled": False}
+ if normalized in {"minimal", "low", "high", "xhigh"}:
+ return {"enabled": True, "effort": normalized}
+ return None
+
+ def _get_effective_reasoning_config(self) -> dict | None:
+ overrides = self._get_runtime_debug_overrides()
+ if "agent.reasoning_effort" not in overrides:
+ return self._load_reasoning_config()
+ return self._reasoning_level_to_config(str(overrides["agent.reasoning_effort"]))
+
+ def _get_effective_show_reasoning(self) -> bool:
+ overrides = self._get_runtime_debug_overrides()
+ if "display.show_reasoning" in overrides:
+ return bool(overrides["display.show_reasoning"])
+ return self._load_show_reasoning()
+
+ def _get_effective_background_notifications_mode(self) -> str:
+ overrides = self._get_runtime_debug_overrides()
+ if "display.background_process_notifications" in overrides:
+ return str(overrides["display.background_process_notifications"])
+ return self._load_background_notifications_mode()
+
+ def _build_effective_provider_routing(self) -> dict:
+ merged = dict(self._load_provider_routing() or {})
+ overrides = self._get_runtime_debug_overrides()
+ for suffix in ("only", "ignore", "order", "sort", "require_parameters", "data_collection"):
+ key = f"provider_routing.{suffix}"
+ if key in overrides:
+ merged[suffix] = overrides[key]
+ return merged
+
+ def _build_effective_fallback_model(self) -> dict | None:
+ merged = dict(self._load_fallback_model() or {})
+ overrides = self._get_runtime_debug_overrides()
+ for suffix in ("provider", "model"):
+ key = f"fallback_model.{suffix}"
+ if key in overrides:
+ merged[suffix] = overrides[key]
+ provider = str(merged.get("provider") or "").strip()
+ model = str(merged.get("model") or "").strip()
+ if not provider or not model:
+ return None
+ return {"provider": provider, "model": model}
+
+ def _build_effective_smart_model_routing(self) -> dict:
+ merged = dict(self._load_smart_model_routing() or {})
+ cheap_model = dict(merged.get("cheap_model") or {})
+ merged["cheap_model"] = cheap_model
+ overrides = self._get_runtime_debug_overrides()
+
+ for suffix in ("enabled", "max_simple_chars", "max_simple_words"):
+ key = f"smart_model_routing.{suffix}"
+ if key in overrides:
+ merged[suffix] = overrides[key]
+
+ for suffix in ("provider", "model"):
+ key = f"smart_model_routing.cheap_model.{suffix}"
+ if key in overrides:
+ cheap_model[suffix] = overrides[key]
+
+ return merged
+
+ def _build_effective_discord_policy_overrides(self) -> dict:
+ overrides = self._get_runtime_debug_overrides()
+ policy_overrides: Dict[str, Any] = {}
+ for source_key, target_key in (
+ ("discord.allow_bots", "allow_bots"),
+ ("discord.free_response_channels", "free_response_channels"),
+ ("discord.require_mention", "require_mention"),
+ ("discord.auto_thread", "auto_thread"),
+ ):
+ if source_key in overrides:
+ policy_overrides[target_key] = overrides[source_key]
+ return policy_overrides
+
+ def _get_effective_runtime_debug_value(self, key: str) -> Any:
+ overrides = self._get_runtime_debug_overrides()
+ if key == "agent.system_prompt":
+ return overrides[key] if key in overrides else self._load_ephemeral_system_prompt()
+ if key == "agent.reasoning_effort":
+ if key in overrides:
+ return overrides[key]
+ return self._reasoning_config_to_level(self._load_reasoning_config())
+ if key == "display.show_reasoning":
+ return self._get_effective_show_reasoning()
+ if key == "display.background_process_notifications":
+ return self._get_effective_background_notifications_mode()
+ if key.startswith("provider_routing."):
+ return self._build_effective_provider_routing().get(key.split(".", 1)[1])
+ if key.startswith("fallback_model."):
+ current = self._build_effective_fallback_model() or {}
+ return current.get(key.split(".", 1)[1])
+ if key.startswith("smart_model_routing.cheap_model."):
+ current = self._build_effective_smart_model_routing().get("cheap_model") or {}
+ return current.get(key.rsplit(".", 1)[1])
+ if key.startswith("smart_model_routing."):
+ current = self._build_effective_smart_model_routing()
+ return current.get(key.split(".", 1)[1])
+ if key.startswith("discord."):
+ from gateway.platforms.discord_impl import config as discord_config
+
+ policy = discord_config.load_policy_config(
+ getattr(self, "config", None).platforms.get(Platform.DISCORD)
+ if getattr(self, "config", None) and getattr(self.config, "platforms", None)
+ else None,
+ overrides=self._build_effective_discord_policy_overrides(),
+ )
+ mapping = {
+ "discord.allow_bots": policy.bot_filter_policy,
+ "discord.free_response_channels": sorted(policy.free_response_channels),
+ "discord.require_mention": policy.require_mention,
+ "discord.auto_thread": policy.auto_thread,
+ }
+ return mapping[key]
+ raise KeyError(key)
+
+ def _apply_runtime_debug_overrides(self) -> None:
+ self._ephemeral_system_prompt = str(self._get_effective_runtime_debug_value("agent.system_prompt") or "")
+ self._reasoning_config = self._get_effective_reasoning_config()
+ self._show_reasoning = self._get_effective_show_reasoning()
+ self._provider_routing = self._build_effective_provider_routing()
+ self._fallback_model = self._build_effective_fallback_model()
+ self._smart_model_routing = self._build_effective_smart_model_routing()
+
+ discord_adapter = getattr(self, "adapters", {}).get(Platform.DISCORD)
+ if discord_adapter and hasattr(discord_adapter, "apply_runtime_policy_overrides"):
+ discord_adapter.apply_runtime_policy_overrides(
+ self._build_effective_discord_policy_overrides()
+ )
def _get_or_create_gateway_honcho(self, session_key: str):
"""Return a persistent Honcho manager/config pair for this gateway session."""
@@ -453,6 +786,7 @@ def _has_setup_skill(self) -> bool:
# -- Voice mode persistence ------------------------------------------
_VOICE_MODE_PATH = _hermes_home / "gateway_voice_mode.json"
+ _RUNTIME_CONTROLS_PATH = _hermes_home / "gateway_runtime_controls.json"
def _load_voice_modes(self) -> Dict[str, str]:
try:
@@ -499,6 +833,56 @@ def _sync_voice_mode_state_to_adapter(self, adapter) -> None:
chat_id for chat_id, mode in self._voice_mode.items() if mode == "off"
)
+ def _load_runtime_controls(self) -> Dict[str, Dict[str, Any]]:
+ """Load persisted per-session send-policy and dock-routing controls."""
+ try:
+ payload = json.loads(self._RUNTIME_CONTROLS_PATH.read_text())
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
+ return {"send_policies": {}, "dock_targets": {}}
+
+ if not isinstance(payload, dict):
+ return {"send_policies": {}, "dock_targets": {}}
+
+ raw_send_policies = payload.get("send_policies") or {}
+ send_policies = {
+ str(session_key): str(mode)
+ for session_key, mode in raw_send_policies.items()
+ if str(mode) in {"on", "off", "inherit"}
+ }
+
+ raw_dock_targets = payload.get("dock_targets") or {}
+ dock_targets: Dict[str, Dict[str, Any]] = {}
+ for session_key, target in raw_dock_targets.items():
+ if not isinstance(target, dict):
+ continue
+ platform = str(target.get("platform") or "").strip().lower()
+ chat_id = str(target.get("chat_id") or "").strip()
+ if not platform or not chat_id:
+ continue
+ dock_targets[str(session_key)] = {
+ "platform": platform,
+ "chat_id": chat_id,
+ "thread_id": str(target.get("thread_id") or "").strip() or None,
+ "name": str(target.get("name") or "").strip(),
+ }
+
+ return {
+ "send_policies": send_policies,
+ "dock_targets": dock_targets,
+ }
+
+ def _save_runtime_controls(self) -> None:
+ """Persist per-session send-policy and dock-routing controls."""
+ payload = {
+ "send_policies": getattr(self, "_session_send_policies", {}) or {},
+ "dock_targets": getattr(self, "_session_docks", {}) or {},
+ }
+ try:
+ self._RUNTIME_CONTROLS_PATH.parent.mkdir(parents=True, exist_ok=True)
+ self._RUNTIME_CONTROLS_PATH.write_text(json.dumps(payload, indent=2, sort_keys=True))
+ except OSError as e:
+ logger.warning("Failed to save gateway runtime controls: %s", e)
+
# -----------------------------------------------------------------
def _flush_memories_for_session(
@@ -610,6 +994,970 @@ def _session_key_for_source(self, source: SessionSource) -> str:
group_sessions_per_user=getattr(config, "group_sessions_per_user", True),
)
+ def _session_source_for_event(self, event: MessageEvent) -> SessionSource:
+ """Resolve the session source for an event, honoring command-session overrides."""
+ metadata = getattr(event, "metadata", None)
+ if isinstance(metadata, dict):
+ session_source = metadata.get("session_source")
+ if isinstance(session_source, SessionSource):
+ return session_source
+ return event.source
+
+ def _command_target_source_for_event(self, event: MessageEvent) -> SessionSource:
+ """Resolve the command target source, falling back to the event source."""
+ metadata = getattr(event, "metadata", None)
+ if isinstance(metadata, dict):
+ target_source = metadata.get("command_target_source")
+ if isinstance(target_source, SessionSource):
+ return target_source
+ return event.source
+
+ @staticmethod
+ def _new_approval_id() -> str:
+ """Generate a short human-readable approval identifier."""
+ return f"appr-{uuid.uuid4().hex[:8]}"
+
+ @staticmethod
+ def _normalize_approval_decision(raw: str) -> Optional[str]:
+ value = str(raw or "").strip().lower()
+ mapping = {
+ "allow-once": "allow-once",
+ "once": "allow-once",
+ "allow_once": "allow-once",
+ "yes": "allow-once",
+ "approve": "allow-once",
+ "allow-session": "allow-session",
+ "allow_session": "allow-session",
+ "session": "allow-session",
+ "ses": "allow-session",
+ "allow-always": "allow-always",
+ "always": "allow-always",
+ "allow_always": "allow-always",
+ "deny": "deny",
+ "no": "deny",
+ "reject": "deny",
+ "cancel": "deny",
+ "show": "show",
+ "view": "show",
+ "full": "show",
+ }
+ return mapping.get(value)
+
+ def _find_pending_approval(
+ self,
+ *,
+ approval_id: Optional[str] = None,
+ source: Optional[SessionSource] = None,
+ ) -> tuple[Optional[str], Optional[dict[str, Any]]]:
+ """Find a pending approval by explicit approval ID or source session."""
+ if approval_id:
+ for session_key, pending in self._pending_approvals.items():
+ if str(pending.get("approval_id") or "") == approval_id:
+ return session_key, pending
+ return None, None
+
+ if source is None:
+ return None, None
+
+ session_key = self._session_key_for_source(source)
+ return session_key, self._pending_approvals.get(session_key)
+
+ def _approval_usage_text(self) -> str:
+ return "`/approve`, `/approve session`, `/approve always`, or `/approve [id] `"
+
+ def _resolve_pending_approval(
+ self,
+ *,
+ decision: str,
+ approval_id: Optional[str] = None,
+ source: Optional[SessionSource] = None,
+ ) -> str:
+ """Resolve a pending approval by ID or source session."""
+ normalized = self._normalize_approval_decision(decision)
+ if normalized is None:
+ return (
+ f"⚠️ Unknown approval decision: `{decision}`\n\n"
+ f"Use {self._approval_usage_text()}"
+ )
+
+ session_key, pending = self._find_pending_approval(approval_id=approval_id, source=source)
+ if not session_key or not pending:
+ if normalized == "deny":
+ return "No pending command to deny."
+ return "No pending command to approve."
+
+ pending_id = str(pending.get("approval_id") or "")
+ command = str(pending.get("command") or "").strip()
+ pattern_keys = list(pending.get("pattern_keys") or [])
+ if not pattern_keys:
+ pattern_key = str(pending.get("pattern_key") or "").strip()
+ if pattern_key:
+ pattern_keys = [pattern_key]
+
+ timestamp = pending.get("timestamp")
+ if isinstance(timestamp, (int, float)) and time.time() - float(timestamp) > 300:
+ self._pending_approvals.pop(session_key, None)
+ return "⚠️ Approval expired (timed out after 5 minutes). Ask the agent to try again."
+
+ if normalized == "show":
+ return (
+ f"⚠️ Pending approval `{pending_id}`\n\n"
+ f"```\n{command}\n```\n\n"
+ f"Use {self._approval_usage_text()}"
+ )
+
+ if normalized == "deny":
+ self._pending_approvals.pop(session_key, None)
+ return "❌ Command denied."
+
+ import tools.approval as approval_mod
+ from tools.terminal_tool import terminal_tool
+
+ if normalized == "allow-session":
+ for pattern_key in pattern_keys:
+ approval_mod.approve_session(session_key, pattern_key)
+ elif normalized == "allow-always":
+ for pattern_key in pattern_keys:
+ approval_mod.approve_session(session_key, pattern_key)
+ approval_mod.approve_permanent(pattern_key)
+ approval_mod.save_permanent_allowlist(approval_mod._permanent_approved)
+ else:
+ for pattern_key in pattern_keys:
+ approval_mod.approve_session(session_key, pattern_key)
+
+ self._pending_approvals.pop(session_key, None)
+ on_approve = pending.get("on_approve")
+ if callable(on_approve):
+ try:
+ return on_approve(normalized)
+ except Exception as e:
+ logger.exception("Pending approval callback failed")
+ return f"❌ Approved command failed: {e}"
+
+ result = terminal_tool(command=command, force=True)
+ if normalized == "allow-session":
+ prefix = (
+ "✅ Command approved and executed "
+ "(pattern approved for this session). Decision: approved (allow-session)."
+ )
+ elif normalized == "allow-always":
+ prefix = (
+ "✅ Command approved and executed "
+ "(pattern approved permanently). Decision: approved (allow-always)."
+ )
+ else:
+ prefix = "✅ Command approved and executed. Decision: approved (allow-once)."
+ if pending_id:
+ prefix = f"{prefix} (`{pending_id}`)"
+ return f"{prefix}\n\n```\n{result[:3500]}\n```"
+
+ @staticmethod
+ def _format_minutes_label(minutes: int) -> str:
+ if minutes <= 0:
+ return "off"
+ if minutes % 1440 == 0:
+ days = minutes // 1440
+ return f"{days}d"
+ if minutes % 60 == 0:
+ hours = minutes // 60
+ return f"{hours}h"
+ return f"{minutes}m"
+
+ @staticmethod
+ def _parse_duration_minutes(raw: str) -> Optional[int]:
+ value = str(raw or "").strip().lower()
+ if not value:
+ return None
+ if value in {"off", "none", "disable", "disabled"}:
+ return 0
+
+ from cron.jobs import parse_duration
+
+ return parse_duration(value)
+
+ def _get_discord_adapter(self):
+ adapter = self.adapters.get(Platform.DISCORD)
+ if adapter is None:
+ return None
+ return adapter
+
+ def _session_send_policy(self, session_key: str) -> str:
+ policies = getattr(self, "_session_send_policies", None)
+ if not isinstance(policies, dict):
+ return "inherit"
+ return policies.get(session_key, "inherit")
+
+ def _dock_target_for_session(self, session_key: str) -> Optional[Dict[str, Any]]:
+ dock_targets = getattr(self, "_session_docks", None)
+ if not isinstance(dock_targets, dict):
+ return None
+ target = dock_targets.get(session_key)
+ if not isinstance(target, dict):
+ return None
+ return target
+
+ def _format_dock_target(self, target: Dict[str, Any]) -> str:
+ platform = str(target.get("platform") or "unknown")
+ name = str(target.get("name") or "").strip()
+ chat_id = str(target.get("chat_id") or "")
+ if name:
+ return f"{platform}:{name} (`{chat_id}`)"
+ return f"{platform} home channel (`{chat_id}`)"
+
+ def _get_bash_jobs(self) -> Dict[str, str]:
+ jobs = getattr(self, "_bash_jobs", None)
+ if jobs is None:
+ jobs = {}
+ self._bash_jobs = jobs
+ return jobs
+
+ @staticmethod
+ def _rewrite_bash_shortcut(raw_text: str) -> Optional[str]:
+ text = str(raw_text or "")
+ stripped = text.lstrip()
+ if not stripped.startswith("!"):
+ return None
+ payload = stripped[1:].strip()
+ if not payload:
+ return None
+ if payload.startswith("poll"):
+ args = payload[4:].strip()
+ return f"/bash poll {args}".strip()
+ if payload.startswith("stop"):
+ args = payload[4:].strip()
+ return f"/bash stop {args}".strip()
+ return f"/bash {payload}"
+
+ @staticmethod
+ def _split_command_args(raw: str, *, max_parts: int | None = None) -> list[str]:
+ text = str(raw or "").strip()
+ if not text:
+ return []
+ try:
+ parts = shlex.split(text)
+ except ValueError:
+ parts = text.split()
+ if max_parts is None or len(parts) <= max_parts:
+ return parts
+ head = parts[: max_parts - 1]
+ tail = " ".join(parts[max_parts - 1 :]).strip()
+ return [*head, tail]
+
+ @staticmethod
+ def _normalize_skill_name(raw: str) -> str:
+ return str(raw or "").strip().lower().replace(" ", "-").replace("_", "-").lstrip("/")
+
+ @staticmethod
+ def _parity_blocker_text(command: str, plan_id: str, detail: str) -> str:
+ return (
+ f"⚠️ `/{command}` is still blocked in Hermes parity work.\n\n"
+ f"**Blocked by:** `{plan_id}`\n"
+ f"**Reason:** {detail}"
+ )
+
+ def _get_bash_foreground_seconds(self) -> int:
+ raw_ms = os.getenv("HERMES_BASH_FOREGROUND_MS", "").strip()
+ if not raw_ms:
+ try:
+ cfg = load_raw_user_config() or {}
+ commands_cfg = cfg.get("commands", {}) if isinstance(cfg, dict) else {}
+ raw_ms = str(commands_cfg.get("bashForegroundMs", "") or "").strip()
+ except Exception:
+ raw_ms = ""
+ try:
+ ms = int(raw_ms or "2000")
+ except ValueError:
+ ms = 2000
+ if ms <= 0:
+ return 0
+ return max(1, (ms + 999) // 1000)
+
+ @staticmethod
+ def _format_bash_output(output: str, *, limit: int = 3500) -> str:
+ text = str(output or "").rstrip()
+ if not text:
+ return "_(no output)_"
+ if len(text) > limit:
+ text = text[-limit:]
+ text = f"...\n{text}"
+ return f"```\n{text}\n```"
+
+ def _resolve_bash_target_session_id(self, session_key: str, raw_target: str) -> Optional[str]:
+ from tools.process_registry import process_registry
+
+ target = str(raw_target or "").strip()
+ jobs = self._get_bash_jobs()
+ if target:
+ return target
+ session_id = jobs.get(session_key)
+ if not session_id:
+ return None
+ current = process_registry.get(session_id)
+ if current is None or current.exited:
+ jobs.pop(session_key, None)
+ return None
+ return session_id
+
+ def _store_pending_bash_approval(
+ self,
+ *,
+ session_key: str,
+ command: str,
+ approval_payload: dict[str, Any],
+ on_approve,
+ ) -> str:
+ approval_id = self._new_approval_id()
+ pending = {
+ "approval_id": approval_id,
+ "session_key": session_key,
+ "created_at": datetime.now().isoformat(),
+ "command": command,
+ "description": approval_payload.get("description", "command flagged"),
+ "pattern_key": approval_payload.get("pattern_key", ""),
+ "on_approve": on_approve,
+ }
+ self._pending_approvals[session_key] = pending
+ return approval_id
+
+ def _run_bash_process(
+ self,
+ *,
+ source: SessionSource,
+ session_key: str,
+ command: str,
+ force: bool = False,
+ ) -> dict[str, Any]:
+ from tools.terminal_tool import terminal_tool
+
+ context = build_session_context(source, self.config)
+ tracked_vars = [
+ "HERMES_SESSION_PLATFORM",
+ "HERMES_SESSION_CHAT_ID",
+ "HERMES_SESSION_CHAT_NAME",
+ "HERMES_SESSION_THREAD_ID",
+ "HERMES_SESSION_KEY",
+ "HERMES_SESSION_SEND_POLICY",
+ ]
+ previous_env = {name: os.environ.get(name) for name in tracked_vars}
+ try:
+ self._set_session_env(context)
+ os.environ["HERMES_SESSION_KEY"] = session_key
+ os.environ["HERMES_SESSION_SEND_POLICY"] = self._session_send_policy(session_key)
+ raw = terminal_tool(command=command, background=True, force=force)
+ finally:
+ self._clear_session_env()
+ for name in ("HERMES_SESSION_KEY", "HERMES_SESSION_SEND_POLICY"):
+ previous = previous_env.get(name)
+ if previous is None:
+ os.environ.pop(name, None)
+ else:
+ os.environ[name] = previous
+ for name in ("HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_CHAT_NAME", "HERMES_SESSION_THREAD_ID"):
+ previous = previous_env.get(name)
+ if previous is None:
+ os.environ.pop(name, None)
+ else:
+ os.environ[name] = previous
+
+ if isinstance(raw, str):
+ try:
+ parsed = json.loads(raw)
+ except Exception:
+ return {"status": "error", "error": raw}
+ if isinstance(parsed, dict):
+ return parsed
+ return {"status": "error", "error": "Unexpected terminal result"}
+
+ def _resume_bash_after_approval(
+ self,
+ *,
+ source: SessionSource,
+ session_key: str,
+ command: str,
+ ) -> str:
+ result = self._run_bash_process(
+ source=source,
+ session_key=session_key,
+ command=command,
+ force=True,
+ )
+ return self._format_bash_result(
+ session_key=session_key,
+ command=command,
+ result=result,
+ )
+
+ def _format_bash_result(
+ self,
+ *,
+ session_key: str,
+ command: str,
+ result: dict[str, Any],
+ ) -> str:
+ from tools.process_registry import process_registry
+
+ status = str(result.get("status") or "").strip()
+ if status == "approval_required":
+ return str(result.get("error") or "Approval required.")
+ if status == "blocked":
+ return f"❌ {result.get('error') or 'Command blocked.'}"
+ if result.get("error") and not result.get("session_id"):
+ return f"❌ Bash command failed: {result.get('error')}"
+
+ proc_id = str(result.get("session_id") or "").strip()
+ if not proc_id:
+ return f"❌ Bash command failed: {result.get('error') or 'No process session ID returned.'}"
+
+ self._get_bash_jobs()[session_key] = proc_id
+ wait_seconds = self._get_bash_foreground_seconds()
+ wait_result = process_registry.wait(proc_id, timeout=wait_seconds) if wait_seconds > 0 else {"status": "timeout"}
+
+ if wait_result.get("status") == "exited":
+ self._get_bash_jobs().pop(session_key, None)
+ exit_code = wait_result.get("exit_code")
+ output = wait_result.get("output", "")
+ return "\n".join(
+ [
+ "💻 **Bash Complete**",
+ "",
+ f"**Command:** `{command}`",
+ f"**Exit Code:** `{exit_code}`",
+ "",
+ self._format_bash_output(output),
+ ]
+ )
+
+ if wait_result.get("status") == "interrupted":
+ self._get_bash_jobs().pop(session_key, None)
+ return "\n".join(
+ [
+ "⚡ **Bash Interrupted**",
+ "",
+ f"**Command:** `{command}`",
+ "",
+ self._format_bash_output(wait_result.get("output", "")),
+ ]
+ )
+
+ process_info = process_registry.poll(proc_id)
+ pid = process_info.get("pid")
+ preview = process_info.get("output_preview", "")
+ return "\n".join(
+ [
+ "💻 **Bash Running**",
+ "",
+ f"**Command:** `{command}`",
+ f"**Session ID:** `{proc_id}`",
+ f"**PID:** `{pid}`" if pid else "**PID:** `unknown`",
+ "",
+ "Use `/bash poll` or `!poll` to check progress.",
+ "Use `/bash stop` or `!stop` to terminate it.",
+ "",
+ self._format_bash_output(preview, limit=1200),
+ ]
+ )
+
+ @staticmethod
+ def _control_commands_during_run() -> set[str]:
+ return {"status", "stop", "approve", "bash", "subagents", "kill", "steer", "tell", "acp"}
+
+ def _get_subagent_runtime(self) -> dict[str, dict[str, Any]]:
+ runtime = getattr(self, "_subagent_runtime", None)
+ if not isinstance(runtime, dict):
+ runtime = {}
+ self._subagent_runtime = runtime
+ return runtime
+
+ def _get_subagent_session_state(self, session_key: str) -> dict[str, Any]:
+ runtime = self._get_subagent_runtime()
+ state = runtime.get(session_key)
+ if not isinstance(state, dict):
+ state = {"next_ordinal": 1, "entries": {}}
+ runtime[session_key] = state
+ state.setdefault("next_ordinal", 1)
+ state.setdefault("entries", {})
+ return state
+
+ @staticmethod
+ def _subagent_now() -> str:
+ return datetime.now().isoformat(timespec="seconds")
+
+ @staticmethod
+ def _truncate_line(text: str, *, limit: int = 120) -> str:
+ value = str(text or "").strip()
+ if len(value) <= limit:
+ return value
+ return value[: limit - 3] + "..."
+
+ def _append_subagent_log(self, entry: dict[str, Any], message: str) -> None:
+ logs = entry.setdefault("logs", [])
+ logs.append(f"[{self._subagent_now()}] {message}")
+ if len(logs) > 60:
+ del logs[:-60]
+ entry["updated_at"] = self._subagent_now()
+
+ def _create_subagent_entry(
+ self,
+ *,
+ session_key: str,
+ child: Any,
+ task_index: int,
+ goal: str,
+ context: Optional[str],
+ toolsets: Optional[list[str]],
+ model: Optional[str],
+ source_kind: str,
+ ) -> dict[str, Any]:
+ state = self._get_subagent_session_state(session_key)
+ ordinal = int(state.get("next_ordinal", 1))
+ state["next_ordinal"] = ordinal + 1
+ subagent_id = f"sa-{ordinal}"
+ entry = {
+ "id": subagent_id,
+ "ordinal": ordinal,
+ "task_index": task_index,
+ "goal": str(goal or "").strip(),
+ "context": str(context or "").strip(),
+ "toolsets": list(toolsets or []),
+ "model": model or "",
+ "status": "pending",
+ "source_kind": source_kind,
+ "created_at": self._subagent_now(),
+ "updated_at": self._subagent_now(),
+ "started_at": None,
+ "ended_at": None,
+ "summary": "",
+ "error": "",
+ "logs": [],
+ "restarts": 0,
+ "child": child,
+ "pending_steer": None,
+ }
+ state["entries"][subagent_id] = entry
+ self._append_subagent_log(entry, f"Registered subagent for goal: {self._truncate_line(goal)}")
+ return entry
+
+ def _find_subagent_entry(self, session_key: str, child: Any) -> Optional[dict[str, Any]]:
+ if child is None:
+ return None
+ state = self._get_subagent_session_state(session_key)
+ for entry in state["entries"].values():
+ if entry.get("child") is child:
+ return entry
+ child_id = str(getattr(child, "_gateway_subagent_id", "") or "").strip()
+ if child_id:
+ return state["entries"].get(child_id)
+ return None
+
+ def _resolve_subagent_targets(
+ self,
+ session_key: str,
+ raw_target: str,
+ *,
+ active_only: bool = False,
+ allow_all: bool = False,
+ ) -> tuple[list[dict[str, Any]], Optional[str]]:
+ state = self._get_subagent_session_state(session_key)
+ entries = list(state["entries"].values())
+ if active_only:
+ entries = [e for e in entries if e.get("status") in {"pending", "running"}]
+ target = str(raw_target or "").strip()
+ if not target:
+ if len(entries) == 1:
+ return [entries[0]], None
+ if not entries:
+ return [], "No matching subagents found for this session."
+ return [], "Multiple subagents match. Use an explicit `sa-#` or `#` target."
+ if allow_all and target.lower() == "all":
+ return entries, None
+ if target.startswith("#"):
+ target = target[1:].strip()
+ if target.isdigit():
+ ordinal = int(target)
+ for entry in entries:
+ if int(entry.get("ordinal", 0)) == ordinal:
+ return [entry], None
+ return [], f"No subagent found for `#{ordinal}`."
+ match = state["entries"].get(target)
+ if match and (not active_only or match.get("status") in {"pending", "running"}):
+ return [match], None
+ return [], f"No subagent found for `{target}`."
+
+ def _format_subagent_list(self, session_key: str) -> str:
+ state = self._get_subagent_session_state(session_key)
+ entries = list(state["entries"].values())
+ if not entries:
+ return "No subagents recorded for this session."
+ lines = ["🤖 **Subagents**", ""]
+ for entry in sorted(entries, key=lambda item: int(item.get("ordinal", 0))):
+ status = str(entry.get("status") or "unknown")
+ goal = self._truncate_line(entry.get("goal") or "(no goal)")
+ lines.append(f"- `#{entry['ordinal']}` / `{entry['id']}` — {status} — {goal}")
+ summary = str(entry.get("summary") or "").strip()
+ if summary:
+ lines.append(f" summary: {self._truncate_line(summary, limit=140)}")
+ return "\n".join(lines)
+
+ def _format_subagent_info(self, entry: dict[str, Any]) -> str:
+ lines = [
+ "🤖 **Subagent Info**",
+ "",
+ f"**ID:** `{entry['id']}`",
+ f"**Ordinal:** `#{entry['ordinal']}`",
+ f"**Status:** `{entry.get('status')}`",
+ f"**Source:** `{entry.get('source_kind')}`",
+ f"**Model:** `{entry.get('model') or 'default'}`",
+ f"**Toolsets:** `{', '.join(entry.get('toolsets') or []) or 'default'}`",
+ f"**Goal:** {entry.get('goal') or '_(empty)_'}",
+ ]
+ if entry.get("error"):
+ lines.append(f"**Error:** `{entry['error']}`")
+ if entry.get("summary"):
+ lines.append(f"**Summary:** {entry['summary']}")
+ if entry.get("started_at"):
+ lines.append(f"**Started:** `{entry['started_at']}`")
+ if entry.get("ended_at"):
+ lines.append(f"**Ended:** `{entry['ended_at']}`")
+ return "\n".join(lines)
+
+ def _format_subagent_log(self, entry: dict[str, Any]) -> str:
+ lines = ["🤖 **Subagent Log**", "", f"**ID:** `{entry['id']}`", ""]
+ logs = entry.get("logs") or []
+ if not logs:
+ lines.append("_(no log events yet)_")
+ else:
+ lines.append("```")
+ lines.extend(logs[-25:])
+ lines.append("```")
+ return "\n".join(lines)
+
+ def _attach_subagent_runtime_hooks(self, agent: Any, session_key: str) -> None:
+ if agent is None:
+ return
+ agent._register_delegate_child = (
+ lambda **payload: self._register_delegate_child(session_key=session_key, **payload)
+ )
+ agent._start_delegate_child = (
+ lambda **payload: self._start_delegate_child(session_key=session_key, **payload)
+ )
+ agent._record_delegate_child_progress = (
+ lambda **payload: self._record_delegate_child_progress(session_key=session_key, **payload)
+ )
+ agent._complete_delegate_child = (
+ lambda **payload: self._complete_delegate_child(session_key=session_key, **payload)
+ )
+
+ def _register_delegate_child(
+ self,
+ *,
+ session_key: str,
+ child: Any,
+ task_index: int,
+ goal: str,
+ context: Optional[str],
+ toolsets: Optional[list[str]],
+ model: Optional[str],
+ ) -> str:
+ entry = self._create_subagent_entry(
+ session_key=session_key,
+ child=child,
+ task_index=task_index,
+ goal=goal,
+ context=context,
+ toolsets=toolsets,
+ model=model,
+ source_kind="delegate",
+ )
+ return entry["id"]
+
+ def _start_delegate_child(
+ self,
+ *,
+ session_key: str,
+ child: Any,
+ goal: str,
+ restarted: bool = False,
+ ) -> None:
+ entry = self._find_subagent_entry(session_key, child)
+ if not entry:
+ return
+ entry["status"] = "running"
+ entry["started_at"] = self._subagent_now()
+ entry["updated_at"] = self._subagent_now()
+ entry["goal"] = str(goal or "").strip()
+ if restarted:
+ entry["restarts"] = int(entry.get("restarts") or 0) + 1
+ self._append_subagent_log(entry, f"Restarted on steer: {self._truncate_line(goal)}")
+ else:
+ self._append_subagent_log(entry, f"Started: {self._truncate_line(goal)}")
+
+ def _record_delegate_child_progress(
+ self,
+ *,
+ session_key: str,
+ child: Any,
+ task_index: int,
+ tool_name: str,
+ preview: Optional[str],
+ args: Optional[dict[str, Any]],
+ summary: Optional[str],
+ ) -> None:
+ entry = self._find_subagent_entry(session_key, child)
+ if not entry:
+ return
+ text = summary or preview or tool_name
+ entry["last_progress"] = str(text or "").strip()
+ self._append_subagent_log(entry, f"{tool_name}: {self._truncate_line(text, limit=160)}")
+
+ def _complete_delegate_child(
+ self,
+ *,
+ session_key: str,
+ child: Any,
+ entry: dict[str, Any],
+ goal: str,
+ ) -> None:
+ stored = self._find_subagent_entry(session_key, child)
+ if not stored:
+ return
+ stored["status"] = entry.get("status", "completed")
+ stored["summary"] = str(entry.get("summary") or "").strip()
+ stored["error"] = str(entry.get("error") or "").strip()
+ stored["goal"] = str(goal or stored.get("goal") or "").strip()
+ stored["ended_at"] = self._subagent_now()
+ stored["updated_at"] = self._subagent_now()
+ stored["child"] = None
+ if stored["status"] == "completed":
+ self._append_subagent_log(stored, f"Completed: {self._truncate_line(stored.get('summary') or 'done', limit=160)}")
+ elif stored["status"] == "interrupted":
+ self._append_subagent_log(stored, "Interrupted.")
+ else:
+ detail = stored.get("error") or stored.get("summary") or "failed"
+ self._append_subagent_log(stored, f"Finished with {stored['status']}: {self._truncate_line(detail, limit=160)}")
+
+ def _build_subagent_progress_callback(self, session_key: str, child: Any):
+ def _callback(tool_name: str, preview: str = None, args: dict = None):
+ self._record_delegate_child_progress(
+ session_key=session_key,
+ child=child,
+ task_index=int(getattr(child, "_gateway_subagent_task_index", 0) or 0),
+ tool_name=tool_name,
+ preview=preview,
+ args=args,
+ summary=preview or tool_name,
+ )
+
+ return _callback
+
+ def _get_acp_session_manager(self):
+ manager = getattr(self, "_acp_session_manager", None)
+ if manager is None:
+ from acp_adapter.session import SessionManager
+
+ manager = SessionManager()
+ self._acp_session_manager = manager
+ return manager
+
+ def _get_acp_bindings(self) -> dict[str, str]:
+ bindings = getattr(self, "_acp_session_bindings", None)
+ if not isinstance(bindings, dict):
+ bindings = {}
+ self._acp_session_bindings = bindings
+ return bindings
+
+ def _get_acp_meta(self) -> dict[str, dict[str, Any]]:
+ meta = getattr(self, "_acp_session_meta", None)
+ if not isinstance(meta, dict):
+ meta = {}
+ self._acp_session_meta = meta
+ return meta
+
+ def _get_acp_tasks(self) -> dict[str, asyncio.Task]:
+ tasks = getattr(self, "_acp_running_tasks", None)
+ if not isinstance(tasks, dict):
+ tasks = {}
+ self._acp_running_tasks = tasks
+ return tasks
+
+ def _get_acp_session_meta_entry(self, session_id: str) -> dict[str, Any]:
+ meta = self._get_acp_meta()
+ entry = meta.get(session_id)
+ if not isinstance(entry, dict):
+ entry = {"options": {}, "last_result": None, "last_prompt": "", "updated_at": self._subagent_now()}
+ meta[session_id] = entry
+ entry.setdefault("options", {})
+ entry.setdefault("updated_at", self._subagent_now())
+ return entry
+
+ def _resolve_acp_target(self, session_key: str, payload: str) -> tuple[Optional[str], str]:
+ manager = self._get_acp_session_manager()
+ text = str(payload or "").strip()
+ parts = self._split_command_args(text, max_parts=2)
+ if parts and manager.get_session(parts[0]):
+ return parts[0], parts[1] if len(parts) > 1 else ""
+ bound = self._get_acp_bindings().get(session_key)
+ return bound, text
+
+ def _format_acp_sessions(self, session_key: str) -> str:
+ manager = self._get_acp_session_manager()
+ listing = manager.list_sessions()
+ if not listing:
+ return "No ACP sessions are active."
+ bound = self._get_acp_bindings().get(session_key)
+ tasks = self._get_acp_tasks()
+ lines = ["🧩 **ACP Sessions**", ""]
+ for item in listing:
+ session_id = item.get("session_id", "")
+ busy = session_id in tasks and not tasks[session_id].done()
+ label = " (bound)" if session_id == bound else ""
+ lines.append(
+ f"- `{session_id}`{label} — cwd `{item.get('cwd')}` — model `{item.get('model') or 'default'}` — busy `{str(busy).lower()}`"
+ )
+ return "\n".join(lines)
+
+ def _format_acp_status(self, session_id: str) -> str:
+ manager = self._get_acp_session_manager()
+ state = manager.get_session(session_id)
+ if state is None:
+ return f"No ACP session found for `{session_id}`."
+ meta = self._get_acp_session_meta_entry(session_id)
+ task = self._get_acp_tasks().get(session_id)
+ busy = bool(task and not task.done())
+ lines = [
+ "🧩 **ACP Status**",
+ "",
+ f"**Session:** `{session_id}`",
+ f"**Busy:** `{str(busy).lower()}`",
+ f"**CWD:** `{state.cwd}`",
+ f"**Model:** `{state.model or getattr(state.agent, 'model', '') or 'default'}`",
+ f"**History Messages:** `{len(state.history)}`",
+ ]
+ options = meta.get("options") or {}
+ if options:
+ lines.append(f"**Options:** `{json.dumps(options, default=str, sort_keys=True)}`")
+ if meta.get("last_prompt"):
+ lines.append(f"**Last Prompt:** {self._truncate_line(meta['last_prompt'], limit=180)}")
+ last_result = meta.get("last_result") or {}
+ if isinstance(last_result, dict) and last_result.get("final_response"):
+ lines.append(f"**Last Result:** {self._truncate_line(last_result['final_response'], limit=180)}")
+ return "\n".join(lines)
+
+ def _prepare_skill_command(
+ self,
+ event: MessageEvent,
+ *,
+ task_id: str,
+ ) -> tuple[Optional[str], Optional[str]]:
+ """Rewrite `/skill` into the underlying skill invocation message."""
+ args_text = event.get_command_args().strip()
+ parts = self._split_command_args(args_text, max_parts=2)
+ if not parts:
+ from agent.skill_commands import get_skill_commands
+
+ skill_cmds = get_skill_commands()
+ if not skill_cmds:
+ return None, "No skill commands are installed."
+ lines = ["⚡ **Installed Skills**", ""]
+ for name, payload in sorted(skill_cmds.items()):
+ lines.append(f"`{name}` — {payload.get('description', 'Skill command')}")
+ lines.append("")
+ lines.append("Usage: `/skill [input]`")
+ return None, "\n".join(lines)
+
+ from agent.skill_commands import build_skill_invocation_message
+
+ skill_name = self._normalize_skill_name(parts[0])
+ user_instruction = parts[1] if len(parts) > 1 else ""
+ cmd_key = f"/{skill_name}"
+ prepared = build_skill_invocation_message(
+ cmd_key,
+ user_instruction,
+ task_id=task_id,
+ )
+ if not prepared:
+ return None, f"Unknown skill `{skill_name}`. Use `/skill` to list installed skills."
+ event.text = prepared
+ return prepared, None
+
+ def _schedule_gateway_restart(self, delay_seconds: float = 1.5) -> None:
+ async def _restart_later():
+ await asyncio.sleep(delay_seconds)
+ hermes_cmd = _resolve_hermes_bin()
+ if not hermes_cmd:
+ logger.error("Could not locate hermes executable for gateway restart")
+ return
+ cmd = hermes_cmd + ["gateway", "restart"]
+ try:
+ import shutil
+ import subprocess
+
+ systemd_run = shutil.which("systemd-run")
+ if systemd_run:
+ subprocess.Popen(
+ [systemd_run, "--user", "--scope", "--unit=hermes-discord-restart", "--", *cmd],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ else:
+ subprocess.Popen(
+ cmd,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ except Exception:
+ logger.exception("Gateway restart scheduling failed")
+
+ asyncio.create_task(_restart_later())
+
+ async def _deliver_docked_response(
+ self,
+ *,
+ session_key: str,
+ response: str,
+ source: SessionSource,
+ ) -> Optional[str]:
+ target = self._dock_target_for_session(session_key)
+ if not target or not response.strip():
+ return response
+
+ target_platform = str(target.get("platform") or "").strip().lower()
+ if not target_platform:
+ return response
+
+ try:
+ platform = Platform(target_platform)
+ except ValueError:
+ return response
+
+ same_target = (
+ platform == source.platform
+ and str(target.get("chat_id") or "") == str(source.chat_id)
+ and str(target.get("thread_id") or "") == str(source.thread_id or "")
+ )
+ if same_target:
+ return response
+
+ dock_target = DeliveryTarget(
+ platform=platform,
+ chat_id=str(target.get("chat_id") or ""),
+ thread_id=str(target.get("thread_id") or "").strip() or None,
+ )
+ results = await self.delivery_router.deliver(
+ response,
+ [dock_target],
+ metadata={
+ "source_platform": source.platform.value if source.platform else "unknown",
+ "source_chat_id": source.chat_id,
+ },
+ )
+ result = results.get(dock_target.to_string()) or {}
+ if result.get("success"):
+ return None
+ error = result.get("error") or "unknown dock delivery failure"
+ return f"⚠️ Reply docking failed: {error}\n\n{response}"
+
def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict:
from agent.smart_model_routing import resolve_turn_route
@@ -901,6 +2249,8 @@ async def start(self) -> bool:
# Set up message + fatal error handlers
adapter.set_message_handler(self._handle_message)
adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)
+ if platform == Platform.DISCORD and hasattr(adapter, "_resolve_exec_approval"):
+ adapter._resolve_exec_approval = self._resolve_pending_approval
# Try to connect
logger.info("Connecting to %s...", platform.value)
@@ -1064,6 +2414,26 @@ async def stop(self) -> None:
except Exception as e:
logger.debug("Failed interrupting agent during shutdown: %s", e)
+ for runtime in self._get_subagent_runtime().values():
+ for entry in runtime.get("entries", {}).values():
+ child = entry.get("child")
+ if child is not None and hasattr(child, "interrupt"):
+ try:
+ child.interrupt("Gateway shutting down")
+ except Exception as e:
+ logger.debug("Failed interrupting subagent during shutdown: %s", e)
+
+ for session_id, task in list(self._get_acp_tasks().items()):
+ state = self._get_acp_session_manager().get_session(session_id)
+ if state and state.cancel_event:
+ state.cancel_event.set()
+ try:
+ state.agent.interrupt("Gateway shutting down")
+ except Exception:
+ logger.debug("Failed interrupting ACP session during shutdown", exc_info=True)
+ if task and not task.done():
+ task.cancel()
+
for platform, adapter in list(self.adapters.items()):
try:
await adapter.cancel_background_tasks()
@@ -1302,6 +2672,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
7. Return response
"""
source = event.source
+ session_source = self._session_source_for_event(event)
# Check if user is authorized
if not self._is_user_authorized(source):
@@ -1332,6 +2703,46 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
)
return None
+ # User-defined quick commands (bypass agent loop, no LLM call)
+ command = event.get_command()
+ if command:
+ if isinstance(self.config, dict):
+ quick_commands = self.config.get("quick_commands", {}) or {}
+ else:
+ quick_commands = getattr(self.config, "quick_commands", {}) or {}
+ if not isinstance(quick_commands, dict):
+ quick_commands = {}
+ if command in quick_commands:
+ qcmd = quick_commands[command]
+ if qcmd.get("type") == "exec":
+ exec_cmd = qcmd.get("command", "")
+ if exec_cmd:
+ try:
+ proc = await asyncio.create_subprocess_shell(
+ exec_cmd,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
+ output = (stdout or stderr).decode().strip()
+ return output if output else "Command returned no output."
+ except asyncio.TimeoutError:
+ return "Quick command timed out (30s)."
+ except Exception as e:
+ return f"Quick command error: {e}"
+ else:
+ return f"Quick command '/{command}' has no command defined."
+ elif qcmd.get("type") == "alias":
+ target = qcmd.get("target", "").strip()
+ if target:
+ target = target if target.startswith("/") else f"/{target}"
+ user_args = event.get_command_args().strip()
+ event.text = f"{target} {user_args}".strip()
+ else:
+ return f"Quick command '/{command}' has no target defined."
+ else:
+ return f"Quick command '/{command}' has unsupported type (supported: 'exec', 'alias')."
+
# PRIORITY handling when an agent is already running for this session.
# Default behavior is to interrupt immediately so user text/stop messages
# are handled with minimal latency.
@@ -1339,44 +2750,44 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
# Special case: Telegram/photo bursts often arrive as multiple near-
# simultaneous updates. Do NOT interrupt for photo-only follow-ups here;
# let the adapter-level batching/queueing logic absorb them.
- _quick_key = self._session_key_for_source(source)
+ _quick_key = self._session_key_for_source(session_source)
if _quick_key in self._running_agents:
- if event.get_command() == "status":
+ command_name = (event.get_command() or "").strip().lower()
+ running_agent = self._running_agents.get(_quick_key)
+ if command_name == "status":
return await self._handle_status_command(event)
-
+ if command_name == "stop" and running_agent is _AGENT_PENDING_SENTINEL:
+ return "⏳ The agent is still starting up — nothing to stop yet."
# /reset and /new must bypass the running-agent guard so they
# actually dispatch as commands instead of being queued as user
# text (which would be fed back to the agent with the same
- # broken history — #2170). Interrupt the agent first, then
+ # broken history — #2170). Interrupt the agent first, then
# clear the adapter's pending queue so the stale "/reset" text
# doesn't get re-processed as a user message after the
# interrupt completes.
from hermes_cli.commands import resolve_command as _resolve_cmd_inner
+
_evt_cmd = event.get_command()
_cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None
if _cmd_def_inner and _cmd_def_inner.name == "new":
- running_agent = self._running_agents.get(_quick_key)
if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
running_agent.interrupt("Session reset requested")
- # Clear any pending messages so the old text doesn't replay
adapter = self.adapters.get(source.platform)
- if adapter and hasattr(adapter, 'get_pending_message'):
- adapter.get_pending_message(_quick_key) # consume and discard
+ if adapter and hasattr(adapter, "get_pending_message"):
+ adapter.get_pending_message(_quick_key)
self._pending_messages.pop(_quick_key, None)
- # Clean up the running agent entry so the reset handler
- # doesn't think an agent is still active.
if _quick_key in self._running_agents:
del self._running_agents[_quick_key]
return await self._handle_reset_command(event)
- # /queue — queue without interrupting
- if event.get_command() in ("queue", "q"):
+ if _cmd_def_inner and _cmd_def_inner.name == "queue":
queued_text = event.get_command_args().strip()
if not queued_text:
return "Usage: /queue "
adapter = self.adapters.get(source.platform)
if adapter:
from gateway.platforms.base import MessageEvent as _ME, MessageType as _MT
+
queued_event = _ME(
text=queued_text,
message_type=_MT.TEXT,
@@ -1386,7 +2797,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
adapter._pending_messages[_quick_key] = queued_event
return "Queued for the next turn."
- if event.message_type == MessageType.PHOTO:
+ if command_name in self._control_commands_during_run():
+ pass
+ elif event.message_type == MessageType.PHOTO:
logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20])
adapter = self.adapters.get(source.platform)
if adapter:
@@ -1406,26 +2819,25 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
else:
adapter._pending_messages[_quick_key] = event
return None
-
- running_agent = self._running_agents.get(_quick_key)
- if running_agent is _AGENT_PENDING_SENTINEL:
- # Agent is being set up but not ready yet.
- if event.get_command() == "stop":
- # Nothing to interrupt — agent hasn't started yet.
- return "⏳ The agent is still starting up — nothing to stop yet."
- # Queue the message so it will be picked up after the
- # agent starts.
- adapter = self.adapters.get(source.platform)
- if adapter:
- adapter._pending_messages[_quick_key] = event
- return None
- logger.debug("PRIORITY interrupt for session %s", _quick_key[:20])
- running_agent.interrupt(event.text)
- if _quick_key in self._pending_messages:
- self._pending_messages[_quick_key] += "\n" + event.text
else:
- self._pending_messages[_quick_key] = event.text
- return None
+ if running_agent is _AGENT_PENDING_SENTINEL:
+ if command_name == "stop":
+ return "⏳ The agent is still starting up — nothing to stop yet."
+ adapter = self.adapters.get(source.platform)
+ if adapter:
+ adapter._pending_messages[_quick_key] = event
+ return None
+ logger.debug("PRIORITY interrupt for session %s", _quick_key[:20])
+ running_agent.interrupt(event.text)
+ if _quick_key in self._pending_messages:
+ self._pending_messages[_quick_key] += "\n" + event.text
+ else:
+ self._pending_messages[_quick_key] = event.text
+ return None
+
+ rewritten_bash = self._rewrite_bash_shortcut(event.text)
+ if rewritten_bash:
+ event.text = rewritten_bash
# Check for commands
command = event.get_command()
@@ -1434,8 +2846,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
# GATEWAY_KNOWN_COMMANDS is derived from the central COMMAND_REGISTRY
# in hermes_cli/commands.py — no hardcoded set to maintain here.
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command as _resolve_cmd
- if command and command in GATEWAY_KNOWN_COMMANDS:
- await self.hooks.emit(f"command:{command}", {
+ hooks = getattr(self, "hooks", None)
+ if command and command in GATEWAY_KNOWN_COMMANDS and hooks is not None:
+ await hooks.emit(f"command:{command}", {
"platform": source.platform.value if source.platform else "",
"user_id": source.user_id,
"command": command,
@@ -1451,19 +2864,64 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
if canonical == "help":
return await self._handle_help_command(event)
+
+ if canonical == "commands":
+ return await self._handle_commands_command(event)
+
+ if canonical == "context":
+ return await self._handle_context_command(event)
+
+ if canonical == "export-session":
+ return await self._handle_export_session_command(event)
+
+ if canonical == "whoami":
+ return await self._handle_whoami_command(event)
+
+ if canonical == "focus":
+ return await self._handle_focus_command(event)
+
+ if canonical == "unfocus":
+ return await self._handle_unfocus_command(event)
+
+ if canonical == "agents":
+ return await self._handle_agents_command(event)
+
+ if canonical == "session":
+ return await self._handle_session_command(event)
if canonical == "status":
return await self._handle_status_command(event)
-
+
+ if canonical == "approve":
+ return await self._handle_approve_command(event)
+
+ if canonical == "allowlist":
+ return await self._handle_allowlist_command(event)
+
+ if canonical == "config":
+ return await self._handle_config_command(event)
+
+ if canonical == "debug":
+ return await self._handle_debug_command(event)
+
if canonical == "stop":
return await self._handle_stop_command(event)
-
- if canonical == "model":
+
+ if canonical in {"model", "models"}:
return await self._handle_model_command(event)
if canonical == "reasoning":
return await self._handle_reasoning_command(event)
+ if canonical == "think":
+ return await self._handle_think_command(event)
+
+ if canonical == "send":
+ return await self._handle_send_command(event)
+
+ if canonical == "activation":
+ return await self._handle_activation_command(event)
+
if canonical == "provider":
return await self._handle_provider_command(event)
@@ -1501,6 +2959,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
if canonical == "sethome":
return await self._handle_set_home_command(event)
+ if canonical == "compact":
+ return await self._handle_compress_command(event)
+
if canonical == "compress":
return await self._handle_compress_command(event)
@@ -1519,9 +2980,31 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
if canonical == "deny":
return await self._handle_deny_command(event)
+ if canonical == "skill":
+ try:
+ _prepared, error = self._prepare_skill_command(event, task_id=_quick_key)
+ if error:
+ return error
+ canonical = None
+ except Exception as e:
+ logger.exception("Failed to prepare /skill command")
+ return f"Failed to invoke skill: {e}"
+
if canonical == "update":
return await self._handle_update_command(event)
+ if canonical == "restart":
+ return await self._handle_restart_command(event)
+
+ if canonical == "dock-telegram":
+ return await self._handle_dock_command(event, Platform.TELEGRAM)
+
+ if canonical == "dock-discord":
+ return await self._handle_dock_command(event, Platform.DISCORD)
+
+ if canonical == "dock-slack":
+ return await self._handle_dock_command(event, Platform.SLACK)
+
if canonical == "title":
return await self._handle_title_command(event)
@@ -1537,47 +3020,20 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
if canonical == "voice":
return await self._handle_voice_command(event)
- # User-defined quick commands (bypass agent loop, no LLM call)
- if command:
- if isinstance(self.config, dict):
- quick_commands = self.config.get("quick_commands", {}) or {}
- else:
- quick_commands = getattr(self.config, "quick_commands", {}) or {}
- if not isinstance(quick_commands, dict):
- quick_commands = {}
- if command in quick_commands:
- qcmd = quick_commands[command]
- if qcmd.get("type") == "exec":
- exec_cmd = qcmd.get("command", "")
- if exec_cmd:
- try:
- proc = await asyncio.create_subprocess_shell(
- exec_cmd,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
- output = (stdout or stderr).decode().strip()
- return output if output else "Command returned no output."
- except asyncio.TimeoutError:
- return "Quick command timed out (30s)."
- except Exception as e:
- return f"Quick command error: {e}"
- else:
- return f"Quick command '/{command}' has no command defined."
- elif qcmd.get("type") == "alias":
- target = qcmd.get("target", "").strip()
- if target:
- target = target if target.startswith("/") else f"/{target}"
- target_command = target.lstrip("/")
- user_args = event.get_command_args().strip()
- event.text = f"{target} {user_args}".strip()
- command = target_command
- # Fall through to normal command dispatch below
- else:
- return f"Quick command '/{command}' has no target defined."
- else:
- return f"Quick command '/{command}' has unsupported type (supported: 'exec', 'alias')."
+ if canonical == "subagents":
+ return await self._handle_subagents_command(event)
+
+ if canonical == "kill":
+ return await self._handle_kill_command(event)
+
+ if canonical == "steer":
+ return await self._handle_steer_command(event)
+
+ if canonical == "acp":
+ return await self._handle_acp_command(event)
+
+ if canonical == "bash":
+ return await self._handle_bash_command(event)
# Skill slash commands: /skill-name loads the skill and sends to agent
if command:
@@ -1631,8 +3087,9 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
session_entry.created_at == session_entry.updated_at
or getattr(session_entry, "was_auto_reset", False)
)
- if _is_new_session:
- await self.hooks.emit("session:start", {
+ hooks = getattr(self, "hooks", None)
+ if _is_new_session and hooks is not None:
+ await hooks.emit("session:start", {
"platform": source.platform.value if source.platform else "",
"user_id": source.user_id,
"session_id": session_entry.session_id,
@@ -2139,19 +3596,28 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
import time as _time
pending = pop_pending(session_key)
if pending:
- pending["timestamp"] = _time.time()
+ approval_id = str(pending.get("approval_id") or self._new_approval_id())
+ pending["approval_id"] = approval_id
+ pending["session_key"] = session_key
+ pending.setdefault("created_at", datetime.now().isoformat())
self._pending_approvals[session_key] = pending
- # Append structured instructions so the user knows how to respond
- cmd_preview = pending.get("command", "")
- if len(cmd_preview) > 200:
- cmd_preview = cmd_preview[:200] + "..."
- approval_hint = (
- f"\n\n⚠️ **Dangerous command requires approval:**\n"
- f"```\n{cmd_preview}\n```\n"
- f"Reply `/approve` to execute, `/approve session` to approve this pattern "
- f"for the session, or `/deny` to cancel."
+ approval_note = (
+ f"\n\nApproval ID: `{approval_id}`\n"
+ f"Use {self._approval_usage_text()} or the Discord approval buttons."
)
- response = (response or "") + approval_hint
+ response = f"{response}{approval_note}" if response else approval_note.strip()
+
+ if source.platform == Platform.DISCORD:
+ approval_adapter = self.adapters.get(Platform.DISCORD)
+ if approval_adapter and hasattr(approval_adapter, "send_exec_approval"):
+ try:
+ await approval_adapter.send_exec_approval(
+ source.chat_id,
+ pending.get("command", ""),
+ approval_id,
+ )
+ except Exception as approval_error:
+ logger.debug("Discord exec approval UI failed: %s", approval_error)
except Exception as e:
logger.debug("Failed to check pending approvals: %s", e)
@@ -2247,8 +3713,11 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
base_url=agent_result.get("base_url"),
)
- # Auto voice reply: send TTS audio before the text response
- if self._should_send_voice_reply(event, response, agent_messages):
+ dock_target = self._dock_target_for_session(session_key)
+
+ # Auto voice reply: send TTS audio before the text response.
+ # Skip when replies for this session are docked elsewhere.
+ if dock_target is None and self._should_send_voice_reply(event, response, agent_messages):
await self._send_voice_reply(event, response)
# If streaming already delivered the response, return None so
@@ -2256,6 +3725,13 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
if agent_result.get("already_sent"):
return None
+ if response:
+ response = await self._deliver_docked_response(
+ session_key=session_key,
+ response=response,
+ source=source,
+ )
+
return response
except Exception as e:
@@ -2356,6 +3832,12 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:
async def _handle_status_command(self, event: MessageEvent) -> str:
"""Handle /status command."""
+ if self._session_source_for_event(event).platform == Platform.DISCORD:
+ from gateway.platforms.discord_impl import runtime_views
+
+ snapshot = runtime_views.collect_discord_status_snapshot(self, event)
+ return runtime_views.render_discord_status(snapshot)
+
source = event.source
session_entry = self.session_store.get_or_create_session(source)
@@ -2378,6 +3860,865 @@ async def _handle_status_command(self, event: MessageEvent) -> str:
]
return "\n".join(lines)
+
+ async def _handle_approve_command(self, event: MessageEvent) -> str:
+ """Handle /approve command for pending exec approvals."""
+ raw_args = event.get_command_args().strip()
+ target_source = self._command_target_source_for_event(event)
+ if not raw_args:
+ return self._resolve_pending_approval(decision="allow-once", source=target_source)
+
+ tokens = raw_args.split()
+ approval_id: Optional[str] = None
+ decision: Optional[str] = None
+
+ if len(tokens) == 1:
+ maybe_decision = self._normalize_approval_decision(tokens[0])
+ if maybe_decision:
+ decision = maybe_decision
+ else:
+ approval_id = tokens[0]
+ else:
+ first_decision = self._normalize_approval_decision(tokens[0])
+ second_decision = self._normalize_approval_decision(tokens[1])
+ if first_decision and not second_decision:
+ decision = first_decision
+ approval_id = tokens[1]
+ else:
+ approval_id = tokens[0]
+ decision = second_decision or self._normalize_approval_decision(tokens[1])
+
+ if approval_id and decision is None:
+ return self._resolve_pending_approval(decision="show", approval_id=approval_id)
+ if decision is None:
+ return f"Use {self._approval_usage_text()}"
+
+ return self._resolve_pending_approval(
+ decision=decision,
+ approval_id=approval_id,
+ source=target_source,
+ )
+
+ async def _handle_allowlist_command(self, event: MessageEvent) -> str:
+ """Handle /allowlist command - inspect or edit command approval patterns."""
+ import tools.approval as approval_mod
+
+ raw_args = event.get_command_args().strip()
+ parts = self._split_command_args(raw_args, max_parts=2)
+ mode = (parts[0] if parts else "list").strip().lower()
+ entry = parts[1].strip() if len(parts) > 1 else ""
+
+ if mode not in {"list", "add", "remove"}:
+ return (
+ f"⚠️ Unknown allowlist action: `{mode}`\n\n"
+ "Use `/allowlist [list|add|remove] [entry]`"
+ )
+
+ patterns = set(approval_mod.load_permanent_allowlist())
+ if mode == "list":
+ if not patterns:
+ return "No permanently approved command patterns are stored."
+ lines = ["🛡️ **Command Allowlist**", ""]
+ for pattern in sorted(patterns):
+ lines.append(f"• `{pattern}`")
+ return "\n".join(lines)
+
+ if not entry:
+ return f"Usage: `/allowlist {mode} `"
+
+ if mode == "add":
+ patterns.add(entry)
+ approval_mod.load_permanent(patterns)
+ approval_mod.save_permanent_allowlist(patterns)
+ return f"✅ Added `{entry}` to the command allowlist."
+
+ if entry not in patterns:
+ return f"`{entry}` is not in the command allowlist."
+ patterns.remove(entry)
+ approval_mod._permanent_approved = set(patterns)
+ approval_mod.save_permanent_allowlist(patterns)
+ return f"🗑️ Removed `{entry}` from the command allowlist."
+
+ async def _handle_config_command(self, event: MessageEvent) -> str:
+ """Handle /config command - inspect or update on-disk Hermes config."""
+ raw_args = event.get_command_args().strip()
+ parts = self._split_command_args(raw_args, max_parts=3)
+ mode = (parts[0] if parts else "show").strip().lower()
+ key = parts[1].strip() if len(parts) > 1 else ""
+ value = parts[2].strip() if len(parts) > 2 else ""
+
+ if mode == "show":
+ if key:
+ try:
+ kind, resolved = read_config_or_env_value(key)
+ except KeyError:
+ return f"`{key}` is not set."
+ return f"📄 **Config Show** (`{kind}`)\n\n`{key}`\n{format_yaml_block(resolved)}"
+ raw_config = load_raw_user_config()
+ return (
+ "📄 **Hermes Config (raw user file)**\n\n"
+ f"{format_yaml_block(raw_config or {})}"
+ )
+
+ if mode == "get":
+ if not key:
+ return "Usage: `/config get `"
+ try:
+ kind, resolved = read_config_or_env_value(key)
+ except KeyError:
+ return f"`{key}` is not set."
+ return f"📄 **Config Value** (`{kind}`)\n\n`{key}`\n{format_yaml_block(resolved)}"
+
+ if mode == "set":
+ if not key or not value:
+ return "Usage: `/config set `"
+ kind, stored_value, path = write_config_or_env_value(key, value)
+ return (
+ f"✅ Updated `{key}` in `{path}` (`{kind}`)\n\n"
+ f"{format_yaml_block(stored_value)}"
+ )
+
+ if mode == "unset":
+ if not key:
+ return "Usage: `/config unset `"
+ kind, removed, path = unset_config_or_env_value(key)
+ if not removed:
+ return f"`{key}` is not set."
+ return f"🗑️ Removed `{key}` from `{path}` (`{kind}`)."
+
+ return (
+ f"⚠️ Unknown config action: `{mode}`\n\n"
+ "Use `/config [show|get|set|unset] [key] [value]`"
+ )
+
+ async def _handle_debug_command(self, event: MessageEvent) -> str:
+ """Handle /debug command - live runtime overrides for the current gateway process."""
+ args = event.get_command_args().strip()
+ try:
+ parts = shlex.split(args) if args else []
+ except ValueError as exc:
+ return f"⚠️ Invalid debug arguments: {exc}"
+
+ if not parts or parts[0] == "show":
+ if len(parts) > 2:
+ return "Usage: `/debug show [key]`"
+ if len(parts) == 2:
+ key = parts[1].strip()
+ if key not in self._DEBUG_SUPPORTED_KEYS:
+ supported = "\n".join(f"- `{name}`" for name in sorted(self._DEBUG_SUPPORTED_KEYS))
+ return (
+ f"⚠️ Unsupported debug key: `{key}`\n\n"
+ f"**Supported keys**\n{supported}"
+ )
+ overrides = self._get_runtime_debug_overrides()
+ value = self._get_effective_runtime_debug_value(key)
+ override_state = "yes" if key in overrides else "no"
+ return (
+ "🛠️ **Runtime Debug Key**\n\n"
+ f"**Key:** `{key}`\n"
+ f"**Override active:** {override_state}\n\n"
+ f"{self._format_runtime_debug_value(value)}"
+ )
+
+ overrides = self._get_runtime_debug_overrides()
+ active = {
+ key: self._get_effective_runtime_debug_value(key)
+ for key in sorted(overrides)
+ }
+ supported = "\n".join(
+ f"- `{name}` — {description}"
+ for name, description in self._DEBUG_SUPPORTED_KEYS.items()
+ )
+ active_block = "None" if not active else self._format_runtime_debug_value(active)
+ return (
+ "🛠️ **Runtime Debug Overrides**\n\n"
+ "Applies only to the running Hermes gateway process and the current Discord connection.\n"
+ "Config files are unchanged.\n\n"
+ "**Active overrides**\n"
+ f"{active_block}\n\n"
+ "**Usage**\n"
+ "`/debug show [key]`\n"
+ "`/debug set `\n"
+ "`/debug unset `\n"
+ "`/debug reset`\n\n"
+ f"**Supported keys**\n{supported}"
+ )
+
+ action = parts[0].lower()
+ if action == "reset":
+ if len(parts) != 1:
+ return "Usage: `/debug reset`"
+ cleared = len(self._get_runtime_debug_overrides())
+ self._get_runtime_debug_overrides().clear()
+ self._apply_runtime_debug_overrides()
+ return (
+ "🛠️ ✓ Runtime debug overrides cleared.\n"
+ f"Removed `{cleared}` override(s) from the running gateway process."
+ )
+
+ if action == "unset":
+ if len(parts) != 2:
+ return "Usage: `/debug unset `"
+ key = parts[1].strip()
+ if key not in self._DEBUG_SUPPORTED_KEYS:
+ return f"⚠️ Unsupported debug key: `{key}`"
+ removed = self._get_runtime_debug_overrides().pop(key, None)
+ self._apply_runtime_debug_overrides()
+ if removed is None:
+ return f"`{key}` did not have a runtime override."
+ return (
+ f"🛠️ ✓ Removed runtime override for `{key}`.\n\n"
+ f"{self._format_runtime_debug_value(self._get_effective_runtime_debug_value(key))}"
+ )
+
+ if action == "set":
+ if len(parts) < 3:
+ return "Usage: `/debug set `"
+ key = parts[1].strip()
+ if key not in self._DEBUG_SUPPORTED_KEYS:
+ return f"⚠️ Unsupported debug key: `{key}`"
+ value_text = args.split(None, 2)[2]
+ try:
+ parsed = self._parse_debug_value(key, value_text)
+ except ValueError as exc:
+ return f"⚠️ Invalid value for `{key}`: {exc}"
+ self._get_runtime_debug_overrides()[key] = parsed
+ self._apply_runtime_debug_overrides()
+ return (
+ f"🛠️ ✓ Runtime override set for `{key}`.\n"
+ "Applies immediately to the running gateway process only.\n\n"
+ f"{self._format_runtime_debug_value(self._get_effective_runtime_debug_value(key))}"
+ )
+
+ return (
+ f"⚠️ Unknown debug action: `{action}`\n\n"
+ "Use `/debug show [key]`, `/debug set `, `/debug unset `, or `/debug reset`."
+ )
+
+ def _build_manual_subagent_agent(
+ self,
+ *,
+ source: SessionSource,
+ session_key: str,
+ entry: dict[str, Any],
+ ):
+ from run_agent import AIAgent
+ from tools.delegate_tool import DEFAULT_TOOLSETS
+
+ model = _resolve_gateway_model()
+ runtime_kwargs = _resolve_runtime_agent_kwargs()
+ self._apply_runtime_debug_overrides()
+ turn_route = self._resolve_turn_agent_config(entry.get("goal", ""), model, runtime_kwargs)
+ platform_key = "cli" if source.platform == Platform.LOCAL else source.platform.value
+ agent = AIAgent(
+ model=turn_route["model"],
+ **turn_route["runtime"],
+ max_iterations=int(os.getenv("HERMES_MAX_ITERATIONS", "90")),
+ quiet_mode=True,
+ verbose_logging=False,
+ enabled_toolsets=DEFAULT_TOOLSETS,
+ reasoning_config=self._get_effective_reasoning_config(),
+ skip_context_files=True,
+ skip_memory=True,
+ session_id=f"{session_key}:{entry['id']}",
+ platform=platform_key,
+ )
+ agent._gateway_subagent_id = entry["id"]
+ agent._gateway_subagent_task_index = entry["ordinal"]
+ agent.tool_progress_callback = self._build_subagent_progress_callback(session_key, agent)
+ self._attach_subagent_runtime_hooks(agent, session_key)
+ entry["child"] = agent
+ entry["toolsets"] = list(DEFAULT_TOOLSETS)
+ entry["model"] = turn_route["model"]
+ return agent
+
+ async def _notify_manual_subagent_completion(
+ self,
+ *,
+ source: SessionSource,
+ entry: dict[str, Any],
+ ) -> None:
+ adapter = self.adapters.get(source.platform)
+ if not adapter:
+ return
+ metadata = {"thread_id": source.thread_id} if source.thread_id else None
+ status = str(entry.get("status") or "")
+ if status == "completed":
+ content = (
+ f"🤖 **Subagent Complete**\n\n"
+ f"**ID:** `{entry['id']}`\n"
+ f"**Goal:** {self._truncate_line(entry.get('goal') or '', limit=160)}\n\n"
+ f"{entry.get('summary') or '_(no summary)_'}"
+ )
+ else:
+ detail = entry.get("error") or entry.get("summary") or "Subagent stopped."
+ content = (
+ f"🤖 **Subagent {status.title() or 'Update'}**\n\n"
+ f"**ID:** `{entry['id']}`\n"
+ f"{self._truncate_line(detail, limit=500)}"
+ )
+ try:
+ await adapter.send(source.chat_id, content, metadata=metadata)
+ except Exception as e:
+ logger.debug("Manual subagent completion send failed: %s", e)
+
+ async def _start_manual_subagent(
+ self,
+ *,
+ source: SessionSource,
+ session_key: str,
+ goal: str,
+ ) -> dict[str, Any]:
+ entry = self._create_subagent_entry(
+ session_key=session_key,
+ child=None,
+ task_index=0,
+ goal=goal,
+ context=None,
+ toolsets=[],
+ model=None,
+ source_kind="manual",
+ )
+ try:
+ agent = self._build_manual_subagent_agent(source=source, session_key=session_key, entry=entry)
+ except Exception as exc:
+ entry["status"] = "error"
+ entry["error"] = str(exc)
+ self._append_subagent_log(entry, f"Failed to start: {exc}")
+ return entry
+
+ self._start_delegate_child(session_key=session_key, child=agent, goal=goal)
+ loop = asyncio.get_running_loop()
+
+ async def _runner():
+ current_goal = goal
+
+ def _run_sync():
+ nonlocal current_goal
+ while True:
+ result = agent.run_conversation(
+ user_message=current_goal,
+ task_id=f"{session_key}:{entry['id']}",
+ )
+ pending_steer = str(entry.get("pending_steer") or "").strip()
+ if result.get("interrupted") and pending_steer:
+ entry["pending_steer"] = None
+ current_goal = pending_steer
+ self._start_delegate_child(
+ session_key=session_key,
+ child=agent,
+ goal=current_goal,
+ restarted=True,
+ )
+ continue
+ return result, current_goal
+
+ result, final_goal = await loop.run_in_executor(None, _run_sync)
+ summary = str(result.get("final_response") or "").strip()
+ completed = bool(result.get("completed")) and bool(summary)
+ status = "interrupted" if result.get("interrupted") else "completed" if completed else "failed"
+ self._complete_delegate_child(
+ session_key=session_key,
+ child=agent,
+ entry={
+ "status": status,
+ "summary": summary,
+ "error": result.get("error") or "",
+ },
+ goal=final_goal,
+ )
+ await self._notify_manual_subagent_completion(source=source, entry=entry)
+
+ entry["async_task"] = asyncio.create_task(_runner())
+ return entry
+
+ async def _handle_subagents_command(self, event: MessageEvent) -> str:
+ target_source = self._command_target_source_for_event(event)
+ session_entry = self.session_store.get_or_create_session(target_source)
+ session_key = session_entry.session_key
+
+ raw_args = event.get_command_args().strip()
+ parts = self._split_command_args(raw_args, max_parts=2)
+ action = (parts[0].strip().lower() if parts else "list") or "list"
+ payload = parts[1] if len(parts) > 1 else ""
+
+ if action == "list":
+ return self._format_subagent_list(session_key)
+
+ if action == "info":
+ targets, error = self._resolve_subagent_targets(session_key, payload, active_only=False)
+ if error:
+ return error
+ return self._format_subagent_info(targets[0])
+
+ if action == "log":
+ targets, error = self._resolve_subagent_targets(session_key, payload, active_only=False)
+ if error:
+ return error
+ return self._format_subagent_log(targets[0])
+
+ if action == "spawn":
+ goal = str(payload or "").strip()
+ if not goal:
+ return "Usage: `/subagents spawn `"
+ entry = await self._start_manual_subagent(source=target_source, session_key=session_key, goal=goal)
+ if entry.get("status") == "error":
+ return f"❌ Failed to spawn subagent: {entry.get('error')}"
+ return (
+ f"🤖 Spawned subagent `#{entry['ordinal']}` / `{entry['id']}`.\n\n"
+ f"**Goal:** {goal}\n"
+ "Use `/subagents list`, `/subagents info `, or `/subagents log ` to inspect it."
+ )
+
+ if action == "kill":
+ target = payload.strip()
+ return await self._handle_kill_command(
+ MessageEvent(
+ text=f"/kill {target}".strip(),
+ message_type=event.message_type,
+ source=event.source,
+ raw_message=event.raw_message,
+ message_id=event.message_id,
+ media_urls=event.media_urls,
+ media_types=event.media_types,
+ reply_to_message_id=event.reply_to_message_id,
+ reply_to_text=event.reply_to_text,
+ timestamp=event.timestamp,
+ metadata=dict(event.metadata or {}),
+ )
+ )
+
+ if action in {"steer", "send"}:
+ if not payload.strip():
+ return f"Usage: `/subagents {action} `"
+ steer_parts = self._split_command_args(payload, max_parts=2)
+ if len(steer_parts) < 2:
+ return f"Usage: `/subagents {action} `"
+ target, message = steer_parts
+ steer_text = f"/steer {target} {message}"
+ return await self._handle_steer_command(
+ MessageEvent(
+ text=steer_text,
+ message_type=event.message_type,
+ source=event.source,
+ raw_message=event.raw_message,
+ message_id=event.message_id,
+ media_urls=event.media_urls,
+ media_types=event.media_types,
+ reply_to_message_id=event.reply_to_message_id,
+ reply_to_text=event.reply_to_text,
+ timestamp=event.timestamp,
+ metadata=dict(event.metadata or {}),
+ )
+ )
+
+ return (
+ "Usage: `/subagents list|info|log|spawn|kill|steer|send`\n"
+ "Examples:\n"
+ "`/subagents list`\n"
+ "`/subagents spawn research the failing tests`\n"
+ "`/subagents steer #1 focus on the Discord adapter only`"
+ )
+
+ async def _handle_kill_command(self, event: MessageEvent) -> str:
+ target_source = self._command_target_source_for_event(event)
+ session_entry = self.session_store.get_or_create_session(target_source)
+ session_key = session_entry.session_key
+ raw_target = event.get_command_args().strip()
+ targets, error = self._resolve_subagent_targets(
+ session_key,
+ raw_target,
+ active_only=True,
+ allow_all=True,
+ )
+ if error:
+ return error
+ if not targets:
+ return "No active subagents found for this session."
+ for entry in targets:
+ entry["pending_steer"] = None
+ child = entry.get("child")
+ if child is not None and hasattr(child, "interrupt"):
+ try:
+ child.interrupt("Killed from /kill")
+ except Exception as e:
+ logger.debug("Subagent kill failed for %s: %s", entry.get("id"), e)
+ self._append_subagent_log(entry, "Kill requested.")
+ if raw_target.strip().lower() == "all":
+ return f"🛑 Requested stop for `{len(targets)}` active subagent(s)."
+ return f"🛑 Requested stop for `{targets[0]['id']}`."
+
+ async def _handle_steer_command(self, event: MessageEvent) -> str:
+ target_source = self._command_target_source_for_event(event)
+ session_entry = self.session_store.get_or_create_session(target_source)
+ session_key = session_entry.session_key
+ parts = self._split_command_args(event.get_command_args().strip(), max_parts=2)
+ if len(parts) < 2:
+ return "Usage: `/steer `"
+ target, message = parts
+ targets, error = self._resolve_subagent_targets(session_key, target, active_only=True)
+ if error:
+ return error
+ entry = targets[0]
+ child = entry.get("child")
+ if child is None or not hasattr(child, "interrupt"):
+ return f"Subagent `{entry['id']}` is not running."
+ entry["pending_steer"] = message
+ self._append_subagent_log(entry, f"Steer requested: {self._truncate_line(message, limit=160)}")
+ child.interrupt(message)
+ return (
+ f"↪ Steering subagent `{entry['id']}`.\n\n"
+ f"**Next goal:** {message}\n"
+ "The current run will stop and restart on the steer message."
+ )
+
+ async def _run_acp_prompt(
+ self,
+ *,
+ source: SessionSource,
+ session_id: str,
+ prompt: str,
+ ) -> str:
+ manager = self._get_acp_session_manager()
+ state = manager.get_session(session_id)
+ if state is None:
+ return f"No ACP session found for `{session_id}`."
+ tasks = self._get_acp_tasks()
+ meta = self._get_acp_session_meta_entry(session_id)
+ if session_id in tasks and not tasks[session_id].done():
+ meta["pending_steer"] = prompt
+ meta["updated_at"] = self._subagent_now()
+ try:
+ state.cancel_event.set()
+ state.agent.interrupt(prompt)
+ except Exception:
+ logger.debug("ACP steer interrupt failed", exc_info=True)
+ return f"↪ Steering ACP session `{session_id}`."
+
+ loop = asyncio.get_running_loop()
+ meta["last_prompt"] = prompt
+ meta["status"] = "running"
+ meta["updated_at"] = self._subagent_now()
+
+ async def _runner():
+ current_prompt = prompt
+ while True:
+ if state.cancel_event:
+ state.cancel_event.clear()
+
+ def _sync_run():
+ return state.agent.run_conversation(
+ user_message=current_prompt,
+ conversation_history=state.history,
+ task_id=session_id,
+ )
+
+ result = await loop.run_in_executor(None, _sync_run)
+ state.history = result.get("messages") or state.history
+ pending_steer = str(meta.pop("pending_steer", "") or "").strip()
+ if result.get("interrupted") and pending_steer:
+ current_prompt = pending_steer
+ meta["last_prompt"] = current_prompt
+ meta["updated_at"] = self._subagent_now()
+ continue
+ meta["last_result"] = result
+ meta["status"] = "interrupted" if result.get("interrupted") else "completed" if result.get("completed") else "failed"
+ meta["updated_at"] = self._subagent_now()
+ adapter = self.adapters.get(source.platform)
+ if adapter and result.get("final_response"):
+ metadata = {"thread_id": source.thread_id} if source.thread_id else None
+ try:
+ await adapter.send(
+ source.chat_id,
+ f"🧩 **ACP Session `{session_id}`**\n\n{result.get('final_response')}",
+ metadata=metadata,
+ )
+ except Exception as e:
+ logger.debug("ACP completion send failed: %s", e)
+ break
+
+ tasks[session_id] = asyncio.create_task(_runner())
+ return f"🧩 ACP session `{session_id}` is running."
+
+ async def _handle_acp_command(self, event: MessageEvent) -> str:
+ target_source = self._command_target_source_for_event(event)
+ session_entry = self.session_store.get_or_create_session(target_source)
+ session_key = session_entry.session_key
+ args = event.get_command_args().strip()
+ parts = self._split_command_args(args, max_parts=2)
+ action = (parts[0].strip().lower() if parts else "sessions") or "sessions"
+ payload = parts[1] if len(parts) > 1 else ""
+ manager = self._get_acp_session_manager()
+ bindings = self._get_acp_bindings()
+
+ if action == "sessions":
+ return self._format_acp_sessions(session_key)
+
+ if action == "spawn":
+ cwd = str(payload or ".").strip() or "."
+ state = manager.create_session(cwd=cwd)
+ bindings[session_key] = state.session_id
+ meta = self._get_acp_session_meta_entry(state.session_id)
+ meta["source"] = target_source
+ meta["updated_at"] = self._subagent_now()
+ return (
+ f"🧩 Created ACP session `{state.session_id}`.\n\n"
+ f"**CWD:** `{cwd}`\n"
+ "This session is now the default ACP target for this chat."
+ )
+
+ if action == "doctor":
+ try:
+ import acp as _acp # noqa: F401
+ available = "yes"
+ except Exception:
+ available = "no"
+ bound = bindings.get(session_key) or "none"
+ running = sum(1 for task in self._get_acp_tasks().values() if not task.done())
+ return (
+ "🧩 **ACP Doctor**\n\n"
+ f"**Python ACP package:** `{available}`\n"
+ f"**Sessions:** `{len(manager.list_sessions())}`\n"
+ f"**Running:** `{running}`\n"
+ f"**Bound session for this chat:** `{bound}`"
+ )
+
+ if action == "install":
+ try:
+ import acp as _acp # noqa: F401
+ return "ACP support is already installed in this Hermes environment."
+ except Exception as exc:
+ return f"ACP tooling is not importable in this environment: {exc}"
+
+ if action == "status":
+ target_id, remainder = self._resolve_acp_target(session_key, payload)
+ if target_id:
+ return self._format_acp_status(target_id)
+ return self._format_acp_sessions(session_key)
+
+ target_id, remainder = self._resolve_acp_target(session_key, payload)
+ if not target_id:
+ return "No ACP session is bound for this chat. Use `/acp spawn [cwd]` first or pass an explicit session ID."
+ state = manager.get_session(target_id)
+ if state is None:
+ return f"No ACP session found for `{target_id}`."
+ meta = self._get_acp_session_meta_entry(target_id)
+
+ if action == "close":
+ task = self._get_acp_tasks().get(target_id)
+ if task and not task.done():
+ return f"ACP session `{target_id}` is still running. Use `/acp cancel {target_id}` first."
+ removed = manager.remove_session(target_id)
+ if removed:
+ for key, value in list(bindings.items()):
+ if value == target_id:
+ bindings.pop(key, None)
+ self._get_acp_meta().pop(target_id, None)
+ return f"🧩 Closed ACP session `{target_id}`."
+ return f"No ACP session found for `{target_id}`."
+
+ if action == "cancel":
+ task = self._get_acp_tasks().get(target_id)
+ if state.cancel_event:
+ state.cancel_event.set()
+ try:
+ state.agent.interrupt()
+ except Exception:
+ logger.debug("ACP cancel interrupt failed", exc_info=True)
+ if task and not task.done():
+ return f"🛑 Cancel requested for ACP session `{target_id}`."
+ return f"ACP session `{target_id}` was idle; cancel flag set."
+
+ if action == "cwd":
+ new_cwd = str(remainder or "").strip()
+ if not new_cwd:
+ return f"🧩 ACP session `{target_id}` cwd: `{state.cwd}`"
+ manager.update_cwd(target_id, new_cwd)
+ return f"🧩 ACP session `{target_id}` cwd set to `{new_cwd}`."
+
+ if action == "model":
+ new_model = str(remainder or "").strip()
+ if not new_model:
+ return f"🧩 ACP session `{target_id}` model: `{state.model or getattr(state.agent, 'model', '') or 'default'}`"
+ state.model = new_model
+ try:
+ state.agent.model = new_model
+ except Exception:
+ logger.debug("Failed to update ACP agent model", exc_info=True)
+ meta["updated_at"] = self._subagent_now()
+ return f"🧩 ACP session `{target_id}` model set to `{new_model}`."
+
+ if action == "set-mode":
+ mode = str(remainder or "").strip()
+ if not mode:
+ return "Usage: `/acp set-mode [session_id] `"
+ meta["options"]["mode"] = mode
+ meta["updated_at"] = self._subagent_now()
+ return f"🧩 ACP session `{target_id}` mode set to `{mode}`."
+
+ if action == "permissions":
+ perms = str(remainder or "").strip()
+ if not perms:
+ current = meta["options"].get("permissions", "default")
+ return f"🧩 ACP session `{target_id}` permissions: `{current}`"
+ meta["options"]["permissions"] = perms
+ meta["updated_at"] = self._subagent_now()
+ return f"🧩 ACP session `{target_id}` permissions set to `{perms}`."
+
+ if action == "timeout":
+ raw_timeout = str(remainder or "").strip()
+ if not raw_timeout:
+ current = meta["options"].get("timeout", "default")
+ return f"🧩 ACP session `{target_id}` timeout: `{current}`"
+ try:
+ timeout_value = int(raw_timeout)
+ except ValueError:
+ return f"Invalid timeout `{raw_timeout}`. Expected an integer number of seconds."
+ meta["options"]["timeout"] = timeout_value
+ meta["updated_at"] = self._subagent_now()
+ return f"🧩 ACP session `{target_id}` timeout set to `{timeout_value}`s."
+
+ if action == "set":
+ kv = self._split_command_args(remainder, max_parts=2)
+ if len(kv) < 2:
+ return "Usage: `/acp set [session_id] `"
+ key, value = kv
+ meta["options"][key] = value
+ meta["updated_at"] = self._subagent_now()
+ return f"🧩 ACP session `{target_id}` option `{key}` set to `{value}`."
+
+ if action == "reset-options":
+ cleared = len(meta["options"])
+ meta["options"] = {}
+ meta["updated_at"] = self._subagent_now()
+ return f"🧩 ACP session `{target_id}` reset `{cleared}` option(s)."
+
+ if action == "steer":
+ prompt = str(remainder or "").strip()
+ if not prompt:
+ return "Usage: `/acp steer [session_id] `"
+ return await self._run_acp_prompt(source=target_source, session_id=target_id, prompt=prompt)
+
+ return (
+ "Usage: `/acp sessions|spawn|status|cancel|close|steer|cwd|model|set-mode|set|permissions|timeout|reset-options|doctor|install`\n"
+ "Examples:\n"
+ "`/acp spawn /home/alan/projects/hermes-agent`\n"
+ "`/acp steer write a quick status report`\n"
+ "`/acp status`"
+ )
+
+ async def _handle_bash_command(self, event: MessageEvent) -> str:
+ """Handle /bash command and !-style shortcuts for host shell execution."""
+ from tools.process_registry import process_registry
+
+ target_source = self._command_target_source_for_event(event)
+ session_entry = self.session_store.get_or_create_session(target_source)
+ session_key = session_entry.session_key
+
+ raw_args = event.get_command_args().strip()
+ parts = self._split_command_args(raw_args, max_parts=2)
+ if not parts:
+ return (
+ "Usage: `/bash `\n"
+ " `/bash poll [session_id]`\n"
+ " `/bash stop [session_id]`\n\n"
+ "Shortcuts: `! `, `!poll`, `!stop`"
+ )
+
+ action = parts[0].strip().lower()
+ if action == "poll":
+ target_id = self._resolve_bash_target_session_id(session_key, parts[1] if len(parts) > 1 else "")
+ if not target_id:
+ return "No active bash job found for this session."
+ poll = process_registry.poll(target_id)
+ if poll.get("status") == "not_found":
+ self._get_bash_jobs().pop(session_key, None)
+ return f"No bash job found for `{target_id}`."
+ log_payload = process_registry.read_log(target_id, limit=40)
+ if poll.get("status") == "exited":
+ self._get_bash_jobs().pop(session_key, None)
+ return "\n".join(
+ [
+ "💻 **Bash Complete**",
+ "",
+ f"**Session ID:** `{target_id}`",
+ f"**Exit Code:** `{poll.get('exit_code')}`",
+ "",
+ self._format_bash_output(log_payload.get("output", "")),
+ ]
+ )
+ return "\n".join(
+ [
+ "💻 **Bash Status**",
+ "",
+ f"**Session ID:** `{target_id}`",
+ f"**PID:** `{poll.get('pid')}`" if poll.get("pid") else "**PID:** `unknown`",
+ f"**Uptime:** `{poll.get('uptime_seconds', 0)}s`",
+ "",
+ self._format_bash_output(log_payload.get("output", "")),
+ ]
+ )
+
+ if action == "stop":
+ target_id = self._resolve_bash_target_session_id(session_key, parts[1] if len(parts) > 1 else "")
+ if not target_id:
+ return "No active bash job found for this session."
+ result = process_registry.kill_process(target_id)
+ if self._get_bash_jobs().get(session_key) == target_id:
+ self._get_bash_jobs().pop(session_key, None)
+ if result.get("status") in {"killed", "already_exited"}:
+ return f"🛑 Bash job `{target_id}` stopped."
+ return f"❌ Failed to stop bash job `{target_id}`: {result.get('error') or result.get('status')}"
+
+ current_job_id = self._resolve_bash_target_session_id(session_key, "")
+ if current_job_id:
+ return (
+ "A bash job is already running for this session.\n\n"
+ f"**Session ID:** `{current_job_id}`\n"
+ "Use `/bash poll` to inspect it or `/bash stop` to terminate it first."
+ )
+
+ command = raw_args
+ result = self._run_bash_process(
+ source=target_source,
+ session_key=session_key,
+ command=command,
+ force=False,
+ )
+
+ if str(result.get("status") or "") == "approval_required":
+ approval_id = self._store_pending_bash_approval(
+ session_key=session_key,
+ command=command,
+ approval_payload=result,
+ on_approve=lambda _decision: self._resume_bash_after_approval(
+ source=target_source,
+ session_key=session_key,
+ command=command,
+ ),
+ )
+ if target_source.platform == Platform.DISCORD:
+ approval_adapter = self.adapters.get(Platform.DISCORD)
+ if approval_adapter and hasattr(approval_adapter, "send_exec_approval"):
+ try:
+ await approval_adapter.send_exec_approval(
+ target_source.chat_id,
+ command,
+ approval_id,
+ )
+ except Exception as approval_error:
+ logger.debug("Discord exec approval UI failed for /bash: %s", approval_error)
+ return (
+ f"⚠️ Bash command requires approval (`{approval_id}`).\n\n"
+ f"```\n{command}\n```\n\n"
+ f"Use {self._approval_usage_text()} or the Discord approval buttons."
+ )
+
+ return self._format_bash_result(
+ session_key=session_key,
+ command=command,
+ result=result,
+ )
async def _handle_stop_command(self, event: MessageEvent) -> str:
"""Handle /stop command - interrupt a running agent."""
@@ -2396,6 +4737,11 @@ async def _handle_stop_command(self, event: MessageEvent) -> str:
async def _handle_help_command(self, event: MessageEvent) -> str:
"""Handle /help command - list available commands."""
+ if self._session_source_for_event(event).platform == Platform.DISCORD:
+ from gateway.platforms.discord_impl import runtime_views
+
+ return runtime_views.render_discord_help()
+
from hermes_cli.commands import gateway_help_lines
lines = [
"📖 **Hermes Commands**\n",
@@ -2411,124 +4757,569 @@ async def _handle_help_command(self, event: MessageEvent) -> str:
except Exception:
pass
return "\n".join(lines)
-
- async def _handle_model_command(self, event: MessageEvent) -> str:
- """Handle /model command - show or change the current model."""
- import yaml
- from hermes_cli.models import (
- parse_model_input,
- validate_requested_model,
- curated_models_for_provider,
- normalize_provider,
- _PROVIDER_LABELS,
+
+ async def _handle_commands_command(self, event: MessageEvent) -> str:
+ """Handle /commands command - show the full gateway command catalog."""
+ if self._session_source_for_event(event).platform == Platform.DISCORD:
+ from gateway.platforms.discord_impl import runtime_views
+
+ return runtime_views.render_discord_commands()
+
+ from hermes_cli.commands import gateway_help_lines
+
+ return "\n".join([
+ "🧭 **Hermes Command Catalog**",
+ "",
+ *gateway_help_lines(),
+ ])
+
+ async def _handle_context_command(self, event: MessageEvent) -> str:
+ """Handle /context command - inspect the current transcript and prompt context."""
+ mode = (event.get_command_args().strip() or "list").lower()
+ if mode not in {"list", "detail", "json"}:
+ return (
+ f"⚠️ Unknown context mode: `{mode}`\n\n"
+ "Use `/context [list|detail|json]`"
+ )
+
+ target_source = self._command_target_source_for_event(event)
+ session_entry = self.session_store.get_or_create_session(target_source)
+ history = self.session_store.load_transcript(session_entry.session_id)
+
+ if self._session_source_for_event(event).platform == Platform.DISCORD:
+ from gateway.platforms.discord_impl import runtime_views
+
+ snapshot = runtime_views.collect_discord_context_snapshot(self, event)
+ return runtime_views.render_discord_context(snapshot, mode)
+
+ role_counts: dict[str, int] = {}
+ for item in history:
+ role = str(item.get("role") or "unknown")
+ role_counts[role] = role_counts.get(role, 0) + 1
+ lines = [
+ "🧠 **Hermes Context**",
+ "",
+ f"• Session ID: `{session_entry.session_id}`",
+ f"• Session Key: `{session_entry.session_key}`",
+ f"• Messages: {len(history)}",
+ f"• Roles: {', '.join(f'{k}={v}' for k, v in sorted(role_counts.items())) or 'none'}",
+ ]
+ if mode == "detail":
+ preview = history[-5:]
+ lines.extend(["", "```json", json.dumps(preview, indent=2, ensure_ascii=False), "```"])
+ elif mode == "json":
+ payload = {
+ "session_id": session_entry.session_id,
+ "session_key": session_entry.session_key,
+ "message_count": len(history),
+ "role_counts": role_counts,
+ }
+ return f"```json\n{json.dumps(payload, indent=2, ensure_ascii=False)}\n```"
+ return "\n".join(lines)
+
+ async def _handle_export_session_command(self, event: MessageEvent) -> str:
+ """Handle /export-session command - write a session export to disk."""
+ target_source = self._command_target_source_for_event(event)
+ session_entry = self.session_store.get_or_create_session(target_source)
+ history = self.session_store.load_transcript(session_entry.session_id)
+ from gateway.platforms.discord_impl import runtime_views
+
+ snapshot = runtime_views.collect_discord_context_snapshot(self, event)
+ export_payload = {
+ "session": {
+ "session_id": session_entry.session_id,
+ "session_key": session_entry.session_key,
+ "source": target_source.to_dict(),
+ "current_model": snapshot.current_model,
+ "current_provider": snapshot.current_provider,
+ "connected_platforms": list(snapshot.connected_platforms),
+ },
+ "context_prompt": snapshot.context_prompt,
+ "messages": history,
+ }
+ cwd = os.environ.get("TERMINAL_CWD") or os.environ.get("MESSAGING_CWD") or os.getcwd()
+ raw_path = event.get_command_args().strip()
+ export_path = resolve_export_path(raw_path, cwd, session_id=session_entry.session_id)
+ if export_path.suffix.lower() == ".json":
+ export_path.write_text(json.dumps(export_payload, indent=2, ensure_ascii=False), encoding="utf-8")
+ mode = "JSON"
+ else:
+ export_path.write_text(build_session_export_html(export_payload), encoding="utf-8")
+ mode = "HTML"
+ return (
+ f"📦 Exported the current session to `{export_path}`\n\n"
+ f"• Format: {mode}\n"
+ f"• Messages: {len(history)}\n"
+ f"• Session ID: `{session_entry.session_id}`"
)
- args = event.get_command_args().strip()
- config_path = _hermes_home / 'config.yaml'
+ async def _handle_whoami_command(self, event: MessageEvent) -> str:
+ """Handle /whoami command - show the sender and routing identity Hermes sees."""
+ if self._session_source_for_event(event).platform == Platform.DISCORD:
+ from gateway.platforms.discord_impl import runtime_views
+
+ snapshot = runtime_views.collect_discord_whoami_snapshot(self, event)
+ return runtime_views.render_discord_whoami(snapshot)
+
+ source = event.source
+ lines = [
+ "👤 **Hermes Sees You As**",
+ "",
+ f"**Platform:** {source.platform.value if source.platform else 'unknown'}",
+ f"**User ID:** `{source.user_id or 'unknown'}`",
+ f"**User Name:** {source.user_name or 'unknown'}",
+ f"**Chat ID:** `{source.chat_id}`",
+ f"**Chat Type:** {source.chat_type}",
+ ]
+ if source.chat_name:
+ lines.append(f"**Chat Name:** {source.chat_name}")
+ if source.thread_id:
+ lines.append(f"**Thread ID:** `{source.thread_id}`")
+ if source.chat_topic:
+ lines.append(f"**Chat Topic:** {source.chat_topic}")
+ return "\n".join(lines)
+
+ def _resolve_discord_thread_target(
+ self,
+ event: MessageEvent,
+ ) -> tuple[Optional[Any], Optional[SessionSource], Optional[str], Optional[str]]:
+ """Return Discord adapter, target source, thread id, and parent channel id."""
+ target_source = self._command_target_source_for_event(event)
+ if target_source.platform != Platform.DISCORD:
+ return None, None, None, None
+
+ adapter = self._get_discord_adapter()
+ if adapter is None:
+ return None, None, None, None
+
+ thread_id = str(target_source.thread_id or "").strip()
+ if not thread_id:
+ return adapter, target_source, None, None
+
+ raw = getattr(event, "raw_message", None)
+ channel = getattr(raw, "channel", None)
+ parent_chat_id = None
+ if channel is not None and hasattr(adapter, "_get_parent_channel_id"):
+ parent_chat_id = adapter._get_parent_channel_id(channel)
+ return adapter, target_source, thread_id, parent_chat_id
+
+ def _render_thread_binding_status(self, binding: Any) -> str:
+ idle_label = self._format_minutes_label(int(binding.idle_timeout_minutes or 0))
+ max_age_label = self._format_minutes_label(int(binding.max_age_minutes or 0))
+ lines = [
+ "🧵 **Focused Thread**",
+ "",
+ f"• Thread: `{binding.thread_id}`",
+ f"• Session: `{binding.session_key}`",
+ f"• Label: {binding.chat_name or binding.thread_id}",
+ f"• Bound By: {binding.bound_by or 'unknown'}",
+ f"• Idle Auto-Unfocus: `{idle_label}`",
+ f"• Max Age: `{max_age_label}`",
+ ]
+ if binding.parent_chat_id:
+ lines.append(f"• Parent Channel: `{binding.parent_chat_id}`")
+ return "\n".join(lines)
+
+ async def _handle_focus_command(self, event: MessageEvent) -> str:
+ """Handle /focus for Discord thread binding."""
+ adapter, target_source, thread_id, parent_chat_id = self._resolve_discord_thread_target(event)
+ if adapter is None or target_source is None:
+ return "This command is only available on Discord."
+ if not thread_id:
+ return "Use `/focus` inside a Discord thread."
+
+ session_key = self._session_key_for_source(target_source)
+ label = event.get_command_args().strip() or (target_source.chat_name or thread_id)
+ binding = adapter.focus_thread_binding(
+ thread_id=thread_id,
+ session_key=session_key,
+ chat_name=label,
+ parent_chat_id=parent_chat_id,
+ bound_by=target_source.user_name or target_source.user_id or "unknown",
+ )
+ return (
+ "🧵 Thread focused.\n\n"
+ f"{self._render_thread_binding_status(binding)}"
+ )
+
+ async def _handle_unfocus_command(self, event: MessageEvent) -> str:
+ """Handle /unfocus for Discord thread binding removal."""
+ adapter, _target_source, thread_id, _parent_chat_id = self._resolve_discord_thread_target(event)
+ if adapter is None:
+ return "This command is only available on Discord."
+ if not thread_id:
+ return "Use `/unfocus` inside a focused Discord thread."
+
+ binding = adapter.unfocus_thread_binding(thread_id)
+ if binding is None:
+ return "No focused thread binding exists here."
+ return "🧵 Thread unfocused. Messages here now require normal Discord activation again."
+
+ async def _handle_agents_command(self, event: MessageEvent) -> str:
+ """Handle /agents for Discord thread-binding inspection."""
+ adapter, target_source, thread_id, parent_chat_id = self._resolve_discord_thread_target(event)
+ if adapter is None or target_source is None:
+ return "This command is only available on Discord."
+
+ if thread_id:
+ binding = adapter.get_thread_binding(thread_id)
+ if binding is None:
+ return "No focused thread binding exists for this thread."
+ is_running = binding.session_key in getattr(self, "_running_agents", {})
+ return "\n".join(
+ [
+ "🧵 **Thread-Bound Hermes Sessions**",
+ "",
+ f"• Target: `{binding.session_key}`",
+ f"• Label: {binding.chat_name or binding.thread_id}",
+ f"• Running: {'yes' if is_running else 'no'}",
+ f"• Idle Auto-Unfocus: `{self._format_minutes_label(int(binding.idle_timeout_minutes or 0))}`",
+ f"• Max Age: `{self._format_minutes_label(int(binding.max_age_minutes or 0))}`",
+ ]
+ )
+
+ bindings = adapter.list_thread_bindings(parent_chat_id=parent_chat_id or target_source.chat_id)
+ if not bindings:
+ return "No focused Discord thread bindings were found for this chat."
+ lines = ["🧵 **Focused Discord Threads**", ""]
+ for binding in bindings:
+ running = "yes" if binding.session_key in getattr(self, "_running_agents", {}) else "no"
+ lines.append(
+ f"• `{binding.thread_id}` — {binding.chat_name or binding.thread_id} "
+ f"(running: {running}, idle: {self._format_minutes_label(int(binding.idle_timeout_minutes or 0))}, "
+ f"max-age: {self._format_minutes_label(int(binding.max_age_minutes or 0))})"
+ )
+ return "\n".join(lines)
+
+ async def _handle_session_command(self, event: MessageEvent) -> str:
+ """Handle /session idle|max-age controls for focused Discord threads."""
+ adapter, _target_source, thread_id, _parent_chat_id = self._resolve_discord_thread_target(event)
+ if adapter is None:
+ return "This command is only available on Discord."
+ if not thread_id:
+ return "Use `/session` inside a focused Discord thread."
+
+ binding = adapter.get_thread_binding(thread_id)
+ if binding is None:
+ return "No focused thread binding exists here. Use `/focus` first."
+
+ tokens = [token for token in event.get_command_args().strip().split() if token]
+ if not tokens or tokens[0].lower() == "status":
+ return self._render_thread_binding_status(binding)
+ if len(tokens) < 2:
+ return "Use `/session idle ` or `/session max-age `."
+
+ mode = tokens[0].strip().lower()
+ try:
+ minutes = self._parse_duration_minutes(tokens[1])
+ except ValueError as exc:
+ return f"⚠️ {exc}"
+ if minutes is None:
+ return "Use `/session idle ` or `/session max-age `."
+
+ if mode == "idle":
+ binding = adapter.update_thread_binding_limits(thread_id, idle_timeout_minutes=minutes)
+ elif mode in {"max-age", "max_age", "maxage"}:
+ binding = adapter.update_thread_binding_limits(thread_id, max_age_minutes=minutes)
+ mode = "max-age"
+ else:
+ return "Use `/session idle ` or `/session max-age `."
+
+ if binding is None:
+ return "No focused thread binding exists here. Use `/focus` first."
+ return (
+ f"🧵 Updated thread session `{mode}` to `{self._format_minutes_label(minutes)}`.\n\n"
+ f"{self._render_thread_binding_status(binding)}"
+ )
+
+ async def _handle_send_command(self, event: MessageEvent) -> str:
+ """Handle /send on|off|inherit for the current session."""
+ target_source = self._command_target_source_for_event(event)
+ session_key = self._session_key_for_source(target_source)
+ args = event.get_command_args().strip().lower()
+ current = self._session_send_policy(session_key)
+ if not args:
+ return f"📮 Current send policy for this session: `{current}`"
+ if args not in {"on", "off", "inherit"}:
+ return "Use `/send on`, `/send off`, or `/send inherit`."
+
+ if not isinstance(getattr(self, "_session_send_policies", None), dict):
+ self._session_send_policies = {}
+ self._session_send_policies[session_key] = args
+ if args == "inherit":
+ self._session_send_policies.pop(session_key, None)
+ self._save_runtime_controls()
+ return f"📮 Send policy for this session is now `{args}`."
+
+ async def _handle_activation_command(self, event: MessageEvent) -> str:
+ """Handle /activation mention|always for Discord chats."""
+ target_source = self._command_target_source_for_event(event)
+ if target_source.platform != Platform.DISCORD or target_source.chat_type == "dm":
+ return "This command only works in Discord servers and threads."
+
+ adapter = self._get_discord_adapter()
+ if adapter is None:
+ return "Discord is not connected."
+
+ args = event.get_command_args().strip().lower()
+ current = adapter.get_activation_mode(target_source.chat_id) or (
+ "mention" if adapter._get_discord_policy().require_mention else "always"
+ )
+ if not args:
+ return f"🎛️ Current activation mode for this chat: `{current}`"
+ if args not in {"mention", "always"}:
+ return "Use `/activation mention` or `/activation always`."
+
+ adapter.set_activation_mode(target_source.chat_id, args)
+ return f"🎛️ Activation mode for this chat is now `{args}`."
+
+ async def _handle_restart_command(self, event: MessageEvent) -> str:
+ """Handle /restart by scheduling a delayed gateway restart."""
+ del event
+ self._schedule_gateway_restart()
+ return "♻️ Gateway restart scheduled. Hermes will reconnect shortly."
+
+ async def _handle_dock_command(self, event: MessageEvent, platform: Platform) -> str:
+ """Handle /dock-* by routing future replies for this session to a home channel."""
+ target_source = self._command_target_source_for_event(event)
+ session_key = self._session_key_for_source(target_source)
+ home = self.config.get_home_channel(platform)
+ if home is None:
+ return f"No home channel is configured for `{platform.value}`."
+
+ if not isinstance(getattr(self, "_session_docks", None), dict):
+ self._session_docks = {}
+ self._session_docks[session_key] = {
+ "platform": platform.value,
+ "chat_id": home.chat_id,
+ "thread_id": None,
+ "name": home.name,
+ }
+ self._save_runtime_controls()
+ return f"📮 Replies for this session are now docked to {platform.value} home channel **{home.name}** (`{home.chat_id}`)."
+
+ def _load_current_model_selection(self) -> dict[str, Any]:
+ """Resolve the configured model/provider from config plus runtime env."""
+ import yaml
+ from hermes_cli.models import normalize_provider
- # Resolve current model and provider from config
- current = os.getenv("HERMES_MODEL") or "anthropic/claude-opus-4.6"
+ config_path = _hermes_home / "config.yaml"
+ current_model = os.getenv("HERMES_MODEL") or "anthropic/claude-opus-4.6"
current_provider = "openrouter"
+
try:
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, str):
- current = model_cfg
+ current_model = model_cfg
elif isinstance(model_cfg, dict):
- current = model_cfg.get("default", current)
+ current_model = model_cfg.get("default", current_model)
current_provider = model_cfg.get("provider", current_provider)
except Exception:
pass
- # Resolve "auto" to the actual provider using credential detection
current_provider = normalize_provider(current_provider)
if current_provider == "auto":
try:
from hermes_cli.auth import resolve_provider as _resolve_provider
+
current_provider = _resolve_provider(current_provider)
except Exception:
current_provider = "openrouter"
- # Detect custom endpoint: provider resolved to openrouter but a custom
- # base URL is configured — the user set up a custom endpoint.
if current_provider == "openrouter" and os.getenv("OPENAI_BASE_URL", "").strip():
current_provider = "custom"
- if not args:
- # If a fallback model is active, show it instead of config
- if self._effective_model:
- eff_provider = self._effective_provider or 'unknown'
- eff_label = _PROVIDER_LABELS.get(eff_provider, eff_provider)
- cfg_label = _PROVIDER_LABELS.get(current_provider, current_provider)
- lines = [
- f"🤖 **Active model:** `{self._effective_model}` (fallback)",
- f"**Provider:** {eff_label}",
- f"**Primary model** (`{current}` via {cfg_label}) is rate-limited.",
+ return {
+ "config_path": config_path,
+ "current_model": current_model,
+ "current_provider": current_provider,
+ }
+
+ def _resolve_model_runtime_details(self, requested_provider: str) -> tuple[dict[str, Any], Optional[str]]:
+ """Return runtime provider details for the active model selection."""
+ try:
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+
+ runtime = resolve_runtime_provider(requested=requested_provider)
+ return runtime, None
+ except Exception as exc:
+ return {}, str(exc)
+
+ async def _respond_to_native_slash(
+ self,
+ event: MessageEvent,
+ content: str,
+ ) -> bool:
+ """Send an ephemeral response for Discord native slash commands."""
+ interaction = getattr(event, "raw_message", None)
+ if interaction is None:
+ return False
+
+ response = getattr(interaction, "response", None)
+ if response is not None and hasattr(response, "send_message"):
+ is_done = getattr(response, "is_done", None)
+ if not callable(is_done) or not is_done():
+ await response.send_message(content, ephemeral=True)
+ return True
+
+ followup = getattr(interaction, "followup", None)
+ if followup is not None and hasattr(followup, "send"):
+ await followup.send(content, ephemeral=True)
+ return True
+ return False
+
+ def _is_native_discord_slash(self, event: MessageEvent) -> bool:
+ metadata = getattr(event, "metadata", None) or {}
+ return bool(
+ event.source.platform == Platform.DISCORD
+ and isinstance(metadata, dict)
+ and metadata.get("is_native_slash")
+ )
+
+ def _render_model_catalog_response(self) -> str:
+ """Render the current model plus numbered catalog for the active provider."""
+ from hermes_cli.models import curated_models_for_provider, provider_label
+
+ state = self._load_current_model_selection()
+ current_model = state["current_model"]
+ current_provider = state["current_provider"]
+ provider_name = provider_label(current_provider)
+ lines = [
+ "🤖 **Model Catalog**",
+ "",
+ f"**Current model:** `{current_model}`",
+ f"**Provider:** {provider_name} (`{current_provider}`)",
+ ]
+ if self._effective_model:
+ lines.extend(
+ [
+ f"**Active fallback:** `{self._effective_model}`",
+ f"**Fallback provider:** {provider_label(self._effective_provider or 'unknown')}",
+ ]
+ )
+ lines.append("")
+
+ curated = curated_models_for_provider(current_provider)
+ if curated:
+ lines.append(f"**Available models ({provider_name}):**")
+ for index, (model_id, desc) in enumerate(curated, start=1):
+ marker = " ← current" if model_id == current_model else ""
+ detail = f" — {desc}" if desc else ""
+ lines.append(f"{index}. `{model_id}`{detail}{marker}")
+ lines.append("")
+ else:
+ lines.extend(
+ [
+ f"No curated model list is available for `{current_provider}` right now.",
"",
]
- lines.append("To change: `/model model-name`")
- lines.append("Switch provider: `/model provider:model-name`")
- return "\n".join(lines)
+ )
- provider_label = _PROVIDER_LABELS.get(current_provider, current_provider)
- lines = [
- f"🤖 **Current model:** `{current}`",
- f"**Provider:** {provider_label}",
+ lines.extend(
+ [
+ "Use `/model ` to choose by index.",
+ "Use `/models` on Discord to open the interactive picker.",
+ "Use `/model provider:model-name` to switch providers.",
+ "Use `/model status` for runtime endpoint and auth details.",
]
- # Show custom endpoint URL when using a custom provider
- if current_provider == "custom":
- from hermes_cli.models import _get_custom_base_url
- custom_url = _get_custom_base_url() or os.getenv("OPENAI_BASE_URL", "")
- if custom_url:
- lines.append(f"**Endpoint:** `{custom_url}`")
- lines.append("")
- curated = curated_models_for_provider(current_provider)
- if curated:
- lines.append(f"**Available models ({provider_label}):**")
- for mid, desc in curated:
- marker = " ←" if mid == current else ""
- label = f" _{desc}_" if desc else ""
- lines.append(f"• `{mid}`{label}{marker}")
- lines.append("")
- lines.append("To change: `/model model-name`")
- lines.append("Switch provider: `/model provider-name` or `/model provider:model-name`")
+ )
+ return "\n".join(lines)
+
+ def _render_model_status_response(self) -> str:
+ """Render detailed model/provider/runtime status."""
+ from hermes_cli.models import provider_label
+
+ state = self._load_current_model_selection()
+ current_model = state["current_model"]
+ current_provider = state["current_provider"]
+ runtime, runtime_error = self._resolve_model_runtime_details(current_provider)
+ runtime_provider = str(runtime.get("provider") or current_provider)
+ runtime_label = provider_label(runtime_provider)
+ api_key = str(runtime.get("api_key") or "").strip()
+ command = runtime.get("command")
+ lines = [
+ "🤖 **Model Status**",
+ "",
+ f"**Configured model:** `{current_model}`",
+ f"**Configured provider:** {provider_label(current_provider)} (`{current_provider}`)",
+ ]
+ if self._effective_model:
+ effective_provider = self._effective_provider or current_provider
+ lines.extend(
+ [
+ f"**Active model:** `{self._effective_model}` (fallback)",
+ f"**Active provider:** {provider_label(effective_provider)} (`{effective_provider}`)",
+ f"**Primary model:** `{current_model}`",
+ ]
+ )
+ else:
+ lines.append(f"**Active model:** `{current_model}`")
+
+ if runtime_error:
+ lines.extend(
+ [
+ "",
+ f"⚠️ Runtime provider resolution failed: {runtime_error}",
+ ]
+ )
return "\n".join(lines)
- # Parse provider:model syntax
- target_provider, new_model = parse_model_input(args, current_provider)
- # Auto-detect provider when no explicit provider:model syntax was used
- if target_provider == current_provider:
- from hermes_cli.models import detect_provider_for_model
- detected = detect_provider_for_model(new_model, current_provider)
- if detected:
- target_provider, new_model = detected
+ lines.extend(
+ [
+ f"**Runtime provider:** {runtime_label} (`{runtime_provider}`)",
+ f"**API mode:** `{runtime.get('api_mode') or 'unknown'}`",
+ f"**Base URL:** `{runtime.get('base_url') or 'unknown'}`",
+ f"**Credentials:** {'configured ✓' if api_key or command else 'missing ⚠️'}",
+ ]
+ )
+ if command:
+ lines.append(f"**Transport command:** `{command}`")
+ source = runtime.get("source")
+ if source:
+ lines.append(f"**Credential source:** `{source}`")
+ return "\n".join(lines)
+
+ def _resolve_numbered_model(self, raw_index: str, current_provider: str) -> Optional[str]:
+ """Resolve `/model ` against the current provider catalog."""
+ from hermes_cli.models import curated_models_for_provider
+
+ try:
+ index = int(raw_index)
+ except (TypeError, ValueError):
+ return None
+ if index < 1:
+ return None
+ curated = curated_models_for_provider(current_provider)
+ if index > len(curated):
+ return None
+ return curated[index - 1][0]
+
+ async def _apply_model_selection(
+ self,
+ target_provider: str,
+ new_model: str,
+ user_id: Optional[str] = None,
+ ) -> str:
+ """Validate, persist, and activate a model/provider choice."""
+ import yaml
+ from hermes_cli.models import validate_requested_model, provider_label
+
+ state = self._load_current_model_selection()
+ current_provider = state["current_provider"]
+ config_path = state["config_path"]
provider_changed = target_provider != current_provider
+ runtime, runtime_error = self._resolve_model_runtime_details(
+ target_provider if provider_changed else current_provider
+ )
+ if provider_changed and runtime_error:
+ return f"⚠️ Could not resolve credentials for provider '{provider_label(target_provider)}': {runtime_error}"
- # Resolve credentials for the target provider (for API probe)
- api_key = os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY") or ""
- base_url = "https://openrouter.ai/api/v1"
- if provider_changed:
- try:
- from hermes_cli.runtime_provider import resolve_runtime_provider
- runtime = resolve_runtime_provider(requested=target_provider)
- api_key = runtime.get("api_key", "")
- base_url = runtime.get("base_url", "")
- except Exception as e:
- provider_label = _PROVIDER_LABELS.get(target_provider, target_provider)
- return f"⚠️ Could not resolve credentials for provider '{provider_label}': {e}"
- else:
- # Use current provider's base_url from config or registry
- try:
- from hermes_cli.runtime_provider import resolve_runtime_provider
- runtime = resolve_runtime_provider(requested=current_provider)
- api_key = runtime.get("api_key", "")
- base_url = runtime.get("base_url", "")
- except Exception:
- pass
+ api_key = str(runtime.get("api_key") or os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY") or "")
+ base_url = str(runtime.get("base_url") or "https://openrouter.ai/api/v1")
- # Validate the model against the live API
try:
validation = validate_requested_model(
new_model,
@@ -2540,11 +5331,10 @@ async def _handle_model_command(self, event: MessageEvent) -> str:
validation = {"accepted": True, "persist": True, "recognized": False, "message": None}
if not validation.get("accepted"):
- msg = validation.get("message", "Invalid model")
- tip = "\n\nUse `/model` to see available models, `/provider` to see providers" if "Did you mean" not in msg else ""
- return f"⚠️ {msg}{tip}"
+ message = validation.get("message", "Invalid model")
+ tip = "\n\nUse `/model list` to see numbered models, `/provider` to see providers." if "Did you mean" not in message else ""
+ return f"⚠️ {message}{tip}"
- # Persist to config only if validation approves
if validation.get("persist"):
try:
user_config = {}
@@ -2554,33 +5344,110 @@ async def _handle_model_command(self, event: MessageEvent) -> str:
if "model" not in user_config or not isinstance(user_config["model"], dict):
user_config["model"] = {}
user_config["model"]["default"] = new_model
- if provider_changed:
- user_config["model"]["provider"] = target_provider
- with open(config_path, 'w', encoding="utf-8") as f:
+ user_config["model"]["provider"] = target_provider
+ with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
- except Exception as e:
- return f"⚠️ Failed to save model change: {e}"
+ except Exception as exc:
+ return f"⚠️ Failed to save model change: {exc}"
- # Set env vars so the next agent run picks up the change
os.environ["HERMES_MODEL"] = new_model
- if provider_changed:
- os.environ["HERMES_INFERENCE_PROVIDER"] = target_provider
+ os.environ["HERMES_INFERENCE_PROVIDER"] = target_provider
- provider_label = _PROVIDER_LABELS.get(target_provider, target_provider)
- provider_note = f"\n**Provider:** {provider_label}" if provider_changed else ""
+ try:
+ from gateway.platforms.discord_impl import model_picker as discord_model_picker
- warning = ""
- if validation.get("message"):
- warning = f"\n⚠️ {validation['message']}"
+ discord_model_picker.record_recent_model(user_id, target_provider, new_model)
+ except Exception:
+ pass
- if validation.get("persist"):
- persist_note = "saved to config"
- else:
- persist_note = "this session only — will revert on restart"
- # Clear fallback state since user explicitly chose a model
self._effective_model = None
self._effective_provider = None
- return f"🤖 Model changed to `{new_model}` ({persist_note}){provider_note}{warning}\n_(takes effect on next message)_"
+
+ warning = f"\n⚠️ {validation['message']}" if validation.get("message") else ""
+ persist_note = "saved to config" if validation.get("persist") else "this session only — will revert on restart"
+ return (
+ f"🤖 Model changed to `{new_model}` ({persist_note})\n"
+ f"**Provider:** {provider_label(target_provider)} (`{target_provider}`){warning}\n"
+ "_(takes effect on next message)_"
+ )
+
+ async def _open_discord_model_picker(self, event: MessageEvent, command_name: str) -> str | None:
+ """Open the Discord-native interactive model picker."""
+ if not self._is_native_discord_slash(event):
+ return self._render_model_catalog_response()
+
+ adapter = self.adapters.get(Platform.DISCORD)
+ interaction = getattr(event, "raw_message", None)
+ if adapter is None or interaction is None:
+ return self._render_model_catalog_response()
+
+ state = self._load_current_model_selection()
+ try:
+ from gateway.platforms.discord_impl import model_picker as discord_model_picker
+
+ await discord_model_picker.open_model_picker(
+ adapter=adapter,
+ interaction=interaction,
+ command_name=command_name,
+ user_id=str(event.source.user_id or ""),
+ current_provider=state["current_provider"],
+ current_model=state["current_model"],
+ apply_selection=self._apply_model_selection,
+ )
+ except Exception as exc:
+ logger.warning("Discord model picker failed; falling back to text response: %s", exc, exc_info=True)
+ return self._render_model_catalog_response()
+ return None
+
+ async def _handle_model_command(self, event: MessageEvent) -> str | None:
+ """Handle /model and /models commands."""
+ from hermes_cli.models import parse_model_input, detect_provider_for_model
+
+ command_name = (event.get_command() or "model").lower()
+ args = event.get_command_args().strip()
+ is_native_slash = self._is_native_discord_slash(event)
+ state = self._load_current_model_selection()
+ current_provider = state["current_provider"]
+
+ response: Optional[str]
+
+ lowered = args.lower()
+ if command_name == "models":
+ if is_native_slash:
+ return await self._open_discord_model_picker(event, command_name="models")
+ response = self._render_model_catalog_response()
+ elif not args:
+ if is_native_slash:
+ return await self._open_discord_model_picker(event, command_name="model")
+ response = self._render_model_catalog_response()
+ elif lowered == "status":
+ response = self._render_model_status_response()
+ elif lowered == "list":
+ response = self._render_model_catalog_response()
+ else:
+ numbered_model = self._resolve_numbered_model(args, current_provider)
+ if numbered_model:
+ response = await self._apply_model_selection(
+ current_provider,
+ numbered_model,
+ user_id=event.source.user_id,
+ )
+ else:
+ target_provider, new_model = parse_model_input(args, current_provider)
+ if target_provider == current_provider:
+ detected = detect_provider_for_model(new_model, current_provider)
+ if detected:
+ target_provider, new_model = detected
+ response = await self._apply_model_selection(
+ target_provider,
+ new_model,
+ user_id=event.source.user_id,
+ )
+
+ if is_native_slash and response:
+ await self._respond_to_native_slash(event, response)
+ return None
+ return response
async def _handle_provider_command(self, event: MessageEvent) -> str:
"""Handle /provider command - show available providers."""
@@ -3242,6 +6109,7 @@ async def _run_background_task(
# Read model from config via shared helper
model = _resolve_gateway_model()
+ self._apply_runtime_debug_overrides()
# Determine toolset (same logic as _run_agent)
default_toolset_map = {
@@ -3289,7 +6157,7 @@ async def _run_background_task(
pr = self._provider_routing
max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
- reasoning_config = self._load_reasoning_config()
+ reasoning_config = self._get_effective_reasoning_config()
self._reasoning_config = reasoning_config
turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs)
@@ -3399,8 +6267,7 @@ async def _handle_reasoning_command(self, event: MessageEvent) -> str:
args = event.get_command_args().strip().lower()
config_path = _hermes_home / "config.yaml"
- self._reasoning_config = self._load_reasoning_config()
- self._show_reasoning = self._load_show_reasoning()
+ self._apply_runtime_debug_overrides()
def _save_config_key(key_path: str, value):
"""Save a dot-separated key to config.yaml."""
@@ -3444,11 +6311,13 @@ def _save_config_key(key_path: str, value):
if args in ("show", "on"):
self._show_reasoning = True
_save_config_key("display.show_reasoning", True)
+ self._apply_runtime_debug_overrides()
return "🧠 ✓ Reasoning display: **ON**\nModel thinking will be shown before each response."
if args in ("hide", "off"):
self._show_reasoning = False
_save_config_key("display.show_reasoning", False)
+ self._apply_runtime_debug_overrides()
return "🧠 ✓ Reasoning display: **OFF**"
# Effort level change
@@ -3466,15 +6335,58 @@ def _save_config_key(key_path: str, value):
self._reasoning_config = parsed
if _save_config_key("agent.reasoning_effort", effort):
+ self._apply_runtime_debug_overrides()
return f"🧠 ✓ Reasoning effort set to `{effort}` (saved to config)\n_(takes effect on next message)_"
else:
+ self._apply_runtime_debug_overrides()
return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)"
+ async def _handle_think_command(self, event: MessageEvent) -> str:
+ """Handle /think command — reasoning effort only, without display toggles."""
+ args = event.get_command_args().strip().lower()
+ self._apply_runtime_debug_overrides()
+
+ if not args:
+ rc = self._reasoning_config
+ if rc is None:
+ level = "medium (default)"
+ elif rc.get("enabled") is False:
+ level = "off"
+ else:
+ level = rc.get("effort", "medium")
+ return (
+ "🧠 **Thinking Effort**\n\n"
+ f"**Current:** `{level}`\n\n"
+ "_Usage:_ `/think `"
+ )
+
+ effort = "none" if args == "off" else args
+ if effort not in ("none", "minimal", "low", "medium", "high", "xhigh"):
+ return (
+ f"⚠️ Unknown thinking level: `{args}`\n\n"
+ "**Valid levels:** off, minimal, low, medium, high, xhigh"
+ )
+
+ think_event = MessageEvent(
+ text=f"/reasoning {effort}",
+ source=event.source,
+ message_type=event.message_type,
+ raw_message=event.raw_message,
+ message_id=event.message_id,
+ media_urls=list(event.media_urls),
+ media_types=list(event.media_types),
+ reply_to_message_id=event.reply_to_message_id,
+ reply_to_text=event.reply_to_text,
+ metadata=event.metadata,
+ )
+ return await self._handle_reasoning_command(think_event)
+
async def _handle_compress_command(self, event: MessageEvent) -> str:
"""Handle /compress command -- manually compress conversation context."""
- source = event.source
+ source = self._command_target_source_for_event(event)
session_entry = self.session_store.get_or_create_session(source)
history = self.session_store.load_transcript(session_entry.session_id)
+ instructions = event.get_command_args().strip()
if not history or len(history) < 4:
return "Not enough conversation to compress (need at least 4 messages)."
@@ -3510,7 +6422,7 @@ async def _handle_compress_command(self, event: MessageEvent) -> str:
loop = asyncio.get_event_loop()
compressed, _ = await loop.run_in_executor(
None,
- lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens),
+ lambda: tmp_agent._compress_context(msgs, instructions, approx_tokens=approx_tokens),
)
self.session_store.rewrite_transcript(session_entry.session_id, compressed)
@@ -3521,10 +6433,13 @@ async def _handle_compress_command(self, event: MessageEvent) -> str:
new_count = len(compressed)
new_tokens = estimate_messages_tokens_rough(compressed)
- return (
+ result = (
f"🗜️ Compressed: {original_count} → {new_count} messages\n"
f"~{approx_tokens:,} → ~{new_tokens:,} tokens"
)
+ if instructions:
+ result += f"\nPreserved guidance: `{instructions[:160]}`"
+ return result
except Exception as e:
logger.warning("Manual compress failed: %s", e)
return f"Compression failed: {e}"
@@ -3794,77 +6709,14 @@ async def _handle_reload_mcp_command(self, event: MessageEvent) -> str:
logger.warning("MCP reload failed: %s", e)
return f"❌ MCP reload failed: {e}"
- # ------------------------------------------------------------------
- # /approve & /deny — explicit dangerous-command approval
- # ------------------------------------------------------------------
-
- _APPROVAL_TIMEOUT_SECONDS = 300 # 5 minutes
-
- async def _handle_approve_command(self, event: MessageEvent) -> str:
- """Handle /approve command — execute a pending dangerous command.
-
- Usage:
- /approve — approve and execute the pending command
- /approve session — approve and remember for this session
- /approve always — approve this pattern permanently
- """
- source = event.source
- session_key = self._session_key_for_source(source)
-
- if session_key not in self._pending_approvals:
- return "No pending command to approve."
-
- import time as _time
- approval = self._pending_approvals[session_key]
-
- # Check for timeout
- ts = approval.get("timestamp", 0)
- if _time.time() - ts > self._APPROVAL_TIMEOUT_SECONDS:
- self._pending_approvals.pop(session_key, None)
- return "⚠️ Approval expired (timed out after 5 minutes). Ask the agent to try again."
-
- self._pending_approvals.pop(session_key)
- cmd = approval["command"]
- pattern_keys = approval.get("pattern_keys", [])
- if not pattern_keys:
- pk = approval.get("pattern_key", "")
- pattern_keys = [pk] if pk else []
-
- # Determine approval scope from args
- args = event.get_command_args().strip().lower()
- from tools.approval import approve_session, approve_permanent
-
- if args in ("always", "permanent", "permanently"):
- for pk in pattern_keys:
- approve_permanent(pk)
- scope_msg = " (pattern approved permanently)"
- elif args in ("session", "ses"):
- for pk in pattern_keys:
- approve_session(session_key, pk)
- scope_msg = " (pattern approved for this session)"
- else:
- # One-time approval — just approve for session so the immediate
- # replay works, but don't advertise it as session-wide
- for pk in pattern_keys:
- approve_session(session_key, pk)
- scope_msg = ""
-
- logger.info("User approved dangerous command via /approve: %s...%s", cmd[:60], scope_msg)
- from tools.terminal_tool import terminal_tool
- result = terminal_tool(command=cmd, force=True)
- return f"✅ Command approved and executed{scope_msg}.\n\n```\n{result[:3500]}\n```"
-
async def _handle_deny_command(self, event: MessageEvent) -> str:
- """Handle /deny command — reject a pending dangerous command."""
- source = event.source
- session_key = self._session_key_for_source(source)
-
- if session_key not in self._pending_approvals:
- return "No pending command to deny."
-
- self._pending_approvals.pop(session_key)
- logger.info("User denied dangerous command via /deny")
- return "❌ Command denied."
+ """Handle /deny command for pending exec approvals."""
+ approval_id = event.get_command_args().strip() or None
+ return self._resolve_pending_approval(
+ decision="deny",
+ approval_id=approval_id,
+ source=self._command_target_source_for_event(event),
+ )
async def _handle_update_command(self, event: MessageEvent) -> str:
"""Handle /update command — update Hermes Agent to the latest version.
@@ -4072,7 +6924,13 @@ def _set_session_env(self, context: SessionContext) -> None:
def _clear_session_env(self) -> None:
"""Clear session environment variables."""
- for var in ["HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_CHAT_NAME", "HERMES_SESSION_THREAD_ID"]:
+ for var in [
+ "HERMES_SESSION_PLATFORM",
+ "HERMES_SESSION_CHAT_ID",
+ "HERMES_SESSION_CHAT_NAME",
+ "HERMES_SESSION_THREAD_ID",
+ "HERMES_SESSION_SEND_POLICY",
+ ]:
if var in os.environ:
del os.environ[var]
@@ -4248,7 +7106,7 @@ async def _run_process_watcher(self, watcher: dict) -> None:
platform_name = watcher.get("platform", "")
chat_id = watcher.get("chat_id", "")
thread_id = watcher.get("thread_id", "")
- notify_mode = self._load_background_notifications_mode()
+ notify_mode = self._get_effective_background_notifications_mode()
logger.debug("Process watcher started: %s (every %ss, notify=%s)",
session_id, interval, notify_mode)
@@ -4605,6 +7463,7 @@ def run_sync():
# Pass session_key to process registry via env var so background
# processes can be mapped back to this gateway session
os.environ["HERMES_SESSION_KEY"] = session_key or ""
+ os.environ["HERMES_SESSION_SEND_POLICY"] = self._session_send_policy(session_key)
# Read from env var or use default (same as CLI)
max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
@@ -4639,9 +7498,10 @@ def run_sync():
"tools": [],
}
+ self._apply_runtime_debug_overrides()
pr = self._provider_routing
honcho_manager, honcho_config = self._get_or_create_gateway_honcho(session_key)
- reasoning_config = self._load_reasoning_config()
+ reasoning_config = self._get_effective_reasoning_config()
self._reasoning_config = reasoning_config
# Set up streaming consumer if enabled
_stream_consumer = None
@@ -4701,6 +7561,7 @@ def run_sync():
session_db=self._session_db,
fallback_model=self._fallback_model,
)
+ self._attach_subagent_runtime_hooks(agent, session_key)
# Store agent reference for interrupt support
agent_holder[0] = agent
diff --git a/gateway/session.py b/gateway/session.py
index c6fb8582261e..68b21d3f1b42 100644
--- a/gateway/session.py
+++ b/gateway/session.py
@@ -84,6 +84,7 @@ class SessionSource:
chat_topic: Optional[str] = None # Channel topic/description (Discord, Slack)
user_id_alt: Optional[str] = None # Signal UUID (alternative to phone number)
chat_id_alt: Optional[str] = None # Signal group internal ID
+ session_namespace: Optional[str] = None # Isolated command/session namespace
@property
def description(self) -> str:
@@ -116,6 +117,7 @@ def to_dict(self) -> Dict[str, Any]:
"user_name": self.user_name,
"thread_id": self.thread_id,
"chat_topic": self.chat_topic,
+ "session_namespace": self.session_namespace,
}
if self.user_id_alt:
d["user_id_alt"] = self.user_id_alt
@@ -136,6 +138,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource":
chat_topic=data.get("chat_topic"),
user_id_alt=data.get("user_id_alt"),
chat_id_alt=data.get("chat_id_alt"),
+ session_namespace=data.get("session_namespace"),
)
@classmethod
@@ -435,11 +438,23 @@ def build_session_key(source: SessionSource, group_sessions_per_user: bool = Tru
if source.chat_type == "dm":
if source.chat_id:
if source.thread_id:
- return f"agent:main:{platform}:dm:{source.chat_id}:{source.thread_id}"
- return f"agent:main:{platform}:dm:{source.chat_id}"
+ parts = ["agent:main", platform, "dm", source.chat_id, source.thread_id]
+ if source.session_namespace:
+ parts.append(source.session_namespace)
+ return ":".join(parts)
+ parts = ["agent:main", platform, "dm", source.chat_id]
+ if source.session_namespace:
+ parts.append(source.session_namespace)
+ return ":".join(parts)
if source.thread_id:
- return f"agent:main:{platform}:dm:{source.thread_id}"
- return f"agent:main:{platform}:dm"
+ parts = ["agent:main", platform, "dm", source.thread_id]
+ if source.session_namespace:
+ parts.append(source.session_namespace)
+ return ":".join(parts)
+ parts = ["agent:main", platform, "dm"]
+ if source.session_namespace:
+ parts.append(source.session_namespace)
+ return ":".join(parts)
participant_id = source.user_id_alt or source.user_id
key_parts = ["agent:main", platform, source.chat_type]
@@ -450,6 +465,8 @@ def build_session_key(source: SessionSource, group_sessions_per_user: bool = Tru
key_parts.append(source.thread_id)
if group_sessions_per_user and participant_id:
key_parts.append(str(participant_id))
+ if source.session_namespace:
+ key_parts.append(source.session_namespace)
return ":".join(key_parts)
diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py
index 1c687f6d384e..a9a7c41f14b7 100644
--- a/hermes_cli/commands.py
+++ b/hermes_cli/commands.py
@@ -61,26 +61,59 @@ class CommandDef:
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
args_hint="[number]"),
CommandDef("stop", "Kill all running background processes", "Session"),
- CommandDef("approve", "Approve a pending dangerous command", "Session",
- gateway_only=True, args_hint="[session|always]"),
CommandDef("deny", "Deny a pending dangerous command", "Session",
gateway_only=True),
CommandDef("background", "Run a prompt in the background", "Session",
aliases=("bg",), args_hint=""),
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
aliases=("q",), args_hint=""),
+ CommandDef("compact", "Compress conversation context with optional instructions", "Session",
+ gateway_only=True, args_hint="[instructions]"),
CommandDef("status", "Show session info", "Session",
gateway_only=True),
+ CommandDef("approve", "Resolve a pending exec approval", "Session",
+ gateway_only=True, args_hint="[id] [allow-once|allow-always|deny]"),
+ CommandDef("commands", "Show the full gateway command catalog", "Session",
+ gateway_only=True),
+ CommandDef("context", "Show current conversation context details", "Session",
+ gateway_only=True, args_hint="[list|detail|json]",
+ subcommands=("list", "detail", "json")),
+ CommandDef("export-session", "Export the current session snapshot", "Session",
+ gateway_only=True, aliases=("export",), args_hint="[path]"),
+ CommandDef("whoami", "Show the sender identity Hermes sees", "Session",
+ gateway_only=True, aliases=("id",)),
+ CommandDef("focus", "Bind the current Discord thread to this Hermes session", "Session",
+ gateway_only=True, args_hint="[label]"),
+ CommandDef("unfocus", "Remove the current Discord thread binding", "Session",
+ gateway_only=True),
+ CommandDef("agents", "Show Discord thread bindings for this session", "Session",
+ gateway_only=True),
+ CommandDef("session", "Manage Discord thread binding idle/max-age controls", "Session",
+ gateway_only=True, args_hint="[idle|max-age] [duration|off]",
+ subcommands=("idle", "max-age", "status")),
CommandDef("sethome", "Set this chat as the home channel", "Session",
gateway_only=True, aliases=("set-home",)),
CommandDef("resume", "Resume a previously-named session", "Session",
args_hint="[name]"),
+ CommandDef("subagents", "Inspect or control sub-agent runs for this session", "Session",
+ gateway_only=True, args_hint="[list|kill|log|info|send|steer|spawn] [args...]",
+ subcommands=("list", "kill", "log", "info", "send", "steer", "spawn")),
+ CommandDef("kill", "Abort a running sub-agent for this session", "Session",
+ gateway_only=True, args_hint=""),
+ CommandDef("steer", "Steer a running sub-agent immediately", "Session",
+ gateway_only=True, aliases=("tell",), args_hint=" "),
# Configuration
- CommandDef("config", "Show current configuration", "Configuration",
- cli_only=True),
+ CommandDef("config", "Show or update Hermes configuration", "Configuration",
+ args_hint="[show|get|set|unset] [key] [value]",
+ subcommands=("show", "get", "set", "unset")),
+ CommandDef("allowlist", "Inspect or edit the command allowlist", "Configuration",
+ gateway_only=True, args_hint="[list|add|remove] [entry]",
+ subcommands=("list", "add", "remove")),
CommandDef("model", "Show or change the current model", "Configuration",
args_hint="[name]"),
+ CommandDef("models", "Open the Discord model picker or list models", "Configuration",
+ gateway_only=True),
CommandDef("provider", "Show available providers and current provider",
"Configuration"),
CommandDef("prompt", "View/set custom system prompt", "Configuration",
@@ -94,10 +127,21 @@ class CommandDef:
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
args_hint="[level|show|hide]",
subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")),
+ CommandDef("think", "Set reasoning effort quickly", "Configuration",
+ gateway_only=True, aliases=("thinking", "t"),
+ args_hint="[off|minimal|low|medium|high|xhigh]"),
+ CommandDef("send", "Control whether this session may use send_message", "Configuration",
+ gateway_only=True, args_hint="[on|off|inherit]"),
+ CommandDef("activation", "Control Discord mention-vs-always activation for this chat", "Configuration",
+ gateway_only=True, args_hint="[mention|always]"),
+ CommandDef("debug", "Manage runtime-only configuration overrides", "Configuration",
+ gateway_only=True, args_hint="[show|set|unset|reset] [key] [value]",
+ subcommands=("show", "set", "unset", "reset")),
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
cli_only=True, args_hint="[name]"),
CommandDef("voice", "Toggle voice mode", "Configuration",
- args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
+ aliases=("vc",), args_hint="[on|off|tts|channel|join|leave|status]",
+ subcommands=("on", "off", "tts", "channel", "join", "leave", "status")),
# Tools & Skills
CommandDef("tools", "Manage tools: /tools [list|disable|enable] [name...]", "Tools & Skills",
@@ -107,11 +151,17 @@ class CommandDef:
CommandDef("skills", "Search, install, inspect, or manage skills",
"Tools & Skills", cli_only=True,
subcommands=("search", "browse", "inspect", "install")),
+ CommandDef("skill", "Run a skill by name from messaging platforms", "Tools & Skills",
+ gateway_only=True, args_hint=" [input]"),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
cli_only=True, args_hint="[subcommand]",
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
aliases=("reload_mcp",)),
+ CommandDef("acp", "Inspect or control ACP runtime sessions", "Tools & Skills",
+ gateway_only=True, args_hint="[spawn|cancel|steer|close|status|set-mode|set|cwd|permissions|timeout|model|reset-options|doctor|install|sessions] [args...]"),
+ CommandDef("bash", "Run a host shell command through gateway control", "Tools & Skills",
+ gateway_only=True, args_hint=""),
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
cli_only=True, args_hint="[connect|disconnect|status]",
subcommands=("connect", "disconnect", "status")),
@@ -129,6 +179,14 @@ class CommandDef:
cli_only=True),
CommandDef("update", "Update Hermes Agent to the latest version", "Info",
gateway_only=True),
+ CommandDef("restart", "Restart the Hermes gateway", "Info",
+ gateway_only=True),
+ CommandDef("dock-telegram", "Dock replies for this session to the Telegram home channel", "Info",
+ gateway_only=True, aliases=("dock_telegram",)),
+ CommandDef("dock-discord", "Dock replies for this session to the Discord home channel", "Info",
+ gateway_only=True, aliases=("dock_discord",)),
+ CommandDef("dock-slack", "Dock replies for this session to the Slack home channel", "Info",
+ gateway_only=True, aliases=("dock_slack",)),
# Exit
CommandDef("quit", "Exit the CLI", "Exit",
@@ -237,6 +295,29 @@ def gateway_help_lines() -> list[str]:
return lines
+def gateway_command_defs() -> list[CommandDef]:
+ """Return gateway-available command definitions in registry order."""
+ return [cmd for cmd in COMMAND_REGISTRY if not cmd.cli_only]
+
+
+def gateway_commands_by_category() -> list[tuple[str, tuple[CommandDef, ...]]]:
+ """Return gateway commands grouped by category in registry order."""
+ grouped: dict[str, list[CommandDef]] = {}
+ category_order: list[str] = []
+ for cmd in gateway_command_defs():
+ if cmd.category not in grouped:
+ grouped[cmd.category] = []
+ category_order.append(cmd.category)
+ grouped[cmd.category].append(cmd)
+ return [(category, tuple(grouped[category])) for category in category_order]
+
+
+def format_gateway_command_signature(cmd: CommandDef) -> str:
+ """Return a formatted gateway command signature."""
+ args = f" {cmd.args_hint}" if cmd.args_hint else ""
+ return f"/{cmd.name}{args}"
+
+
def telegram_bot_commands() -> list[tuple[str, str]]:
"""Return (command_name, description) pairs for Telegram setMyCommands.
diff --git a/tests/gateway/test_bash_command.py b/tests/gateway/test_bash_command.py
new file mode 100644
index 000000000000..727e4507531b
--- /dev/null
+++ b/tests/gateway/test_bash_command.py
@@ -0,0 +1,199 @@
+"""Tests for gateway /bash command parity."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from gateway.config import GatewayConfig, Platform, PlatformConfig
+from gateway.platforms.base import MessageEvent, MessageType
+from gateway.run import GatewayRunner
+from gateway.session import SessionEntry, SessionSource, build_session_key
+
+
+def _make_source(**overrides) -> SessionSource:
+ data = {
+ "platform": Platform.DISCORD,
+ "user_id": "u1",
+ "chat_id": "c1",
+ "user_name": "alan",
+ "chat_name": "Hermes / #general",
+ "chat_type": "group",
+ }
+ data.update(overrides)
+ return SessionSource(**data)
+
+
+def _make_event(text: str, *, source: SessionSource | None = None) -> MessageEvent:
+ return MessageEvent(
+ text=text,
+ message_type=MessageType.COMMAND,
+ source=source or _make_source(),
+ message_id="m1",
+ )
+
+
+def _make_runner() -> GatewayRunner:
+ runner = object.__new__(GatewayRunner)
+ runner.config = GatewayConfig(
+ platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="***")}
+ )
+ runner.adapters = {
+ Platform.DISCORD: SimpleNamespace(send_exec_approval=AsyncMock())
+ }
+ source = _make_source()
+ session_entry = SessionEntry(
+ session_key=build_session_key(source),
+ session_id="sess-1",
+ created_at=datetime(2026, 3, 19, 12, 0),
+ updated_at=datetime(2026, 3, 19, 12, 30),
+ platform=Platform.DISCORD,
+ chat_type="group",
+ )
+ runner.session_store = MagicMock()
+ runner.session_store._generate_session_key = lambda session_source: build_session_key(session_source)
+ runner.session_store.get_or_create_session.return_value = session_entry
+ runner._bash_jobs = {}
+ runner._pending_approvals = {}
+ runner._running_agents = {}
+ runner._pending_messages = {}
+ runner._voice_mode = {}
+ runner._session_send_policies = {}
+ runner._session_docks = {}
+ runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
+ return runner
+
+
+def test_rewrite_bash_shortcut_supports_command_poll_and_stop():
+ assert GatewayRunner._rewrite_bash_shortcut("! echo hello") == "/bash echo hello"
+ assert GatewayRunner._rewrite_bash_shortcut("!poll") == "/bash poll"
+ assert GatewayRunner._rewrite_bash_shortcut("!stop proc-1") == "/bash stop proc-1"
+ assert GatewayRunner._rewrite_bash_shortcut("hello") is None
+
+
+@pytest.mark.asyncio
+async def test_handle_bash_command_completes_fast_process(monkeypatch):
+ runner = _make_runner()
+
+ monkeypatch.setattr(
+ runner,
+ "_run_bash_process",
+ lambda **_kwargs: {"session_id": "proc-1"},
+ )
+ monkeypatch.setattr(
+ "tools.process_registry.process_registry",
+ SimpleNamespace(
+ wait=lambda session_id, timeout=None: {
+ "status": "exited",
+ "exit_code": 0,
+ "output": "hello\nworld",
+ },
+ poll=lambda _sid: {"status": "exited"},
+ ),
+ )
+
+ result = await runner._handle_bash_command(_make_event("/bash echo hello"))
+
+ assert "Bash Complete" in result
+ assert "`0`" in result
+ assert "hello" in result
+ assert runner._bash_jobs == {}
+
+
+@pytest.mark.asyncio
+async def test_handle_bash_command_backgrounds_long_process(monkeypatch):
+ runner = _make_runner()
+
+ monkeypatch.setattr(
+ runner,
+ "_run_bash_process",
+ lambda **_kwargs: {"session_id": "proc-2"},
+ )
+ monkeypatch.setattr(
+ "tools.process_registry.process_registry",
+ SimpleNamespace(
+ wait=lambda session_id, timeout=None: {"status": "timeout"},
+ poll=lambda _sid: {
+ "status": "running",
+ "pid": 4242,
+ "output_preview": "still running",
+ },
+ ),
+ )
+
+ result = await runner._handle_bash_command(_make_event("/bash sleep 30"))
+
+ assert "Bash Running" in result
+ assert "`proc-2`" in result
+ assert "still running" in result
+ assert runner._bash_jobs[build_session_key(_make_source())] == "proc-2"
+
+
+@pytest.mark.asyncio
+async def test_bash_poll_and_stop_use_current_job(monkeypatch):
+ runner = _make_runner()
+ session_key = build_session_key(_make_source())
+ runner._bash_jobs[session_key] = "proc-9"
+
+ registry = SimpleNamespace(
+ get=lambda _sid: SimpleNamespace(exited=False),
+ poll=lambda _sid: {
+ "status": "running",
+ "pid": 999,
+ "uptime_seconds": 8,
+ },
+ read_log=lambda _sid, limit=40: {"output": "line 1\nline 2"},
+ kill_process=lambda _sid: {"status": "killed"},
+ )
+ monkeypatch.setattr("tools.process_registry.process_registry", registry)
+
+ poll_result = await runner._handle_bash_command(_make_event("/bash poll"))
+ stop_result = await runner._handle_bash_command(_make_event("/bash stop"))
+
+ assert "Bash Status" in poll_result
+ assert "line 1" in poll_result
+ assert "stopped" in stop_result
+ assert session_key not in runner._bash_jobs
+
+
+@pytest.mark.asyncio
+async def test_bash_command_records_pending_approval(monkeypatch):
+ runner = _make_runner()
+ monkeypatch.setattr(
+ runner,
+ "_run_bash_process",
+ lambda **_kwargs: {
+ "status": "approval_required",
+ "error": "Need approval",
+ "description": "dangerous command",
+ "pattern_key": "rm-rf",
+ },
+ )
+
+ result = await runner._handle_bash_command(_make_event("/bash rm -rf /tmp/demo"))
+ pending = next(iter(runner._pending_approvals.values()))
+
+ assert "requires approval" in result
+ assert pending["pattern_key"] == "rm-rf"
+ assert callable(pending["on_approve"])
+ runner.adapters[Platform.DISCORD].send_exec_approval.assert_awaited_once()
+
+
+def test_resolve_pending_approval_uses_callback_when_present():
+ runner = _make_runner()
+ source = _make_source()
+ session_key = build_session_key(source)
+ runner._pending_approvals[session_key] = {
+ "approval_id": "appr-1",
+ "command": "echo hi",
+ "pattern_key": "echo",
+ "on_approve": lambda decision: f"approved:{decision}",
+ }
+
+ result = runner._resolve_pending_approval(decision="allow-once", source=source)
+
+ assert result == "approved:allow-once"
+ assert session_key not in runner._pending_approvals
diff --git a/tests/gateway/test_channel_directory.py b/tests/gateway/test_channel_directory.py
index 2ecacc457db0..3bb9802251ab 100644
--- a/tests/gateway/test_channel_directory.py
+++ b/tests/gateway/test_channel_directory.py
@@ -80,6 +80,16 @@ def test_guild_qualified_match(self, tmp_path):
assert resolve_channel_name("discord", "ServerA/general") == "111"
assert resolve_channel_name("discord", "ServerB/general") == "222"
+ def test_discord_channel_mention_and_channel_prefix_resolve_directly(self, tmp_path):
+ platforms = {
+ "discord": [
+ {"id": "111", "name": "general", "guild": "ServerA", "type": "channel"},
+ ]
+ }
+ with self._setup(tmp_path, platforms):
+ assert resolve_channel_name("discord", "<#111>") == "111"
+ assert resolve_channel_name("discord", "channel:111") == "111"
+
def test_prefix_match_unambiguous(self, tmp_path):
platforms = {
"slack": [
diff --git a/tests/gateway/test_debug_command.py b/tests/gateway/test_debug_command.py
new file mode 100644
index 000000000000..79ee43feda4d
--- /dev/null
+++ b/tests/gateway/test_debug_command.py
@@ -0,0 +1,175 @@
+"""Tests for gateway /debug runtime override behavior."""
+
+from __future__ import annotations
+
+import sys
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from gateway.config import GatewayConfig, Platform, PlatformConfig
+from gateway.platforms.base import MessageEvent
+from gateway.session import SessionSource
+
+
+def _ensure_discord_mock():
+ if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
+ return
+
+ discord_mod = MagicMock()
+ discord_mod.Intents.default.return_value = MagicMock()
+ discord_mod.Client = MagicMock
+ discord_mod.File = MagicMock
+ discord_mod.DMChannel = type("DMChannel", (), {})
+ discord_mod.Thread = type("Thread", (), {})
+ discord_mod.ForumChannel = type("ForumChannel", (), {})
+ discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
+ discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3)
+ discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
+ discord_mod.Interaction = object
+ discord_mod.Embed = MagicMock
+ discord_mod.app_commands = SimpleNamespace(
+ describe=lambda **kwargs: (lambda fn: fn),
+ choices=lambda **kwargs: (lambda fn: fn),
+ Choice=lambda **kwargs: SimpleNamespace(**kwargs),
+ )
+
+ ext_mod = MagicMock()
+ commands_mod = MagicMock()
+ commands_mod.Bot = MagicMock
+ ext_mod.commands = commands_mod
+
+ sys.modules.setdefault("discord", discord_mod)
+ sys.modules.setdefault("discord.ext", ext_mod)
+ sys.modules.setdefault("discord.ext.commands", commands_mod)
+
+
+_ensure_discord_mock()
+
+from gateway.platforms.discord import DiscordAdapter # noqa: E402
+from gateway.run import GatewayRunner # noqa: E402
+
+
+def _make_event(text="/debug") -> MessageEvent:
+ source = SessionSource(
+ platform=Platform.DISCORD,
+ user_id="u1",
+ chat_id="c1",
+ user_name="alan",
+ chat_type="group",
+ )
+ return MessageEvent(text=text, source=source, message_id="m1")
+
+
+def _make_runner(discord_extra: dict | None = None) -> GatewayRunner:
+ runner = object.__new__(GatewayRunner)
+ runner.config = GatewayConfig(
+ platforms={
+ Platform.DISCORD: PlatformConfig(
+ enabled=True,
+ token="***",
+ extra=discord_extra or {},
+ )
+ }
+ )
+ runner.adapters = {
+ Platform.DISCORD: DiscordAdapter(runner.config.platforms[Platform.DISCORD])
+ }
+ runner._runtime_debug_overrides = {}
+ runner._ephemeral_system_prompt = ""
+ runner._prefill_messages = []
+ runner._reasoning_config = None
+ runner._show_reasoning = False
+ runner._provider_routing = {}
+ runner._fallback_model = None
+ runner._smart_model_routing = {}
+ runner._running_agents = {}
+ runner._pending_messages = {}
+ runner._pending_approvals = {}
+ runner._voice_mode = {}
+ runner._session_send_policies = {}
+ runner._session_docks = {}
+ runner._session_db = None
+ runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
+ return runner
+
+
+@pytest.mark.asyncio
+async def test_debug_show_lists_supported_keys_when_no_overrides():
+ runner = _make_runner()
+
+ result = await runner._handle_debug_command(_make_event("/debug"))
+
+ assert "Runtime Debug Overrides" in result
+ assert "None" in result
+ assert "`agent.system_prompt`" in result
+ assert "`discord.auto_thread`" in result
+
+
+@pytest.mark.asyncio
+async def test_debug_set_show_reasoning_updates_runtime_cache(tmp_path, monkeypatch):
+ hermes_home = tmp_path / "hermes"
+ hermes_home.mkdir()
+ monkeypatch.setattr("gateway.run._hermes_home", hermes_home)
+ runner = _make_runner()
+
+ result = await runner._handle_debug_command(
+ _make_event("/debug set display.show_reasoning true")
+ )
+
+ assert runner._show_reasoning is True
+ assert "`true`" not in result # format_yaml_block uses yaml-style, not inline code
+ assert "Runtime override set for `display.show_reasoning`" in result
+ assert "true" in result.lower()
+
+
+@pytest.mark.asyncio
+async def test_debug_set_and_unset_reasoning_effort_restores_base_config(tmp_path, monkeypatch):
+ hermes_home = tmp_path / "hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text(
+ "agent:\n reasoning_effort: low\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr("gateway.run._hermes_home", hermes_home)
+ runner = _make_runner()
+
+ await runner._handle_debug_command(_make_event("/debug set agent.reasoning_effort xhigh"))
+ unset_result = await runner._handle_debug_command(_make_event("/debug unset agent.reasoning_effort"))
+
+ assert runner._reasoning_config == {"enabled": True, "effort": "low"}
+ assert "Removed runtime override for `agent.reasoning_effort`" in unset_result
+ assert "low" in unset_result
+
+
+@pytest.mark.asyncio
+async def test_debug_set_discord_policy_override_updates_live_adapter():
+ runner = _make_runner({"auto_thread": True, "require_mention": True})
+ adapter = runner.adapters[Platform.DISCORD]
+
+ result = await runner._handle_debug_command(
+ _make_event("/debug set discord.auto_thread false")
+ )
+
+ assert adapter._get_discord_policy().auto_thread is False
+ assert adapter._get_discord_policy().require_mention is True
+ assert runner._runtime_debug_overrides["discord.auto_thread"] is False
+ assert "Runtime override set for `discord.auto_thread`" in result
+
+
+@pytest.mark.asyncio
+async def test_debug_reset_clears_active_overrides(tmp_path, monkeypatch):
+ hermes_home = tmp_path / "hermes"
+ hermes_home.mkdir()
+ monkeypatch.setattr("gateway.run._hermes_home", hermes_home)
+ runner = _make_runner()
+
+ await runner._handle_debug_command(_make_event("/debug set display.show_reasoning true"))
+ await runner._handle_debug_command(_make_event("/debug set discord.require_mention false"))
+ result = await runner._handle_debug_command(_make_event("/debug reset"))
+
+ assert runner._runtime_debug_overrides == {}
+ assert runner._show_reasoning is False
+ assert runner.adapters[Platform.DISCORD]._get_discord_policy().require_mention is True
+ assert "Removed `2` override(s)" in result
diff --git a/tests/gateway/test_discord_command_catalog.py b/tests/gateway/test_discord_command_catalog.py
new file mode 100644
index 000000000000..fe98bf5dd635
--- /dev/null
+++ b/tests/gateway/test_discord_command_catalog.py
@@ -0,0 +1,232 @@
+"""Tests for Discord long-tail command catalog parity work."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from gateway.config import GatewayConfig, Platform, PlatformConfig
+from gateway.platforms.base import MessageEvent, MessageType
+from gateway.run import GatewayRunner
+from gateway.session import SessionEntry, SessionSource, build_session_key
+
+
+def _make_source(**overrides) -> SessionSource:
+ data = {
+ "platform": Platform.DISCORD,
+ "user_id": "u1",
+ "chat_id": "c1",
+ "user_name": "alan",
+ "chat_name": "Hermes / #general",
+ "chat_type": "group",
+ }
+ data.update(overrides)
+ return SessionSource(**data)
+
+
+def _make_event(text: str, *, source: SessionSource | None = None) -> MessageEvent:
+ return MessageEvent(
+ text=text,
+ message_type=MessageType.COMMAND,
+ source=source or _make_source(),
+ message_id="m1",
+ )
+
+
+def _make_runner(history: list[dict] | None = None) -> GatewayRunner:
+ runner = object.__new__(GatewayRunner)
+ runner.config = GatewayConfig(
+ platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="***")}
+ )
+ runner.adapters = {Platform.DISCORD: MagicMock()}
+ source = _make_source()
+ session_entry = SessionEntry(
+ session_key=build_session_key(source),
+ session_id="sess-1",
+ created_at=datetime(2026, 3, 19, 12, 0),
+ updated_at=datetime(2026, 3, 19, 12, 30),
+ platform=Platform.DISCORD,
+ chat_type="group",
+ )
+ runner.session_store = MagicMock()
+ runner.session_store._generate_session_key = lambda session_source: build_session_key(session_source)
+ runner.session_store.get_or_create_session.return_value = session_entry
+ runner.session_store.load_transcript.return_value = history or []
+ runner.session_store.rewrite_transcript = MagicMock()
+ runner.session_store.update_session = MagicMock()
+ runner._running_agents = {}
+ runner._pending_messages = {}
+ runner._pending_approvals = {}
+ runner._voice_mode = {}
+ runner._session_send_policies = {}
+ runner._session_docks = {}
+ runner._effective_model = None
+ runner._effective_provider = None
+ runner._reasoning_config = {"enabled": True, "effort": "medium"}
+ runner._show_reasoning = False
+ runner._provider_routing = {}
+ runner._fallback_model = None
+ runner._session_db = None
+ runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
+ runner._load_current_model_selection = lambda: {
+ "current_model": "anthropic/claude-opus-4.6",
+ "current_provider": "openrouter",
+ }
+ runner._resolve_model_runtime_details = lambda _provider: ({}, None)
+ runner._is_user_authorized = lambda _source: True
+ return runner
+
+
+@pytest.mark.asyncio
+async def test_allowlist_command_adds_lists_and_removes(monkeypatch):
+ runner = _make_runner()
+ store: set[str] = set()
+ import tools.approval as approval_mod
+
+ monkeypatch.setattr(approval_mod, "load_permanent_allowlist", lambda: set(store))
+ monkeypatch.setattr(
+ approval_mod,
+ "load_permanent",
+ lambda patterns: store.update(patterns),
+ )
+ monkeypatch.setattr(
+ approval_mod,
+ "save_permanent_allowlist",
+ lambda patterns: store.clear() or store.update(patterns),
+ )
+ monkeypatch.setattr(approval_mod, "_permanent_approved", set())
+
+ add_result = await runner._handle_allowlist_command(_make_event("/allowlist add recursive delete"))
+ list_result = await runner._handle_allowlist_command(_make_event("/allowlist"))
+ remove_result = await runner._handle_allowlist_command(_make_event("/allowlist remove recursive delete"))
+
+ assert "Added `recursive delete`" in add_result
+ assert "🛡️ **Command Allowlist**" in list_result
+ assert "`recursive delete`" in list_result
+ assert "Removed `recursive delete`" in remove_result
+
+
+@pytest.mark.asyncio
+async def test_config_command_sets_gets_and_unsets_values(monkeypatch):
+ runner = _make_runner()
+
+ set_result = await runner._handle_config_command(
+ _make_event("/config set terminal.backend docker")
+ )
+ get_result = await runner._handle_config_command(
+ _make_event("/config get terminal.backend")
+ )
+ unset_result = await runner._handle_config_command(
+ _make_event("/config unset terminal.backend")
+ )
+
+ assert "Updated `terminal.backend`" in set_result
+ assert "docker" in get_result
+ assert "Removed `terminal.backend`" in unset_result
+
+
+@pytest.mark.asyncio
+async def test_context_command_renders_list_detail_and_json():
+ history = [
+ {"role": "user", "content": "hello"},
+ {"role": "assistant", "content": "hi"},
+ {"role": "tool", "content": "done"},
+ {"role": "assistant", "content": "all set"},
+ ]
+ runner = _make_runner(history)
+
+ list_result = await runner._handle_context_command(_make_event("/context"))
+ detail_result = await runner._handle_context_command(_make_event("/context detail"))
+ json_result = await runner._handle_context_command(_make_event("/context json"))
+
+ assert "🧠 **Hermes Context**" in list_result
+ assert "Messages: 4" in list_result
+ assert "**Prompt Sources**" in detail_result
+ payload = json.loads(json_result.removeprefix("```json\n").removesuffix("\n```"))
+ assert payload["message_count"] == 4
+ assert payload["role_counts"]["assistant"] == 2
+
+
+@pytest.mark.asyncio
+async def test_export_session_writes_html_and_json(tmp_path, monkeypatch):
+ history = [
+ {"role": "user", "content": "hello"},
+ {"role": "assistant", "content": "hi"},
+ ]
+ runner = _make_runner(history)
+ monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
+
+ html_result = await runner._handle_export_session_command(_make_event("/export-session"))
+ json_path = tmp_path / "session.json"
+ json_result = await runner._handle_export_session_command(
+ _make_event(f"/export-session {json_path}")
+ )
+
+ html_path = next((tmp_path / ".hermes-exports").glob("*.html"))
+ assert html_path.exists()
+ assert "Hermes Session Export" in html_path.read_text(encoding="utf-8")
+ assert json_path.exists()
+ payload = json.loads(json_path.read_text(encoding="utf-8"))
+ assert payload["session"]["session_id"] == "sess-1"
+ assert len(payload["messages"]) == 2
+ assert "Exported the current session" in html_result
+ assert "Format: JSON" in json_result
+
+
+def test_prepare_skill_command_lists_and_rewrites(monkeypatch):
+ runner = _make_runner()
+ event = _make_event("/skill summarize docs")
+
+ monkeypatch.setattr(
+ "agent.skill_commands.build_skill_invocation_message",
+ lambda cmd_key, user_instruction, task_id=None: f"prepared:{cmd_key}:{user_instruction}:{task_id}",
+ )
+ prepared, error = runner._prepare_skill_command(event, task_id="sess-123")
+
+ assert error is None
+ assert prepared == "prepared:/summarize:docs:sess-123"
+ assert event.text == prepared
+
+
+@pytest.mark.asyncio
+async def test_subagents_command_lists_empty_runtime():
+ runner = _make_runner()
+ result = await runner._handle_message(_make_event("/subagents list"))
+
+ assert "No subagents recorded" in result
+
+
+@pytest.mark.asyncio
+async def test_compact_command_passes_instructions_to_compressor(monkeypatch):
+ history = [
+ {"role": "user", "content": "message one"},
+ {"role": "assistant", "content": "reply one"},
+ {"role": "user", "content": "message two"},
+ {"role": "assistant", "content": "reply two"},
+ ]
+ runner = _make_runner(history)
+ captured: dict[str, str] = {}
+
+ class FakeAgent:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def _compress_context(self, msgs, instructions, approx_tokens=None):
+ captured["instructions"] = instructions
+ return ([{"role": "system", "content": "summary"}], None)
+
+ monkeypatch.setattr("gateway.run._resolve_runtime_agent_kwargs", lambda: {"api_key": "secret"})
+ monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "anthropic/claude-opus-4.6")
+ monkeypatch.setattr("run_agent.AIAgent", FakeAgent)
+ monkeypatch.setattr("agent.model_metadata.estimate_messages_tokens_rough", lambda _msgs: 123)
+
+ result = await runner._handle_compress_command(_make_event("/compact keep TODOs and decisions"))
+
+ assert captured["instructions"] == "keep TODOs and decisions"
+ assert "Preserved guidance" in result
+ runner.session_store.rewrite_transcript.assert_called_once()
diff --git a/tests/gateway/test_discord_command_ux.py b/tests/gateway/test_discord_command_ux.py
new file mode 100644
index 000000000000..27fca1d86104
--- /dev/null
+++ b/tests/gateway/test_discord_command_ux.py
@@ -0,0 +1,180 @@
+"""Tests for Discord command UX additions: /approve and /think."""
+
+from datetime import datetime
+import importlib
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from gateway.config import GatewayConfig, Platform, PlatformConfig
+from gateway.platforms.base import MessageEvent, MessageType
+from gateway.session import SessionEntry, SessionSource, build_session_key
+
+
+def _make_source(**overrides) -> SessionSource:
+ data = {
+ "platform": Platform.DISCORD,
+ "user_id": "u1",
+ "chat_id": "c1",
+ "user_name": "alan",
+ "chat_name": "Hermes / #general",
+ "chat_type": "group",
+ }
+ data.update(overrides)
+ return SessionSource(**data)
+
+
+def _make_runner():
+ from gateway.run import GatewayRunner
+
+ runner = object.__new__(GatewayRunner)
+ runner.config = GatewayConfig(
+ platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="***")}
+ )
+ adapter = MagicMock()
+ adapter.send = AsyncMock()
+ adapter.send_exec_approval = AsyncMock()
+ runner.adapters = {Platform.DISCORD: adapter}
+ runner.session_store = MagicMock()
+ runner.session_store._generate_session_key = lambda source: build_session_key(source)
+ runner._pending_approvals = {}
+ runner._voice_mode = {}
+ runner._running_agents = {}
+ runner._pending_messages = {}
+ runner._effective_model = None
+ runner._effective_provider = None
+ runner._resolve_model_runtime_details = lambda _provider: ({}, None)
+ runner._load_current_model_selection = lambda: {
+ "current_model": "anthropic/claude-opus-4.6",
+ "current_provider": "openrouter",
+ }
+ runner._reasoning_config = {"enabled": True, "effort": "medium"}
+ runner._show_reasoning = False
+ return runner
+
+
+@pytest.mark.asyncio
+async def test_approve_command_resolves_current_pending_from_command_target(monkeypatch):
+ runner = _make_runner()
+ target_source = _make_source()
+ slash_source = _make_source(session_namespace="slash:u1")
+ session_key = build_session_key(target_source)
+ runner._pending_approvals[session_key] = {
+ "approval_id": "appr-1234",
+ "command": "rm -rf /tmp/test",
+ "pattern_keys": ["recursive delete"],
+ "session_key": session_key,
+ }
+
+ approve_calls: list[tuple[str, str]] = []
+ monkeypatch.setattr(
+ "tools.approval.approve_session",
+ lambda key, pattern: approve_calls.append((key, pattern)),
+ )
+ terminal_module = importlib.import_module("tools.terminal_tool")
+ monkeypatch.setattr(
+ terminal_module,
+ "terminal_tool",
+ lambda **kwargs: "command output",
+ )
+
+ event = MessageEvent(
+ text="/approve allow-once",
+ message_type=MessageType.COMMAND,
+ source=target_source,
+ message_id="m1",
+ metadata={
+ "session_source": slash_source,
+ "command_target_source": target_source,
+ },
+ )
+
+ result = await runner._handle_approve_command(event)
+
+ assert "approved (allow-once)" in result
+ assert "command output" in result
+ assert approve_calls == [(session_key, "recursive delete")]
+ assert session_key not in runner._pending_approvals
+
+
+@pytest.mark.asyncio
+async def test_approve_command_allow_always_uses_id_lookup(monkeypatch):
+ runner = _make_runner()
+ target_source = _make_source()
+ session_key = build_session_key(target_source)
+ runner._pending_approvals[session_key] = {
+ "approval_id": "appr-9999",
+ "command": "rm -rf /tmp/test",
+ "pattern_keys": ["recursive delete", "tirith:shortened_url"],
+ "session_key": session_key,
+ }
+
+ approval_calls = {
+ "session": [],
+ "permanent": [],
+ "saved": [],
+ }
+ monkeypatch.setattr(
+ "tools.approval.approve_session",
+ lambda key, pattern: approval_calls["session"].append((key, pattern)),
+ )
+ monkeypatch.setattr(
+ "tools.approval.approve_permanent",
+ lambda pattern: approval_calls["permanent"].append(pattern),
+ )
+ monkeypatch.setattr(
+ "tools.approval.save_permanent_allowlist",
+ lambda patterns: approval_calls["saved"].append(set(patterns)),
+ )
+ monkeypatch.setattr(
+ "tools.approval._permanent_approved",
+ {"recursive delete", "tirith:shortened_url"},
+ )
+ terminal_module = importlib.import_module("tools.terminal_tool")
+ monkeypatch.setattr(
+ terminal_module,
+ "terminal_tool",
+ lambda **kwargs: "command output",
+ )
+
+ event = MessageEvent(
+ text="/approve appr-9999 allow-always",
+ message_type=MessageType.COMMAND,
+ source=_make_source(user_id="u2"),
+ message_id="m1",
+ )
+
+ result = await runner._handle_approve_command(event)
+
+ assert "approved (allow-always)" in result
+ assert approval_calls["session"] == [
+ (session_key, "recursive delete"),
+ (session_key, "tirith:shortened_url"),
+ ]
+ assert approval_calls["permanent"] == ["recursive delete", "tirith:shortened_url"]
+ assert approval_calls["saved"] == [{"recursive delete", "tirith:shortened_url"}]
+
+
+@pytest.mark.asyncio
+async def test_think_command_routes_to_reasoning_effort(monkeypatch):
+ runner = _make_runner()
+
+ monkeypatch.setattr(
+ runner,
+ "_handle_reasoning_command",
+ AsyncMock(return_value="🧠 reasoning updated"),
+ )
+ event = MessageEvent(
+ text="/think high",
+ message_type=MessageType.COMMAND,
+ source=_make_source(),
+ message_id="m1",
+ )
+
+ result = await runner._handle_think_command(event)
+
+ assert result == "🧠 reasoning updated"
+ think_event = runner._handle_reasoning_command.await_args.args[0]
+ assert think_event.text == "/reasoning high"
+ assert think_event.source.chat_id == "c1"
diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py
index bf8d4a2920b7..fd57f4ffdf17 100644
--- a/tests/gateway/test_discord_free_response.py
+++ b/tests/gateway/test_discord_free_response.py
@@ -139,6 +139,48 @@ async def test_discord_free_response_in_server_channels(adapter, monkeypatch):
assert event.source.chat_type == "group"
+@pytest.mark.asyncio
+async def test_discord_platform_extra_overrides_env_for_mention_policy(monkeypatch):
+ monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
+ monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
+
+ config = PlatformConfig(
+ enabled=True,
+ token="fake-token",
+ extra={"require_mention": False},
+ )
+ adapter = DiscordAdapter(config)
+ adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
+ adapter.handle_message = AsyncMock()
+
+ message = make_message(channel=FakeTextChannel(channel_id=123), content="hello from channel")
+
+ await adapter._handle_message(message)
+
+ adapter.handle_message.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_discord_platform_extra_overrides_env_for_free_response_channels(monkeypatch):
+ monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
+ monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "999")
+
+ config = PlatformConfig(
+ enabled=True,
+ token="fake-token",
+ extra={"free_response_channels": ["123"]},
+ )
+ adapter = DiscordAdapter(config)
+ adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
+ adapter.handle_message = AsyncMock()
+
+ message = make_message(channel=FakeTextChannel(channel_id=123), content="allowed without mention")
+
+ await adapter._handle_message(message)
+
+ adapter.handle_message.assert_awaited_once()
+
+
@pytest.mark.asyncio
async def test_discord_free_response_in_threads(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
@@ -358,3 +400,68 @@ async def test_discord_thread_participation_tracked_on_dispatch(adapter, monkeyp
await adapter._handle_message(message)
assert "777" in adapter._bot_participated_threads
+
+
+@pytest.mark.asyncio
+async def test_discord_focused_thread_skips_mention_requirement(adapter, monkeypatch):
+ monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
+ monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
+ monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
+
+ parent = FakeTextChannel(channel_id=222, name="general")
+ thread = FakeThread(channel_id=333, name="focused-thread", parent=parent)
+ adapter.focus_thread_binding(
+ thread_id="333",
+ session_key="discord:thread:333",
+ chat_name="focused-thread",
+ parent_chat_id="222",
+ bound_by="alan",
+ )
+
+ message = make_message(channel=thread, content="follow-up without mention")
+
+ await adapter._handle_message(message)
+
+ adapter.handle_message.assert_awaited_once()
+ event = adapter.handle_message.await_args.args[0]
+ assert event.source.chat_type == "thread"
+ assert event.text == "follow-up without mention"
+
+
+@pytest.mark.asyncio
+async def test_discord_unfocused_thread_requires_mention_again(adapter, monkeypatch):
+ monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
+ monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
+ monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
+
+ parent = FakeTextChannel(channel_id=222, name="general")
+ thread = FakeThread(channel_id=334, name="focused-thread", parent=parent)
+ adapter.focus_thread_binding(
+ thread_id="334",
+ session_key="discord:thread:334",
+ chat_name="focused-thread",
+ parent_chat_id="222",
+ bound_by="alan",
+ )
+ adapter.unfocus_thread_binding("334")
+
+ message = make_message(channel=thread, content="follow-up without mention")
+
+ await adapter._handle_message(message)
+
+ adapter.handle_message.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_discord_activation_override_always_allows_channel_without_mention(adapter, monkeypatch):
+ monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
+ monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
+
+ adapter.set_activation_mode("789", "always")
+ message = make_message(channel=FakeTextChannel(channel_id=789), content="allowed without mention")
+
+ await adapter._handle_message(message)
+
+ adapter.handle_message.assert_awaited_once()
+ event = adapter.handle_message.await_args.args[0]
+ assert event.text == "allowed without mention"
diff --git a/tests/gateway/test_discord_impl_components.py b/tests/gateway/test_discord_impl_components.py
new file mode 100644
index 000000000000..54b7ac7ddbd6
--- /dev/null
+++ b/tests/gateway/test_discord_impl_components.py
@@ -0,0 +1,320 @@
+from types import ModuleType, SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+import importlib
+import sys
+
+import pytest
+
+
+class FakeView:
+ def __init__(self, timeout=None):
+ self.timeout = timeout
+ self.children = []
+
+ def add_item(self, item):
+ item.view = self
+ self.children.append(item)
+
+
+class FakeButton:
+ def __init__(
+ self,
+ *,
+ label=None,
+ style=None,
+ custom_id=None,
+ row=None,
+ disabled=False,
+ emoji=None,
+ url=None,
+ ):
+ self.label = label
+ self.style = style
+ self.custom_id = custom_id
+ self.row = row
+ self.disabled = disabled
+ self.emoji = emoji
+ self.url = url
+ self.view = None
+
+
+class FakeSelect:
+ def __init__(
+ self,
+ *,
+ placeholder=None,
+ min_values=1,
+ max_values=1,
+ options=None,
+ custom_id=None,
+ row=None,
+ disabled=False,
+ ):
+ self.placeholder = placeholder
+ self.min_values = min_values
+ self.max_values = max_values
+ self.options = list(options or [])
+ self.custom_id = custom_id
+ self.row = row
+ self.disabled = disabled
+ self.values = []
+ self.view = None
+
+
+class FakeUserSelect(FakeSelect):
+ pass
+
+
+class FakeRoleSelect(FakeSelect):
+ pass
+
+
+class FakeMentionableSelect(FakeSelect):
+ pass
+
+
+class FakeChannelSelect(FakeSelect):
+ pass
+
+
+class FakeModal:
+ def __init__(self, *, title=None, custom_id=None, timeout=None):
+ self.title = title
+ self.custom_id = custom_id
+ self.timeout = timeout
+ self.children = []
+
+ def add_item(self, item):
+ self.children.append(item)
+
+
+class FakeTextInput:
+ def __init__(
+ self,
+ *,
+ label,
+ placeholder=None,
+ default=None,
+ required=True,
+ min_length=None,
+ max_length=None,
+ style=None,
+ ):
+ self.label = label
+ self.placeholder = placeholder
+ self.default = default
+ self.required = required
+ self.min_length = min_length
+ self.max_length = max_length
+ self.style = style
+ self.value = default or ""
+
+
+def _load_components_module():
+ discord_mod = ModuleType("discord")
+ discord_mod.__file__ = "mock-discord.py"
+ discord_mod.Message = type("Message", (), {})
+ discord_mod.Interaction = object
+ discord_mod.ui = SimpleNamespace(
+ View=FakeView,
+ Button=FakeButton,
+ Select=FakeSelect,
+ UserSelect=FakeUserSelect,
+ RoleSelect=FakeRoleSelect,
+ MentionableSelect=FakeMentionableSelect,
+ ChannelSelect=FakeChannelSelect,
+ Modal=FakeModal,
+ TextInput=FakeTextInput,
+ )
+ discord_mod.ButtonStyle = SimpleNamespace(
+ primary=1,
+ secondary=2,
+ success=3,
+ danger=4,
+ link=5,
+ green=3,
+ blurple=1,
+ red=4,
+ )
+ discord_mod.SelectOption = lambda **kwargs: SimpleNamespace(**kwargs)
+ discord_mod.TextStyle = SimpleNamespace(short="short", paragraph="paragraph")
+
+ sys.modules["discord"] = discord_mod
+ return importlib.reload(importlib.import_module("gateway.platforms.discord_impl.components"))
+
+
+components = _load_components_module()
+
+
+def _interaction(user_id="42"):
+ return SimpleNamespace(
+ user=SimpleNamespace(id=user_id, display_name=f"user-{user_id}"),
+ response=SimpleNamespace(
+ send_message=AsyncMock(),
+ send_modal=AsyncMock(),
+ is_done=lambda: False,
+ ),
+ followup=SimpleNamespace(send=AsyncMock()),
+ )
+
+
+def test_component_custom_id_round_trip():
+ custom_id = components.encode_component_custom_id("cmp_abc123")
+ assert components.decode_component_custom_id(custom_id) == "cmp_abc123"
+
+
+@pytest.mark.asyncio
+async def test_single_use_button_is_consumed_after_callback():
+ runtime = components.DiscordComponentRuntime()
+ seen = []
+
+ async def handler(invocation):
+ seen.append(invocation.entry.entry_id)
+
+ view = components.ManagedComponentView(runtime, timeout=300)
+ button = view.add_button(
+ components.DiscordButtonSpec(label="Run", style="primary", handler=handler)
+ )
+ interaction = _interaction()
+
+ await button.callback(interaction)
+ await button.callback(interaction)
+
+ assert len(seen) == 1
+ interaction.response.send_message.assert_awaited_once_with(
+ components.DEFAULT_USED_MESSAGE,
+ ephemeral=True,
+ )
+
+
+@pytest.mark.asyncio
+async def test_reusable_button_can_be_clicked_multiple_times():
+ runtime = components.DiscordComponentRuntime()
+ seen = []
+
+ async def handler(invocation):
+ seen.append(invocation.entry.entry_id)
+
+ view = components.ManagedComponentView(runtime, timeout=300)
+ button = view.add_button(
+ components.DiscordButtonSpec(
+ label="Run Again",
+ style="secondary",
+ handler=handler,
+ reusable=True,
+ )
+ )
+ interaction = _interaction()
+
+ await button.callback(interaction)
+ await button.callback(interaction)
+
+ assert len(seen) == 2
+ interaction.response.send_message.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_unauthorized_component_is_denied_consistently():
+ runtime = components.DiscordComponentRuntime()
+ handler = AsyncMock()
+
+ view = components.ManagedComponentView(runtime, timeout=300)
+ button = view.add_button(
+ components.DiscordButtonSpec(
+ label="Secret",
+ style="danger",
+ handler=handler,
+ allowed_user_ids=("42",),
+ )
+ )
+
+ interaction = _interaction(user_id="7")
+ await button.callback(interaction)
+
+ handler.assert_not_awaited()
+ interaction.response.send_message.assert_awaited_once_with(
+ components.DEFAULT_UNAUTHORIZED_MESSAGE,
+ ephemeral=True,
+ )
+
+
+def test_select_family_builders_cover_all_supported_types():
+ runtime = components.DiscordComponentRuntime()
+ view = components.ManagedComponentView(runtime, timeout=300)
+ handler = AsyncMock()
+
+ view.add_select(
+ components.DiscordSelectSpec(
+ select_type="string",
+ handler=handler,
+ options=(
+ components.DiscordSelectOptionSpec(label="One", value="one"),
+ components.DiscordSelectOptionSpec(label="Two", value="two"),
+ ),
+ )
+ )
+ view.add_select(components.DiscordSelectSpec(select_type="user", handler=handler))
+ view.add_select(components.DiscordSelectSpec(select_type="role", handler=handler))
+ view.add_select(components.DiscordSelectSpec(select_type="mentionable", handler=handler))
+ view.add_select(components.DiscordSelectSpec(select_type="channel", handler=handler))
+
+ assert [type(child).__name__ for child in view.children] == [
+ "ManagedStringSelect",
+ "ManagedUserSelect",
+ "ManagedRoleSelect",
+ "ManagedMentionableSelect",
+ "ManagedChannelSelect",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_modal_trigger_and_submission_round_trip():
+ runtime = components.DiscordComponentRuntime()
+ submitted = []
+
+ async def modal_handler(invocation):
+ submitted.append(invocation.values)
+
+ view = components.ManagedComponentView(runtime, timeout=300)
+ trigger = view.add_modal_trigger(
+ components.DiscordModalTriggerSpec(
+ label="Open Form",
+ reusable=True,
+ modal=components.DiscordModalSpec(
+ title="Feedback",
+ handler=modal_handler,
+ fields=(
+ components.DiscordModalFieldSpec(
+ field_id="summary",
+ label="Summary",
+ default="hello",
+ ),
+ ),
+ ),
+ )
+ )
+ interaction = _interaction()
+
+ await trigger.callback(interaction)
+
+ interaction.response.send_modal.assert_awaited_once()
+ modal = interaction.response.send_modal.await_args.args[0]
+ assert modal.title == "Feedback"
+ modal.children[0].value = "shipped"
+
+ submit_interaction = _interaction()
+ await modal.on_submit(submit_interaction)
+
+ assert submitted == [{"summary": "shipped"}]
+
+
+def test_bind_message_records_message_association_for_entries():
+ runtime = components.DiscordComponentRuntime()
+ view = components.ManagedComponentView(runtime, timeout=300)
+ view.add_button(
+ components.DiscordButtonSpec(label="Bind Me", style="primary", handler=AsyncMock())
+ )
+ view.bind_message("555")
+
+ entry = runtime.get_entry(view.entry_ids[0])
+ assert entry.message_id == "555"
diff --git a/tests/gateway/test_discord_impl_config.py b/tests/gateway/test_discord_impl_config.py
new file mode 100644
index 000000000000..47755b146587
--- /dev/null
+++ b/tests/gateway/test_discord_impl_config.py
@@ -0,0 +1,138 @@
+"""Tests for Discord config and policy helpers."""
+
+from gateway.config import PlatformConfig
+from gateway.platforms.discord_impl import config as discord_config
+
+
+def test_clean_discord_id_strips_common_prefixes():
+ assert discord_config.clean_discord_id(" user:123 ") == "123"
+ assert discord_config.clean_discord_id("<@123>") == "123"
+ assert discord_config.clean_discord_id("<@!123>") == "123"
+ assert discord_config.clean_discord_id("teknium") == "teknium"
+
+
+def test_parse_allowed_users_cleans_and_filters_entries():
+ parsed = discord_config.parse_allowed_users(" 123, <@!456>, user:teknium, , <@789> ")
+
+ assert parsed == {"123", "456", "teknium", "789"}
+
+
+def test_parse_free_response_channels_accepts_lists_and_strings():
+ parsed = discord_config.parse_free_response_channels(["123", " 456 ", 789, ""])
+
+ assert parsed == {"123", "456", "789"}
+
+
+def test_get_bot_filter_policy_defaults_to_none(monkeypatch):
+ monkeypatch.delenv("DISCORD_ALLOW_BOTS", raising=False)
+
+ assert discord_config.get_bot_filter_policy() == "none"
+
+
+def test_get_bot_filter_policy_normalizes_case_and_whitespace(monkeypatch):
+ monkeypatch.setenv("DISCORD_ALLOW_BOTS", " Mentions ")
+
+ assert discord_config.get_bot_filter_policy() == "mentions"
+
+
+def test_get_free_response_channels_parses_ids(monkeypatch):
+ monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", " 123,456 , ,789 ")
+
+ assert discord_config.get_free_response_channels() == {"123", "456", "789"}
+
+
+def test_is_mention_required_defaults_true(monkeypatch):
+ monkeypatch.delenv("DISCORD_REQUIRE_MENTION", raising=False)
+
+ assert discord_config.is_mention_required() is True
+
+
+def test_is_mention_required_accepts_falsey_env_values(monkeypatch):
+ for value in ("false", "0", "no"):
+ monkeypatch.setenv("DISCORD_REQUIRE_MENTION", value)
+ assert discord_config.is_mention_required() is False
+
+
+def test_is_auto_thread_enabled_defaults_true(monkeypatch):
+ monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
+
+ assert discord_config.is_auto_thread_enabled() is True
+
+
+def test_is_auto_thread_enabled_accepts_truthy_and_falsey_values(monkeypatch):
+ for value in ("true", "1", "yes"):
+ monkeypatch.setenv("DISCORD_AUTO_THREAD", value)
+ assert discord_config.is_auto_thread_enabled() is True
+
+ monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
+ assert discord_config.is_auto_thread_enabled() is False
+
+
+def test_load_policy_config_reads_env_defaults(monkeypatch):
+ monkeypatch.setenv("DISCORD_ALLOWED_USERS", "123,<@!456>")
+ monkeypatch.setenv("DISCORD_ALLOW_BOTS", "mentions")
+ monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "1,2")
+ monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
+ monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
+
+ policy = discord_config.load_policy_config()
+
+ assert policy.allowed_users == {"123", "456"}
+ assert policy.bot_filter_policy == "mentions"
+ assert policy.free_response_channels == {"1", "2"}
+ assert policy.require_mention is False
+ assert policy.auto_thread is False
+
+
+def test_load_policy_config_prefers_platform_extra_over_env(monkeypatch):
+ monkeypatch.setenv("DISCORD_ALLOWED_USERS", "111")
+ monkeypatch.setenv("DISCORD_ALLOW_BOTS", "none")
+ monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "10")
+ monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
+ monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
+
+ config = PlatformConfig(
+ enabled=True,
+ extra={
+ "allowed_users": ["<@!222>", "user:teknium"],
+ "allow_bots": "all",
+ "free_response_channels": ["20", "30"],
+ "require_mention": False,
+ "auto_thread": False,
+ },
+ )
+
+ policy = discord_config.load_policy_config(config)
+
+ assert policy.allowed_users == {"222", "teknium"}
+ assert policy.bot_filter_policy == "all"
+ assert policy.free_response_channels == {"20", "30"}
+ assert policy.require_mention is False
+ assert policy.auto_thread is False
+
+
+def test_load_policy_config_applies_runtime_overrides_over_platform_extra(monkeypatch):
+ config = PlatformConfig(
+ enabled=True,
+ extra={
+ "allow_bots": "none",
+ "free_response_channels": ["20"],
+ "require_mention": True,
+ "auto_thread": True,
+ },
+ )
+
+ policy = discord_config.load_policy_config(
+ config,
+ overrides={
+ "allow_bots": "mentions",
+ "free_response_channels": ["44", "55"],
+ "require_mention": False,
+ "auto_thread": False,
+ },
+ )
+
+ assert policy.bot_filter_policy == "mentions"
+ assert policy.free_response_channels == {"44", "55"}
+ assert policy.require_mention is False
+ assert policy.auto_thread is False
diff --git a/tests/gateway/test_discord_impl_delivery.py b/tests/gateway/test_discord_impl_delivery.py
new file mode 100644
index 000000000000..9dd4c0c947f5
--- /dev/null
+++ b/tests/gateway/test_discord_impl_delivery.py
@@ -0,0 +1,192 @@
+from types import ModuleType, SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+import importlib
+import sys
+
+import pytest
+
+
+def _load_delivery_module():
+ class FakeIntents:
+ @staticmethod
+ def default():
+ return SimpleNamespace()
+
+ discord_mod = ModuleType("discord")
+ discord_mod.__file__ = "mock-discord.py"
+ discord_mod.Message = type("Message", (), {})
+ discord_mod.Intents = FakeIntents
+ discord_mod.Client = MagicMock
+ discord_mod.File = MagicMock()
+ discord_mod.DMChannel = type("DMChannel", (), {})
+ discord_mod.Thread = type("Thread", (), {})
+ discord_mod.ForumChannel = type("ForumChannel", (), {})
+ discord_mod.ui = SimpleNamespace(
+ View=object,
+ button=lambda *a, **k: (lambda fn: fn),
+ Button=object,
+ )
+ discord_mod.ButtonStyle = SimpleNamespace(
+ success=1,
+ primary=2,
+ danger=3,
+ green=1,
+ blurple=2,
+ red=3,
+ )
+ discord_mod.Color = SimpleNamespace(
+ orange=lambda: 1,
+ green=lambda: 2,
+ blue=lambda: 3,
+ red=lambda: 4,
+ )
+ discord_mod.Interaction = object
+ discord_mod.Embed = MagicMock
+ discord_mod.app_commands = SimpleNamespace(
+ describe=lambda **kwargs: (lambda fn: fn),
+ choices=lambda **kwargs: (lambda fn: fn),
+ Choice=lambda **kwargs: SimpleNamespace(**kwargs),
+ )
+ discord_mod.opus = SimpleNamespace(
+ is_loaded=lambda: True,
+ load_opus=lambda *_args, **_kwargs: None,
+ Decoder=MagicMock,
+ )
+ discord_mod.FFmpegPCMAudio = MagicMock
+ discord_mod.PCMVolumeTransformer = MagicMock
+ discord_mod.http = SimpleNamespace(Route=MagicMock)
+
+ sys.modules["discord"] = discord_mod
+ ext_mod = ModuleType("discord.ext")
+ commands_mod = ModuleType("discord.ext.commands")
+ commands_mod.Bot = MagicMock
+ ext_mod.commands = commands_mod
+ sys.modules["discord.ext"] = ext_mod
+ sys.modules["discord.ext.commands"] = commands_mod
+ return importlib.reload(importlib.import_module("gateway.platforms.discord_impl.delivery"))
+
+
+delivery = _load_delivery_module()
+
+
+@pytest.mark.asyncio
+async def test_resolve_channel_returns_cached_channel():
+ channel = SimpleNamespace(id=123)
+ client = SimpleNamespace(
+ get_channel=MagicMock(return_value=channel),
+ fetch_channel=AsyncMock(),
+ )
+
+ result = await delivery.resolve_channel(client, "123")
+
+ assert result is channel
+ client.fetch_channel.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_resolve_channel_fetches_when_not_cached():
+ channel = SimpleNamespace(id=456)
+ client = SimpleNamespace(
+ get_channel=MagicMock(return_value=None),
+ fetch_channel=AsyncMock(return_value=channel),
+ )
+
+ result = await delivery.resolve_channel(client, "456")
+
+ assert result is channel
+ client.fetch_channel.assert_awaited_once_with(456)
+
+
+@pytest.mark.asyncio
+async def test_resolve_channel_returns_none_when_missing():
+ client = SimpleNamespace(
+ get_channel=MagicMock(return_value=None),
+ fetch_channel=AsyncMock(return_value=None),
+ )
+
+ result = await delivery.resolve_channel(client, "789")
+
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_send_text_message_sends_normally():
+ message = SimpleNamespace(id=1)
+ channel = SimpleNamespace(send=AsyncMock(return_value=message))
+
+ result = await delivery.send_text_message(channel, "hello")
+
+ assert result is message
+ channel.send.assert_awaited_once_with(content="hello", reference=None)
+
+
+@pytest.mark.asyncio
+async def test_send_text_message_retries_without_reference_for_system_message():
+ message = SimpleNamespace(id=2)
+ send_calls = []
+
+ async def fake_send(*, content, reference=None):
+ send_calls.append({"content": content, "reference": reference})
+ if len(send_calls) == 1:
+ raise RuntimeError(
+ "400 Bad Request (error code: 50035): Invalid Form Body\n"
+ "In message_reference: Cannot reply to a system message"
+ )
+ return message
+
+ reference = SimpleNamespace(id=99)
+ channel = SimpleNamespace(send=AsyncMock(side_effect=fake_send))
+
+ result = await delivery.send_text_message(channel, "hello", reference=reference)
+
+ assert result is message
+ assert send_calls == [
+ {"content": "hello", "reference": reference},
+ {"content": "hello", "reference": None},
+ ]
+
+
+@pytest.mark.asyncio
+async def test_send_file_attachment_succeeds(tmp_path, monkeypatch):
+ file_path = tmp_path / "sample.txt"
+ file_path.write_text("hello", encoding="utf-8")
+
+ sent_message = SimpleNamespace(id=42)
+ channel = SimpleNamespace(send=AsyncMock(return_value=sent_message))
+ client = SimpleNamespace(
+ get_channel=MagicMock(return_value=channel),
+ fetch_channel=AsyncMock(),
+ )
+ file_cls = MagicMock()
+ monkeypatch.setattr(delivery.discord, "File", file_cls)
+
+ result = await delivery.send_file_attachment(client, "123", str(file_path), caption="cap")
+
+ assert result.success is True
+ assert result.message_id == "42"
+ channel.send.assert_awaited_once()
+ assert file_cls.call_args.kwargs["filename"] == "sample.txt"
+
+
+@pytest.mark.asyncio
+async def test_send_file_attachment_returns_not_connected_error():
+ result = await delivery.send_file_attachment(None, "123", "/tmp/missing.txt")
+
+ assert result.success is False
+ assert result.error == "Not connected"
+
+
+@pytest.mark.asyncio
+async def test_send_file_attachment_returns_channel_not_found_error(tmp_path):
+ file_path = tmp_path / "sample.txt"
+ file_path.write_text("hello", encoding="utf-8")
+
+ client = SimpleNamespace(
+ get_channel=MagicMock(return_value=None),
+ fetch_channel=AsyncMock(return_value=None),
+ )
+
+ result = await delivery.send_file_attachment(client, "123", str(file_path))
+
+ assert result.success is False
+ assert result.error == "Channel 123 not found"
diff --git a/tests/gateway/test_discord_impl_history.py b/tests/gateway/test_discord_impl_history.py
new file mode 100644
index 000000000000..8d2d4c9fbc24
--- /dev/null
+++ b/tests/gateway/test_discord_impl_history.py
@@ -0,0 +1,342 @@
+from datetime import datetime, timezone
+from types import ModuleType, SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+import importlib
+import sys
+
+import pytest
+
+def _install_discord_mock():
+ class FakeIntents:
+ @staticmethod
+ def default():
+ return SimpleNamespace()
+
+ discord_mod = ModuleType("discord")
+ discord_mod.__file__ = "mock-discord.py"
+ discord_mod.Message = type("Message", (), {})
+ discord_mod.Intents = FakeIntents
+ discord_mod.Client = MagicMock
+ discord_mod.File = MagicMock
+ discord_mod.DMChannel = type("DMChannel", (), {})
+ discord_mod.Thread = type("Thread", (), {})
+ discord_mod.ForumChannel = type("ForumChannel", (), {})
+ discord_mod.ui = SimpleNamespace(
+ View=object,
+ button=lambda *a, **k: (lambda fn: fn),
+ Button=object,
+ )
+ discord_mod.ButtonStyle = SimpleNamespace(
+ success=1,
+ primary=2,
+ danger=3,
+ green=1,
+ blurple=2,
+ red=3,
+ )
+ discord_mod.Color = SimpleNamespace(
+ orange=lambda: 1,
+ green=lambda: 2,
+ blue=lambda: 3,
+ red=lambda: 4,
+ )
+ discord_mod.Interaction = object
+ discord_mod.Embed = MagicMock
+ discord_mod.app_commands = SimpleNamespace(
+ describe=lambda **kwargs: (lambda fn: fn),
+ choices=lambda **kwargs: (lambda fn: fn),
+ Choice=lambda **kwargs: SimpleNamespace(**kwargs),
+ )
+ discord_mod.opus = SimpleNamespace(
+ is_loaded=lambda: True,
+ load_opus=lambda *_args, **_kwargs: None,
+ Decoder=MagicMock,
+ )
+ discord_mod.FFmpegPCMAudio = MagicMock
+ discord_mod.PCMVolumeTransformer = MagicMock
+ discord_mod.http = SimpleNamespace(Route=MagicMock)
+
+ sys.modules["discord"] = discord_mod
+ ext_mod = ModuleType("discord.ext")
+ commands_mod = ModuleType("discord.ext.commands")
+ commands_mod.Bot = MagicMock
+ ext_mod.commands = commands_mod
+ sys.modules["discord.ext"] = ext_mod
+ sys.modules["discord.ext.commands"] = commands_mod
+
+
+def _load_history_module():
+ _install_discord_mock()
+ importlib.reload(importlib.import_module("gateway.platforms.discord_impl.delivery"))
+ importlib.reload(importlib.import_module("gateway.platforms.discord_impl.permissions"))
+ return importlib.reload(importlib.import_module("gateway.platforms.discord_impl.history"))
+
+
+history = _load_history_module()
+
+
+class FakeHistoryIterator:
+ def __init__(self, messages):
+ self._messages = list(messages)
+ self._index = 0
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self):
+ if self._index >= len(self._messages):
+ raise StopAsyncIteration
+ message = self._messages[self._index]
+ self._index += 1
+ return message
+
+
+class FakeHistoryChannel:
+ def __init__(self, messages, readable=True):
+ self.id = 123
+ self.name = "general"
+ self.guild = SimpleNamespace(me=SimpleNamespace(id=999), name="Hermes")
+ self._messages = list(messages)
+ self.last_history_kwargs = None
+ self.history_calls = 0
+ self.permissions_for = MagicMock(
+ return_value=SimpleNamespace(
+ view_channel=readable,
+ read_message_history=readable,
+ send_messages=True,
+ attach_files=True,
+ embed_links=True,
+ add_reactions=True,
+ manage_threads=False,
+ create_public_threads=False,
+ create_private_threads=False,
+ )
+ )
+
+ def history(self, *, limit, before=None, after=None):
+ self.history_calls += 1
+ self.last_history_kwargs = {
+ "limit": limit,
+ "before": before,
+ "after": after,
+ }
+
+ filtered = list(self._messages)
+ if before is not None:
+ filtered = [message for message in filtered if int(message.id) < int(before.id)]
+ if after is not None:
+ filtered = [message for message in filtered if int(message.id) > int(after.id)]
+ return FakeHistoryIterator(filtered[:limit])
+
+
+def _make_message(
+ message_id,
+ *,
+ content,
+ author_id="42",
+ author_name="Jezza",
+ is_bot=False,
+ timestamp=None,
+ attachments=None,
+ reply_to=None,
+):
+ if timestamp is None:
+ timestamp = datetime(2026, 3, 18, 12, 0, 0, tzinfo=timezone.utc)
+ reference = None if reply_to is None else SimpleNamespace(message_id=reply_to)
+ return SimpleNamespace(
+ id=message_id,
+ author=SimpleNamespace(id=author_id, name=author_name, display_name=author_name, bot=is_bot),
+ content=content,
+ created_at=timestamp,
+ attachments=[SimpleNamespace(url=url) for url in (attachments or [])],
+ reference=reference,
+ )
+
+
+def _make_client(channel):
+ return SimpleNamespace(
+ get_channel=MagicMock(return_value=channel),
+ fetch_channel=AsyncMock(return_value=None if channel is None else channel),
+ user=SimpleNamespace(id=999),
+ )
+
+
+@pytest.mark.asyncio
+async def test_fetch_history_returns_messages_and_clamps_limits():
+ messages = [
+ _make_message(message_id, content=f"message {message_id}")
+ for message_id in range(600, 0, -1)
+ ]
+ channel = FakeHistoryChannel(messages)
+ client = _make_client(channel)
+
+ result_min = await history.fetch_history(client, "123", limit=0)
+
+ assert len(result_min) == 1
+ assert channel.last_history_kwargs["limit"] == 1
+
+ result_max = await history.fetch_history(client, "123", limit=999)
+
+ assert len(result_max) == 500
+ assert channel.last_history_kwargs["limit"] == 500
+ assert result_max[0].id == "600"
+ assert result_max[-1].id == "101"
+
+
+@pytest.mark.asyncio
+async def test_fetch_history_applies_before_and_after_filters():
+ messages = [
+ _make_message(message_id, content=f"message {message_id}")
+ for message_id in (105, 104, 103, 102, 101, 100)
+ ]
+ channel = FakeHistoryChannel(messages)
+ client = _make_client(channel)
+
+ result = await history.fetch_history(client, "123", limit=10, before="104", after="101")
+
+ assert [message.id for message in result] == ["103", "102"]
+ assert channel.last_history_kwargs["before"].id == 104
+ assert channel.last_history_kwargs["after"].id == 101
+
+
+@pytest.mark.asyncio
+async def test_fetch_history_returns_empty_for_empty_channel():
+ channel = FakeHistoryChannel([])
+ client = _make_client(channel)
+
+ result = await history.fetch_history(client, "123")
+
+ assert result == []
+
+
+@pytest.mark.asyncio
+async def test_fetch_history_returns_empty_without_read_access():
+ channel = FakeHistoryChannel([_make_message(1, content="hidden")], readable=False)
+ client = _make_client(channel)
+
+ result = await history.fetch_history(client, "123")
+
+ assert result == []
+ assert channel.history_calls == 0
+
+
+@pytest.mark.asyncio
+async def test_fetch_history_returns_empty_for_missing_channel():
+ client = _make_client(None)
+
+ result = await history.fetch_history(client, "123")
+
+ assert result == []
+
+
+def test_history_message_dataclass_fields():
+ message = history.HistoryMessage(
+ id="1",
+ author_id="42",
+ author_name="Jezza",
+ content="hello",
+ timestamp="2026-03-18T12:00:00+00:00",
+ is_bot=False,
+ attachments=["https://example.com/file.png"],
+ reply_to="0",
+ )
+
+ assert message.id == "1"
+ assert message.author_id == "42"
+ assert message.author_name == "Jezza"
+ assert message.content == "hello"
+ assert message.timestamp == "2026-03-18T12:00:00+00:00"
+ assert message.is_bot is False
+ assert message.attachments == ["https://example.com/file.png"]
+ assert message.reply_to == "0"
+
+
+@pytest.mark.asyncio
+async def test_search_history_filters_by_case_insensitive_query():
+ channel = FakeHistoryChannel(
+ [
+ _make_message(10, content="Hermes status update"),
+ _make_message(9, content="unrelated"),
+ _make_message(8, content="another hermes note"),
+ ]
+ )
+ client = _make_client(channel)
+
+ result = await history.search_history(client, "123", "HeRmEs", limit=5)
+
+ assert [message.id for message in result] == ["10", "8"]
+
+
+@pytest.mark.asyncio
+async def test_search_history_filters_by_author_id():
+ channel = FakeHistoryChannel(
+ [
+ _make_message(10, content="deploy update", author_id="1", author_name="Ada"),
+ _make_message(9, content="deploy update", author_id="2", author_name="Linus"),
+ _make_message(8, content="deploy update", author_id="1", author_name="Ada"),
+ ]
+ )
+ client = _make_client(channel)
+
+ result = await history.search_history(client, "123", "deploy", limit=5, author_id="1")
+
+ assert [message.id for message in result] == ["10", "8"]
+ assert all(message.author_id == "1" for message in result)
+
+
+@pytest.mark.asyncio
+async def test_search_history_returns_empty_when_no_matches():
+ channel = FakeHistoryChannel(
+ [
+ _make_message(10, content="hello world"),
+ _make_message(9, content="still nothing"),
+ ]
+ )
+ client = _make_client(channel)
+
+ result = await history.search_history(client, "123", "needle")
+
+ assert result == []
+
+
+@pytest.mark.asyncio
+async def test_search_history_returns_empty_for_missing_channel():
+ client = _make_client(None)
+
+ result = await history.search_history(client, "123", "needle")
+
+ assert result == []
+
+
+@pytest.mark.asyncio
+async def test_search_history_finds_match_beyond_old_limit_multiplier_window():
+ channel = FakeHistoryChannel(
+ [
+ _make_message(message_id, content="needle" if message_id == 130 else f"message {message_id}")
+ for message_id in range(250, 0, -1)
+ ]
+ )
+ client = _make_client(channel)
+
+ result = await history.search_history(client, "123", "needle", limit=1)
+
+ assert [message.id for message in result] == ["130"]
+
+
+@pytest.mark.asyncio
+async def test_search_history_finds_author_filtered_match_beyond_old_limit_multiplier_window():
+ messages = []
+ for message_id in range(250, 0, -1):
+ if 250 >= message_id >= 150:
+ messages.append(_make_message(message_id, content="deploy update", author_id="2"))
+ elif message_id == 130:
+ messages.append(_make_message(message_id, content="deploy update", author_id="1"))
+ else:
+ messages.append(_make_message(message_id, content=f"message {message_id}", author_id="2"))
+
+ channel = FakeHistoryChannel(messages)
+ client = _make_client(channel)
+
+ result = await history.search_history(client, "123", "deploy", limit=1, author_id="1")
+
+ assert [message.id for message in result] == ["130"]
diff --git a/tests/gateway/test_discord_impl_intake.py b/tests/gateway/test_discord_impl_intake.py
new file mode 100644
index 000000000000..6f2d9ce27d02
--- /dev/null
+++ b/tests/gateway/test_discord_impl_intake.py
@@ -0,0 +1,108 @@
+"""Tests for Discord intake and preflight helpers."""
+
+from types import SimpleNamespace
+
+from gateway.platforms.discord_impl import intake as discord_intake
+
+
+class FakeForumChannel:
+ def __init__(self, channel_id=1, name="forum", guild_name="Hermes Server"):
+ self.id = channel_id
+ self.name = name
+ self.guild = SimpleNamespace(name=guild_name)
+ self.type = 15
+
+
+class FakeTextChannel:
+ def __init__(self, channel_id=1, name="general", guild_name="Hermes Server"):
+ self.id = channel_id
+ self.name = name
+ self.guild = SimpleNamespace(name=guild_name)
+
+
+class FakeThread:
+ def __init__(self, channel_id=1, name="thread", parent=None, guild_name="Hermes Server"):
+ self.id = channel_id
+ self.name = name
+ self.parent = parent
+ self.parent_id = getattr(parent, "id", None)
+ self.guild = getattr(parent, "guild", None) or SimpleNamespace(name=guild_name)
+
+
+def test_should_filter_bot_message_matches_policy():
+ assert discord_intake.should_filter_bot_message(False, "none", False) is False
+ assert discord_intake.should_filter_bot_message(True, "none", False) is True
+ assert discord_intake.should_filter_bot_message(True, "mentions", False) is True
+ assert discord_intake.should_filter_bot_message(True, "mentions", True) is False
+ assert discord_intake.should_filter_bot_message(True, "all", False) is False
+ assert discord_intake.should_filter_bot_message(True, "weird", False) is False
+
+
+def test_should_skip_for_mention_matches_gate_conditions():
+ assert discord_intake.should_skip_for_mention(True, False, False, False) is True
+ assert discord_intake.should_skip_for_mention(True, True, False, False) is False
+ assert discord_intake.should_skip_for_mention(True, False, True, False) is False
+ assert discord_intake.should_skip_for_mention(True, False, False, True) is False
+ assert discord_intake.should_skip_for_mention(False, False, False, False) is False
+
+
+def test_strip_mention_removes_both_mention_forms():
+ assert discord_intake.strip_mention("<@123> hello", 123) == "hello"
+ assert discord_intake.strip_mention("<@!123> hello", 123) == "hello"
+ assert discord_intake.strip_mention("before <@123> and <@!123> after", 123) == "before and after"
+
+
+def test_classify_message_type_prefers_commands_and_attachment_types():
+ assert discord_intake.classify_message_type("/status", []) == "command"
+ assert discord_intake.classify_message_type("hello", [SimpleNamespace(content_type="image/png")]) == "photo"
+ assert discord_intake.classify_message_type("hello", [SimpleNamespace(content_type="video/mp4")]) == "video"
+ assert discord_intake.classify_message_type("hello", [SimpleNamespace(content_type="audio/ogg")]) == "audio"
+ assert discord_intake.classify_message_type("hello", [SimpleNamespace(content_type="application/pdf")]) == "document"
+ assert discord_intake.classify_message_type("hello", [SimpleNamespace(content_type=None)]) == "text"
+
+
+def test_get_parent_channel_id_prefers_parent_object():
+ parent = SimpleNamespace(id=222)
+ channel = SimpleNamespace(parent=parent, parent_id=333)
+
+ assert discord_intake.get_parent_channel_id(channel) == "222"
+
+
+def test_get_parent_channel_id_falls_back_to_parent_id():
+ channel = SimpleNamespace(parent=None, parent_id=333)
+
+ assert discord_intake.get_parent_channel_id(channel) == "333"
+
+
+def test_is_forum_parent_checks_discord_class_and_type(monkeypatch):
+ monkeypatch.setattr(
+ discord_intake,
+ "discord",
+ SimpleNamespace(ForumChannel=FakeForumChannel),
+ raising=False,
+ )
+
+ assert discord_intake.is_forum_parent(FakeForumChannel()) is True
+ assert discord_intake.is_forum_parent(SimpleNamespace(type=15)) is True
+ assert discord_intake.is_forum_parent(SimpleNamespace(type=0)) is False
+ assert discord_intake.is_forum_parent(None) is False
+
+
+def test_format_thread_chat_name_includes_forum_context():
+ forum = FakeForumChannel(name="support-forum")
+ thread = FakeThread(name="Forum topic", parent=forum)
+
+ assert (
+ discord_intake.format_thread_chat_name(thread, discord_intake.is_forum_parent)
+ == "Hermes Server / support-forum / Forum topic"
+ )
+
+
+def test_format_thread_chat_name_formats_regular_threads():
+ parent = FakeTextChannel(name="general")
+ thread = FakeThread(name="Follow-up", parent=parent)
+
+ assert (
+ discord_intake.format_thread_chat_name(thread, discord_intake.is_forum_parent)
+ == "Hermes Server / #general / Follow-up"
+ )
diff --git a/tests/gateway/test_discord_impl_interactions.py b/tests/gateway/test_discord_impl_interactions.py
new file mode 100644
index 000000000000..df5260c3c087
--- /dev/null
+++ b/tests/gateway/test_discord_impl_interactions.py
@@ -0,0 +1,470 @@
+from types import ModuleType, SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+import importlib
+import sys
+
+import pytest
+
+from gateway.config import Platform
+from gateway.session import SessionSource
+from gateway.platforms.base import MessageType
+
+
+class FakeView:
+ def __init__(self, timeout=None):
+ self.timeout = timeout
+ self.children = []
+
+ def add_item(self, item):
+ item.view = self
+ self.children.append(item)
+
+
+class FakeButton:
+ def __init__(
+ self,
+ *,
+ label=None,
+ style=None,
+ custom_id=None,
+ row=None,
+ disabled=False,
+ emoji=None,
+ url=None,
+ ):
+ self.label = label
+ self.style = style
+ self.custom_id = custom_id
+ self.row = row
+ self.disabled = disabled
+ self.emoji = emoji
+ self.url = url
+ self.view = None
+
+
+class FakeSelect:
+ def __init__(
+ self,
+ *,
+ placeholder=None,
+ min_values=1,
+ max_values=1,
+ options=None,
+ custom_id=None,
+ row=None,
+ disabled=False,
+ ):
+ self.placeholder = placeholder
+ self.min_values = min_values
+ self.max_values = max_values
+ self.options = list(options or [])
+ self.custom_id = custom_id
+ self.row = row
+ self.disabled = disabled
+ self.values = []
+ self.view = None
+
+
+class FakeUserSelect(FakeSelect):
+ pass
+
+
+class FakeRoleSelect(FakeSelect):
+ pass
+
+
+class FakeMentionableSelect(FakeSelect):
+ pass
+
+
+class FakeChannelSelect(FakeSelect):
+ pass
+
+
+class FakeModal:
+ def __init__(self, *, title=None, custom_id=None, timeout=None):
+ self.title = title
+ self.custom_id = custom_id
+ self.timeout = timeout
+ self.children = []
+
+ def add_item(self, item):
+ self.children.append(item)
+
+
+class FakeTextInput:
+ def __init__(
+ self,
+ *,
+ label,
+ placeholder=None,
+ default=None,
+ required=True,
+ min_length=None,
+ max_length=None,
+ style=None,
+ ):
+ self.label = label
+ self.placeholder = placeholder
+ self.default = default
+ self.required = required
+ self.min_length = min_length
+ self.max_length = max_length
+ self.style = style
+ self.value = default or ""
+
+
+class FakeEmbed:
+ def __init__(self):
+ self.color = None
+ self.footer_text = None
+
+ def set_footer(self, *, text):
+ self.footer_text = text
+
+
+class FakeTree:
+ def __init__(self):
+ self.commands = {}
+
+ def command(self, *, name, description):
+ def decorator(fn):
+ self.commands[name] = fn
+ return fn
+
+ return decorator
+
+
+def _load_interactions_module():
+ class FakeIntents:
+ @staticmethod
+ def default():
+ return SimpleNamespace()
+
+ discord_mod = ModuleType("discord")
+ discord_mod.__file__ = "mock-discord.py"
+ discord_mod.Message = type("Message", (), {})
+ discord_mod.Intents = FakeIntents
+ discord_mod.Client = MagicMock
+ discord_mod.File = MagicMock
+ discord_mod.DMChannel = type("DMChannel", (), {})
+ discord_mod.Thread = type("Thread", (), {})
+ discord_mod.ForumChannel = type("ForumChannel", (), {})
+ discord_mod.Interaction = object
+ discord_mod.Embed = MagicMock
+ discord_mod.ui = SimpleNamespace(
+ View=FakeView,
+ Button=FakeButton,
+ Select=FakeSelect,
+ UserSelect=FakeUserSelect,
+ RoleSelect=FakeRoleSelect,
+ MentionableSelect=FakeMentionableSelect,
+ ChannelSelect=FakeChannelSelect,
+ Modal=FakeModal,
+ TextInput=FakeTextInput,
+ button=lambda *a, **k: (lambda fn: fn),
+ )
+ discord_mod.ButtonStyle = SimpleNamespace(
+ primary=1,
+ secondary=2,
+ success=3,
+ danger=4,
+ link=5,
+ green=3,
+ blurple=1,
+ red=4,
+ )
+ discord_mod.Color = SimpleNamespace(
+ green=lambda: "green",
+ blue=lambda: "blue",
+ red=lambda: "red",
+ orange=lambda: "orange",
+ )
+ discord_mod.SelectOption = lambda **kwargs: SimpleNamespace(**kwargs)
+ discord_mod.TextStyle = SimpleNamespace(short="short", paragraph="paragraph")
+ discord_mod.app_commands = SimpleNamespace(
+ describe=lambda **kwargs: (lambda fn: fn),
+ choices=lambda **kwargs: (lambda fn: fn),
+ autocomplete=lambda **kwargs: (lambda fn: fn),
+ Choice=lambda **kwargs: SimpleNamespace(**kwargs),
+ )
+ discord_mod.opus = SimpleNamespace(
+ is_loaded=lambda: True,
+ load_opus=lambda *_args, **_kwargs: None,
+ Decoder=MagicMock,
+ )
+ discord_mod.FFmpegPCMAudio = MagicMock
+ discord_mod.PCMVolumeTransformer = MagicMock
+ discord_mod.http = SimpleNamespace(Route=MagicMock)
+
+ sys.modules["discord"] = discord_mod
+ ext_mod = ModuleType("discord.ext")
+ commands_mod = ModuleType("discord.ext.commands")
+ commands_mod.Bot = MagicMock
+ ext_mod.commands = commands_mod
+ sys.modules["discord.ext"] = ext_mod
+ sys.modules["discord.ext.commands"] = commands_mod
+ for module_name in (
+ "gateway.platforms.discord_impl.components",
+ "gateway.platforms.discord_impl.native_commands",
+ "gateway.platforms.discord_impl.interactions",
+ ):
+ if module_name in sys.modules:
+ importlib.reload(sys.modules[module_name])
+ else:
+ importlib.import_module(module_name)
+ return sys.modules["gateway.platforms.discord_impl.interactions"]
+
+
+interactions = _load_interactions_module()
+
+
+def _build_adapter():
+ return SimpleNamespace(
+ build_source=lambda **kwargs: SessionSource(platform=Platform.DISCORD, **kwargs),
+ _run_simple_slash=AsyncMock(),
+ _invoke_native_slash_command=AsyncMock(),
+ _send_native_slash_content=AsyncMock(),
+ _build_slash_event=MagicMock(return_value=SimpleNamespace()),
+ handle_message=AsyncMock(),
+ _handle_thread_create_slash=AsyncMock(),
+ _resolve_exec_approval=AsyncMock(return_value="resolved"),
+ _component_runtime=interactions.create_component_runtime(),
+ )
+
+
+def test_build_slash_event_constructs_message_event():
+ adapter = _build_adapter()
+ interaction = SimpleNamespace(
+ channel=SimpleNamespace(
+ name="general",
+ guild=SimpleNamespace(name="Hermes"),
+ topic="topic",
+ ),
+ channel_id=123,
+ user=SimpleNamespace(id=42, display_name="Jezza"),
+ )
+
+ event = interactions.build_slash_event(adapter, interaction, "/status")
+
+ assert event.text == "/status"
+ assert event.message_type is MessageType.COMMAND
+ assert event.source.chat_id == "123"
+ assert event.source.chat_name == "Hermes / #general"
+ assert event.source.chat_topic == "topic"
+ assert isinstance(event.metadata.get("session_source"), SessionSource)
+ assert event.metadata["session_source"].session_namespace == "slash:42"
+ assert event.metadata["command_target_source"].chat_id == "123"
+
+
+def test_register_slash_commands_registers_expected_names():
+ tree = FakeTree()
+ adapter = _build_adapter()
+
+ interactions.register_slash_commands(tree, adapter)
+
+ assert set(tree.commands) == {
+ "new",
+ "reset",
+ "help",
+ "commands",
+ "context",
+ "export-session",
+ "export",
+ "whoami",
+ "focus",
+ "unfocus",
+ "agents",
+ "session",
+ "id",
+ "approve",
+ "allowlist",
+ "config",
+ "debug",
+ "model",
+ "models",
+ "reasoning",
+ "think",
+ "personality",
+ "retry",
+ "undo",
+ "status",
+ "sethome",
+ "stop",
+ "compact",
+ "compress",
+ "title",
+ "resume",
+ "usage",
+ "provider",
+ "help",
+ "insights",
+ "reload-mcp",
+ "skill",
+ "subagents",
+ "kill",
+ "steer",
+ "tell",
+ "acp",
+ "bash",
+ "voice",
+ "vc",
+ "send",
+ "activation",
+ "update",
+ "restart",
+ "dock-telegram",
+ "dock-discord",
+ "dock-slack",
+ "thread",
+ }
+
+
+def test_extract_inline_shortcut_finds_first_supported_command():
+ command, remaining = interactions.native_commands.extract_inline_shortcut("hey /status please")
+
+ assert command == "status"
+ assert remaining == "hey please"
+
+
+def test_extract_inline_shortcut_returns_none_for_plain_text():
+ command, remaining = interactions.native_commands.extract_inline_shortcut("just chatting here")
+
+ assert command is None
+ assert remaining == "just chatting here"
+
+
+@pytest.mark.asyncio
+async def test_native_dispatch_opens_fallback_menu_for_missing_discrete_arg():
+ adapter = _build_adapter()
+ spec = next(spec for spec in interactions.native_commands.get_command_specs() if spec.name == "think")
+ interaction = SimpleNamespace(
+ user=SimpleNamespace(id=42, display_name="Jezza"),
+ message=None,
+ response=SimpleNamespace(
+ edit_message=AsyncMock(),
+ send_message=AsyncMock(),
+ is_done=lambda: False,
+ ),
+ followup=SimpleNamespace(send=AsyncMock()),
+ )
+
+ await interactions.native_commands._dispatch(adapter, interaction, spec, effort="")
+
+ interaction.response.send_message.assert_awaited_once()
+ args = interaction.response.send_message.await_args.args
+ kwargs = interaction.response.send_message.await_args.kwargs
+ assert kwargs["ephemeral"] is True
+ assert "Choose `effort` for `/think`." == args[0]
+ assert kwargs["view"] is not None
+ adapter.handle_message.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_native_dispatch_route_sends_returned_response():
+ adapter = _build_adapter()
+ adapter._invoke_native_slash_command.return_value = "focused"
+ spec = next(spec for spec in interactions.native_commands.get_command_specs() if spec.name == "focus")
+ interaction = SimpleNamespace(
+ response=SimpleNamespace(
+ defer=AsyncMock(),
+ send_message=AsyncMock(),
+ is_done=lambda: True,
+ ),
+ followup=SimpleNamespace(send=AsyncMock()),
+ )
+
+ await interactions.native_commands._dispatch(adapter, interaction, spec, name="release-room")
+
+ interaction.response.defer.assert_awaited_once_with(ephemeral=True)
+ adapter._invoke_native_slash_command.assert_awaited_once_with(
+ interaction,
+ "/focus release-room",
+ )
+ adapter._send_native_slash_content.assert_awaited_once_with(interaction, "focused")
+
+
+@pytest.mark.asyncio
+async def test_native_autocomplete_filters_discrete_choices():
+ adapter = _build_adapter()
+ spec = next(spec for spec in interactions.native_commands.get_command_specs() if spec.name == "reasoning")
+ arg = spec.args[0]
+
+ result = await interactions.native_commands._autocomplete_choices(
+ adapter,
+ spec,
+ arg,
+ SimpleNamespace(),
+ "hi",
+ )
+
+ assert [choice.value for choice in result] == ["off", "hide", "high", "xhigh"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("method_name", "expected_color", "expected_footer"),
+ [
+ ("allow_once", "green", "allow_once by Jezza"),
+ ("allow_always", "blue", "allow_always by Jezza"),
+ ("deny", "red", "deny by Jezza"),
+ ],
+)
+async def test_exec_approval_view_button_callbacks_resolve_correctly(
+ method_name,
+ expected_color,
+ expected_footer,
+):
+ adapter = _build_adapter()
+ view = interactions.create_exec_approval_view(adapter, "approval-1", {"42"})
+ embed = FakeEmbed()
+ interaction = SimpleNamespace(
+ user=SimpleNamespace(id=42, display_name="Jezza"),
+ message=SimpleNamespace(embeds=[embed]),
+ response=SimpleNamespace(
+ edit_message=AsyncMock(),
+ send_message=AsyncMock(),
+ ),
+ followup=SimpleNamespace(send=AsyncMock()),
+ )
+
+ button_labels = {
+ "allow_once": "Allow Once",
+ "allow_always": "Always Allow",
+ "deny": "Deny",
+ }
+ button = next(child for child in view.children if child.label == button_labels[method_name])
+ await button.callback(interaction)
+
+ assert embed.color == expected_color
+ assert embed.footer_text == expected_footer.replace("_", "-")
+ assert all(child.disabled for child in view.children)
+ interaction.response.edit_message.assert_awaited_once_with(embed=embed, view=view)
+ interaction.followup.send.assert_awaited_once_with("resolved", ephemeral=True)
+ assert adapter._resolve_exec_approval.await_count == 1
+ decision = adapter._resolve_exec_approval.await_args.kwargs["decision"]
+ assert decision == expected_footer.split(" by ")[0].replace("_", "-")
+ assert adapter._resolve_exec_approval.await_args.kwargs["approval_id"] == "approval-1"
+
+
+@pytest.mark.asyncio
+async def test_exec_approval_view_rejects_unauthorized_user():
+ adapter = _build_adapter()
+ view = interactions.create_exec_approval_view(adapter, "approval-1", {"42"})
+ interaction = SimpleNamespace(
+ user=SimpleNamespace(id=7, display_name="Mallory"),
+ message=SimpleNamespace(embeds=[FakeEmbed()]),
+ response=SimpleNamespace(
+ edit_message=AsyncMock(),
+ send_message=AsyncMock(),
+ ),
+ )
+
+ button = next(child for child in view.children if child.label == "Deny")
+ await button.callback(interaction)
+
+ interaction.response.edit_message.assert_not_awaited()
+ interaction.response.send_message.assert_awaited_once_with(
+ "You're not authorized to use this interaction~",
+ ephemeral=True,
+ )
diff --git a/tests/gateway/test_discord_impl_messaging.py b/tests/gateway/test_discord_impl_messaging.py
new file mode 100644
index 000000000000..6686a01859df
--- /dev/null
+++ b/tests/gateway/test_discord_impl_messaging.py
@@ -0,0 +1,407 @@
+from datetime import datetime
+from types import ModuleType, SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+import importlib
+import sys
+
+import pytest
+
+
+def _load_messaging_module():
+ class FakeIntents:
+ @staticmethod
+ def default():
+ return SimpleNamespace()
+
+ discord_mod = ModuleType("discord")
+ discord_mod.__file__ = "mock-discord.py"
+ discord_mod.Message = type("Message", (), {})
+ discord_mod.Intents = FakeIntents
+ discord_mod.Client = MagicMock
+ discord_mod.File = MagicMock
+ discord_mod.DMChannel = type("DMChannel", (), {})
+ discord_mod.Thread = type("Thread", (), {})
+ discord_mod.ForumChannel = type("ForumChannel", (), {})
+ discord_mod.ui = SimpleNamespace(
+ View=object,
+ button=lambda *a, **k: (lambda fn: fn),
+ Button=object,
+ )
+ discord_mod.ButtonStyle = SimpleNamespace(
+ success=1,
+ primary=2,
+ danger=3,
+ green=1,
+ blurple=2,
+ red=3,
+ )
+ discord_mod.Color = SimpleNamespace(
+ orange=lambda: 1,
+ green=lambda: 2,
+ blue=lambda: 3,
+ red=lambda: 4,
+ )
+ discord_mod.Interaction = object
+ discord_mod.Embed = MagicMock
+ discord_mod.app_commands = SimpleNamespace(
+ describe=lambda **kwargs: (lambda fn: fn),
+ choices=lambda **kwargs: (lambda fn: fn),
+ Choice=lambda **kwargs: SimpleNamespace(**kwargs),
+ )
+ discord_mod.opus = SimpleNamespace(
+ is_loaded=lambda: True,
+ load_opus=lambda *_args, **_kwargs: None,
+ Decoder=MagicMock,
+ )
+ discord_mod.FFmpegPCMAudio = MagicMock
+ discord_mod.PCMVolumeTransformer = MagicMock
+ discord_mod.http = SimpleNamespace(Route=MagicMock)
+
+ sys.modules["discord"] = discord_mod
+ ext_mod = ModuleType("discord.ext")
+ commands_mod = ModuleType("discord.ext.commands")
+ commands_mod.Bot = MagicMock
+ ext_mod.commands = commands_mod
+ sys.modules["discord.ext"] = ext_mod
+ sys.modules["discord.ext.commands"] = commands_mod
+ importlib.reload(importlib.import_module("gateway.platforms.discord_impl.delivery"))
+ return importlib.reload(importlib.import_module("gateway.platforms.discord_impl.messaging"))
+
+
+messaging = _load_messaging_module()
+
+
+def _make_client(channel):
+ return SimpleNamespace(
+ get_channel=MagicMock(return_value=channel),
+ fetch_channel=AsyncMock(return_value=None if channel is None else channel),
+ )
+
+
+class _AsyncItemsIterator:
+ def __init__(self, items):
+ self._items = list(items)
+ self._index = 0
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self):
+ if self._index >= len(self._items):
+ raise StopAsyncIteration
+ item = self._items[self._index]
+ self._index += 1
+ return item
+
+
+def test_normalize_edit_content_truncates_to_limit():
+ result = messaging.normalize_edit_content(
+ "a" * 25,
+ format_message=lambda value: value.upper(),
+ max_message_length=10,
+ )
+
+ assert result == "AAAAAAA..."
+
+
+@pytest.mark.asyncio
+async def test_edit_message_returns_success():
+ message = SimpleNamespace(edit=AsyncMock())
+ channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message))
+ client = _make_client(channel)
+
+ result = await messaging.edit_message(
+ client,
+ "123",
+ "456",
+ "hello",
+ format_message=lambda value: f"**{value}**",
+ max_message_length=2000,
+ )
+
+ assert result.success is True
+ assert result.message_id == "456"
+ channel.fetch_message.assert_awaited_once_with(456)
+ message.edit.assert_awaited_once_with(content="**hello**")
+
+
+@pytest.mark.asyncio
+async def test_edit_message_returns_missing_channel_error():
+ result = await messaging.edit_message(
+ _make_client(None),
+ "123",
+ "456",
+ "hello",
+ )
+
+ assert result.success is False
+ assert result.error == "Channel 123 not found"
+
+
+@pytest.mark.asyncio
+async def test_edit_message_returns_fetch_error():
+ channel = SimpleNamespace(fetch_message=AsyncMock(side_effect=RuntimeError("missing message")))
+ client = _make_client(channel)
+
+ result = await messaging.edit_message(client, "123", "456", "hello")
+
+ assert result.success is False
+ assert result.error == "missing message"
+
+
+@pytest.mark.asyncio
+async def test_edit_message_truncates_oversized_content():
+ message = SimpleNamespace(edit=AsyncMock())
+ channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message))
+ client = _make_client(channel)
+
+ result = await messaging.edit_message(
+ client,
+ "123",
+ "456",
+ "a" * 20,
+ max_message_length=10,
+ )
+
+ assert result.success is True
+ message.edit.assert_awaited_once_with(content="aaaaaaa...")
+
+
+@pytest.mark.asyncio
+async def test_delete_message_returns_success():
+ message = SimpleNamespace(delete=AsyncMock())
+ channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message))
+
+ result = await messaging.delete_message(_make_client(channel), "123", "456")
+
+ assert result.success is True
+ assert result.message_id == "456"
+ message.delete.assert_awaited_once_with()
+
+
+@pytest.mark.asyncio
+async def test_add_and_remove_reaction_call_message_methods():
+ message = SimpleNamespace(add_reaction=AsyncMock(), remove_reaction=AsyncMock())
+ channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message))
+ client = _make_client(channel)
+ client.user = SimpleNamespace(id=999)
+
+ add_result = await messaging.add_reaction(client, "123", "456", "🔥")
+ remove_result = await messaging.remove_reaction(client, "123", "456", "🔥")
+
+ assert add_result.success is True
+ assert remove_result.success is True
+ message.add_reaction.assert_awaited_once_with("🔥")
+ message.remove_reaction.assert_awaited_once_with("🔥", client.user)
+
+
+@pytest.mark.asyncio
+async def test_remove_reaction_requires_client_user():
+ message = SimpleNamespace(remove_reaction=AsyncMock())
+ channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message))
+
+ result = await messaging.remove_reaction(_make_client(channel), "123", "456", "🔥")
+
+ assert result.success is False
+ assert result.error == "Client user unavailable"
+ message.remove_reaction.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_list_reactions_serializes_unicode_and_custom_emoji_users():
+ class _CustomEmoji:
+ id = 77
+ name = "party"
+
+ def __str__(self):
+ return "<:party:77>"
+
+ unicode_reaction = SimpleNamespace(
+ emoji="🔥",
+ count=2,
+ users=MagicMock(
+ return_value=_AsyncItemsIterator(
+ [SimpleNamespace(id=1, username="alan", discriminator="1234")]
+ )
+ ),
+ )
+ custom_reaction = SimpleNamespace(
+ emoji=_CustomEmoji(),
+ count=1,
+ users=MagicMock(
+ return_value=_AsyncItemsIterator([SimpleNamespace(id=2, name="bot-user")])
+ ),
+ )
+ message = SimpleNamespace(reactions=[unicode_reaction, custom_reaction])
+ channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message))
+
+ result = await messaging.list_reactions(_make_client(channel), "123", "456", limit=5)
+
+ assert result == [
+ {
+ "emoji": {"id": None, "name": "🔥", "raw": "🔥"},
+ "count": 2,
+ "users": [{"id": "1", "username": "alan", "tag": "alan#1234"}],
+ },
+ {
+ "emoji": {"id": "77", "name": "party", "raw": "<:party:77>"},
+ "count": 1,
+ "users": [{"id": "2", "username": "bot-user", "tag": "bot-user"}],
+ },
+ ]
+ unicode_reaction.users.assert_called_once_with(limit=5)
+ custom_reaction.users.assert_called_once_with(limit=5)
+
+
+@pytest.mark.asyncio
+async def test_list_reactions_returns_empty_when_message_missing():
+ channel = SimpleNamespace(fetch_message=AsyncMock(side_effect=RuntimeError("missing message")))
+
+ result = await messaging.list_reactions(_make_client(channel), "123", "456")
+
+ assert result == []
+
+
+@pytest.mark.asyncio
+async def test_list_threads_returns_active_threads():
+ active_thread = SimpleNamespace(
+ id=10,
+ name="alpha",
+ parent=SimpleNamespace(id=5, name="general"),
+ guild=SimpleNamespace(id=1, name="Hermes"),
+ archived=False,
+ locked=False,
+ message_count=3,
+ member_count=2,
+ )
+ channel = SimpleNamespace(threads=[active_thread])
+
+ result = await messaging.list_threads(_make_client(channel), "123")
+
+ assert result == [
+ {
+ "id": "10",
+ "name": "alpha",
+ "parent_id": "5",
+ "parent_name": "general",
+ "guild_id": "1",
+ "guild_name": "Hermes",
+ "archived": False,
+ "locked": False,
+ "message_count": 3,
+ "member_count": 2,
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_list_threads_includes_archived_threads_when_requested():
+ archived_thread = SimpleNamespace(
+ id=11,
+ name="archive",
+ parent=SimpleNamespace(id=5, name="general"),
+ guild=SimpleNamespace(id=1, name="Hermes"),
+ archived=True,
+ locked=True,
+ message_count=9,
+ member_count=4,
+ )
+ channel = SimpleNamespace(
+ threads=[],
+ archived_threads=MagicMock(return_value=_AsyncItemsIterator([archived_thread])),
+ )
+
+ result = await messaging.list_threads(_make_client(channel), "123", include_archived=True, limit=25)
+
+ assert result[0]["id"] == "11"
+ channel.archived_threads.assert_called_once_with(
+ private=False,
+ joined=False,
+ limit=25,
+ before=None,
+ )
+
+
+@pytest.mark.asyncio
+async def test_reply_in_thread_rejects_non_thread_channel():
+ channel = SimpleNamespace(parent=None)
+
+ result = await messaging.reply_in_thread(_make_client(channel), "123", "hello")
+
+ assert result.success is False
+ assert result.error == "Channel 123 is not a thread"
+
+
+@pytest.mark.asyncio
+async def test_reply_in_thread_sends_chunks():
+ sent_message = SimpleNamespace(id=55)
+ thread = SimpleNamespace(
+ parent=SimpleNamespace(id=5),
+ fetch_message=AsyncMock(return_value=SimpleNamespace(id=99)),
+ )
+ send_text_message = AsyncMock(return_value=sent_message)
+
+ result = await messaging.reply_in_thread(
+ _make_client(thread),
+ "123",
+ "hello",
+ reply_to="99",
+ format_message=lambda value: value.upper(),
+ truncate_message=lambda value, _max_len: [value[:3], value[3:]],
+ send_text_message=send_text_message,
+ )
+
+ assert result.success is True
+ assert result.message_id == "55"
+ assert send_text_message.await_count == 2
+ first_call = send_text_message.await_args_list[0]
+ second_call = send_text_message.await_args_list[1]
+ assert first_call.args[0] is thread
+ assert first_call.args[1] == "HEL"
+ assert first_call.kwargs["reference"].id == 99
+ assert second_call.args[1] == "LO"
+ assert second_call.kwargs["reference"] is None
+
+
+@pytest.mark.asyncio
+async def test_list_pins_serializes_messages():
+ author = SimpleNamespace(id=42, name="Jezza", display_name="Jezza", bot=False)
+ pinned = SimpleNamespace(
+ id=7,
+ author=author,
+ content="important",
+ created_at=datetime(2026, 3, 18, 12, 0, 0),
+ attachments=[],
+ reference=None,
+ )
+ channel = SimpleNamespace(pins=MagicMock(return_value=_AsyncItemsIterator([pinned])))
+
+ result = await messaging.list_pins(_make_client(channel), "123")
+
+ assert result == [
+ {
+ "id": "7",
+ "author_id": "42",
+ "author_name": "Jezza",
+ "content": "important",
+ "timestamp": "2026-03-18T12:00:00",
+ "is_bot": False,
+ "attachments": [],
+ "reply_to": None,
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_pin_and_unpin_message_call_message_methods():
+ message = SimpleNamespace(pin=AsyncMock(), unpin=AsyncMock())
+ channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message))
+ client = _make_client(channel)
+
+ pin_result = await messaging.pin_message(client, "123", "456", reason="keep")
+ unpin_result = await messaging.unpin_message(client, "123", "456", reason="drop")
+
+ assert pin_result.success is True
+ assert unpin_result.success is True
+ message.pin.assert_awaited_once_with(reason="keep")
+ message.unpin.assert_awaited_once_with(reason="drop")
diff --git a/tests/gateway/test_discord_impl_permissions.py b/tests/gateway/test_discord_impl_permissions.py
new file mode 100644
index 000000000000..6020398a4def
--- /dev/null
+++ b/tests/gateway/test_discord_impl_permissions.py
@@ -0,0 +1,250 @@
+from types import ModuleType, SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+import importlib
+import sys
+
+import pytest
+
+def _install_discord_mock():
+ class FakeIntents:
+ @staticmethod
+ def default():
+ return SimpleNamespace()
+
+ discord_mod = ModuleType("discord")
+ discord_mod.__file__ = "mock-discord.py"
+ discord_mod.Message = type("Message", (), {})
+ discord_mod.Intents = FakeIntents
+ discord_mod.Client = MagicMock
+ discord_mod.File = MagicMock
+ discord_mod.DMChannel = type("DMChannel", (), {})
+ discord_mod.Thread = type("Thread", (), {})
+ discord_mod.ForumChannel = type("ForumChannel", (), {})
+ discord_mod.ui = SimpleNamespace(
+ View=object,
+ button=lambda *a, **k: (lambda fn: fn),
+ Button=object,
+ )
+ discord_mod.ButtonStyle = SimpleNamespace(
+ success=1,
+ primary=2,
+ danger=3,
+ green=1,
+ blurple=2,
+ red=3,
+ )
+ discord_mod.Color = SimpleNamespace(
+ orange=lambda: 1,
+ green=lambda: 2,
+ blue=lambda: 3,
+ red=lambda: 4,
+ )
+ discord_mod.Interaction = object
+ discord_mod.Embed = MagicMock
+ discord_mod.app_commands = SimpleNamespace(
+ describe=lambda **kwargs: (lambda fn: fn),
+ choices=lambda **kwargs: (lambda fn: fn),
+ Choice=lambda **kwargs: SimpleNamespace(**kwargs),
+ )
+ discord_mod.opus = SimpleNamespace(
+ is_loaded=lambda: True,
+ load_opus=lambda *_args, **_kwargs: None,
+ Decoder=MagicMock,
+ )
+ discord_mod.FFmpegPCMAudio = MagicMock
+ discord_mod.PCMVolumeTransformer = MagicMock
+ discord_mod.http = SimpleNamespace(Route=MagicMock)
+
+ sys.modules["discord"] = discord_mod
+ ext_mod = ModuleType("discord.ext")
+ commands_mod = ModuleType("discord.ext.commands")
+ commands_mod.Bot = MagicMock
+ ext_mod.commands = commands_mod
+ sys.modules["discord.ext"] = ext_mod
+ sys.modules["discord.ext.commands"] = commands_mod
+
+
+def _load_permissions_module():
+ _install_discord_mock()
+ importlib.reload(importlib.import_module("gateway.platforms.discord_impl.delivery"))
+ return importlib.reload(importlib.import_module("gateway.platforms.discord_impl.permissions"))
+
+
+permissions = _load_permissions_module()
+
+
+def _make_client(channel=None, guilds=None):
+ return SimpleNamespace(
+ get_channel=MagicMock(return_value=channel),
+ fetch_channel=AsyncMock(return_value=None if channel is None else channel),
+ guilds=list(guilds or []),
+ user=SimpleNamespace(id=999),
+ )
+
+
+def _make_guild_channel(channel_id, name, *, can_read=True, can_read_history=True):
+ guild_member = SimpleNamespace(id=999)
+ guild = SimpleNamespace(id=777, me=guild_member, name="Hermes")
+ permission_bits = SimpleNamespace(
+ view_channel=can_read,
+ read_messages=can_read,
+ send_messages=True,
+ read_message_history=can_read_history,
+ attach_files=True,
+ embed_links=True,
+ add_reactions=True,
+ manage_threads=False,
+ create_public_threads=True,
+ create_private_threads=False,
+ )
+ return SimpleNamespace(
+ id=channel_id,
+ name=name,
+ guild=guild,
+ permissions_for=MagicMock(return_value=permission_bits),
+ )
+
+
+@pytest.mark.asyncio
+async def test_check_channel_permissions_for_guild_text_channel():
+ channel = _make_guild_channel(123, "general")
+ client = _make_client(channel=channel)
+
+ result = await permissions.check_channel_permissions(client, "123")
+
+ assert result == permissions.ChannelPermissions(
+ channel_id="123",
+ channel_name="general",
+ can_read=True,
+ can_send=True,
+ can_read_history=True,
+ can_attach_files=True,
+ can_embed_links=True,
+ can_add_reactions=True,
+ can_manage_threads=False,
+ can_create_threads=True,
+ )
+ channel.permissions_for.assert_called_once_with(channel.guild.me)
+
+
+@pytest.mark.asyncio
+async def test_check_channel_permissions_for_dm_channel_disables_thread_flags():
+ dm_channel = permissions.discord.DMChannel()
+ dm_channel.id = 321
+ dm_channel.name = None
+ dm_channel.guild = None
+ dm_channel.recipient = SimpleNamespace(name="Alan")
+ client = _make_client(channel=dm_channel)
+
+ result = await permissions.check_channel_permissions(client, "321")
+
+ assert result == permissions.ChannelPermissions(
+ channel_id="321",
+ channel_name="Alan",
+ can_read=True,
+ can_send=True,
+ can_read_history=True,
+ can_attach_files=True,
+ can_embed_links=True,
+ can_add_reactions=True,
+ can_manage_threads=False,
+ can_create_threads=False,
+ )
+
+
+@pytest.mark.asyncio
+async def test_check_channel_permissions_returns_none_for_missing_channel():
+ client = _make_client(channel=None)
+
+ result = await permissions.check_channel_permissions(client, "123")
+
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_list_accessible_channels_filters_to_readable_channels_only():
+ readable = _make_guild_channel(1, "general", can_read=True)
+ hidden = _make_guild_channel(2, "private", can_read=False)
+ guild = SimpleNamespace(id=777, name="Hermes", text_channels=[readable, hidden])
+ readable.guild = guild
+ hidden.guild = guild
+ client = _make_client(guilds=[guild])
+
+ result = await permissions.list_accessible_channels(client)
+
+ assert result == [
+ permissions.AccessibleChannel(
+ channel_id="1",
+ channel_name="general",
+ guild_id="777",
+ guild_name="Hermes",
+ channel_kind="channel",
+ qualified_name="Hermes/general",
+ mention="<#1>",
+ can_read=True,
+ can_send=True,
+ can_read_history=True,
+ can_attach_files=True,
+ can_embed_links=True,
+ can_add_reactions=True,
+ can_manage_threads=False,
+ can_create_threads=True,
+ )
+ ]
+
+
+@pytest.mark.asyncio
+async def test_list_accessible_channels_respects_guild_id_filter():
+ guild_one = SimpleNamespace(id=111, name="One", text_channels=[_make_guild_channel(1, "one")])
+ guild_two = SimpleNamespace(id=222, name="Two", text_channels=[_make_guild_channel(2, "two")])
+ for guild in (guild_one, guild_two):
+ for channel in guild.text_channels:
+ channel.guild = guild
+ client = _make_client(guilds=[guild_one, guild_two])
+
+ result = await permissions.list_accessible_channels(client, guild_id="222")
+
+ assert [channel.channel_id for channel in result] == ["2"]
+ assert [channel.channel_name for channel in result] == ["two"]
+
+
+@pytest.mark.asyncio
+async def test_list_accessible_channels_includes_normalized_target_metadata():
+ guild = SimpleNamespace(id=777, name="Hermes", text_channels=[_make_guild_channel(1, "general")])
+ for channel in guild.text_channels:
+ channel.guild = guild
+ client = _make_client(guilds=[guild])
+
+ result = await permissions.list_accessible_channels(client)
+
+ assert result == [
+ permissions.AccessibleChannel(
+ channel_id="1",
+ channel_name="general",
+ guild_id="777",
+ guild_name="Hermes",
+ channel_kind="channel",
+ qualified_name="Hermes/general",
+ mention="<#1>",
+ can_read=True,
+ can_send=True,
+ can_read_history=True,
+ can_attach_files=True,
+ can_embed_links=True,
+ can_add_reactions=True,
+ can_manage_threads=False,
+ can_create_threads=True,
+ )
+ ]
+
+
+@pytest.mark.asyncio
+async def test_can_read_channel_returns_true_and_false_correctly():
+ readable = _make_guild_channel(123, "general", can_read=True, can_read_history=True)
+ unreadable = _make_guild_channel(456, "private", can_read=False, can_read_history=False)
+
+ readable_client = _make_client(channel=readable)
+ unreadable_client = _make_client(channel=unreadable)
+
+ assert await permissions.can_read_channel(readable_client, "123") is True
+ assert await permissions.can_read_channel(unreadable_client, "456") is False
diff --git a/tests/gateway/test_discord_impl_scaffold.py b/tests/gateway/test_discord_impl_scaffold.py
new file mode 100644
index 000000000000..fef5c52923b5
--- /dev/null
+++ b/tests/gateway/test_discord_impl_scaffold.py
@@ -0,0 +1,104 @@
+"""Import tests for the Discord v2 internal implementation scaffold."""
+
+import importlib
+import sys
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+
+def _ensure_discord_mock():
+ """Install a lightweight discord mock when discord.py isn't available."""
+ if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
+ return
+
+ discord_mod = MagicMock()
+ discord_mod.Intents.default.return_value = MagicMock()
+ discord_mod.Client = MagicMock
+ discord_mod.File = MagicMock
+ discord_mod.DMChannel = type("DMChannel", (), {})
+ discord_mod.Thread = type("Thread", (), {})
+ discord_mod.ForumChannel = type("ForumChannel", (), {})
+ discord_mod.ui = SimpleNamespace(
+ View=object,
+ button=lambda *a, **k: (lambda fn: fn),
+ Button=object,
+ )
+ discord_mod.ButtonStyle = SimpleNamespace(
+ success=1,
+ primary=2,
+ danger=3,
+ green=1,
+ blurple=2,
+ red=3,
+ )
+ discord_mod.Color = SimpleNamespace(
+ orange=lambda: 1,
+ green=lambda: 2,
+ blue=lambda: 3,
+ red=lambda: 4,
+ )
+ discord_mod.Interaction = object
+ discord_mod.Embed = MagicMock
+ discord_mod.app_commands = SimpleNamespace(
+ describe=lambda **kwargs: (lambda fn: fn),
+ choices=lambda **kwargs: (lambda fn: fn),
+ Choice=lambda **kwargs: SimpleNamespace(**kwargs),
+ )
+ discord_mod.opus = SimpleNamespace(
+ is_loaded=lambda: True,
+ load_opus=lambda *_args, **_kwargs: None,
+ )
+ discord_mod.FFmpegPCMAudio = MagicMock
+ discord_mod.PCMVolumeTransformer = MagicMock
+ discord_mod.http = SimpleNamespace(Route=MagicMock)
+
+ ext_mod = MagicMock()
+ commands_mod = MagicMock()
+ commands_mod.Bot = MagicMock
+ ext_mod.commands = commands_mod
+
+ sys.modules.setdefault("discord", discord_mod)
+ sys.modules.setdefault("discord.ext", ext_mod)
+ sys.modules.setdefault("discord.ext.commands", commands_mod)
+
+
+_ensure_discord_mock()
+
+
+def test_discord_impl_package_is_importable():
+ package = importlib.import_module("gateway.platforms.discord_impl")
+ assert package is not None
+
+
+def test_discord_impl_submodules_are_importable():
+ module_names = (
+ "config",
+ "intake",
+ "delivery",
+ "interactions",
+ "threads",
+ "state",
+ "history",
+ "permissions",
+ )
+
+ for module_name in module_names:
+ module = importlib.import_module(f"gateway.platforms.discord_impl.{module_name}")
+ assert module is not None
+
+
+def test_discord_impl_re_exports_nothing():
+ package = importlib.import_module("gateway.platforms.discord_impl")
+ assert not hasattr(package, "__all__") or not package.__all__
+
+
+def test_discord_public_import_surface_remains_available():
+ from gateway.platforms.discord import (
+ DiscordAdapter,
+ VoiceReceiver,
+ check_discord_requirements,
+ )
+
+ assert DiscordAdapter is not None
+ assert VoiceReceiver is not None
+ assert callable(check_discord_requirements)
diff --git a/tests/gateway/test_discord_impl_state.py b/tests/gateway/test_discord_impl_state.py
new file mode 100644
index 000000000000..cd1438b009f6
--- /dev/null
+++ b/tests/gateway/test_discord_impl_state.py
@@ -0,0 +1,61 @@
+"""Tests for Discord thread participation persistence helpers."""
+
+import json
+
+from gateway.platforms.discord_impl import state as discord_state
+
+
+def test_thread_state_path_uses_hermes_home(tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+
+ assert discord_state.thread_state_path() == tmp_path / "discord_threads.json"
+
+
+def test_load_participated_threads_returns_empty_without_file(tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+
+ assert discord_state.load_participated_threads() == set()
+
+
+def test_load_participated_threads_reads_json_list(tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ (tmp_path / "discord_threads.json").write_text(json.dumps(["111", "222"]), encoding="utf-8")
+
+ assert discord_state.load_participated_threads() == {"111", "222"}
+
+
+def test_load_participated_threads_tolerates_corrupt_json(tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ (tmp_path / "discord_threads.json").write_text("not-json", encoding="utf-8")
+
+ assert discord_state.load_participated_threads() == set()
+
+
+def test_save_participated_threads_persists_trimmed_set(tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+
+ saved_threads = discord_state.save_participated_threads({"1", "2", "3", "4"}, max_threads=2)
+
+ assert saved_threads <= {"1", "2", "3", "4"}
+ assert len(saved_threads) == 2
+ persisted = set(json.loads((tmp_path / "discord_threads.json").read_text(encoding="utf-8")))
+ assert persisted == saved_threads
+
+
+def test_track_thread_adds_and_persists(tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+
+ updated_threads = discord_state.track_thread(set(), "111")
+
+ assert updated_threads == {"111"}
+ assert json.loads((tmp_path / "discord_threads.json").read_text(encoding="utf-8")) == ["111"]
+
+
+def test_track_thread_duplicate_is_noop(tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ existing = discord_state.track_thread(set(), "111")
+
+ updated_threads = discord_state.track_thread(existing, "111")
+
+ assert updated_threads == {"111"}
+ assert json.loads((tmp_path / "discord_threads.json").read_text(encoding="utf-8")) == ["111"]
diff --git a/tests/gateway/test_discord_impl_threads.py b/tests/gateway/test_discord_impl_threads.py
new file mode 100644
index 000000000000..e4a66fee2d7f
--- /dev/null
+++ b/tests/gateway/test_discord_impl_threads.py
@@ -0,0 +1,271 @@
+from types import ModuleType, SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+import importlib
+import sys
+
+import pytest
+
+
+def _load_threads_module():
+ class FakeIntents:
+ @staticmethod
+ def default():
+ return SimpleNamespace()
+
+ discord_mod = ModuleType("discord")
+ discord_mod.__file__ = "mock-discord.py"
+ discord_mod.Message = type("Message", (), {})
+ discord_mod.Intents = FakeIntents
+ discord_mod.Client = MagicMock
+ discord_mod.File = MagicMock
+ discord_mod.DMChannel = type("DMChannel", (), {})
+ discord_mod.Thread = type("Thread", (), {})
+ discord_mod.ForumChannel = type("ForumChannel", (), {})
+ discord_mod.ui = SimpleNamespace(
+ View=object,
+ button=lambda *a, **k: (lambda fn: fn),
+ Button=object,
+ )
+ discord_mod.ButtonStyle = SimpleNamespace(
+ success=1,
+ primary=2,
+ danger=3,
+ green=1,
+ blurple=2,
+ red=3,
+ )
+ discord_mod.Color = SimpleNamespace(
+ orange=lambda: 1,
+ green=lambda: 2,
+ blue=lambda: 3,
+ red=lambda: 4,
+ )
+ discord_mod.Interaction = object
+ discord_mod.Embed = MagicMock
+ discord_mod.app_commands = SimpleNamespace(
+ describe=lambda **kwargs: (lambda fn: fn),
+ choices=lambda **kwargs: (lambda fn: fn),
+ Choice=lambda **kwargs: SimpleNamespace(**kwargs),
+ )
+ discord_mod.opus = SimpleNamespace(
+ is_loaded=lambda: True,
+ load_opus=lambda *_args, **_kwargs: None,
+ Decoder=MagicMock,
+ )
+ discord_mod.FFmpegPCMAudio = MagicMock
+ discord_mod.PCMVolumeTransformer = MagicMock
+ discord_mod.http = SimpleNamespace(Route=MagicMock)
+
+ sys.modules["discord"] = discord_mod
+ ext_mod = ModuleType("discord.ext")
+ commands_mod = ModuleType("discord.ext.commands")
+ commands_mod.Bot = MagicMock
+ ext_mod.commands = commands_mod
+ sys.modules["discord.ext"] = ext_mod
+ sys.modules["discord.ext.commands"] = commands_mod
+ return importlib.reload(importlib.import_module("gateway.platforms.discord_impl.threads"))
+
+
+threads = _load_threads_module()
+
+
+@pytest.mark.asyncio
+async def test_auto_create_thread_uses_message_content():
+ thread = SimpleNamespace(id=1, name="hello")
+ message = SimpleNamespace(
+ content="hello world",
+ create_thread=AsyncMock(return_value=thread),
+ )
+
+ result = await threads.auto_create_thread(message)
+
+ assert result is thread
+ message.create_thread.assert_awaited_once_with(
+ name="hello world",
+ auto_archive_duration=1440,
+ )
+
+
+@pytest.mark.asyncio
+async def test_auto_create_thread_truncates_long_message_names():
+ thread = SimpleNamespace(id=2, name="truncated")
+ message = SimpleNamespace(
+ content="a" * 200,
+ create_thread=AsyncMock(return_value=thread),
+ )
+
+ result = await threads.auto_create_thread(message)
+
+ assert result is thread
+ thread_name = message.create_thread.await_args.kwargs["name"]
+ assert len(thread_name) <= 80
+ assert thread_name.endswith("...")
+
+
+@pytest.mark.asyncio
+async def test_create_thread_succeeds_with_direct_creation():
+ created_thread = SimpleNamespace(id=555, name="Planning", send=AsyncMock())
+ parent_channel = SimpleNamespace(
+ create_thread=AsyncMock(return_value=created_thread),
+ send=AsyncMock(),
+ )
+ interaction = SimpleNamespace(
+ user=SimpleNamespace(display_name="Jezza"),
+ )
+
+ async def resolve_channel_fn(_client, _interaction):
+ return SimpleNamespace(parent=parent_channel)
+
+ result = await threads.create_thread(
+ client=MagicMock(),
+ interaction=interaction,
+ name="Planning",
+ message="Kickoff",
+ auto_archive_duration=1440,
+ resolve_channel_fn=resolve_channel_fn,
+ )
+
+ assert result == {
+ "success": True,
+ "thread_id": "555",
+ "thread_name": "Planning",
+ }
+ parent_channel.create_thread.assert_awaited_once_with(
+ name="Planning",
+ auto_archive_duration=1440,
+ reason="Requested by Jezza via /thread",
+ )
+ created_thread.send.assert_awaited_once_with("Kickoff")
+
+
+@pytest.mark.asyncio
+async def test_create_thread_falls_back_to_seed_message():
+ created_thread = SimpleNamespace(id=777, name="Planning")
+ seed_message = SimpleNamespace(create_thread=AsyncMock(return_value=created_thread))
+ parent_channel = SimpleNamespace(
+ create_thread=AsyncMock(side_effect=RuntimeError("direct failed")),
+ send=AsyncMock(return_value=seed_message),
+ )
+ interaction = SimpleNamespace(
+ user=SimpleNamespace(display_name="Jezza"),
+ )
+
+ async def resolve_channel_fn(_client, _interaction):
+ return SimpleNamespace(parent=parent_channel)
+
+ result = await threads.create_thread(
+ client=MagicMock(),
+ interaction=interaction,
+ name="Planning",
+ message="Kickoff",
+ auto_archive_duration=1440,
+ resolve_channel_fn=resolve_channel_fn,
+ )
+
+ assert result == {
+ "success": True,
+ "thread_id": "777",
+ "thread_name": "Planning",
+ }
+ parent_channel.send.assert_awaited_once_with("Kickoff")
+ seed_message.create_thread.assert_awaited_once_with(
+ name="Planning",
+ auto_archive_duration=1440,
+ reason="Requested by Jezza via /thread",
+ )
+
+
+@pytest.mark.asyncio
+async def test_create_thread_rejects_dm_channel():
+ dm_channel = threads.discord.DMChannel()
+
+ async def resolve_channel_fn(_client, _interaction):
+ return dm_channel
+
+ result = await threads.create_thread(
+ client=MagicMock(),
+ interaction=SimpleNamespace(user=SimpleNamespace(display_name="Jezza")),
+ name="Planning",
+ message="",
+ auto_archive_duration=1440,
+ resolve_channel_fn=resolve_channel_fn,
+ )
+
+ assert result == {"error": "Discord threads can only be created inside server text channels, not DMs."}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("name", "message", "auto_archive_duration", "resolve_mode", "expected_error"),
+ [
+ ("", "", 1440, "unused", "Thread name is required."),
+ (
+ "Planning",
+ "",
+ 999,
+ "unused",
+ "auto_archive_duration must be one of: 60, 1440, 4320, 10080.",
+ ),
+ (
+ "Planning",
+ "",
+ 1440,
+ "missing",
+ "Could not resolve the current Discord channel.",
+ ),
+ ],
+)
+async def test_create_thread_returns_expected_errors(
+ name,
+ message,
+ auto_archive_duration,
+ resolve_mode,
+ expected_error,
+):
+ if resolve_mode == "missing":
+ async def resolve_channel_fn(_client, _interaction):
+ return None
+ else:
+ async def resolve_channel_fn(_client, _interaction):
+ return SimpleNamespace(parent=SimpleNamespace())
+
+ result = await threads.create_thread(
+ client=MagicMock(),
+ interaction=SimpleNamespace(user=SimpleNamespace(display_name="Jezza")),
+ name=name,
+ message=message,
+ auto_archive_duration=auto_archive_duration,
+ resolve_channel_fn=resolve_channel_fn,
+ )
+
+ assert result == {"error": expected_error}
+
+
+def test_thread_parent_channel_returns_parent_or_self():
+ parent = SimpleNamespace(id=1)
+ assert threads.thread_parent_channel(SimpleNamespace(parent=parent)) is parent
+ channel = SimpleNamespace(parent=None)
+ assert threads.thread_parent_channel(channel) is channel
+
+
+@pytest.mark.asyncio
+async def test_resolve_interaction_channel_returns_available_channel():
+ channel = SimpleNamespace(id=123)
+ interaction = SimpleNamespace(channel=channel, channel_id=123)
+
+ result = await threads.resolve_interaction_channel(MagicMock(), interaction)
+
+ assert result is channel
+
+
+@pytest.mark.asyncio
+async def test_resolve_interaction_channel_returns_none_when_missing():
+ client = SimpleNamespace(
+ get_channel=MagicMock(return_value=None),
+ fetch_channel=AsyncMock(side_effect=RuntimeError("missing")),
+ )
+ interaction = SimpleNamespace(channel=None, channel_id=123)
+
+ result = await threads.resolve_interaction_channel(client, interaction)
+
+ assert result is None
diff --git a/tests/gateway/test_discord_model_picker.py b/tests/gateway/test_discord_model_picker.py
new file mode 100644
index 000000000000..2f7956272586
--- /dev/null
+++ b/tests/gateway/test_discord_model_picker.py
@@ -0,0 +1,328 @@
+from types import ModuleType, SimpleNamespace
+from unittest.mock import AsyncMock
+import importlib
+import sys
+
+import pytest
+
+
+class FakeView:
+ def __init__(self, timeout=None):
+ self.timeout = timeout
+ self.children = []
+
+ def add_item(self, item):
+ item.view = self
+ self.children.append(item)
+
+
+class FakeButton:
+ def __init__(
+ self,
+ *,
+ label=None,
+ style=None,
+ custom_id=None,
+ row=None,
+ disabled=False,
+ emoji=None,
+ url=None,
+ ):
+ self.label = label
+ self.style = style
+ self.custom_id = custom_id
+ self.row = row
+ self.disabled = disabled
+ self.emoji = emoji
+ self.url = url
+ self.view = None
+
+
+class FakeSelect:
+ def __init__(
+ self,
+ *,
+ placeholder=None,
+ min_values=1,
+ max_values=1,
+ options=None,
+ custom_id=None,
+ row=None,
+ disabled=False,
+ ):
+ self.placeholder = placeholder
+ self.min_values = min_values
+ self.max_values = max_values
+ self.options = list(options or [])
+ self.custom_id = custom_id
+ self.row = row
+ self.disabled = disabled
+ self.values = []
+ self.view = None
+
+
+class FakeUserSelect(FakeSelect):
+ pass
+
+
+class FakeRoleSelect(FakeSelect):
+ pass
+
+
+class FakeMentionableSelect(FakeSelect):
+ pass
+
+
+class FakeChannelSelect(FakeSelect):
+ pass
+
+
+class FakeModal:
+ def __init__(self, *, title=None, custom_id=None, timeout=None):
+ self.title = title
+ self.custom_id = custom_id
+ self.timeout = timeout
+ self.children = []
+
+ def add_item(self, item):
+ self.children.append(item)
+
+
+class FakeTextInput:
+ def __init__(
+ self,
+ *,
+ label,
+ placeholder=None,
+ default=None,
+ required=True,
+ min_length=None,
+ max_length=None,
+ style=None,
+ ):
+ self.label = label
+ self.placeholder = placeholder
+ self.default = default
+ self.required = required
+ self.min_length = min_length
+ self.max_length = max_length
+ self.style = style
+ self.value = default or ""
+
+
+def _load_model_picker_module():
+ discord_mod = ModuleType("discord")
+ discord_mod.__file__ = "mock-discord.py"
+ discord_mod.Message = type("Message", (), {})
+ discord_mod.Intents = SimpleNamespace(default=lambda: SimpleNamespace())
+ discord_mod.Client = object
+ discord_mod.File = object
+ discord_mod.DMChannel = type("DMChannel", (), {})
+ discord_mod.Thread = type("Thread", (), {})
+ discord_mod.ForumChannel = type("ForumChannel", (), {})
+ discord_mod.Interaction = object
+ discord_mod.Embed = object
+ discord_mod.ui = SimpleNamespace(
+ View=FakeView,
+ Button=FakeButton,
+ Select=FakeSelect,
+ UserSelect=FakeUserSelect,
+ RoleSelect=FakeRoleSelect,
+ MentionableSelect=FakeMentionableSelect,
+ ChannelSelect=FakeChannelSelect,
+ Modal=FakeModal,
+ TextInput=FakeTextInput,
+ button=lambda *a, **k: (lambda fn: fn),
+ )
+ discord_mod.ButtonStyle = SimpleNamespace(
+ primary=1,
+ secondary=2,
+ success=3,
+ danger=4,
+ link=5,
+ green=3,
+ blurple=1,
+ red=4,
+ )
+ discord_mod.Color = SimpleNamespace(
+ green=lambda: "green",
+ blue=lambda: "blue",
+ red=lambda: "red",
+ orange=lambda: "orange",
+ )
+ discord_mod.SelectOption = lambda **kwargs: SimpleNamespace(**kwargs)
+ discord_mod.TextStyle = SimpleNamespace(short="short", paragraph="paragraph")
+ discord_mod.opus = SimpleNamespace(
+ is_loaded=lambda: True,
+ load_opus=lambda *_args, **_kwargs: None,
+ Decoder=object,
+ )
+ discord_mod.FFmpegPCMAudio = object
+ discord_mod.PCMVolumeTransformer = object
+ discord_mod.http = SimpleNamespace(Route=object)
+ discord_mod.app_commands = SimpleNamespace(
+ describe=lambda **kwargs: (lambda fn: fn),
+ choices=lambda **kwargs: (lambda fn: fn),
+ Choice=lambda **kwargs: SimpleNamespace(**kwargs),
+ )
+ sys.modules["discord"] = discord_mod
+ ext_mod = ModuleType("discord.ext")
+ commands_mod = ModuleType("discord.ext.commands")
+ commands_mod.Bot = object
+ ext_mod.commands = commands_mod
+ sys.modules["discord.ext"] = ext_mod
+ sys.modules["discord.ext.commands"] = commands_mod
+ components_mod = importlib.reload(importlib.import_module("gateway.platforms.discord_impl.components"))
+ model_picker_mod = importlib.reload(importlib.import_module("gateway.platforms.discord_impl.model_picker"))
+ return model_picker_mod, components_mod
+
+
+model_picker, components = _load_model_picker_module()
+
+
+def _interaction(*, message_id="123", response_done=False):
+ return SimpleNamespace(
+ user=SimpleNamespace(id="42", display_name="alan"),
+ message=SimpleNamespace(id=message_id),
+ response=SimpleNamespace(
+ send_message=AsyncMock(),
+ edit_message=AsyncMock(),
+ is_done=lambda: response_done,
+ ),
+ followup=SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(id=message_id))),
+ )
+
+
+def _provider_catalog():
+ return [
+ {"id": "openrouter", "label": "OpenRouter", "authenticated": True},
+ {"id": "anthropic", "label": "Anthropic", "authenticated": True},
+ ]
+
+
+def _models_for(provider):
+ data = {
+ "openrouter": [
+ ("anthropic/claude-opus-4.6", "recommended"),
+ ("openai/gpt-5.4", ""),
+ ],
+ "anthropic": [
+ ("claude-opus-4-6", ""),
+ ("claude-sonnet-4-6", ""),
+ ],
+ }
+ return data.get(provider, [])
+
+
+def test_record_recent_model_deduplicates_and_caps(tmp_path):
+ hermes_home = tmp_path / "hermes"
+ for index in range(7):
+ model_picker.record_recent_model(
+ "42",
+ "openrouter",
+ f"model-{index}",
+ hermes_home=hermes_home,
+ )
+ model_picker.record_recent_model(
+ "42",
+ "openrouter",
+ "model-3",
+ hermes_home=hermes_home,
+ )
+
+ recents = model_picker._read_recents(hermes_home)["42"]
+
+ assert recents[0] == {"provider": "openrouter", "model": "model-3"}
+ assert len(recents) == model_picker.RECENTS_LIMIT
+
+
+def test_load_recent_models_filters_unknown_entries(monkeypatch, tmp_path):
+ hermes_home = tmp_path / "hermes"
+ model_picker._write_recents(
+ {
+ "42": [
+ {"provider": "openrouter", "model": "anthropic/claude-opus-4.6"},
+ {"provider": "unknown", "model": "ghost"},
+ {"provider": "anthropic", "model": "missing"},
+ ]
+ },
+ hermes_home,
+ )
+ monkeypatch.setattr(model_picker, "list_available_providers", _provider_catalog)
+ monkeypatch.setattr(model_picker, "curated_models_for_provider", _models_for)
+
+ recents = model_picker.load_recent_models("42", hermes_home=hermes_home)
+
+ assert recents == [
+ model_picker.RecentModel(
+ provider="openrouter",
+ model="anthropic/claude-opus-4.6",
+ )
+ ]
+
+
+@pytest.mark.asyncio
+async def test_open_model_picker_sends_ephemeral_provider_view(monkeypatch):
+ monkeypatch.setattr(model_picker, "list_available_providers", _provider_catalog)
+ monkeypatch.setattr(model_picker, "curated_models_for_provider", _models_for)
+ apply_selection = AsyncMock(return_value="changed")
+ adapter = SimpleNamespace(_component_runtime=components.DiscordComponentRuntime())
+ interaction = _interaction(response_done=True)
+
+ await model_picker.open_model_picker(
+ adapter=adapter,
+ interaction=interaction,
+ command_name="models",
+ user_id="42",
+ current_provider="openrouter",
+ current_model="anthropic/claude-opus-4.6",
+ apply_selection=apply_selection,
+ )
+
+ interaction.followup.send.assert_awaited_once()
+ kwargs = interaction.followup.send.await_args.kwargs
+ assert kwargs["ephemeral"] is True
+ assert kwargs["view"] is not None
+ assert "Discord Model Picker" in interaction.followup.send.await_args.args[0]
+
+
+@pytest.mark.asyncio
+async def test_picker_select_and_submit_flow_applies_pending_model(monkeypatch, tmp_path):
+ monkeypatch.setattr(model_picker, "list_available_providers", _provider_catalog)
+ monkeypatch.setattr(model_picker, "curated_models_for_provider", _models_for)
+ model_picker.record_recent_model(
+ "42",
+ "openrouter",
+ "openai/gpt-5.4",
+ hermes_home=tmp_path / "hermes",
+ )
+ apply_selection = AsyncMock(return_value="updated")
+ controller = model_picker.DiscordModelPickerController(
+ runtime=components.DiscordComponentRuntime(),
+ command_name="model",
+ user_id="42",
+ current_provider="openrouter",
+ current_model="anthropic/claude-opus-4.6",
+ apply_selection=apply_selection,
+ hermes_home=tmp_path / "hermes",
+ )
+
+ _content, provider_view = controller._build_provider_view()
+ provider_select = next(child for child in provider_view.children if isinstance(child, FakeSelect))
+ interaction = _interaction(message_id="321")
+ provider_select.values = ["anthropic"]
+ await provider_select.callback(interaction)
+
+ assert controller.state.pending_provider == "anthropic"
+ assert interaction.response.edit_message.await_count == 1
+
+ _content, model_view = controller._build_model_view()
+ model_select = next(child for child in model_view.children if isinstance(child, FakeSelect))
+ model_select.values = ["claude-sonnet-4-6"]
+ await model_select.callback(interaction)
+
+ submit_button = next(child for child in model_view.children if getattr(child, "label", "") == "Submit")
+ await submit_button.callback(interaction)
+
+ apply_selection.assert_awaited_once_with("anthropic", "claude-sonnet-4-6", "42")
+ assert interaction.response.edit_message.await_count >= 3
diff --git a/tests/gateway/test_discord_native_command_registration.py b/tests/gateway/test_discord_native_command_registration.py
new file mode 100644
index 000000000000..70555467bc64
--- /dev/null
+++ b/tests/gateway/test_discord_native_command_registration.py
@@ -0,0 +1,37 @@
+import importlib
+import sys
+from types import SimpleNamespace
+
+import pytest
+
+
+def _real_discord_available() -> bool:
+ try:
+ importlib.import_module("discord")
+ return True
+ except ImportError:
+ return False
+
+
+@pytest.mark.skipif(not _real_discord_available(), reason="discord.py not installed")
+def test_register_slash_commands_with_real_command_tree(monkeypatch):
+ for module_name in (
+ "discord",
+ "discord.ext",
+ "discord.ext.commands",
+ "gateway.platforms.discord_impl.native_commands",
+ ):
+ monkeypatch.delitem(sys.modules, module_name, raising=False)
+
+ importlib.invalidate_caches()
+ discord = importlib.import_module("discord")
+ native_commands = importlib.import_module("gateway.platforms.discord_impl.native_commands")
+
+ client = discord.Client(intents=discord.Intents.none())
+ tree = discord.app_commands.CommandTree(client)
+
+ native_commands.register_slash_commands(tree, SimpleNamespace())
+
+ assert tree.get_command("status") is not None
+ assert tree.get_command("model") is not None
+ assert tree.get_command("bash") is not None
diff --git a/tests/gateway/test_discord_reactions.py b/tests/gateway/test_discord_reactions.py
new file mode 100644
index 000000000000..a6bd26368a21
--- /dev/null
+++ b/tests/gateway/test_discord_reactions.py
@@ -0,0 +1,67 @@
+"""Smoke coverage for the reserved Discord reactions test path.
+
+This module keeps the expected pytest path stable for staged Discord v2
+verification commands until dedicated reaction behavior tests are added.
+"""
+
+import sys
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+
+def _ensure_discord_mock():
+ """Install a lightweight discord mock when discord.py isn't available."""
+ if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
+ return
+
+ discord_mod = MagicMock()
+ discord_mod.Intents.default.return_value = MagicMock()
+ discord_mod.Client = MagicMock
+ discord_mod.File = MagicMock
+ discord_mod.DMChannel = type("DMChannel", (), {})
+ discord_mod.Thread = type("Thread", (), {})
+ discord_mod.ForumChannel = type("ForumChannel", (), {})
+ discord_mod.ui = SimpleNamespace(
+ View=object,
+ button=lambda *a, **k: (lambda fn: fn),
+ Button=object,
+ )
+ discord_mod.ButtonStyle = SimpleNamespace(
+ success=1,
+ primary=2,
+ danger=3,
+ green=1,
+ blurple=2,
+ red=3,
+ )
+ discord_mod.Color = SimpleNamespace(
+ orange=lambda: 1,
+ green=lambda: 2,
+ blue=lambda: 3,
+ red=lambda: 4,
+ )
+ discord_mod.Interaction = object
+ discord_mod.Embed = MagicMock
+ discord_mod.app_commands = SimpleNamespace(
+ describe=lambda **kwargs: (lambda fn: fn),
+ choices=lambda **kwargs: (lambda fn: fn),
+ Choice=lambda **kwargs: SimpleNamespace(**kwargs),
+ )
+
+ ext_mod = MagicMock()
+ commands_mod = MagicMock()
+ commands_mod.Bot = MagicMock
+ ext_mod.commands = commands_mod
+
+ sys.modules.setdefault("discord", discord_mod)
+ sys.modules.setdefault("discord.ext", ext_mod)
+ sys.modules.setdefault("discord.ext.commands", commands_mod)
+
+
+_ensure_discord_mock()
+
+
+def test_discord_reactions_path_preserves_public_import_surface():
+ from gateway.platforms.discord import DiscordAdapter
+
+ assert DiscordAdapter is not None
diff --git a/tests/gateway/test_discord_runtime_controls.py b/tests/gateway/test_discord_runtime_controls.py
new file mode 100644
index 000000000000..1251929d7eba
--- /dev/null
+++ b/tests/gateway/test_discord_runtime_controls.py
@@ -0,0 +1,256 @@
+"""Tests for Discord runtime control commands added in slice 0025."""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig
+from gateway.delivery import DeliveryTarget
+from gateway.platforms.base import MessageEvent, MessageType
+from gateway.run import GatewayRunner
+from gateway.session import SessionSource, build_session_key
+
+
+def _make_source(**overrides) -> SessionSource:
+ data = {
+ "platform": Platform.DISCORD,
+ "user_id": "u1",
+ "user_name": "alan",
+ "chat_id": "c1",
+ "chat_name": "Hermes / #general",
+ "chat_type": "group",
+ }
+ data.update(overrides)
+ return SessionSource(**data)
+
+
+def _make_event(
+ text: str,
+ *,
+ source: SessionSource | None = None,
+ session_source: SessionSource | None = None,
+ target_source: SessionSource | None = None,
+ raw_channel=None,
+) -> MessageEvent:
+ source = source or target_source or _make_source()
+ metadata = {}
+ if session_source is not None:
+ metadata["session_source"] = session_source
+ if target_source is not None:
+ metadata["command_target_source"] = target_source
+ raw_message = SimpleNamespace(channel=raw_channel) if raw_channel is not None else None
+ return MessageEvent(
+ text=text,
+ message_type=MessageType.COMMAND,
+ source=source,
+ raw_message=raw_message,
+ message_id="m1",
+ metadata=metadata,
+ )
+
+
+def _make_binding(**overrides):
+ data = {
+ "thread_id": "thr-1",
+ "session_key": "discord:thread:thr-1",
+ "chat_name": "Release Thread",
+ "bound_by": "alan",
+ "parent_chat_id": "c1",
+ "idle_timeout_minutes": 1440,
+ "max_age_minutes": 0,
+ }
+ data.update(overrides)
+ return SimpleNamespace(**data)
+
+
+def _make_runner():
+ runner = object.__new__(GatewayRunner)
+ runner.config = GatewayConfig(
+ platforms={
+ Platform.DISCORD: PlatformConfig(enabled=True, token="***"),
+ Platform.TELEGRAM: PlatformConfig(
+ enabled=True,
+ token="***",
+ home_channel=HomeChannel(platform=Platform.TELEGRAM, chat_id="tg-home", name="Ops Feed"),
+ ),
+ Platform.SLACK: PlatformConfig(
+ enabled=True,
+ token="***",
+ home_channel=HomeChannel(platform=Platform.SLACK, chat_id="slack-home", name="Slack Ops"),
+ ),
+ }
+ )
+ adapter = MagicMock()
+ adapter._get_parent_channel_id = MagicMock(return_value="c1")
+ runner.adapters = {Platform.DISCORD: adapter}
+ runner.session_store = MagicMock()
+ runner.session_store._generate_session_key = lambda source: build_session_key(source)
+ runner._running_agents = {}
+ runner._voice_mode = {}
+ runner._pending_messages = {}
+ runner._pending_approvals = {}
+ runner._session_send_policies = {}
+ runner._session_docks = {}
+ runner._save_runtime_controls = MagicMock()
+ runner._schedule_gateway_restart = MagicMock()
+ runner.delivery_router = SimpleNamespace(deliver=AsyncMock())
+ return runner, adapter
+
+
+@pytest.mark.asyncio
+async def test_focus_command_binds_current_thread():
+ runner, adapter = _make_runner()
+ target_source = _make_source(
+ chat_id="thr-1",
+ chat_name="Hermes / #general / Release Thread",
+ chat_type="thread",
+ thread_id="thr-1",
+ )
+ adapter.focus_thread_binding.return_value = _make_binding(session_key=build_session_key(target_source))
+ raw_channel = SimpleNamespace(parent_id="c1")
+
+ result = await runner._handle_focus_command(
+ _make_event("/focus release", target_source=target_source, raw_channel=raw_channel)
+ )
+
+ assert "Thread focused" in result
+ adapter.focus_thread_binding.assert_called_once()
+ kwargs = adapter.focus_thread_binding.call_args.kwargs
+ assert kwargs["thread_id"] == "thr-1"
+ assert kwargs["parent_chat_id"] == "c1"
+ assert kwargs["bound_by"] == "alan"
+
+
+@pytest.mark.asyncio
+async def test_unfocus_command_removes_current_thread_binding():
+ runner, adapter = _make_runner()
+ target_source = _make_source(
+ chat_id="thr-1",
+ chat_type="thread",
+ thread_id="thr-1",
+ )
+ adapter.unfocus_thread_binding.return_value = _make_binding()
+
+ result = await runner._handle_unfocus_command(
+ _make_event("/unfocus", target_source=target_source, raw_channel=SimpleNamespace(parent_id="c1"))
+ )
+
+ assert "Thread unfocused" in result
+ adapter.unfocus_thread_binding.assert_called_once_with("thr-1")
+
+
+@pytest.mark.asyncio
+async def test_session_command_updates_idle_timeout():
+ runner, adapter = _make_runner()
+ target_source = _make_source(
+ chat_id="thr-1",
+ chat_type="thread",
+ thread_id="thr-1",
+ )
+ adapter.get_thread_binding.return_value = _make_binding()
+ adapter.update_thread_binding_limits.return_value = _make_binding(idle_timeout_minutes=120)
+
+ result = await runner._handle_session_command(
+ _make_event("/session idle 2h", target_source=target_source, raw_channel=SimpleNamespace(parent_id="c1"))
+ )
+
+ assert "Updated thread session `idle` to `2h`" in result
+ adapter.update_thread_binding_limits.assert_called_once_with("thr-1", idle_timeout_minutes=120)
+
+
+@pytest.mark.asyncio
+async def test_agents_command_lists_focused_threads_for_current_chat():
+ runner, adapter = _make_runner()
+ target_source = _make_source(chat_id="c1", chat_type="group")
+ binding = _make_binding(session_key="discord:thread:thr-1")
+ adapter.list_thread_bindings.return_value = [binding]
+ runner._running_agents[binding.session_key] = object()
+
+ result = await runner._handle_agents_command(_make_event("/agents", target_source=target_source))
+
+ assert "Focused Discord Threads" in result
+ assert "`thr-1`" in result
+ assert "running: yes" in result
+
+
+@pytest.mark.asyncio
+async def test_send_command_persists_session_policy():
+ runner, _adapter = _make_runner()
+ target_source = _make_source(chat_id="c9", chat_type="group")
+ session_key = build_session_key(target_source)
+
+ result = await runner._handle_send_command(_make_event("/send off", target_source=target_source))
+
+ assert result == "📮 Send policy for this session is now `off`."
+ assert runner._session_send_policies[session_key] == "off"
+ runner._save_runtime_controls.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_activation_command_sets_discord_chat_override():
+ runner, adapter = _make_runner()
+ target_source = _make_source(chat_id="c7", chat_type="group")
+
+ result = await runner._handle_activation_command(
+ _make_event("/activation always", target_source=target_source)
+ )
+
+ assert result == "🎛️ Activation mode for this chat is now `always`."
+ adapter.set_activation_mode.assert_called_once_with("c7", "always")
+
+
+@pytest.mark.asyncio
+async def test_dock_command_persists_home_channel_target():
+ runner, _adapter = _make_runner()
+ target_source = _make_source(chat_id="c1", chat_type="group")
+ session_key = build_session_key(target_source)
+
+ result = await runner._handle_dock_command(
+ _make_event("/dock-telegram", target_source=target_source),
+ Platform.TELEGRAM,
+ )
+
+ assert "Replies for this session are now docked to telegram home channel" in result
+ assert runner._session_docks[session_key] == {
+ "platform": "telegram",
+ "chat_id": "tg-home",
+ "thread_id": None,
+ "name": "Ops Feed",
+ }
+ runner._save_runtime_controls.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_restart_command_schedules_restart():
+ runner, _adapter = _make_runner()
+
+ result = await runner._handle_restart_command(_make_event("/restart"))
+
+ assert result == "♻️ Gateway restart scheduled. Hermes will reconnect shortly."
+ runner._schedule_gateway_restart.assert_called_once_with()
+
+
+@pytest.mark.asyncio
+async def test_deliver_docked_response_routes_to_dock_target_and_suppresses_echo():
+ runner, _adapter = _make_runner()
+ session_key = "discord:session:1"
+ runner._session_docks[session_key] = {
+ "platform": "telegram",
+ "chat_id": "tg-home",
+ "thread_id": None,
+ "name": "Ops Feed",
+ }
+ dock_target = DeliveryTarget(platform=Platform.TELEGRAM, chat_id="tg-home", thread_id=None)
+ runner.delivery_router.deliver.return_value = {
+ dock_target.to_string(): {"success": True},
+ }
+
+ result = await runner._deliver_docked_response(
+ session_key=session_key,
+ response="hello world",
+ source=_make_source(chat_id="c1", chat_type="group"),
+ )
+
+ assert result is None
+ runner.delivery_router.deliver.assert_awaited_once()
diff --git a/tests/gateway/test_discord_runtime_views.py b/tests/gateway/test_discord_runtime_views.py
new file mode 100644
index 000000000000..18ca6162d58b
--- /dev/null
+++ b/tests/gateway/test_discord_runtime_views.py
@@ -0,0 +1,263 @@
+"""Tests for Discord-native runtime status, help, commands, and whoami views."""
+
+from datetime import datetime
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from gateway.config import GatewayConfig, Platform, PlatformConfig
+from gateway.platforms.base import MessageEvent
+from gateway.session import SessionEntry, SessionSource, build_session_key
+
+
+def _make_source(**overrides) -> SessionSource:
+ base = {
+ "platform": Platform.DISCORD,
+ "user_id": "u1",
+ "chat_id": "c1",
+ "user_name": "alan",
+ "chat_name": "Hermes / #general",
+ "chat_type": "group",
+ "thread_id": "thr-1",
+ "chat_topic": "release room",
+ }
+ base.update(overrides)
+ return SessionSource(**base)
+
+
+def _make_event(
+ text: str,
+ *,
+ source: SessionSource | None = None,
+ session_source: SessionSource | None = None,
+ target_source: SessionSource | None = None,
+) -> MessageEvent:
+ source = source or _make_source()
+ metadata = {}
+ if session_source is not None:
+ metadata["session_source"] = session_source
+ if target_source is not None:
+ metadata["command_target_source"] = target_source
+ return MessageEvent(
+ text=text,
+ source=source,
+ message_id="m1",
+ metadata=metadata,
+ )
+
+
+def _make_runner(session_entry: SessionEntry):
+ from gateway.run import GatewayRunner
+
+ runner = object.__new__(GatewayRunner)
+ runner.config = GatewayConfig(
+ platforms={
+ Platform.DISCORD: PlatformConfig(enabled=True, token="***"),
+ Platform.TELEGRAM: PlatformConfig(enabled=True, token="***"),
+ }
+ )
+ discord_adapter = MagicMock()
+ discord_adapter.get_activation_mode = MagicMock(return_value="always")
+ discord_adapter.get_thread_binding = MagicMock(
+ return_value=SimpleNamespace(
+ chat_name="release room",
+ idle_timeout_minutes=120,
+ max_age_minutes=1440,
+ )
+ )
+ runner.adapters = {
+ Platform.DISCORD: discord_adapter,
+ Platform.TELEGRAM: MagicMock(),
+ }
+ runner._voice_mode = {}
+ runner._session_send_policies = {}
+ runner._session_docks = {}
+ runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
+ runner.session_store = MagicMock()
+ runner.session_store.get_or_create_session.return_value = session_entry
+ runner.session_store.load_transcript.return_value = []
+ runner.session_store.has_any_sessions.return_value = True
+ runner.session_store.append_to_transcript = MagicMock()
+ runner.session_store.rewrite_transcript = MagicMock()
+ runner.session_store.update_session = MagicMock()
+ runner._running_agents = {}
+ runner._pending_messages = {}
+ runner._pending_approvals = {}
+ runner._session_db = None
+ runner._reasoning_config = None
+ runner._provider_routing = {}
+ runner._fallback_model = None
+ runner._show_reasoning = False
+ runner._is_user_authorized = lambda _source: True
+ runner._set_session_env = lambda _context: None
+ runner._should_send_voice_reply = lambda *_args, **_kwargs: False
+ runner._send_voice_reply = AsyncMock()
+ runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None
+ runner._emit_gateway_run_progress = AsyncMock()
+ runner._load_current_model_selection = lambda: {
+ "current_model": "anthropic/claude-opus-4.6",
+ "current_provider": "openrouter",
+ }
+ runner._resolve_model_runtime_details = lambda _provider: (
+ {
+ "provider": "openrouter",
+ "api_mode": "responses",
+ "base_url": "https://openrouter.ai/api/v1",
+ "api_key": "secret-key",
+ "source": "OPENROUTER_API_KEY",
+ },
+ None,
+ )
+ runner._effective_model = None
+ runner._effective_provider = None
+ return runner
+
+
+@pytest.mark.asyncio
+async def test_discord_status_command_returns_rich_runtime_summary(monkeypatch):
+ source = _make_source()
+ slash_source = _make_source(session_namespace="slash:u1")
+ session_key = build_session_key(slash_source)
+ session_entry = SessionEntry(
+ session_key=session_key,
+ session_id="sess-1",
+ created_at=datetime(2026, 3, 19, 10, 0),
+ updated_at=datetime(2026, 3, 19, 11, 15),
+ platform=Platform.DISCORD,
+ chat_type="group",
+ input_tokens=220,
+ output_tokens=110,
+ cache_read_tokens=45,
+ cache_write_tokens=12,
+ total_tokens=387,
+ last_prompt_tokens=144,
+ estimated_cost_usd=0.0234,
+ cost_status="estimated",
+ )
+ runner = _make_runner(session_entry)
+ runner._running_agents[session_key] = MagicMock()
+ runner._pending_messages[session_key] = {"text": "queued"}
+ runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/not-real"}
+ runner._voice_mode[source.chat_id] = "all"
+ runner._session_send_policies[session_key] = "off"
+ runner._session_docks[session_key] = {
+ "platform": "telegram",
+ "chat_id": "-1001",
+ "thread_id": None,
+ "name": "Ops Feed",
+ }
+
+ monkeypatch.setattr(
+ "agent.model_metadata.get_model_context_length",
+ lambda *_args, **_kwargs: 200000,
+ )
+ monkeypatch.setattr(
+ "tools.process_registry.process_registry.has_active_for_session",
+ lambda key: key == session_key,
+ )
+
+ event = _make_event(
+ "/status",
+ source=source,
+ session_source=slash_source,
+ target_source=source,
+ )
+ result = await runner._handle_status_command(event)
+
+ assert "📊 **Hermes Status**" in result
+ assert "**Session**" in result
+ assert "**Model**" in result
+ assert "**Usage & Context**" in result
+ assert "**Runtime**" in result
+ assert "**Platforms**" in result
+ assert "`slash:u1`" in result
+ assert "`anthropic/claude-opus-4.6`" in result
+ assert "Context Window: 200,000 tokens" in result
+ assert "Pending Approval: Yes" in result
+ assert "Background Processes: Yes" in result
+ assert "Send Policy: `off`" in result
+ assert "Dock Target: telegram:Ops Feed (`-1001`)" in result
+ assert "Activation: `always`" in result
+ assert "Focused Thread: yes (release room; idle 120m; max-age 1440m)" in result
+ assert "Connected: discord, telegram" in result
+ assert "secret-key" not in result
+
+
+@pytest.mark.asyncio
+async def test_discord_help_and_commands_are_distinct():
+ session_entry = SessionEntry(
+ session_key=build_session_key(_make_source()),
+ session_id="sess-1",
+ created_at=datetime.now(),
+ updated_at=datetime.now(),
+ platform=Platform.DISCORD,
+ chat_type="group",
+ )
+ runner = _make_runner(session_entry)
+ event = _make_event("/help")
+
+ help_result = await runner._handle_help_command(event)
+ commands_result = await runner._handle_commands_command(_make_event("/commands"))
+
+ assert help_result != commands_result
+ assert "📖 **Hermes Help**" in help_result
+ assert "Use `/commands` for the full command catalog." in help_result
+ assert "**Quick Start**" in help_result
+ assert "`/whoami` — Show the sender identity Hermes sees (alias: `/id`)" in help_result
+
+ assert "🧭 **Hermes Command Catalog**" in commands_result
+ assert "Use `/help` for the guided overview." in commands_result
+ assert "**Session**" in commands_result
+ assert "**Configuration**" in commands_result
+ assert "`/models` — Open the Discord model picker or list models" in commands_result
+ assert "`/whoami` — Show the sender identity Hermes sees (alias: `/id`)" in commands_result
+
+
+@pytest.mark.asyncio
+async def test_discord_whoami_includes_routing_context():
+ session_entry = SessionEntry(
+ session_key=build_session_key(_make_source()),
+ session_id="sess-1",
+ created_at=datetime.now(),
+ updated_at=datetime.now(),
+ platform=Platform.DISCORD,
+ chat_type="group",
+ )
+ runner = _make_runner(session_entry)
+ target_source = _make_source(thread_id="thr-1", chat_topic="release room")
+ session_source = _make_source(session_namespace="slash:u1")
+
+ result = await runner._handle_whoami_command(
+ _make_event(
+ "/whoami",
+ source=target_source,
+ session_source=session_source,
+ target_source=target_source,
+ )
+ )
+
+ assert "👤 **Hermes Sees You As**" in result
+ assert "• Platform: discord" in result
+ assert "• User ID: `u1`" in result
+ assert "• Chat ID: `c1`" in result
+ assert "• Thread ID: `thr-1`" in result
+ assert "• Chat Topic: release room" in result
+ assert "• Session Namespace: `slash:u1`" in result
+
+
+@pytest.mark.asyncio
+async def test_id_alias_dispatches_to_discord_whoami():
+ session_entry = SessionEntry(
+ session_key=build_session_key(_make_source()),
+ session_id="sess-1",
+ created_at=datetime.now(),
+ updated_at=datetime.now(),
+ platform=Platform.DISCORD,
+ chat_type="group",
+ )
+ runner = _make_runner(session_entry)
+
+ result = await runner._handle_message(_make_event("/id"))
+
+ assert "👤 **Hermes Sees You As**" in result
diff --git a/tests/gateway/test_discord_send.py b/tests/gateway/test_discord_send.py
index de253146e6c4..3f7605cd7feb 100644
--- a/tests/gateway/test_discord_send.py
+++ b/tests/gateway/test_discord_send.py
@@ -1,3 +1,4 @@
+from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import sys
@@ -78,3 +79,459 @@ async def fake_send(*, content, reference=None):
assert channel.send.await_count == 2
assert send_calls[0]["reference"] is ref_msg
assert send_calls[1]["reference"] is None
+
+
+@pytest.mark.asyncio
+async def test_edit_message_updates_existing_message():
+ adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
+ message = SimpleNamespace(edit=AsyncMock())
+ channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message))
+ adapter._client = SimpleNamespace(
+ get_channel=MagicMock(return_value=channel),
+ fetch_channel=AsyncMock(),
+ )
+
+ result = await adapter.edit_message("555", "99", "updated")
+
+ assert result.success is True
+ assert result.message_id == "99"
+ channel.fetch_message.assert_awaited_once_with(99)
+ message.edit.assert_awaited_once_with(content="updated")
+
+
+@pytest.mark.asyncio
+async def test_edit_message_returns_not_connected_when_client_missing():
+ adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
+ adapter._client = None
+
+ result = await adapter.edit_message("555", "99", "updated")
+
+ assert result.success is False
+ assert result.error == "Not connected"
+
+
+@pytest.mark.asyncio
+async def test_edit_message_returns_channel_error_for_missing_channel():
+ adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
+ adapter._client = SimpleNamespace(
+ get_channel=MagicMock(return_value=None),
+ fetch_channel=AsyncMock(return_value=None),
+ )
+
+ result = await adapter.edit_message("555", "99", "updated")
+
+ assert result.success is False
+ assert result.error == "Channel 555 not found"
+
+
+@pytest.mark.asyncio
+async def test_delete_message_delegates_to_message_delete():
+ adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
+ message = SimpleNamespace(delete=AsyncMock())
+ channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message))
+ adapter._client = SimpleNamespace(
+ get_channel=MagicMock(return_value=channel),
+ fetch_channel=AsyncMock(),
+ )
+
+ result = await adapter.delete_message("555", "99")
+
+ assert result.success is True
+ assert result.message_id == "99"
+ message.delete.assert_awaited_once_with()
+
+
+@pytest.mark.asyncio
+async def test_add_and_remove_reaction_delegate_to_message_methods():
+ adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
+ message = SimpleNamespace(add_reaction=AsyncMock(), remove_reaction=AsyncMock())
+ channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message))
+ adapter._client = SimpleNamespace(
+ get_channel=MagicMock(return_value=channel),
+ fetch_channel=AsyncMock(),
+ user=SimpleNamespace(id=999),
+ )
+
+ add_result = await adapter.add_reaction("555", "99", "🔥")
+ remove_result = await adapter.remove_reaction("555", "99", "🔥")
+
+ assert add_result.success is True
+ assert remove_result.success is True
+ message.add_reaction.assert_awaited_once_with("🔥")
+ message.remove_reaction.assert_awaited_once_with("🔥", adapter._client.user)
+
+
+def test_apply_runtime_policy_overrides_refreshes_cached_policy():
+ adapter = DiscordAdapter(
+ PlatformConfig(
+ enabled=True,
+ token="***",
+ extra={
+ "allow_bots": "none",
+ "free_response_channels": ["10"],
+ "require_mention": True,
+ "auto_thread": True,
+ },
+ )
+ )
+
+ original = adapter._get_discord_policy()
+ updated = adapter.apply_runtime_policy_overrides(
+ {
+ "allow_bots": "mentions",
+ "free_response_channels": ["77", "88"],
+ "require_mention": False,
+ "auto_thread": False,
+ }
+ )
+
+ assert original.bot_filter_policy == "none"
+ assert updated.bot_filter_policy == "mentions"
+ assert updated.free_response_channels == {"77", "88"}
+ assert updated.require_mention is False
+ assert updated.auto_thread is False
+
+
+class _FakeHistoryIterator:
+ def __init__(self, messages):
+ self._messages = list(messages)
+ self._index = 0
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self):
+ if self._index >= len(self._messages):
+ raise StopAsyncIteration
+ message = self._messages[self._index]
+ self._index += 1
+ return message
+
+
+class _FakeHistoryChannel:
+ def __init__(self, messages, readable=True):
+ self.id = 123
+ self.name = "general"
+ self.guild = SimpleNamespace(me=SimpleNamespace(id=999), name="Hermes")
+ self._messages = list(messages)
+ self.permissions_for = MagicMock(
+ return_value=SimpleNamespace(
+ view_channel=readable,
+ read_message_history=readable,
+ send_messages=True,
+ attach_files=True,
+ embed_links=True,
+ add_reactions=True,
+ manage_threads=False,
+ create_public_threads=False,
+ create_private_threads=False,
+ )
+ )
+
+ def history(self, *, limit, before=None, after=None):
+ filtered = list(self._messages)
+ if before is not None:
+ filtered = [message for message in filtered if int(message.id) < int(before.id)]
+ if after is not None:
+ filtered = [message for message in filtered if int(message.id) > int(after.id)]
+ return _FakeHistoryIterator(filtered[:limit])
+
+
+def _history_message(
+ message_id,
+ *,
+ content,
+ author_id="42",
+ author_name="Jezza",
+ is_bot=False,
+ timestamp=None,
+):
+ if timestamp is None:
+ timestamp = datetime(2026, 3, 18, 12, 0, 0, tzinfo=timezone.utc)
+ return SimpleNamespace(
+ id=message_id,
+ author=SimpleNamespace(id=author_id, name=author_name, display_name=author_name, bot=is_bot),
+ content=content,
+ created_at=timestamp,
+ attachments=[],
+ reference=None,
+ )
+
+
+@pytest.mark.asyncio
+async def test_fetch_channel_history_returns_empty_for_missing_channel():
+ adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
+ adapter._client = SimpleNamespace(
+ get_channel=MagicMock(return_value=None),
+ fetch_channel=AsyncMock(return_value=None),
+ user=SimpleNamespace(id=999),
+ )
+
+ result = await adapter.fetch_channel_history("123")
+
+ assert result == []
+
+
+@pytest.mark.asyncio
+async def test_search_channel_history_serializes_messages():
+ adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
+ adapter._client = SimpleNamespace(
+ get_channel=MagicMock(
+ return_value=_FakeHistoryChannel(
+ [
+ _history_message(10, content="Hermes status update"),
+ _history_message(9, content="unrelated"),
+ ]
+ )
+ ),
+ fetch_channel=AsyncMock(),
+ user=SimpleNamespace(id=999),
+ )
+
+ result = await adapter.search_channel_history("123", "hermes", limit=1)
+
+ assert result == [
+ {
+ "id": "10",
+ "author_id": "42",
+ "author_name": "Jezza",
+ "content": "Hermes status update",
+ "timestamp": "2026-03-18T12:00:00+00:00",
+ "is_bot": False,
+ "attachments": [],
+ "reply_to": None,
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_get_channel_permissions_serializes_dm_thread_flags_as_false():
+ adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
+ dm_channel = sys.modules["discord"].DMChannel()
+ dm_channel.id = 321
+ dm_channel.name = None
+ dm_channel.guild = None
+ dm_channel.recipient = SimpleNamespace(name="Alan")
+
+ adapter._client = SimpleNamespace(
+ get_channel=MagicMock(return_value=dm_channel),
+ fetch_channel=AsyncMock(return_value=None),
+ user=SimpleNamespace(id=999),
+ )
+
+ result = await adapter.get_channel_permissions("321")
+
+ assert result == {
+ "channel_id": "321",
+ "channel_name": "Alan",
+ "can_read": True,
+ "can_send": True,
+ "can_read_history": True,
+ "can_attach_files": True,
+ "can_embed_links": True,
+ "can_add_reactions": True,
+ "can_manage_threads": False,
+ "can_create_threads": False,
+ }
+
+
+@pytest.mark.asyncio
+async def test_get_accessible_channels_includes_normalized_target_metadata():
+ adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
+ perms = SimpleNamespace(
+ view_channel=True,
+ read_messages=True,
+ send_messages=True,
+ read_message_history=True,
+ attach_files=True,
+ embed_links=True,
+ add_reactions=True,
+ manage_threads=False,
+ create_public_threads=True,
+ create_private_threads=False,
+ )
+ guild = SimpleNamespace(id=777, name="Hermes")
+ channel = SimpleNamespace(
+ id=123,
+ name="general",
+ guild=guild,
+ permissions_for=MagicMock(return_value=perms),
+ )
+ guild.me = SimpleNamespace(id=999)
+ guild.text_channels = [channel]
+ adapter._client = SimpleNamespace(guilds=[guild], user=SimpleNamespace(id=999))
+
+ result = await adapter.get_accessible_channels()
+
+ assert result == [
+ {
+ "channel_id": "123",
+ "channel_name": "general",
+ "guild_id": "777",
+ "guild_name": "Hermes",
+ "channel_kind": "channel",
+ "qualified_name": "Hermes/general",
+ "mention": "<#123>",
+ "can_read": True,
+ "can_send": True,
+ "can_read_history": True,
+ "can_attach_files": True,
+ "can_embed_links": True,
+ "can_add_reactions": True,
+ "can_manage_threads": False,
+ "can_create_threads": True,
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_list_threads_returns_serialized_threads():
+ adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
+ thread = sys.modules["discord"].Thread()
+ thread.id = 77
+ thread.name = "planning"
+ thread.parent = SimpleNamespace(id=5, name="general")
+ thread.guild = SimpleNamespace(id=1, name="Hermes")
+ thread.archived = False
+ thread.locked = False
+ thread.message_count = 4
+ thread.member_count = 2
+ channel = SimpleNamespace(threads=[thread])
+ adapter._client = SimpleNamespace(
+ get_channel=MagicMock(return_value=channel),
+ fetch_channel=AsyncMock(),
+ )
+
+ result = await adapter.list_threads("123")
+
+ assert result == [
+ {
+ "id": "77",
+ "name": "planning",
+ "parent_id": "5",
+ "parent_name": "general",
+ "guild_id": "1",
+ "guild_name": "Hermes",
+ "archived": False,
+ "locked": False,
+ "message_count": 4,
+ "member_count": 2,
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_reply_in_thread_sends_to_valid_thread():
+ adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
+ thread = sys.modules["discord"].Thread()
+ thread.parent = SimpleNamespace(id=5)
+ thread.fetch_message = AsyncMock(return_value=SimpleNamespace(id=99))
+ sent = SimpleNamespace(id=101)
+ thread.send = AsyncMock(return_value=sent)
+ adapter._client = SimpleNamespace(
+ get_channel=MagicMock(return_value=thread),
+ fetch_channel=AsyncMock(),
+ )
+
+ result = await adapter.reply_in_thread("123", "hello", reply_to="99")
+
+ assert result.success is True
+ assert result.message_id == "101"
+ thread.fetch_message.assert_awaited_once_with(99)
+ thread.send.assert_awaited_once_with(content="hello", reference=thread.fetch_message.return_value)
+
+
+@pytest.mark.asyncio
+async def test_reply_in_thread_rejects_non_thread_channel():
+ adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
+ adapter._client = SimpleNamespace(
+ get_channel=MagicMock(return_value=SimpleNamespace(parent=None)),
+ fetch_channel=AsyncMock(),
+ )
+
+ result = await adapter.reply_in_thread("123", "hello")
+
+ assert result.success is False
+ assert result.error == "Channel 123 is not a thread"
+
+
+@pytest.mark.asyncio
+async def test_list_pins_returns_serialized_messages():
+ adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
+ author = SimpleNamespace(id=42, name="Jezza", display_name="Jezza", bot=False)
+ pinned = SimpleNamespace(
+ id=7,
+ author=author,
+ content="important",
+ created_at=datetime(2026, 3, 18, 12, 0, 0, tzinfo=timezone.utc),
+ attachments=[],
+ reference=None,
+ )
+ channel = SimpleNamespace(pins=MagicMock(return_value=_FakeHistoryIterator([pinned])))
+ adapter._client = SimpleNamespace(
+ get_channel=MagicMock(return_value=channel),
+ fetch_channel=AsyncMock(),
+ )
+
+ result = await adapter.list_pins("123")
+
+ assert result == [
+ {
+ "id": "7",
+ "author_id": "42",
+ "author_name": "Jezza",
+ "content": "important",
+ "timestamp": "2026-03-18T12:00:00+00:00",
+ "is_bot": False,
+ "attachments": [],
+ "reply_to": None,
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_pin_and_unpin_message_delegate_to_message_methods():
+ adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
+ message = SimpleNamespace(pin=AsyncMock(), unpin=AsyncMock())
+ channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message))
+ adapter._client = SimpleNamespace(
+ get_channel=MagicMock(return_value=channel),
+ fetch_channel=AsyncMock(),
+ )
+
+ pin_result = await adapter.pin_message("123", "456", reason="keep")
+ unpin_result = await adapter.unpin_message("123", "456", reason="drop")
+
+ assert pin_result.success is True
+ assert unpin_result.success is True
+ message.pin.assert_awaited_once_with(reason="keep")
+ message.unpin.assert_awaited_once_with(reason="drop")
+
+
+@pytest.mark.asyncio
+async def test_list_reactions_returns_serialized_summaries():
+ adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
+ reaction = SimpleNamespace(
+ emoji="🔥",
+ count=2,
+ users=MagicMock(
+ return_value=_FakeHistoryIterator([SimpleNamespace(id=1, username="alan", discriminator="1234")])
+ ),
+ )
+ message = SimpleNamespace(reactions=[reaction])
+ channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message))
+ adapter._client = SimpleNamespace(
+ get_channel=MagicMock(return_value=channel),
+ fetch_channel=AsyncMock(),
+ )
+
+ result = await adapter.list_reactions("123", "456", limit=3)
+
+ assert result == [
+ {
+ "emoji": {"id": None, "name": "🔥", "raw": "🔥"},
+ "count": 2,
+ "users": [{"id": "1", "username": "alan", "tag": "alan#1234"}],
+ }
+ ]
+ reaction.users.assert_called_once_with(limit=3)
diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py
index eea4dc2cba97..c9f2e4151dce 100644
--- a/tests/gateway/test_discord_slash_commands.py
+++ b/tests/gateway/test_discord_slash_commands.py
@@ -86,6 +86,58 @@ async def test_registers_native_thread_slash_command(adapter):
adapter._handle_thread_create_slash.assert_awaited_once_with(interaction, "Planning", "", 1440)
+@pytest.mark.asyncio
+async def test_run_simple_slash_sends_returned_native_response(adapter):
+ state = {"done": False}
+
+ async def mark_deferred(*_args, **_kwargs):
+ state["done"] = True
+
+ adapter._message_handler = AsyncMock(return_value="hello from status")
+ interaction = SimpleNamespace(
+ response=SimpleNamespace(
+ defer=AsyncMock(side_effect=mark_deferred),
+ send_message=AsyncMock(),
+ is_done=lambda: state["done"],
+ ),
+ followup=SimpleNamespace(send=AsyncMock()),
+ channel=SimpleNamespace(
+ id=123,
+ name="general",
+ guild=SimpleNamespace(name="Hermes"),
+ topic="topic",
+ ),
+ channel_id=123,
+ user=SimpleNamespace(id=42, display_name="Jezza"),
+ )
+
+ await adapter._run_simple_slash(interaction, "/status")
+
+ adapter._message_handler.assert_awaited_once()
+ event = adapter._message_handler.await_args.args[0]
+ assert event.text == "/status"
+ interaction.response.defer.assert_awaited_once_with(ephemeral=True)
+ interaction.followup.send.assert_awaited_once_with("hello from status", ephemeral=True)
+
+
+@pytest.mark.asyncio
+async def test_send_native_slash_content_chunks_long_output(adapter):
+ interaction = SimpleNamespace(
+ response=SimpleNamespace(
+ send_message=AsyncMock(),
+ is_done=lambda: True,
+ ),
+ followup=SimpleNamespace(send=AsyncMock()),
+ )
+ content = "x" * (adapter.MAX_MESSAGE_LENGTH + 200)
+
+ await adapter._send_native_slash_content(interaction, content)
+
+ assert interaction.followup.send.await_count == 2
+ sent_chunks = [call.args[0] for call in interaction.followup.send.await_args_list]
+ assert all(len(chunk) <= adapter.MAX_MESSAGE_LENGTH for chunk in sent_chunks)
+
+
# ------------------------------------------------------------------
# _handle_thread_create_slash — success, session dispatch, failure
# ------------------------------------------------------------------
@@ -412,6 +464,32 @@ async def capture_handle(event):
assert captured_events[0].source.chat_id == "100" # stays in channel
+@pytest.mark.asyncio
+async def test_auto_thread_platform_extra_overrides_env(monkeypatch):
+ monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
+ monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
+
+ config = PlatformConfig(enabled=True, token="fake-token", extra={"auto_thread": False})
+ adapter = DiscordAdapter(config)
+ adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
+ adapter._auto_create_thread = AsyncMock()
+
+ captured_events = []
+
+ async def capture_handle(event):
+ captured_events.append(event)
+
+ adapter.handle_message = capture_handle
+
+ msg = _fake_message(_FakeTextChannel())
+
+ await adapter._handle_message(msg)
+
+ adapter._auto_create_thread.assert_not_awaited()
+ assert len(captured_events) == 1
+ assert captured_events[0].source.chat_id == "100"
+
+
@pytest.mark.asyncio
async def test_auto_thread_skips_threads_and_dms(adapter, monkeypatch):
"""Auto-thread should not create threads inside existing threads."""
diff --git a/tests/gateway/test_model_command.py b/tests/gateway/test_model_command.py
new file mode 100644
index 000000000000..f8b83617f25b
--- /dev/null
+++ b/tests/gateway/test_model_command.py
@@ -0,0 +1,225 @@
+"""Tests for gateway /model and /models behavior."""
+
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import yaml
+
+import gateway.run as gateway_run
+from gateway.config import Platform
+from gateway.platforms.base import MessageEvent
+from gateway.session import SessionSource
+
+
+def _make_runner():
+ from gateway.run import GatewayRunner
+
+ runner = object.__new__(GatewayRunner)
+ runner.adapters = {Platform.DISCORD: SimpleNamespace(_component_runtime=object())}
+ runner._effective_model = None
+ runner._effective_provider = None
+ return runner
+
+
+def _make_event(text: str, *, native_slash: bool = False):
+ source = SessionSource(
+ platform=Platform.DISCORD,
+ chat_id="c1",
+ chat_name="Hermes / #general",
+ chat_type="group",
+ user_id="u1",
+ user_name="alan",
+ )
+ metadata = {"is_native_slash": True} if native_slash else {}
+ interaction = None
+ if native_slash:
+ interaction = SimpleNamespace(
+ response=SimpleNamespace(is_done=lambda: True, send_message=AsyncMock()),
+ followup=SimpleNamespace(send=AsyncMock()),
+ )
+ return MessageEvent(
+ text=text,
+ source=source,
+ raw_message=interaction,
+ metadata=metadata,
+ )
+
+
+@pytest.mark.asyncio
+async def test_model_without_args_returns_numbered_catalog(monkeypatch, tmp_path):
+ hermes_home = tmp_path / "hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text(
+ "model:\n default: anthropic/claude-opus-4.6\n provider: openrouter\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
+ monkeypatch.setattr(
+ "hermes_cli.models.curated_models_for_provider",
+ lambda _provider: [
+ ("anthropic/claude-opus-4.6", "recommended"),
+ ("openai/gpt-5.4", ""),
+ ],
+ )
+
+ runner = _make_runner()
+ result = await runner._handle_model_command(_make_event("/model"))
+
+ assert "Model Catalog" in result
+ assert "1. `anthropic/claude-opus-4.6`" in result
+ assert "2. `openai/gpt-5.4`" in result
+ assert "/model " in result
+
+
+@pytest.mark.asyncio
+async def test_model_numeric_selection_persists_and_records_recent(monkeypatch, tmp_path):
+ hermes_home = tmp_path / "hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "model:\n default: anthropic/claude-opus-4.6\n provider: openrouter\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
+ monkeypatch.setattr(
+ "hermes_cli.models.curated_models_for_provider",
+ lambda _provider: [
+ ("anthropic/claude-opus-4.6", "recommended"),
+ ("openai/gpt-5.4", ""),
+ ],
+ )
+ monkeypatch.setattr(
+ "hermes_cli.models.validate_requested_model",
+ lambda *_args, **_kwargs: {
+ "accepted": True,
+ "persist": True,
+ "recognized": True,
+ "message": None,
+ },
+ )
+ recent_calls = []
+ monkeypatch.setattr(
+ "gateway.platforms.discord_impl.model_picker.record_recent_model",
+ lambda user_id, provider, model: recent_calls.append((user_id, provider, model)),
+ )
+
+ runner = _make_runner()
+ runner._resolve_model_runtime_details = lambda _provider: (
+ {"api_key": "test-key", "base_url": "https://openrouter.ai/api/v1"},
+ None,
+ )
+
+ result = await runner._handle_model_command(_make_event("/model 2"))
+
+ saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
+ assert result.startswith("🤖 Model changed to `openai/gpt-5.4`")
+ assert saved["model"]["default"] == "openai/gpt-5.4"
+ assert saved["model"]["provider"] == "openrouter"
+ assert recent_calls == [("u1", "openrouter", "openai/gpt-5.4")]
+
+
+@pytest.mark.asyncio
+async def test_model_status_reports_runtime_details(monkeypatch, tmp_path):
+ hermes_home = tmp_path / "hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text(
+ "model:\n default: anthropic/claude-opus-4.6\n provider: openrouter\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
+
+ runner = _make_runner()
+ runner._resolve_model_runtime_details = lambda _provider: (
+ {
+ "provider": "openrouter",
+ "api_mode": "chat_completions",
+ "base_url": "https://openrouter.ai/api/v1",
+ "api_key": "test-key",
+ "source": "env/config",
+ },
+ None,
+ )
+
+ result = await runner._handle_model_command(_make_event("/model status"))
+
+ assert "Model Status" in result
+ assert "**Runtime provider:** OpenRouter (`openrouter`)" in result
+ assert "**API mode:** `chat_completions`" in result
+ assert "**Base URL:** `https://openrouter.ai/api/v1`" in result
+ assert "**Credentials:** configured" in result
+
+
+@pytest.mark.asyncio
+async def test_native_slash_model_without_args_opens_picker(monkeypatch, tmp_path):
+ hermes_home = tmp_path / "hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text(
+ "model:\n default: anthropic/claude-opus-4.6\n provider: openrouter\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
+ open_picker = AsyncMock()
+ monkeypatch.setattr(
+ "gateway.platforms.discord_impl.model_picker.open_model_picker",
+ open_picker,
+ )
+
+ runner = _make_runner()
+ result = await runner._handle_model_command(_make_event("/model", native_slash=True))
+
+ assert result is None
+ open_picker.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_native_slash_models_without_args_opens_picker(monkeypatch, tmp_path):
+ hermes_home = tmp_path / "hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text(
+ "model:\n default: anthropic/claude-opus-4.6\n provider: openrouter\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
+ open_picker = AsyncMock()
+ monkeypatch.setattr(
+ "gateway.platforms.discord_impl.model_picker.open_model_picker",
+ open_picker,
+ )
+
+ runner = _make_runner()
+ result = await runner._handle_model_command(_make_event("/models", native_slash=True))
+
+ assert result is None
+ open_picker.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_native_slash_model_status_uses_ephemeral_followup(monkeypatch, tmp_path):
+ hermes_home = tmp_path / "hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text(
+ "model:\n default: anthropic/claude-opus-4.6\n provider: openrouter\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
+
+ runner = _make_runner()
+ runner._resolve_model_runtime_details = lambda _provider: (
+ {
+ "provider": "openrouter",
+ "api_mode": "chat_completions",
+ "base_url": "https://openrouter.ai/api/v1",
+ "api_key": "test-key",
+ },
+ None,
+ )
+ event = _make_event("/model status", native_slash=True)
+
+ result = await runner._handle_model_command(event)
+
+ assert result is None
+ event.raw_message.followup.send.assert_awaited_once()
+ sent = event.raw_message.followup.send.await_args.args[0]
+ assert "Model Status" in sent
diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py
index bf698cdd15ec..89f462b95ae2 100644
--- a/tests/gateway/test_session.py
+++ b/tests/gateway/test_session.py
@@ -54,6 +54,17 @@ def test_full_roundtrip_with_chat_topic(self):
assert restored.chat_topic == "Planning and coordination for Project X"
assert restored.chat_name == "Server / #project-planning"
+ def test_full_roundtrip_with_session_namespace(self):
+ source = SessionSource(
+ platform=Platform.DISCORD,
+ chat_id="789",
+ chat_type="group",
+ user_id="42",
+ session_namespace="slash:42",
+ )
+ restored = SessionSource.from_dict(source.to_dict())
+ assert restored.session_namespace == "slash:42"
+
def test_minimal_roundtrip(self):
source = SessionSource(platform=Platform.LOCAL, chat_id="cli")
d = source.to_dict()
@@ -553,6 +564,17 @@ def test_group_thread_sessions_are_isolated_per_user(self):
key = build_session_key(source)
assert key == "agent:main:telegram:group:-1002285219667:17585:42"
+ def test_session_namespace_extends_session_key(self):
+ source = SessionSource(
+ platform=Platform.DISCORD,
+ chat_id="guild-123",
+ chat_type="group",
+ user_id="alice",
+ session_namespace="slash:alice",
+ )
+ key = build_session_key(source)
+ assert key == "agent:main:discord:group:guild-123:alice:slash:alice"
+
class TestSessionStoreEntriesAttribute:
"""Regression: /reset must access _entries, not _sessions."""
diff --git a/tests/gateway/test_session_env.py b/tests/gateway/test_session_env.py
index 596df89ecf7e..19b76d74ba55 100644
--- a/tests/gateway/test_session_env.py
+++ b/tests/gateway/test_session_env.py
@@ -36,6 +36,7 @@ def test_clear_session_env_removes_thread_id(monkeypatch):
monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "-1001")
monkeypatch.setenv("HERMES_SESSION_CHAT_NAME", "Group")
monkeypatch.setenv("HERMES_SESSION_THREAD_ID", "17585")
+ monkeypatch.setenv("HERMES_SESSION_SEND_POLICY", "off")
runner._clear_session_env()
@@ -43,3 +44,4 @@ def test_clear_session_env_removes_thread_id(monkeypatch):
assert os.getenv("HERMES_SESSION_CHAT_ID") is None
assert os.getenv("HERMES_SESSION_CHAT_NAME") is None
assert os.getenv("HERMES_SESSION_THREAD_ID") is None
+ assert os.getenv("HERMES_SESSION_SEND_POLICY") is None
diff --git a/tests/gateway/test_status_command.py b/tests/gateway/test_status_command.py
index 1378ff1cb961..4a87967b91bf 100644
--- a/tests/gateway/test_status_command.py
+++ b/tests/gateway/test_status_command.py
@@ -138,3 +138,70 @@ async def test_handle_message_persists_agent_token_counts(monkeypatch):
provider=None,
base_url=None,
)
+
+
+@pytest.mark.asyncio
+async def test_commands_command_returns_gateway_catalog():
+ session_entry = SessionEntry(
+ session_key=build_session_key(_make_source()),
+ session_id="sess-1",
+ created_at=datetime.now(),
+ updated_at=datetime.now(),
+ platform=Platform.TELEGRAM,
+ chat_type="dm",
+ )
+ runner = _make_runner(session_entry)
+
+ result = await runner._handle_commands_command(_make_event("/commands"))
+
+ assert "Hermes Command Catalog" in result
+ assert "/commands" in result
+ assert "/whoami" in result
+
+
+@pytest.mark.asyncio
+async def test_whoami_command_returns_sender_identity():
+ session_entry = SessionEntry(
+ session_key=build_session_key(_make_source()),
+ session_id="sess-1",
+ created_at=datetime.now(),
+ updated_at=datetime.now(),
+ platform=Platform.TELEGRAM,
+ chat_type="dm",
+ )
+ runner = _make_runner(session_entry)
+
+ result = await runner._handle_whoami_command(_make_event("/whoami"))
+
+ assert "Hermes Sees You As" in result
+ assert "**Platform:** telegram" in result
+ assert "**User ID:** `u1`" in result
+ assert "**Chat ID:** `c1`" in result
+
+
+def test_session_source_override_is_used_for_command_sessions():
+ session_entry = SessionEntry(
+ session_key=build_session_key(_make_source()),
+ session_id="sess-1",
+ created_at=datetime.now(),
+ updated_at=datetime.now(),
+ platform=Platform.TELEGRAM,
+ chat_type="dm",
+ )
+ runner = _make_runner(session_entry)
+ command_source = SessionSource(
+ platform=Platform.TELEGRAM,
+ user_id="u1",
+ chat_id="c1",
+ user_name="tester",
+ chat_type="dm",
+ session_namespace="slash:u1",
+ )
+ event = MessageEvent(
+ text="/status",
+ source=_make_source(),
+ message_id="m1",
+ metadata={"session_source": command_source},
+ )
+
+ assert runner._session_source_for_event(event) == command_source
diff --git a/tests/gateway/test_subagent_acp_commands.py b/tests/gateway/test_subagent_acp_commands.py
new file mode 100644
index 000000000000..8825353c8744
--- /dev/null
+++ b/tests/gateway/test_subagent_acp_commands.py
@@ -0,0 +1,181 @@
+from __future__ import annotations
+
+from datetime import datetime
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from acp_adapter.session import SessionManager
+from gateway.config import GatewayConfig, Platform, PlatformConfig
+from gateway.platforms.base import MessageEvent, MessageType
+from gateway.run import GatewayRunner
+from gateway.session import SessionEntry, SessionSource, build_session_key
+
+
+def _make_source(**overrides) -> SessionSource:
+ data = {
+ "platform": Platform.DISCORD,
+ "user_id": "u1",
+ "chat_id": "c1",
+ "user_name": "alan",
+ "chat_name": "Hermes / #general",
+ "chat_type": "group",
+ }
+ data.update(overrides)
+ return SessionSource(**data)
+
+
+def _make_event(text: str, *, source: SessionSource | None = None) -> MessageEvent:
+ return MessageEvent(
+ text=text,
+ message_type=MessageType.COMMAND,
+ source=source or _make_source(),
+ message_id="m1",
+ )
+
+
+def _make_runner() -> GatewayRunner:
+ runner = object.__new__(GatewayRunner)
+ runner.config = GatewayConfig(
+ platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="***")}
+ )
+ runner.adapters = {
+ Platform.DISCORD: SimpleNamespace(send=AsyncMock())
+ }
+ source = _make_source()
+ session_entry = SessionEntry(
+ session_key=build_session_key(source),
+ session_id="sess-1",
+ created_at=datetime(2026, 3, 19, 12, 0),
+ updated_at=datetime(2026, 3, 19, 12, 30),
+ platform=Platform.DISCORD,
+ chat_type="group",
+ )
+ runner.session_store = MagicMock()
+ runner.session_store._generate_session_key = lambda session_source: build_session_key(session_source)
+ runner.session_store.get_or_create_session.return_value = session_entry
+ runner.session_store.load_transcript.return_value = []
+ runner._running_agents = {}
+ runner._pending_messages = {}
+ runner._pending_approvals = {}
+ runner._voice_mode = {}
+ runner._session_send_policies = {}
+ runner._session_docks = {}
+ runner._effective_model = None
+ runner._effective_provider = None
+ runner._reasoning_config = {"enabled": True, "effort": "medium"}
+ runner._show_reasoning = False
+ runner._provider_routing = {}
+ runner._fallback_model = None
+ runner._smart_model_routing = {}
+ runner._runtime_debug_overrides = {}
+ runner._prefill_messages = []
+ runner._ephemeral_system_prompt = ""
+ runner._session_db = None
+ runner._subagent_runtime = {}
+ runner._acp_session_bindings = {}
+ runner._acp_session_meta = {}
+ runner._acp_running_tasks = {}
+ runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
+ runner._is_user_authorized = lambda _source: True
+ return runner
+
+
+@pytest.mark.asyncio
+async def test_running_session_allows_subagent_command_without_interrupt():
+ runner = _make_runner()
+ source = _make_source()
+ session_key = build_session_key(source)
+ child = MagicMock()
+ child.interrupt = MagicMock()
+
+ runner._register_delegate_child(
+ session_key=session_key,
+ child=child,
+ task_index=0,
+ goal="inspect logs",
+ context="",
+ toolsets=["terminal"],
+ model="anthropic/claude-opus-4.6",
+ )
+ runner._start_delegate_child(session_key=session_key, child=child, goal="inspect logs")
+
+ active_agent = MagicMock()
+ runner._running_agents[session_key] = active_agent
+
+ result = await runner._handle_message(_make_event("/subagents list", source=source))
+
+ assert "Subagents" in result
+ assert "sa-1" in result
+ active_agent.interrupt.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_kill_and_steer_commands_control_live_subagent():
+ runner = _make_runner()
+ source = _make_source()
+ session_key = build_session_key(source)
+ child = MagicMock()
+ child.interrupt = MagicMock()
+
+ runner._register_delegate_child(
+ session_key=session_key,
+ child=child,
+ task_index=0,
+ goal="inspect logs",
+ context="",
+ toolsets=["terminal"],
+ model="anthropic/claude-opus-4.6",
+ )
+ runner._start_delegate_child(session_key=session_key, child=child, goal="inspect logs")
+
+ steer_result = await runner._handle_steer_command(_make_event("/steer #1 focus on tests", source=source))
+ kill_result = await runner._handle_kill_command(_make_event("/kill #1", source=source))
+
+ entry = runner._get_subagent_session_state(session_key)["entries"]["sa-1"]
+ assert "Steering subagent" in steer_result
+ assert entry["pending_steer"] is None
+ assert "Requested stop" in kill_result
+ assert child.interrupt.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_acp_spawn_status_option_commands_and_close():
+ runner = _make_runner()
+
+ class FakeACPAgent:
+ def __init__(self):
+ self.model = "anthropic/claude-opus-4.6"
+ self.interrupt = MagicMock()
+
+ def run_conversation(self, user_message, conversation_history=None, task_id=None):
+ return {
+ "final_response": f"ACP:{user_message}",
+ "messages": list(conversation_history or []),
+ "completed": True,
+ }
+
+ runner._acp_session_manager = SessionManager(agent_factory=FakeACPAgent)
+ source = _make_source()
+ session_key = build_session_key(source)
+
+ spawn_result = await runner._handle_acp_command(_make_event("/acp spawn /tmp/work", source=source))
+ session_id = runner._get_acp_bindings()[session_key]
+
+ sessions_result = await runner._handle_acp_command(_make_event("/acp sessions", source=source))
+ status_result = await runner._handle_acp_command(_make_event("/acp status", source=source))
+ model_result = await runner._handle_acp_command(_make_event("/acp model openai/gpt-5.2", source=source))
+ timeout_result = await runner._handle_acp_command(_make_event("/acp timeout 45", source=source))
+ set_result = await runner._handle_acp_command(_make_event("/acp set profile fast", source=source))
+ reset_result = await runner._handle_acp_command(_make_event("/acp reset-options", source=source))
+ close_result = await runner._handle_acp_command(_make_event("/acp close", source=source))
+
+ assert "Created ACP session" in spawn_result
+ assert session_id in sessions_result
+ assert session_id in status_result
+ assert "model set to `openai/gpt-5.2`" in model_result
+ assert "timeout set to `45`s" in timeout_result
+ assert "option `profile` set to `fast`" in set_result
+ assert "reset `2` option" in reset_result
+ assert f"Closed ACP session `{session_id}`" in close_result
diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py
index 1a779f8a0bb7..37b935b08b14 100644
--- a/tests/tools/test_delegate.py
+++ b/tests/tools/test_delegate.py
@@ -877,5 +877,84 @@ def test_model_only_no_provider_inherits_parent_credentials(self, mock_creds, mo
self.assertEqual(kwargs["base_url"], parent.base_url)
+class TestGatewaySubagentHooks(unittest.TestCase):
+ def test_build_child_agent_registers_gateway_subagent_id(self):
+ from tools.delegate_tool import _build_child_agent
+
+ parent = _make_mock_parent(depth=0)
+ parent.enabled_toolsets = ["terminal", "file"]
+ parent.max_tokens = None
+ parent.reasoning_config = None
+ parent.prefill_messages = None
+ parent.tool_progress_callback = None
+ parent._delegate_spinner = None
+ parent.iteration_budget = None
+ parent._register_delegate_child = MagicMock(return_value="sa-9")
+
+ with patch("run_agent.AIAgent") as MockAgent:
+ mock_child = MagicMock()
+ MockAgent.return_value = mock_child
+
+ child = _build_child_agent(
+ task_index=0,
+ goal="Inspect Discord UX parity",
+ context=None,
+ toolsets=None,
+ model=None,
+ max_iterations=10,
+ parent_agent=parent,
+ )
+
+ self.assertIs(child, mock_child)
+ self.assertEqual(child._gateway_subagent_id, "sa-9")
+ parent._register_delegate_child.assert_called_once()
+
+ def test_run_single_child_restarts_on_pending_steer(self):
+ from tools.delegate_tool import _run_single_child
+
+ parent = _make_mock_parent(depth=0)
+ parent._start_delegate_child = MagicMock()
+ parent._record_delegate_child_progress = MagicMock()
+ parent._complete_delegate_child = MagicMock()
+
+ child = MagicMock()
+ child._delegate_saved_tool_names = []
+ child._gateway_pending_steer = "Refocus on Discord slash commands"
+ child.run_conversation.side_effect = [
+ {
+ "final_response": "Interrupted for steer",
+ "messages": [],
+ "api_calls": 1,
+ "completed": False,
+ "interrupted": True,
+ },
+ {
+ "final_response": "Done after steer",
+ "messages": [],
+ "api_calls": 2,
+ "completed": True,
+ "interrupted": False,
+ },
+ ]
+
+ result = _run_single_child(
+ task_index=0,
+ goal="Original goal",
+ child=child,
+ parent_agent=parent,
+ )
+
+ self.assertEqual(child.run_conversation.call_count, 2)
+ self.assertEqual(child.run_conversation.call_args_list[0].kwargs["user_message"], "Original goal")
+ self.assertEqual(
+ child.run_conversation.call_args_list[1].kwargs["user_message"],
+ "Refocus on Discord slash commands",
+ )
+ self.assertEqual(result["status"], "completed")
+ self.assertEqual(result["summary"], "Done after steer")
+ self.assertGreaterEqual(parent._start_delegate_child.call_count, 2)
+ parent._complete_delegate_child.assert_called_once()
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py
index 058678d36a99..278214140b9e 100644
--- a/tests/tools/test_send_message_tool.py
+++ b/tests/tools/test_send_message_tool.py
@@ -33,6 +33,23 @@ def _install_telegram_mock(monkeypatch, bot):
class TestSendMessageTool:
+ def test_session_send_policy_off_blocks_send_message(self):
+ with patch.dict(os.environ, {"HERMES_SESSION_SEND_POLICY": "off"}, clear=False):
+ result = json.loads(
+ send_message_tool(
+ {
+ "action": "send",
+ "target": "telegram:-1001",
+ "message": "hello",
+ }
+ )
+ )
+
+ assert result["error"] == (
+ "send_message is disabled for this session. "
+ "Use /send on or /send inherit to re-enable it."
+ )
+
def test_cron_duplicate_target_is_skipped_and_explained(self):
home = SimpleNamespace(chat_id="-1001")
config, _telegram_cfg = _make_config()
diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py
index 36c6dad98435..33ca789d014b 100644
--- a/tools/delegate_tool.py
+++ b/tools/delegate_tool.py
@@ -75,6 +75,18 @@ def _strip_blocked_tools(toolsets: List[str]) -> List[str]:
return [t for t in toolsets if t not in blocked_toolset_names]
+def _notify_parent(parent_agent, hook_name: str, **payload):
+ """Best-effort callback into parent-owned delegation hooks."""
+ hook = getattr(parent_agent, hook_name, None)
+ if not callable(hook):
+ return None
+ try:
+ return hook(**payload)
+ except Exception as e:
+ logger.debug("Delegate parent hook %s failed: %s", hook_name, e)
+ return None
+
+
def _build_child_progress_callback(task_index: int, parent_agent, task_count: int = 1) -> Optional[callable]:
"""Build a callback that relays child agent tool calls to the parent display.
@@ -98,7 +110,7 @@ def _build_child_progress_callback(task_index: int, parent_agent, task_count: in
_BATCH_SIZE = 5
_batch: List[str] = []
- def _callback(tool_name: str, preview: str = None):
+ def _callback(tool_name: str, preview: str = None, args: dict = None):
# Special "_thinking" event: model produced text content (reasoning)
if tool_name == "_thinking":
if spinner:
@@ -131,8 +143,29 @@ def _callback(tool_name: str, preview: str = None):
parent_cb("subagent_progress", f"🔀 {prefix}{summary}")
except Exception as e:
logger.debug("Parent callback failed: %s", e)
+ _notify_parent(
+ parent_agent,
+ "_record_delegate_child_progress",
+ child=getattr(_callback, "_delegate_child", None),
+ task_index=task_index,
+ tool_name=tool_name,
+ preview=preview,
+ args=args,
+ summary=summary,
+ )
_batch.clear()
+ _notify_parent(
+ parent_agent,
+ "_record_delegate_child_progress",
+ child=getattr(_callback, "_delegate_child", None),
+ task_index=task_index,
+ tool_name=tool_name,
+ preview=preview,
+ args=args,
+ summary=preview or tool_name,
+ )
+
def _flush():
"""Flush remaining batched tool names to gateway on completion."""
if parent_cb and _batch:
@@ -232,9 +265,25 @@ def _build_child_agent(
tool_progress_callback=child_progress_cb,
iteration_budget=shared_budget,
)
+ child._delegate_saved_tool_names = list(model_tools._last_resolved_tool_names)
+ if child_progress_cb is not None:
+ child_progress_cb._delegate_child = child
# Set delegation depth so children can't spawn grandchildren
child._delegate_depth = getattr(parent_agent, '_delegate_depth', 0) + 1
+ subagent_id = _notify_parent(
+ parent_agent,
+ "_register_delegate_child",
+ child=child,
+ task_index=task_index,
+ goal=goal,
+ context=context,
+ toolsets=child_toolsets,
+ model=effective_model,
+ )
+ if subagent_id:
+ child._gateway_subagent_id = subagent_id
+
# Register child for interrupt propagation
if hasattr(parent_agent, '_active_children'):
lock = getattr(parent_agent, '_active_children_lock', None)
@@ -268,8 +317,40 @@ def _run_single_child(
_saved_tool_names = getattr(child, "_delegate_saved_tool_names",
list(model_tools._last_resolved_tool_names))
+ current_goal = goal
+ _notify_parent(
+ parent_agent,
+ "_start_delegate_child",
+ child=child,
+ goal=current_goal,
+ )
+
try:
- result = child.run_conversation(user_message=goal)
+ while True:
+ result = child.run_conversation(user_message=current_goal)
+ pending_steer = getattr(child, "_gateway_pending_steer", None)
+ if result.get("interrupted") and pending_steer:
+ setattr(child, "_gateway_pending_steer", None)
+ current_goal = str(pending_steer).strip()
+ _notify_parent(
+ parent_agent,
+ "_record_delegate_child_progress",
+ child=child,
+ task_index=task_index,
+ tool_name="subagent_steer",
+ preview=current_goal,
+ args=None,
+ summary=f"restart on steer: {current_goal[:120]}",
+ )
+ _notify_parent(
+ parent_agent,
+ "_start_delegate_child",
+ child=child,
+ goal=current_goal,
+ restarted=True,
+ )
+ continue
+ break
# Flush any remaining batched progress to gateway
if child_progress_cb and hasattr(child_progress_cb, '_flush'):
@@ -360,12 +441,20 @@ def _run_single_child(
if status == "failed":
entry["error"] = result.get("error", "Subagent did not produce a response.")
+ _notify_parent(
+ parent_agent,
+ "_complete_delegate_child",
+ child=child,
+ entry=entry,
+ goal=current_goal,
+ )
+
return entry
except Exception as exc:
duration = round(time.monotonic() - child_start, 2)
logging.exception(f"[subagent-{task_index}] failed")
- return {
+ entry = {
"task_index": task_index,
"status": "error",
"summary": None,
@@ -373,6 +462,14 @@ def _run_single_child(
"api_calls": 0,
"duration_seconds": duration,
}
+ _notify_parent(
+ parent_agent,
+ "_complete_delegate_child",
+ child=child,
+ entry=entry,
+ goal=current_goal,
+ )
+ return entry
finally:
# Restore the parent's tool names so the process-global is correct
diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py
index ed0a5cb60e2d..f69db0f6ac79 100644
--- a/tools/send_message_tool.py
+++ b/tools/send_message_tool.py
@@ -74,6 +74,15 @@ def _handle_list():
def _handle_send(args):
"""Send a message to a platform target."""
+ session_send_policy = os.getenv("HERMES_SESSION_SEND_POLICY", "inherit").strip().lower()
+ if session_send_policy == "off":
+ return json.dumps({
+ "error": (
+ "send_message is disabled for this session. "
+ "Use /send on or /send inherit to re-enable it."
+ )
+ })
+
target = args.get("target", "")
message = args.get("message", "")
if not target or not message: