diff --git a/CHANGELOG.md b/CHANGELOG.md index 3305c5be..1137235b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ### Fixed +- **PR #2227** by @theh4v0c (closes #2223 — critical) — Context compression no longer destroys session history. The previous implementation renamed `old_sid.json` → `new_sid.json` before the new compressed session had been saved, destroying the only persistent copy of the full conversation. When the summarisation LLM call also failed, the user was left with zero recoverable messages and the bug report `Summary generation was unavailable. N message(s) were removed to free context space but could not be summarized.` text with no way to scroll back. The fix removes the destructive `old_path.rename(new_path)` call: `old_sid.json` is preserved intact as an immutable pre-compression archive, `new_sid.json` is created fresh via `s.save()`, and `parent_session_id` is set on the continuation session so the frontend can traverse the lineage chain back to the original. Even when summarisation or `s.save()` fails, the original conversation file survives on disk. New 106-line regression test file covers the no-rename invariant, parent_session_id stamping, and marker-only-result preservation. Stage-353 Opus SHOULD-FIX applied inline: the preservation block previously cleared `s.parent_session_id` before saving the snapshot (writing `parent=None` to `old_sid.json` on disk) and used a `if not s.parent_session_id` guard when stamping the continuation. Both bugs broke fork-of-fork compression lineage — a `/branch` fork that subsequently compressed would lose its "Forked from X" badge on the snapshot AND the continuation would skip past the snapshot back to the original fork parent. Maintainer fix removes the parent clearing during preservation (preserves the fork lineage on disk) and drops the `if not` guard (always stamps continuation to `old_sid`). Two new regression tests pin both invariants. Traversal now consistently walks new → old → old.parent → ... root. + +- **PR #2222** by @franksong2702 — Settings → Appearance now wraps the "Load older messages while scrolling up" checkbox in its own ``. Stage-353 maintainer resolution adopted PR #2227's stronger structural variant (each preference in its own `settings-field`) over PR #2222's smaller in-place wrap. Regression test `test_session_endless_scroll.py` pins the new per-label per-settings-field contract. + +### Added + +- **PR #2225** by @franksong2702 (refs #2224) — Adds an Extra Large option to Settings → Appearance → Font size for tablet and large-desktop readability. The new `xlarge` value is accepted by the persisted settings contract, appears alongside the existing Small / Default / Large picker options, and scales the same key UI text surfaces already covered by the font-size preference: sidebar session rows, chat message bodies/headings/code/tables, the composer textarea, workspace file rows, and app-level em/rem text. The picker grid now uses `repeat(auto-fit, minmax(96px,1fr))` instead of a fixed 3-column grid so the fourth option doesn't crowd narrow viewports. + +## [v0.51.59] — 2026-05-14 — Release AI (stage-352 — 4-PR clean batch — _summary_cache LRU cap + re.MULTILINE strip fix + Compact sidebar lineage hide + CONTRIBUTORS/README refresh) + +### Fixed + - **PR #2217** by @franksong2702 (refs #2215 Fix B) — Drops the leftover `re.MULTILINE` flag from the "the user is asking" pre-amble strip pattern in `api/streaming.py:695`. PR #2213 removed `re.MULTILINE` from the three sibling wrapper-strip patterns (``, MiniMax, Gemma) but missed this one instance. With `re.MULTILINE`, `^` matched the start of any line in the response, so a mid-response line that legitimately started with "The user is asking us to wait" could be stripped silently. Now the pattern only matches when the entire response leads with that wrapper, consistent with the other strips. One-flag, two-character change + regression test pinning the new behavior. - **PR #2216** by @franksong2702 (closes #2215 Fix A) — Caps the `_summary_cache` for per-target update summaries with an `OrderedDict`-backed LRU bounded at 16 entries. Pre-fix the cache was an unbounded plain dict introduced in PR #2207; cardinality is small in practice (0-2 active update ranges per server lifetime) so this is defensive future-proofing rather than a leak being hit today. Cache hits call `move_to_end()` to refresh recency; cache writes call `popitem(last=False)` to evict the oldest entry when at capacity. Overwrites of existing keys bypass eviction. Both operations run under the existing `_cache_lock` for thread safety. With Fix A and Fix B both shipped, issue #2215 is closed. @@ -18,8 +30,6 @@ ### Fixed -- **PR #2219** by @franksong2702 (refs #2218) — Compact sidebar density no longer shows compressed-session prior-turn lineage badges or expandable lineage segment rows. The sidebar still collapses compressed continuations to the latest tip, but the advanced lineage metadata now appears only in Detailed density so the default session list stays focused on ordinary session switching. - - **PR #2210** by @Jordan-SkyLF — MCP Tools list in Settings → System no longer renders an unbounded inventory that makes the settings panel scroll-trapping. Added a toolbar (result-count summary, page-size 5/10/20/50/all, search input), bounded scroll area with consistent height, paginated rendering, and focused regression coverage for the large-inventory case. Existing WebUI-only/runtime-only contract preserved (no MCP server probing, no agent-side changes). Visual before/after evidence shipped under `docs/pr-media/2210/`. - **PR #2213** by @franksong2702 (fixes #2152) — Literal ``/`` discussions in normal assistant prose are no longer stripped from saved messages and re-renders. The old server cleanup and stored-message render regexes stripped the first closed thinking-looking block anywhere in the content. PR aligns saved/static paths with the existing streaming rule: provider reasoning wrappers (`...`, MiniMax `<|channel>thought...`, Gemma 4 `<|turn|>thinking...`) are stripped only when they lead the response (i.e. the wrapper is the first non-whitespace content). diff --git a/api/config.py b/api/config.py index 223df061..d0c047bc 100644 --- a/api/config.py +++ b/api/config.py @@ -3912,7 +3912,7 @@ _SETTINGS_DEFAULTS = { "whats_new_summary_enabled": False, # show an LLM-written What's New summary before diff links "theme": "dark", # light | dark | system "skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard - "font_size": "default", # small | default | large + "font_size": "default", # small | default | large | xlarge "session_jump_buttons": False, # show Start/End transcript jump pills "session_endless_scroll": False, # auto-load older transcript pages while scrolling upward "language": "en", # UI locale code; must match a key in static/i18n.js LOCALES @@ -4027,7 +4027,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - { _SETTINGS_ENUM_VALUES = { "send_key": {"enter", "ctrl+enter"}, "sidebar_density": {"compact", "detailed"}, - "font_size": {"small", "default", "large"}, + "font_size": {"small", "default", "large", "xlarge"}, "auto_title_refresh_every": {"0", "5", "10", "20"}, "busy_input_mode": {"queue", "interrupt", "steer"}, } diff --git a/api/streaming.py b/api/streaming.py index 0d1334e0..0c7b0236 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -3567,7 +3567,6 @@ def _run_agent_streaming( if _agent_sid and _agent_sid != session_id: old_sid = session_id new_sid = _agent_sid - # Rename the session file old_path = SESSION_DIR / f'{old_sid}.json' new_path = SESSION_DIR / f'{new_sid}.json' s.session_id = new_sid @@ -3587,6 +3586,62 @@ def _run_agent_streaming( "Stamped profile=%r on continuation session %s after compression", _resolved_profile_name, new_sid, ) + # Preserve the original session file so the full pre-compression + # history survives even when summarisation fails. The previous + # implementation renamed old_sid.json → new_sid.json, which + # destroyed the only persistent copy of the uncompressed history + # before the new (possibly summary-only) session had been saved. + # If the LLM summariser also failed, the user was left with zero + # recoverable messages. (#2223) + # --- + # Archive the old session: write its current state to disk so + # the full conversation history survives even when context + # compression removes messages from the model's context. Skip + # the write when the file already contains up-to-date data + # (i.e. it was just saved by a checkpoint). + if old_path.exists(): + try: + existing_text = old_path.read_text(encoding='utf-8') + try: + existing = json.loads(existing_text) + existing_msgs = len(existing.get('messages') or []) + except (json.JSONDecodeError, ValueError): + existing_msgs = -1 + if len(s.messages) > existing_msgs: + # In-memory messages are newer than the file — save. + # Preserve s.parent_session_id as-is (do NOT clear it): + # the OLD session's parent_session_id is its own + # pre-existing lineage (e.g. a /branch fork's + # parent_session_id back to the original). We must + # not overwrite that with None on disk. Stage-353 + # Opus SHOULD-FIX: previous code cleared parent + # before save then restored after — but the on-disk + # copy persisted with parent=None, breaking + # fork-of-fork lineage traversal. (#2227 + #2223) + saved_sid = s.session_id + s.session_id = old_sid + try: + s.save(touch_updated_at=False, skip_index=True) + logger.info( + "Preserved pre-compression session %s (%d messages) to disk", + old_sid, len(s.messages), + ) + except Exception: + logger.debug("Failed to preserve pre-compression session file", exc_info=True) + finally: + s.session_id = saved_sid + except OSError: + logger.debug("Could not read old session file before preservation") + # Always link the continuation session to its immediate predecessor + # (the preserved snapshot). This OVERRIDES any prior + # parent_session_id because the new continuation IS the next link + # in the chain: traversal walks new → old → old.parent → ... root. + # Stage-353 Opus SHOULD-FIX: previous `if not s.parent_session_id` + # guard skipped this stamp on fork-of-fork compressions, so a + # subsequent traversal from the new continuation would jump + # over the just-preserved snapshot back to the original fork + # parent, losing access to the recoverable history in old_sid.json. + s.parent_session_id = old_sid with LOCK: if old_sid in SESSIONS: SESSIONS[new_sid] = SESSIONS.pop(old_sid) @@ -3603,11 +3658,6 @@ def _run_agent_streaming( _cached_entry = SESSION_AGENT_CACHE.pop(old_sid, None) if _cached_entry: SESSION_AGENT_CACHE[new_sid] = _cached_entry - if old_path.exists() and not new_path.exists(): - try: - old_path.rename(new_path) - except OSError: - logger.debug("Failed to rename session file during compression") _compressed = True # Also detect compression via the result dict or compressor state if not _compressed: diff --git a/docs/pr-media/2224/after-session-extra-large.png b/docs/pr-media/2224/after-session-extra-large.png new file mode 100644 index 00000000..ae7e7855 Binary files /dev/null and b/docs/pr-media/2224/after-session-extra-large.png differ diff --git a/docs/pr-media/2224/before-session-large.png b/docs/pr-media/2224/before-session-large.png new file mode 100644 index 00000000..014d7130 Binary files /dev/null and b/docs/pr-media/2224/before-session-large.png differ diff --git a/docs/pr-media/appearance-endless-scroll-label/after-label.png b/docs/pr-media/appearance-endless-scroll-label/after-label.png new file mode 100644 index 00000000..a1ec7f09 Binary files /dev/null and b/docs/pr-media/appearance-endless-scroll-label/after-label.png differ diff --git a/docs/pr-media/appearance-endless-scroll-label/before-label.png b/docs/pr-media/appearance-endless-scroll-label/before-label.png new file mode 100644 index 00000000..e590c61e Binary files /dev/null and b/docs/pr-media/appearance-endless-scroll-label/before-label.png differ diff --git a/static/i18n.js b/static/i18n.js index fb0b0944..1c6321e7 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -516,6 +516,7 @@ const LOCALES = { font_size_small: 'Small', font_size_default: 'Default', font_size_large: 'Large', + font_size_xlarge: 'Extra Large', settings_autosave_saving: 'Saving…', settings_autosave_saved: 'Saved', settings_autosave_failed: 'Save failed', @@ -1657,6 +1658,7 @@ const LOCALES = { font_size_small: 'Piccolo', font_size_default: 'Predefinito', font_size_large: 'Grande', + font_size_xlarge: 'Molto grande', settings_autosave_saving: 'Salvataggio…', settings_autosave_saved: 'Salvato', settings_autosave_failed: 'Salvataggio fallito', @@ -2790,6 +2792,7 @@ const LOCALES = { font_size_small: '小', font_size_default: 'デフォルト', font_size_large: '大', + font_size_xlarge: '特大', settings_autosave_saving: '保存中…', settings_autosave_saved: '保存しました', settings_autosave_failed: '保存失敗', @@ -3663,6 +3666,7 @@ const LOCALES = { font_size_small: 'Маленький', font_size_default: 'Стандарт', font_size_large: 'Большой', + font_size_xlarge: 'Очень большой', settings_autosave_saving: 'Сохранение…', settings_autosave_saved: 'Сохранено', settings_autosave_failed: 'Не удалось сохранить', @@ -4810,6 +4814,7 @@ const LOCALES = { font_size_small: 'Pequeño', font_size_default: 'Por defecto', font_size_large: 'Grande', + font_size_xlarge: 'Extra grande', settings_autosave_saving: 'Guardando…', settings_autosave_saved: 'Guardado', settings_autosave_failed: 'Error al guardar', @@ -5865,6 +5870,7 @@ const LOCALES = { font_size_small: 'Small', font_size_default: 'Default', font_size_large: 'Large', + font_size_xlarge: 'Extra Large', settings_autosave_saving: 'Wird gespeichert…', settings_autosave_saved: 'Gespeichert', settings_autosave_failed: 'Speichern fehlgeschlagen', @@ -6961,6 +6967,7 @@ const LOCALES = { font_size_small: '小', font_size_default: '默认', font_size_large: '大', + font_size_xlarge: '超大', settings_autosave_saving: '保存中…', settings_autosave_saved: '已保存', settings_autosave_failed: '保存失败', @@ -8042,6 +8049,7 @@ const LOCALES = { font_size_small: '\u5c0f', font_size_default: '\u9810\u8a2d', font_size_large: '\u5927', + font_size_xlarge: '\u8d85\u5927', settings_autosave_saving: '\u5132\u5b58\u4e2d…', settings_autosave_saved: '\u5df2\u5132\u5b58', settings_autosave_failed: '\u5132\u5b58\u5931\u6557', @@ -9260,6 +9268,7 @@ const LOCALES = { font_size_small: 'Pequeno', font_size_default: 'Padrão', font_size_large: 'Grande', + font_size_xlarge: 'Extra grande', settings_autosave_saving: 'Salvando…', settings_autosave_saved: 'Salvo', settings_autosave_failed: 'Falha ao salvar', @@ -10297,6 +10306,7 @@ const LOCALES = { font_size_small: '작게', font_size_default: '기본', font_size_large: '크게', + font_size_xlarge: '매우 크게', settings_autosave_saving: '저장 중…', settings_autosave_saved: '저장됨', settings_autosave_failed: '저장 실패', @@ -11350,6 +11360,7 @@ const LOCALES = { font_size_small: 'Petit', font_size_default: 'Défaut', font_size_large: 'Grand', + font_size_xlarge: 'Très grand', settings_autosave_saving: 'Économie…', settings_autosave_saved: 'Enregistré', settings_autosave_failed: 'Échec de l\'enregistrement', diff --git a/static/index.html b/static/index.html index 424e9c44..84e4c7c8 100644 --- a/static/index.html +++ b/static/index.html @@ -858,7 +858,7 @@
-
+
+
@@ -893,6 +899,9 @@ Show session jump buttons
Show floating Start and End buttons while reading long session histories.
+
+
+ diff --git a/static/style.css b/static/style.css index 9f955d3f..77f22e2b 100644 --- a/static/style.css +++ b/static/style.css @@ -19,7 +19,7 @@ } /* ── Font size modifiers ── */ /* ── Font size preference: scale key UI text elements ── */ - /* Default is 14px (no attribute needed). Small=12px, Large=16px. */ + /* Default is 14px (no attribute needed). Small=12px, Large=16px, Extra Large=18px. */ /* We override the px values directly on key containers since most of the */ /* stylesheet uses hardcoded px — changing :root font-size alone only affects */ /* the small number of em/rem values. */ @@ -27,44 +27,60 @@ /* Sidebar session list */ :root[data-font-size="small"] .session-item { font-size: 11px; } :root[data-font-size="large"] .session-item { font-size: 15px; } + :root[data-font-size="xlarge"] .session-item { font-size: 17px; } :root[data-font-size="small"] .session-meta { font-size: 10px; } :root[data-font-size="large"] .session-meta { font-size: 13px; } + :root[data-font-size="xlarge"] .session-meta { font-size: 15px; } :root[data-font-size="small"] .session-title-input { font-size: 11px; } :root[data-font-size="large"] .session-title-input { font-size: 15px; } + :root[data-font-size="xlarge"] .session-title-input { font-size: 17px; } /* Chat message body */ :root[data-font-size="small"] .msg-body { font-size: 12px; } :root[data-font-size="large"] .msg-body { font-size: 16px; } + :root[data-font-size="xlarge"] .msg-body { font-size: 18px; } :root[data-font-size="small"] .msg-body h1 { font-size: 21px; } :root[data-font-size="large"] .msg-body h1 { font-size: 27px; } + :root[data-font-size="xlarge"] .msg-body h1 { font-size: 30px; } :root[data-font-size="small"] .msg-body h2 { font-size: 17px; } :root[data-font-size="large"] .msg-body h2 { font-size: 23px; } + :root[data-font-size="xlarge"] .msg-body h2 { font-size: 26px; } :root[data-font-size="small"] .msg-body h3 { font-size: 15px; } :root[data-font-size="large"] .msg-body h3 { font-size: 20px; } + :root[data-font-size="xlarge"] .msg-body h3 { font-size: 22px; } :root[data-font-size="small"] .msg-body h4 { font-size: 13px; } :root[data-font-size="large"] .msg-body h4 { font-size: 17px; } + :root[data-font-size="xlarge"] .msg-body h4 { font-size: 19px; } :root[data-font-size="small"] .msg-body h5 { font-size: 12px; } :root[data-font-size="large"] .msg-body h5 { font-size: 16px; } + :root[data-font-size="xlarge"] .msg-body h5 { font-size: 18px; } :root[data-font-size="small"] .msg-body h6 { font-size: 11px; } :root[data-font-size="large"] .msg-body h6 { font-size: 15px; } + :root[data-font-size="xlarge"] .msg-body h6 { font-size: 17px; } :root[data-font-size="small"] .msg-body code { font-size: 10.5px; } :root[data-font-size="large"] .msg-body code { font-size: 14.5px; } + :root[data-font-size="xlarge"] .msg-body code { font-size: 16px; } :root[data-font-size="small"] .msg-body pre code { font-size: 11px; } :root[data-font-size="large"] .msg-body pre code { font-size: 15px; } + :root[data-font-size="xlarge"] .msg-body pre code { font-size: 16.5px; } :root[data-font-size="small"] .msg-body table { font-size: 11px; } :root[data-font-size="large"] .msg-body table { font-size: 14px; } + :root[data-font-size="xlarge"] .msg-body table { font-size: 16px; } /* Composer textarea (default is 16px in stylesheet) */ :root[data-font-size="small"] #msg { font-size: 14px; } :root[data-font-size="large"] #msg { font-size: 18px; } + :root[data-font-size="xlarge"] #msg { font-size: 20px; } /* Workspace file tree */ :root[data-font-size="small"] .file-item { font-size: 11px; } :root[data-font-size="large"] .file-item { font-size: 14px; } + :root[data-font-size="xlarge"] .file-item { font-size: 16px; } /* App-level base — keeps em/rem values scaling correctly */ :root[data-font-size="small"] { font-size: 12px; } :root[data-font-size="large"] { font-size: 16px; } + :root[data-font-size="xlarge"] { font-size: 18px; } /* ── Dark mode — navy-black + gold accent matching Hermes terminal ── */ :root.dark { --bg:#0D0D1A;--sidebar:#141425;--border:#2A2A45;--border2:rgba(255,255,255,0.14); diff --git a/tests/test_font_size_setting.py b/tests/test_font_size_setting.py index e3b049fb..ed01e4ff 100644 --- a/tests/test_font_size_setting.py +++ b/tests/test_font_size_setting.py @@ -1,4 +1,4 @@ -"""Tests for font size setting (#833) — 3-toggle Small/Default/Large in Appearance.""" +"""Tests for font size setting (#833) — Small/Default/Large/Extra Large in Appearance.""" import os import re @@ -9,7 +9,7 @@ def _read(name): class TestFontSizeCssModifiers: - """CSS must define font-size overrides for small and large via data attribute.""" + """CSS must define font-size overrides via data attribute.""" def test_small_font_size_rule_exists(self): css = _read("static/style.css") @@ -23,14 +23,26 @@ class TestFontSizeCssModifiers: "style.css must have :root[data-font-size=\"large\"] font-size rule" ) - def test_small_is_smaller_than_default(self): + def test_extra_large_font_size_rule_exists(self): + css = _read("static/style.css") + assert 'data-font-size="xlarge"' in css, ( + "style.css must have :root[data-font-size=\"xlarge\"] font-size rule" + ) + + def test_small_large_and_xlarge_scale_from_default(self): css = _read("static/style.css") # Match both compact {font-size:12px} and spaced { font-size: 12px; } formats m_small = re.search(r':root\[data-font-size="small"\][^{]*\{[^}]*font-size:\s*(\d+)px', css) m_large = re.search(r':root\[data-font-size="large"\][^{]*\{[^}]*font-size:\s*(\d+)px', css) - assert m_small and m_large, "Both small and large font-size rules must set px values" + m_xlarge = re.search(r':root\[data-font-size="xlarge"\][^{]*\{[^}]*font-size:\s*(\d+)px', css) + assert m_small and m_large and m_xlarge, ( + "Small, large, and extra-large font-size rules must set px values" + ) assert int(m_small.group(1)) < 14, "Small font size must be < 14px (default)" assert int(m_large.group(1)) > 14, "Large font size must be > 14px (default)" + assert int(m_xlarge.group(1)) > int(m_large.group(1)), ( + "Extra Large font size must be larger than Large" + ) class TestFontSizeBootScript: @@ -57,11 +69,15 @@ class TestFontSizeBootScript: "Font size picker buttons must have font-size-pick-btn class" ) - def test_three_font_size_values_present(self): + def test_font_size_values_present(self): html = _read("static/index.html") assert 'data-font-size-val="small"' in html, "Small button must exist" assert 'data-font-size-val="default"' in html, "Default button must exist" assert 'data-font-size-val="large"' in html, "Large button must exist" + assert 'data-font-size-val="xlarge"' in html, "Extra Large button must exist" + assert 'data-i18n="font_size_xlarge"' in html, ( + "Extra Large button must use the font_size_xlarge i18n key" + ) def test_font_size_picker_not_duplicated(self): """Regression guard: the font size picker grid must appear exactly once @@ -153,7 +169,13 @@ class TestFontSizeI18nCoverage: block = src[start:end if end > 0 else start + 20000] return set(re.findall(r"(\w[\w_]+):", block)) - REQUIRED_KEYS = {"settings_label_font_size", "font_size_small", "font_size_default", "font_size_large"} + REQUIRED_KEYS = { + "settings_label_font_size", + "font_size_small", + "font_size_default", + "font_size_large", + "font_size_xlarge", + } def test_all_locales_have_font_size_keys(self): src = _read("static/i18n.js") @@ -173,6 +195,21 @@ class TestFontSizeI18nCoverage: count = src.count("font_size_large") assert count >= 6, f"font_size_large must appear in all 6 locales, found {count}" + def test_font_size_extra_large_key_in_all_locales(self): + src = _read("static/i18n.js") + count = src.count("font_size_xlarge") + assert count >= 6, f"font_size_xlarge must appear in all locales, found {count}" + + +class TestFontSizeSettingsValidation: + """The backend settings contract must accept the persisted xlarge value.""" + + def test_config_allows_extra_large_font_size(self): + config = _read("api/config.py") + assert '"font_size": {"small", "default", "large", "xlarge"}' in config, ( + "api/config.py must accept xlarge as a persisted font_size value" + ) + class TestFontSizeCssTargetedOverrides: """CSS must override px-unit text in key UI elements, not just :root font-size. @@ -192,6 +229,11 @@ class TestFontSizeCssTargetedOverrides: assert ':root[data-font-size="large"] .msg-body' in css, \ "Chat message text must be explicitly scaled for large" + def test_msg_body_overridden_for_extra_large(self): + css = _read("static/style.css") + assert ':root[data-font-size="xlarge"] .msg-body' in css, \ + "Chat message text must be explicitly scaled for extra large" + def test_session_item_overridden_for_small(self): css = _read("static/style.css") assert ':root[data-font-size="small"] .session-item' in css, \ @@ -202,6 +244,11 @@ class TestFontSizeCssTargetedOverrides: assert ':root[data-font-size="large"] .session-item' in css, \ "Sidebar session list text must be explicitly scaled for large" + def test_session_item_overridden_for_extra_large(self): + css = _read("static/style.css") + assert ':root[data-font-size="xlarge"] .session-item' in css, \ + "Sidebar session list text must be explicitly scaled for extra large" + def test_composer_overridden_for_small(self): css = _read("static/style.css") assert ':root[data-font-size="small"] #msg' in css, \ @@ -217,6 +264,15 @@ class TestFontSizeCssTargetedOverrides: assert m and int(m.group(1)) != 16, \ "Large composer font-size must differ from default (16px) to have visible effect" + def test_composer_overridden_for_extra_large(self): + css = _read("static/style.css") + assert ':root[data-font-size="xlarge"] #msg' in css, \ + "Composer textarea must be explicitly scaled for extra large" + m_large = re.search(r':root\[data-font-size="large"\] #msg \{ font-size: (\d+)px', css) + m_xlarge = re.search(r':root\[data-font-size="xlarge"\] #msg \{ font-size: (\d+)px', css) + assert m_large and m_xlarge and int(m_xlarge.group(1)) > int(m_large.group(1)), \ + "Extra Large composer font-size must be larger than Large" + def test_file_item_overridden_for_small(self): css = _read("static/style.css") assert ':root[data-font-size="small"] .file-item' in css, \ @@ -226,3 +282,8 @@ class TestFontSizeCssTargetedOverrides: css = _read("static/style.css") assert ':root[data-font-size="large"] .file-item' in css, \ "Workspace file tree text must be explicitly scaled for large" + + def test_file_item_overridden_for_extra_large(self): + css = _read("static/style.css") + assert ':root[data-font-size="xlarge"] .file-item' in css, \ + "Workspace file tree text must be explicitly scaled for extra large" diff --git a/tests/test_issue2223_compression_no_rename.py b/tests/test_issue2223_compression_no_rename.py new file mode 100644 index 00000000..5963b9e1 --- /dev/null +++ b/tests/test_issue2223_compression_no_rename.py @@ -0,0 +1,139 @@ +""" +Tests for #2223: compression session rotation must not destroy session history. + +The previous implementation renamed old_sid.json → new_sid.json during context +compression, destroying the only persistent copy of the uncompressed history +before the new session had been saved. If the summariser also failed, the user +was left with zero recoverable messages. + +The fix preserves old_sid.json and creates new_sid.json as a fresh file, setting +parent_session_id to link the lineage. +""" +import json +import pathlib +import textwrap +import threading + +import pytest + +STREAMING = pathlib.Path(__file__).resolve().parents[1] / "api" / "streaming.py" +streaming_src = STREAMING.read_text(encoding="utf-8") + + +# ── Structural checks ──────────────────────────────────────────────────────── + + +class TestNoRenameDuringCompression: + """The destructive old_path.rename(new_path) call must be removed.""" + + def test_rename_call_removed(self): + """old_path.rename(new_path) must not appear in the compression rotation block.""" + # The old code had: if old_path.exists() and not new_path.exists(): old_path.rename(new_path) + # That line must be gone. + assert "old_path.rename(new_path)" not in streaming_src, ( + "old_path.rename(new_path) still present — compression rotation " + "still destroys session history (#2223)" + ) + + def test_parent_session_id_stamped_on_continuation(self): + """The continuation session must carry parent_session_id linking to old_sid.""" + assert "s.parent_session_id = old_sid" in streaming_src, ( + "parent_session_id not stamped on continuation session (#2223)" + ) + + def test_old_session_preservation_logic_exists(self): + """There must be logic to preserve the pre-compression session file.""" + assert "Preserved pre-compression session" in streaming_src, ( + "Pre-compression session preservation logging not found (#2223)" + ) + + + def test_parent_session_id_stamped_unconditionally(self): + """Stage-353 Opus SHOULD-FIX: the continuation session's parent_session_id + must be stamped UNCONDITIONALLY to old_sid (the immediate predecessor). + + The previous `if not s.parent_session_id` guard skipped the stamp on + fork-of-fork compressions (i.e. when the session already had a + parent_session_id from a prior /branch operation), so the continuation + would jump back to the original fork instead of the just-preserved + snapshot, losing access to the recoverable history in old_sid.json. + + The fix removes the guard: continuation ALWAYS points to the preserved + snapshot. Traversal then walks new → old → old.parent → ... root. + """ + # The guarded form is the bug; the unconditional form is the fix. + assert "if not s.parent_session_id:\n s.parent_session_id = old_sid" not in streaming_src, ( + "Guarded parent_session_id stamping resurfaced — breaks fork-of-fork " + "lineage traversal after compression" + ) + + def test_old_session_parent_preserved_during_archive_save(self): + """Stage-353 Opus SHOULD-FIX: when preserving old_sid.json to disk, the + OLD session's parent_session_id must NOT be cleared. + + Previous bug: code did `s.parent_session_id = None; s.save(); s.parent_session_id = _old_parent`. + The save persisted parent=None to disk; in-memory restoration didn't help. + Result: fork lineage badge ("Forked from X") disappeared on the old snapshot. + """ + # The clearing pattern must be gone. + assert "s.parent_session_id = None" not in streaming_src, ( + "Clearing parent_session_id before preservation save resurfaced — " + "breaks fork lineage on the old snapshot" + ) + +class TestMergePreservesHistory: + """_merge_display_messages_after_agent_result must preserve all previous + display messages when compression returns only a marker.""" + + @pytest.fixture + def merge(self): + from api.streaming import _merge_display_messages_after_agent_result + return _merge_display_messages_after_agent_result + + def test_marker_only_preserves_all_previous(self, merge): + """When result is just a compression-failure marker, previous display survives.""" + previous_display = [ + {"role": "user", "content": f"msg{i}"} for i in range(100) + ] + [ + {"role": "assistant", "content": f"reply{i}"} for i in range(100) + ] + previous_context = list(previous_display) + marker = { + "role": "user", + "content": ( + "Summary generation was unavailable. 200 message(s) were removed " + "to free context space but could not be summarized." + ), + } + result = [marker, {"role": "user", "content": "continue"}, {"role": "assistant", "content": "ok"}] + + merged = merge(previous_display, previous_context, result, "continue") + + # All 200 original messages must survive. + assert len(merged) >= 200 + for i in range(100): + assert merged[i]["content"] == f"msg{i}" + + def test_empty_result_preserves_all_previous(self, merge): + """If result_messages is empty, previous display is returned unchanged.""" + previous_display = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + previous_context = list(previous_display) + + merged = merge(previous_display, previous_context, [], "test") + + assert merged == previous_display + + def test_none_result_preserves_all_previous(self, merge): + """If result_messages is None, previous display is returned unchanged.""" + previous_display = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + previous_context = list(previous_display) + + merged = merge(previous_display, previous_context, None, "test") + + assert merged == previous_display \ No newline at end of file diff --git a/tests/test_session_endless_scroll.py b/tests/test_session_endless_scroll.py index c075b140..44d5fa45 100644 --- a/tests/test_session_endless_scroll.py +++ b/tests/test_session_endless_scroll.py @@ -1,4 +1,5 @@ from pathlib import Path +import re ROOT = Path(__file__).resolve().parents[1] CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8") @@ -20,6 +21,25 @@ def test_endless_scroll_is_opt_in_setting(): assert "window._sessionEndlessScrollEnabled=false" in BOOT_JS +def test_endless_scroll_setting_has_its_own_label(): + match = re.search( + r"]*>\s*" + r']*>\s*' + r'[^<]+\s*' + r"\s*" + r']*data-i18n="settings_desc_session_endless_scroll"', + INDEX_HTML, + ) + assert match, "endless-scroll checkbox must be wrapped by its own label before its description" + + +def test_session_jump_and_endless_scroll_labels_are_separate(): + jump_label_end = INDEX_HTML.index('data-i18n="settings_desc_session_jump_buttons"') + endless_label_start = INDEX_HTML.index('id="settingsSessionEndlessScroll"') + between = INDEX_HTML[jump_label_end:endless_label_start] + assert "