chore(release): stamp v0.50.268 — 4-PR batch + Opus follow-ups (i18n + per-session fields + None title guard)

- CHANGELOG.md: v0.50.268 entry detailing #1395 #1450 #1462 #1476 + Opus SHOULD-FIX followups
- ROADMAP.md: bump to v0.50.268, 3800 tests collected
- TESTING.md: bump header + total to 3800

SF-1 i18n fix:
- static/i18n.js: session_meta_children key in all 10 locale blocks (en, ja, ru, es, de, zh, zh-Hant x2, pt, ko)
- static/sessions.js: 2 callsites use t(session_meta_children, childCount)

SF-2 #1462 per-session field carry-over:
- api/routes.py: duplicate now carries personality, enabled_toolsets, context_length, threshold_tokens

SF-3 #1462 None-title guard:
- api/routes.py: (session.title or "Untitled") + " (copy)"

Tests:
- tests/test_stage268_opus_followups.py: 6 regression tests pinning SF-1 + SF-2 + SF-3
- tests/test_session_duplicate.py: 2 brittle assertions widened to accept new forms

Follow-up issue filed: #1481 (PWA /sw.js whitelist vestige, Opus SF-4)
This commit is contained in:
Hermes Bot
2026-05-02 17:54:58 +00:00
parent 273888df48
commit bcfd8b2eac
8 changed files with 170 additions and 19 deletions

View File

