Merge #4969 into stage
This commit is contained in:
commit
373c3e4b63
6 changed files with 427 additions and 11 deletions
|
|
@ -99,6 +99,30 @@ async function _savedSessionSidebarOnlyState(sid){
|
|||
}
|
||||
|
||||
// ── Mobile navigation ──────────────────────────────────────────────────────
|
||||
// URL prefill boot helpers.
|
||||
function _prefillHasDraftText(prefillIntent){
|
||||
return !!(prefillIntent&&prefillIntent.hasText);
|
||||
}
|
||||
function _rootPrefillNeedsFreshComposer(urlSession, savedLocal, prefillIntent){
|
||||
return !urlSession&&!!savedLocal&&_prefillHasDraftText(prefillIntent);
|
||||
}
|
||||
async function _applyComposerPrefillOnBoot(prefillIntent){
|
||||
if(!prefillIntent||!prefillIntent.hasText) return;
|
||||
const msg=(typeof $==='function')?$('msg'):document.getElementById('msg');
|
||||
if(!msg) return;
|
||||
const text=String(prefillIntent.text||'');
|
||||
msg.value=text;
|
||||
if(typeof autoResize==='function') autoResize();
|
||||
else if(typeof updateSendBtn==='function') updateSendBtn();
|
||||
}
|
||||
async function _finalizeComposerPrefillOnBoot(prefillIntent){
|
||||
if(prefillIntent&&prefillIntent.hasParams&&typeof _consumeComposerPrefillParamsFromLocation==='function'){
|
||||
_consumeComposerPrefillParamsFromLocation();
|
||||
}
|
||||
await _applyComposerPrefillOnBoot(prefillIntent);
|
||||
}
|
||||
|
||||
// Mobile navigation.
|
||||
let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview'
|
||||
|
||||
function _isCompactWorkspaceViewport(){
|
||||
|
|
@ -224,7 +248,8 @@ function handleWorkspaceClose(){
|
|||
closeWorkspacePanel();
|
||||
}
|
||||
|
||||
async function _maybeBindFreshDefaultWorkspaceSession(){
|
||||
async function _maybeBindFreshDefaultWorkspaceSession(prefillIntent=null){
|
||||
if(_prefillHasDraftText(prefillIntent)) return false;
|
||||
if(S.session) return false;
|
||||
if(_workspacePanelMode!=='browse') return false;
|
||||
if(!S._profileDefaultWorkspace) return false;
|
||||
|
|
@ -2424,6 +2449,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
|
|||
(async()=>{
|
||||
// Load send key preference
|
||||
let _bootSettings={};
|
||||
const prefillIntent=(typeof _composerPrefillIntentFromLocation==='function')?_composerPrefillIntentFromLocation():null;
|
||||
try{
|
||||
const s=await api('/api/settings');
|
||||
_bootSettings=s;
|
||||
|
|
@ -2822,7 +2848,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
|
|||
try{Promise.resolve(_startBootModelDropdown()).catch(()=>{});}catch(_){}
|
||||
}
|
||||
S._bootReady=true;
|
||||
syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();return;
|
||||
syncTopbar();syncWorkspacePanelState();await renderSessionList();await _finalizeComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE();return;
|
||||
}catch(e){console.warn('[pwa] new-chat launch action failed', e);}
|
||||
}
|
||||
const savedLocal=localStorage.getItem('hermes-webui-session');
|
||||
|
|
@ -2840,7 +2866,19 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
|
|||
S._bootReady=true;
|
||||
syncTopbar();syncWorkspacePanelState();
|
||||
$('emptyState').style.display='';
|
||||
await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();
|
||||
await renderSessionList();await _finalizeComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE();
|
||||
return;
|
||||
}
|
||||
if(_rootPrefillNeedsFreshComposer(urlSession, savedLocal, prefillIntent)){
|
||||
S.session=null; S.messages=[]; S.activeStreamId=null; S.busy=false;
|
||||
S._bootReady=true;
|
||||
const _ephPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
|
||||
|| localStorage.getItem('hermes-webui-workspace-panel')==='open';
|
||||
if(_ephPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';
|
||||
await _maybeBindFreshDefaultWorkspaceSession(prefillIntent);
|
||||
syncTopbar();syncWorkspacePanelState();
|
||||
$('emptyState').style.display='';
|
||||
await renderSessionList();await _finalizeComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE();
|
||||
return;
|
||||
}
|
||||
await loadSession(saved, {preserveActiveInput:true});
|
||||
|
|
@ -2875,10 +2913,10 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
|
|||
const _ephPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
|
||||
|| localStorage.getItem('hermes-webui-workspace-panel')==='open';
|
||||
if(_ephPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';
|
||||
await _maybeBindFreshDefaultWorkspaceSession();
|
||||
await _maybeBindFreshDefaultWorkspaceSession(prefillIntent);
|
||||
syncTopbar();syncWorkspacePanelState();
|
||||
$('emptyState').style.display='';
|
||||
await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();
|
||||
await renderSessionList();await _finalizeComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE();
|
||||
return;
|
||||
}
|
||||
// Restore the panel from localStorage when the session has a workspace.
|
||||
|
|
@ -2890,7 +2928,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
|
|||
_workspacePanelMode='browse';
|
||||
}
|
||||
S._bootReady=true;
|
||||
syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
|
||||
syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);await _finalizeComposerPrefillOnBoot(prefillIntent);return;}
|
||||
catch(e){localStorage.removeItem('hermes-webui-session');}
|
||||
}
|
||||
// no saved session - show empty state, wait for user to hit +
|
||||
|
|
@ -2901,10 +2939,10 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
|
|||
const _freshPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
|
||||
|| localStorage.getItem('hermes-webui-workspace-panel')==='open';
|
||||
if(_freshPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';
|
||||
await _maybeBindFreshDefaultWorkspaceSession();
|
||||
await _maybeBindFreshDefaultWorkspaceSession(prefillIntent);
|
||||
syncWorkspacePanelState();
|
||||
$('emptyState').style.display='';
|
||||
await renderSessionList();
|
||||
await renderSessionList();await _finalizeComposerPrefillOnBoot(prefillIntent);
|
||||
// Start real-time gateway session sync if setting is enabled
|
||||
if(typeof startGatewaySSE==='function') startGatewaySSE();
|
||||
})().catch(e=>{
|
||||
|
|
|
|||
|
|
@ -3303,6 +3303,38 @@ function _sessionIdFromLocation(){
|
|||
return qs.get('session')||qs.get('session_id')||null;
|
||||
}catch(_e){return null;}
|
||||
}
|
||||
function _composerPrefillIntentFromLocation(){
|
||||
const empty={hasParams:false,hasText:false,text:'',autoSend:false};
|
||||
if(typeof window==='undefined'||!window.location) return empty;
|
||||
try{
|
||||
const qs=new URLSearchParams(window.location.search||'');
|
||||
const hasQ=qs.has('q');
|
||||
const hasPrompt=qs.has('prompt');
|
||||
const hasSend=qs.has('send');
|
||||
if(!hasQ&&!hasPrompt&&!hasSend) return empty;
|
||||
const text=hasQ?(qs.get('q')||''):(hasPrompt?(qs.get('prompt')||''):'');
|
||||
return {
|
||||
hasParams:true,
|
||||
hasText:!!String(text).trim(),
|
||||
text,
|
||||
autoSend:false
|
||||
};
|
||||
}catch(_e){return empty;}
|
||||
}
|
||||
function _consumeComposerPrefillParamsFromLocation(){
|
||||
if(typeof window==='undefined'||!window.location||!window.history||typeof window.history.replaceState!=='function') return;
|
||||
try{
|
||||
const current=new URL(window.location.href);
|
||||
const before=current.searchParams.toString();
|
||||
current.searchParams.delete('q');
|
||||
current.searchParams.delete('prompt');
|
||||
current.searchParams.delete('send');
|
||||
const after=current.searchParams.toString();
|
||||
if(after===before) return;
|
||||
const next=current.pathname+(after?`?${after}`:'')+(current.hash||'');
|
||||
window.history.replaceState(window.history.state||null,'',next);
|
||||
}catch(_e){}
|
||||
}
|
||||
function _appRootPath(){
|
||||
try{
|
||||
const base = new URL(document.baseURI||window.location.origin+'/', window.location.origin);
|
||||
|
|
@ -3318,6 +3350,9 @@ function _sessionUrlForSid(sid){
|
|||
const current=new URL(window.location.href);
|
||||
current.searchParams.delete('session');
|
||||
current.searchParams.delete('session_id');
|
||||
current.searchParams.delete('q');
|
||||
current.searchParams.delete('prompt');
|
||||
current.searchParams.delete('send');
|
||||
base.search=current.searchParams.toString();
|
||||
base.hash=current.hash;
|
||||
}catch(_e){}
|
||||
|
|
|
|||
312
tests/test_4961_url_query_prefill.py
Normal file
312
tests/test_4961_url_query_prefill.py
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
"""Regression tests for #4961 URL query composer prefill behavior."""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent.resolve()
|
||||
SESSIONS_JS_PATH = REPO_ROOT / "static" / "sessions.js"
|
||||
BOOT_JS_PATH = REPO_ROOT / "static" / "boot.js"
|
||||
SESSIONS_JS = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
||||
BOOT_JS = BOOT_JS_PATH.read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
|
||||
|
||||
def _run_node(source: str) -> str:
|
||||
result = subprocess.run(
|
||||
[NODE],
|
||||
input=source,
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _node_prelude() -> str:
|
||||
return f"""
|
||||
const sessionsSrc = {SESSIONS_JS!r};
|
||||
const bootSrc = {BOOT_JS!r};
|
||||
function extractFunc(src, name) {{
|
||||
const re = new RegExp('(?:async\\\\s+)?function\\\\s+' + name + '\\\\s*\\\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{{', start);
|
||||
let depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {{
|
||||
if (src[i] === '{{') depth++;
|
||||
else if (src[i] === '}}') depth--;
|
||||
i++;
|
||||
}}
|
||||
return src.slice(start, i);
|
||||
}}
|
||||
function evalSession(name) {{
|
||||
globalThis[name] = (0, eval)('(' + extractFunc(sessionsSrc, name) + ')');
|
||||
}}
|
||||
function evalBoot(name) {{
|
||||
globalThis[name] = (0, eval)('(' + extractFunc(bootSrc, name) + ')');
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def test_prefill_intent_parses_q_prompt_and_send_flag():
|
||||
source = _node_prelude() + """
|
||||
global.window = { location: { search: '?prompt=backup&q=hello%20world&send=YES' } };
|
||||
evalSession('_composerPrefillIntentFromLocation');
|
||||
const first = _composerPrefillIntentFromLocation();
|
||||
window.location.search = '?prompt=from+prompt';
|
||||
const second = _composerPrefillIntentFromLocation();
|
||||
window.location.search = '?q=%20%20&send=1';
|
||||
const third = _composerPrefillIntentFromLocation();
|
||||
console.log(JSON.stringify({ first, second, third }));
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload["first"] == {
|
||||
"hasParams": True,
|
||||
"hasText": True,
|
||||
"text": "hello world",
|
||||
"autoSend": False,
|
||||
}
|
||||
assert payload["second"] == {
|
||||
"hasParams": True,
|
||||
"hasText": True,
|
||||
"text": "from prompt",
|
||||
"autoSend": False,
|
||||
}
|
||||
assert payload["third"] == {
|
||||
"hasParams": True,
|
||||
"hasText": False,
|
||||
"text": " ",
|
||||
"autoSend": False,
|
||||
}
|
||||
|
||||
|
||||
def test_prefill_cleanup_removes_only_consumed_query_params():
|
||||
source = _node_prelude() + """
|
||||
function applyUrl(rel) {
|
||||
const next = new URL(rel, 'https://example.test');
|
||||
window.location.href = next.href;
|
||||
window.location.pathname = next.pathname;
|
||||
window.location.search = next.search;
|
||||
window.location.hash = next.hash;
|
||||
}
|
||||
global.window = {
|
||||
location: {},
|
||||
history: {
|
||||
state: { from: 'test' },
|
||||
calls: [],
|
||||
replaceState(state, title, url) {
|
||||
this.calls.push({ state, title, url });
|
||||
this.state = state;
|
||||
applyUrl(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
global.document = { baseURI: 'https://example.test/app/' };
|
||||
applyUrl('/app/?q=hello&prompt=backup&send=1&session_id=target&keep=1#frag');
|
||||
evalSession('_consumeComposerPrefillParamsFromLocation');
|
||||
evalSession('_sessionUrlForSid');
|
||||
_consumeComposerPrefillParamsFromLocation();
|
||||
const cleaned = window.history.calls[0];
|
||||
const promoted = _sessionUrlForSid('abc 123');
|
||||
console.log(JSON.stringify({ cleaned, promoted }));
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload["cleaned"]["url"] == "/app/?session_id=target&keep=1#frag"
|
||||
assert payload["cleaned"]["state"] == {"from": "test"}
|
||||
assert payload["promoted"] == "/app/session/abc%20123?keep=1#frag"
|
||||
|
||||
|
||||
def test_root_prefill_keeps_saved_local_sidebar_only():
|
||||
source = _node_prelude() + """
|
||||
evalBoot('_prefillHasDraftText');
|
||||
evalBoot('_rootPrefillNeedsFreshComposer');
|
||||
const result = {
|
||||
savedLocalWins: _rootPrefillNeedsFreshComposer(null, 'saved-local', { hasText: true }),
|
||||
explicitSessionWins: _rootPrefillNeedsFreshComposer('url-session', 'saved-local', { hasText: true }),
|
||||
blankPrefillIgnored: _rootPrefillNeedsFreshComposer(null, 'saved-local', { hasText: false })
|
||||
};
|
||||
console.log(JSON.stringify(result));
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload == {
|
||||
"savedLocalWins": True,
|
||||
"explicitSessionWins": False,
|
||||
"blankPrefillIgnored": False,
|
||||
}
|
||||
prefill_guard = BOOT_JS.find("if(_rootPrefillNeedsFreshComposer(urlSession, savedLocal, prefillIntent)){")
|
||||
saved_state_pos = BOOT_JS.find("const savedSidebarOnlyState=(!urlSession&&savedLocal)")
|
||||
saved_guard = BOOT_JS.find(
|
||||
"if(savedSidebarOnlyState&&savedSidebarOnlyState.sidebarOnly){",
|
||||
saved_state_pos,
|
||||
)
|
||||
load_pos = BOOT_JS.find("await loadSession(saved, {preserveActiveInput:true});")
|
||||
assert prefill_guard >= 0
|
||||
assert saved_state_pos >= 0
|
||||
assert saved_guard > saved_state_pos
|
||||
assert prefill_guard > saved_guard
|
||||
assert load_pos > prefill_guard
|
||||
|
||||
|
||||
def test_fresh_default_workspace_bind_skips_prefill_draft_boot():
|
||||
source = _node_prelude() + """
|
||||
(async () => {
|
||||
evalBoot('_prefillHasDraftText');
|
||||
evalBoot('_maybeBindFreshDefaultWorkspaceSession');
|
||||
global.S = { session: null, _profileDefaultWorkspace: 'D:/workspace' };
|
||||
global._workspacePanelMode = 'browse';
|
||||
const calls = [];
|
||||
global.newSession = async () => { calls.push('newSession'); };
|
||||
const skipped = await _maybeBindFreshDefaultWorkspaceSession({ hasText: true });
|
||||
const bound = await _maybeBindFreshDefaultWorkspaceSession({ hasText: false });
|
||||
console.log(JSON.stringify({ skipped, bound, calls }));
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload == {
|
||||
"skipped": False,
|
||||
"bound": True,
|
||||
"calls": ["newSession"],
|
||||
}
|
||||
|
||||
|
||||
def test_apply_prefill_updates_composer_without_autosending():
|
||||
source = _node_prelude() + """
|
||||
(async () => {
|
||||
evalBoot('_applyComposerPrefillOnBoot');
|
||||
const counts = { autoResize: 0, updateSendBtn: 0, send: 0 };
|
||||
const msg = { value: '' };
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
return id === 'msg' ? msg : null;
|
||||
}
|
||||
};
|
||||
global.$ = (id) => document.getElementById(id);
|
||||
global.autoResize = () => { counts.autoResize++; };
|
||||
global.updateSendBtn = () => { counts.updateSendBtn++; };
|
||||
global.send = async () => { counts.send++; };
|
||||
await _applyComposerPrefillOnBoot({ hasText: true, text: 'hello world', autoSend: true });
|
||||
delete global.autoResize;
|
||||
await _applyComposerPrefillOnBoot({ hasText: true, text: 'second pass', autoSend: false });
|
||||
await _applyComposerPrefillOnBoot({ hasText: false, text: ' ', autoSend: true });
|
||||
console.log(JSON.stringify({ counts, value: msg.value }));
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload["counts"] == {"autoResize": 1, "updateSendBtn": 1, "send": 0}
|
||||
assert payload["value"] == "second pass"
|
||||
|
||||
|
||||
def test_terminal_prefill_step_consumes_params_after_boot_state_is_ready():
|
||||
source = _node_prelude() + """
|
||||
(async () => {
|
||||
evalBoot('_applyComposerPrefillOnBoot');
|
||||
evalBoot('_finalizeComposerPrefillOnBoot');
|
||||
const counts = { autoResize: 0, updateSendBtn: 0, consume: 0, send: 0 };
|
||||
const msg = { value: '' };
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
return id === 'msg' ? msg : null;
|
||||
}
|
||||
};
|
||||
global.$ = (id) => document.getElementById(id);
|
||||
global.autoResize = () => { counts.autoResize++; };
|
||||
global.updateSendBtn = () => { counts.updateSendBtn++; };
|
||||
global.send = async () => { counts.send++; };
|
||||
global._consumeComposerPrefillParamsFromLocation = () => { counts.consume++; };
|
||||
await _finalizeComposerPrefillOnBoot({ hasParams: true, hasText: true, text: 'hello world', autoSend: false });
|
||||
delete global.autoResize;
|
||||
await _finalizeComposerPrefillOnBoot({ hasParams: false, hasText: true, text: 'second pass', autoSend: false });
|
||||
console.log(JSON.stringify({ counts, value: msg.value }));
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload["counts"] == {
|
||||
"autoResize": 1,
|
||||
"updateSendBtn": 1,
|
||||
"consume": 1,
|
||||
"send": 0,
|
||||
}
|
||||
assert payload["value"] == "second pass"
|
||||
|
||||
|
||||
def test_explicit_session_url_keeps_target_session_with_prefill():
|
||||
source = _node_prelude() + """
|
||||
global.window = {
|
||||
location: {
|
||||
pathname: '/session/url-target',
|
||||
search: '?q=hello%20world&send=1',
|
||||
hash: ''
|
||||
}
|
||||
};
|
||||
evalSession('_sessionIdFromLocation');
|
||||
evalBoot('_prefillHasDraftText');
|
||||
evalBoot('_rootPrefillNeedsFreshComposer');
|
||||
console.log(JSON.stringify({
|
||||
urlSession: _sessionIdFromLocation(),
|
||||
bypassRootOverride: _rootPrefillNeedsFreshComposer(_sessionIdFromLocation(), 'saved-local', { hasText: true })
|
||||
}));
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload == {"urlSession": "url-target", "bypassRootOverride": False}
|
||||
query_source = _node_prelude() + """
|
||||
global.window = {
|
||||
location: {
|
||||
pathname: '/',
|
||||
search: '?session_id=query-target&q=hello%20world&send=1',
|
||||
hash: ''
|
||||
}
|
||||
};
|
||||
evalSession('_sessionIdFromLocation');
|
||||
evalBoot('_prefillHasDraftText');
|
||||
evalBoot('_rootPrefillNeedsFreshComposer');
|
||||
console.log(JSON.stringify({
|
||||
urlSession: _sessionIdFromLocation(),
|
||||
bypassRootOverride: _rootPrefillNeedsFreshComposer(_sessionIdFromLocation(), 'saved-local', { hasText: true })
|
||||
}));
|
||||
"""
|
||||
query_payload = json.loads(_run_node(query_source))
|
||||
assert query_payload == {"urlSession": "query-target", "bypassRootOverride": False}
|
||||
prefill_pos = BOOT_JS.find(
|
||||
"const prefillIntent=(typeof _composerPrefillIntentFromLocation==='function')?_composerPrefillIntentFromLocation():null;"
|
||||
)
|
||||
first_await_pos = BOOT_JS.find("const s=await api('/api/settings');", prefill_pos)
|
||||
active_profile_pos = BOOT_JS.find(
|
||||
"const activeProfileState = await _resolveActiveProfileBootstrapState();",
|
||||
first_await_pos,
|
||||
)
|
||||
new_pos = BOOT_JS.find("await newSession(true);", active_profile_pos)
|
||||
load_pos = BOOT_JS.find("await loadSession(saved, {preserveActiveInput:true});", active_profile_pos)
|
||||
saved_pos = BOOT_JS.find("const saved=urlSession||savedLocal;")
|
||||
check_pos = BOOT_JS.find("await checkInflightOnBoot(saved);", load_pos)
|
||||
apply_pos = BOOT_JS.find("await _finalizeComposerPrefillOnBoot(prefillIntent);", check_pos)
|
||||
assert saved_pos >= 0
|
||||
assert prefill_pos >= 0
|
||||
assert active_profile_pos > first_await_pos
|
||||
assert "_consumeComposerPrefillParamsFromLocation();" not in BOOT_JS[prefill_pos:first_await_pos]
|
||||
assert 0 <= active_profile_pos < new_pos
|
||||
assert 0 <= active_profile_pos < load_pos
|
||||
assert 0 <= check_pos < apply_pos
|
||||
assert BOOT_JS.find("await _maybeBindFreshDefaultWorkspaceSession(prefillIntent);", prefill_pos) > prefill_pos
|
||||
zero_message_pos = BOOT_JS.find(
|
||||
"await renderSessionList();await _finalizeComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE();",
|
||||
load_pos,
|
||||
)
|
||||
assert zero_message_pos > load_pos
|
||||
|
|
@ -232,6 +232,31 @@ class TestVoiceModePreferenceGate:
|
|||
"so the button appears/disappears immediately."
|
||||
|
||||
|
||||
class TestVoiceModeRuntimePreferences:
|
||||
"""Voice mode runtime prefs must stay wired to localStorage-backed settings."""
|
||||
|
||||
def test_voice_mode_silence_pref_reads_localstorage_with_floor(self):
|
||||
"""boot.js must keep the silence timeout configurable without allowing
|
||||
tiny or invalid values to auto-send instantly."""
|
||||
src = _src("boot.js")
|
||||
assert "localStorage.getItem('hermes-voice-silence-ms')" in src, (
|
||||
"voice mode must read hermes-voice-silence-ms from localStorage "
|
||||
"so pause timing survives reloads."
|
||||
)
|
||||
assert "Math.max(200,_silenceMsRaw)" in src, (
|
||||
"voice mode must floor the configured silence timeout at 200ms "
|
||||
"instead of trusting tiny values."
|
||||
)
|
||||
|
||||
def test_voice_mode_continuous_pref_reads_localstorage(self):
|
||||
"""boot.js must preserve the continuous-recognition preference across reloads."""
|
||||
src = _src("boot.js")
|
||||
assert "localStorage.getItem('hermes-voice-continuous')==='true'" in src, (
|
||||
"voice mode must read hermes-voice-continuous from localStorage "
|
||||
"instead of hardcoding continuous recognition off."
|
||||
)
|
||||
|
||||
|
||||
class TestActiveStateTooltips:
|
||||
"""When recording / in voice mode, tooltips should flip to the
|
||||
'stop' variants so the affordance is honest."""
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ node_test = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|||
def _extract_async_function(source: str, name: str) -> str:
|
||||
marker = f"async function {name}("
|
||||
start = source.find(marker)
|
||||
if start < 0:
|
||||
marker = f"function {name}("
|
||||
start = source.find(marker)
|
||||
assert start >= 0, f"{name}() function must exist"
|
||||
brace = source.find("{", source.find(")", start))
|
||||
assert brace > start, f"{name}() function body must start"
|
||||
|
|
@ -79,6 +82,7 @@ def _run_node(script: str) -> dict:
|
|||
|
||||
|
||||
def _helper_driver(panel_mode: str, default_workspace: str | None, *, reject: bool = False) -> dict:
|
||||
helper_dep = _extract_async_function(BOOT_JS.read_text(encoding="utf-8"), "_prefillHasDraftText")
|
||||
helper = _extract_async_function(BOOT_JS.read_text(encoding="utf-8"), "_maybeBindFreshDefaultWorkspaceSession")
|
||||
default_workspace_repr = json.dumps(default_workspace)
|
||||
script = textwrap.dedent(
|
||||
|
|
@ -94,6 +98,7 @@ def _helper_driver(panel_mode: str, default_workspace: str | None, *, reject: bo
|
|||
calls.push({{arg0:a,arg1:b}});
|
||||
return shouldReject ? Promise.reject(new Error('bind-failed')) : Promise.resolve({{}});
|
||||
}}
|
||||
{helper_dep}
|
||||
{helper}
|
||||
_maybeBindFreshDefaultWorkspaceSession().then((bound)=>{{
|
||||
process.stdout.write(JSON.stringify({{bound,calls}}));
|
||||
|
|
@ -150,7 +155,7 @@ def test_no_saved_session_branch_restores_panel_pref_before_bind_attempt():
|
|||
fresh_pref = "if(_freshPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';"
|
||||
pref_idx = segment.find(fresh_pref)
|
||||
assert pref_idx >= 0, "no-saved-session path must restore panel preference"
|
||||
bind_call = "await _maybeBindFreshDefaultWorkspaceSession();"
|
||||
bind_call = "await _maybeBindFreshDefaultWorkspaceSession(prefillIntent);"
|
||||
bind_idx = segment.find(bind_call)
|
||||
assert bind_idx >= 0, "no-saved-session path must attempt to bind after pref restore"
|
||||
assert pref_idx < bind_idx, "panel preference restoration must happen before bind attempt"
|
||||
|
|
@ -167,7 +172,7 @@ def test_ephemeral_blank_session_branch_restores_panel_pref_before_bind_attempt(
|
|||
eph_pref = "if(_ephPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';"
|
||||
pref_idx = segment.find(eph_pref)
|
||||
assert pref_idx >= 0, "ephemeral blank-session path must restore panel preference"
|
||||
bind_call = "await _maybeBindFreshDefaultWorkspaceSession();"
|
||||
bind_call = "await _maybeBindFreshDefaultWorkspaceSession(prefillIntent);"
|
||||
bind_idx = segment.find(bind_call)
|
||||
assert bind_idx >= 0, "ephemeral blank-session path must attempt to bind before returning"
|
||||
assert pref_idx < bind_idx, "ephemeral panel preference restoration must happen before bind attempt"
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ def test_hard_refresh_hydrates_saved_session_model_before_revealing_model_chip()
|
|||
boot_js = Path("static/boot.js").read_text(encoding="utf-8")
|
||||
load_marker = "await loadSession(saved, {preserveActiveInput:true});"
|
||||
assert load_marker in boot_js
|
||||
saved_restore = boot_js[boot_js.index(load_marker) : boot_js.index("await checkInflightOnBoot(saved);return;", boot_js.index(load_marker))]
|
||||
restore_end = "await checkInflightOnBoot(saved);"
|
||||
saved_restore = boot_js[boot_js.index(load_marker) : boot_js.index(restore_end, boot_js.index(load_marker))]
|
||||
assert "await _startBootModelDropdown();" in saved_restore
|
||||
assert saved_restore.index("await _startBootModelDropdown();") > saved_restore.index(load_marker)
|
||||
assert saved_restore.index("await _startBootModelDropdown();") < saved_restore.index("S._bootReady=true;"), (
|
||||
|
|
|
|||
Loading…
Reference in a new issue