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>
This commit is contained in:
teknium1
2026-09-20 12:23:07 -07:00
committed by Teknium
parent 08c4063ff4
commit dbcbd9d9db
23 changed files with 306 additions and 19 deletions

View File

@@ -0,0 +1,72 @@
"""``/branch`` destination on thread-capable platforms (#66023).
On Discord, Telegram, Slack and Matrix a plain ``/branch`` opens a NEW sibling thread for the
clone and leaves the current chat bound to the original session, so the user can keep both paths
alive. ``/branch --here`` keeps the legacy in-place switch; platforms without threads (and the CLI)
always branch in place. Pure helpers only — the handler lives in ``slash_commands_session.py``.
"""
from __future__ import annotations
import dataclasses
from typing import Optional
from gateway.config import Platform
from gateway.session import SessionSource
# Adapters that override ``BasePlatformAdapter.create_handoff_thread`` (the base returns None).
BRANCH_THREAD_PLATFORMS = frozenset({Platform.DISCORD, Platform.TELEGRAM, Platform.SLACK, Platform.MATRIX})
BRANCH_HERE_FLAG = "--here"
def parse_branch_args(raw: str) -> tuple[bool, str]:
"""``(stay_here, title)`` from the text after ``/branch``.
Only a leading ``--here`` is a flag; everything else is the optional title. The CLI strips the
flag through the same parser so ``/branch --here` never becomes a session titled ``--here``.
"""
text = (raw or "").strip()
head, _, rest = text.partition(" ")
if head.lower() == BRANCH_HERE_FLAG:
return True, rest.strip()
return False, text
def branch_thread_parent(source: SessionSource) -> Optional[str]:
"""Chat that can host a sibling thread for *source*, or None when nothing can (Discord DMs,
a Discord thread whose parent channel is unknown)."""
if source.platform not in BRANCH_THREAD_PLATFORMS:
return None
if source.platform == Platform.DISCORD:
if source.chat_type == "dm":
return None
# Inbound Discord threads carry ``chat_id == thread_id``; the sibling goes under the real
# text channel, which only ``parent_chat_id`` names.
if source.thread_id or source.chat_type == "thread":
return str(source.parent_chat_id) if source.parent_chat_id else None
# Telegram forum topics, Slack threads and Matrix threads all key on the parent chat/room id.
return str(source.chat_id) if source.chat_id else None
def branch_dest_source(source: SessionSource, *, parent_id: str, thread_id: str, title: str) -> SessionSource:
"""The ``SessionSource`` a follow-up typed in the new thread will arrive on — the shape must
match each adapter's inbound source or the clone is bound to a key nobody ever reads (the
CLI→platform handoff in ``run_startup.py::_handoff_resolve_destination`` mirrors the same
rules). Per-message fields are dropped; identity/scope fields travel with the copy."""
common = dict(thread_id=str(thread_id), message_id=None, prospective_thread_id=None,
auto_thread_created=False, auto_thread_initial_name=None)
if source.platform == Platform.DISCORD:
# Discord keys an in-thread message on the thread's OWN id as chat_id.
return dataclasses.replace(source, chat_id=str(thread_id), chat_name=title or source.chat_name,
chat_type="thread", parent_chat_id=str(parent_id), **common)
# Telegram (``group:<chat>:<topic>``, private-chat topics stay ``dm``), Slack (parent channel's
# dm/group + workspace scope) and Matrix (room type) key a thread reply on the PARENT chat's type.
chat_type = "group" if source.chat_type == "thread" else source.chat_type
return dataclasses.replace(source, chat_id=str(parent_id), chat_type=chat_type, **common)
def format_thread_ref(platform: Optional[Platform], thread_id: str) -> str:
"""Clickable pointer where the platform has one (Discord ``<#id>`` mentions); id otherwise."""
if platform == Platform.DISCORD:
return f"<#{thread_id}>"
return f"`{thread_id}`"

View File

