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:
1 parent
fea47bd986
commit
52bfceaa3b
9 files changed
+616
-59
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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 모드 꺼짐',
|
||||
|
||||
@@ -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"/>',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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){
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
)
|
||||
Reference in new issue
Block a user