diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff8b6e4..aca5c160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## [Unreleased] +## [v0.50.246] — 2026-04-30 + +### Added +- **Render fenced code blocks in user messages** — typing a triple-backtick fenced code block in the composer now renders with proper code styling, syntax-aware diff/patch coloring, and the same `
` pipeline used for assistant responses. Plain user text outside fences stays escaped (no markdown bold/italic/links interpreted in user bubbles); only fenced blocks are upgraded. Includes specialized colored-line rendering for `diff` / `patch` languages. (`static/ui.js`, `tests/test_1325_user_fenced_code.py`) @bergeouss — PR #1335, fixes #1325
+
+### Fixed
+- **Stop/Cancel during streaming no longer wipes the user's typed message (data-loss bug)** — When a user clicked Stop while the agent was streaming, `cancel_stream()` cleared `pending_user_message` before the streaming thread had merged the user turn into `s.messages`, persisting a session with neither the pending field nor a corresponding message. The user's typed text was permanently lost from the session JSON, not just the in-memory client copy. Now `cancel_stream()` synthesizes a user turn into `s.messages` from `pending_user_message` (with attachments preserved) when the most recent user message isn't already that turn — guards against double-append by content-matching against the last user message. (`api/streaming.py`, `tests/test_issue1298_cancel_and_activity.py`) — fixes #1298 (issue 2)
+- **Activity panel no longer auto-collapses when new tool/thinking events arrive** — Both `ensureActivityGroup()` (which re-creates the group with `tool-call-group-collapsed` on every destroy/recreate) and `finalizeThinkingCard()` (which force-adds the collapsed class on every tool boundary) ignored the user's manual expand. Tracks the user's last explicit toggle on the live activity group in a per-turn singleton (`_liveActivityUserExpanded`), restored on re-create and respected by the finalize path. Cleared between turns by `clearLiveToolCards()`. (`static/ui.js`, `tests/test_issue1298_cancel_and_activity.py`) — fixes #1298 (issue 1)
+- **Stale Mermaid render errors no longer leak into every chat** — Mermaid's render-failure path leaves a temporary `
` body-level node containing a "Syntax error in text" SVG. The previous code never removed it, so once any Mermaid block failed (or got mis-detected as Mermaid), every subsequent tab kept the syntax-error SVG visible regardless of content. Also tightens Mermaid detection so line-numbered tool output (`123|line`) and code blocks that don't start with a recognized Mermaid keyword are no longer mis-parsed as Mermaid; failed blocks are marked so a later render pass can't retry them. (`static/ui.js`, `tests/test_issue347.py`) @dso2ng — PR #1337 +- **Static asset cache busts automatically on every release** — ` - - - - - - - - - - + + + + + + + + + + + diff --git a/static/sw.js b/static/sw.js index a141d4d3..58fa2c46 100644 --- a/static/sw.js +++ b/static/sw.js @@ -64,6 +64,10 @@ self.addEventListener('fetch', (event) => { // Never intercept cross-origin requests if (url.origin !== self.location.origin) return; + // Never intercept the service worker script itself. Returning a cached sw.js + // prevents the browser from seeing a new cache version after local patches. + if (url.pathname.endsWith('/sw.js')) return; + // API and streaming endpoints — always go to network. // The WebUI may be mounted under a subpath such as /hermes/, so API // requests can look like /hermes/api/sessions rather than /api/sessions. diff --git a/static/ui.js b/static/ui.js index cffc8a31..5f6e2fc9 100644 --- a/static/ui.js +++ b/static/ui.js @@ -51,6 +51,43 @@ function _setCompressionSessionLock(sid){ } const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +/** + * Render fenced code blocks inside user messages. + * Extracts ```…``` fences, replaces them with placeholders, + * escapes remaining text as plain HTML, then restores code blocks + * with the same
 pipeline used by renderMd().
+ * All non-fenced text stays escaped (no bold/italic/link interpretation).
+ */
+function _renderUserFencedBlocks(text){
+  const stash=[];
+  let s=String(text||'');
+  // Extract fenced code blocks → stash, replace with null-token placeholder
+  s=s.replace(/```([a-zA-Z0-9_+-]*)\n([\s\S]*?)```/g,(_,lang,code)=>{
+    lang=(lang||'').trim().toLowerCase();
+    // Remove one trailing newline if present (the fence consumes its own)
+    if(code.endsWith('\n')) code=code.slice(0,-1);
+    const h=lang?`
${esc(lang)}
`:''; + const langAttr=lang?` class="language-${esc(lang)}"`:''; + if(lang==='diff'||lang==='patch'){ + const colored=esc(code).split('\n').map(line=>{ + if(line.startsWith('@@')) return `${line}`; + if(line.startsWith('+')) return `${line}`; + if(line.startsWith('-')) return `${line}`; + return `${line}`; + }).join('\n'); + stash.push(`${h}
${colored}
`); + } else { + stash.push(`${h}
${esc(code)}
`); + } + return '\x00UF'+(stash.length-1)+'\x00'; + }); + // Escape remaining plain text and convert newlines to
+ s=esc(s).replace(/\n/g,'
'); + // Restore stashed code blocks + s=s.replace(/\x00UF(\d+)\x00/g,(_,i)=>stash[+i]); + return s; +} + /* ── Image lightbox — click any .msg-media-img to enlarge ─────────────────── */ function _openImgLightbox(src, alt) { const lb = document.createElement('div'); @@ -1085,12 +1122,17 @@ function renderMd(raw){ const fence_stash=[]; s=s.replace(/```([\s\S]*?)```/g,(_,raw)=>{ const m=raw.match(/^(\w[\w+-]*)\n?([\s\S]*)$/); - if(m&&m[1].trim().toLowerCase()==='mermaid'){ + const lang=m?(m[1]||'').trim().toLowerCase():''; + const code=m?m[2]:raw.replace(/^\n?/,''); + const codeLines=code.split('\n'); + const firstCodeLine=codeLines.find(line=>line.trim())||''; + const firstMermaidLine=codeLines.map(line=>line.trim()).find(line=>line&&!line.startsWith('%%'))||''; + const looksLikeLineNumberedToolOutput=/^\s*\d+\|/.test(firstCodeLine); + const looksLikeMermaidStart=firstMermaidLine==='---'||/^(graph|flowchart|sequenceDiagram|classDiagram|classDiagram-v2|stateDiagram|stateDiagram-v2|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline|quadrantChart|requirementDiagram|C4Context|C4Container|C4Component|C4Dynamic|c4Context|c4Container|c4Component|c4Dynamic|sankey-beta|block-beta|packet-beta|xychart-beta|kanban|architecture-beta)\b/.test(firstMermaidLine); + if(lang==='mermaid'&&!looksLikeLineNumberedToolOutput&&looksLikeMermaidStart){ const id='mermaid-'+Math.random().toString(36).slice(2,10); - _preBlock_stash.push(`
${esc(m[2].trim())}
`); + _preBlock_stash.push(`
${esc(code.trim())}
`); } else { - const lang=m?(m[1]||'').trim().toLowerCase():''; - const code=m?m[2]:raw.replace(/^\n?/,''); const h=lang?`
${esc(lang)}
`:''; const langAttr=lang?` class="language-${esc(lang)}"`:''; // For diff/patch blocks, wrap each line in a colored span @@ -2602,6 +2644,30 @@ function _thinkingActivityNode(text){ row.innerHTML=_thinkingCardHtml(text); return row; } +// ── Activity-group user expand intent (#1298) ────────────────────────────── +// When the user manually expands the live "Activity" dropdown during streaming, +// preserve that intent across the destroy/recreate cycle that fires on every +// thinking/tool event. Without this, ensureActivityGroup() re-creates the group +// with the default collapsed state and finalizeThinkingCard() force-collapses +// it whenever the assistant transitions from thinking → tool → thinking, so +// the panel snaps shut every few seconds while the user is trying to read it. +// +// The tracker is a singleton boolean: there is at most one live activity group +// at a time (selector .tool-call-group[data-live-tool-call-group="1"]). It is +// set to true when the user clicks the summary to expand, false when they +// click to collapse, and cleared back to undefined when the live group is +// finalized into a settled assistant turn (the live attribute is removed in +// _convertLiveActivityGroupToSettled / when liveAssistantTurn loses its id). +let _liveActivityUserExpanded; +function _onLiveActivityToggle(group){ + if(!group) return; + // Only track explicit user clicks on the live group, not programmatic toggles. + if(group.getAttribute('data-live-tool-call-group')!=='1') return; + _liveActivityUserExpanded = !group.classList.contains('tool-call-group-collapsed'); +} +function _clearLiveActivityUserIntent(){ + _liveActivityUserExpanded = undefined; +} function ensureActivityGroup(inner, opts){ opts=opts||{}; if(!inner) return null; @@ -2610,12 +2676,16 @@ function ensureActivityGroup(inner, opts){ let group=inner.querySelector(selector); if(!group){ group=document.createElement('div'); - const collapsed=opts.collapsed!==false; + let collapsed=opts.collapsed!==false; + // Restore the user's explicit expand intent when recreating the live + // activity group within the same turn (#1298). + if(live && _liveActivityUserExpanded === true) collapsed=false; + else if(live && _liveActivityUserExpanded === false) collapsed=true; group.className='tool-call-group agent-activity-group'+(collapsed?' tool-call-group-collapsed':''); group.setAttribute('data-tool-call-group','1'); group.setAttribute('data-agent-activity-group','1'); if(live) group.setAttribute('data-live-tool-call-group','1'); - group.innerHTML=`
`; + group.innerHTML=`
`; const anchor=opts.anchor||null; if(anchor&&anchor.parentElement===inner) anchor.insertAdjacentElement('afterend', group); else inner.appendChild(group); @@ -3042,7 +3112,7 @@ function renderMessages(){ return _renderAttachmentHtml(fname,fileUrl); }).join('')}
`; } - const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'
') : renderMd(_stripXmlToolCallsDisplay(String(content))); + const bodyHtml = isUser ? _renderUserFencedBlocks(content) : renderMd(_stripXmlToolCallsDisplay(String(content))); const isEditableUser=isUser&&rawIdx===lastUserRawIdx; const editBtn = isEditableUser ? `` : ''; const undoBtn = isLastAssistant ? `` : ''; @@ -3534,6 +3604,9 @@ function appendLiveToolCard(tc){ function clearLiveToolCards(){ const inner=_assistantTurnBlocks($('liveAssistantTurn')); if(inner) inner.querySelectorAll('.tool-call-group[data-live-tool-call-group],.tool-card-row[data-live-tid]').forEach(el=>el.remove()); + // Reset the per-turn user expand intent so the next turn starts at the + // default collapsed state (#1298). + if(typeof _clearLiveActivityUserIntent==='function') _clearLiveActivityUserIntent(); // Legacy #liveToolCards container cleanup — kept for safety in case any // leftover cards were inserted there before this refactor took effect. const container=$('liveToolCards'); @@ -4130,10 +4203,17 @@ function renderMermaidBlocks(){ const id=block.dataset.mermaidId||('m-'+Math.random().toString(36).slice(2)); try{ const {svg}=await mermaid.render(id,code); + const tmp=document.getElementById('d'+id); + if(tmp) tmp.remove(); block.innerHTML=svg; block.classList.add('mermaid-rendered'); }catch(e){ - // Fall back to showing as a code block + const tmp=document.getElementById('d'+id); + if(tmp) tmp.remove(); + // Fall back to showing as a code block. Remove the mermaid marker so a + // later render pass cannot retry this already-failed block. + block.classList.remove('mermaid-block'); + block.classList.add('prewrap'); block.innerHTML=`
mermaid
${esc(code)}
`; } }); @@ -4213,9 +4293,15 @@ function finalizeThinkingCard(){ const turn=$('liveAssistantTurn'); const group=turn&&turn.querySelector('.tool-call-group[data-live-tool-call-group="1"]'); if(group){ - group.classList.add('tool-call-group-collapsed'); - const summary=group.querySelector('.tool-call-group-summary'); - if(summary) summary.setAttribute('aria-expanded','false'); + // Respect the user's explicit expand intent (#1298) — only force-collapse + // when the user has not manually expanded this turn's activity group, or + // has manually collapsed it. Otherwise the panel snaps shut whenever new + // activity arrives, even mid-read. + if(_liveActivityUserExpanded !== true){ + group.classList.add('tool-call-group-collapsed'); + const summary=group.querySelector('.tool-call-group-summary'); + if(summary) summary.setAttribute('aria-expanded','false'); + } const active=group.querySelector('.agent-activity-thinking[data-thinking-active="1"]'); if(active) active.removeAttribute('data-thinking-active'); _syncToolCallGroupSummary(group); diff --git a/tests/test_1325_user_fenced_code.py b/tests/test_1325_user_fenced_code.py new file mode 100644 index 00000000..80c1be39 --- /dev/null +++ b/tests/test_1325_user_fenced_code.py @@ -0,0 +1,123 @@ +"""Tests for issue #1325 — fenced code blocks in user message bubbles.""" +import os +import subprocess +import tempfile + +UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js') + + +def _extract_js_functions(): + """Extract esc and _renderUserFencedBlocks from ui.js by line numbers.""" + lines = open(UI_JS).read().split('\n') + # esc is on line 52 (0-indexed: 51) + esc_def = lines[51] + # _renderUserFencedBlocks starts at line 61 (0-indexed: 60) + # Find the end by matching closing brace at column 0 + fn_lines = [] + i = 60 # 0-indexed + depth = 0 + while i < len(lines): + fn_lines.append(lines[i]) + depth += lines[i].count('{') - lines[i].count('}') + if depth <= 0: + break + i += 1 + fn_def = '\n'.join(fn_lines) + return esc_def, fn_def + + +def _run_user_render(text_input): + """Return the HTML output of _renderUserFencedBlocks for the given input text.""" + import json + esc_def, fn_def = _extract_js_functions() + js_code = esc_def + '\n' + fn_def + '\n' + js_code += 'var input = JSON.parse(process.argv[2]);\n' + js_code += 'process.stdout.write(_renderUserFencedBlocks(input));\n' + tf = tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False, encoding='utf-8') + tf.write(js_code) + tf.close() + try: + result = subprocess.run( + ['node', tf.name, json.dumps(text_input)], + capture_output=True, text=True, timeout=10 + ) + if result.returncode != 0: + raise RuntimeError(f"node error: {result.stderr}") + return result.stdout + finally: + os.unlink(tf.name) + + +class TestUserFencedBlocks: + """Fenced code blocks in user messages should render as
."""
+
+    def test_simple_fenced_block(self):
+        out = _run_user_render("hello\n```python\nprint(1)\n```\nworld")
+        assert '
' in out
+        assert 'print(1)' in out
+        # Newlines around the fenced block become 
(same as original plain-text path) + assert 'hello
' in out + assert '
world' in out + + def test_fenced_block_escaped_html(self): + """HTML in code blocks should be escaped.""" + out = _run_user_render("```html\n
hi
\n```") + assert '<div>' in out + # No raw
in code content + assert '
' not in out.replace('<div>', '').replace('>', '') + + def test_plain_text_not_interpreted_as_markdown(self): + """Bold/italic/links in non-fenced text should stay escaped.""" + out = _run_user_render("**bold** and *italic* and ") + assert '**bold**' in out + assert '*italic*' in out + assert '<script>' in out + assert '' not in out + + def test_language_header_shown(self): + out = _run_user_render("```javascript\nconst x = 1;\n```") + assert 'class="pre-header"' in out + assert 'javascript' in out + + def test_no_language_no_header(self): + out = _run_user_render("```\nsome code\n```") + assert 'class="pre-header"' not in out + assert '
' in out
+        assert 'some code' in out
+
+    def test_diff_block_colored(self):
+        out = _run_user_render("```diff\n+added\n-removed\n```")
+        assert 'diff-block' in out
+        assert 'diff-plus' in out
+        assert 'diff-minus' in out
+
+    def test_multiple_fenced_blocks(self):
+        out = _run_user_render("first\n```python\n1\n```\nmiddle\n```js\n2\n```\nlast")
+        assert 'language-python' in out
+        assert 'language-js' in out
+        assert 'first
' in out + assert '
last' in out + + def test_fenced_block_with_ampersand(self): + out = _run_user_render("```python\nx & y\n```") + assert 'x & y' in out + + def test_empty_code_block(self): + out = _run_user_render("```\n```") + assert '
' in out
+
+    def test_special_chars_outside_blocks_escaped(self):
+        out = _run_user_render("a < b > c & d")
+        assert 'a < b > c & d' in out
+
+    def test_links_not_rendered_in_plain_text(self):
+        """URLs in plain text should NOT become clickable links."""
+        out = _run_user_render("Check https://example.com for details")
+        assert '' not in out
diff --git a/tests/test_issue1298_cancel_and_activity.py b/tests/test_issue1298_cancel_and_activity.py
new file mode 100644
index 00000000..fe6e363d
--- /dev/null
+++ b/tests/test_issue1298_cancel_and_activity.py
@@ -0,0 +1,377 @@
+"""Regression tests for #1298 — Activity panel UI state and Stop/Cancel data loss.
+
+Two distinct bugs reported in YanTianlong-01's bug report on v0.50.240:
+
+  1. The expanded Activity list collapses automatically when new activity arrives.
+  2. The latest user message disappears after clicking Stop/Cancel during streaming.
+
+Bug 2 is server-side data loss (the message is gone from session JSON, not just
+the in-memory client copy) caused by cancel_stream() clearing pending_user_message
+without first persisting it to s.messages. This test suite locks down both fixes.
+"""
+import pathlib
+import queue
+import re
+import threading
+from unittest.mock import Mock
+
+import pytest
+
+import api.config as config
+import api.models as models
+import api.streaming as streaming
+from api.models import Session
+from api.streaming import cancel_stream
+
+REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
+
+
+# ── Fixtures ────────────────────────────────────────────────────────────────
+
+@pytest.fixture(autouse=True)
+def _isolate_session_dir(tmp_path, monkeypatch):
+    """Redirect SESSION_DIR / SESSION_INDEX_FILE to an isolated temp dir."""
+    session_dir = tmp_path / "sessions"
+    session_dir.mkdir()
+    index_file = session_dir / "_index.json"
+    monkeypatch.setattr(models, "SESSION_DIR", session_dir)
+    monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
+    models.SESSIONS.clear()
+    yield
+    models.SESSIONS.clear()
+
+
+@pytest.fixture(autouse=True)
+def _isolate_stream_state():
+    config.STREAMS.clear()
+    config.CANCEL_FLAGS.clear()
+    config.AGENT_INSTANCES.clear()
+    config.STREAM_PARTIAL_TEXT.clear()
+    yield
+    config.STREAMS.clear()
+    config.CANCEL_FLAGS.clear()
+    config.AGENT_INSTANCES.clear()
+    config.STREAM_PARTIAL_TEXT.clear()
+
+
+@pytest.fixture(autouse=True)
+def _isolate_agent_locks():
+    config.SESSION_AGENT_LOCKS.clear()
+    yield
+    config.SESSION_AGENT_LOCKS.clear()
+
+
+def _make_pending_session(session_id="cancel_sid_1298",
+                          pending_msg="Help me debug this issue",
+                          messages=None,
+                          attachments=None):
+    """Build a session in mid-stream state: pending_user_message set, messages may be empty."""
+    s = Session(
+        session_id=session_id,
+        title="Test Session",
+        messages=messages or [],
+    )
+    s.pending_user_message = pending_msg
+    s.pending_attachments = list(attachments or [])
+    s.pending_started_at = None
+    s.active_stream_id = "stream_1298"
+    s.save()
+    models.SESSIONS[session_id] = s
+    return s
+
+
+def _setup_cancel_stream_state(session_id, stream_id="stream_1298"):
+    """Wire up STREAMS/CANCEL_FLAGS/AGENT_INSTANCES so cancel_stream() can run."""
+    config.STREAMS[stream_id] = queue.Queue()
+    config.CANCEL_FLAGS[stream_id] = threading.Event()
+    mock_agent = Mock()
+    mock_agent.session_id = session_id
+    mock_agent.interrupt = Mock()
+    config.AGENT_INSTANCES[stream_id] = mock_agent
+    return stream_id, mock_agent
+
+
+# ── Server-side: cancel preserves pending_user_message in s.messages ────────
+
+class TestIssue1298CancelPreservesUserMessage:
+    """Issue 2: Latest user message disappears after Stop/Cancel during streaming.
+
+    Root cause: cancel_stream() at api/streaming.py:2575+ clears
+    s.pending_user_message before the streaming thread's
+    _merge_display_messages_after_agent_result() has a chance to merge the
+    user turn into s.messages. The session is saved with neither
+    pending_user_message nor a corresponding s.messages entry, so the user's
+    typed text is lost permanently.
+
+    Fix: synthesize a user turn from pending_user_message into s.messages when
+    the most recent message isn't already that turn.
+    """
+
+    def test_cancel_synthesizes_user_message_when_messages_empty(self):
+        """When the agent thread is killed before it can append the user turn,
+        cancel_stream() must persist pending_user_message into s.messages so
+        the typed text survives a session reload."""
+        s = _make_pending_session(
+            session_id="cancel_sid_empty",
+            pending_msg="What's the weather forecast?",
+            messages=[],
+        )
+        stream_id, _agent = _setup_cancel_stream_state(s.session_id)
+
+        result = cancel_stream(stream_id)
+        assert result is True
+
+        # Reload from disk to confirm save happened
+        s2 = models.SESSIONS[s.session_id]
+        roles = [m.get("role") for m in s2.messages if isinstance(m, dict)]
+        contents = [m.get("content") for m in s2.messages if isinstance(m, dict)]
+
+        assert "user" in roles, (
+            "Expected user turn synthesized into s.messages — "
+            f"got roles={roles}"
+        )
+        assert "What's the weather forecast?" in contents, (
+            "Expected pending_user_message text preserved verbatim in s.messages — "
+            f"got contents={contents}"
+        )
+        assert s2.pending_user_message is None, (
+            "pending_user_message must be cleared after cancel"
+        )
+        assert s2.active_stream_id is None
+
+    def test_cancel_does_not_double_append_when_streaming_thread_already_merged(self):
+        """If the streaming thread won the race and already merged the user turn
+        into s.messages before cancel_stream() got the lock, cancel must not
+        append a duplicate."""
+        prior_user = {"role": "user", "content": "Run a tool for me"}
+        s = _make_pending_session(
+            session_id="cancel_sid_already_merged",
+            pending_msg="Run a tool for me",
+            messages=[prior_user],
+        )
+        stream_id, _agent = _setup_cancel_stream_state(s.session_id)
+
+        cancel_stream(stream_id)
+
+        s2 = models.SESSIONS[s.session_id]
+        user_messages = [m for m in s2.messages
+                         if isinstance(m, dict) and m.get("role") == "user"]
+        # Exactly one user turn — no duplicate
+        matching = [m for m in user_messages
+                    if "Run a tool for me" in str(m.get("content") or "")]
+        assert len(matching) == 1, (
+            "Expected exactly one user turn matching pending_user_message — "
+            f"got {len(matching)} ({user_messages})"
+        )
+
+    def test_cancel_synthesized_user_message_carries_attachments(self):
+        """A cancelled turn that had attachments uploaded should keep them on
+        the recovered user message."""
+        s = _make_pending_session(
+            session_id="cancel_sid_attachments",
+            pending_msg="Look at this screenshot",
+            messages=[],
+            attachments=["bug_screenshot.png", "stack_trace.txt"],
+        )
+        stream_id, _agent = _setup_cancel_stream_state(s.session_id)
+
+        cancel_stream(stream_id)
+
+        s2 = models.SESSIONS[s.session_id]
+        user_msgs = [m for m in s2.messages
+                     if isinstance(m, dict) and m.get("role") == "user"]
+        assert user_msgs, "User turn must be persisted on cancel"
+        recovered = user_msgs[0]
+        assert recovered.get("attachments") == [
+            "bug_screenshot.png", "stack_trace.txt"
+        ], (
+            "Attachment list must be preserved on the synthesized user turn — "
+            f"got {recovered.get('attachments')}"
+        )
+
+    def test_cancel_no_pending_user_message_does_nothing_extra(self):
+        """When there is no pending_user_message (e.g. cancel after the agent
+        has already returned), cancel_stream() must not synthesize a phantom
+        user turn."""
+        s = Session(
+            session_id="cancel_sid_no_pending",
+            title="Test",
+            messages=[{"role": "user", "content": "earlier turn"}],
+        )
+        s.active_stream_id = "stream_1298"
+        s.pending_user_message = None
+        s.save()
+        models.SESSIONS[s.session_id] = s
+        stream_id, _agent = _setup_cancel_stream_state(s.session_id)
+
+        cancel_stream(stream_id)
+
+        s2 = models.SESSIONS[s.session_id]
+        user_messages = [m for m in s2.messages
+                         if isinstance(m, dict) and m.get("role") == "user"]
+        # Still exactly one — the original earlier turn
+        assert len(user_messages) == 1
+        assert user_messages[0].get("content") == "earlier turn"
+
+    def test_cancel_synthesizes_when_prior_turn_content_is_substring_of_pending(self):
+        """Regression for Opus pre-release review of v0.50.246 (PR #1338):
+
+        The substring guard in cancel_stream() was symmetric — it would skip
+        synthesis if the prior user turn's content was a substring of the new
+        pending message. Common confirmation replies ("ok", "yes", "go") would
+        match longer follow-up prompts ("ok please continue") and the
+        synthesis would be skipped, re-introducing the data-loss bug.
+
+        The fix: gate the substring check on a timestamp comparison —
+        only treat the latest user turn as "already merged by the streaming
+        thread" if its timestamp is at or after pending_started_at. Earlier
+        turns whose content happens to be a substring must not short-circuit
+        the synthesis path.
+        """
+        import time as _time
+        # Prior reply was "ok" (a common short reply).
+        prior_ts = int(_time.time()) - 60  # 1 minute ago
+        prior_user = {
+            "role": "user",
+            "content": "ok",
+            "timestamp": prior_ts,
+        }
+        s = _make_pending_session(
+            session_id="cancel_sid_substring_collision",
+            pending_msg="ok please continue with the analysis",
+            messages=[prior_user],
+        )
+        # The pending turn started AFTER the prior turn was logged.
+        s.pending_started_at = prior_ts + 10
+        s.save()
+        models.SESSIONS[s.session_id] = s
+
+        stream_id, _agent = _setup_cancel_stream_state(s.session_id)
+        cancel_stream(stream_id)
+
+        s2 = models.SESSIONS[s.session_id]
+        user_messages = [m for m in s2.messages
+                         if isinstance(m, dict) and m.get("role") == "user"]
+        contents = [m.get("content") for m in user_messages]
+
+        assert "ok please continue with the analysis" in contents, (
+            "Pending user message must be synthesized — the substring 'ok' from a prior turn "
+            "must NOT cause the synthesis to be skipped. "
+            f"Got contents={contents}"
+        )
+        assert len(user_messages) == 2, (
+            "Expected both the original prior turn AND the synthesized new turn — "
+            f"got {len(user_messages)} user messages"
+        )
+
+
+# ── Client-side: ui.js source-level guards for activity-group state ─────────
+
+class TestIssue1298ActivityGroupExpandPersistence:
+    """Issue 1: Expanded Activity list collapses automatically when new
+    activity arrives.
+
+    Root cause:
+      - ensureActivityGroup() (static/ui.js) creates the live activity group
+        with `tool-call-group-collapsed` whenever it's missing
+      - finalizeThinkingCard() force-adds `tool-call-group-collapsed` on every
+        tool boundary, regardless of user intent
+      - The user's manually-set expand state lives only on a DOM class list,
+        so any destroy/recreate cycle (which fires on every thinking → tool →
+        thinking transition) wipes it.
+
+    Fix: track the user's last explicit toggle in a per-turn singleton, and
+    skip the force-collapse when the user has explicitly expanded.
+    """
+
+    def test_ui_js_tracks_user_expand_intent_for_live_activity_group(self):
+        src = (REPO_ROOT / "static" / "ui.js").read_text()
+        assert "_liveActivityUserExpanded" in src, (
+            "ui.js must declare a per-turn tracker for the user's expand intent "
+            "on the live activity group (#1298)"
+        )
+        assert "_onLiveActivityToggle" in src, (
+            "ui.js must expose a helper that records the user's manual toggle "
+            "of the live activity group"
+        )
+
+    def test_ensure_activity_group_restores_expand_intent(self):
+        """ensureActivityGroup() must consult _liveActivityUserExpanded when
+        creating a fresh live group so the user's prior expand survives the
+        destroy/recreate cycle."""
+        src = (REPO_ROOT / "static" / "ui.js").read_text()
+        # Find the ensureActivityGroup function body
+        m = re.search(
+            r"function ensureActivityGroup\(inner, opts\)\{(.*?)\n\}",
+            src, re.DOTALL,
+        )
+        assert m, "ensureActivityGroup() must exist in ui.js"
+        body = m.group(1)
+        assert "_liveActivityUserExpanded" in body, (
+            "ensureActivityGroup() body must reference the user-expand tracker "
+            "to restore intent on re-create (#1298)"
+        )
+        assert "live" in body and "_liveActivityUserExpanded === true" in body, (
+            "ensureActivityGroup() must override the default `collapsed` flag "
+            "when the user previously expanded the live group"
+        )
+
+    def test_finalize_thinking_card_respects_user_expand(self):
+        """finalizeThinkingCard() must NOT force-collapse the live activity
+        group when the user has explicitly expanded it (#1298)."""
+        src = (REPO_ROOT / "static" / "ui.js").read_text()
+        m = re.search(
+            r"function finalizeThinkingCard\(\)\{(.*?)\n\}",
+            src, re.DOTALL,
+        )
+        assert m, "finalizeThinkingCard() must exist in ui.js"
+        body = m.group(1)
+        assert "_liveActivityUserExpanded" in body, (
+            "finalizeThinkingCard() must respect the user's expand intent — "
+            "without this guard, the panel snaps shut on every tool boundary"
+        )
+        # Hard fail if force-collapse is unconditional
+        assert "_liveActivityUserExpanded !== true" in body or \
+               "_liveActivityUserExpanded!==true" in body.replace(" ", ""), (
+            "finalizeThinkingCard() must skip the force-collapse path when "
+            "_liveActivityUserExpanded === true"
+        )
+
+    def test_inline_onclick_records_user_intent(self):
+        """The summary button's inline onclick must call _onLiveActivityToggle
+        so user clicks update the tracker (#1298)."""
+        src = (REPO_ROOT / "static" / "ui.js").read_text()
+        # The summary button is built inline inside ensureActivityGroup.
+        assert "_onLiveActivityToggle" in src, (
+            "_onLiveActivityToggle helper must be defined"
+        )
+        # The inline onclick string must include the call so user toggles
+        # are captured into _liveActivityUserExpanded.
+        m = re.search(r'class="tool-call-group-summary"[^`]*`', src)
+        assert m, "live activity summary button template must be present"
+        # The onclick fragment is in the same template literal that builds
+        # the button — pull a wider window
+        m2 = re.search(
+            r"group\.innerHTML=` container; "
+        "otherwise rejected renders leave a visible 'Syntax error in text' SVG in every tab."
+    )
+    assert fn.count(cleanup) >= 2, (
+        "Mermaid temporary DOM cleanup must run after both successful and failed renders."
+    )
+
+
 # ── index.html ────────────────────────────────────────────────────────────────
 
 def test_katex_css_in_index_html():
diff --git a/tests/test_issue765_streaming_persistence.py b/tests/test_issue765_streaming_persistence.py
index e68c08d1..f55bb188 100644
--- a/tests/test_issue765_streaming_persistence.py
+++ b/tests/test_issue765_streaming_persistence.py
@@ -112,7 +112,12 @@ class TestPeriodicCheckpoint:
     """
 
     def test_checkpoint_fires_on_activity_counter_increment(self):
-        """Checkpoint saves when _checkpoint_activity counter grows."""
+        """Checkpoint saves when _checkpoint_activity counter grows.
+
+        Deterministic: instead of relying on time-based polling windows, we
+        wait for the checkpoint thread's save_count to advance after each
+        increment. Generous timeout guards against CI scheduling jitter.
+        """
         s = _make_session("ckpt1")
         s.pending_user_message = "do a long task"
         s.save()  # initial save (like routes.py does before streaming starts)
@@ -120,28 +125,38 @@ class TestPeriodicCheckpoint:
         stop_event = threading.Event()
         _checkpoint_activity = [0]
         save_count = [0]
+        save_event = threading.Event()
 
         def periodic_checkpoint():
             last = 0
-            while not stop_event.wait(0.1):  # fast interval for test
+            while not stop_event.wait(0.02):  # fast poll for low-jitter test
                 try:
                     cur = _checkpoint_activity[0]
                     if cur > last:
                         s.save(skip_index=True)
                         last = cur
                         save_count[0] += 1
+                        save_event.set()
                 except Exception:
                     pass
 
         t = threading.Thread(target=periodic_checkpoint, daemon=True)
         t.start()
 
-        # Simulate on_tool() completing twice (as would happen during a real agent run)
-        time.sleep(0.15)
+        def _wait_for_save(target_count, timeout=3.0):
+            """Wait until save_count[0] >= target_count, or timeout."""
+            deadline = time.monotonic() + timeout
+            while save_count[0] < target_count and time.monotonic() < deadline:
+                save_event.wait(timeout=0.05)
+                save_event.clear()
+            return save_count[0] >= target_count
+
+        # Simulate on_tool() completing twice
         _checkpoint_activity[0] += 1  # first tool completes
-        time.sleep(0.25)
+        assert _wait_for_save(1), f"Expected 1 save after first increment; got {save_count[0]}"
+
         _checkpoint_activity[0] += 1  # second tool completes
-        time.sleep(0.25)
+        assert _wait_for_save(2), f"Expected 2 saves after second increment; got {save_count[0]}"
 
         stop_event.set()
         t.join(timeout=2)
diff --git a/tests/test_onboarding_static.py b/tests/test_onboarding_static.py
index f61f3a99..b0d7bb95 100644
--- a/tests/test_onboarding_static.py
+++ b/tests/test_onboarding_static.py
@@ -13,7 +13,7 @@ def test_index_contains_onboarding_overlay_markup():
     assert 'id="onboardingOverlay"' in html
     assert 'id="onboardingBody"' in html
     assert 'id="onboardingNextBtn"' in html
-    assert 'src="static/onboarding.js"' in html
+    assert 'src="static/onboarding.js?v=__WEBUI_VERSION__"' in html
 
 
 def test_onboarding_css_rules_exist():
diff --git a/tests/test_pr1339_fallback_providers_list.py b/tests/test_pr1339_fallback_providers_list.py
new file mode 100644
index 00000000..c180c8dd
--- /dev/null
+++ b/tests/test_pr1339_fallback_providers_list.py
@@ -0,0 +1,72 @@
+"""Test for PR #1339 — streaming.py must support both single-dict `fallback_model`
+and list-form `fallback_providers` config without crashing on `.get()`.
+
+Before the fix, when config had `fallback_providers: [{provider, model, ...}, ...]`,
+streaming.py read it as if it were a dict and called `.get('model', '')` on a list,
+which would raise `AttributeError: 'list' object has no attribute 'get'`.
+
+The fix makes streaming.py handle both legacy dict form and new list form, picking
+the first entry from the list when given a list.
+"""
+import re
+from pathlib import Path
+
+STREAMING_PY = Path(__file__).resolve().parent.parent / "api" / "streaming.py"
+
+
+def _extract_fallback_block():
+    """Return the source range that handles fallback_model/fallback_providers."""
+    src = STREAMING_PY.read_text(encoding="utf-8")
+    # Locate the resolved-fallback region
+    idx = src.find("# Fallback model from profile config")
+    assert idx != -1, "Fallback block marker not found in streaming.py"
+    end = src.find("# Build kwargs defensively", idx)
+    assert end != -1, "End-of-block marker not found"
+    return src[idx:end]
+
+
+def test_fallback_handles_both_dict_and_list_config():
+    """Block must read either fallback_model (dict) or fallback_providers (list)."""
+    block = _extract_fallback_block()
+
+    # Both keys must be consulted
+    assert "fallback_model" in block, "Must still support legacy single-dict fallback_model"
+    assert "fallback_providers" in block, (
+        "Must support new list-form fallback_providers (PR #1339)"
+    )
+
+
+def test_fallback_list_iteration_picks_first_valid_entry():
+    """When given a list, code must pick the first valid dict entry, not call .get on the list."""
+    block = _extract_fallback_block()
+
+    # Must isinstance-check before calling .get
+    assert "isinstance(_fallback, list)" in block, (
+        "Must detect list-form fallback_providers explicitly to avoid AttributeError"
+    )
+    assert "isinstance(_fallback, dict)" in block or "isinstance(_fallback,dict)" in block, (
+        "Must keep legacy single-dict path explicitly"
+    )
+
+    # No bare _fallback.get() — every .get() on _fallback must be guarded by an isinstance(_fallback, dict) check.
+    # We verify this structurally: every line containing `_fallback.get(` must be inside or preceded by an isinstance(_fallback, dict) gate.
+    lines = block.split("\n")
+    in_dict_block = False
+    for i, line in enumerate(lines):
+        if "isinstance(_fallback, dict)" in line:
+            in_dict_block = True
+        if "_fallback.get(" in line and not in_dict_block:
+            # Look back up to 3 lines for the isinstance gate on the same elif/if
+            window = "\n".join(lines[max(0, i - 3): i + 1])
+            assert "isinstance(_fallback, dict)" in window, (
+                f"Line {i} calls _fallback.get() without a nearby isinstance(_fallback, dict) gate:\n{line}"
+            )
+
+
+def test_fallback_resolved_initialized_to_none():
+    """_fallback_resolved must default to None so AIAgent gets an explicit None when no fallback."""
+    block = _extract_fallback_block()
+    # The variable must be assignable to None at the top of the block
+    assert "_fallback_resolved = None" in block, (
+        "_fallback_resolved must be initialized to None so callers can rely on its presence"
+    )
diff --git a/tests/test_pr1341_context_window_persistence.py b/tests/test_pr1341_context_window_persistence.py
new file mode 100644
index 00000000..517d89e5
--- /dev/null
+++ b/tests/test_pr1341_context_window_persistence.py
@@ -0,0 +1,142 @@
+"""Regression test for PR #1341 + Opus pre-release review of v0.50.246.
+
+PR #1341 added context_length/threshold_tokens/last_prompt_tokens fields to
+the Session model — but didn't add the writer that actually populates them
+during streaming. The pre-release review caught this: without the writer,
+the user-visible bug (context-ring shows 0% after page reload) would NOT
+have been fixed by #1341 alone.
+
+This test verifies that:
+1. After a streaming turn completes, the session's context_length /
+   threshold_tokens / last_prompt_tokens are written from the agent's
+   compressor BEFORE s.save() is called (so they land on disk).
+2. GET /api/session response includes the populated values.
+3. A reloaded session retains the populated values.
+
+Implementation reference: api/streaming.py around line 2188 (the per-turn
+post-merge save) writes from getattr(agent, 'context_compressor', None).
+"""
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+STREAMING = ROOT / "api" / "streaming.py"
+MODELS = ROOT / "api" / "models.py"
+ROUTES = ROOT / "api" / "routes.py"
+
+
+def test_streaming_persists_context_fields_on_session_before_save():
+    """The post-merge per-turn save block must write the three fields to the
+    session BEFORE calling s.save(), otherwise the values never reach disk."""
+    src = STREAMING.read_text(encoding="utf-8")
+
+    # Find the post-merge save block — anchored on the unique reasoning trace
+    # marker right above the persistence block.
+    block_start = src.find("if _reasoning_text and s.messages:")
+    assert block_start != -1, "Reasoning-trace marker not found in streaming.py"
+
+    # Save call follows shortly after
+    save_call = src.find("\n                s.save()", block_start)
+    assert save_call != -1, "s.save() not found after the post-merge marker"
+    assert save_call - block_start < 2000, (
+        "s.save() should be close to the post-merge marker — block expanded unexpectedly"
+    )
+
+    block = src[block_start:save_call]
+
+    # The three fields must all be assigned on s within this block
+    assert "s.context_length" in block, (
+        "s.context_length must be written before s.save() in the post-merge block"
+    )
+    assert "s.threshold_tokens" in block, (
+        "s.threshold_tokens must be written before s.save() in the post-merge block"
+    )
+    assert "s.last_prompt_tokens" in block, (
+        "s.last_prompt_tokens must be written before s.save() in the post-merge block"
+    )
+
+    # The values must come from the agent's context_compressor
+    assert "context_compressor" in block, (
+        "Values must be sourced from agent.context_compressor"
+    )
+
+
+def test_session_init_accepts_context_fields():
+    """Session.__init__ must accept the three fields as named kwargs."""
+    src = MODELS.read_text(encoding="utf-8")
+    # The init signature spans many lines — read the full def block
+    init_match = re.search(r"def __init__\(self,(.*?)\):", src, re.DOTALL)
+    assert init_match, "Session.__init__ signature not found"
+    sig = init_match.group(1)
+    assert "context_length" in sig, "Session.__init__ must accept context_length"
+    assert "threshold_tokens" in sig, "Session.__init__ must accept threshold_tokens"
+    assert "last_prompt_tokens" in sig, "Session.__init__ must accept last_prompt_tokens"
+
+
+def test_session_metadata_fields_includes_context_fields():
+    """Session.save() METADATA_FIELDS must include all three for round-trip persistence."""
+    src = MODELS.read_text(encoding="utf-8")
+    # Locate METADATA_FIELDS list
+    meta_match = re.search(
+        r"METADATA_FIELDS\s*=\s*\[(.*?)\]",
+        src,
+        re.DOTALL,
+    )
+    assert meta_match, "METADATA_FIELDS list not found in Session.save"
+    fields = meta_match.group(1)
+    assert "'context_length'" in fields, "METADATA_FIELDS must include 'context_length'"
+    assert "'threshold_tokens'" in fields, "METADATA_FIELDS must include 'threshold_tokens'"
+    assert "'last_prompt_tokens'" in fields, "METADATA_FIELDS must include 'last_prompt_tokens'"
+
+
+def test_session_compact_exposes_context_fields():
+    """Session.compact() must include the three fields in its output dict."""
+    src = MODELS.read_text(encoding="utf-8")
+    # Find compact() method body
+    compact_idx = src.find("def compact(")
+    assert compact_idx != -1, "Session.compact not found"
+    # Look ahead for the next def or 200 lines
+    end = src.find("\n    def ", compact_idx + 1)
+    body = src[compact_idx:end if end != -1 else compact_idx + 4000]
+
+    assert "'context_length':" in body, "compact() must include context_length"
+    assert "'threshold_tokens':" in body, "compact() must include threshold_tokens"
+    assert "'last_prompt_tokens':" in body, "compact() must include last_prompt_tokens"
+
+
+def test_routes_session_get_returns_context_fields():
+    """GET /api/session response must include the three fields."""
+    src = ROUTES.read_text(encoding="utf-8")
+    # The session-detail response builder uses getattr(s, ..., 0) or 0 pattern.
+    # Look for the three keys in the same response shape.
+    assert '"context_length"' in src, "GET /api/session response must include context_length"
+    assert '"threshold_tokens"' in src, "GET /api/session response must include threshold_tokens"
+    assert '"last_prompt_tokens"' in src, "GET /api/session response must include last_prompt_tokens"
+
+
+def test_session_round_trip_persists_context_fields(tmp_path, monkeypatch):
+    """Real round-trip: save a Session with the fields set, reload, fields still there.
+
+    Patches SESSION_DIR on the live api.models module so we don't pollute
+    sys.modules state and break test ordering for sibling tests that depend
+    on a stable api.models import (e.g. test_session_sidecar_repair.py).
+    """
+    from api import models
+
+    # Use tmp_path as the session dir for this test only
+    sessions_dir = tmp_path / "sessions"
+    sessions_dir.mkdir(parents=True, exist_ok=True)
+    monkeypatch.setattr(models, "SESSION_DIR", sessions_dir)
+
+    s = models.Session(session_id="ctxtest1", title="Context test")
+    s.context_length = 200000
+    s.threshold_tokens = 180000
+    s.last_prompt_tokens = 45123
+    s.save()
+
+    # Reload from disk
+    s2 = models.Session.load("ctxtest1")
+    assert s2 is not None, "Session should reload"
+    assert s2.context_length == 200000, f"context_length lost on reload: got {s2.context_length}"
+    assert s2.threshold_tokens == 180000, f"threshold_tokens lost on reload: got {s2.threshold_tokens}"
+    assert s2.last_prompt_tokens == 45123, f"last_prompt_tokens lost on reload: got {s2.last_prompt_tokens}"
diff --git a/tests/test_pwa_manifest_sw.py b/tests/test_pwa_manifest_sw.py
index 34897335..82e956ff 100644
--- a/tests/test_pwa_manifest_sw.py
+++ b/tests/test_pwa_manifest_sw.py
@@ -124,6 +124,16 @@ class TestPWARoutes:
             "sw.js route must import and use WEBUI_VERSION for cache busting"
         )
 
+    def test_sw_route_url_encodes_cache_version(self):
+        src = ROUTES.read_text(encoding="utf-8")
+        idx = src.find('"/sw.js"')
+        assert idx != -1, "routes.py must handle /sw.js"
+        block = src[idx:idx + 1200]
+        assert "quote(WEBUI_VERSION, safe=\"\")" in block, (
+            "sw.js route must URL-encode the injected cache version so unusual git tags "
+            "cannot break the JavaScript string literal"
+        )
+
     def test_sw_route_sets_service_worker_allowed(self):
         src = ROUTES.read_text(encoding="utf-8")
         idx = src.find('"/sw.js"')
@@ -145,6 +155,21 @@ class TestIndexHtmlIntegration:
             "index.html must register the service worker"
         )
 
+    def test_index_uses_version_placeholders_for_static_assets(self):
+        src = INDEX.read_text(encoding="utf-8")
+        assert "sw.js?v=__WEBUI_VERSION__" in src
+        assert "static/ui.js?v=__WEBUI_VERSION__" in src
+
+    def test_index_route_url_encodes_asset_version(self):
+        src = ROUTES.read_text(encoding="utf-8")
+        idx = src.find('parsed.path in ("/", "/index.html")')
+        assert idx != -1, "routes.py must handle / and /index.html"
+        block = src[idx:idx + 800]
+        assert "quote(WEBUI_VERSION, safe=\"\")" in block, (
+            "index route must URL-encode the cache-busting version token before "
+            "injecting it into script src attributes and service worker registration"
+        )
+
     def test_index_has_ios_pwa_meta_tags(self):
         src = INDEX.read_text(encoding="utf-8")
         assert "apple-mobile-web-app-capable" in src, (
diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py
index 6b7c3f09..102c69df 100644
--- a/tests/test_renderer_js_behaviour.py
+++ b/tests/test_renderer_js_behaviour.py
@@ -503,6 +503,51 @@ class TestBlockquoteEntityEncodedInput:
         assert "
" in out, f"Fenced code inside entity-encoded blockquote must render: {out!r}"
 
 
+class TestMermaidToolOutputGuard:
+    """Line-numbered tool excerpts must not be auto-rendered as Mermaid."""
+
+    def test_line_numbered_mermaid_fence_renders_as_code_block(self, driver_path):
+        src = "```mermaid\n23|flowchart TB\n24|    A --> B\n```"
+        out = _render(driver_path, src)
+        assert 'class="mermaid-block"' not in out, (
+            f"Line-numbered read_file excerpts are not valid Mermaid and must not auto-render: {out!r}"
+        )
+        assert '
mermaid
' in out + assert '
' in out
+        assert '23|flowchart TB' in out
+
+    def test_valid_mermaid_fence_still_creates_mermaid_block(self, driver_path):
+        out = _render(driver_path, "```mermaid\nflowchart TB\n    A --> B\n```")
+        assert 'class="mermaid-block"' in out, (
+            f"Valid Mermaid fences should still be queued for Mermaid rendering: {out!r}"
+        )
+        assert 'flowchart TB' in out
+
+    def test_valid_mermaid_c4_fence_still_creates_mermaid_block(self, driver_path):
+        out = _render(driver_path, "```mermaid\nC4Context\n    title System Context\n```")
+        assert 'class="mermaid-block"' in out, (
+            f"Valid C4 Mermaid fences should still be queued for Mermaid rendering: {out!r}"
+        )
+        assert 'C4Context' in out
+
+    def test_valid_mermaid_frontmatter_fence_still_creates_mermaid_block(self, driver_path):
+        out = _render(driver_path, "```mermaid\n---\ntitle: Demo\n---\nflowchart TB\n    A --> B\n```")
+        assert 'class="mermaid-block"' in out, (
+            f"Valid Mermaid fences with frontmatter should still be queued for Mermaid rendering: {out!r}"
+        )
+        assert 'title: Demo' in out
+
+    def test_prose_mention_of_mermaid_fence_renders_as_code_block(self, driver_path):
+        src = "```mermaid\n` fence should not be auto-rendered too aggressively.\n\nSome prose, not a diagram.\n```"
+        out = _render(driver_path, src)
+        assert 'class="mermaid-block"' not in out, (
+            f"Prose captured by a mermaid fence is not valid Mermaid and must not auto-render: {out!r}"
+        )
+        assert '
mermaid
' in out + assert '
' in out
+        assert 'Some prose, not a diagram.' in out
+
+
 class TestRawPreCodePreservation:
     """Raw 
 HTML from model output should remain structurally intact."""
 
diff --git a/tests/test_service_worker_api_cache.py b/tests/test_service_worker_api_cache.py
index 6900524f..3118357f 100644
--- a/tests/test_service_worker_api_cache.py
+++ b/tests/test_service_worker_api_cache.py
@@ -29,3 +29,9 @@ def test_service_worker_excludes_subpath_mounted_health_routes_from_cache():
 def test_service_worker_documents_api_routes_are_never_cached():
     assert "API and streaming endpoints" in SW_SRC
     assert "always go to network" in SW_SRC
+
+
+def test_service_worker_does_not_intercept_its_own_script():
+    assert "url.pathname.endsWith('/sw.js')" in SW_SRC, (
+        "service worker must bypass /sw.js so a stale cached worker cannot block cache-version updates"
+    )
diff --git a/tests/test_sprint9.py b/tests/test_sprint9.py
index 78711972..7a74e29a 100644
--- a/tests/test_sprint9.py
+++ b/tests/test_sprint9.py
@@ -68,19 +68,19 @@ def test_app_js_no_longer_referenced_in_html(cleanup_test_sessions):
     """index.html must not reference the old monolithic app.js."""
     html = get_text("/")
     assert 'src="static/app.js"' not in html
-    # All 6 modules must be present
+    # All split modules must be present with the server-injected cache-busting version query.
     for module in ["ui.js", "workspace.js", "sessions.js", "messages.js", "panels.js", "boot.js"]:
-        assert f'src="static/{module}"' in html, f"Missing {module} in index.html"
+        assert f'src="static/{module}?v=' in html, f"Missing versioned {module} in index.html"
 
 def test_module_load_order_correct(cleanup_test_sessions):
     """ui.js must appear before sessions.js which must appear before boot.js."""
     html = get_text("/")
-    ui_pos = html.find('src="static/ui.js"')
-    ws_pos = html.find('src="static/workspace.js"')
-    sess_pos = html.find('src="static/sessions.js"')
-    msg_pos = html.find('src="static/messages.js"')
-    panels_pos = html.find('src="static/panels.js"')
-    boot_pos = html.find('src="static/boot.js"')
+    ui_pos = html.find('src="static/ui.js?v=')
+    ws_pos = html.find('src="static/workspace.js?v=')
+    sess_pos = html.find('src="static/sessions.js?v=')
+    msg_pos = html.find('src="static/messages.js?v=')
+    panels_pos = html.find('src="static/panels.js?v=')
+    boot_pos = html.find('src="static/boot.js?v=')
     assert ui_pos < ws_pos < sess_pos < msg_pos < panels_pos < boot_pos
 
 def test_no_duplicate_function_definitions(cleanup_test_sessions):