Files
hermes-agent/tests/gateway/test_branch_thread_command.py
teknium1 dbcbd9d9db feat(gateway): /branch opens a sibling thread by default; --here keeps this chat (#66023)
On Discord, Telegram, Slack and Matrix a plain `/branch` used to rebind the
CURRENT chat/thread's session key to the clone, ending the original session
on that surface. The user could not keep the original path live while
exploring an alternate one — the opposite of what a branch is for.

Now the handler opens a sibling thread through the adapter's existing
`create_handoff_thread` BEFORE cloning (a failed create never orphans a
branch row), binds the thread's own session key to the clone with the
thread's routing columns written at create time, and leaves the origin key
untouched. `/branch --here` keeps the legacy in-place switch; platforms
without threads, DMs, unknown Discord parents and adapters that cannot open
a thread fall back to in-place with a one-line note. The CLI strips the
flag through the same parser so `--here` never becomes a session title.

Destination source shapes mirror each adapter's inbound key (Discord keys
threads on their own id; Telegram/Slack/Matrix on the parent chat), the
same rules the CLI->platform handoff uses.

Live repro (real gateway + real Slack adapter against a stand-in Slack
Socket Mode/Web API): base ends the origin session and rebinds its key;
fixed posts the thread seed, replies "this chat stays on it", the origin
thread keeps its session and the follow-up typed in the new thread lands on
the branch (parent_session_id = origin).

Design and first implementation by Angello Picasso (#66014, #66024);
this is a slim port onto the split slash_commands_* layout.

Co-authored-by: Angello Picasso <angello.picasso@devsu.com>
2026-09-20 13:37:10 -07:00

115 lines
4.6 KiB
Python

"""/branch on thread-capable platforms opens a sibling thread and keeps the origin (#66023).
Drives the REAL ``_handle_branch_command`` against a REAL SessionStore + SessionDB (SQLite in
tmp_path); only the platform adapter is a fake whose ``create_handoff_thread`` returns a fixed id.
"""
from __future__ import annotations
import pytest
from gateway.config import GatewayConfig, Platform
from gateway.platforms.event import MessageEvent
from gateway.session import SessionSource, SessionStore
from hermes_state import AsyncSessionDB
class _ThreadAdapter:
"""Discord-shaped fake: the only thing /branch needs from an adapter."""
def __init__(self, thread_id="777000", fail=False):
self.thread_id, self.fail, self.calls = thread_id, fail, []
async def create_handoff_thread(self, parent_chat_id, name):
self.calls.append((parent_chat_id, name))
return None if self.fail else self.thread_id
@pytest.fixture()
def store(tmp_path, monkeypatch):
import hermes_state
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db")
return SessionStore(sessions_dir=tmp_path, config=GatewayConfig())
def _runner(store, adapter):
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.adapters = {Platform.DISCORD: adapter} if adapter else {}
runner._profile_adapters = {}
runner.config = {}
runner._background_tasks = set()
runner._running_agents = {}
runner._running_agents_ts = {}
runner._busy_ack_ts = {}
runner._pending_approvals = {}
runner._update_prompt_pending = {}
runner._agent_cache_lock = None
runner.session_store = store
runner._session_db = AsyncSessionDB(store._db)
runner._pending_skills_reload_notes = {}
return runner
def _discord_channel_source():
return SessionSource(platform=Platform.DISCORD, chat_id="123", chat_type="group", user_id="u1",
user_name="ann", scope_id="g9")
def _seed(store, source):
entry = store.get_or_create_session(source)
store._db.append_message(entry.session_id, role="user", content="hello")
store._db.append_message(entry.session_id, role="assistant", content="world")
return entry
@pytest.mark.asyncio
async def test_plain_branch_binds_new_thread_and_keeps_origin(store):
source = _discord_channel_source()
parent = _seed(store, source)
adapter = _ThreadAdapter()
runner = _runner(store, adapter)
reply = await runner._handle_branch_command(MessageEvent(text="/branch side quest", source=source))
assert adapter.calls == [("123", "side quest")]
# The chat the command came from is still on the original session.
assert store.get_or_create_session(source).session_id == parent.session_id
# The new thread (Discord keys it on its own id) is on the clone, with the routing columns of
# the THREAD, so a restart routes the next in-thread message to the branch.
thread_source = SessionSource(platform=Platform.DISCORD, chat_id="777000", chat_type="thread",
thread_id="777000", parent_chat_id="123", user_id="u1", scope_id="g9")
branch = store.get_or_create_session(thread_source)
assert branch.session_id != parent.session_id
row = store._db.get_session(branch.session_id)
assert (row["parent_session_id"], row["chat_id"], row["thread_id"], row["chat_type"]) == (
parent.session_id, "777000", "777000", "thread")
assert [m["content"] for m in store._db.get_messages(branch.session_id)] == ["hello", "world"]
assert "<#777000>" in reply and parent.session_id in reply
@pytest.mark.asyncio
@pytest.mark.parametrize("text, adapter", [
("/branch --here side quest", _ThreadAdapter()), # explicit opt-out
("/branch side quest", _ThreadAdapter(fail=True)), # platform could not open a thread
("/branch side quest", None), # no adapter for the platform
])
async def test_here_or_no_thread_branches_in_place(store, text, adapter):
source = _discord_channel_source()
parent = _seed(store, source)
runner = _runner(store, adapter)
reply = await runner._handle_branch_command(MessageEvent(text=text, source=source))
current = store.get_or_create_session(source)
assert current.session_id != parent.session_id
row = store._db.get_session(current.session_id)
assert row["parent_session_id"] == parent.session_id
assert store._db.get_session_title(current.session_id) == "side quest"
if adapter is not None:
# ``--here`` never even asks the platform for a thread.
assert adapter.calls == ([] if "--here" in text else [("123", "side quest")])
assert "side quest" in reply