@@ -21,6 +21,9 @@ from gateway.platforms.base import EphemeralReply
from gateway.platforms.event import MessageEvent, MessageType
from gateway.session import SessionSource, build_session_key, is_shared_multi_user_session
from gateway.session_transcript import TranscriptReadError
from gateway.slash_commands_branch_thread import (
BRANCH_THREAD_PLATFORMS, branch_dest_source, branch_thread_parent, format_thread_ref, parse_branch_args,
)
from gateway.slash_commands_status import HISTORY_UNREADABLE
logger = logging.getLogger("gateway.run") # log-record parity with gateway/run.py
@@ -987,7 +990,12 @@ class GatewaySessionCommandsMixin:
# ----------------------------------------------------------------------- /branch
async def _handle_branch_command(self, event: MessageEvent) -> str:
"""Handle /branch [name] — fork the current session into an independent copy."""
"""Handle /branch [--here] [name] — fork the current session into an independent copy.
Thread-capable platforms (Discord/Telegram/Slack/Matrix) open a NEW sibling thread bound
to the clone and leave this chat on the original session; ``--here`` (and every platform
without threads) switches the current chat onto the clone instead (#66023).
"""
import json as _json
import uuid as _uuid
from datetime import datetime as _dt
@@ -1004,17 +1012,25 @@ class GatewaySessionCommandsMixin:
if not history:
return t("gateway.branch.no_conversation")
new_session_id = f"{_dt.now().strftime('%Y%m%d_%H%M%S')}_{_uuid.uuid4().hex[:6]}"
branch_title = event.get_command_args().strip()
stay_here, branch_title = parse_branch_args(event.get_command_args())
if not branch_title:
current_title = await self._session_db.get_session_title(current_entry.session_id)
branch_title = await self._session_db.get_next_title_in_lineage(current_title or "branch")
parent_session_id = current_entry.session_id
# Full parent origin (same shape as the reset path in gateway/session.py); the live entry's
# origin may hold richer metadata than the triggering event's source.
# See #82633.
# The thread is created BEFORE the clone so a failed create never orphans a branch row;
# ``None`` = branch in place (--here, no threads here, or the adapter could not open one).
dest_source = None if stay_here else await self._branch_open_thread(source, branch_title)
in_place = dest_source is None
if in_place:
dest_source = source
dest_key = session_key if in_place else self._session_key_for_source(dest_source)
# Full origin (same shape as the reset path in gateway/session.py); the live entry's origin
# may hold richer metadata than the triggering event's source (#82633). A thread branch
# is routed by the NEW thread, so its origin is the destination.
_branch_origin_json = None
with contextlib.suppress(Exception):
_branch_origin_json = _json.dumps((current_entry.origin or source).to_dict())
_origin = (current_entry.origin or source) if in_place else dest_source
_branch_origin_json = _json.dumps(_origin.to_dict())
# ``_branched_from`` keeps the branch visible in /resume and /sessions after the parent is
# reopened and re-ended. ALL routing columns go in at CREATE time: a crash before
# switch_session() records the peer would otherwise leave the branch unroutable.
@@ -1024,9 +1040,9 @@ class GatewaySessionCommandsMixin:
source=source.platform.value if source.platform else "gateway",
model=(self.config.get("model", {}) or {}).get("default") if isinstance(self.config, dict) else None,
model_config={"_branched_from": parent_session_id},
parent_session_id=parent_session_id, user_id=source.user_id,
session_key=session_key, chat_id=source.chat_id, chat_type=source.chat_type,
thread_id=source.thread_id, origin_json=_branch_origin_json,
parent_session_id=parent_session_id, user_id=dest_source.user_id,
session_key=dest_key, chat_id=dest_source.chat_id, chat_type=dest_source.chat_type,
thread_id=dest_source.thread_id, origin_json=_branch_origin_json,
display_name=current_entry.display_name)
except Exception as e:
logger.error("Failed to create branch session: %s", e)
@@ -1041,11 +1057,43 @@ class GatewaySessionCommandsMixin:
new_session_id, [_branch_row(msg) for msg in history], chunk_rows=500)
with contextlib.suppress(Exception):
await self._session_db.set_session_title(new_session_id, branch_title)
new_entry = await self.async_session_store.switch_session(session_key, new_session_id)
if not in_place:
# Materialize the thread's own entry, then point IT at the clone; ``session_key`` (this
# chat) is never touched, so the original conversation stays live here.
await self.async_session_store.get_or_create_session(dest_source)
new_entry = await self.async_session_store.switch_session(dest_key, new_session_id)
if not new_entry:
return t("gateway.branch.switch_failed")
self._clear_session_boundary_security_state(session_key)
self._evict_cached_agent(session_key)
self._clear_session_boundary_security_state(dest_key)
self._evict_cached_agent(dest_key)
msg_count = len([m for m in history if m.get("role") == "user"])
key = "gateway.branch.branched_one" if msg_count == 1 else "gateway.branch.branched_many"
return t(key, title=branch_title, count=msg_count, parent=parent_session_id, new=new_session_id)
if in_place:
key = "gateway.branch.branched_one" if msg_count == 1 else "gateway.branch.branched_many"
reply = t(key, title=branch_title, count=msg_count, parent=parent_session_id, new=new_session_id)
if not stay_here and source.platform in BRANCH_THREAD_PLATFORMS:
reply += "\n" + t("gateway.branch.thread_fallback")
return reply
key = "gateway.branch.branched_thread_one" if msg_count == 1 else "gateway.branch.branched_thread_many"
return t(key, title=branch_title, count=msg_count, parent=parent_session_id, new=new_session_id,
thread=format_thread_ref(source.platform, dest_source.thread_id))
async def _branch_open_thread(self, source: SessionSource, title: str) -> Optional[SessionSource]:
"""Open the sibling thread a plain ``/branch`` clones into; the destination source, or
None when this chat cannot host one (in-place fallback)."""
parent_id = branch_thread_parent(source)
adapter = self._delivery_adapter_for(source) if parent_id else None
if adapter is None:
return None
try:
thread_id = await adapter.create_handoff_thread(parent_id, title)
except Exception:
logger.warning("Branch: create_handoff_thread failed on %s; branching in place",
source.platform.value, exc_info=True)
return None
if not thread_id:
return None
# Discord only answers un-mentioned follow-ups in threads it has participated in.
threads = getattr(adapter, "_threads", None)
if threads is not None:
threads.mark(str(thread_id))
return branch_dest_source(source, parent_id=parent_id, thread_id=str(thread_id), title=title)

