From 52bfceaa3b6961fce916cffeb23ac0c2add1dd68 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 1 May 2026 05:32:45 +0000 Subject: [PATCH] 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. --- api/models.py | 6 + api/routes.py | 64 ++++ static/commands.js | 44 +++ static/i18n.js | 126 +++++++- static/icons.js | 1 + static/sessions.js | 13 + static/ui.js | 3 +- tests/test_465_session_branching.py | 273 ++++++++++++++++++ .../test_issue1195_session_profile_routing.py | 145 ++++++---- 9 files changed, 616 insertions(+), 59 deletions(-) create mode 100644 tests/test_465_session_branching.py diff --git a/api/models.py b/api/models.py index 8e8d604d..6ab78174 100644 --- a/api/models.py +++ b/api/models.py @@ -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, diff --git a/api/routes.py b/api/routes.py index ee203874..cffec5ea 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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 " (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) diff --git a/static/commands.js b/static/commands.js index 4199ac8d..8dc5fa6f 100644 --- a/static/commands.js +++ b/static/commands.js @@ -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; diff --git a/static/i18n.js b/static/i18n.js index 423f0b2c..df275687 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -177,9 +177,15 @@ const LOCALES = { cmd_undo:'Remove the last exchange', cmd_btw:'Ask a side question (ephemeral)', cmd_btw_usage:'/btw — ask a side question using session context', - cmd_background:'Run a prompt in background', - cmd_background_usage:'/background — run in parallel without blocking', - btw_asking:'Asking side question...', + cmd_background:'Run a prompt in background', + cmd_background_usage:'/background — 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 모드 꺼짐', diff --git a/static/icons.js b/static/icons.js index 612b11b0..d87fa624 100644 --- a/static/icons.js +++ b/static/icons.js @@ -61,6 +61,7 @@ const LI_PATHS = { // Suggestion buttons 'clipboard-list': '', 'map': '', + 'git-branch': '', }; /** diff --git a/static/sessions.js b/static/sessions.js index a75cb38e..2f5e2efa 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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'; diff --git a/static/ui.js b/static/ui.js index 43005d2c..93a8021a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3127,6 +3127,7 @@ function renderMessages(){ const undoBtn = isLastAssistant ? `` : ''; const retryBtn = isLastAssistant ? `` : ''; const copyBtn = ``; + const forkBtn = ``; const ttsBtn = !isUser ? `` : ''; 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 ? `${tsTime}` : ''; - const footHtml = `
${timeHtml}${editBtn}${ttsBtn}${copyBtn}${retryBtn}
`; + const footHtml = `
${timeHtml}${editBtn}${ttsBtn}${forkBtn}${copyBtn}${retryBtn}
`; if(_isContextCompactionMessage(m)){ if(compressionState || referenceNode){ diff --git a/tests/test_465_session_branching.py b/tests/test_465_session_branching.py new file mode 100644 index 00000000..d8a68b7f --- /dev/null +++ b/tests/test_465_session_branching.py @@ -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 ' (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" diff --git a/tests/test_issue1195_session_profile_routing.py b/tests/test_issue1195_session_profile_routing.py index 534a3f73..02685a3f 100644 --- a/tests/test_issue1195_session_profile_routing.py +++ b/tests/test_issue1195_session_profile_routing.py @@ -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" + )