Add /branch command to fork conversations from any message (#1342, fixes #465)

Fix: gate parent_session_id emission in compact() on truthiness so
sessions without a fork link don't leak parent_session_id: None and
break the v0.50.251 lineage end_reason gating in agent_sessions.py.
The /branch endpoint sets the field on saved forks; everything else
keeps the v0.50.251 sidebar lineage path as the canonical source.
This commit is contained in:
Hermes Agent committed 2026-05-01 05:32:45 +00:00
1 parent fea47bd986
commit 52bfceaa3b
9 files changed
+616 -59

No files matched your search

+6
View File
@@ -320,6 +320,7 @@ class Session:
compression_anchor_message_key=None,
context_length=None, threshold_tokens=None,
last_prompt_tokens=None,
parent_session_id: str=None,
**kwargs):
self.session_id = session_id or uuid.uuid4().hex[:12]
self.title = title
@@ -347,6 +348,7 @@ class Session:
self.context_length = context_length
self.threshold_tokens = threshold_tokens
self.last_prompt_tokens = last_prompt_tokens
self.parent_session_id = parent_session_id
self.is_cli_session = bool(kwargs.get('is_cli_session', False))
self.source_tag = kwargs.get('source_tag')
self.session_source = kwargs.get('session_source')
@@ -371,6 +373,7 @@ class Session:
'pending_user_message', 'pending_attachments', 'pending_started_at',
'compression_anchor_visible_idx', 'compression_anchor_message_key',
'context_length', 'threshold_tokens', 'last_prompt_tokens',
'parent_session_id',
'is_cli_session', 'source_tag', 'session_source', 'source_label',
]
meta = {k: getattr(self, k, None) for k in METADATA_FIELDS}
@@ -466,6 +469,9 @@ class Session:
'context_length': self.context_length,
'threshold_tokens': self.threshold_tokens,
'last_prompt_tokens': self.last_prompt_tokens,
# Only emit 'parent_session_id' when set (the /branch fork link, #1342).
# Sessions without a fork must not leak None — see test_session_lineage_metadata_api.
**({'parent_session_id': self.parent_session_id} if self.parent_session_id else {}),
'active_stream_id': self.active_stream_id,
'is_cli_session': self.is_cli_session,
'source_tag': self.source_tag,
+64
View File
@@ -1702,6 +1702,70 @@ def handle_post(handler, parsed) -> bool:
handler, {"ok": True, "session": s.compact() | {"messages": s.messages}}
)
if parsed.path == "/api/session/branch":
# Fork a conversation from any message point (#465).
# Accepts: {session_id, keep_count?, title?}
# keep_count: number of messages to copy (0=empty, undefined=full history)
# title: custom title (defaults to "<original title> (fork)")
try:
require(body, "session_id")
except ValueError as e:
return bad(handler, str(e))
try:
source = get_session(body["session_id"])
except KeyError:
return bad(handler, "Session not found", 404)
keep_count = body.get("keep_count")
if keep_count is not None:
try:
keep_count = int(keep_count)
except (ValueError, TypeError):
return bad(handler, "keep_count must be an integer")
custom_title = body.get("title")
if custom_title:
custom_title = str(custom_title).strip()[:80] or None
# Build messages slice
source_messages = source.messages or []
if keep_count is not None:
forked_messages = source_messages[:keep_count]
else:
forked_messages = list(source_messages)
# Derive title
if custom_title:
branch_title = custom_title
else:
source_title = source.title or "Untitled"
branch_title = f"{source_title} (fork)"
# Create new session inheriting workspace/model/profile
branch = Session(
workspace=source.workspace,
model=source.model,
profile=getattr(source, "profile", None),
title=branch_title,
messages=forked_messages,
parent_session_id=source.session_id,
)
with LOCK:
SESSIONS[branch.session_id] = branch
SESSIONS.move_to_end(branch.session_id)
while len(SESSIONS) > SESSIONS_MAX:
SESSIONS.popitem(last=False)
# Persist only if there are messages (matches new_session pattern)
if forked_messages:
branch.save()
return j(handler, {
"session_id": branch.session_id,
"title": branch_title,
"parent_session_id": source.session_id,
})
if parsed.path == "/api/session/compress":
return _handle_session_compress(handler, body)
+44
View File
@@ -30,6 +30,7 @@ const COMMANDS=[
{name:'voice', desc:t('cmd_voice'), fn:cmdVoice, noEcho:true},
{name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh'], noEcho:true},
{name:'yolo', desc:t('cmd_yolo'), fn:cmdYolo, noEcho:true},
{name:'branch', desc:t('cmd_branch'), fn:cmdBranch, arg:'[name]', noEcho:true},
];
const SLASH_SUBARG_SOURCES={
@@ -903,6 +904,49 @@ async function cmdYolo(){
}catch(e){showToast('YOLO: '+e.message);}
}
// ── Branch / fork command ──
// Forks the current conversation into a new session (#465).
// /branch → full history copy
// /branch My Name → full history copy with custom title
async function cmdBranch(args){
if(!S.session){showToast(t('no_active_session'));return;}
const customTitle=(args||'').trim()||null;
try{
const data=await api('/api/session/branch',{
method:'POST',
body:JSON.stringify({
session_id:S.session.session_id,
title:customTitle||undefined,
}),
});
if(data&&data.session_id){
await loadSession(data.session_id);
if(typeof renderSessionList==='function') await renderSessionList();
showToast(t('branch_forked'));
}
}catch(e){showToast(t('branch_failed')+e.message);}
}
// ── Fork from a specific message point ──
// Called from the "Fork from here" button on message hover actions.
async function forkFromMessage(msgIdx){
if(!S.session||S.busy)return;
try{
const data=await api('/api/session/branch',{
method:'POST',
body:JSON.stringify({
session_id:S.session.session_id,
keep_count:msgIdx,
}),
});
if(data&&data.session_id){
await loadSession(data.session_id);
if(typeof renderSessionList==='function') await renderSessionList();
showToast(t('branch_forked'));
}
}catch(e){showToast(t('branch_failed')+e.message);}
}
let _skillCommandCache=[];
let _skillCommandLoadPromise=null;
let _skillCommandCacheReady=false;
+123 -3
View File
@@ -177,9 +177,15 @@ const LOCALES = {
cmd_undo:'Remove the last exchange',
cmd_btw:'Ask a side question (ephemeral)',
cmd_btw_usage:'/btw <question> — ask a side question using session context',
cmd_background:'Run a prompt in background',
cmd_background_usage:'/background <prompt> — run in parallel without blocking',
btw_asking:'Asking side question...',
cmd_background:'Run a prompt in background',
cmd_background_usage:'/background <prompt> — run in parallel without blocking',
cmd_branch:'Fork this conversation into a new session',
cmd_branch_usage:'/branch [name] — fork conversation (optionally with a name)',
branch_forked:'Forked into new session',
branch_failed:'Fork failed: ',
fork_from_here:'Fork from here',
forked_from:'Forked from',
btw_asking:'Asking side question...',
btw_label:'Side question — not in history',
btw_done:'Side question answered',
btw_no_answer:'No answer received.',
@@ -587,6 +593,15 @@ const LOCALES = {
onboarding_error_workspace_required: 'Workspace is required.',
onboarding_error_model_required: 'Model is required.',
onboarding_complete: 'Onboarding complete',
wiki_panel_title: 'LLM Wiki',
wiki_panel_desc: 'Enable or disable the LLM Wiki knowledge base integration.',
wiki_status_label: 'Status',
wiki_entry_count: '{0} entries',
wiki_last_modified: 'Last modified',
wiki_not_available: 'N/A',
wiki_enabled: 'Wiki enabled',
wiki_disabled: 'Wiki disabled',
wiki_toggle_failed: 'Failed to toggle wiki: ',
// panel/runtime i18n
error_prefix: 'Error: ',
not_available: 'N/A',
@@ -1482,6 +1497,12 @@ const LOCALES = {
status_started: 'Started',
status_tokens: 'Tokens',
status_unknown: 'Unknown',
cmd_branch:'Fork this conversation into a new session',
cmd_branch_usage:'/branch [name] — fork conversation (optionally with a name)',
branch_forked:'Forked into new session',
branch_failed:'Fork failed: ',
fork_from_here:'Fork from here',
forked_from:'Forked from',
cmd_yolo: 'Переключить YOLO режим (без подтверждений)',
yolo_no_session: 'Нет активного сеанса',
yolo_enabled: '⚡ YOLO режим ВКЛ — подтверждения пропущены',
@@ -1537,6 +1558,15 @@ const LOCALES = {
settings_desc_tts_voice: 'Выберите голос для синтеза речи',
settings_label_tts_rate: 'Скорость речи',
settings_label_tts_pitch: 'Тон речи',
wiki_panel_title: 'LLM Wiki',
wiki_panel_desc: '\u0412\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0438\u043b\u0438 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0431\u0430\u0437\u044b \u0437\u043d\u0430\u043d\u0438\u0439 LLM Wiki.',
wiki_status_label: '\u0421\u0442\u0430\u0442\u0443\u0441',
wiki_entry_count: '{0} \u0437\u0430\u043f\u0438\u0441\u0435\u0439',
wiki_last_modified: '\u041f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0435 \u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435',
wiki_not_available: '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445',
wiki_enabled: '\u0412\u0438\u043a\u0438 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0430',
wiki_disabled: '\u0412\u0438\u043a\u0438 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0430',
wiki_toggle_failed: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0432\u0438\u043a\u0438: ',
},
es: {
@@ -1970,6 +2000,15 @@ const LOCALES = {
onboarding_error_workspace_required: 'El espacio de trabajo es obligatorio.',
onboarding_error_model_required: 'El modelo es obligatorio.',
onboarding_complete: 'Onboarding completado',
wiki_panel_title: 'Wiki LLM',
wiki_panel_desc: 'Habilitar o deshabilitar la integraci\u00f3n de la base de conocimientos LLM Wiki.',
wiki_status_label: 'Estado',
wiki_entry_count: '{0} entradas',
wiki_last_modified: '\u00daltima modificaci\u00f3n',
wiki_not_available: 'N/D',
wiki_enabled: 'Wiki habilitado',
wiki_disabled: 'Wiki deshabilitado',
wiki_toggle_failed: 'Error al cambiar wiki: ',
// panel/runtime i18n
error_prefix: 'Error: ',
not_available: 'N/A',
@@ -2216,6 +2255,12 @@ const LOCALES = {
status_tokens: 'Tokens',
status_unknown: 'Unknown',
cmd_yolo: 'Alternar modo YOLO (saltar aprobaciones)',
cmd_branch:'Fork this conversation into a new session',
cmd_branch_usage:'/branch [name] — fork conversation (optionally with a name)',
branch_forked:'Forked into new session',
branch_failed:'Fork failed: ',
fork_from_here:'Fork from here',
forked_from:'Forked from',
yolo_no_session: 'No hay sesión activa',
yolo_enabled: '⚡ Modo YOLO activado — aprobaciones omitidas',
yolo_disabled: 'Modo YOLO desactivado',
@@ -2724,6 +2769,12 @@ const LOCALES = {
status_unknown: 'Unknown',
cmd_yolo: 'YOLO-Modus umschalten',
yolo_no_session: 'Keine aktive Sitzung',
cmd_branch:'Fork this conversation into a new session',
cmd_branch_usage:'/branch [name] — fork conversation (optionally with a name)',
branch_forked:'Forked into new session',
branch_failed:'Fork failed: ',
fork_from_here:'Fork from here',
forked_from:'Forked from',
yolo_enabled: '⚡ YOLO-Modus AN — Genehmigungen übersprungen',
yolo_disabled: 'YOLO-Modus AUS',
yolo_pill_label: 'YOLO',
@@ -3007,6 +3058,15 @@ const LOCALES = {
settings_desc_tts_voice: 'Stimme für Sprachsynthese auswählen',
settings_label_tts_rate: 'Sprechgeschwindigkeit',
settings_label_tts_pitch: 'Tonhöhe',
wiki_panel_title: 'LLM Wiki',
wiki_panel_desc: 'Aktivieren oder Deaktivieren der LLM Wiki-Wissensdatenbankintegration.',
wiki_status_label: 'Status',
wiki_entry_count: '{0} Eintr\u00e4ge',
wiki_last_modified: 'Zuletzt ge\u00e4ndert',
wiki_not_available: 'N/V',
wiki_enabled: 'Wiki aktiviert',
wiki_disabled: 'Wiki deaktiviert',
wiki_toggle_failed: 'Wiki-Umschaltung fehlgeschlagen: ',
},
zh: {
@@ -3438,6 +3498,15 @@ const LOCALES = {
onboarding_error_workspace_required: '必须填写工作区。',
onboarding_error_model_required: '必须填写模型。',
onboarding_complete: '引导完成',
wiki_panel_title: 'LLM Wiki',
wiki_panel_desc: '\u542f\u7528\u6216\u7981\u7528 LLM Wiki \u77e5\u8bc6\u5e93\u6574\u5408\u3002',
wiki_status_label: '\u72b6\u6001',
wiki_entry_count: '{0} \u6761\u76ee',
wiki_last_modified: '\u6700\u540e\u4fee\u6539',
wiki_not_available: '\u65e0\u6570\u636e',
wiki_enabled: 'Wiki \u5df2\u542f\u7528',
wiki_disabled: 'Wiki \u5df2\u7981\u7528',
wiki_toggle_failed: '\u5207\u6362 Wiki \u5931\u8d25: ',
// panel/runtime i18n
error_prefix: '错误:',
not_available: '无',
@@ -3685,6 +3754,12 @@ const LOCALES = {
cmd_yolo: 'YOLO 模式切换',
yolo_no_session: '无活动会话',
yolo_enabled: '⚡ YOLO 模式已开启 — 将跳过所有审批',
cmd_branch:'Fork this conversation into a new session',
cmd_branch_usage:'/branch [name] — fork conversation (optionally with a name)',
branch_forked:'Forked into new session',
branch_failed:'Fork failed: ',
fork_from_here:'Fork from here',
forked_from:'Forked from',
yolo_disabled: 'YOLO 模式已关闭',
yolo_pill_label: 'YOLO',
yolo_pill_title_active: 'YOLO 模式激活 — 点击关闭',
@@ -4468,6 +4543,12 @@ const LOCALES = {
yolo_no_session: '無活動工作階段',
yolo_enabled: '⚡ YOLO 模式已開啟 — 將跳過所有審批',
yolo_disabled: 'YOLO 模式已關閉',
cmd_branch:'Fork this conversation into a new session',
cmd_branch_usage:'/branch [name] — fork conversation (optionally with a name)',
branch_forked:'Forked into new session',
branch_failed:'Fork failed: ',
fork_from_here:'Fork from here',
forked_from:'Forked from',
yolo_pill_label: 'YOLO',
yolo_pill_title_active: 'YOLO 模式激活 — 點擊關閉',
approval_skip_all: '⚡ 本次工作階段全部跳過',
@@ -4577,6 +4658,15 @@ const LOCALES = {
settings_desc_tts_voice: '選擇語音合成聲音',
settings_label_tts_rate: '語速',
settings_label_tts_pitch: '音調',
wiki_panel_title: 'LLM Wiki',
wiki_panel_desc: '\u555f\u7528\u6216\u505c\u7528 LLM Wiki \u77e5\u8b58\u5eab\u6574\u5408\u3002',
wiki_status_label: '\u72b6\u614b',
wiki_entry_count: '{0} \u689d\u76ee',
wiki_last_modified: '\u6700\u5f8c\u4fee\u6539',
wiki_not_available: '\u7121\u6578\u64da',
wiki_enabled: 'Wiki \u5df2\u555f\u7528',
wiki_disabled: 'Wiki \u5df2\u505c\u7528',
wiki_toggle_failed: '\u5207\u63db Wiki \u5931\u6557: ',
},
@@ -4714,6 +4804,12 @@ const LOCALES = {
btw_done: 'Pergunta lateral respondida',
btw_no_answer: 'Nenhuma resposta recebida.',
btw_failed: 'Pergunta lateral falhou: ',
cmd_branch:'Fork this conversation into a new session',
cmd_branch_usage:'/branch [name] — fork conversation (optionally with a name)',
branch_forked:'Forked into new session',
branch_failed:'Fork failed: ',
fork_from_here:'Fork from here',
forked_from:'Forked from',
bg_running: 'Rodando em background...',
bg_complete: 'Tarefa de background completa',
bg_label: 'Resultado de background:',
@@ -5070,6 +5166,15 @@ const LOCALES = {
onboarding_error_model_required: 'Modelo é necessário.',
onboarding_complete: 'Configuração completa',
wiki_panel_title: 'Wiki LLM',
wiki_panel_desc: 'Ativar ou desativar a integra\u00e7\u00e3o da base de conhecimento LLM Wiki.',
wiki_status_label: 'Estado',
wiki_entry_count: '{0} entradas',
wiki_last_modified: '\u00daltima modifica\u00e7\u00e3o',
wiki_not_available: 'N/D',
wiki_enabled: 'Wiki ativado',
wiki_disabled: 'Wiki desativado',
wiki_toggle_failed: 'Falha ao alternar wiki: ',
// panel/runtime i18n
error_prefix: 'Erro: ',
not_available: 'N/D',
@@ -5782,6 +5887,15 @@ const LOCALES = {
onboarding_error_workspace_required: 'Workspace is required.',
onboarding_error_model_required: 'Model is required.',
onboarding_complete: '설정 완료',
wiki_panel_title: 'LLM Wiki',
wiki_panel_desc: 'LLM Wiki \uc9c0\uc2dd \ubca0\uc774\uc2a4 \ud56d\ubaa9 \ud1b5\ud565\uc744 \ud65c\uc131\ud654\uac78\ub9b4 \uc9c0 \uc2eb\uc74c\uc744 \uc124\uc815\ud569\ub2c8\ub2e4.',
wiki_status_label: '\uc0c1\ud0dc',
wiki_entry_count: '{0}\uac1c \ud56d\ubaa9',
wiki_last_modified: '\ub9c8\uc9c0\ub9c9 \uc218\uc815',
wiki_not_available: '\uc0ac\uc6a9 \uc218\uc5c6\uc74c',
wiki_enabled: 'Wiki \ud65c\uc131\ud654\ub428',
wiki_disabled: 'Wiki \ub044\ud658\ub428',
wiki_toggle_failed: 'Wiki \ud1b5\uad6c \uc2dc\ub3c4 \uc2e4\ud328: ',
// panel/runtime i18n
error_prefix: 'Error: ',
not_available: 'N/A',
@@ -5960,6 +6074,12 @@ const LOCALES = {
profile_base_url_label: 'Base URL',
profile_api_key_label: 'API key',
cmd_yolo: 'YOLO 모드 전환',
cmd_branch: 'Fork this conversation into a new session',
cmd_branch_usage: '/branch [name] — fork conversation (optionally with a name)',
branch_forked: 'Forked into new session',
branch_failed: 'Fork failed: ',
fork_from_here: 'Fork from here',
forked_from: 'Forked from',
yolo_no_session: '활성 세션 없음',
yolo_enabled: '⚡ YOLO 모드 켜짐 — 이 세션에서 승인 건너뜀',
yolo_disabled: 'YOLO 모드 꺼짐',
+1
View File
@@ -61,6 +61,7 @@ const LI_PATHS = {
// Suggestion buttons
'clipboard-list': '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="12" y2="16"/>',
'map': '<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>',
'git-branch': '<line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
};
/**
+13
View File
@@ -1442,6 +1442,19 @@ function renderSessionListFromCache(){
pinInd.innerHTML=ICONS.pin;
titleRow.appendChild(pinInd);
}
// Parent session indicator for forked/branched sessions (#465)
if(s.parent_session_id){
const branchInd=document.createElement('span');
branchInd.className='session-branch-indicator';
branchInd.textContent='\u2482'; // ⑂
branchInd.title=(typeof t==='function'?t('forked_from'):'Forked from')+' '+s.parent_session_id;
branchInd.style.cursor='pointer';
branchInd.onclick=(e)=>{
e.stopPropagation();
if(typeof loadSession==='function') loadSession(s.parent_session_id);
};
titleRow.appendChild(branchInd);
}
const title=document.createElement('span');
title.className='session-title';
title.textContent=cleanTitle||'Untitled';
+2 -1
View File
@@ -3127,6 +3127,7 @@ function renderMessages(){
const undoBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('undo_exchange')}" onclick="undoLastExchange()">${li('undo',13)}</button>` : '';
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('regenerate')}" onclick="regenerateResponse(this)">${li('rotate-ccw',13)}</button>` : '';
const copyBtn = `<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">${li('copy',13)}</button>`;
const forkBtn = `<button class="msg-action-btn" title="${t('fork_from_here')}" onclick="forkFromMessage(${rawIdx+1})">${li('git-branch',13)}</button>`;
const ttsBtn = !isUser ? `<button class="msg-action-btn msg-tts-btn" title="${t('tts_listen')||'Listen'}" onclick="speakMessage(this)">${li('volume-2',13)}</button>` : '';
const tsVal=m._ts||m.timestamp;
// _formatInServerTz handles fractional-hour offsets (India +0530 etc.)
@@ -3135,7 +3136,7 @@ function renderMessages(){
const tsTitle=tsVal?(_fmtSv?_fmtSv(new Date(tsVal*1000),{}):new Date(tsVal*1000).toLocaleString()):'';
const tsTime=_formatMessageFooterTimestamp(tsVal);
const timeHtml = tsTime ? `<span class="msg-time" title="${esc(tsTitle)}">${tsTime}</span>` : '';
const footHtml = `<div class="msg-foot">${timeHtml}<span class="msg-actions">${editBtn}${ttsBtn}${copyBtn}${retryBtn}</span></div>`;
const footHtml = `<div class="msg-foot">${timeHtml}<span class="msg-actions">${editBtn}${ttsBtn}${forkBtn}${copyBtn}${retryBtn}</span></div>`;
if(_isContextCompactionMessage(m)){
if(compressionState || referenceNode){
+273
View File
@@ -0,0 +1,273 @@
"""Tests for issue #465 — session branching (/branch).
Verifies:
1. Backend endpoint POST /api/session/branch exists in routes.py
2. Session model supports parent_session_id field
3. Frontend /branch slash command is registered
4. forkFromMessage function exists in commands.js
5. Fork button (git-branch icon) is rendered in ui.js message actions
6. Parent session indicator (⑂) is rendered in sessions.js sidebar
7. i18n keys exist for all branch-related strings
8. git-branch icon exists in icons.js
"""
import re
# ── Backend ────────────────────────────────────────────────────────────────────
def test_branch_endpoint_exists():
"""Verify the POST /api/session/branch route handler exists."""
with open('api/routes.py') as f:
src = f.read()
assert '"POST /api/session/branch"' in src or '"/api/session/branch"' in src, \
"Missing /api/session/branch route"
def test_branch_endpoint_validates_session_id():
"""Verify the branch endpoint requires session_id."""
with open('api/routes.py') as f:
src = f.read()
# Find the branch block
branch_match = re.search(
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
src, re.DOTALL
)
assert branch_match, "Could not find /api/session/branch handler block"
block = branch_match.group(1)
assert 'require(body, "session_id")' in block, \
"Branch handler should validate session_id"
def test_branch_endpoint_returns_new_session_id():
"""Verify the branch endpoint returns session_id and title."""
with open('api/routes.py') as f:
src = f.read()
branch_match = re.search(
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
src, re.DOTALL
)
assert branch_match
block = branch_match.group(1)
assert '"session_id"' in block, "Branch handler should return session_id"
assert '"title"' in block, "Branch handler should return title"
assert '"parent_session_id"' in block, \
"Branch handler should return parent_session_id"
def test_branch_creates_session_with_parent():
"""Verify the branch creates a Session with parent_session_id set."""
with open('api/routes.py') as f:
src = f.read()
branch_match = re.search(
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
src, re.DOTALL
)
assert branch_match
block = branch_match.group(1)
assert 'parent_session_id=source.session_id' in block, \
"Branch handler should set parent_session_id to source session"
def test_branch_keep_count_support():
"""Verify the branch endpoint supports keep_count parameter."""
with open('api/routes.py') as f:
src = f.read()
branch_match = re.search(
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
src, re.DOTALL
)
assert branch_match
block = branch_match.group(1)
assert 'keep_count' in block, "Branch handler should support keep_count"
assert 'forked_messages = source_messages[:keep_count]' in block, \
"Branch handler should slice messages by keep_count"
def test_branch_auto_title():
"""Verify fork title defaults to '<original> (fork)'."""
with open('api/routes.py') as f:
src = f.read()
branch_match = re.search(
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
src, re.DOTALL
)
assert branch_match
block = branch_match.group(1)
assert '(fork)' in block, "Branch handler should auto-title as '(fork)'"
# ── Session model ──────────────────────────────────────────────────────────────
def test_session_model_parent_session_id():
"""Verify Session model supports parent_session_id."""
with open('api/models.py') as f:
src = f.read()
assert 'parent_session_id' in src, "Session model should have parent_session_id"
# Check __init__ parameter
assert 'parent_session_id: str=None' in src, \
"Session.__init__ should accept parent_session_id parameter"
# Check it's set on self
assert 'self.parent_session_id = parent_session_id' in src, \
"Session.__init__ should assign parent_session_id"
def test_session_compact_includes_parent():
"""Verify compact() includes parent_session_id."""
with open('api/models.py') as f:
src = f.read()
# Use simpler search - find the compact method and check for parent_session_id after it
compact_def_match = re.search(r"def compact\(self", src)
assert compact_def_match, "Could not find compact() method"
# Check the next 1000 chars after def compact for parent_session_id
snippet = src[compact_def_match.start():compact_def_match.start() + 1500]
assert "'parent_session_id'" in snippet, \
"compact() should include parent_session_id"
def test_session_metadata_fields_includes_parent():
"""Verify parent_session_id is in METADATA_FIELDS for persistence."""
with open('api/models.py') as f:
src = f.read()
assert "'parent_session_id'" in src, \
"METADATA_FIELDS should include parent_session_id"
# ── Frontend: slash command ────────────────────────────────────────────────────
def test_branch_slash_command_registered():
"""Verify /branch is registered as a slash command."""
with open('static/commands.js') as f:
src = f.read()
assert "name:'branch'" in src, "/branch should be registered as a command"
assert 'cmdBranch' in src, "cmdBranch handler should be defined"
def test_cmdBranch_function_exists():
"""Verify cmdBranch function is defined."""
with open('static/commands.js') as f:
src = f.read()
assert 'async function cmdBranch(' in src, \
"cmdBranch should be an async function"
def test_cmdBranch_calls_branch_endpoint():
"""Verify cmdBranch calls the /api/session/branch endpoint."""
with open('static/commands.js') as f:
src = f.read()
branch_fn = re.search(r'async function cmdBranch\(.*?\n\}', src, re.DOTALL)
assert branch_fn, "Could not find cmdBranch function"
block = branch_fn.group(0)
assert "'/api/session/branch'" in block, \
"cmdBranch should call /api/session/branch"
def test_cmdBranch_switches_session():
"""Verify cmdBranch calls loadSession after branching."""
with open('static/commands.js') as f:
src = f.read()
branch_fn = re.search(r'async function cmdBranch\(.*?\n\}', src, re.DOTALL)
assert branch_fn
block = branch_fn.group(0)
assert 'loadSession(' in block, \
"cmdBranch should switch to the new session via loadSession"
# ── Frontend: forkFromMessage ─────────────────────────────────────────────────
def test_forkFromMessage_function_exists():
"""Verify forkFromMessage function exists."""
with open('static/commands.js') as f:
src = f.read()
assert 'async function forkFromMessage(' in src, \
"forkFromMessage should be defined"
def test_forkFromMessage_passes_keep_count():
"""Verify forkFromMessage passes keep_count to the endpoint."""
with open('static/commands.js') as f:
src = f.read()
fn = re.search(r'async function forkFromMessage\(.*?\n\}', src, re.DOTALL)
assert fn
block = fn.group(0)
assert 'keep_count' in block, \
"forkFromMessage should pass keep_count to /api/session/branch"
# ── Frontend: fork button in messages ──────────────────────────────────────────
def test_fork_button_rendered_in_ui():
"""Verify fork button is rendered in message actions."""
with open('static/ui.js') as f:
src = f.read()
assert "forkBtn" in src, "forkBtn variable should exist in ui.js"
assert "fork_from_here" in src, \
"fork_from_here i18n key should be referenced for tooltip"
assert "forkFromMessage(" in src, \
"forkFromMessage should be called from the button"
def test_fork_button_in_message_actions():
"""Verify fork button is included in the msg-actions span."""
with open('static/ui.js') as f:
src = f.read()
# The footHtml template should include forkBtn
assert '${forkBtn}' in src, \
"forkBtn should be included in message actions template"
# ── Frontend: sidebar parent indicator ────────────────────────────────────────
def test_sidebar_parent_indicator():
"""Verify parent session indicator is rendered in session list."""
with open('static/sessions.js') as f:
src = f.read()
assert 'parent_session_id' in src, \
"sessions.js should check parent_session_id"
assert 'session-branch-indicator' in src, \
"Should have session-branch-indicator class"
assert '\\u2482' in src, \
"Should use ⑂ character for parent indicator"
def test_parent_indicator_clickable():
"""Verify parent indicator navigates to parent session on click."""
with open('static/sessions.js') as f:
src = f.read()
# Find the parent indicator block
parent_block = re.search(
r'parent_session_id\).*?(?=titleRow\.appendChild)',
src, re.DOTALL
)
assert parent_block, "Could not find parent indicator block"
block = parent_block.group(0)
assert 'loadSession(' in block, \
"Parent indicator should call loadSession on click"
# ── Frontend: i18n keys ────────────────────────────────────────────────────────
def test_i18n_branch_keys():
"""Verify all branch-related i18n keys exist in English locale."""
with open('static/i18n.js') as f:
src = f.read()
required_keys = [
'cmd_branch',
'cmd_branch_usage',
'branch_forked',
'branch_failed',
'fork_from_here',
'forked_from',
]
for key in required_keys:
assert f"{key}:" in src or f"{key} :" in src, \
f"Missing i18n key: {key}"
# ── Frontend: icon ─────────────────────────────────────────────────────────────
def test_git_branch_icon_exists():
"""Verify git-branch icon is defined in icons.js."""
with open('static/icons.js') as f:
src = f.read()
assert "'git-branch'" in src, \
"git-branch icon should be defined in LI_PATHS"
+90 -55
View File
@@ -1,81 +1,116 @@
"""Tests for issue #1195: sessions must route to the correct profile directory
even when that profile directory does not exist yet on disk."""
"""Regression tests for issue #1195.
When the WebUI creates a session for a profile whose directory doesn't exist yet
(e.g. 'ayan' in ~/.hermes/profiles/ayan/), the session must be routed to that
profile's sessions dir — NOT to the default ~/.hermes/ dir. Otherwise sessions
created in WebUI for 'ayan' appear in TUI under 'awen' or 'default', breaking
profile isolation.
"""
import importlib
import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_hermes_home(base: Path, profile_name: str | None = None) -> Path:
"""Create a temp HERMES_HOME (with optional profile dir) and return it."""
hermes_home = base / ".hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
if profile_name:
(hermes_home / "profiles" / profile_name).mkdir(parents=True, exist_ok=True)
return hermes_home
REPO_ROOT = Path(__file__).parent.parent.resolve()
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
# ── tests ────────────────────────────────────────────────────────────────────
def _reload_profiles_module(base_home: Path):
"""Hot-reload api.profiles with a fresh _DEFAULT_HERMES_HOME."""
os.environ["HERMES_BASE_HOME"] = str(base_home)
os.environ["HERMES_HOME"] = str(base_home)
class TestGetHermesHomeForProfile:
"""get_hermes_home_for_profile() must return the profile path regardless of
whether the directory already exists on disk (#1195)."""
# Save and remove so we get a fresh import
_saved = {name: sys.modules[name] for name in ["api.config", "api.profiles"]
if name in sys.modules}
for name in ["api.config", "api.profiles"]:
if name in sys.modules:
del sys.modules[name]
@pytest.fixture(autouse=True)
def _patch_default_home(self, tmp_path):
"""Patch _DEFAULT_HERMES_HOME to a temp directory for isolation."""
from api.profiles import _DEFAULT_HERMES_HOME as real_default
profiles = importlib.import_module("api.profiles")
fake_home = tmp_path / ".hermes"
fake_home.mkdir(parents=True)
with patch("api.profiles._DEFAULT_HERMES_HOME", fake_home):
yield fake_home, real_default
# Restore so other tests aren't broken
sys.modules.update(_saved)
return profiles
def test_existing_profile_returns_profile_dir(self, _patch_default_home):
fake_home, _ = _patch_default_home
from api.profiles import get_hermes_home_for_profile
# Create an existing profile directory
profile_dir = fake_home / "profiles" / "ayan"
def test_get_hermes_home_routes_to_profile_dir_when_dir_missing():
"""Issue #1195: get_hermes_home_for_profile should return the profile dir
even when that directory does not exist yet.
WebUI can create sessions for a profile before TUI has ever initialised it.
The routing must NOT fallback to default home in that case — otherwise
session files end up in the wrong profile directory.
"""
with tempfile.TemporaryDirectory() as td:
base = Path(td) / ".hermes"
base.mkdir(parents=True)
# Deliberately NO profiles/ayan subdir — simulating a WebUI-only profile
profiles = _reload_profiles_module(base)
result = profiles.get_hermes_home_for_profile("ayan")
expected = base / "profiles" / "ayan"
assert result == expected, (
f"Expected {expected}, got {result}. "
"Sessions for 'ayan' would be saved to the wrong directory!"
)
def test_get_hermes_home_routes_to_existing_profile_dir():
"""Existing profile dir should still be returned as-is."""
with tempfile.TemporaryDirectory() as td:
base = Path(td) / ".hermes"
base.mkdir(parents=True)
profile_dir = base / "profiles" / "ayan"
profile_dir.mkdir(parents=True)
result = get_hermes_home_for_profile("ayan")
profiles = _reload_profiles_module(base)
result = profiles.get_hermes_home_for_profile("ayan")
assert result == profile_dir
def test_nonexistent_profile_still_returns_profile_path(self, _patch_default_home):
"""Core bug fix: profile dir doesn't exist yet but should still route there."""
fake_home, _ = _patch_default_home
from api.profiles import get_hermes_home_for_profile
# Do NOT create the profile directory
expected = fake_home / "profiles" / "newprofile"
assert not expected.exists() # confirm it doesn't exist
def test_default_profile_routes_to_default_home():
"""'default' profile should always route to _DEFAULT_HERMES_HOME."""
with tempfile.TemporaryDirectory() as td:
base = Path(td) / ".hermes"
base.mkdir(parents=True)
result = get_hermes_home_for_profile("newprofile")
assert result == expected, "Should route to profile path even when dir missing"
profiles = _reload_profiles_module(base)
result = profiles.get_hermes_home_for_profile("default")
def test_none_returns_default(self, _patch_default_home):
fake_home, _ = _patch_default_home
from api.profiles import get_hermes_home_for_profile
assert result == base
result = get_hermes_home_for_profile(None)
assert result == fake_home
def test_empty_string_returns_default(self, _patch_default_home):
fake_home, _ = _patch_default_home
from api.profiles import get_hermes_home_for_profile
def test_none_empty_profile_routes_to_default_home():
"""None / empty profile should route to default home."""
with tempfile.TemporaryDirectory() as td:
base = Path(td) / ".hermes"
base.mkdir(parents=True)
result = get_hermes_home_for_profile("")
assert result == fake_home
profiles = _reload_profiles_module(base)
def test_default_string_returns_default(self, _patch_default_home):
fake_home, _ = _patch_default_home
from api.profiles import get_hermes_home_for_profile
for name in [None, "", "default"]:
result = profiles.get_hermes_home_for_profile(name)
assert result == base, f"Profile {name!r} should route to default home"
result = get_hermes_home_for_profile("default")
assert result == fake_home
def test_path_traversal_still_rejected():
"""Path traversal attempts must still be rejected (security regression guard)."""
with tempfile.TemporaryDirectory() as td:
base = Path(td) / ".hermes"
base.mkdir(parents=True)
profiles = _reload_profiles_module(base)
for malicious in ["../../etc", "../..", "%2e%2e/etc"]:
result = profiles.get_hermes_home_for_profile(malicious)
assert result == base, (
f"Path traversal {malicious!r} should be rejected and fallback to default"
)