View File

@@ -1388,7 +1388,9 @@ class CLICommandsMixin:
return _cp(" No conversation to branch — send a message first.")
if not self._session_db:
return _cp(_db_unavailable_line())
branch_name = _command_arg(cmd_original)
# CLI has no threads: always in place; strip the gateway's ``--here`` so it is never a title.
from gateway.slash_commands_branch_thread import parse_branch_args
_, branch_name = parse_branch_args(_command_arg(cmd_original))
now = datetime.now()
new_session_id = mint_session_id(now)
branch_title = branch_name or self._session_db.get_next_title_in_lineage(

View File

@@ -71,8 +71,8 @@ COMMAND_REGISTRY: list[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, argument_mode="options"),
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
aliases=("fork",), args_hint="[name]"),
CommandDef("branch", "Branch the current session (new thread on Discord/Telegram/Slack/Matrix; --here stays here)",
"Session", aliases=("fork",), args_hint="[--here] [name]"),
CommandDef("worktree", "Show, list, create, or prune isolated git worktrees", "Session",
cli_only=True, args_hint="[new [name]|list|prune [--dry-run]]",
subcommands=("new", "list", "prune")),

View File

@@ -86,6 +86,9 @@ gateway:
switch_failed: "Tak is geskep, maar oorskakeling het misluk."
branched_one: "⑂ Vertak na **{title}** ({count} boodskap gekopieer)\nOorspronklik: `{parent}`\nTak: `{new}`\nGebruik `/resume` om terug te gaan na die oorspronklike."
branched_many: "⑂ Vertak na **{title}** ({count} boodskappe gekopieer)\nOorspronklik: `{parent}`\nTak: `{new}`\nGebruik `/resume` om terug te gaan na die oorspronklike."
branched_thread_one: "⑂ Vertak na **{title}** in 'n nuwe draad ({count} boodskap gekopieer)\nDraad: {thread}\nOorspronklik: `{parent}` — hierdie klets bly daarop\nTak: `{new}`\nGebruik `/branch --here` om in hierdie klets te vertak."
branched_thread_many: "⑂ Vertak na **{title}** in 'n nuwe draad ({count} boodskappe gekopieer)\nDraad: {thread}\nOorspronklik: `{parent}` — hierdie klets bly daarop\nTak: `{new}`\nGebruik `/branch --here` om in hierdie klets te vertak."
thread_fallback: "Geen draad kon hier geskep word nie, so die tak het hierdie klets se sessie vervang."
commands:
usage: "Gebruik: `/commands [page]`"

View File

@@ -109,6 +109,9 @@ gateway:
switch_failed: "أُنشئ الفرع لكن تعذّر التبديل إليه."
branched_one: "⑂ تم التفريع إلى **{title}** (نُسخت {count} رسالة)\nالأصل: `{parent}`\nالفرع: `{new}`\nاستخدم `/resume` للعودة إلى الأصل."
branched_many: "⑂ تم التفريع إلى **{title}** (نُسخت {count} رسالة)\nالأصل: `{parent}`\nالفرع: `{new}`\nاستخدم `/resume` للعودة إلى الأصل."
branched_thread_one: "⑂ تم التفريع إلى **{title}** في سلسلة جديدة (تم نسخ {count} رسالة)\nالسلسلة: {thread}\nالأصل: `{parent}` — تبقى هذه المحادثة عليه\nالفرع: `{new}`\nاستخدم `/branch --here` للتفريع في هذه المحادثة."
branched_thread_many: "⑂ تم التفريع إلى **{title}** في سلسلة جديدة (تم نسخ {count} رسائل)\nالسلسلة: {thread}\nالأصل: `{parent}` — تبقى هذه المحادثة عليه\nالفرع: `{new}`\nاستخدم `/branch --here` للتفريع في هذه المحادثة."
thread_fallback: "تعذر إنشاء سلسلة هنا، لذا حل الفرع محل جلسة هذه المحادثة."
commands:
usage: "الاستخدام: `/commands [page]`"