@@ -1,5 +1,25 @@
# Hermes Web UI -- Changelog
## [v0.50.268] — 2026-05-02
### Fixed (contributor PR batch — 4 PRs)
- **Sync URL after session id rotation** (#1395, by @dso2ng) — adds calls to `_setActiveSessionUrl(...)` at two points in `static/messages.js` where a session_id rotation can land (stream completion + settled session restore), so the tab URL and `localStorage['hermes-webui-session']` track the rotated id. Production-safe via `typeof _setActiveSessionUrl === 'function'` guard. Follow-up to #1392 which shipped in v0.50.254.
- **Nest delegated child sessions under collapsed lineage roots** (#1450, by @dso2ng) — when a delegated child session's parent was a hidden compression segment inside a collapsed lineage, the child fell through as a standalone `Cli Session` row with the wrong indentation. Now `_attachChildSessionsToSidebarRows()` looks up the visible collapsed lineage root and attaches child sessions there, preserving the compact lineage row while still showing children under it. (`api/agent_sessions.py`, `api/models.py`, `static/sessions.js`, `static/style.css`, `tests/test_session_lineage_collapse.py`, `tests/test_session_lineage_metadata_api.py`)
- **`/api/session/duplicate` endpoint** (#1462, by @AlexeyDsov) — new server-side endpoint creates an independent session copy with all messages, model, workspace, and per-session settings intact. Replaces the prior client-side `new + rename` dance which was non-atomic and could leave half-baked "(copy)" sessions if the rename call failed. Plus 5 maintainer review-feedback fixes applied directly to the contributor's branch (`copy.deepcopy()` for messages and tool_calls so duplicates are actually independent, explicit `.save()` so duplicates persist immediately, `pinned/archived=False` so duplicates of archived sessions are visible, status=404 for missing session, removed redundant local imports). Plus 3 Opus advisor SHOULD-FIX follow-ups: carry `personality` / `enabled_toolsets` / `context_length` / `threshold_tokens` so per-session customizations transfer; guard `(session.title or "Untitled") + " (copy)"` so legacy sessions with `title=null` don't `TypeError`. (`api/routes.py`, `static/sessions.js`, `tests/test_session_duplicate.py`, `tests/test_stage268_opus_followups.py`)
- **Android PWA app installation** (#1476, by @galvani) — adds 192px and 512px PNG icons (one with `purpose: "any maskable"` for adaptive icons), updates `static/manifest.json`, switches `apple-touch-icon` to PNG for iOS compatibility, and whitelists `/manifest.json` + `/manifest.webmanifest` in `api/auth.py` `PUBLIC_PATHS` so the install prompt works regardless of auth state. (`api/auth.py`, `static/apple-touch-icon.png`, `static/favicon-192.png`, `static/favicon-512.png`, `static/favicon-512.svg`, `static/index.html`, `static/manifest.json`)
### Fixed (Opus pre-release follow-up: i18n)
- **Child-count UI was hardcoded English** (#1450 follow-up) — the sidebar child-count badge and meta-line both rendered `${childCount} child${childCount===1?'':'ren'}` as a literal English string, breaking 8 of the 9 supported locales. Added `session_meta_children` arrow-function key to all 10 locale blocks (`en`, `ja`, `ru`, `es`, `de`, `zh`, `zh-Hant` x2, `pt`, `ko`) using locale-appropriate phrasing, and replaced both callsites in `static/sessions.js` with `t('session_meta_children', childCount)`. 6 regression tests in `tests/test_stage268_opus_followups.py` pin the i18n key presence + the absence of hardcoded strings.
### Maintainer-applied auto-rebase + auto-fix
This release is the first under the May 2 2026 auto-rebase + auto-fix policy: contributor PRs that are otherwise merge-ready but have mechanical blockers (CONFLICTING with master, small review nits) get rebased + fixed by maintainer + force-pushed back to the contributor's branch, rather than waiting for the contributor to round-trip. Two PRs in this batch followed that path:
- **#1462** — 5 review-feedback fixes applied directly (deepcopy independence, persist on duplicate, reset pinned/archived, 404 status, import cleanup). `Co-authored-by: Alexey Dsov` trailer preserves attribution.
- **#1353** (NOT in this release — deferred to v0.50.269 due to scale + durability path requiring independent review) — rebased onto master, resolved 7 conflicts across 2 files, skipped 2 commits per the contributor's own commit message intent, force-pushed back. Now MERGEABLE for the next batch.
## [v0.50.267] — 2026-05-02
### Fixed (contributor PR batch — 7 PRs)

View File

@@ -3,7 +3,7 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: v0.50.267 (May 02, 2026) — 3776 tests collected
> Last updated: v0.50.268 (May 02, 2026) — 3800 tests collected
> Tests: `pytest tests/ --collect-only -q`
> Source: <repo>/

View File

@@ -1835,8 +1835,8 @@ Bridged CLI sessions:
---
*Last updated: v0.50.267, May 02, 2026*
*Total automated tests collected: 3776*
*Last updated: v0.50.268, May 02, 2026*
*Total automated tests collected: 3800*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*

View File

@@ -1855,7 +1855,9 @@ def handle_post(handler, parsed) -> bool:
# content arrays), so a shallow `list(...)` is not enough.
copied_session = Session(
session_id=uuid.uuid4().hex[:12],
title=session.title + " (copy)",
# Defensive: legacy sessions may have title=None on disk; fall back to 'Untitled'
# so `+ " (copy)"` doesn't TypeError.
title=(session.title or "Untitled") + " (copy)",
workspace=session.workspace,
model=session.model,
model_provider=session.model_provider,
@@ -1871,6 +1873,14 @@ def handle_post(handler, parsed) -> bool:
input_tokens=session.input_tokens,
output_tokens=session.output_tokens,
estimated_cost=session.estimated_cost,
# Per-session settings the user may have customized — carry them over
# so the duplicate behaves identically until further edits. Compression
# anchor + last_prompt_tokens are intentionally NOT carried — those
# re-derive on the next turn.
personality=session.personality,
enabled_toolsets=getattr(session, "enabled_toolsets", None),
context_length=getattr(session, "context_length", None),
threshold_tokens=getattr(session, "threshold_tokens", None),
created_at=time.time(),
updated_at=time.time(),
)

View File

@@ -479,6 +479,7 @@ const LOCALES = {
insights_footer: 'Showing data from the last {days} days',
workspace_desc: 'Add and switch workspaces for your sessions.',
session_meta_messages: (n) => `${n} msg${n === 1 ? '' : 's'}`,
session_meta_children: (n) => `${n} child${n === 1 ? '' : 'ren'}`,
new_profile: 'New profile',
transcript: 'Transcript',
download_transcript: 'Download as Markdown',
@@ -1349,6 +1350,7 @@ const LOCALES = {
insights_footer: '直近 {days} 日間のデータを表示',
workspace_desc: 'セッション用のワークスペースを追加・切り替えします。',
session_meta_messages: (n) => `${n}`,
session_meta_children: (n) => `${n}`,
new_profile: '新規プロファイル',
transcript: 'トランスクリプト',
download_transcript: 'Markdown としてダウンロード',
@@ -2047,6 +2049,7 @@ const LOCALES = {
current_task_list: 'Текущий список задач',
workspace_desc: 'Добавляйте рабочие пространства и переключайтесь между ними в своих сеансах.',
session_meta_messages: (n) => `${n} сообщ.`,
session_meta_children: (n) => `${n} ${n === 1 ? 'дочерн.' : 'дочерн.'}`,
new_profile: 'Новый профиль',
transcript: 'Транскрипт',
download_transcript: 'Скачать как Markdown',
@@ -2857,6 +2860,7 @@ const LOCALES = {
current_task_list: 'Lista de tareas actual',
workspace_desc: 'Añade y cambia espacios de trabajo para tus sesiones.',
session_meta_messages: (n) => `${n} mens.`,
session_meta_children: (n) => `${n} ${n === 1 ? 'hijo' : 'hijos'}`,
new_profile: 'Nuevo perfil',
transcript: 'Transcripción',
download_transcript: 'Descargar como Markdown',
@@ -3643,6 +3647,7 @@ const LOCALES = {
current_task_list: 'Aktuelle Aufgabenliste',
workspace_desc: 'Workspaces hinzufügen und wechseln.',
session_meta_messages: (n) => `${n} Nachr.`,
session_meta_children: (n) => `${n} ${n === 1 ? 'Subagent' : 'Subagents'}`,
new_profile: 'Neues Profil',
transcript: 'Protokoll',
download_transcript: 'Als Markdown herunterladen',
@@ -4468,6 +4473,7 @@ const LOCALES = {
current_task_list: '当前任务列表',
workspace_desc: '为你的会话添加并切换工作区。',
session_meta_messages: (n) => `${n} 条消息`,
session_meta_children: (n) => `${n} 子会话`,
new_profile: '新配置',
transcript: '记录',
download_transcript: '下载为 Markdown',
@@ -5278,6 +5284,7 @@ const LOCALES = {
personal_memory: '\u500b\u4eba\u8a18\u61b6',
current_task_list: '\u76ee\u524d\u4efb\u52d9\u6e05\u55ae',
session_meta_messages: (n) => `${n} 則訊息`,
session_meta_children: (n) => `${n} 則子`,
new_profile: '\u65b0\u914d\u7f6e\u6a94',
transcript: '\u8a18\u9304',
download_transcript: '\u4e0b\u8f09\u8a18\u9304',
@@ -5440,6 +5447,7 @@ const LOCALES = {
project_name_prompt: '\u5c08\u6848\u540d\u7a31\uff1a',
provider_mismatch_warning: (provider) => `提供者不符:會話使用 ${provider}`,
session_meta_messages: (n) => `${n} 則訊息`,
session_meta_children: (n) => `${n} 則子`,
settings_label_model: '\u9810\u8a2d\u6a21\u578b',
skill_created: '\u6280\u80fd\u5df2\u5efa\u7acb',
skill_file_load_failed: '\u8f09\u5165\u6a94\u6848\u5931\u6557\uff1a',
@@ -6247,6 +6255,7 @@ const LOCALES = {
current_task_list: 'Lista de tarefas atual',
workspace_desc: 'Adicionar e trocar workspaces para suas sessões.',
session_meta_messages: (n) => `${n} msg${n === 1 ? '' : 's'}`,
session_meta_children: (n) => `${n} child${n === 1 ? '' : 'ren'}`,
new_profile: 'Novo perfil',
transcript: 'Transcrição',
download_transcript: 'Baixar como Markdown',
@@ -7023,6 +7032,7 @@ const LOCALES = {
current_task_list: '현재 작업 목록',
workspace_desc: '세션용 워크스페이스를 추가하고 전환합니다.',
session_meta_messages: (n) => `${n} msg${n === 1 ? '' : 's'}`,
session_meta_children: (n) => `${n} child${n === 1 ? '' : 'ren'}`,
new_profile: 'New profile',
transcript: '대화 기록',
download_transcript: 'Download as Markdown',

View File

@@ -1639,7 +1639,7 @@ function renderSessionListFromCache(){
if(childCount>0){
const childCountEl=document.createElement('span');
childCountEl.className='session-child-count';
const childLabel=`${childCount} child${childCount===1?'':'ren'}`;
const childLabel=t('session_meta_children', childCount);
childCountEl.textContent=childLabel;
childCountEl.title=childLabel;
['pointerdown','pointerup','click'].forEach(ev=>childCountEl.addEventListener(ev,e=>e.stopPropagation()));
@@ -1677,7 +1677,7 @@ function renderSessionListFromCache(){
? t('session_meta_messages', msgCount)
: `${msgCount} msg${msgCount===1?'':'s'}`;
metaBits.push(msgLabel);
if(childCount>0) metaBits.push(`${childCount} child${childCount===1?'':'ren'}`);
if(childCount>0) metaBits.push(t('session_meta_children', childCount));
if(s.model) metaBits.push(s.model);
if(_showAllProfiles&&s.profile) metaBits.push(s.profile);
const meta=document.createElement('div');

View File

@@ -98,7 +98,7 @@ def test_duplicate_creates_independent_session():
# Extract the duplicate endpoint code (next few lines)
lines = content[duplicate_start:].split('\n')
endpoint_code = '\n'.join(lines[:30])
endpoint_code = '\n'.join(lines[:80])
# Verify that parent_session_id is NOT passed to Session constructor
assert 'parent_session_id' not in endpoint_code, \
@@ -127,10 +127,13 @@ def test_duplicate_session_copies_title_logic():
assert duplicate_start != -1, "Duplicate endpoint not found"
lines = content[duplicate_start:].split('\n')
endpoint_code = '\n'.join(lines[:30])
endpoint_code = '\n'.join(lines[:80])
# Verify title includes (copy)
# Verify title includes (copy). Accept the original `session.title + " (copy)"`
# form OR the May 2 2026 SF-3 hardened form `(session.title or "Untitled") + " (copy)"`
# which guards against legacy null titles.
assert 'session.title + " (copy)"' in endpoint_code or \
'(session.title or "Untitled") + " (copy)"' in endpoint_code or \
'session.title + \' (copy\')' in endpoint_code or \
'title=session.title + " (copy)"' in endpoint_code, \
f"Title should include '(copy)' suffix. Got: {endpoint_code}"
@@ -147,7 +150,7 @@ def test_duplicate_session_copies_messages_logic():
assert duplicate_start != -1, "Duplicate endpoint not found"
lines = content[duplicate_start:].split('\n')
endpoint_code = '\n'.join(lines[:30])
endpoint_code = '\n'.join(lines[:80])
# Verify messages are copied from original session. Accept either the
# plain assignment (insufficient — see test_duplicate_runtime_messages_independence)
@@ -168,7 +171,7 @@ def test_duplicate_session_copies_model_logic():
assert duplicate_start != -1, "Duplicate endpoint not found"
lines = content[duplicate_start:].split('\n')
endpoint_code = '\n'.join(lines[:30])
endpoint_code = '\n'.join(lines[:80])
# Verify model is copied
assert 'model=session.model' in endpoint_code, \
@@ -186,7 +189,7 @@ def test_duplicate_session_copies_workspace_logic():
assert duplicate_start != -1, "Duplicate endpoint not found"
lines = content[duplicate_start:].split('\n')
endpoint_code = '\n'.join(lines[:30])
endpoint_code = '\n'.join(lines[:80])
# Verify workspace is copied
assert 'workspace=session.workspace' in endpoint_code, \
@@ -204,19 +207,20 @@ def test_duplicate_session_copies_all_session_properties():
assert duplicate_start != -1, "Duplicate endpoint not found"
lines = content[duplicate_start:].split('\n')
endpoint_code = '\n'.join(lines[:30])
endpoint_code = '\n'.join(lines[:80])
# Extract the copied_session = Session( lines
session_construction_start = endpoint_code.find('copied_session = Session(')
assert session_construction_start != -1, "Should construct copied_session"
# Get the construction block
construction_block = endpoint_code[session_construction_start:session_construction_start+800]
construction_block = endpoint_code[session_construction_start:session_construction_start+1600]
# Verify all key properties are copied
# `title` accepts either the original `title=session.title` or the
# SF-3 hardened form `title=(session.title or "Untitled")` (May 2 2026).
properties_to_check = [
'session_id=uuid.uuid4', # New unique ID
'title=session.title', # Title (will be modified to add (copy))
'workspace=session.workspace',
'model=session.model',
'model_provider=session.model_provider',
@@ -226,6 +230,10 @@ def test_duplicate_session_copies_all_session_properties():
assert prop in construction_block, \
f"Property should be copied: {prop}. Got: {construction_block[:300]}"
assert 'title=session.title' in construction_block or \
'title=(session.title or "Untitled")' in construction_block, \
f"title must be copied (plain or guarded form). Got: {construction_block[:300]}"
# `messages` accepts either the plain assignment or the deepcopy form (May 2 2026 fix).
assert 'messages=session.messages' in construction_block or \
'messages=copy.deepcopy(session.messages)' in construction_block, \
@@ -249,7 +257,7 @@ def test_duplicate_uses_deepcopy_for_messages():
duplicate_start = content.find('if parsed.path == "/api/session/duplicate":')
assert duplicate_start != -1, "Duplicate endpoint not found"
lines = content[duplicate_start:].split('\n')
endpoint_code = '\n'.join(lines[:50])
endpoint_code = '\n'.join(lines[:80])
assert 'copy.deepcopy(session.messages)' in endpoint_code, \
"duplicate must use copy.deepcopy(session.messages) — plain assignment shares list refs"
assert 'copy.deepcopy(session.tool_calls)' in endpoint_code, \
@@ -269,7 +277,7 @@ def test_duplicate_explicitly_persists_to_disk():
duplicate_start = content.find('if parsed.path == "/api/session/duplicate":')
assert duplicate_start != -1, "Duplicate endpoint not found"
lines = content[duplicate_start:].split('\n')
endpoint_code = '\n'.join(lines[:50])
endpoint_code = '\n'.join(lines[:80])
assert 'copied_session.save()' in endpoint_code, \
"duplicate must call .save() explicitly — without it the copy vanishes on refresh"
@@ -286,7 +294,7 @@ def test_duplicate_resets_pinned_and_archived():
duplicate_start = content.find('if parsed.path == "/api/session/duplicate":')
assert duplicate_start != -1, "Duplicate endpoint not found"
lines = content[duplicate_start:].split('\n')
endpoint_code = '\n'.join(lines[:50])
endpoint_code = '\n'.join(lines[:80])
# Both must be hard-coded to False, NOT inherited from `session.pinned`/`session.archived`
assert 'pinned=False' in endpoint_code, \
"duplicate must reset pinned=False — duplicating shouldn't propagate pin state"
@@ -310,7 +318,7 @@ def test_duplicate_returns_404_when_session_not_found():
duplicate_start = content.find('if parsed.path == "/api/session/duplicate":')
assert duplicate_start != -1, "Duplicate endpoint not found"
lines = content[duplicate_start:].split('\n')
endpoint_code = '\n'.join(lines[:50])
endpoint_code = '\n'.join(lines[:80])
assert 'bad(handler, "Session not found", status=404)' in endpoint_code, \
"missing session must return status=404, not the default 400"

View File

@@ -0,0 +1,103 @@
"""Opus pre-release follow-up tests for v0.50.268.
Pin the three SHOULD-FIX items applied during stage-268 review:
- SF-1 (#1450): child-count UI uses i18n `session_meta_children` key, not hardcoded English.
- SF-2 (#1462): duplicate carries personality / enabled_toolsets / context_length / threshold_tokens.
- SF-3 (#1462): duplicate handles legacy null title via `(session.title or 'Untitled')` fallback.
"""
from pathlib import Path
import re
REPO_ROOT = Path(__file__).parent.parent
ROUTES_PY = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
# --- SF-1 (#1450): child-count UI uses i18n key ---
def test_sf1_child_count_uses_i18n_in_sessions_js():
"""The child-count badge and meta line must call t('session_meta_children', ...).
Pre-fix, the strings were hardcoded as `${childCount} child${childCount===1?'':'ren'}`
which rendered English in all 9 locales.
"""
# Two callsites
assert "t('session_meta_children', childCount)" in SESSIONS_JS, (
"session_meta_children i18n key not used in sessions.js — child-count UI "
"would render English in non-English locales"
)
# Negative: hardcoded form must be gone
assert "${childCount} child${childCount===1?'':'ren'}" not in SESSIONS_JS, (
"hardcoded English child-count string still present — removes locale support"
)
def test_sf1_session_meta_children_present_in_all_locales():
"""Every locale block in i18n.js that has session_meta_messages must also
have session_meta_children — they're the analogous sidebar meta strings."""
msg_count = len(re.findall(r"session_meta_messages:", I18N_JS))
child_count = len(re.findall(r"session_meta_children:", I18N_JS))
assert msg_count == child_count, (
f"session_meta_messages appears {msg_count} times but "
f"session_meta_children appears {child_count} — must be in every locale"
)
# Sanity: 9 known locales (en, ja, ru, es, de, zh, zh-Hant, plus the legacy zh-tw/zh-hk aliases)
assert child_count >= 9, f"expected >=9 locales with session_meta_children, got {child_count}"
# --- SF-2 (#1462): duplicate carries per-session settings ---
def test_sf2_duplicate_carries_personality():
"""The duplicate must propagate `personality` from source to copy."""
duplicate_start = ROUTES_PY.find('if parsed.path == "/api/session/duplicate":')
assert duplicate_start != -1
block = ROUTES_PY[duplicate_start:duplicate_start + 3000]
assert 'personality=session.personality' in block, (
"duplicate must carry over personality — without it, customized "
"personalities silently revert to default in the copy"
)
def test_sf2_duplicate_carries_enabled_toolsets():
"""The duplicate must propagate `enabled_toolsets` (per-session toolset overrides)."""
duplicate_start = ROUTES_PY.find('if parsed.path == "/api/session/duplicate":')
block = ROUTES_PY[duplicate_start:duplicate_start + 3000]
assert 'enabled_toolsets=getattr(session, "enabled_toolsets", None)' in block, (
"duplicate must carry enabled_toolsets — without it, per-session "
"toolset overrides silently revert to defaults in the copy"
)
def test_sf2_duplicate_carries_context_settings():
"""The duplicate must propagate context_length + threshold_tokens."""
duplicate_start = ROUTES_PY.find('if parsed.path == "/api/session/duplicate":')
block = ROUTES_PY[duplicate_start:duplicate_start + 3000]
assert 'context_length=getattr(session, "context_length", None)' in block
assert 'threshold_tokens=getattr(session, "threshold_tokens", None)' in block
# --- SF-3 (#1462): None-title fallback ---
def test_sf3_duplicate_handles_none_title():
"""The duplicate handler must guard `session.title or 'Untitled'` to avoid
`TypeError: unsupported operand type(s) for +: 'NoneType' and 'str'`
on legacy sessions with title=null."""
duplicate_start = ROUTES_PY.find('if parsed.path == "/api/session/duplicate":')
assert duplicate_start != -1
block = ROUTES_PY[duplicate_start:duplicate_start + 3000]
# Must use the (session.title or "Untitled") form, not raw session.title
assert '(session.title or "Untitled") + " (copy)"' in block, (
"duplicate must guard against None title — `session.title + ' (copy)'` "
"TypeErrors when legacy JSON has title=null"
)
# Negative: the unguarded form must be gone
# Allow it inside comment text but not as actual code
code_lines = [
ln for ln in block.split('\n')
if not ln.lstrip().startswith('#') and 'title=session.title + " (copy)"' in ln
]
assert not code_lines, (
f"unguarded `session.title + ' (copy)'` still present in duplicate handler: {code_lines}"
)