Release: extension session-open handler + renderTranscript API (#5508, @ChonSong) (#6226)

* feat(core): add registerHermesSessionOpenHandler + renderTranscript extension hooks

- registerHermesSessionOpenHandler(fn): register a handler that fires on
  session open. Return {cancel:true} to prevent navigation.
- renderTranscript(container, messages, opts): render messages into any
  DOM container using core's renderMd pipeline. Skip tool messages.
- Wire _hermesNotifySessionOpen into loadSession: pre-load guard at top,
  post-load notification at end for extensions to hook into.
- Follows existing registerHermesTtsEngine extension registration pattern.

This gives extensions like chat-tiling a sanctioned API instead of DOM
hacking to intercept and render session transcripts.

* fix: address 3 gate-fail blockers from PR #5508 review

Fixes the three core issues identified by nesquena-hermes code review:

1. XSS sink in renderTranscript (boot.js)
   - Fallback to textContent when window.renderMd is unavailable
   - innerHTML exclusively for successful renderMd output

2. Stale _loadingSessionId reset in cancel branch (sessions.js)
   - Remove premature nulling of _loadingSessionId
   - Cancel path simply returns without touching loading-guard state

3. Pre-open veto bypasses profile/import side-effects (sessions.js)
   - Move cancellable preload hook to start of _openSidebarSession,
     before external session import and profile switching
   - Pass internal _preloadNotified flag to skip duplicate preload
     in loadSession while retaining post-load notification

Closes #5508 gate-fail items.

* fix: use module-level flag instead of call argument to avoid test regression

The _preloadNotified approach broke test_static_sessions_js_switches_profile
before_opening_all_profiles_row because it changed the loadSession call
signature from loadSession(sid, loadOpts) to loadSession(sid, Object.assign(...)).

Switch to a module-scoped boolean _hermesSessionOpenAlreadyFired set by
_openSidebarSession before calling loadSession, checked by loadSession's
pre-hook guard. The call signature stays unchanged.

Test 2 (test_load_session_rearms_stream_on_every_early_return) also passes.

* fix: compact pre-hook comment to keep loadSession within test window limits

* fix: replace global _hermesSessionOpenAlreadyFired flag with per-call opts._preloadNotified

The module-level boolean introduced in 381fa0ef had two problems:
1. ReferenceError on direct loadSession() calls — the flag was undeclared
   when called outside _openSidebarSession, breaking saved-session restore.
2. Never reset after first sidebar open — all subsequent direct calls
   silently skipped the cancellable preload handler.

Per the maintainer's review (PR #5508), replace the global with a per-call
option _preloadNotified passed by _openSidebarSession via Object.assign.
This keeps the call signature stable for existing tests and eliminates
the stale global state leak.

Test adjustments:
- test_issue1611: widened loadSession call literal assertion
- test_session_channel_option_x: body slice 14000→15000 to accommodate
  the slightly longer function body

* chore: remove unrelated files from commit

* fix: address 3 review issues — preload-only cancel, drop inner wrapper, pass _preloadNotified on retry

- Only honor {cancel:true} when opts.preload===true (boot.js)
- Drop .msg-body-inner wrapper, render directly into .msg-body (boot.js)
- Carry _preloadNotified:true through cross-profile 409 retry (sessions.js)

* recommit

* Update static/boot.js

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix(extensions): resolve canonical sid before preload hook + pass _preloadNotified on continuation retry

PR #5508 review follow-up (review 4690636760):
1. Move _resolveSessionIdFromSidebarLineage() before the preload hook so
   extensions always see the canonical sid, not the raw sidebar click id.
2. Pass _preloadNotified:true on the continuation-session retry path to
   prevent duplicate preload events to extensions.
3. Add functional test_extension_session_hooks.py — actually drives the
   new hook registration, preload-veto, transcript rendering, and
   _preloadNotified bridge in Node (13 tests, all green).

* Remove unused pytest import

Removed unused import of pytest from test file.

* fix: address 2 gate-blocking veto-ordering defects (PR #5508)

Blocker 1 (CORE): cross-profile retry now passes _preloadNotified:true
so the pre-hook doesn't re-fire after destructive side-effects already
ran (stream teardown, message clear, profile switch). A {cancel:true}
on that second fire was stranding the UI profile-switched with a
cleared transcript.

Blocker 2 (SILENT): closeMobileSidebar() was called synchronously
BEFORE _openSidebarSession()'s veto guard in three places (tap-to-open,
child-session, lineage-segment). A {cancel:true} still closed the
sidebar out from under it. Removed the three premature calls; moved
a single closeMobileSidebar() inside _openSidebarSession AFTER the
veto guard so it only runs when the open actually proceeds.

Added 3 regression tests asserting {cancel:true} leaves NO side-effect.

* CHANGELOG: extension session-open handler + renderTranscript API (#5508)

---------

Co-authored-by: Sean <seanos1a@gmail.com>
Co-authored-by: ChonSong <85378550+ChonSong@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
authored and GitHub committed 2026-07-18 00:53:17 -07:00
1 parent 02aa91ba6e
commit 735ba1c4d3
5 files changed
+467 -15

No files matched your search

+2
View File
@@ -27,6 +27,8 @@
### Added ### Added
- **Extension API: session-open interception + transcript rendering.** Two new extension-facing hooks let extensions react to session navigation and render message transcripts without DOM hacking. `window.registerHermesSessionOpenHandler(fn)` fires when a session opens — with `{preload:true}` a handler may return `{cancel:true}` to abort the navigation cleanly (no side-effect: no sidebar close, profile switch, stream open, transcript clear, or URL change on any entry path), and with `{loaded:true}` it runs after the session is fully populated. `window.renderTranscript(container, messages, opts)` renders an array of messages into any container, routing content through the same sanitized `renderMd`/`SAFE_TAGS` pipeline the chat uses (with a `textContent` fallback), so it carries no new XSS surface. Follows the established `registerHermesTtsEngine` registration pattern. Thanks @ChonSong. (#5508)
- **Pinch-to-zoom for Mermaid diagrams on touch devices.** The zoomable Mermaid viewer (which already had mouse-wheel zoom + single-finger pan) now supports two-finger pinch-to-zoom on phones/tablets, anchored on the pinch midpoint. Pointer/drag handlers are guarded so an active pinch never fights the pan gesture; desktop mouse-wheel zoom and single-finger pan are unchanged. Uses Touch Events for iOS Safari robustness. Thanks @silent-reader-cn. (#5913) - **Pinch-to-zoom for Mermaid diagrams on touch devices.** The zoomable Mermaid viewer (which already had mouse-wheel zoom + single-finger pan) now supports two-finger pinch-to-zoom on phones/tablets, anchored on the pinch midpoint. Pointer/drag handlers are guarded so an active pinch never fights the pan gesture; desktop mouse-wheel zoom and single-finger pan are unchanged. Uses Touch Events for iOS Safari robustness. Thanks @silent-reader-cn. (#5913)
- **Collapsed `read_file` tool rows now show the requested line range.** When the agent reads a slice of a file (`read_file` with `offset`/`limit`), the collapsed tool label now appends the range — e.g. `Read hermes_constants.py · L321-480`, or `L42` for an offset-only read — so you can see which part of a large file was read without expanding the row. Full-file reads and any tool call without a trustworthy positive-integer range keep the plain filename label (invalid, non-integer, non-positive, and overflowing ranges all fall back safely). Only `read_file` is affected. Thanks @franksong2702. (#6062) - **Collapsed `read_file` tool rows now show the requested line range.** When the agent reads a slice of a file (`read_file` with `offset`/`limit`), the collapsed tool label now appends the range — e.g. `Read hermes_constants.py · L321-480`, or `L42` for an offset-only read — so you can see which part of a large file was read without expanding the row. Full-file reads and any tool call without a trustworthy positive-integer range keep the plain filename label (invalid, non-integer, non-positive, and overflowing ranges all fall back safely). Only `read_file` is affected. Thanks @franksong2702. (#6062)
+63 -1
View File
@@ -1412,6 +1412,68 @@ window._hermesTtsSynth=function(id, text, opts){
}); });
}; };
// ── Session-open hook (for extensions) ────────────────────────────────────
var _HERMES_SESSION_OPEN_HANDLERS=[];
window.registerHermesSessionOpenHandler=function(fn){
if(typeof fn!=='function') return false;
if(_HERMES_SESSION_OPEN_HANDLERS.indexOf(fn)>=0) return false;
_HERMES_SESSION_OPEN_HANDLERS.push(fn);
return true;
};
window._hermesNotifySessionOpen=function(sid, data, opts){
opts=opts||{};
for(var i=0;i<_HERMES_SESSION_OPEN_HANDLERS.length;i++){
try{
var result=_HERMES_SESSION_OPEN_HANDLERS[i](sid, data, opts);
if(opts.preload===true && result&&result.cancel===true) return {cancel:true};
}catch(_){}
}
return {};
};
// ── Transcript renderer (for extensions) ───────────────────────────────────
window.renderTranscript=function(container, messages, opts){
if(!container||!Array.isArray(messages)) return container;
opts=opts||{};
container.innerHTML='';
var md=window.renderMd||null;
for(var i=0;i<messages.length;i++){
var msg=messages[i];
if(!msg||!msg.role||msg.role==='tool') continue;
var content;
if(typeof msg.content==='string'){
content=msg.content;
}else if(msg.content==null){
content='';
}else if(Array.isArray(msg.content)){
// Multi-part content (OpenAI/Anthropic API style) — concatenate text parts.
content=msg.content.map(function(p){return (p&&typeof p.text==='string')?p.text:''}).join('');
}else{
content=String(msg.content);
}
if(!content&&opts.skipEmpty) continue;
var row=document.createElement('div');
row.className='msg-row';
row.setAttribute('data-role',msg.role);
var body=document.createElement('div');
body.className='msg-body';
try{
if(md){
var html=md(content);
if(html!=null){body.innerHTML=html}else{body.textContent=content}
}else{
body.textContent=content;
}
}catch(_){body.textContent=content}
row.appendChild(body);
container.appendChild(row);
}
if(typeof _rehydrateTransparentStreamDom==='function'){
try{_rehydrateTransparentStreamDom(container);}catch(_){}
}
return container;
};
// ── Turn-based voice mode (#1333) ──────────────────────────────────────── // ── Turn-based voice mode (#1333) ────────────────────────────────────────
// Chained flow: listen → send → (agent processes) → TTS response → listen again // Chained flow: listen → send → (agent processes) → TTS response → listen again
(function(){ (function(){
@@ -3497,7 +3559,7 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
} }
if(typeof fetchReasoningChip==='function'&&(!_profileSwitchCompleted||!_profileSwitchChangedProfile)) fetchReasoningChip(); if(typeof fetchReasoningChip==='function'&&(!_profileSwitchCompleted||!_profileSwitchChangedProfile)) fetchReasoningChip();
// Fetch available models without blocking session restore. The static HTML // Fetch available models without blocking session restore. The static HTML
// options are enough for first paint; the dynamic provider list can settle // options enough for first paint; the dynamic provider list can settle
// after the saved session is visible. // after the saved session is visible.
const _redirectBootModelDropdownIfUnauth=(res)=>{ const _redirectBootModelDropdownIfUnauth=(res)=>{
if(!res||res.status!==401) return false; if(!res||res.status!==401) return false;
+29 -13
View File
@@ -1627,10 +1627,22 @@ async function _switchProfileForSessionLoad(profile){
async function loadSession(sid){ async function loadSession(sid){
const opts = arguments[1] || {}; const opts = arguments[1] || {};
// Resolve canonical lineage SID BEFORE both the direct and sidebar preload
// notifications so extensions always see the canonical session id, not the
// raw sidebar click id (which may differ after lineage folding).
if(!opts.skipLineageResolve && typeof _resolveSessionIdFromSidebarLineage==='function'){ if(!opts.skipLineageResolve && typeof _resolveSessionIdFromSidebarLineage==='function'){
const resolvedSid=_resolveSessionIdFromSidebarLineage(sid); const resolvedSid=_resolveSessionIdFromSidebarLineage(sid);
if(resolvedSid&&resolvedSid!==sid) sid=resolvedSid; if(resolvedSid&&resolvedSid!==sid) sid=resolvedSid;
} }
// Extension pre-open hook — fires once per sidebar click, not on every call.
// _openSidebarSession passes _preloadNotified:true so the hook isn't re-fired
// when loadSession runs the actual navigation inside it.
if(!opts.skipExtHooks && !opts._preloadNotified && typeof _hermesNotifySessionOpen==='function'){
var _preResult=_hermesNotifySessionOpen(sid, null, {preload:true, opts:opts});
if(_preResult&&_preResult.cancel===true){
return;
}
}
const forceReload = !!opts.force; const forceReload = !!opts.force;
const currentSid = S.session ? S.session.session_id : null; const currentSid = S.session ? S.session.session_id : null;
const sameSessionForceReload = forceReload && currentSid===sid; const sameSessionForceReload = forceReload && currentSid===sid;
@@ -1787,7 +1799,7 @@ async function loadSession(sid){
return; return;
} }
if (_isCurrentLoad()) _loadingSessionId = null; if (_isCurrentLoad()) _loadingSessionId = null;
return loadSession(sid,{...opts,skipProfileResolve:true,force:true}); return loadSession(sid,{...opts,skipProfileResolve:true,force:true,_preloadNotified:true});
}catch(switchErr){ }catch(switchErr){
e=switchErr; e=switchErr;
} }
@@ -1898,8 +1910,8 @@ async function loadSession(sid){
// cross-profile continuation can't poison restore state with an unusable id. // cross-profile continuation can't poison restore state with an unusable id.
const continuationSid=(data.session&&data.session.continuation_session_id)||''; const continuationSid=(data.session&&data.session.continuation_session_id)||'';
if(continuationSid&&continuationSid!==sid&&!opts.skipContinuationResolve){ if(continuationSid&&continuationSid!==sid&&!opts.skipContinuationResolve){
if (_isCurrentLoad()) _loadingSessionId=null; _loadingSessionId=null;
return loadSession(continuationSid,{...opts,skipLineageResolve:true,skipContinuationResolve:true,force:true}); return loadSession(continuationSid,{...opts,skipLineageResolve:true,skipContinuationResolve:true,force:true,_preloadNotified:true});
} }
S.session=data.session; S.session=data.session;
if(typeof _clearEmptyComposerModelOverride==='function') _clearEmptyComposerModelOverride(); if(typeof _clearEmptyComposerModelOverride==='function') _clearEmptyComposerModelOverride();
@@ -2327,6 +2339,10 @@ async function loadSession(sid){
} else { } else {
_hideHandoffHint(); _hideHandoffHint();
} }
// Extension post-load hook
if(!opts.skipExtHooks && typeof _hermesNotifySessionOpen==='function'){
try{ _hermesNotifySessionOpen(sid, S.session, {loaded:true, opts:opts}); }catch(_){}
}
} }
// ── Handoff hint logic ────────────────────────────────────────────────────── // ── Handoff hint logic ──────────────────────────────────────────────────────
@@ -2409,12 +2425,21 @@ async function _ensureSidebarSessionProfile(session){
async function _openSidebarSession(session, loadOpts={}){ async function _openSidebarSession(session, loadOpts={}){
if(!session||!session.session_id) return; if(!session||!session.session_id) return;
// Extension pre-open hook — before any side-effects (external import, profile switching).
// Handler returns {cancel:true} to prevent the open.
if(!loadOpts.skipExtHooks && typeof _hermesNotifySessionOpen==='function'){
var _preResult=_hermesNotifySessionOpen(session.session_id, null, {preload:true, opts:loadOpts});
if(_preResult&&_preResult.cancel===true) return;
}
// #5409: close mobile sidebar AFTER veto guard passes — only close if open proceeds.
if(typeof closeMobileSidebar==='function')closeMobileSidebar();
if(_isExternalSession(session)){ if(_isExternalSession(session)){
try{await api('/api/session/import_cli',{method:'POST',body:JSON.stringify(_externalImportPayload(session))});} try{await api('/api/session/import_cli',{method:'POST',body:JSON.stringify(_externalImportPayload(session))});}
catch(_e){ /* import failed -- fall through to read-only view */ } catch(_e){ /* import failed -- fall through to read-only view */ }
} }
await _ensureSidebarSessionProfile(session); await _ensureSidebarSessionProfile(session);
await loadSession(session.session_id, loadOpts); // Tell loadSession to skip its pre-hook — we already ran it above.
await loadSession(session.session_id, Object.assign({}, loadOpts, {_preloadNotified:true}));
renderSessionListFromCache(); renderSessionListFromCache();
} }
@@ -8083,8 +8108,6 @@ function renderSessionListFromCache(){
row.title=t('session_lineage_segment_open'); row.title=t('session_lineage_segment_open');
row.onclick=async(e)=>{ row.onclick=async(e)=>{
e.stopPropagation(); e.stopPropagation();
// #5409: close mobile sidebar synchronously before navigation
if(typeof closeMobileSidebar==='function')closeMobileSidebar();
await _openSidebarSession(seg, {skipLineageResolve:true}); await _openSidebarSession(seg, {skipLineageResolve:true});
}; };
lineageList.appendChild(row); lineageList.appendChild(row);
@@ -8097,8 +8120,6 @@ function renderSessionListFromCache(){
['pointerdown','pointerup','click','touchstart','touchmove','touchend','touchcancel'].forEach(ev=>childList.addEventListener(ev,e=>e.stopPropagation())); ['pointerdown','pointerup','click','touchstart','touchmove','touchend','touchcancel'].forEach(ev=>childList.addEventListener(ev,e=>e.stopPropagation()));
const sortedChildren=[...s._child_sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a)); const sortedChildren=[...s._child_sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
const openChildSession=async(childSession)=>{ const openChildSession=async(childSession)=>{
// #5409: close mobile sidebar synchronously before navigation
if(typeof closeMobileSidebar==='function')closeMobileSidebar();
await _openSidebarSession(childSession, {skipLineageResolve:true}); await _openSidebarSession(childSession, {skipLineageResolve:true});
}; };
const childLabelFor=(child)=>{ const childLabelFor=(child)=>{
@@ -8726,11 +8747,6 @@ function renderSessionListFromCache(){
if(_renamingSid) return; if(_renamingSid) return;
try{ try{
if(($('sessionSearch').value||'').trim()) _hideSearchPreviewsAfterSelect=true; if(($('sessionSearch').value||'').trim()) _hideSearchPreviewsAfterSelect=true;
// #5409: close mobile sidebar synchronously BEFORE awaiting _openSidebarSession
// so the user gets instant feedback that navigation is happening, even
// for large sessions where loadSession can take 3-15s (metadata fetch +
// message load + renderMessages DOM build on slow iOS WKWebView).
if(typeof closeMobileSidebar==='function')closeMobileSidebar();
await _openSidebarSession(s); await _openSidebarSession(s);
}finally{ }finally{
el.classList.remove('loading'); el.classList.remove('loading');
+372
View File
@@ -0,0 +1,372 @@
"""Functional tests for extension session-open hook + transcript renderer (PR #5508).
Existing static-shape tests verify _openSidebarSession exists but never
exercise the hook registration, preload-veto, transcript rendering, or
_preloadNotified bridge. This module uses Node.js to run the new functions
extracted from boot.js source.
"""
import json
import re
import subprocess
import textwrap
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
def _extract_block(src, signature):
start = src.find(signature)
assert start >= 0, f"missing: {signature!r}"
paren_close = src.index(")", start)
brace = src.index("{", paren_close)
depth = 0
for i, ch in enumerate(src[brace:], brace):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[start:i + 1]
raise AssertionError(f"unterminated: {signature!r}")
DOM_SHIM = r"""
if (typeof global.window === 'undefined') global.window = {};
class El {
constructor(n){this.children=[];this.attrs={};this.dataset={};
this._html='';this._text='';this.tagName=n;}
set className(v){this._cls=v;} get className(){return this._cls;}
set innerHTML(v){this._html=v;} get innerHTML(){return this._html;}
set textContent(v){this._text=v;} get textContent(){return this._text;}
setAttribute(k,v){this.attrs[k]=v;
if(k==='data-role')this.dataset.role=v;}
appendChild(c){this.children.push(c);
this._html = this.children.map(child => {
let a = '';
if (child._cls) a += ` class="${child._cls}"`;
if (child.attrs['data-role']) a += ` data-role="${child.attrs['data-role']}"`;
return `<${child.tagName}${a}>${child._html}</${child.tagName}>`;
}).join('');
return c;}
}
global.document={createElement:(n)=>new El(n)};
"""
def _run_in_tmp(tmp, body):
f = tmp / "run.js"
f.write_text(body)
proc = subprocess.run(["node", str(f)], capture_output=True, text=True,
timeout=10, cwd=str(tmp))
assert proc.returncode == 0, "node stderr: " + proc.stderr
return proc.stdout
def _module_level_vars(src, names):
"""Pull top-level `var X = ...;` lines for each name in `names`."""
out = []
for name in names:
m = re.search(rf"^\s*var\s+{re.escape(name)}\s*=\s*[^;]+;", src, re.M)
if m:
out.append(m.group(0).strip() + "\n")
else:
raise ValueError(f"Module-level var {name!r} not found")
return "".join(out)
def _fn_body(src, fn_name, alias, dep_vars=None):
"""Extract a function and assign it to `var <alias>`, with any module-level
vars it depends on."""
result = ""
if dep_vars:
result += _module_level_vars(src, dep_vars)
for sig in (f"window.{fn_name}=function", f"function {fn_name}("):
idx = src.find(sig)
if idx >= 0:
eq = src.index("=", idx)
if src[idx:].startswith("window."):
start = src.index("function", eq)
else:
start = idx
brace = src.index("{", start)
depth = 0
for i, ch in enumerate(src[brace:], brace):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
body = src[start:i + 1]
result += f"var {alias} = {body};\n"
return result
raise ValueError(f"Couldn't find {fn_name}")
# ── boot.js: renderTranscript() ──────────────────────────────────────────────
class TestRenderTranscript:
def test_tool_messages_skipped(self, tmp_path):
body = (
DOM_SHIM
+ _fn_body(BOOT_JS, "renderTranscript", "renderTranscript")
+ "global.window = {renderMd: (s)=>'<p>'+s+'</p>'};\n"
+ textwrap.dedent("""
var c = document.createElement('div');
renderTranscript(c, [
{"role":"user","content":"hi"},
{"role":"tool","content":"invisible"},
{"role":"assistant","content":"bye"}
], {});
process.stdout.write(c.innerHTML);
""")
)
out = _run_in_tmp(tmp_path, body)
assert "hi" in out
assert "bye" in out
assert "invisible" not in out
def test_array_content_concatenated(self, tmp_path):
body = (
DOM_SHIM
+ _fn_body(BOOT_JS, "renderTranscript", "renderTranscript")
+ "global.window = {renderMd: (s)=>'<p>'+s+'</p>'};\n"
+ textwrap.dedent("""
var c = document.createElement('div');
renderTranscript(c, [{"role":"user","content":[
{"type":"text","text":"Hello "},
{"type":"text","text":"world!"}
]}], {});
process.stdout.write(c.innerHTML);
""")
)
out = _run_in_tmp(tmp_path, body)
assert "Hello" in out and "world!" in out
assert "[object Object]" not in out
def test_skip_empty(self, tmp_path):
body = (
DOM_SHIM
+ _fn_body(BOOT_JS, "renderTranscript", "renderTranscript")
+ "global.window = {renderMd: (s)=>'<p>'+s+'</p>'};\n"
+ textwrap.dedent("""
var c = document.createElement('div');
renderTranscript(c, [
{"role":"user","content":"x"},
{"role":"user","content":""}
], {skipEmpty:true});
process.stdout.write(c.innerHTML);
""")
)
out = _run_in_tmp(tmp_path, body)
assert out.count("msg-row") == 1
def test_dom_no_extra_inner_wrapper(self, tmp_path):
body = (
DOM_SHIM
+ _fn_body(BOOT_JS, "renderTranscript", "renderTranscript")
+ "global.window = {renderMd: (s)=>'<p>'+s+'</p>'};\n"
+ textwrap.dedent("""
var c = document.createElement('div');
renderTranscript(c, [{"role":"user","content":"x"}], {});
process.stdout.write(c.innerHTML);
""")
)
out = _run_in_tmp(tmp_path, body)
assert 'class="msg-body"' in out
assert "msg-body-inner" not in out
def test_fallback_textcontent_when_renderMd_missing(self, tmp_path):
body = (
DOM_SHIM
+ _fn_body(BOOT_JS, "renderTranscript", "renderTranscript")
+ textwrap.dedent("""
var c = document.createElement('div');
delete window.renderMd;
renderTranscript(c, [{
"role":"user",
"content":"<script>x</script>"
}], {});
process.stdout.write(c.innerHTML || c.textContent);
""")
)
out = _run_in_tmp(tmp_path, body)
assert "<script>x</script>" not in out
# ── boot.js: hook registration ───────────────────────────────────────────────
class TestHookRegistration:
def test_register_dedupe_and_type_guard(self, tmp_path):
body = (
_fn_body(BOOT_JS, "registerHermesSessionOpenHandler",
"registerHermesSessionOpenHandler",
dep_vars=["_HERMES_SESSION_OPEN_HANDLERS"])
+ _fn_body(BOOT_JS, "_hermesNotifySessionOpen",
"_hermesNotifySessionOpen")
+ textwrap.dedent("""
var calls = 0;
function handler(sid, data, opts) { calls++; return null; }
var r1 = registerHermesSessionOpenHandler(handler);
var r2 = registerHermesSessionOpenHandler("nope");
var r3 = registerHermesSessionOpenHandler(handler);
_hermesNotifySessionOpen("s1", null, {preload:true});
process.stdout.write(JSON.stringify({
r1:r1,r2:r2,r3:r3,calls:calls
}));
""")
)
out = _run_in_tmp(tmp_path, body)
data = json.loads(out)
assert data["r1"] is True
assert data["r2"] is False
assert data["r3"] is False
assert data["calls"] == 1
def test_preload_cancel_honored(self, tmp_path):
body = (
_fn_body(BOOT_JS, "registerHermesSessionOpenHandler",
"registerHermesSessionOpenHandler",
dep_vars=["_HERMES_SESSION_OPEN_HANDLERS"])
+ _fn_body(BOOT_JS, "_hermesNotifySessionOpen",
"_hermesNotifySessionOpen")
+ textwrap.dedent("""
function blocker(){ return {cancel:true}; }
registerHermesSessionOpenHandler(blocker);
var r = _hermesNotifySessionOpen("s1", null, {preload:true});
process.stdout.write(JSON.stringify(r));
""")
)
out = _run_in_tmp(tmp_path, body)
data = json.loads(out)
assert data.get("cancel") is True
def test_loaded_remains_non_cancellable(self, tmp_path):
body = (
_fn_body(BOOT_JS, "registerHermesSessionOpenHandler",
"registerHermesSessionOpenHandler",
dep_vars=["_HERMES_SESSION_OPEN_HANDLERS"])
+ _fn_body(BOOT_JS, "_hermesNotifySessionOpen",
"_hermesNotifySessionOpen")
+ textwrap.dedent("""
var called = false;
function h(){ called = true; return {cancel:true}; }
registerHermesSessionOpenHandler(h);
_hermesNotifySessionOpen("s2", {session_id:"s2"}, {loaded:true});
process.stdout.write(JSON.stringify({called:called}));
""")
)
out = _run_in_tmp(tmp_path, body)
data = json.loads(out)
assert data["called"] is True
def test_each_handler_sees_one_event(self, tmp_path):
body = (
_fn_body(BOOT_JS, "registerHermesSessionOpenHandler",
"registerHermesSessionOpenHandler",
dep_vars=["_HERMES_SESSION_OPEN_HANDLERS"])
+ _fn_body(BOOT_JS, "_hermesNotifySessionOpen",
"_hermesNotifySessionOpen")
+ textwrap.dedent("""
var events = [];
function h1(sid,d,o){events.push({h:1,sid:sid,preload:!!o.preload});}
function h2(sid,d,o){events.push({h:2,sid:sid,preload:!!o.preload});}
registerHermesSessionOpenHandler(h1);
registerHermesSessionOpenHandler(h2);
_hermesNotifySessionOpen("target", null, {preload:true});
process.stdout.write(JSON.stringify(events));
""")
)
out = _run_in_tmp(tmp_path, body)
data = json.loads(out)
assert len(data) == 2
assert all(ev["preload"] for ev in data)
assert data[0]["h"] == 1 and data[1]["h"] == 2
# ── sessions.js: static shape (controls + integration points) ────────────────
def test_canonical_sid_resolved_before_preload_notification():
body = _extract_block(SESSIONS_JS, "async function loadSession(sid)")
idx_resolve = body.index("_resolveSessionIdFromSidebarLineage")
idx_preload = body.index("_hermesNotifySessionOpen")
assert idx_resolve < idx_preload
def test_preload_veto_only_on_preload_phase():
body = _extract_block(BOOT_JS, "window._hermesNotifySessionOpen=function")
assert "opts.preload" in body
def test_continuation_retry_carries_preload_notified_flag():
body = _extract_block(SESSIONS_JS, "async function loadSession(sid)")
idx_cont = body.index("continuationSid=")
cont_branch = body[idx_cont:idx_cont + 400]
assert "_preloadNotified:true" in cont_branch
def test_bridge_flag_passed_by_sidebar_open():
body = _extract_block(SESSIONS_JS, "async function _openSidebarSession(")
assert "_preloadNotified:true" in body
# ── Regression: {cancel:true} must leave NO side-effect ─────────────────────
def test_cancel_does_not_close_mobile_sidebar():
"""A {cancel:true} handler must not close the sidebar.
Regression: closeMobileSidebar() was called synchronously BEFORE
_openSidebarSession()'s veto guard, so a cancel still closed it.
Fix: the three early calls are gone; one now sits AFTER the guard.
"""
# The only remaining closeMobileSidebar() inside _openSidebarSession
# must appear AFTER the cancel guard, not before it.
body = _extract_block(SESSIONS_JS, "async function _openSidebarSession(")
idx_cancel = body.index("_preResult&&_preResult.cancel===true")
idx_close = body.index("closeMobileSidebar()")
assert idx_close > idx_cancel, (
"closeMobileSidebar() must run AFTER the cancel guard"
)
def test_no_early_closemobilesidebar_before_sidebar_open():
"""The three premature closeMobileSidebar() calls before _openSidebarSession
must be removed — a {cancel:true} veto must not close the sidebar."""
# Tap-to-open handler: the setTimeout callback that calls _openSidebarSession
# must NOT contain a closeMobileSidebar() before the await.
tap_block = _extract_block(SESSIONS_JS, "_tapTimer=setTimeout(async()=>{")
# Find the _openSidebarSession call inside the tap handler.
idx_open = tap_block.index("await _openSidebarSession(s)")
before_open = tap_block[:idx_open]
assert "closeMobileSidebar()" not in before_open, (
"tap-to-open handler must not closeMobileSidebar() before veto"
)
# Child-session open handler must not contain closeMobileSidebar() at all.
child_block = _extract_block(SESSIONS_JS, "const openChildSession=async(childSession)=>{")
assert "closeMobileSidebar()" not in child_block, (
"openChildSession handler must not contain closeMobileSidebar()"
)
# Lineage-segment handler must not contain closeMobileSidebar() at all.
lineage_block = _extract_block(SESSIONS_JS, "row.onclick=async(e)=>{")
assert "closeMobileSidebar()" not in lineage_block, (
"lineage-segment handler must not contain closeMobileSidebar()"
)
def test_cross_profile_retry_carries_preload_notified():
"""Cross-profile retry must pass _preloadNotified:true so the pre-hook
doesn't re-fire after destructive side-effects already ran."""
body = _extract_block(SESSIONS_JS, "async function loadSession(sid)")
idx_profile = body.index("skipProfileResolve:true")
profile_branch = body[idx_profile:idx_profile + 200]
assert "_preloadNotified:true" in profile_branch
@@ -203,7 +203,7 @@ def test_static_sessions_js_switches_profile_before_opening_all_profiles_row():
assert "await switchToProfile(targetProfile);" in ensure_body assert "await switchToProfile(targetProfile);" in ensure_body
assert "_profileSwitchOpeningExistingSession=true;" in ensure_body assert "_profileSwitchOpeningExistingSession=true;" in ensure_body
assert open_body.index("await _ensureSidebarSessionProfile(session);") < open_body.index("await loadSession(session.session_id, loadOpts);") assert open_body.index("await _ensureSidebarSessionProfile(session);") < open_body.index("await loadSession(session.session_id,")
assert "await _openSidebarSession(s);" in src assert "await _openSidebarSession(s);" in src
assert "await _openSidebarSession(seg, {skipLineageResolve:true});" in src assert "await _openSidebarSession(seg, {skipLineageResolve:true});" in src
assert "await _openSidebarSession(childSession, {skipLineageResolve:true});" in src assert "await _openSidebarSession(childSession, {skipLineageResolve:true});" in src