View File

@@ -86,6 +86,9 @@ gateway:
switch_failed: "Verzweigung erstellt, aber Wechsel fehlgeschlagen."
branched_one: "⑂ Verzweigt zu **{title}** ({count} Nachricht kopiert)\nOriginal: `{parent}`\nZweig: `{new}`\nVerwenden Sie `/resume`, um zum Original zurückzukehren."
branched_many: "⑂ Verzweigt zu **{title}** ({count} Nachrichten kopiert)\nOriginal: `{parent}`\nZweig: `{new}`\nVerwenden Sie `/resume`, um zum Original zurückzukehren."
branched_thread_one: "⑂ Verzweigt zu **{title}** in einem neuen Thread ({count} Nachricht kopiert)\nThread: {thread}\nOriginal: `{parent}` — dieser Chat bleibt darauf\nZweig: `{new}`\nMit `/branch --here` stattdessen in diesem Chat verzweigen."
branched_thread_many: "⑂ Verzweigt zu **{title}** in einem neuen Thread ({count} Nachrichten kopiert)\nThread: {thread}\nOriginal: `{parent}` — dieser Chat bleibt darauf\nZweig: `{new}`\nMit `/branch --here` stattdessen in diesem Chat verzweigen."
thread_fallback: "Hier konnte kein Thread erstellt werden, daher hat der Zweig die Sitzung dieses Chats ersetzt."
commands:
usage: "Verwendung: `/commands [page]`"

View File

@@ -101,6 +101,9 @@ gateway:
switch_failed: "Branch created but failed to switch to it."
branched_one: "⑂ Branched to **{title}** ({count} message copied)\nOriginal: `{parent}`\nBranch: `{new}`\nUse `/resume` to switch back to the original."
branched_many: "⑂ Branched to **{title}** ({count} messages copied)\nOriginal: `{parent}`\nBranch: `{new}`\nUse `/resume` to switch back to the original."
branched_thread_one: "⑂ Branched to **{title}** in a new thread ({count} message copied)\nThread: {thread}\nOriginal: `{parent}` — this chat stays on it\nBranch: `{new}`\nUse `/branch --here` to branch in this chat instead."
branched_thread_many: "⑂ Branched to **{title}** in a new thread ({count} messages copied)\nThread: {thread}\nOriginal: `{parent}` — this chat stays on it\nBranch: `{new}`\nUse `/branch --here` to branch in this chat instead."
thread_fallback: "No thread could be created here, so the branch replaced this chat's session."
commands:
usage: "Usage: `/commands [page]`"

View File

@@ -86,6 +86,9 @@ gateway:
switch_failed: "Rama creada pero no se pudo cambiar a ella."
branched_one: "⑂ Ramificado a **{title}** ({count} mensaje copiado)\nOriginal: `{parent}`\nRama: `{new}`\nUsa `/resume` para volver al original."
branched_many: "⑂ Ramificado a **{title}** ({count} mensajes copiados)\nOriginal: `{parent}`\nRama: `{new}`\nUsa `/resume` para volver al original."
branched_thread_one: "⑂ Ramificado a **{title}** en un hilo nuevo ({count} mensaje copiado)\nHilo: {thread}\nOriginal: `{parent}` — este chat permanece en él\nRama: `{new}`\nUsa `/branch --here` para ramificar en este chat."
branched_thread_many: "⑂ Ramificado a **{title}** en un hilo nuevo ({count} mensajes copiados)\nHilo: {thread}\nOriginal: `{parent}` — este chat permanece en él\nRama: `{new}`\nUsa `/branch --here` para ramificar en este chat."
thread_fallback: "No se pudo crear un hilo aquí, así que la rama reemplazó la sesión de este chat."
commands:
usage: "Uso: `/commands [page]`"

View File

@@ -86,6 +86,9 @@ gateway:
switch_failed: "Branche créée mais impossible de basculer dessus."
branched_one: "⑂ Branche **{title}** créée ({count} message copié)\nOriginal : `{parent}`\nBranche : `{new}`\nUtilisez `/resume` pour revenir à l'original."
branched_many: "⑂ Branche **{title}** créée ({count} messages copiés)\nOriginal : `{parent}`\nBranche : `{new}`\nUtilisez `/resume` pour revenir à l'original."
branched_thread_one: "⑂ Branché vers **{title}** dans un nouveau fil ({count} message copié)\nFil : {thread}\nOriginal : `{parent}` — cette discussion y reste\nBranche : `{new}`\nUtilisez `/branch --here` pour brancher dans cette discussion."
branched_thread_many: "⑂ Branché vers **{title}** dans un nouveau fil ({count} messages copiés)\nFil : {thread}\nOriginal : `{parent}` — cette discussion y reste\nBranche : `{new}`\nUtilisez `/branch --here` pour brancher dans cette discussion."
thread_fallback: "Aucun fil n'a pu être créé ici, la branche a donc remplacé la session de cette discussion."
commands:
usage: "Utilisation : `/commands [page]`"

