fix: batch v0.50.228 — renderer, model race, tool card, empty session, .env (#1179)
Some checks failed
Release & Docker / release (push) Has been cancelled

Merged as v0.50.228. 2644 tests passing. Browser QA 21/21 (desktop 1440×900 + mobile iPhone 14). All 5 fix invariants verified live in browser.

**Fix verifications:**
- #1172 (`renderMd` pre-stash): `rawPreStash` present in function, `<pre>` blocks pass through without content rewrite 
- #1174 (model race guard): `syncTopbar()` contains `liveStillPending` guard 
- #1175 (tool card): `.tool-card-result pre` max-height=360px, `.tool-card.open .tool-card-detail` overflow=auto, cap=600px   
- #1176 (empty session guard): double-click New Conversation on empty session → stays on same session, composer focused 
- #1178 (`.env` atomic write): `tempfile.mkstemp + os.replace` in `providers.py`, 9/9 env tests pass 

Thanks @bsgdigital (#1150) and @bergeouss (#1178)!
This commit is contained in:
nesquena-hermes
2026-04-27 15:28:19 -07:00
committed by GitHub
parent 8b8ff3328a
commit ef26d19549
10 changed files with 360 additions and 54 deletions

View File

@@ -357,6 +357,32 @@
workspace subtree) and never enumerate blocked system roots. (`api/routes.py`,
`api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616)
## [v0.50.228] — 2026-04-27
### Fixed
- **Raw `<pre>` blocks preserved in markdown renderer** — the inline `<code>` rewrite
pass in `renderMd()` no longer processes content inside raw `<pre>` blocks, preventing
multiline HTML code blocks from being degraded to backtick strings.
(`static/ui.js`) (#1150, @bsgdigital)
- **Live model race silently overwrites session model** — `syncTopbar()` now skips
the destructive fallback-to-first-model path while a live model fetch is in flight
for the active provider; `_addLiveModelsToSelect()` re-applies the session model
once the fetch completes, so models only present in the live catalog (e.g. Kimi K2)
are never silently replaced. (`static/ui.js`) (#1169)
- **Tool card output truncated at 220 chars and unscrollable** — JS truncation threshold
raised to 800 chars; CSS `overflow:auto` added to `.tool-card.open .tool-card-detail`
so the inner `<pre>` scroll works correctly; `<pre>` max-height raised to 360 px.
(`static/ui.js`, `static/style.css`) (#1170)
- **New Conversation creates empty session when already on empty session** — clicking
the New Conversation button or pressing Cmd/Ctrl+K when the current session has zero
messages now focuses the composer instead of creating another empty Untitled session.
(`static/boot.js`) (#1171)
- **`.env` file corruption from concurrent WebUI and CLI/Telegram writes** — removes
the unlocked duplicate `_write_env_file()` in `api/onboarding.py` that bypassed
`_ENV_LOCK`; rewrites the shared version to preserve comments, blank lines, and
original key order rather than rebuilding from a sorted dict.
(`api/onboarding.py`, `api/providers.py`) (#1164, @bergeouss)
## [v0.50.227] — 2026-04-27
### Fixed

View File

@@ -23,6 +23,7 @@ from api.config import (
save_settings,
verify_hermes_imports,
)
from api.providers import _write_env_file # shared impl with _ENV_LOCK (#1164)
from api.workspace import get_last_workspace, load_workspaces
logger = logging.getLogger(__name__)
@@ -167,26 +168,6 @@ def _load_env_file(env_path: Path) -> dict[str, str]:
return values
def _write_env_file(env_path: Path, updates: dict[str, str]) -> None:
current = _load_env_file(env_path)
for key, value in updates.items():
if value is None:
current.pop(key, None)
os.environ.pop(key, None)
continue
clean = str(value).strip()
if not clean:
continue
# Reject embedded newlines/carriage returns to prevent .env injection
if "\n" in clean or "\r" in clean:
raise ValueError("API key must not contain newline characters.")
current[key] = clean
os.environ[key] = clean
env_path.parent.mkdir(parents=True, exist_ok=True)
lines = [f"{key}={current[key]}" for key in sorted(current)]
env_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
def _load_yaml_config(config_path: Path) -> dict:
try:

View File

@@ -88,6 +88,10 @@ def _write_env_file(env_path: Path, updates: dict[str, str | None]) -> None:
"""Write key=value pairs to the .env file.
Values of ``None`` cause the key to be removed.
Preserves comments, blank lines, and original key order (#1164).
New keys are appended at the end of the file with a blank-line separator.
Holds ``_ENV_LOCK`` from ``api.streaming`` for the entire load → modify →
write cycle to prevent TOCTOU races between concurrent POST /api/providers
calls (each reading the same file baseline and overwriting the other's key).
@@ -97,11 +101,31 @@ def _write_env_file(env_path: Path, updates: dict[str, str | None]) -> None:
import stat as _stat
with _ENV_LOCK:
current = _load_env_file(env_path)
# ── Read existing lines (preserving comments and blank lines) ──
existing_lines: list[str] = []
if env_path.exists():
try:
existing_lines = env_path.read_text(encoding="utf-8").splitlines()
except Exception:
existing_lines = []
# Map each existing key to its line index so we can update in-place.
existing_key_indices: dict[str, int] = {}
for _i, _raw in enumerate(existing_lines):
_stripped = _raw.strip()
if _stripped and not _stripped.startswith("#") and "=" in _stripped:
_existing_key_indices_key = _stripped.split("=", 1)[0].strip()
existing_key_indices[_existing_key_indices_key] = _i
output_lines = list(existing_lines)
new_keys: list[str] = []
for key, value in updates.items():
if value is None:
current.pop(key, None)
# Mark the line for removal (None sentinel) and clear env.
os.environ.pop(key, None)
if key in existing_key_indices:
output_lines[existing_key_indices[key]] = None # type: ignore[assignment]
continue
clean = str(value).strip()
if not clean:
@@ -109,17 +133,49 @@ def _write_env_file(env_path: Path, updates: dict[str, str | None]) -> None:
# Reject embedded newlines/carriage returns to prevent .env injection
if "\n" in clean or "\r" in clean:
raise ValueError("API key must not contain newline characters.")
current[key] = clean
os.environ[key] = clean
if key in existing_key_indices:
output_lines[existing_key_indices[key]] = f"{key}={clean}"
else:
new_keys.append(f"{key}={clean}")
# Remove deleted lines (None sentinels)
output_lines = [l for l in output_lines if l is not None]
# Append new keys after a blank-line separator
if new_keys:
if output_lines and output_lines[-1].strip() != "":
output_lines.append("")
output_lines.extend(new_keys)
env_path.parent.mkdir(parents=True, exist_ok=True)
lines = [f"{key}={current[key]}" for key in sorted(current)]
# Create at owner-only mode from the first byte (O_CREAT honours the mode
# argument subject to umask). A trailing chmod guards pre-existing files.
content = "\n".join(output_lines)
if content:
content += "\n"
# Atomic write via tempfile + os.replace so cross-process readers
# (Telegram bot, CLI) never see a half-truncated file. The shared
# ``~/.hermes/.env`` is also written by ``hermes_cli.config.save_env_value``
# using the same atomic pattern; matching it here closes the
# cross-process leg of #1164 (within-process is covered by _ENV_LOCK).
_mode = _stat.S_IRUSR | _stat.S_IWUSR # 0o600
_fd = os.open(str(env_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _mode)
with os.fdopen(_fd, "w", encoding="utf-8") as _f:
_f.write("\n".join(lines) + ("\n" if lines else ""))
import tempfile as _tempfile
_tmp_fd, _tmp_path = _tempfile.mkstemp(
dir=str(env_path.parent), prefix=".env_", suffix=".tmp"
)
try:
with os.fdopen(_tmp_fd, "w", encoding="utf-8") as _f:
_f.write(content)
_f.flush()
os.fsync(_f.fileno())
os.chmod(_tmp_path, _mode) # tighten before rename so readers see 0600
os.replace(_tmp_path, env_path)
except BaseException:
try:
os.unlink(_tmp_path)
except OSError:
pass
raise
try:
env_path.chmod(_mode)
except OSError:

View File

@@ -382,7 +382,12 @@ $('btnAttach').onclick=()=>$('fileInput').click();
window._micActive=window._micActive||false;
window._micPendingSend=window._micPendingSend||false;
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();};
$('btnNewChat').onclick=async()=>{
// If the current session has no messages, just focus the composer rather than
// creating another empty session that will clutter the sidebar list (#1171).
if(S.session&&(S.session.message_count||0)===0){$('msg').focus();closeMobileSidebar();return;}
await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();
};
$('btnDownload').onclick=()=>{
if(!S.session)return;
const blob=new Blob([transcript()],{type:'text/markdown'});
@@ -516,6 +521,9 @@ document.addEventListener('keydown',async e=>{
}
if((e.metaKey||e.ctrlKey)&&e.key==='k'){
e.preventDefault();
// If the current session has no messages, just focus the composer rather than
// creating another empty session that will clutter the sidebar list (#1171).
if(S.session&&(S.session.message_count||0)===0){$('msg').focus();return;}
if(!S.busy){await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();}
}
if(e.key==='Escape'){

View File

@@ -1233,12 +1233,12 @@ body.resizing{user-select:none;cursor:col-resize;}
.tool-card-toggle{font-size:10px;color:var(--muted);opacity:.5;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;transform-origin:center;transition:transform .18s ease;will-change:transform;}
.tool-card.open .tool-card-toggle{transform:rotate(90deg);}
.tool-card-detail{display:block;max-height:0;opacity:0;overflow:hidden;border-top:1px solid transparent;padding:0 12px;transition:max-height .22s ease,opacity .18s ease,padding .22s ease,border-top-color .22s ease;}
.tool-card.open .tool-card-detail{max-height:520px;opacity:1;padding:8px 12px;border-top-color:rgba(255,255,255,.06);}
.tool-card.open .tool-card-detail{max-height:600px;opacity:1;padding:8px 12px;border-top-color:rgba(255,255,255,.06);overflow:auto;}
.tool-card-args{margin-bottom:6px;}
.tool-card-args div{font-size:11px;line-height:1.6;}
.tool-arg-key{color:var(--blue);font-family:'SF Mono',ui-monospace,monospace;font-size:11px;}
.tool-arg-val{color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:11px;word-break:break-all;}
.tool-card-result pre{font-size:11px;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;max-height:180px;overflow-y:auto;margin:0;line-height:1.55;}
.tool-card-result pre{font-size:11px;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;max-height:360px;overflow-y:auto;margin:0;line-height:1.55;}
/* ── Manual compression cards (transient transcript-local feedback) ── */
.live-compression-cards{

View File

@@ -165,6 +165,10 @@ async function populateModelDropdown(){
// Cache so we don't re-fetch on every page load
const _liveModelCache={};
// Tracks providers for which a live-model fetch is in flight.
// Used by syncTopbar() to defer model corrections until the fetch completes,
// preventing premature fallback to the first static model (#1169).
const _liveModelFetchPending=new Set();
function _addLiveModelsToSelect(provider, models, sel){
if(!provider||!models||!models.length||!sel) return 0;
@@ -215,6 +219,14 @@ function _addLiveModelsToSelect(provider, models, sel){
added++;
}
if(added>0 && currentVal) _applyModelToDropdown(currentVal, sel);
// After live models are added, re-apply the session's model in case it was
// absent from the static list and syncTopbar() fired before the live fetch
// completed (#1169). This ensures the session model wins over any premature
// fallback that may have set sel.value to the first available option.
if(S.session && S.session.model && sel.id==='modelSelect'){
const reapplied=_applyModelToDropdown(S.session.model, sel);
if(reapplied && typeof syncModelChip==='function') syncModelChip();
}
return added;
}
@@ -226,6 +238,7 @@ async function _fetchLiveModels(provider, sel){
if(added>0 && typeof syncModelChip==='function') syncModelChip();
return;
}
_liveModelFetchPending.add(provider);
try{
const url=new URL('api/models/live',location.href);
url.searchParams.set('provider',provider);
@@ -241,6 +254,8 @@ async function _fetchLiveModels(provider, sel){
}
}catch(e){
console.debug('[hermes] Live model fetch failed for',provider,e.message);
}finally{
_liveModelFetchPending.delete(provider);
}
}
@@ -810,12 +825,18 @@ function renderMd(raw){
s=s.replace(/\\\\\((.+?)\\\\\)/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
s=s.replace(/\\\\\[(.+?)\\\\\]/gs,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
// Safe tag → markdown equivalent (these produce the same output as **text** etc.)
// Stash raw <pre> blocks so the inline <code> rewrite below does not run
// inside them. Running that rewrite in <pre> content can introduce stray
// backticks for multiline code and break subsequent code-box rendering.
const rawPreStash=[];
s=s.replace(/(<pre\b[^>]*>[\s\S]*?<\/pre>)/gi,m=>{rawPreStash.push(m);return `\x00R${rawPreStash.length-1}\x00`;});
s=s.replace(/<strong>([\s\S]*?)<\/strong>/gi,(_,t)=>'**'+t+'**');
s=s.replace(/<b>([\s\S]*?)<\/b>/gi,(_,t)=>'**'+t+'**');
s=s.replace(/<em>([\s\S]*?)<\/em>/gi,(_,t)=>'*'+t+'*');
s=s.replace(/<i>([\s\S]*?)<\/i>/gi,(_,t)=>'*'+t+'*');
s=s.replace(/<code>([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`');
s=s.replace(/<br\s*\/?>/gi,'\n');
s=s.replace(/\x00R(\d+)\x00/g,(_,i)=>rawPreStash[+i]);
// Restore stashed code blocks
s=s.replace(/\x00F(\d+)\x00/g,(_,i)=>fence_stash[+i]);
// Mermaid blocks: render as diagram containers (processed after DOM insertion)
@@ -1832,21 +1853,30 @@ function syncTopbar(){
// first available model so stale values don't pollute the picker (#829).
if(!applied && currentModel){
const deferModelCorrection=Boolean(S.session._modelResolutionDeferred);
// Stale session model not in the current provider catalog — reset to the
// first available model rather than injecting an "(unavailable)" option
// that visually appears under the wrong provider group (#829).
const modelSel=$('modelSelect');
const first=modelSel&&modelSel.querySelector('optgroup > option, option');
if(first){
modelSel.value=first.value;
if(!deferModelCorrection){
S.session.model=first.value;
// Persist the correction so the session doesn't re-inject on next load.
fetch(new URL('api/session/update',location.href).href,{
method:'POST',credentials:'include',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:first.value})
}).catch(()=>{});
// Also defer if a live model fetch is still in flight — the model may be
// in the list once the fetch completes. Persisting now would corrupt the
// session with the wrong model before live models arrive (#1169).
const liveStillPending=window._activeProvider&&_liveModelFetchPending.has(window._activeProvider);
if(liveStillPending){
// Live fetch in flight — don't touch sel.value or S.session.model yet.
// _addLiveModelsToSelect() will re-apply S.session.model once done (#1169).
} else {
// Stale session model not in the current provider catalog — reset to the
// first available model rather than injecting an "(unavailable)" option
// that visually appears under the wrong provider group (#829).
const modelSel=$('modelSelect');
const first=modelSel&&modelSel.querySelector('optgroup > option, option');
if(first){
modelSel.value=first.value;
if(!deferModelCorrection){
S.session.model=first.value;
// Persist the correction so the session doesn't re-inject on next load.
fetch(new URL('api/session/update',location.href).href,{
method:'POST',credentials:'include',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:first.value})
}).catch(()=>{});
}
}
}
}
@@ -2614,9 +2644,9 @@ function buildToolCard(tc){
let displaySnippet='';
if(tc.snippet){
const s=tc.snippet;
if(s.length<=220){displaySnippet=s;}
if(s.length<=800){displaySnippet=s;}
else{
const cutoff=s.slice(0,220);
const cutoff=s.slice(0,800);
const lastBreak=Math.max(cutoff.lastIndexOf('. '),cutoff.lastIndexOf('\n'),cutoff.lastIndexOf('; '));
displaySnippet=lastBreak>80?s.slice(0,lastBreak+1):cutoff;
}

View File

@@ -0,0 +1,170 @@
"""
Regression tests for #1164 — .env file corruption by WebUI.
The WebUI's onboarding flow had a duplicate _write_env_file() without
_ENV_LOCK protection. Concurrent writes (e.g. Telegram bot + WebUI) could
corrupt the shared .env file. Additionally, both _write_env_file copies
rewrote the entire file from a parsed dict, stripping comments and
reordering keys alphabetically.
Fix:
- onboarding.py now imports _write_env_file from providers.py (which holds
_ENV_LOCK from api.streaming for the entire load→modify→write cycle).
- _write_env_file in providers.py now preserves comments, blank lines, and
original key order instead of rebuilding from a sorted dict.
Sprint/commit: v0.50.227+
"""
import os
import tempfile
import textwrap
import unittest
from pathlib import Path
class TestEnvFileCommentPreservation(unittest.TestCase):
"""Verify _write_env_file preserves comments, blank lines, and key order."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.env_path = Path(self.tmpdir) / ".env"
# Must import AFTER setting up, as the module has top-level code
from api.providers import _write_env_file
self._write_env_file = _write_env_file
def tearDown(self):
# Clean os.environ entries set during tests
for key in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "NEW_KEY"):
os.environ.pop(key, None)
import shutil
shutil.rmtree(self.tmpdir, ignore_errors=True)
def _read(self) -> str:
return self.env_path.read_text(encoding="utf-8")
# ── Comment preservation ──────────────────────────────────────────
def test_comments_preserved_on_update(self):
"""Comments in .env must survive a key value update."""
self.env_path.write_text(textwrap.dedent("""\
# Hermes API keys
OPENROUTER_API_KEY=sk-or-old
# Another comment
OPENAI_API_KEY=sk-oai-old
""").strip() + "\n", encoding="utf-8")
self._write_env_file(self.env_path, {"OPENROUTER_API_KEY": "sk-or-new"})
content = self._read()
self.assertIn("# Hermes API keys", content,
"Leading comment must be preserved")
self.assertIn("# Another comment", content,
"Inline comment must be preserved")
self.assertIn("sk-or-new", content)
def test_blank_lines_preserved(self):
"""Blank lines between key blocks must be preserved."""
self.env_path.write_text(
"KEY_A=val_a\n\nKEY_B=val_b\n", encoding="utf-8")
self._write_env_file(self.env_path, {"KEY_A": "updated"})
content = self._read()
self.assertEqual(content.count("\n\n"), 1,
"Blank line between keys must be preserved")
def test_key_order_preserved(self):
"""Original key order must not be sorted alphabetically."""
self.env_path.write_text(
"ZZZ_KEY=last\nAAA_KEY=first\nBBB_KEY=middle\n",
encoding="utf-8")
self._write_env_file(self.env_path, {"AAA_KEY": "updated"})
content = self._read()
zzz_pos = content.find("ZZZ_KEY")
aaa_pos = content.find("AAA_KEY")
bbb_pos = content.find("BBB_KEY")
# Original order: ZZZ, AAA, BBB
self.assertLess(zzz_pos, aaa_pos,
"ZZZ_KEY must still come before AAA_KEY (original order)")
self.assertLess(aaa_pos, bbb_pos,
"AAA_KEY must still come before BBB_KEY (original order)")
def test_new_key_appended_with_separator(self):
"""New keys are appended at the end with a blank-line separator."""
self.env_path.write_text(
"EXISTING_KEY=value\n", encoding="utf-8")
self._write_env_file(self.env_path, {"NEW_KEY": "new_value"})
content = self._read()
self.assertIn("NEW_KEY=new_value", content)
# New key should appear after the existing one
self.assertGreater(content.find("NEW_KEY"), content.find("EXISTING_KEY"))
def test_key_removal_preserves_others(self):
"""Removing a key leaves other keys and comments intact."""
self.env_path.write_text(textwrap.dedent("""\
# Comment A
KEY_A=val_a
# Comment B
KEY_B=val_b
""").strip() + "\n", encoding="utf-8")
self._write_env_file(self.env_path, {"KEY_B": None})
content = self._read()
self.assertIn("KEY_A=val_a", content)
self.assertIn("# Comment A", content)
self.assertNotIn("KEY_B", content)
# Comment B stays (it's just a comment, not tied to KEY_B structurally)
self.assertIn("# Comment B", content)
def test_empty_file_handled_gracefully(self):
"""Writing to a non-existent .env file works."""
self.assertFalse(self.env_path.exists())
self._write_env_file(self.env_path, {"NEW_KEY": "value"})
self.assertTrue(self.env_path.exists())
self.assertEqual(self._read().strip(), "NEW_KEY=value")
class TestOnboardingUsesProviderWriteEnv(unittest.TestCase):
"""Verify that onboarding.py delegates to providers._write_env_file
(which holds _ENV_LOCK), eliminating the duplicate unprotected path."""
def test_onboarding_imports_write_env_from_providers(self):
"""api.onboarding._write_env_file must be the same object as
api.providers._write_env_file (shared implementation with lock)."""
from api import onboarding, providers
self.assertIs(
onboarding._write_env_file,
providers._write_env_file,
"onboarding must use providers._write_env_file for thread safety (#1164)"
)
def test_providers_write_env_holds_env_lock(self):
"""providers._write_env_file must acquire _ENV_LOCK from api.streaming."""
import inspect
from api.providers import _write_env_file
source = inspect.getsource(_write_env_file)
self.assertIn("_ENV_LOCK", source,
"_write_env_file must use _ENV_LOCK for concurrency safety")
self.assertIn("from api.streaming import _ENV_LOCK", source,
"_ENV_LOCK must be imported from api.streaming")
def test_providers_write_env_uses_atomic_rename(self):
"""providers._write_env_file must write atomically via tempfile +
os.replace so cross-process readers (Telegram, CLI) never observe
a truncated half-written file (#1164 cross-process leg)."""
import inspect
from api.providers import _write_env_file
source = inspect.getsource(_write_env_file)
self.assertIn("tempfile", source,
"_write_env_file must stage writes through a tempfile")
self.assertIn("os.replace(", source,
"_write_env_file must atomically rename via os.replace")
# The original O_TRUNC pattern must NOT remain — it is the source of
# the cross-process race the PR is closing.
self.assertNotIn("O_TRUNC", source,
"_write_env_file must not truncate-in-place (#1164)")

View File

@@ -147,14 +147,17 @@ def test_toggle_mobile_files_js_defined():
def test_new_conversation_closes_mobile_sidebar():
"""New conversation must close the mobile drawer so the chat pane is visible immediately."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
click_line = next((ln for ln in boot_js.splitlines() if "$('btnNewChat').onclick" in ln), "")
assert click_line, "btnNewChat onclick handler missing from static/boot.js"
assert "closeMobileSidebar" in click_line, \
# Handler is now multi-line — search for the full block rather than a single line.
assert "$('btnNewChat').onclick" in boot_js, "btnNewChat onclick handler missing from static/boot.js"
# Find the handler block and verify closeMobileSidebar appears in it.
idx = boot_js.find("$('btnNewChat').onclick")
handler_block = boot_js[idx:idx+500]
assert "closeMobileSidebar" in handler_block, \
"btnNewChat handler must closeMobileSidebar() after creating the new session"
shortcut_line = next((ln for ln in boot_js.splitlines() if "e.key==='k'" in ln or "e.key === 'k'" in ln), "")
assert shortcut_line, "Cmd/Ctrl+K new chat shortcut missing from static/boot.js"
shortcut_block = "\n".join(boot_js.splitlines()[boot_js.splitlines().index(shortcut_line):boot_js.splitlines().index(shortcut_line)+4])
shortcut_block = "\n".join(boot_js.splitlines()[boot_js.splitlines().index(shortcut_line):boot_js.splitlines().index(shortcut_line)+6])
assert "closeMobileSidebar" in shortcut_block, \
"Cmd/Ctrl+K new chat shortcut must closeMobileSidebar() after creating the new session"

View File

@@ -461,3 +461,30 @@ class TestBlockquoteEntityEncodedInput:
f"Entity-encoded blockquote with fenced code must render: {out!r}"
)
assert "<pre>" in out, f"Fenced code inside entity-encoded blockquote must render: {out!r}"
class TestRawPreCodePreservation:
"""Raw <pre><code> HTML from model output should remain structurally intact."""
def test_multiline_pre_code_blocks_do_not_degrade_to_backticks(self, driver_path):
src = (
"<pre><code>line 1\n"
"line 2\n"
"</code></pre>\n\n"
"After paragraph.\n\n"
"<pre><code>line 3\n"
"line 4\n"
"</code></pre>\n\n"
"Done."
)
out = _render(driver_path, src)
assert out.count("<pre>") == 2 and out.count("</pre>") == 2, (
f"Expected two balanced <pre> blocks, got: {out!r}"
)
assert out.count("<code>") == 2 and out.count("</code>") == 2, (
f"Expected two balanced <code> blocks, got: {out!r}"
)
assert "`line 1" not in out and "line 2\n`</pre>" not in out, (
f"<code> content inside <pre> must not be rewritten to backticks: {out!r}"
)
assert "After paragraph." in out and "Done." in out

View File

@@ -16,7 +16,12 @@ def test_tool_card_toggle_uses_transformable_layout_and_transition():
def test_tool_card_detail_uses_transitionable_collapsed_state():
assert ".tool-card-detail{display:block;max-height:0;opacity:0;overflow:hidden;" in COMPACT_CSS
assert re.search(
r"\.tool-card\.open\s+\.tool-card-detail\s*\{[^}]*max-height:\s*520px;[^}]*opacity:\s*1;",
r"\.tool-card\.open\s+\.tool-card-detail\s*\{[^}]*max-height:\s*600px;[^}]*opacity:\s*1;",
STYLE_CSS,
)
# Open state must set overflow to auto so the inner <pre> scroll is not clipped (#1170).
assert re.search(
r"\.tool-card\.open\s+\.tool-card-detail\s*\{[^}]*overflow:\s*auto;",
STYLE_CSS,
)