Skip to content
Closed
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
7 changes: 7 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1583,6 +1583,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 All @@ -1591,6 +1593,11 @@ async def create_handoff_thread(
handed-off conversation from any pre-existing chat in the home
channel and gives users a clean per-handoff scrollback.

``user_ids`` is optional platform-specific context: adapters that
support explicit thread membership (Discord) can add/follow the
operator immediately after creation. Platforms without membership
APIs ignore it.

Returns the new thread/topic id (as a string) on success, or
``None`` if the platform doesn't support threading or the
attempt failed (permissions, topics-mode off, etc.). When ``None``
Expand Down
47 changes: 47 additions & 0 deletions gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import asyncio
import hashlib
import inspect
import json
import logging
import os
Expand Down Expand Up @@ -3876,6 +3877,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 @@ -3915,6 +3918,48 @@ async def create_handoff_thread(
thread_name = (name or "handoff").strip()[:80] or "handoff"
reason = "Hermes session handoff"

async def _add_thread_members(thread: Any) -> None:
"""Best-effort: make handoff threads visible/followed for users.

Discord only auto-adds the bot that created a thread. For CLI
handoffs into a Discord home channel this means the operator may
not see the new thread in the channel's thread list. Adding the
initiating user (when known) fixes that without needing a visible
@mention in the seed message. We intentionally do not fall back to
every allowed user: that would make automated handoff/Kanban
threads noisy in multi-operator servers.
"""
candidates = user_ids or []
seen: set[str] = set()
add_user = getattr(thread, "add_user", None)
if not callable(add_user):
return
for raw_user_id in candidates:
user_id = _clean_discord_id(str(raw_user_id or ""))
if not user_id or not user_id.isdigit() or user_id in seen:
continue
seen.add(user_id)
try:
user_int = int(user_id)
guild = getattr(thread, "guild", None) or getattr(parent, "guild", None)
user_obj = None
if guild is not None and hasattr(guild, "get_member"):
user_obj = guild.get_member(user_int)
if user_obj is None and self._client is not None and hasattr(self._client, "get_user"):
user_obj = self._client.get_user(user_int)
if user_obj is None and discord is not None:
user_obj = discord.Object(id=user_int)
if user_obj is None:
continue
result = add_user(user_obj)
if inspect.isawaitable(result):
await result
except Exception as exc:
logger.warning(
"[%s] Handoff thread: failed to add user %s to thread %s: %s",
self.name, user_id, getattr(thread, "id", "?"), exc,
)

# First try: create a thread directly on the channel.
try:
create = getattr(parent, "create_thread", None)
Expand All @@ -3924,6 +3969,7 @@ async def create_handoff_thread(
auto_archive_duration=1440,
reason=reason,
)
await _add_thread_members(thread)
return str(thread.id)
except Exception as direct_error:
logger.debug(
Expand All @@ -3942,6 +3988,7 @@ async def create_handoff_thread(
auto_archive_duration=1440,
reason=reason,
)
await _add_thread_members(thread)
return str(thread.id)
except Exception as fallback_error:
logger.warning(
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,8 @@ async def create_handoff_thread(
self,
parent_chat_id: str,
name: str,
*,
user_ids: Optional[List[str]] = None,
) -> Optional[str]:
"""Create a Slack thread anchor for a session handoff.

Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,8 @@ async def create_handoff_thread(
self,
parent_chat_id: str,
name: str,
*,
user_ids: Optional[List[str]] = None,
) -> Optional[str]:
"""Create a forum topic for a session handoff.

Expand Down
18 changes: 15 additions & 3 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -3923,9 +3923,21 @@ async def _process_handoff(self, row: Dict[str, Any]) -> None:
# synthetic turn still lands; just without thread isolation.
thread_name = f"Hermes — {cli_title}"

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.

row["user_id"] is not populated for the normal CLI handoff flow: current agent-created rows pass user_id=None (run_agent.py:600-608) and CLI row creation omits it (cli.py:7091-7099). Persist a trustworthy destination Discord operator ID at request time, then test _process_handoff with that CLI-shaped row.

try:
new_thread_id = await adapter.create_handoff_thread(
str(home.chat_id), thread_name,
)
create_handoff = adapter.create_handoff_thread
handoff_user_id = str(row.get("user_id") or "").strip()
create_kwargs: dict[str, Any] = {}
try:
if "user_ids" in inspect.signature(create_handoff).parameters and handoff_user_id:
create_kwargs["user_ids"] = [handoff_user_id]
except (TypeError, ValueError):
if handoff_user_id:
create_kwargs["user_ids"] = [handoff_user_id]
try:
new_thread_id = await create_handoff(str(home.chat_id), thread_name, **create_kwargs)
except TypeError:
if not create_kwargs:
raise
new_thread_id = await create_handoff(str(home.chat_id), thread_name)
except Exception as exc:
logger.debug(
"Handoff: create_handoff_thread raised on %s: %s",
Expand Down
40 changes: 40 additions & 0 deletions tests/gateway/test_discord_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,3 +445,43 @@ 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_requested_discord_members():
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", "123", ""])

assert thread_id == "555"
parent.create_thread.assert_awaited_once()
thread.add_user.assert_awaited_once_with(member)


@pytest.mark.asyncio
async def test_create_handoff_thread_does_not_add_allowed_users_when_no_explicit_user():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
adapter._allowed_user_ids = {"123", "not-a-snowflake"}
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")

assert thread_id == "555"
thread.add_user.assert_not_awaited()