View File

@@ -90,6 +90,9 @@ gateway:
switch_failed: "Cruthaíodh an brainse ach theip ar athrú chuige."
branched_one: "⑂ Brainseáilte go **{title}** ({count} teachtaireacht cóipeáilte)\nBunaidh: `{parent}`\nBrainse: `{new}`\nÚsáid `/resume` chun filleadh ar an mbunaidh."
branched_many: "⑂ Brainseáilte go **{title}** ({count} teachtaireacht cóipeáilte)\nBunaidh: `{parent}`\nBrainse: `{new}`\nÚsáid `/resume` chun filleadh ar an mbunaidh."
branched_thread_one: "⑂ Brainseáilte go **{title}** i snáithe nua ({count} teachtaireacht cóipeáilte)\nSnáithe: {thread}\nBunleagan: `{parent}` — fanann an comhrá seo air\nBrainse: `{new}`\nÚsáid `/branch --here` le brainseáil sa chomhrá seo."
branched_thread_many: "⑂ Brainseáilte go **{title}** i snáithe nua ({count} teachtaireacht cóipeáilte)\nSnáithe: {thread}\nBunleagan: `{parent}` — fanann an comhrá seo air\nBrainse: `{new}`\nÚsáid `/branch --here` le brainseáil sa chomhrá seo."
thread_fallback: "Níorbh fhéidir snáithe a chruthú anseo, mar sin ghlac an brainse ionad sheisiún an chomhrá seo."
commands:
usage: "Úsáid: `/commands [page]`"

View File

@@ -86,6 +86,9 @@ gateway:
switch_failed: "Az ág létrejött, de nem sikerült rá váltani."
branched_one: "⑂ Új ág: **{title}** ({count} üzenet másolva)\nEredeti: `{parent}`\nÁg: `{new}`\nHasználd a `/resume` parancsot az eredetihez való visszatéréshez."
branched_many: "⑂ Új ág: **{title}** ({count} üzenet másolva)\nEredeti: `{parent}`\nÁg: `{new}`\nHasználd a `/resume` parancsot az eredetihez való visszatéréshez."
branched_thread_one: "⑂ Elágazás **{title}** néven új szálban ({count} üzenet másolva)\nSzál: {thread}\nEredeti: `{parent}` — ez a csevegés rajta marad\nÁg: `{new}`\nA `/branch --here` ebben a csevegésben ágaztat el."
branched_thread_many: "⑂ Elágazás **{title}** néven új szálban ({count} üzenet másolva)\nSzál: {thread}\nEredeti: `{parent}` — ez a csevegés rajta marad\nÁg: `{new}`\nA `/branch --here` ebben a csevegésben ágaztat el."
thread_fallback: "Itt nem sikerült szálat létrehozni, így az ág e csevegés munkamenetét váltotta le."
commands:
usage: "Használat: `/commands [page]`"

View File

@@ -86,6 +86,9 @@ gateway:
switch_failed: "Ramo creato ma il passaggio ad esso non è riuscito."
branched_one: "⑂ Diramato in **{title}** ({count} messaggio copiato)\nOriginale: `{parent}`\nRamo: `{new}`\nUsa `/resume` per tornare all'originale."
branched_many: "⑂ Diramato in **{title}** ({count} messaggi copiati)\nOriginale: `{parent}`\nRamo: `{new}`\nUsa `/resume` per tornare all'originale."
branched_thread_one: "⑂ Ramificato in **{title}** in un nuovo thread ({count} messaggio copiato)\nThread: {thread}\nOriginale: `{parent}` — questa chat resta su di esso\nRamo: `{new}`\nUsa `/branch --here` per ramificare in questa chat."
branched_thread_many: "⑂ Ramificato in **{title}** in un nuovo thread ({count} messaggi copiati)\nThread: {thread}\nOriginale: `{parent}` — questa chat resta su di esso\nRamo: `{new}`\nUsa `/branch --here` per ramificare in questa chat."
thread_fallback: "Non è stato possibile creare un thread qui, quindi il ramo ha sostituito la sessione di questa chat."
commands:
usage: "Uso: `/commands [page]`"

View File

