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:
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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)
|
||||
|
||||
@@ -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>/
|
||||
|
||||
|
||||
@@ -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>/*
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
103
tests/test_stage268_opus_followups.py
Normal file
103
tests/test_stage268_opus_followups.py
Normal 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}"
|
||||
)
|
||||
Reference in New Issue
Block a user