* 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:
5 files changed
+467
-15
No files matched your search
@@ -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
@@ -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
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user