@@ -86,6 +86,9 @@ gateway:
switch_failed: "ブランチは作成されましたが、切り替えに失敗しました。"
branched_one: "⑂ **{title}** に分岐しました ({count} メッセージをコピー)\n元: `{parent}`\nブランチ: `{new}`\n元のセッションに戻るには `/resume` を使用してください。"
branched_many: "⑂ **{title}** に分岐しました ({count} メッセージをコピー)\n元: `{parent}`\nブランチ: `{new}`\n元のセッションに戻るには `/resume` を使用してください。"
branched_thread_one: "⑂ 新しいスレッドで **{title}** に分岐しました({count} 件のメッセージをコピー)\nスレッド: {thread}\n元: `{parent}` — このチャットは元のまま\n分岐: `{new}`\nこのチャット内で分岐するには `/branch --here` を使用してください。"
branched_thread_many: "⑂ 新しいスレッドで **{title}** に分岐しました({count} 件のメッセージをコピー)\nスレッド: {thread}\n元: `{parent}` — このチャットは元のまま\n分岐: `{new}`\nこのチャット内で分岐するには `/branch --here` を使用してください。"
thread_fallback: "ここではスレッドを作成できなかったため、分岐がこのチャットのセッションを置き換えました。"
commands:
usage: "使い方: `/commands [page]`"

View File

@@ -86,6 +86,9 @@ gateway:
switch_failed: "분기는 생성되었으나 전환에 실패했습니다."
branched_one: "⑂ **{title}**(으)로 분기했습니다 (메시지 {count}개 복사됨)\n원본: `{parent}`\n분기: `{new}`\n원본으로 돌아가려면 `/resume`을 사용하세요."
branched_many: "⑂ **{title}**(으)로 분기했습니다 (메시지 {count}개 복사됨)\n원본: `{parent}`\n분기: `{new}`\n원본으로 돌아가려면 `/resume`을 사용하세요."
branched_thread_one: "⑂ 새 스레드에서 **{title}**(으)로 분기했습니다 ({count}개 메시지 복사)\n스레드: {thread}\n원본: `{parent}` — 이 채팅은 원본에 남습니다\n분기: `{new}`\n이 채팅에서 분기하려면 `/branch --here`를 사용하세요."
branched_thread_many: "⑂ 새 스레드에서 **{title}**(으)로 분기했습니다 ({count}개 메시지 복사)\n스레드: {thread}\n원본: `{parent}` — 이 채팅은 원본에 남습니다\n분기: `{new}`\n이 채팅에서 분기하려면 `/branch --here`를 사용하세요."
thread_fallback: "여기서는 스레드를 만들 수 없어 분기가 이 채팅의 세션을 대체했습니다."
commands:
usage: "사용법: `/commands [page]`"

View File

@@ -86,6 +86,9 @@ gateway:
switch_failed: "Ramo criado, mas não foi possível mudar para ele."
branched_one: "⑂ Ramificado para **{title}** ({count} mensagem copiada)\nOriginal: `{parent}`\nRamo: `{new}`\nUsa `/resume` para voltar ao original."
branched_many: "⑂ Ramificado para **{title}** ({count} mensagens copiadas)\nOriginal: `{parent}`\nRamo: `{new}`\nUsa `/resume` para voltar ao original."
branched_thread_one: "⑂ Ramificado para **{title}** em um novo tópico ({count} mensagem copiada)\nTópico: {thread}\nOriginal: `{parent}` — este chat permanece nele\nRamo: `{new}`\nUse `/branch --here` para ramificar neste chat."
branched_thread_many: "⑂ Ramificado para **{title}** em um novo tópico ({count} mensagens copiadas)\nTópico: {thread}\nOriginal: `{parent}` — este chat permanece nele\nRamo: `{new}`\nUse `/branch --here` para ramificar neste chat."
thread_fallback: "Não foi possível criar um tópico aqui, então o ramo substituiu a sessão deste chat."
commands:
usage: "Uso: `/commands [page]`"

View File

@@ -86,6 +86,9 @@ gateway:
switch_failed: "Ветка создана, но переключиться на неё не удалось."
branched_one: "⑂ Создана ветка **{title}** (скопировано {count} сообщение)\nОригинал: `{parent}`\nВетка: `{new}`\nИспользуйте `/resume`, чтобы вернуться к оригиналу."
branched_many: "⑂ Создана ветка **{title}** (скопировано {count} сообщений)\nОригинал: `{parent}`\nВетка: `{new}`\nИспользуйте `/resume`, чтобы вернуться к оригиналу."
branched_thread_one: "⑂ Ветка **{title}** создана в новом треде ({count} сообщение скопировано)\nТред: {thread}\nОригинал: `{parent}` — этот чат остаётся на нём\nВетка: `{new}`\nИспользуйте `/branch --here`, чтобы ветвить в этом чате."
branched_thread_many: "⑂ Ветка **{title}** создана в новом треде ({count} сообщений скопировано)\nТред: {thread}\nОригинал: `{parent}` — этот чат остаётся на нём\nВетка: `{new}`\nИспользуйте `/branch --here`, чтобы ветвить в этом чате."
thread_fallback: "Здесь не удалось создать тред, поэтому ветка заменила сессию этого чата."
commands:
usage: "Использование: `/commands [page]`"

