diff --git a/CHANGELOG.md b/CHANGELOG.md index bd8e37d2..f176ea99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ ### 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) - **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) diff --git a/static/boot.js b/static/boot.js index 0a8f7774..8e9a25ad 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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{ if(!res||res.status!==401) return false; diff --git a/static/sessions.js b/static/sessions.js index 8bacdb90..8c893735 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1627,10 +1627,22 @@ async function _switchProfileForSessionLoad(profile){ async function loadSession(sid){ 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'){ const resolvedSid=_resolveSessionIdFromSidebarLineage(sid); 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 currentSid = S.session ? S.session.session_id : null; const sameSessionForceReload = forceReload && currentSid===sid; @@ -1787,7 +1799,7 @@ async function loadSession(sid){ return; } if (_isCurrentLoad()) _loadingSessionId = null; - return loadSession(sid,{...opts,skipProfileResolve:true,force:true}); + return loadSession(sid,{...opts,skipProfileResolve:true,force:true,_preloadNotified:true}); }catch(switchErr){ e=switchErr; } @@ -1898,8 +1910,8 @@ async function loadSession(sid){ // cross-profile continuation can't poison restore state with an unusable id. const continuationSid=(data.session&&data.session.continuation_session_id)||''; if(continuationSid&&continuationSid!==sid&&!opts.skipContinuationResolve){ - if (_isCurrentLoad()) _loadingSessionId=null; - return loadSession(continuationSid,{...opts,skipLineageResolve:true,skipContinuationResolve:true,force:true}); + _loadingSessionId=null; + return loadSession(continuationSid,{...opts,skipLineageResolve:true,skipContinuationResolve:true,force:true,_preloadNotified:true}); } S.session=data.session; if(typeof _clearEmptyComposerModelOverride==='function') _clearEmptyComposerModelOverride(); @@ -2327,6 +2339,10 @@ async function loadSession(sid){ } else { _hideHandoffHint(); } + // Extension post-load hook + if(!opts.skipExtHooks && typeof _hermesNotifySessionOpen==='function'){ + try{ _hermesNotifySessionOpen(sid, S.session, {loaded:true, opts:opts}); }catch(_){} + } } // ── Handoff hint logic ────────────────────────────────────────────────────── @@ -2409,12 +2425,21 @@ async function _ensureSidebarSessionProfile(session){ async function _openSidebarSession(session, loadOpts={}){ 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)){ try{await api('/api/session/import_cli',{method:'POST',body:JSON.stringify(_externalImportPayload(session))});} catch(_e){ /* import failed -- fall through to read-only view */ } } 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(); } @@ -8083,8 +8108,6 @@ function renderSessionListFromCache(){ row.title=t('session_lineage_segment_open'); row.onclick=async(e)=>{ e.stopPropagation(); - // #5409: close mobile sidebar synchronously before navigation - if(typeof closeMobileSidebar==='function')closeMobileSidebar(); await _openSidebarSession(seg, {skipLineageResolve:true}); }; lineageList.appendChild(row); @@ -8097,8 +8120,6 @@ function renderSessionListFromCache(){ ['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 openChildSession=async(childSession)=>{ - // #5409: close mobile sidebar synchronously before navigation - if(typeof closeMobileSidebar==='function')closeMobileSidebar(); await _openSidebarSession(childSession, {skipLineageResolve:true}); }; const childLabelFor=(child)=>{ @@ -8726,11 +8747,6 @@ function renderSessionListFromCache(){ if(_renamingSid) return; try{ 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); }finally{ el.classList.remove('loading'); diff --git a/tests/test_extension_session_hooks.py b/tests/test_extension_session_hooks.py new file mode 100644 index 00000000..90f12f0f --- /dev/null +++ b/tests/test_extension_session_hooks.py @@ -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}`; + }).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 `, 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)=>'

'+s+'

'};\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)=>'

'+s+'

'};\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)=>'

'+s+'

'};\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)=>'

'+s+'

'};\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":"" + }], {}); + process.stdout.write(c.innerHTML || c.textContent); + """) + ) + out = _run_in_tmp(tmp_path, body) + assert "" 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 diff --git a/tests/test_issue1611_session_profile_filtering.py b/tests/test_issue1611_session_profile_filtering.py index 63071a64..2ca830f8 100644 --- a/tests/test_issue1611_session_profile_filtering.py +++ b/tests/test_issue1611_session_profile_filtering.py @@ -203,7 +203,7 @@ def test_static_sessions_js_switches_profile_before_opening_all_profiles_row(): assert "await switchToProfile(targetProfile);" 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(seg, {skipLineageResolve:true});" in src assert "await _openSidebarSession(childSession, {skipLineageResolve:true});" in src