Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 4 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
25 changes: 21 additions & 4 deletions hermes_cli/cli_commands_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ def _handle_profile_command(self):
print()

def _handle_handoff_command(self, cmd_original: str) -> bool:
"""Handle ``/handoff <platform>`` — transfer this CLI session to a gateway platform.
"""Handle ``/handoff <platform> [discord_user_id]`` — transfer this CLI session to a gateway platform.

Flow:
1. Validate platform name + the gateway has a home channel for it.
Expand All @@ -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 <platform>")
_cprint(" Usage: /handoff <platform> [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 <platform> [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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="<platform>", cli_only=True),
args_hint="<platform> [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",
Expand Down
13 changes: 10 additions & 3 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -7133,10 +7139,11 @@ def _do(conn):
"UPDATE sessions "
"SET handoff_state = 'pending', "
" handoff_platform = ?, "
" handoff_error = NULL "
" handoff_error = NULL, "
" user_id = ? "

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sessions.user_id is durable gateway ownership/routing metadata, not handoff scratch state. This shared method is also called by tui_gateway/server.py without user_id, so this assignment clears existing identity. Please add a handoff-specific initiator field and leave sessions.user_id unchanged.

"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)
Expand Down
39 changes: 39 additions & 0 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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(
Expand Down
112 changes: 111 additions & 1 deletion tests/gateway/test_discord_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import pytest

from gateway.config import PlatformConfig
from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig


def _ensure_discord_mock():
Expand Down Expand Up @@ -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")
11 changes: 11 additions & 0 deletions tests/hermes_cli/test_session_handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down