View File

@@ -86,6 +86,9 @@ gateway:
switch_failed: "Dal oluşturuldu ancak ona geçilemedi."
branched_one: "⑂ **{title}** dalına geçildi ({count} mesaj kopyalandı)\nOrijinal: `{parent}`\nDal: `{new}`\nOrijinale geri dönmek için `/resume` kullanın."
branched_many: "⑂ **{title}** dalına geçildi ({count} mesaj kopyalandı)\nOrijinal: `{parent}`\nDal: `{new}`\nOrijinale geri dönmek için `/resume` kullanın."
branched_thread_one: "⑂ **{title}** yeni bir konuya dallandı ({count} mesaj kopyalandı)\nKonu: {thread}\nOrijinal: `{parent}` — bu sohbet onda kalır\nDal: `{new}`\nBu sohbette dallanmak için `/branch --here` kullanın."
branched_thread_many: "⑂ **{title}** yeni bir konuya dallandı ({count} mesaj kopyalandı)\nKonu: {thread}\nOrijinal: `{parent}` — bu sohbet onda kalır\nDal: `{new}`\nBu sohbette dallanmak için `/branch --here` kullanın."
thread_fallback: "Burada konu oluşturulamadı, bu yüzden dal bu sohbetin oturumunun yerini aldı."
commands:
usage: "Kullanım: `/commands [page]`"

View File

@@ -86,6 +86,9 @@ gateway:
switch_failed: "Гілку створено, але не вдалося переключитися на неї."
branched_one: "⑂ Створено гілку **{title}** (скопійовано {count} повідомлення)\nОригінал: `{parent}`\nГілка: `{new}`\nВикористайте `/resume`, щоб повернутися до оригіналу."
branched_many: "⑂ Створено гілку **{title}** (скопійовано {count} повідомлень)\nОригінал: `{parent}`\nГілка: `{new}`\nВикористайте `/resume`, щоб повернутися до оригіналу."
branched_thread_one: "⑂ Гілку **{title}** створено в новому треді ({count} повідомлення скопійовано)\nТред: {thread}\nОригінал: `{parent}` — цей чат залишається на ньому\nГілка: `{new}`\nВикористовуйте `/branch --here`, щоб розгалузити в цьому чаті."
branched_thread_many: "⑂ Гілку **{title}** створено в новому треді ({count} повідомлень скопійовано)\nТред: {thread}\nОригінал: `{parent}` — цей чат залишається на ньому\nГілка: `{new}`\nВикористовуйте `/branch --here`, щоб розгалузити в цьому чаті."
thread_fallback: "Тут не вдалося створити тред, тому гілка замінила сесію цього чату."
commands:
usage: "Використання: `/commands [page]`"

View File

@@ -86,6 +86,9 @@ gateway:
switch_failed: "分支已建立,但無法切換到該分支。"
branched_one: "⑂ 已分支至 **{title}**(已複製 {count} 則訊息)\n原始:`{parent}`\n分支:`{new}`\n使用 `/resume` 切換回原始工作階段。"
branched_many: "⑂ 已分支至 **{title}**(已複製 {count} 則訊息)\n原始:`{parent}`\n分支:`{new}`\n使用 `/resume` 切換回原始工作階段。"
branched_thread_one: "⑂ 已在新討論串中分支到 **{title}**(已複製 {count} 則訊息)\n討論串:{thread}\n原始:`{parent}` — 本聊天保持在原會話\n分支:`{new}`\n使用 `/branch --here` 可在本聊天中分支。"
branched_thread_many: "⑂ 已在新討論串中分支到 **{title}**(已複製 {count} 則訊息)\n討論串:{thread}\n原始:`{parent}` — 本聊天保持在原會話\n分支:`{new}`\n使用 `/branch --here` 可在本聊天中分支。"
thread_fallback: "此處無法建立討論串,因此分支取代了本聊天的會話。"
commands:
usage: "用法:`/commands [page]`"

View File

