* 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>
373 lines
15 KiB
Python
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
|