Files
hermes-webui/tests/test_extension_session_hooks.py
T
735ba1c4d3 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>
2026-07-18 00:53:17 -07:00

373 lines
15 KiB
Python

"""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