@@ -86,6 +86,9 @@ gateway:
switch_failed: "分支已创建,但无法切换到它。"
branched_one: "⑂ 已分支到 **{title}**(已复制 {count} 条消息)\n原始:`{parent}`\n分支:`{new}`\n使用 `/resume` 切换回原始会话。"
branched_many: "⑂ 已分支到 **{title}**(已复制 {count} 条消息)\n原始:`{parent}`\n分支:`{new}`\n使用 `/resume` 切换回原始会话。"
branched_thread_one: "⑂ 已在新话题中分支到 **{title}**(已复制 {count} 条消息)\n话题:{thread}\n原始:`{parent}` — 本聊天保持在原会话\n分支:`{new}`\n使用 `/branch --here` 可在本聊天中分支。"
branched_thread_many: "⑂ 已在新话题中分支到 **{title}**(已复制 {count} 条消息)\n话题:{thread}\n原始:`{parent}` — 本聊天保持在原会话\n分支:`{new}`\n使用 `/branch --here` 可在本聊天中分支。"
thread_fallback: "此处无法创建话题,因此分支替换了本聊天的会话。"
commands:
usage: "用法:`/commands [page]`"

View File

@@ -0,0 +1,114 @@
"""/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

View File

@@ -67,7 +67,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
| `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. |
| `/bg <prompt>` | Run a prompt in a separate background session. The agent processes your prompt independently — your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](../user-guide/cli.md#background-sessions). |
| `/btw <question>` | Ask a quick side question **about the current conversation** without interrupting it. A one-shot auxiliary LLM call answers from a read-only snapshot of the transcript — the live session's history and prompt cache are untouched, and the current turn keeps running. For independent work with a fresh context, use `/bg`. |
| `/branch [name]` (alias: `/fork`) | Branch the current session (explore a different path). Classic CLI: refused mid-turn like `/handoff` — wait for the current response to finish, then retry. |
| `/branch [--here] [name]` (alias: `/fork`) | Branch the current session into an independent copy (explore a different path). On Discord, Telegram, Slack and Matrix the branch opens in a **new sibling thread** and the current chat stays on the original session; `--here` switches the current chat onto the branch instead (the pre-#66023 behaviour). The CLI and platforms without threads always branch in place. Classic CLI: refused mid-turn like `/handoff` — wait for the current response to finish, then retry. |
| `/worktree [new [name]\|list]` | **CLI only.** Inspect or create isolated git worktrees mid-session (inspired by Copilot CLI's `/worktree new`). Bare `/worktree` shows the active worktree; `/worktree list` lists the repo's worktrees; `/worktree new [name]` creates a worktree under `.worktrees/` (branched from the freshly-fetched remote tip, honoring `worktree_sync`) and retargets the session's terminal and file tools into it. Named trees use your name (`hermes/<name>` branch); unnamed ones get a random `hermes-<id>`. On exit the tree is kept only if it has unpushed commits — same lifecycle as `hermes -w`. See [Git Worktrees](../user-guide/git-worktrees.md). |
| `/handoff <platform>` | **CLI only.** Hand the current session off to a messaging platform (Telegram, Discord, Slack, WhatsApp, Signal, Matrix). The gateway picks it up immediately, creates a fresh thread on platforms that support threads (Telegram topics, Discord text-channel threads, Slack and Matrix message-anchored threads), re-binds the destination to your CLI session_id so the full role-aware transcript replays, and forges a synthetic user turn so the agent confirms it's working in the new place. Your CLI exits cleanly on success with a `/resume` hint; resume locally any time with `/resume <title>`. Refused mid-turn. Requires the gateway to be running and a home channel configured for the target platform (`/sethome` from the destination chat). See [Cross-Platform Handoff](../user-guide/sessions.md#cross-platform-handoff). |
| `/journey [list\|delete <id>\|edit <id>]` (aliases: `/learning`, `/memory-graph`) | Open the learning journey timeline of learned skills + memories. Works in the classic CLI, as a TUI overlay, and in the desktop app (Star Map panel). Not available on messaging platforms. See [Learning Journey](../user-guide/features/memory.md#learning-journey-journey). |
@@ -277,7 +277,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
| `/refine [focus]` | Run the memory/skill self-improvement review now, optionally with focus instructions. On Slack use `/hermes refine …`. |
| `/review [instructions]` | Spawn an independent reviewer subagent for the work just discussed (PR, code, docs); its review re-enters this chat when done. On Slack use `/hermes review …`. |
| `/moa <prompt>` | Run one prompt through the default [Mixture of Agents](../user-guide/features/mixture-of-agents.md) preset, then restore the session model. |
| `/branch [name]` (alias: `/fork`) | Branch the current session (explore a different path). |
| `/branch [--here] [name]` (alias: `/fork`) | Branch the current session. Thread-capable platforms (Discord, Telegram, Slack, Matrix) open the branch in a new sibling thread and keep this chat on the original; `--here` switches this chat onto the branch. |
| `/agents` (alias: `/tasks`) | Show active agents and running tasks. |
| `/sessions` | Browse and resume previous sessions. |
| `/context [all]` (alias: `/ctx`) | Context-window usage gauge and category breakdown (messaging-friendly text form). `/context all` adds per-skill / per-toolset cost detail. |