From fd77d4f17ae1a71718352d5c97114e904eb5b83f Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Jul 2026 10:42:29 +0200 Subject: [PATCH] fix(discord): add explicit CLI handoff initiator --- gateway/platforms/base.py | 2 + gateway/run.py | 5 +- hermes_cli/cli_commands_mixin.py | 25 ++++- hermes_cli/commands.py | 2 +- hermes_state.py | 13 ++- plugins/platforms/discord/adapter.py | 39 ++++++++ tests/gateway/test_discord_send.py | 112 ++++++++++++++++++++++- tests/hermes_cli/test_session_handoff.py | 11 +++ 8 files changed, 199 insertions(+), 10 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index d3c935733e6be..922a9fbde56f9 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2960,6 +2960,8 @@ async def create_handoff_thread( self, parent_chat_id: str, name: str, + *, + user_ids: Optional[List[str]] = None, ) -> Optional[str]: """Create a fresh thread under ``parent_chat_id`` for a session handoff. diff --git a/gateway/run.py b/gateway/run.py index 37c4718d80b94..a3fa2ea5bdab2 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7625,9 +7625,12 @@ async def _process_handoff(self, row: Dict[str, Any]) -> None: # None we fall through to using the home channel directly — the # synthetic turn still lands; just without thread isolation. thread_name = f"Hermes — {cli_title}" + handoff_thread_args = {} + if platform == Platform.DISCORD and row.get("user_id"): + handoff_thread_args["user_ids"] = [str(row["user_id"])] try: new_thread_id = await adapter.create_handoff_thread( - str(home.chat_id), thread_name, + str(home.chat_id), thread_name, **handoff_thread_args ) except Exception as exc: logger.debug( diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index c8bf1e67136ca..bdbc5f54242a0 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -516,7 +516,7 @@ def _handle_profile_command(self): print() def _handle_handoff_command(self, cmd_original: str) -> bool: - """Handle ``/handoff `` — transfer this CLI session to a gateway platform. + """Handle ``/handoff [discord_user_id]`` — transfer this CLI session to a gateway platform. Flow: 1. Validate platform name + the gateway has a home channel for it. @@ -537,12 +537,25 @@ def _handle_handoff_command(self, cmd_original: str) -> bool: parts = cmd_original.split(maxsplit=1) if len(parts) < 2 or not parts[1].strip(): - _cprint(" Usage: /handoff ") + _cprint(" Usage: /handoff [discord_user_id]") _cprint(" Hands the current session off to that platform's home channel.") _cprint(" The CLI session ends here; resume it later with /resume.") return True - platform_name = parts[1].strip().lower() + handoff_args = parts[1].split() + platform_name = handoff_args[0].lower() + initiator_user_id = None + if len(handoff_args) > 2: + _cprint(" Usage: /handoff [discord_user_id]") + return True + if len(handoff_args) == 2: + if platform_name != "discord": + _cprint(" A Discord user ID may only be specified for a Discord handoff.") + return True + initiator_user_id = handoff_args[1] + if not initiator_user_id.isascii() or not initiator_user_id.isdigit() or int(initiator_user_id) <= 0: + _cprint(" Discord user ID must be a positive numeric Snowflake.") + return True # Validate platform name + home channel via the live gateway config. try: @@ -620,7 +633,11 @@ def _handle_handoff_command(self, cmd_original: str) -> bool: session_title = self.session_id[:8] # Mark pending — gateway watcher will pick this up. - ok = self._session_db.request_handoff(self.session_id, platform_name) + ok = self._session_db.request_handoff( + self.session_id, + platform_name, + user_id=initiator_user_id, + ) if not ok: _cprint(" Session is already in flight for handoff. Wait for it to settle, then retry.") return True diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 10f8fbf046b88..8c3bcda9d1be6 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -85,7 +85,7 @@ class CommandDef: CommandDef("title", "Set a title for the current session", "Session", args_hint="[name]"), CommandDef("handoff", "Hand off this session to a messaging platform (Telegram, Discord, etc.)", "Session", - args_hint="", cli_only=True), + args_hint=" [discord_user_id]", cli_only=True), CommandDef("branch", "Branch the current session (explore a different path)", "Session", aliases=("fork",), args_hint="[name]"), CommandDef("compress", "Compress conversation context (add 'here [N]' to keep recent N turns; --preview shows what would happen)", "Session", diff --git a/hermes_state.py b/hermes_state.py index e5a517c505346..43a801a6f447d 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -7122,7 +7122,13 @@ def maybe_auto_prune_and_vacuum( # The CLI writes "pending" then poll-waits for terminal state. The gateway # watcher transitions pending→running→{completed,failed}. - def request_handoff(self, session_id: str, platform: str) -> bool: + def request_handoff( + self, + session_id: str, + platform: str, + *, + user_id: Optional[str] = None, + ) -> bool: """Mark a session as pending handoff to the given platform. Returns True if the row was found and not already in flight; False if @@ -7133,10 +7139,11 @@ def _do(conn): "UPDATE sessions " "SET handoff_state = 'pending', " " handoff_platform = ?, " - " handoff_error = NULL " + " handoff_error = NULL, " + " user_id = ? " "WHERE id = ? AND (handoff_state IS NULL " " OR handoff_state IN ('completed', 'failed'))", - (platform, session_id), + (platform, user_id, session_id), ) return cur.rowcount > 0 return self._execute_write(_do) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index a97aef0677108..4bca65f8127bb 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -5456,6 +5456,8 @@ async def create_handoff_thread( self, parent_chat_id: str, name: str, + *, + user_ids: Optional[List[str]] = None, ) -> Optional[str]: """Create a Discord thread under a text channel for a handoff. @@ -5495,6 +5497,41 @@ async def create_handoff_thread( thread_name = (name or "handoff").strip()[:80] or "handoff" reason = "Hermes session handoff" + async def add_explicit_users(thread) -> None: + """Add only the explicitly requested users to the new thread.""" + if not user_ids: + return + guild = getattr(thread, "guild", None) or getattr(parent, "guild", None) + if guild is None: + logger.warning( + "[%s] Handoff thread: cannot resolve guild for explicit users", + self.name, + ) + return + add_user = getattr(thread, "add_user", None) + if add_user is None: + return + for raw_user_id in user_ids: + try: + user_id = int(raw_user_id) + member = guild.get_member(user_id) + if member is None: + fetch_member = getattr(guild, "fetch_member", None) + if fetch_member is not None: + member = await fetch_member(user_id) + if member is not None: + await add_user(member) + else: + logger.warning( + "[%s] Handoff thread: Discord member %s not found", + self.name, raw_user_id, + ) + except Exception: + logger.warning( + "[%s] Handoff thread: failed to add explicit user %s", + self.name, raw_user_id, exc_info=True, + ) + # First try: create a thread directly on the channel. try: create = getattr(parent, "create_thread", None) @@ -5504,6 +5541,7 @@ async def create_handoff_thread( auto_archive_duration=1440, reason=reason, ) + await add_explicit_users(thread) return str(thread.id) except Exception as direct_error: logger.debug( @@ -5522,6 +5560,7 @@ async def create_handoff_thread( auto_archive_duration=1440, reason=reason, ) + await add_explicit_users(thread) return str(thread.id) except Exception as fallback_error: logger.warning( diff --git a/tests/gateway/test_discord_send.py b/tests/gateway/test_discord_send.py index cd2950f9fbbb8..7b48fbe47903f 100644 --- a/tests/gateway/test_discord_send.py +++ b/tests/gateway/test_discord_send.py @@ -5,7 +5,7 @@ import pytest -from gateway.config import PlatformConfig +from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig def _ensure_discord_mock(): @@ -445,3 +445,113 @@ async def test_typing_stop_cleans_up(): await adapter.stop_typing("12345") assert "12345" not in adapter._typing_tasks + + +@pytest.mark.asyncio +async def test_create_handoff_thread_adds_explicit_discord_initiator(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + member = SimpleNamespace(id=123) + guild = SimpleNamespace(get_member=lambda user_id: member if user_id == 123 else None) + thread = SimpleNamespace(id=555, guild=guild, add_user=AsyncMock()) + parent = SimpleNamespace(id=999, guild=guild, create_thread=AsyncMock(return_value=thread)) + adapter._client = SimpleNamespace( + get_channel=lambda _chat_id: parent, + fetch_channel=AsyncMock(), + get_user=lambda _user_id: None, + ) + + thread_id = await adapter.create_handoff_thread("999", "queued handoff", user_ids=["123"]) + + assert thread_id == "555" + thread.add_user.assert_awaited_once_with(member) + + +@pytest.mark.asyncio +async def test_process_handoff_passes_cli_discord_initiator_to_adapter(): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + token="***", + home_channel=HomeChannel( + platform=Platform.DISCORD, + chat_id="999", + name="Discord home", + ), + ) + } + ) + adapter = MagicMock() + adapter.create_handoff_thread = AsyncMock(return_value="555") + adapter.send = AsyncMock(return_value=SimpleNamespace(success=True)) + runner.adapters = {Platform.DISCORD: adapter} + runner.session_store = MagicMock() + async_session_store = MagicMock() + async_session_store._store = runner.session_store + async_session_store.get_or_create_session = AsyncMock() + async_session_store.switch_session = AsyncMock(return_value=object()) + runner._async_session_store = async_session_store + runner._evict_cached_agent = MagicMock() + runner._release_running_agent_state = MagicMock() + runner._handle_message = AsyncMock(return_value="handoff response") + + await runner._process_handoff( + { + "id": "cli-session", + "title": "CLI work", + "handoff_platform": "discord", + "user_id": "123456789", + } + ) + + adapter.create_handoff_thread.assert_awaited_once_with( + "999", "Hermes — CLI work", user_ids=["123456789"] + ) + runner._handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_process_handoff_does_not_pass_discord_kwargs_to_other_adapters(): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={ + Platform.TELEGRAM: PlatformConfig( + enabled=True, + token="***", + home_channel=HomeChannel( + platform=Platform.TELEGRAM, + chat_id="999", + name="Telegram home", + ), + ) + } + ) + adapter = MagicMock() + adapter.create_handoff_thread = AsyncMock(return_value=None) + adapter.send = AsyncMock(return_value=SimpleNamespace(success=True)) + runner.adapters = {Platform.TELEGRAM: adapter} + runner.session_store = MagicMock() + async_session_store = MagicMock() + async_session_store._store = runner.session_store + async_session_store.get_or_create_session = AsyncMock() + async_session_store.switch_session = AsyncMock(return_value=object()) + runner._async_session_store = async_session_store + runner._evict_cached_agent = MagicMock() + runner._release_running_agent_state = MagicMock() + runner._handle_message = AsyncMock(return_value=None) + + await runner._process_handoff( + { + "id": "cli-session", + "title": "CLI work", + "handoff_platform": "telegram", + "user_id": "123456789", + } + ) + + adapter.create_handoff_thread.assert_awaited_once_with("999", "Hermes — CLI work") diff --git a/tests/hermes_cli/test_session_handoff.py b/tests/hermes_cli/test_session_handoff.py index 2fd9e9e1ab969..128caa4001b8f 100644 --- a/tests/hermes_cli/test_session_handoff.py +++ b/tests/hermes_cli/test_session_handoff.py @@ -58,6 +58,17 @@ def test_request_handoff_marks_pending(self, db): "error": None, } + def test_request_handoff_persists_explicit_discord_initiator(self, db): + sid = "sess-discord-initiator" + self._make_session(db, sid) + + assert db.request_handoff(sid, "discord", user_id="123456789") is True + + row = db.get_session(sid) + assert row["user_id"] == "123456789" + pending = db.list_pending_handoffs() + assert pending[0]["user_id"] == "123456789" + def test_request_handoff_rejects_in_flight(self, db): sid = "sess-2" self._make_session(db, sid)